diff --git a/src/__tests__/dashboard-summary-read-plan-guard.test.ts b/src/__tests__/dashboard-summary-read-plan-guard.test.ts index 44c9707d8..b1b9a14e1 100644 --- a/src/__tests__/dashboard-summary-read-plan-guard.test.ts +++ b/src/__tests__/dashboard-summary-read-plan-guard.test.ts @@ -97,10 +97,9 @@ describe("dashboard summary reads — the route keeps using them", () => { expect(route).toMatch( /readSparkBuckets\(\s*prisma,\s*userId,\s*measurementTypes,/, ); - // No second copy of the rollup read can drift out of the guarded - // module. (`FROM measurements` still appears in the route's own - // streak-day aggregate, which is a bounded 365-row read and not part - // of this finding.) + expect(route).toMatch(/readActivityDayKeys\(\s*prisma,\s*userId,/); + // No second copy of a read can drift out of the guarded module. expect(route.includes("FROM measurement_rollups")).toBe(false); + expect(route.includes("FROM measurements")).toBe(false); }); }); diff --git a/src/__tests__/naive-timestamp-zone-shift-guard.test.ts b/src/__tests__/naive-timestamp-zone-shift-guard.test.ts new file mode 100644 index 000000000..d0e55846c --- /dev/null +++ b/src/__tests__/naive-timestamp-zone-shift-guard.test.ts @@ -0,0 +1,67 @@ +/** + * Structural guard: a naive UTC timestamp is read as UTC before it is + * shifted into a person's zone. + * + * `measured_at`, `scheduled_for`, `taken_at` and the other `*_at` instants + * are `timestamp` columns holding UTC wall-clock time. On that type, + * `col AT TIME ZONE tz` means "this is local time in tz" and returns the + * matching instant, so the value moves by the offset in the wrong direction: + * in Berlin a 23:30 UTC reading lands on the day before instead of the day + * after. The right form is `(col AT TIME ZONE 'UTC') AT TIME ZONE tz`. The + * dashboard streak and the medication-compliance backfill both keyed days + * with the single form, while their comments called it identical to + * `userDayKey`. + * + * The matcher flags a quoted `*_at` or `scheduled_for` column shifted + * straight into a bound zone (`${...}` or `$N`). A `timestamptz` column + * (`bucket_start`) is out of its reach by name. Comments are stripped first; + * the planted cases prove it fires. It is a tripwire, not a proof. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +const SINGLE_SHIFT = + /"(?:[a-z_]+_at|scheduled_for)"\s*\)?\s*AT\s+TIME\s+ZONE\s+(?:\$\{|\$\d)/i; + +function stripComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, "") + .replace(/^\s*--.*$/gm, ""); +} + +describe("naive timestamp zone shifts", () => { + it("the matcher fires on the single shift and spares the double one", () => { + expect( + SINGLE_SHIFT.test(`to_char(m."measured_at" AT TIME ZONE \${tz}, 'D')`), + ).toBe(true); + expect(SINGLE_SHIFT.test(`("scheduled_for" AT TIME ZONE $3)`)).toBe(true); + expect( + SINGLE_SHIFT.test( + `("scheduled_for" AT TIME ZONE 'UTC') AT TIME ZONE \${tz}`, + ), + ).toBe(false); + expect(SINGLE_SHIFT.test(`r."bucket_start" AT TIME ZONE \${tz}`)).toBe( + false, + ); + }); + + it("no production read shifts a naive column once", () => { + const offenders = walkSourceFiles(SRC, { floor: 3000 }) + .filter( + (file) => + !file.startsWith("generated/") && + !file.includes("__tests__/") && + !/\.test\.tsx?$/.test(file), + ) + .filter((file) => + SINGLE_SHIFT.test(stripComments(readFileSync(join(SRC, file), "utf8"))), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 55a3d77c3..5fe712a16 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -78,6 +78,7 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { computeStreak } from "@/lib/dashboard/streak"; import { + readActivityDayKeys, readLatestEver, readSparkBuckets, } from "@/lib/dashboard/summary-reads"; @@ -329,15 +330,6 @@ function trendOf(values: number[]): MetricCard["trend"] { return delta > 0 ? "up" : "down"; } -/** v1.4.38 W-F — distinct activity day-keys from the streak window. - * The route only needs YYYY-MM-DD strings, so the aggregate runs - * `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns - * the day-keys directly. At most 365 rows; vs. the legacy `findMany` - * which could return tens of thousands. */ -interface ActivityDayRow { - day_key: string; -} - export const GET = apiHandler(async () => { // v1.37.0 — MANAGE-level read: computed over the whole record, with no // provider anywhere on the path. @@ -532,27 +524,8 @@ async function buildDashboardSummary( select: { takenAt: true, scheduledFor: true }, }), ), - // v1.4.38 W-F — replaces the legacy 365-day `measurement.findMany` - // that pulled every raw row in the window only to collapse them to - // day-keys in JS. The SQL `date_trunc` returns at most 365 rows - // even on accounts with millions of step samples. The `userTz` - // conversion is folded server-side so the day boundary matches - // `userDayKey`'s output (it formats the timestamp in the user's - // tz before slicing YYYY-MM-DD). - time( - "streakDays", - () => - prisma.$queryRaw` - SELECT DISTINCT - to_char( - (m."measured_at" AT TIME ZONE ${userTz}), - 'YYYY-MM-DD' - ) AS day_key - FROM measurements m - WHERE m."user_id" = ${userId} - AND m."measured_at" >= ${streakWindowStart} - AND m."deleted_at" IS NULL - `, + time("streakDays", () => + readActivityDayKeys(prisma, userId, userTz, streakWindowStart, now), ), // v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total // tile. `SLEEP_DURATION` is stored one row per STAGE per night @@ -635,13 +608,9 @@ async function buildDashboardSummary( } // v1.4.38 W-F — activity-day set assembled from the bounded streak - // queries. The measurement side already arrives as YYYY-MM-DD keys - // from the SQL `date_trunc + AT TIME ZONE` aggregate, byte-identical - // to what `userDayKey(measuredAt, userTz)` would produce in JS. - const activityDays = new Set(); - for (const row of measurementStreakDays) { - if (row.day_key) activityDays.add(row.day_key); - } + // queries. The measurement side arrives as local YYYY-MM-DD keys, the + // same keys `userDayKey(measuredAt, userTz)` produces for the intakes. + const activityDays = new Set(measurementStreakDays); for (const e of streakActivity) { activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz)); } diff --git a/src/lib/dashboard/summary-reads.ts b/src/lib/dashboard/summary-reads.ts index a5206307e..2ca4df790 100644 --- a/src/lib/dashboard/summary-reads.ts +++ b/src/lib/dashboard/summary-reads.ts @@ -165,3 +165,46 @@ export function readSparkBuckets( ORDER BY type, bucket_start ASC `; } + +/** + * The local days in `[since, now]` that hold at least one live reading, as + * `YYYY-MM-DD` keys in `timeZone`: the measurement half of the streak. + * + * One existence probe per local day against `(user_id, measured_at)`, each + * stopping at its first row, instead of a `DISTINCT` over a year of readings + * (tens of thousands of rows on an account with a watch). The DAY rollups + * cannot answer this: their buckets are UTC days, and the streak counts the + * person's own days. + * + * `measured_at` is a naive UTC timestamp, so a local day's bounds are its + * local midnights read in `timeZone` and taken back to UTC. The read this + * replaced shifted the column with a single `AT TIME ZONE`, which treats the + * UTC value as local time and moves it the wrong way: in Berlin a reading at + * 23:30 UTC landed on the day before instead of the day after. + */ +export async function readActivityDayKeys( + db: PrismaClient, + userId: string, + timeZone: string, + since: Date, + now: Date, +): Promise { + const rows = await db.$queryRaw>` + SELECT to_char(d, 'YYYY-MM-DD') AS day_key + FROM generate_series( + (${since}::timestamptz AT TIME ZONE ${timeZone})::date, + (${now}::timestamptz AT TIME ZONE ${timeZone})::date, + INTERVAL '1 day' + ) AS d + WHERE EXISTS ( + SELECT 1 + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."measured_at" >= (d::timestamp AT TIME ZONE ${timeZone}) AT TIME ZONE 'UTC' + AND m."measured_at" < ((d + INTERVAL '1 day')::timestamp AT TIME ZONE ${timeZone}) AT TIME ZONE 'UTC' + ) + ORDER BY d + `; + return rows.map((row) => row.day_key); +} diff --git a/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts b/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts index 848cce605..13c7d970a 100644 --- a/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts +++ b/src/lib/rollups/__tests__/medication-compliance-rollups.test.ts @@ -50,8 +50,6 @@ import { dayKeyForScheduledFor, recomputeMedicationComplianceForDay, recomputeMedicationComplianceForEvent, - readMedicationCompliance, - hasMedicationComplianceCoverage, recomputeUserMedicationCompliance, enqueueBootTimeMedicationComplianceBackfill, enqueueUserMedicationComplianceBackfill, @@ -317,107 +315,6 @@ describe("recomputeMedicationComplianceForEvent", () => { }); }); -describe("readMedicationCompliance", () => { - it("returns a trailing-window zero-filled bucket array", async () => { - rollupFindMany.mockResolvedValue([ - { day: "2026-05-18", scheduled: 2, taken: 1, skipped: 1 }, - ]); - - const now = new Date("2026-05-18T12:00:00.000Z"); - const buckets = await readMedicationCompliance( - "user-1", - 3, - "Europe/Berlin", - now, - ); - - expect(buckets).toHaveLength(3); - expect(buckets[buckets.length - 1]).toEqual({ - date: "2026-05-18", - scheduled: 2, - taken: 1, - }); - expect(buckets[0].scheduled).toBe(0); - expect(buckets[0].taken).toBe(0); - expect(buckets.map((b) => b.date)).toEqual([ - "2026-05-16", - "2026-05-17", - "2026-05-18", - ]); - }); - - it("folds per-medication rows into one per-day total", async () => { - rollupFindMany.mockResolvedValue([ - { day: "2026-05-18", scheduled: 1, taken: 1, skipped: 0 }, - { day: "2026-05-18", scheduled: 2, taken: 0, skipped: 1 }, - ]); - const now = new Date("2026-05-18T12:00:00.000Z"); - const buckets = await readMedicationCompliance( - "user-1", - 1, - "Europe/Berlin", - now, - ); - expect(buckets[0].scheduled).toBe(3); - expect(buckets[0].taken).toBe(1); - }); -}); - -describe("hasMedicationComplianceCoverage", () => { - it("returns true when rolled-day count meets the event-day count in window", async () => { - queryRaw.mockResolvedValue([ - { rolled_days: BigInt(7), event_days: BigInt(7) }, - ]); - await expect( - hasMedicationComplianceCoverage( - "user-1", - 7, - "Europe/Berlin", - new Date("2026-05-18T12:00:00.000Z"), - ), - ).resolves.toBe(true); - }); - - it("returns true when the user has zero events in window (trivially covered)", async () => { - // Zero events → the read path returns a trailing-window zero-fill - // from an empty rollup table; covered semantically. - queryRaw.mockResolvedValue([ - { rolled_days: BigInt(0), event_days: BigInt(0) }, - ]); - await expect( - hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"), - ).resolves.toBe(true); - }); - - it("returns false on partial coverage (boot backfill mid-fold)", async () => { - // QA F-H-01 (v1.4.39): the legacy "any row exists" probe would - // flip true here and serve zero-filled buckets for days N..days-1 - // that the backfill hasn't reached yet. With the tightened probe - // partial coverage forces the route into the legacy aggregator - // until the fold completes. - queryRaw.mockResolvedValue([ - { rolled_days: BigInt(2), event_days: BigInt(7) }, - ]); - await expect( - hasMedicationComplianceCoverage( - "user-1", - 7, - "Europe/Berlin", - new Date("2026-05-18T12:00:00.000Z"), - ), - ).resolves.toBe(false); - }); - - it("returns false on zero rollups when events exist (legacy account cold start)", async () => { - queryRaw.mockResolvedValue([ - { rolled_days: BigInt(0), event_days: BigInt(7) }, - ]); - await expect( - hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"), - ).resolves.toBe(false); - }); -}); - describe("recomputeUserMedicationCompliance", () => { it("upserts one rollup per (medication, day) pair returned by discovery", async () => { queryRaw.mockResolvedValue([ diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 46f9e2e29..8e6163ac3 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -9,10 +9,11 @@ * Write hooks fire from every `MedicationIntakeEvent` mutation site * (create / update / delete / bulk import) plus the reminder-worker's * mint path that creates `takenAt:null` rows on RED-phase reminders. - * Reads collapse the trailing N-day window into a single bounded - * `findMany` against `medication_compliance_rollups`; the route-level - * coverage probe falls back to live + fires a boot backfill when a - * user has intake events but zero rollup rows. + * The dashboard tile it was built for reads the schedule-anchored + * compliance (`src/lib/analytics/schedule-anchored-compliance.ts`) since + * v1.26, and the per-day recompute below is now what keeps a day's + * supplement nutrients in step with its doses; the table itself has no + * reader left. * * v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count, * NOT the band-attributed compliance %. It aggregates, across ALL of a @@ -77,14 +78,6 @@ export interface MedicationComplianceBackfillPayload { enqueuedAt: string; } -/** Reader-result row shape — matches `buildComplianceBuckets` output. */ -export interface ComplianceBucket { - /** YYYY-MM-DD in the user's tz. */ - date: string; - scheduled: number; - taken: number; -} - /** * Resolve a safe tz string — fall back to the server default when the * supplied value is missing or not an IANA zone the host recognises. @@ -254,138 +247,6 @@ export async function recomputeMedicationComplianceForEvent(input: { } } -/** - * Read trailing `days` of compliance buckets for the user, oldest day - * first. Returns the same `{date, scheduled, taken}` shape the legacy - * `buildComplianceBuckets` helper produced so the route swap is - * byte-identical. - * - * Days without a rollup row land as zero-filled buckets. The caller's - * cache wrapper carries the user-tz in its key so a tz change forces - * a fresh read. - */ -export async function readMedicationCompliance( - userId: string, - days: number, - tz: string | null | undefined, - now: Date = new Date(), -): Promise { - const safeTz = safeTimezone(tz); - - // Build the expected day-key window oldest → newest so the response - // is sorted and zero-filled regardless of which days hold rollups. - const todayKey = userDayKey(now, safeTz); - const expectedKeys: string[] = []; - for (let i = days - 1; i >= 0; i--) { - const probe = new Date(now.getTime() - i * 86_400_000); - expectedKeys.push(userDayKey(probe, safeTz)); - } - // Guard against a DST day-of-the-year landing the loop on the same - // dayKey twice — dedup by Map insertion order. - const dedup = new Set(); - const orderedKeys: string[] = []; - for (const key of expectedKeys) { - if (!dedup.has(key)) { - dedup.add(key); - orderedKeys.push(key); - } - } - // Always include today even if the trailing window dropped it (e.g. - // days===1 on a DST boundary). Cheap defensive insert. - if (!dedup.has(todayKey)) { - orderedKeys.push(todayKey); - } - - const oldestKey = orderedKeys[0]; - const newestKey = orderedKeys[orderedKeys.length - 1]; - - const rows = await prisma.medicationComplianceRollup.findMany({ - where: { - userId, - day: { gte: oldestKey, lte: newestKey }, - }, - select: { day: true, scheduled: true, taken: true, skipped: true }, - }); - - // Fold per-medication rows into one per-day total. The legacy route - // shape was aggregated across all the user's medications; preserving - // that surface keeps the front-end byte-stable. - const totals = new Map(); - for (const key of orderedKeys) { - totals.set(key, { scheduled: 0, taken: 0 }); - } - for (const row of rows) { - const bucket = totals.get(row.day); - if (!bucket) continue; - bucket.scheduled += row.scheduled; - bucket.taken += row.taken; - } - - return orderedKeys.map((date) => { - const bucket = totals.get(date) ?? { scheduled: 0, taken: 0 }; - return { date, scheduled: bucket.scheduled, taken: bucket.taken }; - }); -} - -/** - * Returns true when the rollup tier covers every day inside the - * trailing `days` window that has at least one intake event. Coverage - * is full when the count of DISTINCT rolled days equals the count of - * DISTINCT days that hold a `medication_intake_events` row in window — - * partial coverage (boot backfill mid-fold on a multi-medication - * account) returns false so the route falls through to the legacy - * aggregator instead of serving zero-filled tiles for un-rolled days. - * - * v1.4.39 hotfix (QA F-H-01): the previous "any row exists" probe - * could short-circuit to the rollup path while only the first few days - * had been backfilled, exposing zero-filled tiles for the un-rolled - * days until the boot fold completed. - */ -export async function hasMedicationComplianceCoverage( - userId: string, - days: number, - tz: string | null | undefined, - now: Date = new Date(), -): Promise { - const safeTz = safeTimezone(tz); - const oldestKey = userDayKey( - new Date(now.getTime() - (days - 1) * 86_400_000), - safeTz, - ); - const oldestStart = startOfLocalDayKey(oldestKey, safeTz); - - // Single SQL aggregate: count DISTINCT rolled days vs DISTINCT - // event-days in window. The event side is anchored on the same - // user-tz day-key (`to_char(... AT TIME ZONE $tz, 'YYYY-MM-DD')`) so - // the comparison stays apples-to-apples even on DST boundaries. - const result = await prisma.$queryRaw< - Array<{ rolled_days: bigint; event_days: bigint }> - >` - SELECT - ( - SELECT COUNT(DISTINCT "day")::bigint - FROM "medication_compliance_rollups" - WHERE "user_id" = ${userId} - AND "day" >= ${oldestKey} - ) AS rolled_days, - ( - SELECT COUNT(DISTINCT to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD'))::bigint - FROM "medication_intake_events" - WHERE "user_id" = ${userId} - AND "deleted_at" IS NULL - AND "scheduled_for" >= ${oldestStart} - ) AS event_days - `; - if (result.length === 0) return true; - const { rolled_days, event_days } = result[0]; - const rolled = Number(rolled_days); - const events = Number(event_days); - // Zero events in window → trivially covered (the read path produces - // a zero-filled trailing window from the empty rollup table). - if (events === 0) return true; - return rolled >= events; -} - /** * Full-fold helper. Walks every `(medicationId, day)` pair the user * has logged across the trailing `days` window and upserts each @@ -395,7 +256,7 @@ export async function hasMedicationComplianceCoverage( * * Bounded SQL: one `DISTINCT (medicationId, dayKey)` $queryRaw scan * over the trailing window, then one upsert per row. The dayKey is - * computed in Postgres via `to_char(... AT TIME ZONE $tz)` so we + * computed in Postgres in the user's zone so we * don't have to ship millions of rows back to Node. */ export async function recomputeUserMedicationCompliance( @@ -414,14 +275,20 @@ export async function recomputeUserMedicationCompliance( const oldestStart = startOfLocalDayKey(oldestKey, safeTz); // Distinct (medication_id, dayKey) pairs the user has any intake - // event for inside the trailing window. `to_char(... AT TIME ZONE - // $tz)` keeps the day-bucket on the user's wall-clock. + // event for inside the trailing window. `scheduled_for` is a naive UTC + // timestamp: it is read as UTC first and only then shifted into the + // user's zone. A single `AT TIME ZONE $tz` reads it as local time and + // shifts it the wrong way, which keyed every slot within the zone's + // offset of midnight onto the neighbouring day. const pairs = await prisma.$queryRaw< Array<{ medication_id: string; day: string }> >` SELECT DISTINCT "medication_id", - to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD') AS "day" + to_char( + ("scheduled_for" AT TIME ZONE 'UTC') AT TIME ZONE ${safeTz}, + 'YYYY-MM-DD' + ) AS "day" FROM "medication_intake_events" WHERE "user_id" = ${userId} AND "deleted_at" IS NULL diff --git a/tests/integration/local-day-keys.test.ts b/tests/integration/local-day-keys.test.ts new file mode 100644 index 000000000..04b891ea6 --- /dev/null +++ b/tests/integration/local-day-keys.test.ts @@ -0,0 +1,124 @@ +/** + * Local day keys from naive UTC timestamps, against a real Postgres. + * + * `measured_at` and `scheduled_for` hold UTC wall-clock time in `timestamp` + * columns. The dashboard streak and the medication-compliance backfill keyed + * them with a single `AT TIME ZONE tz`, which reads the value as local time + * and shifts it the wrong way. Both now agree with `userDayKey`: a reading at + * 23:30 UTC is the next day in Berlin and still the same day in Los Angeles, + * a reading at 03:00 UTC is the previous day in Los Angeles. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readActivityDayKeys } from "@/lib/dashboard/summary-reads"; +import { recomputeUserMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups"; +import { userDayKey } from "@/lib/tz/format"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const USER = "local-day-keys"; +const now = new Date(); +/** UTC midnight ten days back. */ +const D = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - 10, +); +const LATE = new Date(D + 23.5 * 3_600_000); // 23:30 UTC on D +const EARLY = new Date(D + 3 * 3_600_000); // 03:00 UTC on D +const key = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +beforeEach(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + email: `${USER}@example.test`, + timezone: "Europe/Berlin", + }, + }); +}); + +describe("readActivityDayKeys", () => { + beforeEach(async () => { + await prisma.measurement.createMany({ + data: [ + { at: LATE, deleted: false }, + { at: EARLY, deleted: false }, + { at: new Date(D - 3 * 86_400_000), deleted: true }, + ].map(({ at, deleted }, i) => ({ + id: `${USER}-m${i}`, + userId: USER, + type: "WEIGHT" as const, + source: "MANUAL" as const, + value: 80, + unit: "kg", + measuredAt: at, + deletedAt: deleted ? new Date() : null, + })), + }); + }); + + const since = new Date(D - 5 * 86_400_000); + + it("keys a late reading onto the next day east of UTC", async () => { + const keys = await readActivityDayKeys( + prisma, + USER, + "Europe/Berlin", + since, + now, + ); + expect(keys).toEqual([key(D), key(D + 86_400_000)]); + expect(keys).toEqual( + [EARLY, LATE].map((at) => userDayKey(at, "Europe/Berlin")), + ); + }); + + it("keys an early reading onto the previous day west of UTC", async () => { + const keys = await readActivityDayKeys( + prisma, + USER, + "America/Los_Angeles", + since, + now, + ); + expect(keys).toEqual([key(D - 86_400_000), key(D)]); + expect(keys).toEqual( + [EARLY, LATE].map((at) => userDayKey(at, "America/Los_Angeles")), + ); + }); +}); + +describe("recomputeUserMedicationCompliance", () => { + it("keys a late dose onto the person's own day", async () => { + const med = await prisma.medication.create({ + data: { userId: USER, name: "Ramipril", dose: "5mg", active: true }, + }); + await prisma.medicationIntakeEvent.create({ + data: { + userId: USER, + medicationId: med.id, + scheduledFor: LATE, + takenAt: LATE, + skipped: false, + source: "API", + }, + }); + + await recomputeUserMedicationCompliance(USER, 30, "Europe/Berlin"); + + const rows = await prisma.medicationComplianceRollup.findMany({ + where: { userId: USER }, + select: { day: true, scheduled: true, taken: true }, + }); + expect(rows).toEqual([ + { day: key(D + 86_400_000), scheduled: 1, taken: 1 }, + ]); + }); +});