diff --git a/src/lib/medications/__tests__/compliance-rollups.test.ts b/src/lib/medications/__tests__/compliance-rollups.test.ts index 0bb333f45..7b6374b03 100644 --- a/src/lib/medications/__tests__/compliance-rollups.test.ts +++ b/src/lib/medications/__tests__/compliance-rollups.test.ts @@ -312,8 +312,10 @@ describe("readMedicationCompliance", () => { }); describe("hasMedicationComplianceCoverage", () => { - it("returns true when at least one row exists inside the window", async () => { - rollupFindFirst.mockResolvedValue({ day: "2026-05-17" }); + 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", @@ -324,8 +326,40 @@ describe("hasMedicationComplianceCoverage", () => { ).resolves.toBe(true); }); - it("returns false when the window is empty", async () => { - rollupFindFirst.mockResolvedValue(null); + 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); diff --git a/src/lib/medications/compliance-rollups.ts b/src/lib/medications/compliance-rollups.ts index bb72b9604..2b25c96a3 100644 --- a/src/lib/medications/compliance-rollups.ts +++ b/src/lib/medications/compliance-rollups.ts @@ -363,12 +363,18 @@ export async function readMedicationCompliance( } /** - * Returns true when the user has at least one - * `medication_compliance_rollups` row inside the trailing `days` - * window. The route uses this as the coverage probe — when the - * trailing window is empty but the live `medication_intake_events` - * table has rows, the read path falls through to the legacy live - * aggregator + fires a boot backfill in the background. + * 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, @@ -381,11 +387,37 @@ export async function hasMedicationComplianceCoverage( new Date(now.getTime() - (days - 1) * 86_400_000), safeTz, ); - const row = await prisma.medicationComplianceRollup.findFirst({ - where: { userId, day: { gte: oldestKey } }, - select: { day: true }, - }); - return row !== null; + const oldestStart = startOfDayUtcInTz(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 "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; } /**