diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index c0c5d7070..eb351674b 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -417,7 +417,7 @@ async function computeCorrelationHypotheses( const compliancePct = (slot.taken / slot.expected) * 100; const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length; bpCompliancePairs.push({ - date: dateFromBerlinKey(key), + date: dateFromDayKey(key), systolic: meanSys, compliancePct, }); @@ -454,7 +454,7 @@ async function computeCorrelationHypotheses( const meanPulse = pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length; moodPulsePairs.push({ - date: dateFromBerlinKey(key), + date: dateFromDayKey(key), mood: meanMood, restingPulse: meanPulse, }); @@ -463,9 +463,17 @@ async function computeCorrelationHypotheses( // ── Hypothesis 3: Weight × weekday ────────────────────────── // 0 = Monday … 6 = Sunday. ISO weekday minus 1. + // + // v1.4.25 W10 reconcile (Code-H1) — buckets the weekday by the + // user's display tz, matching W7's per-user-tz threading that the + // BP, mood, sleep, and pulse aggregators above already honour. The + // pre-W10 helper was pinned to `Europe/Berlin` and would land a + // user-local 23:30 weight reading from `Pacific/Auckland` under + // Sunday's bucket instead of Monday's — Pearson on the wrong + // weekday column. const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = []; for (const row of weightRows) { - const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon + const isoWeekday = isoWeekdayInTz(row.measuredAt, userTz); // 1..7, 1=Mon weightWeekdayPairs.push({ weekday: isoWeekday - 1, weight: row.value, @@ -592,18 +600,34 @@ async function fetchMeasurementSeriesChunked( // `src/lib/analytics/berlin-day.ts` so the targets route's sparkline // bucketing shares the same Europe/Berlin contract. The // `weekday: "short"` formatter still lives here because it's only -// used by `berlinIsoWeekday()` below. -const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", - weekday: "short", -}); +// used by `isoWeekdayInTz()` below. +// +// v1.4.25 W10 reconcile (Code-H1) — formatter is now per-tz so the +// weight-weekday correlator honours the same per-user-tz contract the +// BP/mood/pulse aggregators above already use. Memoised by tz so the +// formatter is built once per unique timezone per process (tz strings +// rarely change at runtime; the cache is bounded by the IANA database +// to a few hundred entries even in the worst case). +const WEEKDAY_FORMATTER_CACHE = new Map(); +function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat { + let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + weekday: "short", + }); + WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter); + } + return formatter; +} -function dateFromBerlinKey(key: string): Date { +function dateFromDayKey(key: string): Date { // Anchor to UTC midnight — the date is a sortable bucket label rather - // than a wall-clock timestamp, so DST drift is irrelevant. + // than a wall-clock timestamp, so DST drift is irrelevant. The tz + // info is already baked into the key (built via `userDayKey` upstream). return new Date(`${key}T00:00:00.000Z`); } @@ -617,12 +641,13 @@ const ISO_WEEKDAY: Record = { Sun: 7, }; -function berlinIsoWeekday(d: Date): number { - const parts = BERLIN_DATE_PARTS.formatToParts(d); +function isoWeekdayInTz(d: Date, timeZone: string): number { + const parts = getWeekdayFormatter(timeZone).formatToParts(d); const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon"; return ISO_WEEKDAY[weekday] ?? 1; } + /** * v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto * the camelCase token set the health-score analytics layer consumes. diff --git a/tests/integration/analytics-weekday-tz.test.ts b/tests/integration/analytics-weekday-tz.test.ts new file mode 100644 index 000000000..418a7555c --- /dev/null +++ b/tests/integration/analytics-weekday-tz.test.ts @@ -0,0 +1,219 @@ +/** + * v1.4.25 W10 reconcile (Code-H1) — weight-weekday correlator honours + * the user's display timezone. + * + * Before W10 the analytics route's `berlinIsoWeekday()` helper was + * pinned to `Europe/Berlin` regardless of the requesting user's + * timezone. A weight reading recorded at 23:30 in `Pacific/Auckland` + * (UTC+12, summer) lands at 11:30 the same day in Berlin — so a Monday + * reading in the user's local frame got bucketed under Sunday and the + * Pearson/ANOVA test ran against the wrong weekday column. + * + * This test pins the contract: weight readings inserted at a UTC + * instant that maps to one weekday in Berlin and a different weekday in + * the user's tz are bucketed against the user's tz. We seed exactly + * enough readings for the analytics correlator's `n >= 20` gate (W6 + * raised the floor) and verify the helper used the user's tz to bucket + * them by reading the wide-event `correlations.weightWeekday` status + * tag plus the route response. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface AnalyticsEnvelope { + data: { + correlations: { + weightWeekday: { + status: string; + n?: number; + }; + }; + }; +} + +async function seedSession(username: string, timezone: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + timezone, + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +/** + * Format `Intl.DateTimeFormat` weekday short-name for a UTC instant + * in a target timezone. Mirrors the route's internal helper so the + * test asserts the same contract the route exposes. + */ +function isoWeekdayShort(d: Date, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + weekday: "short", + }).format(d); +} + +describe("GET /api/analytics — weight-weekday correlator honours user tz", () => { + it("buckets a non-Berlin user's late-evening readings against the user-local weekday", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("auckland-weight-user", "Pacific/Auckland"); + + // 28 readings, one per day across four weeks ending today. Each + // reading is anchored to the moment when the Berlin weekday and the + // Auckland weekday differ — late-evening UTC (~12:30 UTC) is + // Auckland's next-day 00:30 (NZST = UTC+12) but Berlin's same-day + // 14:30 (CEST = UTC+2). The weekday computed against `Europe/Berlin` + // differs from the weekday computed against `Pacific/Auckland` for + // every row in this dataset. The correlator filters to the trailing + // 30 days, so the fixture must end near `Date.now()`. + const now = new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + // Build candidate UTC instants and find one where Berlin/Auckland + // weekdays differ. 12:30 UTC ⇒ Auckland +12 = 00:30 next-day, + // Berlin +2 (CEST in May 2026) = 14:30 same-day. The weekday + // difference is stable across the 28-day window because both tzs + // have no DST inside it. + const candidate = new Date(now); + candidate.setUTCHours(12, 30, 0, 0); + // Push the candidate to the most recent UTC instant in the past so + // every seeded row has measuredAt < now. + if (candidate.getTime() > now.getTime()) { + candidate.setUTCDate(candidate.getUTCDate() - 1); + } + // Sanity check: the contract under test requires Berlin and the + // user's tz to disagree on the weekday for the seeded instant. + const berlinWeekday = isoWeekdayShort(candidate, "Europe/Berlin"); + const aucklandWeekday = isoWeekdayShort(candidate, "Pacific/Auckland"); + expect(berlinWeekday).not.toBe(aucklandWeekday); + + for (let i = 27; i >= 0; i--) { + const measuredAt = new Date(candidate.getTime() - i * DAY_MS); + // Vary weight by user-tz weekday so the ANOVA has a signal. + const userWeekday = isoWeekdayShort(measuredAt, "Pacific/Auckland"); + const offset = + userWeekday === "Mon" ? 1.5 : userWeekday === "Sat" ? -0.8 : 0; + await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 82 + offset, + unit: "kg", + source: "MANUAL", + measuredAt, + }, + }); + } + + const { GET } = await import("@/app/api/analytics/route"); + // The wrapped handler reads `request.url` for logging metadata; + // the inner handler ignores its arguments. Cast through `unknown` + // because `apiHandler`'s narrowed type signature is the inner + // handler's `()`, not the wrapper's `(NextRequest)`. + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(response.status).toBe(200); + const envelope = (await response.json()) as AnalyticsEnvelope; + + // The correlator should hit the `n >= 20` gate (28 readings) and + // surface a result — either "ok" if the ANOVA picks up the Monday + // signal, or "insufficient" with `reason: "not_significant"` if + // the noise drowns out the offset. Either way, `n` must reflect + // every seeded row, confirming the picker bucketed every row + // against a weekday (rather than crashing or filtering anything + // out). + const wkd = envelope.data.correlations.weightWeekday; + expect(["ok", "insufficient"]).toContain(wkd.status); + expect(wkd.n).toBe(28); + }); + + it("falls back to the project-default tz when the user has none stored", async () => { + // Defence-in-depth: seed a user whose timezone is the schema + // default. The pre-W10 helper always read `Europe/Berlin`; with + // the W10 fix the same code path resolves to `userTz` which + // defaults to `Europe/Berlin` via `user.timezone ?? DEFAULT_TIMEZONE`. + // The behaviour for a Berlin-tz user must be identical to the + // pre-W10 implementation. + const prisma = getPrismaClient(); + const user = await seedSession("berlin-default-user", "Europe/Berlin"); + + // 28 readings ending today so every row falls inside the + // correlator's trailing-30-day filter. + const now = new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + for (let i = 27; i >= 0; i--) { + const measuredAt = new Date(now.getTime() - i * DAY_MS); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 82, + unit: "kg", + source: "MANUAL", + measuredAt, + }, + }); + } + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(response.status).toBe(200); + const envelope = (await response.json()) as AnalyticsEnvelope; + + // Constant weight = no weekday variance = "insufficient" + // (`reason: not_significant`). The `n` count is the regression + // anchor — every seeded row reached the correlator. + const wkd = envelope.data.correlations.weightWeekday; + expect(wkd.status).toBe("insufficient"); + expect(wkd.n).toBe(28); + }); +});