diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bae350852..b781a8621 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2118,7 +2118,7 @@ paths: summary: List medications for the calling user description: Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the - count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per + count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache. responses: "200": @@ -4890,6 +4890,7 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: description: When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. + Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ @@ -8362,7 +8363,9 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - description: Number of intake events scheduled for today (user-local day window). + description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an + explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this + against the passed-dose count, and a pending mint must not read as covered." required: - id - name diff --git a/messages/de.json b/messages/de.json index 5d5fab0d6..bea30b511 100644 --- a/messages/de.json +++ b/messages/de.json @@ -760,6 +760,7 @@ "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", + "takenEarly": "Bereits vorzeitig eingenommen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", "compliance7d": "Therapietreue (7 Tage)", diff --git a/messages/en.json b/messages/en.json index 8f1d22f32..2a398b384 100644 --- a/messages/en.json +++ b/messages/en.json @@ -760,6 +760,7 @@ "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", + "takenEarly": "Already taken early", "overdue": "Overdue", "veryOverdue": "Very overdue", "compliance7d": "Adherence (7 days)", diff --git a/messages/es.json b/messages/es.json index 35af7d077..3d0217e3a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -760,6 +760,7 @@ "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", + "takenEarly": "Ya tomado con antelación", "overdue": "Vencida", "veryOverdue": "Muy vencida", "compliance7d": "Cumplimiento 7 días", diff --git a/messages/fr.json b/messages/fr.json index 98c1c3f2b..a4bdb7f90 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -760,6 +760,7 @@ "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", + "takenEarly": "Déjà pris en avance", "overdue": "En retard", "veryOverdue": "Très en retard", "compliance7d": "Observance 7 jours", diff --git a/messages/it.json b/messages/it.json index 2a2466464..5358627c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -760,6 +760,7 @@ "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", + "takenEarly": "Già assunto in anticipo", "overdue": "In ritardo", "veryOverdue": "Molto in ritardo", "compliance7d": "Aderenza 7 giorni", diff --git a/messages/pl.json b/messages/pl.json index 2c8b818c3..72127650e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -760,6 +760,7 @@ "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", + "takenEarly": "Już przyjęto wcześniej", "overdue": "Po terminie", "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index e8171168f..5299cc133 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; +import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -536,6 +537,10 @@ const handler = apiHandler( }, }); + // v1.16.9 — the restore replaced every row the owner's cached + // payloads were built on; hard-evict all of that user's buckets. + invalidateUserData(ownerId); + const response: RestoreResponse = { restored: true, summary, diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index 444965d80..06e90123d 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; +import { invalidateAllCaches } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { details: result, }); + // v1.16.9 — the wipe touched every user's rows; clear every cache + // bucket so no per-user payload survives the reset. + invalidateAllCaches(); + return apiSuccess({ cleared: true, ...result }); }); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index cc2deb55d..629b8c247 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -12,7 +12,9 @@ vi.mock("@/lib/db", () => ({ }, medicationIntakeEvent: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), + update: vi.fn(), }, medication: { findFirst: vi.fn(), @@ -38,6 +40,9 @@ vi.mock("@/lib/app-settings", () => ({ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -55,6 +60,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; function postReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/ingest/medication", { @@ -145,3 +151,156 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => { expect(res.status).toBe(422); }); }); + +describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => { + // The maintainer's live bug: a daily 09:00 med, the worker pre-minted the + // pending REMINDER row at 09:00 Berlin, the user ingested the take at + // 08:42 Berlin via the external API. The bare create anchored a SECOND + // row at 08:42 and left the 09:00 pending row open — reminder kept + // firing, ledger showed ad-hoc + later missed, today feed said "due". + const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST + const TAKEN_0842_BERLIN = new Date("2026-06-10T06:42:00.000Z"); // 08:42 CEST + + const scheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + + const pendingReminderRow = { + id: "evt-pending-0900", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT_0900_BERLIN, + source: "REMINDER", + createdAt: new Date("2026-06-10T05:00:00.000Z"), + }; + + function wireHappyPath() { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + lastUsedAt: null, + } as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + // First findFirst (by name) returns the bare medication; the slot + // resolver's findFirst (selects schedules) returns the projection. + vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { + where: Record; + }) => { + if (args.where && "name" in args.where) { + return Promise.resolve({ id: "med-1", name: "Metformin" }); + } + return Promise.resolve({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [scheduleRow], + scheduleRevisions: [], + }); + }) as never); + // Idempotency probe → no replay. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); + // The slot's live rows: the worker-minted pending REMINDER row. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingReminderRow, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: { + where: { id: string }; + data: Record; + }) => + Promise.resolve({ + ...pendingReminderRow, + ...args.data, + syncVersion: 2, + })) as never); + } + + it("converges an 08:42 take onto the pending 09:00 REMINDER row (no second row)", async () => { + wireHappyPath(); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-1", + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { id: string } }; + // The pending REMINDER row was updated in place — never a bare create. + expect(body.data.id).toBe("evt-pending-0900"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date; skipped: boolean; autoMissed?: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt?.getTime()).toBe(TAKEN_0842_BERLIN.getTime()); + expect(update.data.skipped).toBe(false); + // A recorded take clears a prior cron auto-miss. + expect(update.data.autoMissed).toBe(false); + }); + + it("hard-evicts the user's medication caches after the write", async () => { + wireHappyPath(); + await POST( + postReq({ + medicationName: "Metformin", + takenAt: TAKEN_0842_BERLIN.toISOString(), + idempotencyKey: "ha-ingest-2", + }), + ); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("keeps the standalone ad-hoc contract for an off-window take", async () => { + wireHappyPath(); + // 14:30 Berlin — outside the 09:00 band and its late tail. + const offWindow = new Date("2026-06-10T12:30:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "evt-adhoc", + scheduledFor: offWindow, + takenAt: offWindow, + skipped: false, + source: "API", + } as never); + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: offWindow.toISOString(), + idempotencyKey: "ha-ingest-3", + }), + ); + expect(res.status).toBe(201); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + const create = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0] as { data: { scheduledFor: Date; takenAt: Date } }; + // Ad-hoc rows anchor on the intake instant (`scheduledFor = takenAt`). + expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime()); + expect(create.data.takenAt.getTime()).toBe(offWindow.getTime()); + }); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index fe75ffda1..dfeafa5f4 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -15,6 +15,12 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { externalIntakeSchema } from "@/lib/validations/medication"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { NextRequest, NextResponse } from "next/server"; /** @@ -159,17 +165,64 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API endpoint for this medication is disabled", 403); } - const event = await prisma.medicationIntakeEvent.create({ - data: { + // The external ingest path runs without a User record in scope; + // resolve the timezone once — band attribution and the compliance + // recompute below both anchor on the user's local day. + const ingestUser = await prisma.user.findUnique({ + where: { id: apiToken.userId }, + select: { timezone: true }, + }); + const userTz = ingestUser?.timezone || DEFAULT_TIMEZONE; + + const effectiveTakenAt = takenAt ?? new Date(); + + // v1.16.9 — attribute the take to its scheduled slot by window-band + // membership, the SAME engine the per-medication intake route and the + // read ledger consume. The bare create this replaces anchored every + // ingest at `scheduledFor = takenAt`, leaving the slot's pending + // REMINDER row open: the reminder kept firing, the ledger showed an + // ad-hoc take PLUS a missed slot, and the today feed still said "due". + const attribution = await resolveSlotForWriteByBand({ + userId: apiToken.userId, + medicationId: medication.id, + userTz, + takenAt: effectiveTakenAt, + }); + + let event; + if (attribution.slotInstant) { + // Scheduled dose — converge onto the one canonical slot row (the + // pending REMINDER row when one exists) through the shared upsert: + // H1 deterministic selection, C2 no-downgrade, P2002-safe create. + const applied = await applyCanonicalSlotWrite({ + client: prisma, userId: apiToken.userId, medicationId: medication.id, - scheduledFor: takenAt ?? new Date(), - takenAt: takenAt ?? new Date(), + canonicalSlot: attribution.slotInstant, + takenAt: effectiveTakenAt, skipped: false, - source: "API", + isExplicitTaken: true, + isExplicitSkip: false, idempotencyKey, - }, - }); + createSource: "API", + attributionSource: "AUTO", + }); + event = applied.row; + } else { + // Ad-hoc / PRN / off-window — standalone row under the documented + // contract (`scheduledFor = takenAt`). + event = await prisma.medicationIntakeEvent.create({ + data: { + userId: apiToken.userId, + medicationId: medication.id, + scheduledFor: effectiveTakenAt, + takenAt: effectiveTakenAt, + skipped: false, + source: "API", + idempotencyKey, + }, + }); + } await auditLog("medication.ingest.external", { userId: apiToken.userId, @@ -181,15 +234,21 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - annotate({ meta: { medication_id: medication.id, event_id: event.id } }); + annotate({ + meta: { + medication_id: medication.id, + event_id: event.id, + slot_resolved: attribution.slotInstant !== null, + }, + }); + + // v1.16.9 — the ingest is an interactive dose record; the next read + // (today feed, card pill, compliance heatmap) must reflect it rather + // than wait out the TTL. + invalidateUserMedications(apiToken.userId, { evict: true }); // v1.4.39 W-MED — refresh the compliance rollup for the ingested - // event's user-day. The external ingest path runs without a User - // record in scope; resolve the tz once for the recompute. - const ingestUser = await prisma.user.findUnique({ - where: { id: apiToken.userId }, - select: { timezone: true }, - }); + // event's user-day. await recomputeMedicationComplianceForEvent({ userId: apiToken.userId, medicationId: medication.id, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 47b93df37..997db221a 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -264,6 +264,11 @@ export const PUT = apiHandler( idempotencyKey: null, createSource: "WEB", attributionSource, + // v1.16.9 — a slot move is a re-binding, not a new dose: the + // original row's recorded injection site and dose override ride + // along onto the converged row instead of being tombstoned away. + injectionSite: event.injectionSite, + doseTaken: event.doseTaken, }); updated = applied.row; } diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index faa5beb26..0fe4beafb 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -179,14 +179,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // Skips carry no binding decision (undefined → column untouched/default). let attributionSource: "AUTO" | "USER_PIN" | undefined; if (skipped) { - canonicalSlot = await resolveSlotInstantForWrite({ - userId: user.id, - medicationId: id, - userTz: user.timezone, - incoming: incomingScheduledFor, - instantIsExplicit: scheduledFor !== undefined, - isTakenWrite: false, - }); + if (scheduledFor === undefined) { + // v1.16.9 — a slot-less deliberate skip resolves by band membership + // on the skip moment, like a take does. The defaulted-now snap only + // reached the tight reminder-grace window, so a skip tapped past it + // landed as an orphan ad-hoc row while the slot's pending REMINDER + // row stayed open to auto-miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; + } else { + canonicalSlot = await resolveSlotInstantForWrite({ + userId: user.id, + medicationId: id, + userTz: user.timezone, + incoming: incomingScheduledFor, + instantIsExplicit: true, + isTakenWrite: false, + }); + } } else if (forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 919c01e26..b7c729399 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -25,6 +25,7 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, @@ -137,7 +138,9 @@ export const GET = apiHandler( { autoMissed: true }, ], }, - select: { scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor + // === takenAt`) cannot ±6h-resolve a different slot. + select: { scheduledFor: true, takenAt: true }, }); // v1.16.4 — current-era floor: the open-overdue search mints from // the LIVE schedule rows, so it must not reach past the newest @@ -161,7 +164,7 @@ export const GET = apiHandler( now, userTz: user.timezone || "Europe/Berlin", lastIntakeAt: lastIntake?.takenAt ?? null, - resolvedSlots: resolvedRows.map((r) => r.scheduledFor), + resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, }); diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index 10ead9ff6..bc9011a47 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -39,6 +39,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; @@ -249,6 +250,10 @@ export const PATCH = apiHandler( // The corrected era re-segments history; refresh the pre-aggregated // compliance rollups asynchronously (best-effort). + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ @@ -321,6 +326,10 @@ export const DELETE = apiHandler( // History re-segments without the era; refresh the pre-aggregated // compliance rollups asynchronously (best-effort). + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ deleted: true }); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 9eda687b5..c78500192 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -40,6 +40,7 @@ import { type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string }> }; @@ -280,6 +281,10 @@ export const POST = apiHandler( // The new era re-segments history the compliance rollups already // pre-aggregated; refresh them asynchronously. Best-effort like // every other rollup write-hook. + // v1.16.9 — an era write re-segments the bands every cached payload + // (list next-due, compliance cells, dashboard tally) was built on; + // hard-evict so the next read reflects the new history immediately. + invalidateUserMedications(user.id, { evict: true }); await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess( diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 69c198e46..c00b3f3b0 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), }, + medicationIntakeEvent: { + groupBy: vi.fn().mockResolvedValue([]), + findMany: vi.fn().mockResolvedValue([]), + }, + medicationScheduleRevision: { + groupBy: vi.fn().mockResolvedValue([]), + }, medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]), }, @@ -59,9 +66,11 @@ vi.mock("next/headers", () => ({ })), })); -import { POST } from "../route"; +import { GET, POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -353,3 +362,52 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => { expect(res.status).toBe(422); }); }); + +describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => { + it("filters the today-count groupBy to taken-or-skipped rows", async () => { + // The dashboard projector mints a pending row per slot of the day, so + // an unfiltered count covered every passed dose after any dashboard + // visit and the cards' overdue pill went dark. The count read must + // exclude pending rows. + // (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so + // re-wire the pass-through + empty result sets here.) + vi.mocked(cachedSwr).mockImplementation((async ( + _c: unknown, + _k: string, + f: () => Promise, + ) => f()) as never); + vi.mocked(getUserTodayBounds).mockReturnValue({ + start: new Date("2026-06-10T00:00:00Z"), + end: new Date("2026-06-10T23:59:59Z"), + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + // The handler body takes no parameters; `apiHandler` still receives + // the request through the wrapper's rest args. + const res = await ( + GET as unknown as (req: NextRequest) => Promise + )(new NextRequest("http://localhost/api/medications")); + expect(res.status).toBe(200); + + const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock + .calls; + const countCall = groupByCalls.find( + (c) => (c[0] as { _count?: unknown })._count !== undefined, + ); + expect(countCall).toBeDefined(); + const where = ( + countCall![0] as { + where: { OR?: unknown }; + } + ).where; + expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 8bc18e95b..5a1440f62 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -59,6 +59,7 @@ import { } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { + boundedTakenAtSchema, injectionSiteEnum, type InjectionSiteValue, } from "@/lib/validations/medication"; @@ -74,10 +75,9 @@ const bulkEntrySchema = z.object({ .datetime({ offset: true }) .transform((s) => new Date(s)) .optional(), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.16.9 — shared plausibility bounds (no future beyond clock skew, + // 5-year floor), matching the single-intake create + edit paths. + takenAt: boundedTakenAtSchema.optional(), skipped: z.boolean().optional().default(false), idempotencyKey: z.string().min(1).max(128).optional(), // v1.8.5 — optional per-entry injection site. Validated against the @@ -313,6 +313,21 @@ async function postBulk(request: NextRequest): Promise { }); canonicalSlot = attribution.slotInstant; attributionSource = "AUTO"; + } else if (isExplicitSkip && entry.scheduledFor === undefined) { + // v1.16.9 — a slot-less deliberate skip resolves by band + // membership on the skip moment, exactly like a take does. The + // defaulted-now snap below only reaches the tight reminder-grace + // window, so a skip tapped 20 minutes past the dose time inserted + // an orphan ad-hoc skip row while the slot's pending REMINDER row + // stayed open and later auto-missed — the user's deliberate skip + // recorded as a miss. + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: entry.medicationId, + userTz: user.timezone, + takenAt: incomingScheduledFor, + }); + canonicalSlot = attribution.slotInstant; } else { // v1.8.2 — source-agnostic anchor snap for pending echoes + skips. // iOS posts the reminder actions here (source API); without this diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 50891c00e..cbc3461a5 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => { isExplicitSkip: false, idempotencyKey: null, createSource: "WEB", - injectionSite: resolvedInjectionSite, + // v1.16.9 — carry the recorded site / dose override across the + // tombstone + re-insert: a slot move is a re-binding, not a new + // dose, so the original row's documentation must survive when the + // toggle write itself carries none. + injectionSite: + resolvedInjectionSite ?? + (existing.injectionSite as InjectionSiteValue | null), + doseTaken: existing.doseTaken, }); updated = applied.row; await prisma.medication.update({ @@ -611,6 +618,18 @@ export const POST = apiHandler(async (request: NextRequest) => { scheduledFor: existing.scheduledFor, tz: userTzForHook, }); + // v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant, + // possibly a different local day. Recompute the target day too, or the + // source day self-corrects while the destination keeps its stale tuple. + const movedTo = updated?.scheduledFor; + if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { + await recomputeMedicationComplianceForEvent({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: movedTo, + tz: userTzForHook, + }); + } return apiSuccess(updated); }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 34f8c1418..2da559eaa 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -18,6 +18,8 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -73,10 +75,17 @@ async function buildMedicationsList( prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], // v1.7.0 sync — exclude tombstoned rows from the today-count map. + // v1.16.9 — count only ACTIONED rows (taken or skipped). The + // dashboard projector mints pending rows for every slot of the + // day, so counting all rows made `todayEventCount` cover every + // passed dose after any dashboard visit — and the cards' + // overdue-pill suppression (`todayEventCount < passedDoseCount`) + // went dark nondeterministically for genuinely overdue doses. where: { userId, deletedAt: null, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + OR: [{ takenAt: { not: null } }, { skipped: true }], }, _count: { id: true }, }), @@ -91,7 +100,11 @@ async function buildMedicationsList( { autoMissed: true }, ], }, - select: { medicationId: true, scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so the ad-hoc shape + // (`scheduledFor === takenAt`) is detectable: such a row must not + // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's + // genuinely-due 20:00 dose). + select: { medicationId: true, scheduledFor: true, takenAt: true }, }), // v1.16.4 — current-era floor per medication: the newest revision's // `validUntil` is where the LIVE schedule rows became valid. The @@ -106,11 +119,12 @@ async function buildMedicationsList( }), ]); - const resolvedSlotsByMedId = new Map(); + const resolvedSlotsByMedId = new Map(); for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); const list = resolvedSlotsByMedId.get(e.medicationId); - if (list) list.push(e.scheduledFor); - else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); } const eraStartByMedId = new Map(); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 71235937e..b537b765e 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -119,5 +120,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "settings.data.clear" }, meta: result }); + // v1.16.9 — every cached payload was built on the rows just deleted; + // hard-evict the user's buckets so no surface serves pre-wipe data. + invalidateUserData(userId); + return apiSuccess({ cleared: true, ...result }); }); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index e33c3622c..036acbb34 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { create: vi.fn(), findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), }, telegramReminderMessage: { deleteMany: vi.fn(), + findFirst: vi.fn(), }, telegramScheduledDeletion: { createMany: vi.fn(), @@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({ })), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); + +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { answerTelegramCallbackQuery, deleteMessage, @@ -67,10 +79,40 @@ const TG_USER = { id: "user-1", telegramBotToken: "ENC(token-blob)", locale: "en", + timezone: "Europe/Berlin", }; const MEDICATION = { id: "med-1", name: "Ramipril" }; +/** + * The slot-attribution load (`loadAttributeMedication`) selects the full + * schedule projection (distinguishable by `select.schedules`); every other + * `medication.findFirst` in the webhook wants the bare id/name shape. The + * default projection carries no schedules, so the generic dispatch tests + * exercise the ad-hoc (standalone create) path unchanged. + */ +function wireMedicationFindFirst( + schedules: unknown[] = [], + scheduleRevisions: unknown[] = [], +) { + vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { + select?: Record; + }) => { + if (args?.select && "schedules" in args.select) { + return Promise.resolve({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules, + scheduleRevisions, + }); + } + return Promise.resolve(MEDICATION); + }) as never); +} + beforeEach(() => { vi.resetAllMocks(); process.env.TELEGRAM_WEBHOOK_SECRET = "sekret"; @@ -80,14 +122,18 @@ beforeEach(() => { resetAt: Date.now() + 60_000, } as never); vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); - vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never); + wireMedicationFindFirst(); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({ count: 0, } as never); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({ count: 0, } as never); @@ -185,15 +231,7 @@ describe("Telegram webhook — callback dispatch", () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); - // $transaction is invoked with [create-intake, update-medication]. Inspect - // its argument list for both writes. - expect(prisma.$transaction).toHaveBeenCalledTimes(1); - const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown; - expect(Array.isArray(txOps)).toBe(true); - - // The route calls prisma.medicationIntakeEvent.create + medication.update - // synchronously while building the transaction array, so these mocks - // record the arguments the way it built them. + // No schedules wired → the take is ad-hoc and inserts standalone. expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock .calls[0][0]; @@ -226,7 +264,7 @@ describe("Telegram webhook — callback dispatch", () => { null, ); await POST(tgRequest(callbackUpdate("taken:med-1"))); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // Second call: existing-row lookup hits → no second write. vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({ @@ -234,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => { } as never); const res2 = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res2.status).toBe(200); - expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged }); it("'snooze::60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => { @@ -599,6 +637,159 @@ describe("Telegram webhook — text-message dispatch", () => { }); }); +describe("Telegram webhook — slot convergence (v1.16.9)", () => { + // Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row + // at the canonical slot instant. A Telegram take/skip must converge onto + // that row instead of inserting a second row anchored at `now` — the old + // shape left the pending row open to auto-miss, punishing compliance for + // a dose the user explicitly confirmed. + const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST + const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST + + const scheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }; + + const pendingReminderRow = { + id: "evt-pending-0900", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT_0900_BERLIN, + source: "REMINDER", + createdAt: new Date("2026-06-10T05:00:00.000Z"), + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_0903_BERLIN); + wireMedicationFindFirst([scheduleRow]); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingReminderRow, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: { + data: Record; + }) => + Promise.resolve({ + ...pendingReminderRow, + ...args.data, + syncVersion: 2, + })) as never); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + + // The pending row was updated in place — never a duplicate insert. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date; skipped: boolean; autoMissed?: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime()); + expect(update.data.skipped).toBe(false); + expect(update.data.autoMissed).toBe(false); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("a take converges via band attribution when no reminder row matches", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null); + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + // 09:03 sits inside the 09:00 band → same convergence, no second row. + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + expect( + ( + vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as { + where: { id: string }; + } + ).where.id, + ).toBe("evt-pending-0900"); + }); + + it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(res.status).toBe(200); + + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { takenAt: Date | null; skipped: boolean }; + }; + expect(update.where.id).toBe("evt-pending-0900"); + expect(update.data.takenAt).toBeNull(); + expect(update.data.skipped).toBe(true); + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); + }); + + it("an exotic-zone reminder slot reconstructs on the correct local day", async () => { + // Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is + // 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off. + const slotKiritimati = new Date("2026-06-09T19:00:00.000Z"); + const nowKiritimati = new Date("2026-06-09T19:05:00.000Z"); + vi.setSystemTime(nowKiritimati); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...TG_USER, + timezone: "Pacific/Kiritimati", + } as never); + const pendingRow = { + ...pendingReminderRow, + scheduledFor: slotKiritimati, + }; + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + pendingRow, + ] as never); + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({ + date: "2026-06-10", + timeOfDay: "09:00", + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const update = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string } }; + expect(update.where.id).toBe("evt-pending-0900"); + }); +}); + describe("Telegram webhook — GET verification", () => { it("returns 200 with a valid header secret", async () => { const req = new NextRequest("http://localhost/api/telegram/webhook", { diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index b492fa489..742c2c269 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { + applyCanonicalSlotWrite, + resolveForcedSlotForWrite, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface TelegramUpdate { update_id: number; @@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) { }); } +/** + * v1.16.9 — the slot a Telegram action should converge onto. + * + * The reminder context knows its own slot: the dispatcher records every + * reminder message as a `TelegramReminderMessage` row carrying the + * local `date` + `timeOfDay` of the dose it fired for. When the action + * callback carries the chat/message ids, that row names the EXACT slot + * — preferred over band attribution, which would orphan a confirmation + * tapped hours after the reminder (band closed → ad-hoc) even though + * the user is answering a specific dose prompt. + * + * Returns the canonical band anchor (validated against the schedule's + * real slots), or `null` when no reminder row matches / the instant is + * not a real slot — the caller then falls back to band attribution. + */ +async function resolveReminderSlot(input: { + userId: string; + medicationId: string; + userTz: string; + chatId: string; + messageId: number; +}): Promise { + const reminder = await prisma.telegramReminderMessage.findFirst({ + where: { + medicationId: input.medicationId, + chatId: input.chatId, + messageId: input.messageId, + }, + select: { date: true, timeOfDay: true }, + }); + if (!reminder) return null; + const instant = reminderLocalInstant( + reminder.date, + reminder.timeOfDay, + input.userTz, + ); + if (!instant) return null; + // Validate against the medication's real band anchors so a stale or + // malformed reminder row can never park a dose on a non-slot instant. + return resolveForcedSlotForWrite({ + userId: input.userId, + medicationId: input.medicationId, + userTz: input.userTz, + slotInstant: instant, + }); +} + +/** + * Mint the UTC instant of local `timeOfDay` on the local calendar day + * `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either + * side of UTC noon so the result is correct in every zone offset, then + * derives the DST-correct instant via `localHmAsUtc`. + */ +function reminderLocalInstant( + date: string, + timeOfDay: string, + tz: string, +): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) { + return null; + } + const [hour, minute] = timeOfDay.split(":").map(Number); + const base = new Date(`${date}T12:00:00.000Z`); + if (Number.isNaN(base.getTime())) return null; + for (const shiftDays of [0, -1, 1]) { + const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000); + const parts = wallClockInTz(ref, tz); + const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String( + parts.day, + ).padStart(2, "0")}`; + if (localDay === date) return localHmAsUtc(ref, tz, hour, minute); + } + return null; +} + async function markMedicationTaken( userId: string, medicationId: string, idempotencyKey: string, locale: Locale, userTz: string | null, + reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); const medication = await prisma.medication.findFirst({ @@ -146,29 +231,77 @@ async function markMedicationTaken( let scheduledFor: Date | null = null; if (!existing) { - scheduledFor = new Date(); - await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ + const takenAt = new Date(); + const tz = userTz || DEFAULT_TIMEZONE; + + // v1.16.9 — converge onto the slot's canonical row instead of bare- + // creating a second row anchored at `now`. The old shape left the + // worker-minted pending REMINDER row open: the slot later auto-missed + // and compliance punished a dose the user explicitly confirmed. + // Prefer the reminder's own slot (the message names the dose it fired + // for); fall back to band attribution on the tap instant. + let canonicalSlot: Date | null = reminderRef + ? await resolveReminderSlot({ + userId, + medicationId: medication.id, + userTz: tz, + chatId: reminderRef.chatId, + messageId: reminderRef.messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId, + medicationId: medication.id, + userTz: tz, + takenAt, + }); + canonicalSlot = attribution.slotInstant; + } + + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId, + medicationId: medication.id, + canonicalSlot, + takenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey, + createSource: "REMINDER", + attributionSource: "AUTO", + }); + scheduledFor = applied.row.scheduledFor; + } else { + // Ad-hoc / PRN — standalone row under the documented contract + // (`scheduledFor = takenAt`). + const created = await prisma.medicationIntakeEvent.create({ data: { userId, medicationId: medication.id, - scheduledFor, - takenAt: scheduledFor, + scheduledFor: takenAt, + takenAt, skipped: false, source: "REMINDER", idempotencyKey, }, - }), - prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: null }, - }), - ]); + }); + scheduledFor = created.scheduledFor; + } + await prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: null }, + }); + // The confirmation must reflect on the next read (today feed, card + // pill, compliance) rather than wait out the cache TTL. + invalidateUserMedications(userId, { evict: true }); } // v1.4.39 W-MED — refresh the compliance rollup for the // affected day so the next read after the cache miss reflects the - // new dose. Only fires when the create-path landed a new row. + // new dose. Only fires when the write landed a row. if (scheduledFor) { await recomputeMedicationComplianceForEvent({ userId, @@ -225,6 +358,7 @@ async function handleCallback(update: TelegramUpdate) { idempotencyKey, locale, user.timezone, + messageId !== undefined ? { chatId, messageId } : undefined, ); await answerTelegramCallbackQuery(botToken, callback.id, result.message); if (messageId) { @@ -313,25 +447,68 @@ async function handleCallback(update: TelegramUpdate) { if (!existing) { const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); - skippedScheduledFor = new Date(); + const skipMoment = new Date(); + const tz = user.timezone || DEFAULT_TIMEZONE; - await prisma.$transaction([ - prisma.medicationIntakeEvent.create({ + // v1.16.9 — a Telegram skip answers a specific dose prompt, so it + // must converge onto the slot's pending row like a take does. The + // old shape inserted a second row anchored at `now` and left the + // pending REMINDER row open to auto-miss — the user's deliberate + // skip recorded as a miss AND an orphan. Prefer the reminder's own + // slot; fall back to the band the skip moment falls in. + let canonicalSlot: Date | null = + messageId !== undefined + ? await resolveReminderSlot({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + chatId, + messageId, + }) + : null; + if (canonicalSlot === null) { + const attribution = await resolveSlotForWriteByBand({ + userId: user.id, + medicationId: medication.id, + userTz: tz, + takenAt: skipMoment, + }); + canonicalSlot = attribution.slotInstant; + } + + if (canonicalSlot) { + const applied = await applyCanonicalSlotWrite({ + client: prisma, + userId: user.id, + medicationId: medication.id, + canonicalSlot, + takenAt: null, + skipped: true, + isExplicitTaken: false, + isExplicitSkip: true, + idempotencyKey, + createSource: "REMINDER", + }); + skippedScheduledFor = applied.row.scheduledFor; + } else { + const created = await prisma.medicationIntakeEvent.create({ data: { userId: user.id, medicationId: medication.id, - scheduledFor: skippedScheduledFor, + scheduledFor: skipMoment, takenAt: null, skipped: true, source: "REMINDER", idempotencyKey, }, - }), - prisma.medication.update({ - where: { id: medication.id }, - data: { snoozedUntil: endOfDay }, - }), - ]); + }); + skippedScheduledFor = created.scheduledFor; + } + await prisma.medication.update({ + where: { id: medication.id }, + data: { snoozedUntil: endOfDay }, + }); + invalidateUserMedications(user.id, { evict: true }); } // v1.4.39 W-MED — refresh the compliance rollup for the skipped diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index df9042c79..91d882fea 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -116,6 +116,65 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => { expect(result).toBe("b"); }); + it("does not pre-select a medication whose server next-due is in the future (v1.16.9)", () => { + // The band alone reads "in window" at 10:00 Berlin, but the server + // says the next unresolved dose is tomorrow (a rolling cadence whose + // dose was taken) — the gate must keep the heuristic off it. + const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin (UTC+2) + const result = pickDefaultMedicationId( + [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + nextDueAt: "2026-05-13T06:00:00.000Z", // tomorrow + nextDueOverdue: false, + schedules: [ + { + id: "s1", + windowStart: "08:00", + windowEnd: "11:00", + daysOfWeek: null, + label: null, + dose: null, + }, + ], + }), + ], + now, + ); + // Nothing genuinely due → alphabetical fallback ("Aaa"). + expect(result).toBe("a"); + }); + + it("does not pre-select a day-scale medication already taken early in its period (v1.16.9)", () => { + // Weekly Tuesday med (2026-05-12 is a Tuesday), taken two days ago. + const now = new Date("2026-05-12T08:00:00Z"); // 10:00 Berlin + const result = pickDefaultMedicationId( + [ + makeMed("a", { name: "Aaa" }), + makeMed("b", { + name: "Bbb", + lastTakenAt: "2026-05-10T07:00:00.000Z", // Sunday — early take + nextDueAt: "2026-05-12T07:00:00.000Z", // today's slot (unresolved) + nextDueOverdue: true, + schedules: [ + { + id: "s1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: "2", + timesOfDay: ["09:00"], + label: null, + dose: null, + }, + ], + }), + ], + now, + ); + expect(result).toBe("a"); + }); + it("falls back to the alphabetical-first active medication when nothing is due", () => { // 03:00 Berlin time: no schedules overlap. The helper should // alphabetically sort and pick the leading id, "ramipril". diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 353156978..d617055af 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -80,6 +80,15 @@ export interface MedicationOption { schedules: Schedule[]; lastTakenAt: string | null; todayEventCount: number | null; + /** + * v1.16.9 — the server display-due verdict from `GET /api/medications` + * (`nextDueAt` + `nextDueOverdue`). Threaded into the default pick so + * the quick-add gates its "due now" heuristic exactly like the cards: + * a rolling cadence whose next dose is days away must not pre-select. + * Optional for older mocks; absent keeps the legacy band-only pick. + */ + nextDueAt?: string | null; + nextDueOverdue?: boolean; } interface MedicationIntakeQuickAddProps { @@ -127,6 +136,10 @@ export function pickDefaultMedicationId( const nowBerlin = toBerlinDate(now); const due = actives.find((m) => { + // v1.16.9 — the same server display-due gate the cards apply: a + // future (non-overdue) next-due suppresses the overdue tiers and a + // day-scale dose taken early in its period must not pre-select. + const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN; const status = reduceCurrentWindowStatus({ schedules: m.schedules, nowBerlin, @@ -135,8 +148,14 @@ export function pickDefaultMedicationId( active: true, lastTakenAt: m.lastTakenAt, todayEventCount: m.todayEventCount ?? 0, + nextDue: + m.nextDueAt === undefined + ? undefined + : Number.isFinite(nextDueMs) + ? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true } + : null, }); - return status.status !== null; + return status.status !== null && !status.takenEarly; }); if (due) return due.id; diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index bc695e3b7..7161bd33d 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -71,6 +71,8 @@ export interface MedicationCardBodyProps { status: Exclude; windowStart: string; windowEnd: string; + /** v1.16.9 — day-scale early-take context (see the pill). */ + takenEarly?: boolean; } | null; /** @@ -178,7 +180,17 @@ export function MedicationCardBody({ overdue tier arrives with the compliance query, and a line popping in late used to shift every row below it (CLS). */}
- {overdueLabel ? ( + {windowStatus?.takenEarly ? ( + // v1.16.9 — taken-early context outranks the overdue line: a + // day-scale dose already taken earlier in its period must never + // escalate into a take prompt (double-dose risk). + + ) : overdueLabel ? (

+ + + + {" "} + — {formatTimeWindowRange(windowStart, windowEnd, locale)} + +

+ ); + } + return (

x + 1, 0); @@ -288,7 +293,7 @@ export function Glp1MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toBerlinDate(now); + const nowBerlin = toZonedDate(now, userTz); const lateMinutes = thresholds?.lateMinutes ?? 120; const missedMinutes = thresholds?.missedMinutes ?? 240; const currentWindowStatus = reduceCurrentWindowStatus({ @@ -299,6 +304,7 @@ export function Glp1MedicationCard({ active: medication.active, lastTakenAt: medication.lastTakenAt, todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, // v1.16.6 — gate the pill on the server display-due so a rolling // cadence whose next injection is days away can never paint an overdue // pill today. `undefined` (older payloads / fixtures) keeps legacy @@ -319,6 +325,7 @@ export function Glp1MedicationCard({ currentWindowStatus, nextDueAt: medication.nextDueAt, now, + timeZone: userTz, }); // v1.15.9 — the recent-injection list now feeds ONLY the post-dose @@ -439,6 +446,7 @@ export function Glp1MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, + takenEarly: currentWindowStatus.takenEarly, } : null } diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index af72a7006..3edb02c54 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -13,7 +13,7 @@ import { formatDateTime, formatTime } from "@/lib/format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { reduceCurrentWindowStatus, - toBerlinDate, + toZonedDate, } from "@/lib/medications/window-status"; import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -26,6 +26,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary"; import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog"; import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; +import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; interface Schedule { @@ -104,6 +105,11 @@ export function MedicationCard({ }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); + // v1.16.9 — the card reasons in the PROFILE timezone (the snapshot the + // session carries); Berlin stays the last-resort fallback so logged-out + // mounts and legacy fixtures behave unchanged. + const { user } = useAuth(); + const userTz = user?.timezone || "Europe/Berlin"; const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); // v1.8.5 — post-dose injection-site prompt state. Holds the intake @@ -199,7 +205,7 @@ export function MedicationCard({ a.windowStart.localeCompare(b.windowStart) || a.windowEnd.localeCompare(b.windowEnd), ); - const nowBerlin = toBerlinDate(new Date()); + const nowBerlin = toZonedDate(new Date(), userTz); // v1.8.4 — the next-due instant comes from the server (`nextDueAt`, // computed by the canonical recurrence engine anchored on the last // intake). The day label below derives from it; the window-range / @@ -224,6 +230,7 @@ export function MedicationCard({ active: medication.active, lastTakenAt: medication.lastTakenAt, todayEventCount: medication.todayEventCount ?? 0, + tz: userTz, // v1.16.6 — gate the pill on the server display-due so a rolling // cadence whose next dose is tomorrow can never paint an overdue pill // today. `undefined` (older payloads / fixtures) keeps legacy behaviour. @@ -244,6 +251,7 @@ export function MedicationCard({ currentWindowStatus, nextDueAt: medication.nextDueAt, now: new Date(), + timeZone: userTz, }); function formatLastTakenAt(value: string): string { @@ -251,7 +259,7 @@ export function MedicationCard({ // string-comparable for the today / yesterday / older bucketing below. // The actual user-facing display goes through formatTime / formatDateTime. const dayFormatter = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Berlin", + timeZone: userTz, year: "numeric", month: "2-digit", day: "2-digit", @@ -314,7 +322,7 @@ export function MedicationCard({ // Format day label relative to today let dayLabel = ""; if (nextAt) { - const nextDate = toBerlinDate(new Date(nextAt)); + const nextDate = toZonedDate(new Date(nextAt), userTz); const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`; const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; const tomorrow = new Date(nowBerlin); @@ -381,6 +389,7 @@ export function MedicationCard({ // legacy schedule window (which may be stale / degenerate). windowStart: currentWindowStatus.window!.start, windowEnd: currentWindowStatus.window!.end, + takenEarly: currentWindowStatus.takenEarly, } : null } diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 063506be0..97ba155be 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, @@ -67,6 +68,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; workout: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; @@ -170,12 +172,51 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { score: 3 + (i % 3), })), ); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue( - Array.from({ length: 360 }, (_, i) => ({ - scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000), - takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000), - skipped: false, - })), + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + // v1.16.9 — the compliance timeline derives from the band-engine + // ledger now; the snapshot loads medications (schedules + window + // events) instead of raw intake rows. + // Branch on the caller: the GLP-1 block filters on + // `treatmentClass: "GLP1"` (no GLP-1 therapy in this fixture); the + // compliance loader reads every medication. + const complianceMedRows = [ + { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000), + schedules: [ + { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + scheduleRevisions: [], + intakeEvents: Array.from({ length: 360 }, (_, i) => ({ + scheduledFor: new Date(Date.now() - i * 24 * 60 * 60 * 1000), + takenAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000), + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + })), + }, + ]; + prismaMock.medication.findMany.mockImplementation( + (args: { where?: { treatmentClass?: string } }) => + Promise.resolve( + args?.where?.treatmentClass === "GLP1" ? [] : complianceMedRows, + ), ); prismaMock.workout.findMany.mockResolvedValue( Array.from({ length: 80 }, (_, i) => ({ @@ -295,6 +336,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null, diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index f3d565677..df2c12a89 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, @@ -26,6 +27,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; workout: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; @@ -60,6 +62,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => __resetCoachSnapshotCacheForTests(); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default // null = legacy defaults so the existing test fixtures stay diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index a8bfa305a..f7725f414 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, }, })); @@ -36,6 +37,7 @@ const prismaMock = prisma as unknown as { measurement: { findMany: ReturnType }; moodEntry: { findMany: ReturnType }; medicationIntakeEvent: { findMany: ReturnType }; + medication: { findMany: ReturnType }; user: { findUnique: ReturnType }; }; const featuresMock = extractFeatures as unknown as ReturnType; @@ -68,6 +70,7 @@ describe("buildCoachSnapshot", () => { prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply // per-user excludeMetrics. Default null = use legacy defaults. prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a144309cc..651488c0f 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -35,6 +35,13 @@ import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { + buildComplianceLedgerRows, + buildComplianceMedicationContext, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, @@ -753,12 +760,46 @@ async function buildCoachSnapshotImpl( select: { moodLoggedAt: true, score: true }, }) : null; - const intakeRowsPromise = wantsCompliance - ? prisma.medicationIntakeEvent.findMany({ - // Tombstoned intake rows must never reach the Coach snapshot. - where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } }, - orderBy: { scheduledFor: "asc" }, - select: { scheduledFor: true, takenAt: true, skipped: true }, + // v1.16.9 — the adherence timeline derives from the LEDGER tally (the + // same band engine the compliance % + dose history consume), not from a + // raw intake-row count. The raw count read a worker-minted pending row + // as "not taken" (today's later doses dragged the day's rate down all + // morning) and double-counted cross-source duplicate rows on one slot. + // Load each medication's schedules + eras + window events so the ledger + // can be reconstructed per medication. + const complianceMedsPromise = wantsCompliance + ? prisma.medication.findMany({ + where: { userId }, + select: { + id: true, + startsOn: true, + endsOn: true, + oneShot: true, + createdAt: true, + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, + }, + }, + intakeEvents: { + // Tombstoned intake rows must never reach the Coach snapshot. + where: { deletedAt: null, scheduledFor: { gte: cutoff } }, + orderBy: { scheduledFor: "asc" }, + select: { + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + attributionSource: true, + }, + }, + }, }) : null; const sleepRowsPromise = sources.has("sleep") @@ -826,7 +867,7 @@ async function buildCoachSnapshotImpl( const [ moodRows, - intakeRows, + complianceMeds, sleepRows, workoutRows, glp1Block, @@ -836,7 +877,7 @@ async function buildCoachSnapshotImpl( cycleBlock, ] = await Promise.all([ moodRowsPromise, - intakeRowsPromise, + complianceMedsPromise, sleepRowsPromise, workoutRowsPromise, glp1BlockPromise, @@ -939,31 +980,60 @@ async function buildCoachSnapshotImpl( registerBlock("mood", "mood"); } - if (wantsCompliance && intakeRows) { - // Medication compliance lives outside the structured features - // today — the legacy Coach surface labelled it as "general" - // provenance only. v1.4.20.1 ships a per-day adherence row built - // from the intake-event log so the Coach can answer "did I miss - // my dose on Tuesday?" without inventing the schedule. The read is - // issued in parallel above. - if (intakeRows.length > 0) { - // Per-day adherence rate within the recent window. Older days - // collapse into a single weekly bucket. - const recent = intakeRows.filter((r) => r.scheduledFor >= recentCutoff); - const olderRows = intakeRows.filter((r) => r.scheduledFor < recentCutoff); + if (wantsCompliance && complianceMeds) { + // Medication compliance lives outside the structured features today — + // the legacy Coach surface labelled it as "general" provenance only. + // v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from + // the band-engine LEDGER (the same expansion the compliance % and the + // dose-history view consume): a slot counts against the rate only once + // it is genuinely missed, a pending/upcoming slot never reads as "not + // taken", deliberate skips and ad-hoc takes stay out of the + // denominator, and cross-source duplicate rows collapse onto one slot. + const ledgerRows: DoseHistoryRow[] = []; + for (const med of complianceMeds) { + if (med.schedules.length === 0) continue; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(med.intakeEvents), + userTz, + ); + ledgerRows.push( + ...buildComplianceLedgerRows( + med.intakeEvents, + med.schedules, + ctx, + cutoff, + now, + now, + ), + ); + } + // Countable rows: taken (on-time or late) or genuinely missed. The + // pending / upcoming / skipped / ad-hoc rows carry no adherence signal. + const countable = ledgerRows.filter( + (r) => + r.status === "taken_on_time" || + r.status === "taken_late" || + r.status === "missed", + ); + if (countable.length > 0) { + const recent = countable.filter((r) => r.at >= recentCutoff); + const olderRows = countable.filter((r) => r.at < recentCutoff); + const isTaken = (r: DoseHistoryRow) => + r.status === "taken_on_time" || r.status === "taken_late"; const recentByDay = new Map< string, { date: Date; total: number; taken: number } >(); for (const r of recent) { - const key = tzDayKey(r.scheduledFor, userTz); + const key = tzDayKey(r.at, userTz); const e = recentByDay.get(key) ?? { - date: r.scheduledFor, + date: r.at, total: 0, taken: 0, }; e.total += 1; - if (r.takenAt && !r.skipped) e.taken += 1; + if (isTaken(r)) e.taken += 1; recentByDay.set(key, e); } const recentRows = Array.from(recentByDay.entries()) @@ -977,10 +1047,10 @@ async function buildCoachSnapshotImpl( .sort((a, b) => a.date.localeCompare(b.date)); const olderByWeek = new Map(); for (const r of olderRows) { - const key = isoWeekKey(r.scheduledFor, userTz); + const key = isoWeekKey(r.at, userTz); const e = olderByWeek.get(key) ?? { taken: 0, total: 0 }; e.total += 1; - if (r.takenAt && !r.skipped) e.taken += 1; + if (isTaken(r)) e.taken += 1; olderByWeek.set(key, e); } const weeklyRows = Array.from(olderByWeek.entries()) @@ -995,7 +1065,7 @@ async function buildCoachSnapshotImpl( timeline: { recent: recentRows, weekly: weeklyRows }, }; metrics.add("compliance"); - counts.compliance = intakeRows.length; + counts.compliance = countable.length; registerBlock("compliance", "compliance"); } else { // v1.7.0 — toggled-on cluster with no rows. Annotate so the diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 7b531e2ce..e38410022 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -8,7 +8,10 @@ import { auditLog } from "@/lib/auth/audit"; import { profileSchema } from "@/lib/validations/auth"; import { isValidTimezone } from "@/lib/tz/format"; import { encrypt } from "@/lib/crypto"; -import { invalidateUserProfile } from "@/lib/cache/invalidate"; +import { + invalidateUserMedications, + invalidateUserProfile, +} from "@/lib/cache/invalidate"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -168,6 +171,14 @@ export async function applyProfileUpdate( invalidateUserProfile(userId); } + // v1.16.9 — the medication payloads (list next-due, today counts, the + // compliance day buckets) are computed in the user's timezone, but the + // list cache key carries only the userId. A timezone change must evict + // the bucket or the next read serves the previous zone's day grid. + if (data.timezone !== undefined) { + invalidateUserMedications(userId, { evict: true }); + } + await auditLog("profile.update", { userId: updatedUser.id, ipAddress: ipAddress ?? null, diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts index 12664fbb2..6a0ea86b6 100644 --- a/src/lib/cache/__tests__/server-cache.test.ts +++ b/src/lib/cache/__tests__/server-cache.test.ts @@ -451,6 +451,165 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => { }); }); +describe("ServerCache invalidation vs in-flight builders", () => { + it("a read right after an evict does NOT join the pre-write builder", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let releaseFirst: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((r) => { + releaseFirst = r; + }); + } + return Promise.resolve("post-write"); + }); + + // Builder 1 starts (reads pre-write state) and is still in flight… + const first = cache.wrap("u1|list", builder); + // …when a write evicts the key. + cache.delete("u1|list"); + + // The next read must start a FRESH build, not join the stale one. + const second = await cache.wrap("u1|list", builder); + expect(second.outcome).toBe("miss"); + expect(second.value).toBe("post-write"); + expect(builder).toHaveBeenCalledTimes(2); + + // The detached pre-write builder resolves AFTER the fresh one — its + // commit must be discarded, never overwriting the post-write entry. + releaseFirst!("pre-write"); + const firstResult = await first; + expect(firstResult.value).toBe("pre-write"); + expect(cache.get("u1|list")).toBe("post-write"); + }); + + it("deleteByPrefix fences an in-flight builder the same way", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let release: ((v: string) => void) | null = null; + const builder = vi.fn( + () => + new Promise((r) => { + release = r; + }), + ); + const inFlight = cache.wrap("u1|compliance", builder); + cache.deleteByPrefix("u1|"); + release!("pre-write"); + await inFlight; + // The pre-write result never entered the cache. + expect(cache.get("u1|compliance")).toBeNull(); + }); + + it("an SWR refresh that completes after an evict does not re-cache the pre-write body", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let releaseRefresh: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve("v1"); + if (calls === 2) { + return new Promise((r) => { + releaseRefresh = r; + }); + } + return Promise.resolve("post-write"); + }); + + await cache.wrapSwr("u1|list", builder); + vi.advanceTimersByTime(1500); + + // Stale read kicks off the detached background refresh (pre-write). + const stale = await cache.wrapSwr("u1|list", builder); + expect(stale.outcome).toBe("stale"); + + // A write evicts the bucket while the refresh is still in flight… + cache.deleteByPrefix("u1|"); + // …then the pre-write refresh completes. Without the fence it would + // re-insert the pre-write body as FRESH for the full TTL. + releaseRefresh!("pre-write"); + await vi.runAllTimersAsync(); + + expect(cache.get("u1|list")).toBeNull(); + const next = await cache.wrapSwr("u1|list", builder); + expect(next.outcome).toBe("miss"); + expect(next.value).toBe("post-write"); + }); + + it("markStaleByPrefix discards an in-flight refresh and keeps SWR semantics", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let releaseRefresh: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) return Promise.resolve("v1"); + if (calls === 2) { + return new Promise((r) => { + releaseRefresh = r; + }); + } + return Promise.resolve("post-write"); + }); + + await cache.wrapSwr("u1|k", builder); + vi.advanceTimersByTime(1500); + const stale = await cache.wrapSwr("u1|k", builder); + expect(stale.outcome).toBe("stale"); + + // Background write marks the bucket stale mid-refresh; the pre-write + // refresh result must not land as a fresh entry. + cache.markStaleByPrefix("u1|"); + releaseRefresh!("pre-write"); + await vi.runAllTimersAsync(); + + // SWR semantics hold: the prior value still serves stale and a NEW + // (post-write) recompute is kicked off. + const next = await cache.wrapSwr("u1|k", builder); + expect(next.outcome).toBe("stale"); + expect(next.value).toBe("v1"); + await vi.runAllTimersAsync(); + expect(builder).toHaveBeenCalledTimes(3); + const warm = await cache.wrapSwr("u1|k", builder); + expect(warm.outcome).toBe("hit"); + expect(warm.value).toBe("post-write"); + }); + + it("invalidation between two stampeding readers gives the later reader fresh data", async () => { + const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); + let release: ((v: string) => void) | null = null; + let calls = 0; + const builder = vi.fn(() => { + calls += 1; + if (calls === 1) { + return new Promise((r) => { + release = r; + }); + } + return Promise.resolve("fresh"); + }); + + const a = cache.wrap("k", builder); // pre-write build in flight + cache.delete("k"); // write + const b = cache.wrap("k", builder); // post-write read → fresh build + release!("stale"); + const [ra, rb] = await Promise.all([a, b]); + expect(ra.value).toBe("stale"); // joined its own (pre-write) build + expect(rb.value).toBe("fresh"); + expect(cache.get("k")).toBe("fresh"); + }); +}); + describe("global cache registry", () => { it("exposes the eight blueprint caches as module-scope singletons", () => { expect(caches.analytics).toBeDefined(); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index c099b2e09..140769403 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -246,6 +246,38 @@ export function invalidateUserInsightsLayout(userId: string): void { caches.insightsLayout.deleteByPrefix(userId); } +/** + * v1.16.9 — full per-user cache eviction for the wipe / restore paths. + * A data wipe or a backup restore replaces (or removes) every row the + * cached payloads were built on, so every per-user bucket hard-evicts — + * a marked-stale SWR cell would keep serving the pre-wipe body. + */ +export function invalidateUserData(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); + caches.medications.deleteByPrefix(userId); + caches.medicationCompliance.deleteByPrefix(`${userId}|`); + caches.medicationsIntake.deleteByPrefix(`${userId}|`); + caches.achievements.deleteByPrefix(userId); + caches.insightsTargets.deleteByPrefix(userId); + caches.insightsDerived.deleteByPrefix(`${userId}|`); + caches.moodAnalytics.deleteByPrefix(userId); + caches.moodInsights.deleteByPrefix(userId); + caches.workouts.deleteByPrefix(`${userId}|`); + caches.dashboardWidgets.deleteByPrefix(userId); + caches.insightsLayout.deleteByPrefix(userId); +} + +/** + * v1.16.9 — bucket-wide clear for the admin all-user wipe. Every cache + * empties; the next read per user is a cold build over the (now empty / + * restored) tables. Acceptable for an operation this rare. + */ +export function invalidateAllCaches(): void { + for (const cache of Object.values(caches)) { + cache.deleteByPrefix(""); + } +} + /** * Global eviction of the bug-report-status cache. * diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 3afdd06fa..c19601b2a 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -67,12 +67,30 @@ export interface ServerCacheStats { readonly stampedes: number; } +/** + * v1.16.9 — single-flight slot with invalidation fencing. A builder that + * was in flight when an invalidation (`delete` / `deleteByPrefix` / + * `markStaleByPrefix`) hit its key must NOT commit its result: the build + * read pre-write data, and committing it after the eviction would + * re-cache the pre-write body as fresh for the full TTL. The slot is the + * fence — invalidation flips `invalidated` and detaches the slot from + * `pending`, so (a) the builder's `.then` skips the `set()` and (b) the + * next read starts a fresh build instead of joining the stale one. + * Callers already awaiting the detached promise still receive its value + * (equivalent to having read a moment before the write); it just never + * enters the cache. + */ +interface PendingBuild { + promise: Promise; + invalidated: boolean; +} + export class ServerCache { private readonly map = new Map< string, { expiresAt: number; staleUntil: number; value: T } >(); - private readonly pending = new Map>(); + private readonly pending = new Map>(); private hits = 0; private misses = 0; private evictions = 0; @@ -80,6 +98,59 @@ export class ServerCache { constructor(private readonly opts: ServerCacheOptions) {} + /** + * Fence + detach the in-flight builder for one key (no-op when none). + * Part of every invalidation op — see `PendingBuild`. + */ + private detachPending(key: string): void { + const slot = this.pending.get(key); + if (slot) { + slot.invalidated = true; + this.pending.delete(key); + } + } + + /** Fence + detach every in-flight builder under `prefix`. */ + private detachPendingByPrefix(prefix: string): void { + for (const [key, slot] of this.pending) { + if (key.startsWith(prefix)) { + slot.invalidated = true; + this.pending.delete(key); + } + } + } + + /** + * Register a fenced single-flight build for `key`. The commit only + * lands when no invalidation hit the key while the builder ran; the + * `finally` only clears the slot it owns (an invalidation may already + * have detached it and a newer build may occupy the key). + */ + private startBuild( + key: string, + builder: () => Promise, + ttlMsOverride?: number, + ): PendingBuild { + const slot: PendingBuild = { + promise: undefined as unknown as Promise, + invalidated: false, + }; + slot.promise = builder() + .then((value) => { + if (!slot.invalidated) { + this.set(key, value, ttlMsOverride); + } + return value; + }) + .finally(() => { + if (this.pending.get(key) === slot) { + this.pending.delete(key); + } + }); + this.pending.set(key, slot); + return slot; + } + /** * Read a key. Returns null on miss, expired entry, or absent entry. * Side effect: an expired entry is removed; a live entry is @@ -178,6 +249,12 @@ export class ServerCache { * behaviour). */ markStaleByPrefix(prefix: string): number { + // v1.16.9 — fence in-flight builders too: a refresh that started + // before the write would otherwise complete after this mark and + // re-cache the pre-write body as FRESH for the full TTL. Detaching + // keeps the SWR contract — the marked entry still serves stale, and + // the next read kicks off a fresh (post-write) recompute. + this.detachPendingByPrefix(prefix); let marked = 0; const now = Date.now(); for (const [key, entry] of this.map) { @@ -191,6 +268,9 @@ export class ServerCache { /** Single-key eviction. Returns true when the key was present. */ delete(key: string): boolean { + // v1.16.9 — fence the in-flight builder so a pre-write build can + // neither be joined by post-evict reads nor commit its result. + this.detachPending(key); return this.map.delete(key); } @@ -203,6 +283,8 @@ export class ServerCache { * possible cache-key variant the user might have warmed. */ deleteByPrefix(prefix: string): number { + // v1.16.9 — fence in-flight builders under the prefix (see `delete`). + this.detachPendingByPrefix(prefix); let removed = 0; for (const key of this.map.keys()) { if (key.startsWith(prefix)) { @@ -238,21 +320,13 @@ export class ServerCache { const inFlight = this.pending.get(key); if (inFlight) { this.stampedes += 1; - const value = await inFlight; + const value = await inFlight.promise; return { value, outcome: "stampede" }; } this.misses += 1; - const promise = builder() - .then((value) => { - this.set(key, value, ttlMsOverride); - return value; - }) - .finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, promise); - const value = await promise; + const slot = this.startBuild(key, builder, ttlMsOverride); + const value = await slot.promise; return { value, outcome: "miss" }; } @@ -288,20 +362,11 @@ export class ServerCache { // single-flight slot so concurrent stale reads share one rebuild. this.hits += 1; if (!this.pending.has(key)) { - const refresh = builder() - .then((value) => { - this.set(key, value, ttlMsOverride); - return value; - }) - .catch((err) => { - // The stale value already went out; don't crash the worker. - // Re-throw so the join path of any awaiting caller sees it. - throw err; - }) - .finally(() => { - this.pending.delete(key); - }); - this.pending.set(key, refresh); + // v1.16.9 — the background refresh runs through the same fenced + // single-flight slot as `wrap`, so an invalidation that lands + // while it is in flight discards its (pre-write) result instead + // of re-caching it as fresh. + const refresh = this.startBuild(key, builder, ttlMsOverride).promise; // Detach: the foreground caller does not await the rebuild. // Handle the rejection on the detached handle so an // unhandled-rejection warning never fires — but never silently: @@ -344,6 +409,11 @@ export class ServerCache { /** Reset every counter + entry. Test-only escape hatch. */ __resetForTests(): void { this.map.clear(); + // Fence any in-flight builder so a build crossing a test boundary + // cannot commit into the next test's clean cache. + for (const slot of this.pending.values()) { + slot.invalidated = true; + } this.pending.clear(); this.hits = 0; this.misses = 0; diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index ec0266f46..8d738d11a 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -77,7 +77,11 @@ function medication( let callOrder: string[]; let medications: Array>; let todayRows: FakeIntakeRow[]; -let resolvedRows: Array<{ medicationId: string; scheduledFor: Date }>; +let resolvedRows: Array<{ + medicationId: string; + scheduledFor: Date; + takenAt: Date | null; +}>; let latestIntakeGroups: Array<{ medicationId: string; _max: { takenAt: Date | null }; @@ -298,6 +302,8 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { { medicationId: "med-1", scheduledFor: new Date("2026-06-10T09:00:00.000Z"), + // Slot-anchored take (attributed): takenAt differs from the anchor. + takenAt: new Date("2026-06-10T09:05:00.000Z"), }, ]; diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 4f501cb3f..1d7745c3c 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -39,6 +39,8 @@ import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/pr import { computeDisplayDue, OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, } from "@/lib/medications/scheduling/next-due"; import { getUserTodayBounds } from "@/lib/tz/local-day"; @@ -136,7 +138,9 @@ export async function buildMedsTodayBlock( { autoMissed: true }, ], }, - select: { medicationId: true, scheduledFor: true }, + // v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor + // === takenAt`) cannot ±6h-resolve a different slot. + select: { medicationId: true, scheduledFor: true, takenAt: true }, }), // Current-era floor per medication: the newest revision's // `validUntil` is where the LIVE schedule rows became valid; the @@ -157,11 +161,12 @@ export async function buildMedsTodayBlock( const lastTakenAtByMedId = new Map( latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]), ); - const resolvedSlotsByMedId = new Map(); + const resolvedSlotsByMedId = new Map(); for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); const list = resolvedSlotsByMedId.get(e.medicationId); - if (list) list.push(e.scheduledFor); - else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); } const eraStartByMedId = new Map(); for (const f of eraFloors) { diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts index 8e2778214..f37ced16d 100644 --- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -40,9 +40,12 @@ describe("prompt reads exclude tombstoned rows", () => { }); it("the Coach snapshot filters its intake read", () => { + // v1.16.9 — the compliance timeline reads intake events through the + // medication relation (`intakeEvents`) so the ledger can be rebuilt + // per medication; the tombstone filter must ride on that nested read. const src = read("../../ai/coach/snapshot.ts"); const intakeRead = src.match( - /medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/, + /intakeEvents:\s*\{[\s\S]{0,300}?where:\s*\{[^}]*\}/, ); expect(intakeRead).not.toBeNull(); expect(intakeRead![0]).toContain("deletedAt: null"); diff --git a/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts new file mode 100644 index 000000000..ef78d293c --- /dev/null +++ b/src/lib/medications/__tests__/intake-slot-dedup-converge.test.ts @@ -0,0 +1,226 @@ +/** + * v1.16.9 — ad-hoc→pending band convergence in the nightly dedup. + * + * The production damage this heals: the external-API and Telegram write + * paths historically bare-created `scheduledFor = takenAt`, leaving the + * worker-minted pending REMINDER row open. The legacy snap pass only + * reaches a ± half-window tolerance and the discovery query only matched + * 2-minute drift, so an 08:42 API take beside its pending 09:00 row + * never collapsed — the ledger showed an ad-hoc take PLUS a missed slot + * for one real dose. + * + * Covered here: + * 1. The 08:42 / 09:00 pair heals: the pending row tombstones, the + * take normalises onto the canonical anchor. + * 2. A take outside the legacy snap tolerance but inside the band's + * late tail still heals (the case the snap pass can never reach). + * 3. An ambiguous double-slot (two live rows on the anchor) is left + * alone by the convergence pass. + * 4. A WEB ad-hoc row (deliberate user shape) is never converged. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { + findFirst: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; +import { prisma } from "@/lib/db"; + +const TZ = "Europe/Berlin"; + +// Daily 09:00 Berlin med. 09:00 CEST on 2026-06-10 = 07:00:00Z. +const SLOT_0900 = new Date("2026-06-10T07:00:00.000Z"); +const TAKEN_0842 = new Date("2026-06-10T06:42:00.000Z"); +// 12:30 Berlin — outside the ±2h legacy snap tolerance of the 09:00 +// anchor, but inside the band's late tail (on-time end 10:00 + 180 min). +const TAKEN_1230 = new Date("2026-06-10T10:30:00.000Z"); + +const SCHEDULE = { + id: "s1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +const MED = { + id: "med-1", + userId: "u1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [SCHEDULE], + scheduleRevisions: [], +}; + +function pendingRow(over: Record = {}) { + return { + id: "r-pending", + medicationId: "med-1", + scheduledFor: SLOT_0900, + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-10T05:00:00Z"), + attributionSource: "AUTO", + source: "REMINDER", + ...over, + }; +} + +function adhocRow(at: Date, over: Record = {}) { + return { + id: "r-adhoc", + medicationId: "med-1", + scheduledFor: at, + takenAt: at, + skipped: false, + syncVersion: 1, + createdAt: at, + attributionSource: "AUTO", + source: "API", + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: TZ, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); +}); + +describe("dedupeUserIntakeSlots — ad-hoc→pending convergence (v1.16.9)", () => { + it("heals the 08:42 API take / pending 09:00 REMINDER pair", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_0842), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + // The pending row tombstones; the take is the dose of record. + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1); + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { + where: { id: string }; + data: { deletedAt: Date }; + }; + expect(tombstone.where.id).toBe("r-pending"); + expect(tombstone.data.deletedAt).toBeInstanceOf(Date); + + // The take normalises onto the canonical 09:00 anchor. + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { + where: { id: string }; + data: { scheduledFor: Date }; + }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + + expect(summary.rowsSoftDeleted).toBe(1); + expect(summary.rowsNormalised).toBe(1); + expect(summary.slotsCollapsed).toBe(1); + expect(summary.daysRecomputed).toBeGreaterThanOrEqual(1); + }); + + it("heals a take outside the legacy snap tolerance but inside the band's late tail", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(1); + expect(summary.rowsNormalised).toBe(1); + const tombstone = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock + .calls[0][0] as { where: { id: string } }; + expect(tombstone.where.id).toBe("r-pending"); + const normalise = vi.mocked(prisma.medicationIntakeEvent.update).mock + .calls[0][0] as { where: { id: string }; data: { scheduledFor: Date } }; + expect(normalise.where.id).toBe("r-adhoc"); + expect(normalise.data.scheduledFor.getTime()).toBe(SLOT_0900.getTime()); + }); + + it("leaves an ambiguous double-slot (two live rows on the anchor) alone", async () => { + // Two live rows already sit on the 09:00 anchor — converging the take + // would have to pick one blindly. The convergence pass declines; the + // take stays where it is. (The legacy exact-instant collapse of the + // two anchor rows is a separate, pre-existing pass.) + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230), + pendingRow(), + pendingRow({ id: "r-pending-2", source: "API" }), + ] as never); + + await dedupeUserIntakeSlots("u1"); + + // The ad-hoc take was never normalised onto the anchor. + const normaliseCalls = vi + .mocked(prisma.medicationIntakeEvent.update) + .mock.calls.map((c) => (c[0] as { where: { id: string } }).where.id); + expect(normaliseCalls).not.toContain("r-adhoc"); + }); + + it("never converges a WEB ad-hoc row (deliberate user shape)", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230, { source: "WEB" }), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(0); + expect(summary.rowsNormalised).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("never converges a USER_PIN released row", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + adhocRow(TAKEN_1230, { attributionSource: "USER_PIN" }), + pendingRow(), + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.rowsSoftDeleted).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/medications/__tests__/window-status.test.ts b/src/lib/medications/__tests__/window-status.test.ts index 23dcb806f..4d2dd09d4 100644 --- a/src/lib/medications/__tests__/window-status.test.ts +++ b/src/lib/medications/__tests__/window-status.test.ts @@ -365,3 +365,95 @@ describe("reduceCurrentWindowStatus — display-due gate (v1.16.6)", () => { expect(res.status).toBeNull(); }); }); + +/** + * v1.16.9 — day-scale early-take downgrade. A weekly injectable taken two + * days before its slot day is already on board; on the slot day the + * in-window pill must carry taken-early context instead of prompting a + * full take (a "take now" there is a double-dose prompt). + */ +describe("reduceCurrentWindowStatus — day-scale taken-early context", () => { + // Weekly Wednesday 09:00 (2026-06-10 is a Wednesday). daysOfWeek "3" = + // Wednesday in the legacy CSV encoding. + const weeklyWedSchedule = { + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: "3", + timesOfDay: ["09:00"], + }; + // Monday 2026-06-08, 08:00 Berlin (CEST) — two days before the slot day. + const MONDAY_TAKE = "2026-06-08T06:00:00.000Z"; + + it("flags takenEarly on the slot day after a Monday take (Mon-take/Wed-slot)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), + lastTakenAt: MONDAY_TAKE, + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(true); + }); + + it("does not flag takenEarly when the last take was a full period ago", () => { + // Previous Wednesday — the prior cycle's regular dose. + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), + lastTakenAt: "2026-06-03T07:05:00.000Z", + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(false); + }); + + it("does not flag a daily cadence (minute-scale periods stay untouched)", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [ + { + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + }, + ], + nowBerlin: localClock(9, 15), + lastTakenAt: "2026-06-09T07:05:00.000Z", // yesterday — normal daily take + todayEventCount: 0, + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(false); + }); + + it("rides on the late tier too — an early take must not escalate to overdue", () => { + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(11, 30), // past the band + inside lateMinutes + lastTakenAt: MONDAY_TAKE, + todayEventCount: 0, + }); + expect(res.status).toBe("late"); + expect(res.takenEarly).toBe(true); + }); + + it("evaluates the early-take day in the supplied timezone", () => { + // Pacific/Auckland (UTC+12): the same instant lands on different local + // days in Berlin vs Auckland near midnight. 2026-06-08T13:00:00Z is + // 2026-06-09 01:00 in Auckland (one day before the Wed slot day) — + // still inside the weekly period → takenEarly. + const res = reduceCurrentWindowStatus({ + ...BASE, + schedules: [weeklyWedSchedule], + nowBerlin: localClock(9, 15), // interpreted as Auckland wall clock + lastTakenAt: "2026-06-08T13:00:00.000Z", + todayEventCount: 0, + tz: "Pacific/Auckland", + }); + expect(res.status).toBe("in_window"); + expect(res.takenEarly).toBe(true); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index b5e06713d..8f2004853 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -49,6 +49,11 @@ import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; +import { + buildMedicationDayBands, + type AttributeIntakeMedication, +} from "@/lib/medications/scheduling/attribute-intake"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; import type { WorkerMedicationRow, WorkerScheduleRow, @@ -89,6 +94,8 @@ type DedupMedication = WorkerMedicationRow & { userId: string; timezone: string; schedules: WorkerScheduleRow[]; + /** v1.16.9 — archived eras so the band convergence honours old times. */ + scheduleRevisions: ScheduleRevisionLike[]; }; interface DedupIntakeRow { @@ -105,6 +112,13 @@ interface DedupIntakeRow { * ad-hoc). Such a row never joins a snap cluster. */ attributionSource: string; + /** + * v1.16.9 — write origin. The ad-hoc→pending band convergence only + * touches API / REMINDER rows (external ingest + the Telegram webhook, + * the two paths that historically bare-created `scheduledFor = + * takenAt`); a WEB or IMPORT ad-hoc row is a deliberate user shape. + */ + source: string; } /** @@ -197,6 +211,21 @@ export async function dedupeUserIntakeSlots( scheduleType: true, cyclicOnWeeks: true, cyclicOffWeeks: true, + // v1.16.9 — the band convergence honours the persisted per-dose + // windows (the same lever the write path + read ledger consume). + doseWindows: true, + }, + }, + // v1.16.9 — archived eras: an old ad-hoc row converges against the + // schedule that was live at its takenAt, not today's times. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, }, }, }, @@ -235,12 +264,33 @@ export async function dedupeUserIntakeSlots( createdAt: true, // v1.16.0 — pin-awareness: a USER_PIN row never joins a cluster. attributionSource: true, + // v1.16.9 — write origin for the ad-hoc→pending convergence gate. + source: true, }, orderBy: { scheduledFor: "asc" }, })) as DedupIntakeRow[]; if (rows.length < 2) continue; + // v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER + // rows onto the pending row of the slot whose minted band contains + // the takenAt. The legacy snap pass below only reaches a ±half-window + // tolerance, so an 18-minute-early API ingest beside its pending + // 09:00 REMINDER row never clustered — the production damage this + // backfill exists to repair. The pass mutates `rows` in place + // (tombstoned pending rows removed, the winner's `scheduledFor` + // normalised) so the snap pass below operates on the healed state. + await convergeAdhocRowsOntoPendingSlots({ + med, + rows, + userTz, + lastIntakeAt, + summary, + daysToRecompute, + }); + + if (rows.length < 2) continue; + // Group rows by the canonical slot instant they snap to. Rows that // resolve to `null` are PRN / off-slot — keyed individually so they // never collapse together. @@ -402,6 +452,161 @@ export async function dedupeUserIntakeSlots( return summary; } +/** + * v1.16.9 — converge ad-hoc actioned rows onto their slot's pending row. + * + * The healed shape: an external-API or Telegram take historically + * bare-created `scheduledFor = takenAt`, leaving the worker-minted + * pending REMINDER row open. The pending row later auto-missed, so the + * ledger showed an ad-hoc take PLUS a missed slot for one real dose. + * The write paths converge since v1.16.9; this pass repairs the rows + * already written. + * + * Deliberately conservative — a pair converges ONLY when: + * - the actioned row is ad-hoc shaped (`scheduledFor === takenAt`, + * taken, not skipped), source API or REMINDER, and not USER_PIN; + * - exactly ONE minted band contains the takenAt (the bands are + * disjoint by construction, but a zero- or ambiguous-match take is + * left alone); + * - the band's anchor carries exactly ONE live row and that row is + * unactioned (no takenAt, not skipped — a cron auto-miss flag does + * not count as an action: the take proves the miss wrong). + * + * The actioned row is the dose of record: its `scheduledFor` normalises + * to the band anchor and the pending row is tombstoned (soft-deleted, + * syncVersion bumped) per the canonical pattern. `rows` is mutated in + * place so the caller's snap pass sees the healed state. + */ +async function convergeAdhocRowsOntoPendingSlots(input: { + med: DedupMedication; + rows: DedupIntakeRow[]; + userTz: string; + lastIntakeAt: Date | null; + summary: IntakeSlotDedupSummary; + daysToRecompute: Set; +}): Promise { + const { med, rows, userTz, lastIntakeAt, summary, daysToRecompute } = input; + + const isRolling = med.schedules.some((s) => s.rollingIntervalDays !== null); + + const adhocCandidates = rows.filter( + (r) => + r.takenAt !== null && + !r.skipped && + r.scheduledFor.getTime() === r.takenAt.getTime() && + r.attributionSource !== "USER_PIN" && + (r.source === "API" || r.source === "REMINDER"), + ); + if (adhocCandidates.length === 0) return; + + const medication: AttributeIntakeMedication = { + id: med.id, + startsOn: med.startsOn, + endsOn: med.endsOn, + oneShot: med.oneShot, + createdAt: med.createdAt, + schedules: med.schedules, + scheduleRevisions: med.scheduleRevisions ?? [], + }; + + for (const adhoc of adhocCandidates) { + try { + const takenAt = adhoc.takenAt as Date; + // Rolling cadences anchor their retrospective grid AT each intake; + // build the instants from the rows already in memory, EXCLUDING the + // candidate itself (its own ad-hoc anchor must not mint the band it + // is judged against). + const intakeInstants = isRolling + ? rows + .filter( + (r) => + r.id !== adhoc.id && + r.takenAt !== null && + !r.skipped && + r.takenAt.getTime() <= takenAt.getTime(), + ) + .map((r) => r.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()) + : undefined; + + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication, + userTz, + around: takenAt, + lastIntakeAt, + intakeInstants, + now: takenAt, + }); + if (!hasExpectedSlots) continue; + + const t = takenAt.getTime(); + const matching = bands.filter( + (b) => t >= b.onTimeStart.getTime() && t <= b.overdueEnd.getTime(), + ); + if (matching.length !== 1) continue; // zero or ambiguous — leave alone. + const anchor = matching[0].at; + if (anchor.getTime() === adhoc.scheduledFor.getTime()) continue; // same instant — the snap pass owns it. + + const anchorRows = rows.filter( + (r) => + r.id !== adhoc.id && r.scheduledFor.getTime() === anchor.getTime(), + ); + if (anchorRows.length !== 1) continue; // empty or double-slot — leave alone. + const pending = anchorRows[0]; + if (pending.takenAt !== null || pending.skipped) continue; // already served. + + // Tombstone the pending row; the take is the dose of record. + await prisma.medicationIntakeEvent.updateMany({ + where: { id: pending.id, deletedAt: null }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + summary.rowsSoftDeleted += 1; + + const adhocDayInstant = adhoc.scheduledFor; + // Normalise the take onto the canonical anchor. Same P2002 guard as + // the snap pass: a tombstoned row may already occupy + // `(anchor, source)`; when it does the take keeps its own anchor — + // the pending row is already gone, so the slot no longer double-counts. + try { + await prisma.medicationIntakeEvent.update({ + where: { id: adhoc.id }, + data: { scheduledFor: anchor, syncVersion: { increment: 1 } }, + }); + summary.rowsNormalised += 1; + adhoc.scheduledFor = anchor; + } catch (normErr) { + if (!isP2002(normErr)) throw normErr; + annotate({ + meta: { + intake_slot_dedup_converge_collision: true, + intake_slot_dedup_medication: med.id, + intake_slot_dedup_slot: anchor.toISOString(), + }, + }); + } + + summary.slotsCollapsed += 1; + + // Both local days can differ for day-scale bands — recompute each. + daysToRecompute.add(`${med.id}|${anchor.toISOString()}`); + daysToRecompute.add(`${med.id}|${adhocDayInstant.toISOString()}`); + + // Mutate the in-memory rows so the snap pass sees the healed state. + const pendingIdx = rows.findIndex((r) => r.id === pending.id); + if (pendingIdx >= 0) rows.splice(pendingIdx, 1); + } catch (pairErr) { + annotate({ + meta: { + intake_slot_dedup_converge_failed: true, + intake_slot_dedup_medication: med.id, + intake_slot_dedup_error: + pairErr instanceof Error ? pairErr.message : String(pairErr), + }, + }); + } + } +} + /** * Discovery pass — runs at worker boot and on the daily cron tick (the * cron payload omits `userId`; the worker handler dispatches here). Finds @@ -444,6 +649,15 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{ // `(user_id, medication_id, scheduled_for)`. The window absorbs the // sub-minute iOS-vs-server drift; the per-user pass re-checks // precisely so a wider-than-needed window only costs idle jobs. + // + // v1.16.9 — second arm: the ad-hoc→pending heal pattern. An actioned + // ad-hoc row (`scheduled_for = taken_at`) beside an unactioned row on + // the same medication can sit up to the widest band reach apart + // (weekly on-time 1d + overdue 4d ≈ 5 days; 475200 s adds half a day + // of slack) — far outside the 2-minute drift arm, which is why the + // 18-minute API/REMINDER pairs were never enqueued. The per-user pass + // re-checks by exact band membership, so the wide arm only costs idle + // jobs. const users = await prisma.$queryRaw>` SELECT DISTINCT a."user_id" AS id FROM "medication_intake_events" a @@ -453,7 +667,18 @@ export async function enqueueBootTimeIntakeSlotDedup(): Promise<{ AND a."id" <> b."id" AND a."deleted_at" IS NULL AND b."deleted_at" IS NULL - AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120 + AND ( + abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 120 + OR ( + a."taken_at" IS NOT NULL + AND a."scheduled_for" = a."taken_at" + AND a."skipped" = false + AND a."source" IN ('API', 'REMINDER') + AND b."taken_at" IS NULL + AND b."skipped" = false + AND abs(extract(epoch FROM (a."scheduled_for" - b."scheduled_for"))) <= 475200 + ) + ) `; if (users.length === 0) { diff --git a/src/lib/medications/scheduling/__tests__/attribution.test.ts b/src/lib/medications/scheduling/__tests__/attribution.test.ts index 9d2b40a0f..227f30e3f 100644 --- a/src/lib/medications/scheduling/__tests__/attribution.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribution.test.ts @@ -96,8 +96,17 @@ describe("attributeIntakeToSlot — summer (CEST)", () => { expect(r?.status).toBe("on_time"); }); - it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => { - expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull(); + it("a take at 17:30 credits the 19:00 slot through the bounded early grace (v1.16.9)", () => { + // 30 min before the 18:00 on-time start — inside the 60-min early + // grace, so a slightly-early take credits its slot instead of + // orphaning ad-hoc while the slot later reads missed. + const r = attributeIntakeToSlot(at(day, 17, 30), bands); + expect(r?.band.timeOfDay).toBe("19:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take at 16:30 stays ad-hoc (beyond the 60-min early grace)", () => { + expect(attributeIntakeToSlot(at(day, 16, 30), bands)).toBeNull(); }); }); @@ -166,3 +175,73 @@ describe("attributeIntakeToSlot — configurable window widens on-time", () => { expect(r?.status).toBe("on_time"); }); }); + +/** + * v1.16.9 — bounded early grace ahead of the on-time band. + * + * A window configured to start AT the dose time ("09:00–10:00") refused + * an 08:42 take: it orphaned ad-hoc and the slot read missed. Takes up to + * 60 min before `onTimeStart` now credit the slot; the reach is capped at + * the previous slot's `overdueEnd` so a take inside the prior dose's late + * tail is never re-claimed. + */ +describe("attributeIntakeToSlot — bounded early grace (v1.16.9)", () => { + const day = new Date("2026-06-08T12:00:00Z"); + + function nineToTenWindow(): SlotWindowInput { + return { + at: localHmAsUtc(day, TZ, 9, 0), + timeOfDay: "09:00", + onTimeStart: localHmAsUtc(day, TZ, 9, 0), + onTimeEnd: localHmAsUtc(day, TZ, 10, 0), + lateGraceMs: 180 * MIN, + }; + } + + it("an 08:42 take credits the 09:00 slot whose window starts AT 09:00", () => { + const bands = buildSlotBands([nineToTenWindow()]); + const r = attributeIntakeToSlot(at(day, 8, 42), bands); + expect(r?.band.timeOfDay).toBe("09:00"); + expect(r?.status).toBe("on_time"); + }); + + it("a take more than 60 min before the window start stays ad-hoc", () => { + const bands = buildSlotBands([nineToTenWindow()]); + expect(attributeIntakeToSlot(at(day, 7, 55), bands)).toBeNull(); + }); + + it("the early grace never crosses into the previous slot's band", () => { + // 06:00 slot with a long late tail reaching to 09:00 (capped exactly + // at the 09:00 window start) + the 09:00–10:00 window. A take at + // 08:42 sits inside the 06:00 slot's tail — it must stay attributed + // LATE to the 06:00 slot, never early-claimed by the 09:00 slot. + const slots: SlotWindowInput[] = [ + { + at: localHmAsUtc(day, TZ, 6, 0), + timeOfDay: "06:00", + onTimeStart: localHmAsUtc(day, TZ, 5, 30), + onTimeEnd: localHmAsUtc(day, TZ, 6, 30), + lateGraceMs: 600 * MIN, // capped at the next window's start + }, + nineToTenWindow(), + ]; + const bands = buildSlotBands(slots); + // The cap binds: the 09:00 band's early reach starts where the 06:00 + // tail ends. + expect(bands[1].earlyStart.toISOString()).toBe( + bands[0].overdueEnd.toISOString(), + ); + const r = attributeIntakeToSlot(at(day, 8, 42), bands); + expect(r?.band.timeOfDay).toBe("06:00"); + expect(r?.status).toBe("late"); + }); + + it("isLastIntakeInBand suppression mirrors the grace (no take-now after an 08:42 take)", () => { + // Covered at the card level: see window-status — this pins the band + // shape the suppression relies on (earlyStart <= onTimeStart always). + const bands = buildSlotBands([nineToTenWindow()]); + expect(bands[0].earlyStart.getTime()).toBeLessThanOrEqual( + bands[0].onTimeStart.getTime(), + ); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 495fecd35..3a9dec442 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -11,12 +11,22 @@ */ import { describe, expect, it } from "vitest"; -import { computeDisplayDue, computeNextDueAt } from "../next-due"; +import { + computeDisplayDue, + computeNextDueAt, + toResolvedSlotMark, + type ResolvedSlotMark, +} from "../next-due"; import type { WorkerMedicationRow, WorkerScheduleRow, } from "../worker-helpers"; +/** A slot-anchored resolved mark (the write paths' canonical shape). */ +function mark(at: Date): ResolvedSlotMark { + return { at, slotAnchored: true }; +} + function d(iso: string): Date { return new Date(iso); } @@ -155,8 +165,8 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { userTz: BERLIN, lastIntakeAt: d("2026-06-10T15:30:00Z"), resolvedSlots: [ - d("2026-06-10T05:00:00Z"), // today 07:00 Berlin - d("2026-06-10T17:00:00Z"), // today 19:00 Berlin + mark(d("2026-06-10T05:00:00Z")), // today 07:00 Berlin + mark(d("2026-06-10T17:00:00Z")), // today 19:00 Berlin ], }); expect(next).not.toBeNull(); @@ -173,7 +183,7 @@ describe("computeNextDueAt — skip resolved slots (twice-daily)", () => { now, userTz: BERLIN, lastIntakeAt: d("2026-06-10T07:13:00Z"), - resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00 + resolvedSlots: [mark(d("2026-06-10T05:00:00Z"))], // only today 07:00 }); expect(next).not.toBeNull(); expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00 @@ -315,7 +325,7 @@ describe("computeDisplayDue — open overdue slot", () => { now, userTz: BERLIN, lastIntakeAt: null, - resolvedSlots: [d("2026-06-10T19:00:00Z")], + resolvedSlots: [mark(d("2026-06-10T19:00:00Z"))], }); expect(due).not.toBeNull(); expect(due!.overdue).toBe(false); @@ -336,3 +346,137 @@ describe("computeDisplayDue — open overdue slot", () => { expect(due!.overdue).toBe(false); }); }); + +/** + * v1.16.9 — an AD-HOC row (`scheduledFor === takenAt`) must never + * ±6h-resolve a DIFFERENT slot. The proven failure: twice-daily 08:00 / + * 20:00 Berlin, an ad-hoc take at 14:30 sat within 6h of tonight's 20:00 + * anchor and resolved it — the genuinely-due dose disappeared from the + * card while the ledger still counted the slot missed. + */ +describe("resolved-slot marks — ad-hoc rows only match their own anchor", () => { + const medication = makeMedication(); + // 08:00 / 20:00 Berlin (CEST): 06:00Z / 18:00Z. + function schedule(): WorkerScheduleRow { + return { + id: "sched-2x", + windowStart: "08:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + const ADHOC_1430 = d("2026-06-10T12:30:00Z"); // 14:30 Berlin + + it("a 14:30 ad-hoc take leaves tonight's 20:00 slot due", () => { + const now = d("2026-06-10T15:00:00Z"); // 17:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: ADHOC_1430, + resolvedSlots: [ + // The morning slot was genuinely taken (slot-anchored row)… + mark(d("2026-06-10T06:00:00Z")), + // …and the 14:30 take recorded ad-hoc (`scheduledFor === takenAt`). + toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }), + ], + }); + expect(next).not.toBeNull(); + // Tonight's 20:00 Berlin (18:00Z) stays due — the ad-hoc row must not + // resolve it across the radius. + expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); + }); + + it("a 14:30 ad-hoc take does not suppress the 20:00 overdue pill either", () => { + const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin — inside the late tail + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: ADHOC_1430, + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + toResolvedSlotMark({ scheduledFor: ADHOC_1430, takenAt: ADHOC_1430 }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T18:00:00.000Z"); + }); + + it("a slot-anchored attributed row still resolves its slot across drift", () => { + const now = d("2026-06-10T19:00:00Z"); // 21:00 Berlin + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T18:05:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + // Attributed take: scheduledFor on the anchor, takenAt 20:05. + toResolvedSlotMark({ + scheduledFor: d("2026-06-10T18:00:00Z"), + takenAt: d("2026-06-10T18:05:00Z"), + }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + // Both today's slots resolved → tomorrow's 08:00 Berlin (06:00Z). + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("an ad-hoc row sitting exactly on the anchor still resolves that slot", () => { + const now = d("2026-06-10T19:00:00Z"); + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T18:00:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + // Take recorded at the exact anchor instant — ad-hoc shaped but ON + // the slot; the near-exact epsilon keeps it resolving. + toResolvedSlotMark({ + scheduledFor: d("2026-06-10T18:00:00Z"), + takenAt: d("2026-06-10T18:00:00Z"), + }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); + }); + + it("behaves identically in a non-European zone (Pacific/Auckland)", () => { + const AUCKLAND = "Pacific/Auckland"; // UTC+12 in June (NZST) + // 08:00 / 20:00 Auckland on 2026-06-10 = 2026-06-09T20:00Z / 2026-06-10T08:00Z. + const adhoc = d("2026-06-10T02:30:00Z"); // 14:30 Auckland + const now = d("2026-06-10T09:00:00Z"); // 21:00 Auckland + const due = computeDisplayDue({ + medication, + schedules: [schedule()], + now, + userTz: AUCKLAND, + lastIntakeAt: adhoc, + resolvedSlots: [ + mark(d("2026-06-09T20:00:00Z")), + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T08:00:00.000Z"); + }); +}); diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts index 7d8b705bb..91e428d35 100644 --- a/src/lib/medications/scheduling/attribution.ts +++ b/src/lib/medications/scheduling/attribution.ts @@ -39,10 +39,27 @@ export interface SlotWindowInput { lateGraceMs: number; } +/** + * v1.16.9 — bounded early grace ahead of the on-time band. A user who + * configures a window to start AT the dose time ("09:00–10:00") would + * otherwise have an 08:42 take refused and orphaned ad-hoc while the + * slot read missed. A take up to this far before `onTimeStart` still + * credits the slot (as on-time); the reach is capped at the PREVIOUS + * slot's `overdueEnd` so it can never claim a take that belongs to the + * prior dose's late tail. + */ +export const EARLY_GRACE_MS = 60 * 60 * 1000; + /** A slot window with its (capped) late-tail cutoff resolved. */ export interface SlotBand extends SlotWindowInput { /** Cutoff past which a take no longer pairs to this slot (capped). */ overdueEnd: Date; + /** + * Earliest instant a take still credits this slot (the bounded early + * grace before `onTimeStart`, capped at the previous slot's + * `overdueEnd`). + */ + earlyStart: Date; } export type AttributionStatus = "on_time" | "late"; @@ -59,7 +76,7 @@ export interface AttributionResult { */ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime()); - return sorted.map((slot, i) => { + const withTails = sorted.map((slot, i) => { let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs; const next = sorted[i + 1]; if (next) { @@ -71,6 +88,19 @@ export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] { overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime()); return { ...slot, overdueEnd: new Date(overdueEnd) }; }); + // Second pass — the early reach is bounded by the PREVIOUS slot's + // resolved tail, so it never claims a take inside the prior dose's + // band. The previous `overdueEnd` is already capped at this slot's + // `onTimeStart`, so `earlyStart <= onTimeStart` holds by construction. + return withTails.map((band, i) => { + let earlyStart = band.onTimeStart.getTime() - EARLY_GRACE_MS; + const prev = withTails[i - 1]; + if (prev) { + earlyStart = Math.max(earlyStart, prev.overdueEnd.getTime()); + } + earlyStart = Math.min(earlyStart, band.onTimeStart.getTime()); + return { ...band, earlyStart: new Date(earlyStart) }; + }); } /** @@ -90,7 +120,10 @@ export function attributeIntakeToSlot( null; for (const band of bands) { - const onStart = band.onTimeStart.getTime(); + // v1.16.9 — the bounded early grace extends the on-time capture below + // `onTimeStart` (never into the previous slot's band — `buildSlotBands` + // caps it). Hand-built bands without the field keep the strict start. + const onStart = (band.earlyStart ?? band.onTimeStart).getTime(); const onEnd = band.onTimeEnd.getTime(); const overdueEnd = band.overdueEnd.getTime(); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 4f646770a..21cb71e94 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -29,6 +29,63 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d */ const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; +/** + * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors + * `scheduledFor = takenAt` on its own instant, so it can only resolve a + * slot it actually sits on (sub-minute drift absorbed); letting it use + * the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved + * tonight's 20:00 dose while the ledger still counted that slot missed. + */ +const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; + +/** + * A row that resolves a slot (taken / deliberately skipped / + * cron-auto-missed), with its anchoring shape preserved. + * + * `slotAnchored: false` marks the ad-hoc shape (`scheduledFor === + * takenAt`): such a row resolves a slot only on a near-exact anchor + * match, never across the ±6h radius. Slot-anchored rows (the write + * paths snap their `scheduledFor` to the canonical slot instant) keep + * the defensive radius. + */ +export interface ResolvedSlotMark { + at: Date; + slotAnchored: boolean; +} + +/** + * Map a resolved intake row to its `ResolvedSlotMark`. The ad-hoc shape + * is detectable only for taken rows (`scheduledFor === takenAt` to the + * millisecond — the documented standalone-insert contract); skips and + * auto-misses anchor on their slot by construction. + */ +export function toResolvedSlotMark(row: { + scheduledFor: Date; + takenAt: Date | null; +}): ResolvedSlotMark { + return { + at: row.scheduledFor, + slotAnchored: + row.takenAt === null || + row.takenAt.getTime() !== row.scheduledFor.getTime(), + }; +} + +function buildIsResolved( + resolved: ResolvedSlotMark[], +): (slotAt: Date) => boolean { + return (slotAt: Date): boolean => { + const slot = slotAt.getTime(); + for (const r of resolved) { + const radius = r.slotAnchored + ? RESOLVE_RADIUS_MS + : ADHOC_RESOLVE_EPSILON_MS; + if (Math.abs(r.at.getTime() - slot) <= radius) return true; + } + return false; + }; +} + /** * v1.16.4 — how far back the open-overdue search mints bands. The widest * possible band reach is a weekly slot's on-time half-width plus its @@ -57,21 +114,15 @@ export function computeNextDueAt(input: { * these so a twice-daily med whose remaining slots today are all logged * advances to the next genuinely-open slot (tomorrow's first dose) instead * of re-surfacing a resolved present/past slot. Omit for the legacy - * purely-time-anchored next-due. + * purely-time-anchored next-due. v1.16.9 — each mark carries its + * anchoring shape; ad-hoc rows only resolve on a near-exact match. */ - resolvedSlots?: Date[]; + resolvedSlots?: ResolvedSlotMark[]; }): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const resolved = input.resolvedSlots ?? []; - const isResolved = (slotAt: Date): boolean => { - const slot = slotAt.getTime(); - for (const r of resolved) { - if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; - } - return false; - }; + const isResolved = buildIsResolved(input.resolvedSlots ?? []); const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); let earliest: Date | null = null; @@ -113,7 +164,7 @@ export interface ComputeDisplayDueInput { now: Date; userTz: string; lastIntakeAt: Date | null; - resolvedSlots?: Date[]; + resolvedSlots?: ResolvedSlotMark[]; /** * Floor of the CURRENT schedule era (the newest revision's `validUntil`), * when the medication has archived revisions. The overdue search mints @@ -148,14 +199,7 @@ function findOpenOverdueSlot(input: ComputeDisplayDueInput): Date | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const resolved = input.resolvedSlots ?? []; - const isResolved = (slotAt: Date): boolean => { - const slot = slotAt.getTime(); - for (const r of resolved) { - if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true; - } - return false; - }; + const isResolved = buildIsResolved(input.resolvedSlots ?? []); let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); if (input.eraStart && input.eraStart.getTime() > floor.getTime()) { diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 527026b53..2fba3b485 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -117,6 +117,18 @@ export interface RecurrenceContext { * Latest `MedicationIntakeEvent.takenAt` for this medication. * Used only by rolling schedules. Null when the user has never * logged an intake (rolling then anchors on `startsOn ?? createdAt`). + * + * Skip semantics (documented decision, v1.16.9): a deliberate SKIP + * does NOT advance the rolling anchor. A skipped row carries + * `takenAt: null`, so every `lastIntakeAt` feeder query + * (`takenAt: { not: null }`) excludes it by construction, and the + * next due stays `previous take + N` — the skipped dose keeps + * surfacing as due until a real take re-anchors the grid. Advancing + * the anchor from the skipped instant instead would silently shift + * the whole future grid off the user's established rhythm; if that + * trade-off is ever revisited, every `lastIntakeAt` feeder (the + * projector, the reminder worker, the list route, the write-path + * resolvers, the dedup pass) must change together. */ lastIntakeAt: Date | null; } diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index a0df2d2c8..f50e55ac0 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -54,8 +54,13 @@ export interface ApplyCanonicalSlotWriteInput { isExplicitTaken: boolean; isExplicitSkip: boolean; idempotencyKey: string | null; - /** Source to stamp on a freshly-created slot row. */ - createSource: "WEB" | "API"; + /** + * Source to stamp on a freshly-created slot row. `REMINDER` is the + * Telegram-webhook path — its take / skip confirmations converge onto + * the worker-minted pending row like every other write, and a fresh + * create keeps the historical reminder provenance. + */ + createSource: "WEB" | "API" | "REMINDER"; /** * v1.8.5 — resolved + server-validated injection site to persist on a * taken write. `null` = no site (the column stays / is set NULL). Only diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index d97e2f2f8..22a3f0230 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -87,6 +87,16 @@ export interface CurrentWindowStatus { schedule: Schedule | null; /** Non-null exactly when `status` is non-null. */ window: MatchedDoseWindow | null; + /** + * v1.16.9 — true when the matched schedule is a DAY-SCALE cadence + * (weekly / N-weekly injectable) and a recent actioned take exists + * inside the current cadence period but on an earlier day (an early + * shot the band attribution recorded ad-hoc). The dose is already on + * board: the card must render taken-early context instead of the + * "take now" / overdue prompt — a full take prompt on the slot day is + * a double-dose prompt. Always false when `status` is null. + */ + takenEarly: boolean; } /** @@ -303,11 +313,25 @@ function getScheduleStatus( return best; } +/** + * v1.16.9 — mirror of the attribution module's bounded early grace: a + * take slightly before the on-time band still credits the slot, so the + * pill suppression must accept it too (an 08:42 take for a 09:00-window + * slot must not leave "take now" burning at 09:05). + */ +const EARLY_GRACE_MINUTES = 60; + function isLastIntakeInBand( lastTakenAt: string | null, band: DoseWindowBounds, nowLocal: Date, tz: string, + /** + * Lower bound (minutes-of-day) the early grace may not cross — the end + * of the schedule's previous dose band, so a take belonging to the + * prior dose never suppresses this one. Defaults to 0 (no earlier band). + */ + earlyFloorMins = 0, ): boolean { if (!lastTakenAt) return false; @@ -330,7 +354,77 @@ function isLastIntakeInBand( ? intakeMins + 24 * 60 : intakeMins; - return adjustedIntake >= band.startMins && adjustedIntake <= band.endMins; + const startWithGrace = Math.max( + band.startMins - EARLY_GRACE_MINUTES, + earlyFloorMins, + 0, + ); + return adjustedIntake >= startWithGrace && adjustedIntake <= band.endMins; +} + +/** + * v1.16.9 — day-scale cadence detection for the taken-early downgrade. + * + * A schedule whose dose days sit ≥ 2 days apart (a single weekday, a + * sparse Mo/Th pattern, an N-weekly interval) is day-scale: one dose + * covers a multi-day period, so a take earlier in the period must inform + * the slot-day pill. Daily / near-daily patterns (minute-scale) return + * `dayScale: false` — their period is a day and the same-day band checks + * already cover them. `periodDays` is the smallest gap between + * consecutive dose days (the span one dose covers). + */ +function dayScaleCadence(daysOfWeek: string | null): { + dayScale: boolean; + periodDays: number; +} { + const recurrence = parseScheduleRecurrence(daysOfWeek); + const days = [...new Set(recurrence.daysOfWeek)].sort((a, b) => a - b); + if (days.length === 0) return { dayScale: false, periodDays: 0 }; + const intervalWeeks = Math.max(1, recurrence.intervalWeeks ?? 1); + if (days.length === 1) { + return { dayScale: true, periodDays: 7 * intervalWeeks }; + } + let minGap = Infinity; + for (let i = 1; i < days.length; i++) { + minGap = Math.min(minGap, days[i] - days[i - 1]); + } + // Wrap from the week's last dose day to the next cycle's first. + const wrapGap = days[0] + 7 * intervalWeeks - days[days.length - 1]; + minGap = Math.min(minGap, wrapGap); + return { dayScale: minGap >= 2, periodDays: minGap }; +} + +/** + * True when the last actioned take lies on an EARLIER local day inside + * the current cadence period — the early-shot shape the taken-early + * downgrade fires on. A same-day take is the in-band suppression's job; + * a take a full period (or more) ago is the previous cycle's dose. + */ +function hasEarlyTakeInPeriod( + lastTakenAt: string | null, + nowLocal: Date, + tz: string, + periodDays: number, +): boolean { + if (!lastTakenAt || periodDays < 2) return false; + const intake = toZonedDate(new Date(lastTakenAt), tz); + const dayMs = 24 * 60 * 60 * 1000; + const intakeDay = Math.floor( + new Date( + intake.getFullYear(), + intake.getMonth(), + intake.getDate(), + ).getTime() / dayMs, + ); + const nowDay = Math.floor( + new Date( + nowLocal.getFullYear(), + nowLocal.getMonth(), + nowLocal.getDate(), + ).getTime() / dayMs, + ); + const diff = nowDay - intakeDay; + return diff >= 1 && diff < periodDays; } /** @@ -396,6 +490,13 @@ export function reduceCurrentWindowStatus( missedMinutes: number; active: boolean; lastTakenAt: string | null; + /** + * v1.16.9 — number of ACTIONED intake events today (taken or + * explicitly skipped). The server list feeder counts only actioned + * rows: the dashboard projector mints a pending row for every slot of + * the day, so an all-rows count covered every passed dose after any + * dashboard visit and the overdue pill went dark nondeterministically. + */ todayEventCount: number; /** IANA timezone matching `nowBerlin`'s conversion; defaults to Berlin. */ tz?: string; @@ -430,6 +531,7 @@ export function reduceCurrentWindowStatus( status: null, schedule: null, window: null, + takenEarly: false, }; if (!active) return none; if (nextDue === null) return none; @@ -465,14 +567,33 @@ export function reduceCurrentWindowStatus( // intake events for the day. if (hit.status !== "in_window" && !hasUncoveredOverdue) return best; // Don't show in_window if last intake is already within this band - // today. + // today (including the bounded early grace, floored at the end of + // the schedule's previous dose band so a prior dose's take never + // suppresses this one). + const earlyFloorMins = resolveDoseWindows(s).reduce( + (floor, b) => + b.endMins <= hit.band.startMins && b.endMins > floor + ? b.endMins + : floor, + 0, + ); if ( hit.status === "in_window" && - isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz) + isLastIntakeInBand(lastTakenAt, hit.band, nowBerlin, tz, earlyFloorMins) ) { return best; } if (!best.status || STATUS_PRIORITY[hit.status] > STATUS_PRIORITY[best.status]) { + // v1.16.9 — day-scale early-take downgrade: a weekly injectable + // taken days before its slot day is already on board, so the pill + // must carry taken-early context instead of prompting a full take + // (a "Jetzt einnehmen" / overdue prompt here is a double-dose + // prompt). The server next-due stays canonical — only the pill's + // framing changes. + const cadence = dayScaleCadence(s.daysOfWeek); + const takenEarly = + cadence.dayScale && + hasEarlyTakeInPeriod(lastTakenAt, nowBerlin, tz, cadence.periodDays); return { status: hit.status, schedule: s, @@ -481,6 +602,7 @@ export function reduceCurrentWindowStatus( start: hit.band.startHm, end: hit.band.endHm, }, + takenEarly, }; } return best; diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts index fbbde4171..2bec317a1 100644 --- a/src/lib/openapi/routes/medications.ts +++ b/src/lib/openapi/routes/medications.ts @@ -217,7 +217,7 @@ const medicationListEntry = medicationResource .int() .nonnegative() .describe( - "Number of intake events scheduled for today (user-local day window).", + "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", ), }) .meta({ @@ -619,7 +619,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "List medications for the calling user", description: - "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", + "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", responses: { "200": { description: "Medication list.", diff --git a/src/lib/validations/__tests__/medication-intake-event.test.ts b/src/lib/validations/__tests__/medication-intake-event.test.ts index 402fe7110..25930c729 100644 --- a/src/lib/validations/__tests__/medication-intake-event.test.ts +++ b/src/lib/validations/__tests__/medication-intake-event.test.ts @@ -11,7 +11,11 @@ */ import { describe, expect, it } from "vitest"; -import { updateIntakeEventSchema } from "@/lib/validations/medication"; +import { + externalIntakeSchema, + intakeSchema, + updateIntakeEventSchema, +} from "@/lib/validations/medication"; const HOUR_MS = 60 * 60 * 1000; const YEAR_MS = 365 * 24 * HOUR_MS; @@ -74,3 +78,70 @@ describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => { expect(result.success).toBe(true); }); }); + +/** + * v1.16.9 — the same bounds on every CREATE path. The retro-add dialog + * posts through `intakeSchema`, the iOS sync through the bulk entry + * shape, external integrations through `externalIntakeSchema`; none of + * them may insert a row the edit path would refuse. + */ +describe("intakeSchema — takenAt bounds on create (v1.16.9)", () => { + it("rejects a future takenAt beyond the skew allowance", () => { + const result = intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() + HOUR_MS).toISOString(), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("future"); + } + }); + + it("rejects a takenAt more than 5 years in the past", () => { + const result = intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("5 years"); + } + }); + + it("accepts a recent takenAt and a body that omits it", () => { + expect( + intakeSchema.safeParse({ + medicationId: "med-1", + takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(), + }).success, + ).toBe(true); + expect(intakeSchema.safeParse({ medicationId: "med-1" }).success).toBe( + true, + ); + }); +}); + +describe("externalIntakeSchema — takenAt bounds on ingest (v1.16.9)", () => { + it("rejects future and implausibly-old instants, accepts recent ones", () => { + const future = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-1", + takenAt: new Date(Date.now() + HOUR_MS).toISOString(), + }); + expect(future.success).toBe(false); + + const ancient = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-2", + takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(), + }); + expect(ancient.success).toBe(false); + + const ok = externalIntakeSchema.safeParse({ + medicationName: "Metformin", + idempotencyKey: "k-3", + takenAt: new Date(Date.now() - HOUR_MS).toISOString(), + }); + expect(ok.success).toBe(true); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 03bda0bc9..1c9213675 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -163,6 +163,30 @@ function hhmmToMinutes(hhmm: string): number { return Number(h) * 60 + Number(m); } +/** + * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path + * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the + * per-medication intake POST, the bulk endpoint, the external ingest) so + * the retro-add dialog and a hand-rolled API call can't insert a row a + * decade in the past or hours in the future. Future allowance covers + * client clock skew; the 5-year floor matches the GLP-1 dose-change + * validator's window. Slot-distance / medication-start checks stay in + * the routes — the schema cannot see the medication. + */ +export const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; +export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** ISO `takenAt` → `Date` with the shared plausibility bounds applied. */ +export const boundedTakenAtSchema = z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { + message: "takenAt must not be in the future", + }) + .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { + message: "takenAt must be within the last 5 years", + }); + export const scheduleSchema = z .object({ windowStart: z @@ -598,12 +622,10 @@ export const intakeSchema = z .describe( "Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.", ), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) + takenAt: boundedTakenAtSchema .optional() .describe( - "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes.", + "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.", ), skipped: z .boolean() @@ -673,10 +695,8 @@ export const intakeSchema = z export const externalIntakeSchema = z.object({ medicationName: z.string().min(1).max(200), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), + // v1.16.9 — same plausibility bounds as the interactive create paths. + takenAt: boundedTakenAtSchema.optional(), idempotencyKey: z.string().max(128), }); @@ -718,22 +738,9 @@ export const listIntakeEventsSchema = z.object({ * checks stay out of the schema — it cannot see the medication — and live * in the route (start-date guard) + the edit dialog (non-blocking hint). */ -const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000; -const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; - export const updateIntakeEventSchema = z .object({ - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, { - message: "takenAt must not be in the future", - }) - .refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, { - message: "takenAt must be within the last 5 years", - }) - .nullable() - .optional(), + takenAt: boundedTakenAtSchema.nullable().optional(), skipped: z.boolean().optional(), scheduledFor: z.iso .datetime({ offset: true })