diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6243f7b3f..a82bb99ed 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -27456,9 +27456,13 @@ components: - type: "null" anchorDate: anyOf: - - 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)))$ + - anyOf: + - type: string + format: date + 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])))$ + - 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)))$ - type: "null" notifyHour: default: 9 @@ -27530,9 +27534,13 @@ components: - type: "null" anchorDate: anyOf: - - 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)))$ + - anyOf: + - type: string + format: date + 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])))$ + - 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)))$ - type: "null" notifyHour: type: integer diff --git a/messages/de.json b/messages/de.json index 8b6523d6b..3f6b58b75 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8085,7 +8085,8 @@ "bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).", "bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.", "bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.", - "bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert." + "bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert.", + "bodyAsNeeded": "Von {medName} sind nur noch {units} Einheiten übrig. Bestelle nach, bevor du die nächste Dosis brauchst." }, "moodReminders": { "dailyTitle": "Stimmung erfassen", diff --git a/messages/en.json b/messages/en.json index 8eeaf9ad7..324f73f5e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8085,7 +8085,8 @@ "bodyToday": "{medName}: {units} units left — less than one day of supply.", "bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.", "bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.", - "bodyExpired": "{medName}: no usable supply left. {units} units are marked expired." + "bodyExpired": "{medName}: no usable supply left. {units} units are marked expired.", + "bodyAsNeeded": "{medName}: only {units} units left. Reorder before you need the next dose." }, "moodReminders": { "dailyTitle": "Log your mood", diff --git a/messages/es.json b/messages/es.json index 193dd1fea..e2eecfc72 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8085,7 +8085,8 @@ "bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.", "bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.", "bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.", - "bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas." + "bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas.", + "bodyAsNeeded": "{medName}: solo quedan {units} unidades. Vuelve a pedir antes de necesitar la próxima dosis." }, "moodReminders": { "dailyTitle": "Registrar el ánimo", diff --git a/messages/fr.json b/messages/fr.json index 2fe9be017..c1a90a9fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8085,7 +8085,8 @@ "bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.", "bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.", "bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.", - "bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées." + "bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées.", + "bodyAsNeeded": "{medName} : plus que {units} unités. Recommandez avant d’avoir besoin de la prochaine dose." }, "moodReminders": { "dailyTitle": "Enregistrer votre humeur", diff --git a/messages/it.json b/messages/it.json index 4f527a583..6ec3c1592 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8085,7 +8085,8 @@ "bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.", "bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.", "bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.", - "bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute." + "bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute.", + "bodyAsNeeded": "{medName}: restano solo {units} unità. Riordina prima di aver bisogno della prossima dose." }, "moodReminders": { "dailyTitle": "Registra il tuo umore", diff --git a/messages/ko.json b/messages/ko.json index 5e80dc3b9..afd335255 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8085,7 +8085,8 @@ "bodyToday": "{medName}: {units}개 남았어요 — 하루치도 안 돼요.", "bodyReorder": "{medName}: {units}개 남았어요. {runsOutOn}쯤 소진돼요 — {reorderBy}까지 재주문하세요.", "bodyLastDose": "{medName}: {units}개 남았어요 — 마지막 복용분이에요. {runsOutOn}쯤 소진돼요.", - "bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요." + "bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요.", + "bodyAsNeeded": "{medName}: {units}개만 남았어요. 다음에 필요하기 전에 재주문하세요." }, "moodReminders": { "dailyTitle": "기분 기록하기", diff --git a/messages/pl.json b/messages/pl.json index f04de5400..4d6ab0504 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8085,7 +8085,8 @@ "bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.", "bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.", "bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.", - "bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane." + "bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane.", + "bodyAsNeeded": "{medName}: zostało tylko {units} jednostek. Zamów ponownie, zanim będziesz potrzebować kolejnej dawki." }, "moodReminders": { "dailyTitle": "Zapisz nastrój", diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 2a1939e08..b24339aa7 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -167,9 +167,20 @@ export const GET = apiHandler(async () => { a.username.localeCompare(b.username), ); + // A wipe whose copies are already gone waits for the next nightly run + // before its request is removed (see `offhost-purge.ts`); nothing of it is + // left in the bucket, so it is not counted as a pending deletion. + const stillInBucket = { + NOT: { + reason: "data_wiped", + lastFailure: null, + lastAttemptAt: { not: null }, + }, + }; const [purgeCount, oldestPurge, lifecycle] = await Promise.all([ - prisma.offhostPurgeRequest.count(), + prisma.offhostPurgeRequest.count({ where: stillInBucket }), prisma.offhostPurgeRequest.findFirst({ + where: stillInBucket, orderBy: { requestedAt: "asc" }, select: { requestedAt: true, lastFailure: true }, }), diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index 0d2761b00..3ca886bea 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -227,7 +227,11 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => { it("surfaces an externalId row as updated when it already existed (updateMany)", async () => { mMeasurement().findMany.mockResolvedValue([ - { type: "WEIGHT", externalId: "ext-1" }, + { + type: "WEIGHT", + externalId: "ext-1", + measuredAt: new Date("2026-05-01T08:00:00Z"), + }, ]); const res = await POST( @@ -400,7 +404,11 @@ describe("POST /api/import/csv — contextless blood glucose", () => { it("keeps a re-upload of the same external id idempotent", async () => { // The key already exists under (userId, type, source=IMPORT, externalId). mMeasurement().findMany.mockResolvedValue([ - { type: "BLOOD_GLUCOSE", externalId: "sensor-1" }, + { + type: "BLOOD_GLUCOSE", + externalId: "sensor-1", + measuredAt: new Date("2024-04-03T02:15:00Z"), + }, ]); const res = await POST( @@ -513,6 +521,47 @@ describe("POST /api/import/csv — re-import", () => { expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1); }); + it("re-folds the day a moved reading left as well as the day it moved to", async () => { + mMeasurement().findMany.mockResolvedValue([stored("ext-1")]); + await POST( + csvRequest( + [HEADER, "WEIGHT,80.5,kg,2026-05-03T08:00:00Z,,after run,ext-1"].join( + "\n", + ), + ), + ); + const days = vi + .mocked(recomputeBucketsForMeasurement) + .mock.calls.map( + ([, type, at]) => `${type}@${at.toISOString().slice(0, 10)}`, + ) + .sort(); + expect(days).toEqual(["WEIGHT@2026-05-01", "WEIGHT@2026-05-03"]); + }); + + it("re-folds what it touched when a write chunk fails part-way", async () => { + mMeasurement().findMany.mockResolvedValue([]); + mMeasurement().createManyAndReturn.mockRejectedValue( + new Error("connection reset"), + ); + await expect( + POST( + csvRequest( + [ + HEADER, + "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,", + "WEIGHT,80.7,kg,2026-05-02T08:00:00Z,,,", + ].join("\n"), + ), + ), + ).rejects.toThrow("connection reset"); + const days = vi + .mocked(recomputeBucketsForMeasurement) + .mock.calls.map(([, , at]) => at.toISOString().slice(0, 10)) + .sort(); + expect(days).toEqual(["2026-05-01", "2026-05-02"]); + }); + it("probes per type in bounded chunks and writes updates in bounded batches", async () => { const lines = Array.from({ length: 2500 }, (_, i) => { const at = new Date(Date.UTC(2026, 0, 1) + i * 60_000).toISOString(); diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 369599d4b..37aafbf5e 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -284,6 +284,15 @@ export const POST = apiHandler(async (request: NextRequest) => { type: m.type as MeasurementType, measuredAt: m.measuredAt, }); + // An update that moves the reading to another instant leaves the day + // it came from: that day is re-folded too, or the old day keeps a + // reading that no longer exists there. + if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) { + touchedMeasurements.push({ + type: m.type as MeasurementType, + measuredAt: stored.measuredAt, + }); + } } // --- externalId-less rows: create-only, deduped on the natural unique key @@ -353,83 +362,90 @@ export const POST = apiHandler(async (request: NextRequest) => { type: MeasurementType; measuredAt: Date; }> = []; - for (const chunk of chunked(toCreate, WRITE_CHUNK)) { - const rows = await prisma.measurement.createManyAndReturn({ - data: chunk, - skipDuplicates: true, - select: { id: true, type: true, measuredAt: true }, - }); - createdRows.push(...rows); - createdCount += rows.length; - } - for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) { - await prisma.$transaction( - chunk.map((m) => - prisma.measurement.updateMany({ - where: { - userId, - source: "IMPORT", - type: m.type as MeasurementType, - externalId: m.externalId as string, - }, - data: { - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - glucoseContext: - (m.glucoseContext as GlucoseContext | undefined) ?? null, - // No-op on a live row, a deliberate RESURRECTION on a - // tombstoned one — IMPORT rows are re-importable by design, so - // a re-imported externalId brings the row back (mirrors the - // source-owned sync resurrect rule). - deletedAt: null, - }, - }), - ), - ); - } + // The re-fold runs whatever happens to the writes. A chunk that fails + // part-way leaves the earlier chunks written, and a retry reads those + // rows as duplicates and touches nothing: without the re-fold here their + // days would keep the rollups from before the import for good. Re-folding + // a (type, day) nothing landed on is harmless. + try { + for (const chunk of chunked(toCreate, WRITE_CHUNK)) { + const rows = await prisma.measurement.createManyAndReturn({ + data: chunk, + skipDuplicates: true, + select: { id: true, type: true, measuredAt: true }, + }); + createdRows.push(...rows); + createdCount += rows.length; + } + for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) { + await prisma.$transaction( + chunk.map((m) => + prisma.measurement.updateMany({ + where: { + userId, + source: "IMPORT", + type: m.type as MeasurementType, + externalId: m.externalId as string, + }, + data: { + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + notes: null, + notesEncrypted: encryptNote(m.notes ?? null), + glucoseContext: + (m.glucoseContext as GlucoseContext | undefined) ?? null, + // No-op on a live row, a deliberate RESURRECTION on a + // tombstoned one — IMPORT rows are re-importable by design, so + // a re-imported externalId brings the row back (mirrors the + // source-owned sync resurrect rule). + deletedAt: null, + }, + }), + ), + ); + } - void emitInsertedMeasurementArrivals( - userId, - createdRows, - "csv_import", - ).catch(() => {}); - void maybeEnqueueMorningRefresh( - userId, - createdRows - .filter((row) => row.type === "SLEEP_DURATION") - .map((row) => row.measuredAt), - ).catch(() => {}); + void emitInsertedMeasurementArrivals( + userId, + createdRows, + "csv_import", + ).catch(() => {}); + void maybeEnqueueMorningRefresh( + userId, + createdRows + .filter((row) => row.type === "SLEEP_DURATION") + .map((row) => row.measuredAt), + ).catch(() => {}); - // Reconcile `inserted` against what `createMany` actually wrote - // (mirrors the batch route's raced-duplicate downgrade). Under - // `skipDuplicates` a conflicting row is silently absorbed — the key is - // present in the table either way, so we cannot identify the SPECIFIC - // raced lines; we only need the counters and per-line statuses to sum - // to the truth. Downgrade enough `inserted` lines to `duplicate` so - // the envelope matches the DB write count. - const racedDuplicates = toCreate.length - createdCount; - if (racedDuplicates > 0) { - let downgraded = 0; - for (const [line, outcome] of writeOutcome) { - if (downgraded >= racedDuplicates) break; - if (outcome === "inserted") { - writeOutcome.set(line, "duplicate"); - inserted--; - skipped++; - downgraded++; + // Reconcile `inserted` against what `createMany` actually wrote + // (mirrors the batch route's raced-duplicate downgrade). Under + // `skipDuplicates` a conflicting row is silently absorbed — the key is + // present in the table either way, so we cannot identify the SPECIFIC + // raced lines; we only need the counters and per-line statuses to sum + // to the truth. Downgrade enough `inserted` lines to `duplicate` so + // the envelope matches the DB write count. + const racedDuplicates = toCreate.length - createdCount; + if (racedDuplicates > 0) { + let downgraded = 0; + for (const [line, outcome] of writeOutcome) { + if (downgraded >= racedDuplicates) break; + if (outcome === "inserted") { + writeOutcome.set(line, "duplicate"); + inserted--; + skipped++; + downgraded++; + } } } + } finally { + // One bounded rollup re-fold per touched (type, day) — a 10 000-row + // CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks + // — and the status re-warm for the touched types. Best-effort: a + // populator hiccup never fails the importer. + await afterMeasurementMutation(userId, touchedMeasurements, "import.csv"); } - // One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV - // pays at most ~N (type, day) recomputes, not 10 000 per-row hooks — and - // the status re-warm for the touched types. Best-effort: a populator - // hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import.csv"); - await auditLog("import.csv.upload", { userId, ipAddress: getClientIp(request), diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 39228567a..9922ff88c 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -29,6 +29,7 @@ import { annotate } from "@/lib/logging/context"; import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { computeReminderNextDueAt, + parseReminderAnchor, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; @@ -141,9 +142,12 @@ export const PATCH = apiHandler( updateData.intervalDays = null; } } + const timezone = await resolveTimezone(user.id); if (data.anchorDate !== undefined) { updateData.anchorDate = - data.anchorDate != null ? new Date(data.anchorDate) : null; + data.anchorDate != null + ? parseReminderAnchor(data.anchorDate, timezone) + : null; } if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour; if (data.location !== undefined) updateData.location = data.location; @@ -166,7 +170,6 @@ export const PATCH = apiHandler( // auto-clear a few lines up — so keying off its own keys (present, even // when the value is `null`) can never diverge from what the database // ends up holding. - const timezone = await resolveTimezone(user.id); const now = new Date(); const merged: ReminderScheduleInput = { intervalDays: Object.hasOwn(updateData, "intervalDays") @@ -193,10 +196,10 @@ export const PATCH = apiHandler( // open, overdue check-up quietly moved it to its next slot. Compared by // value, because a client may resend every field on each save. // - // The first due date is compared by CALENDAR DAY in the profile zone. The - // web form sends a date as the browser's local midnight, and a booster - // row stores the dose instant plus N days, so the same day arrives as a - // different instant. A resent same-day anchor is dropped from the write + // The first due date is compared by CALENDAR DAY in the profile zone. A + // client may send a date-time (older web builds sent the browser's local + // midnight), and a booster row stores the dose instant plus N days, so the + // same day arrives as a different instant. A resent same-day anchor is dropped from the write // altogether, so the stored instant stays what it was. const sameInstant = (a: Date | null, b: Date | null) => (a?.getTime() ?? null) === (b?.getTime() ?? null); diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 06fe23443..150bbe793 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -21,6 +21,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { computeReminderNextDueAt, + parseReminderAnchor, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; @@ -88,7 +89,10 @@ async function postReminder(request: NextRequest): Promise { const timezone = await resolveTimezone(user.id); const now = new Date(); - const anchorDate = data.anchorDate != null ? new Date(data.anchorDate) : null; + const anchorDate = + data.anchorDate != null + ? parseReminderAnchor(data.anchorDate, timezone) + : null; // Server-authoritative next-due. No satisfy yet → anchors on // anchorDate ?? createdAt (createdAt ≈ now for a fresh row). diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 1674b2457..f1085e2c5 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -181,11 +181,13 @@ function toDateInputValue(iso: string | null): string { return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10); } -/** `` value → an ISO instant at local midnight, or null. */ +/** + * `` value → the calendar date itself, or null. The server + * reads a bare date as that day in the profile's zone, so a browser set to + * another zone cannot move the first-due day. + */ function fromDateInputValue(value: string): string | null { - if (!value) return null; - const d = new Date(`${value}T00:00:00`); - return Number.isNaN(d.getTime()) ? null : d.toISOString(); + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; } interface FormState { diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index 90c861570..25513acc4 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -394,6 +394,26 @@ describe("estimateDailyDoseCount — cadence parts the rate used to drop", () => ).toBeCloseTo(2 / 30, 10); }); + it.each([ + // Every Monday of the month is a weekly dose, not one a month. + ["FREQ=MONTHLY;BYDAY=MO", 1 / 7], + ["FREQ=MONTHLY;BYDAY=MO,TH", 2 / 7], + // With an ordinal it is one day of the month. + ["FREQ=MONTHLY;BYDAY=1MO", 1 / 30], + ["FREQ=MONTHLY;BYDAY=1MO,-1FR", 2 / 30], + // The last weekday of the month: BYSETPOS picks one of the five. + ["FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", 1 / 30], + ["FREQ=MONTHLY;INTERVAL=2;BYDAY=MO", 1 / 14], + // Every Monday in January, and the first Monday of March and September. + ["FREQ=YEARLY;BYMONTH=1;BYDAY=MO", 30 / 7 / 365], + ["FREQ=YEARLY;BYMONTH=3,9;BYDAY=1MO", 2 / 365], + ])("counts the occurrences of %s", (rrule, perDay) => { + expect(estimateDailyDoseCount([schedule({ rrule })])).toBeCloseTo( + perDay, + 10, + ); + }); + it("stretches a daily RRULE by its INTERVAL (every other day)", () => { expect( estimateDailyDoseCount([schedule({ rrule: "FREQ=DAILY;INTERVAL=2" })]), diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index 1041acb0e..c264ab807 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -51,6 +51,38 @@ function rruleListCount(rrule: string, key: string): number { return m ? m[1].split(",").filter((v) => v.trim() !== "").length : 0; } +/** The raw values of an RRULE list part (`BYDAY=1MO,FR` → `1MO`, `FR`). */ +function rruleList(rrule: string, key: string): string[] { + const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule); + return m ? m[1].split(",").filter((v) => v.trim() !== "") : []; +} + +/** + * Occurrences one period of a monthly or yearly rule holds, with the period + * `periodDays` long. `BYSETPOS` picks that many of the candidates. A + * `BYDAY` entry with an ordinal (`1MO`, `-1FR`) is one day of the period; + * one without (`MO`) is every such weekday in it, about `periodDays / 7`. + * Counting `FREQ=MONTHLY;BYDAY=MO` as one dose a month made a Monday + * medication's supply read four times longer than it lasts. + */ +function occurrencesPerPeriod( + rrule: string, + periodDays: number, + fallback: number, +): number { + const setPos = rruleListCount(rrule, "BYSETPOS"); + if (setPos > 0) return setPos; + const monthDays = rruleListCount(rrule, "BYMONTHDAY"); + if (monthDays > 0) return monthDays; + const byDay = rruleList(rrule, "BYDAY"); + if (byDay.length === 0) return fallback; + let count = 0; + for (const entry of byDay) { + count += /^[+-]?\d/.test(entry.trim()) ? 1 : periodDays / 7; + } + return count; +} + /** * Approximate doses per day for ONE schedule: times-of-day count, scaled * down by the cadence (rolling interval, weekly day picks + interval @@ -82,14 +114,17 @@ function cadenceDailyDoseCount(s: RunwaySchedule): number { return times / interval; } if (/FREQ=MONTHLY/i.test(rrule)) { - const perMonth = Math.max( - 1, - rruleListCount(rrule, "BYMONTHDAY") || rruleListCount(rrule, "BYDAY"), - ); + const perMonth = occurrencesPerPeriod(rrule, 30, 1); return (times * perMonth) / (30 * interval); } if (/FREQ=YEARLY/i.test(rrule)) { - const perYear = Math.max(1, rruleListCount(rrule, "BYMONTH")); + // Within each BYMONTH month (or the year, without one), the day parts + // count as they would for a monthly rule. + const months = rruleListCount(rrule, "BYMONTH"); + const perYear = + months > 0 + ? months * occurrencesPerPeriod(rrule, 30, 1) + : occurrencesPerPeriod(rrule, 365, 1); return (times * perYear) / (365 * interval); } // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the diff --git a/src/lib/analytics/__tests__/range-delta-per-period.test.ts b/src/lib/analytics/__tests__/range-delta-per-period.test.ts index 445aba5bc..b16f32343 100644 --- a/src/lib/analytics/__tests__/range-delta-per-period.test.ts +++ b/src/lib/analytics/__tests__/range-delta-per-period.test.ts @@ -22,7 +22,11 @@ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(async () => "UTC"), })); -import { computeRangeDelta } from "@/lib/analytics/range-delta"; +import { + computeRangeDelta, + dailyTotalsEndKey, + splitDailyTotals, +} from "@/lib/analytics/range-delta"; const DAY_MS = 86_400_000; const NOW = Date.UTC(2026, 5, 30, 12); @@ -49,8 +53,9 @@ beforeEach(() => { describe("computeRangeDelta — per-day and per-night metrics", () => { it("compares average daily step totals, not the mean of the readings", async () => { - // Six complete days of 8,000 in the previous week, and in the current - // week five drained days plus today still arriving as 200 samples. + // Six complete days of 8,000 in the previous week and five in the + // current one. Today is still arriving as 200 small samples and must not + // count: its partial total made every morning's delta read "down". mocks.readRollupBuckets.mockResolvedValue([ ...[8, 9, 10, 11, 12, 13].map((d) => dayRow(d, 8000, 1)), ...[1, 2, 3, 4, 5].map((d) => dayRow(d, 8000, 1)), @@ -61,9 +66,38 @@ describe("computeRangeDelta — per-day and per-night metrics", () => { expect(result.granularity).toBe("DAY"); expect(result.previous).toMatchObject({ count: 6, mean: 8000 }); - expect(result.current.count).toBe(6); - expect(result.current.mean).toBeCloseTo((5 * 8000 + 4000) / 6, 6); - expect(result.current.sum).toBeCloseTo(44_000, 6); + expect(result.current).toMatchObject({ count: 5, mean: 8000, sum: 40_000 }); + expect(result.delta).toBe(0); + const [, , , from, to] = mocks.readRollupBuckets.mock.calls[0]!; + expect((from as Date).toISOString()).toBe("2026-06-16T00:00:00.000Z"); + expect((to as Date).toISOString()).toBe("2026-06-30T00:00:00.000Z"); + }); + + it.each([ + // West of UTC in the evening: the UTC day already rolled over, the + // user's calendar did not. + ["America/New_York", "2026-07-01T01:00:00.000Z", "2026-06-29"], + // East of UTC in the early morning: the bucket keyed yesterday is still + // filling until UTC midnight. + ["Asia/Tokyo", "2026-06-29T20:00:00.000Z", "2026-06-28"], + ["Europe/Berlin", "2026-06-30T06:00:00.000Z", "2026-06-29"], + ["UTC", "2026-06-30T12:00:00.000Z", "2026-06-29"], + ])("ends the daily-total window on a completed day in %s", (tz, iso, end) => { + expect(dailyTotalsEndKey(Date.parse(iso), tz)).toBe(end); + }); + + it("splits daily totals on day keys ending at the end key", () => { + const points = [ + { day: "2026-06-30", value: 100 }, + { day: "2026-06-29", value: 9000 }, + { day: "2026-06-23", value: 9000 }, + { day: "2026-06-22", value: 7000 }, + { day: "2026-06-16", value: 7000 }, + { day: "2026-06-15", value: 1 }, + ]; + const { current, previous } = splitDailyTotals(points, 7, "2026-06-29"); + expect(current).toMatchObject({ count: 2, mean: 9000 }); + expect(previous).toMatchObject({ count: 2, mean: 7000 }); }); it("compares average nights of sleep, not the mean of stage rows", async () => { diff --git a/src/lib/analytics/range-delta.ts b/src/lib/analytics/range-delta.ts index d2673064c..eba1c4abe 100644 --- a/src/lib/analytics/range-delta.ts +++ b/src/lib/analytics/range-delta.ts @@ -51,7 +51,7 @@ import { readBestGranularityRollups, type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; -import { startOfUtcDay } from "@/lib/tz/start-of-utc-day"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { ANALYTICS_RANGES, @@ -146,21 +146,62 @@ function splitPerPeriod( }; } +/** + * The last day a daily total may count for: yesterday on the user's + * calendar, and never a UTC day bucket that has not ended yet. The DAY + * rollups are UTC buckets; east of UTC the bucket keyed "yesterday" is still + * filling until UTC midnight, so the earlier of the two keys wins. Counting + * today's still-growing total made every delta read "down" in the morning. + */ +export function dailyTotalsEndKey(now: number, timezone: string): string { + const localYesterday = shiftDateKey(userDayKey(new Date(now), timezone), -1); + const utcYesterday = shiftDateKey( + new Date(now).toISOString().slice(0, 10), + -1, + ); + return localYesterday < utcYesterday ? localYesterday : utcYesterday; +} + +/** + * Split one-per-day totals into the `windowDays` completed days ending + * `endKey` (current) and the `windowDays` days before them (previous). + */ +export function splitDailyTotals( + points: ReadonlyArray<{ day: string; value: number }>, + windowDays: number, + endKey: string, +): { current: WindowAggregate; previous: WindowAggregate } { + const currentStart = shiftDateKey(endKey, -(windowDays - 1)); + const previousStart = shiftDateKey(endKey, -(2 * windowDays - 1)); + const current: number[] = []; + const previous: number[] = []; + for (const p of points) { + if (p.day > endKey) continue; + if (p.day >= currentStart) current.push(p.value); + else if (p.day >= previousStart) previous.push(p.value); + } + return { + current: aggregatePerPeriod(current), + previous: aggregatePerPeriod(previous), + }; +} + /** The canonical daily totals of a step-like metric, one point per day. */ async function readDailyTotals( userId: string, type: MeasurementType, windowDays: number, - now: number, -): Promise> { - const rows = await readRollupBuckets( - userId, - type, - "DAY", - startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)), - new Date(now), + endKey: string, +): Promise> { + const from = new Date( + `${shiftDateKey(endKey, -(2 * windowDays - 1))}T00:00:00.000Z`, ); - return rows.map((r) => ({ at: r.bucketStart, value: r.mean * r.count })); + const to = new Date(`${shiftDateKey(endKey, 1)}T00:00:00.000Z`); + const rows = await readRollupBuckets(userId, type, "DAY", from, to); + return rows.map((r) => ({ + day: r.bucketStart.toISOString().slice(0, 10), + value: r.mean * r.count, + })); } /** Time asleep per reconstructed night, one point per night. */ @@ -233,22 +274,30 @@ export async function computeRangeDelta( ): Promise { const windowDays = rangeWindowDays(range); - if (type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type)) { - const points = - type === "SLEEP_DURATION" - ? await readNightlySleep(userId, windowDays, now) - : await readDailyTotals(userId, type, windowDays, now); + if (type === "SLEEP_DURATION") { + const points = await readNightlySleep(userId, windowDays, now); const { current, previous } = splitPerPeriod(points, windowDays, now); const { delta, deltaPct } = composeDelta(current, previous); return { range, windowDays, - granularity: - points.length === 0 - ? "none" - : type === "SLEEP_DURATION" - ? "live" - : "DAY", + granularity: points.length === 0 ? "none" : "live", + current, + previous, + delta, + deltaPct, + }; + } + + if (CUMULATIVE_HK_TYPES.has(type)) { + const endKey = dailyTotalsEndKey(now, await resolveUserTimezone(userId)); + const points = await readDailyTotals(userId, type, windowDays, endKey); + const { current, previous } = splitDailyTotals(points, windowDays, endKey); + const { delta, deltaPct } = composeDelta(current, previous); + return { + range, + windowDays, + granularity: points.length === 0 ? "none" : "DAY", current, previous, delta, diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 901a4f885..05fa74ec1 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -61,7 +61,7 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets"; import { summariseDenseBuckets } from "./measurement-series"; import { emptyGlucoseClinical } from "./glucose-panel"; import { buildAdministrationLedger, buildGlp1Block } from "./medications"; -import { validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { userDayKey, validTimezoneOr, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { loadAllergies, loadAnamnesis, @@ -148,6 +148,12 @@ export async function collectDoctorReportData( }, }); const reportTz = validTimezoneOr(userProfile?.timezone, DEFAULT_TIMEZONE); + // `startsOn` / `endsOn` are calendar dates (stored as UTC midnight), so + // they compare against the window's first and last day in the report zone, + // not its instants: west of UTC the window starts hours after UTC midnight, + // and a course that ended on the first day read as over before it began. + const firstDay = new Date(`${userDayKey(start, reportTz)}T00:00:00.000Z`); + const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`); const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS; const densePulse = aggregateDenseTypes && !excluded.includes("PULSE"); @@ -202,7 +208,7 @@ export async function collectDoctorReportData( AND: [ { OR: [ - { startsOn: { lte: end } }, + { startsOn: { lte: lastDay } }, { startsOn: null, createdAt: { lte: end } }, { intakeEvents: { @@ -214,7 +220,7 @@ export async function collectDoctorReportData( }, ], }, - { OR: [{ endsOn: null }, { endsOn: { gte: start } }] }, + { OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }] }, ], }, include: { diff --git a/src/lib/import/__tests__/apple-health-staging.test.ts b/src/lib/import/__tests__/apple-health-staging.test.ts index a8132d17e..3907c27d5 100644 --- a/src/lib/import/__tests__/apple-health-staging.test.ts +++ b/src/lib/import/__tests__/apple-health-staging.test.ts @@ -46,6 +46,28 @@ describe("sweepStaleImportStaging", () => { ); }); + it("keeps what a queued or running import still owns, whatever its age", async () => { + const dir = mkdtempSync(join(tmpdir(), "healthlog-sweep-")); + const uuid = "0b7f3c2a-1d4e-4f5a-9b8c-7d6e5f4a3b2c"; + const queued = `healthlog-apple-health-import-${uuid}.bin`; + const orphan = `healthlog-upload-${uuid}.bin`; + const xml = `healthlog-import-${"cd".repeat(12)}.xml`; + const now = Date.now(); + const old = new Date(now - STAGING_MAX_AGE_MS - 60_000); + for (const name of [queued, orphan, xml]) { + writeFileSync(join(dir, name), "x"); + utimesSync(join(dir, name), old, old); + } + + expect( + await sweepStaleImportStaging(dir, now, { + paths: new Set([join(dir, queued)]), + xmlInUse: true, + }), + ).toBe(1); + expect(readdirSync(dir).sort()).toEqual([queued, xml].sort()); + }); + it("answers zero for a directory that is not there", async () => { expect(await sweepStaleImportStaging("/nonexistent/healthlog")).toBe(0); }); diff --git a/src/lib/import/apple-health-staging.ts b/src/lib/import/apple-health-staging.ts index 08819a490..c38dacfa4 100644 --- a/src/lib/import/apple-health-staging.ts +++ b/src/lib/import/apple-health-staging.ts @@ -81,14 +81,27 @@ export async function discardStagedUpload(path: string): Promise { await rm(path, { force: true }).catch(() => {}); } +/** + * What a queued or running import still owns: the staged uploads its queued + * jobs name, and whether an import is extracting or reading its XML now. + */ +export interface StagingInUse { + paths: ReadonlySet; + xmlInUse: boolean; +} + /** * Remove staged uploads and extracted XML files older than - * `STAGING_MAX_AGE_MS` from `dir`. Only names this app writes are touched. - * Answers how many were removed. + * `STAGING_MAX_AGE_MS` from `dir`, except what `inUse` names. Age alone is + * not enough: an upload can wait in the queue behind another account's + * import for longer than that, and removing it failed the import when its + * turn came. Only names this app writes are touched. Answers how many were + * removed. */ export async function sweepStaleImportStaging( dir: string = tmpdir(), now: number = Date.now(), + inUse: StagingInUse = { paths: new Set(), xmlInUse: false }, ): Promise { let names: string[]; try { @@ -100,6 +113,8 @@ export async function sweepStaleImportStaging( for (const name of names) { if (!STAGING_NAME.test(name)) continue; const path = join(dir, name); + if (inUse.paths.has(path)) continue; + if (inUse.xmlInUse && name.endsWith(".xml")) continue; try { const info = await stat(path); if (!info.isFile()) continue; diff --git a/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts b/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts index cff18e33f..ad055b9a2 100644 --- a/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts +++ b/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/lib/import/apple-health-staging", () => ({ + ACTIVE_IMPORT_STATUSES: ["queued", "unpacking", "parsing", "upserting"], sweepStaleImportStaging: vi.fn(async () => 2), })); @@ -49,7 +50,9 @@ import { IMPORT_JOB_RECONCILE_QUEUE, handleImportJobReconcileTick, reconcileOrphanImportJobs, + stagedImportFilesInUse, } from "../apple-health-import-worker"; +import { sweepStaleImportStaging } from "@/lib/import/apple-health-staging"; const maintenanceSource = readFileSync( join(process.cwd(), "src/lib/jobs/reminder/register-maintenance.ts"), @@ -178,7 +181,8 @@ describe("handleImportJobReconcileTick", () => { ok: true, did: { import_staging_swept: 2 }, }); - expect(mocks.findMany).toHaveBeenCalledTimes(1); + // Once for what the sweep must keep, once for the reconcile. + expect(mocks.findMany).toHaveBeenCalledTimes(2); }); it("reports a failed outcome when the reconcile pass throws", async () => { @@ -198,3 +202,64 @@ describe("handleImportJobReconcileTick", () => { }); }); }); + +describe("stagedImportFilesInUse", () => { + beforeEach(() => { + vi.mocked(sweepStaleImportStaging).mockResolvedValue(0); + }); + + it("keeps the upload of an import still waiting in the queue", async () => { + // An upload can wait behind another account's import for longer than + // the sweep's age limit; removing it failed the import on its turn. + const rows = [ + { status: "queued", pgBossJobId: "boss-1" }, + { status: "parsing", pgBossJobId: "boss-2" }, + { status: "queued", pgBossJobId: "boss-3" }, + ]; + // The sweep's read, then (in the tick) the reconcile's. + mocks.findMany + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce([]); + mocks.getGlobalBoss.mockReturnValue({ getJobById: mocks.getJobById }); + mocks.getJobById.mockImplementation(async (queue: string, id: string) => { + if (queue !== APPLE_HEALTH_IMPORT_V2_QUEUE) return null; + if (id === "boss-1") { + return { state: "created", data: { uploadPath: "/tmp/a.bin" } }; + } + if (id === "boss-2") { + return { state: "active", data: { uploadPath: "/tmp/b.bin" } }; + } + // A job pg-boss already finished holds nothing. + return { state: "failed", data: { uploadPath: "/tmp/c.bin" } }; + }); + + const inUse = await stagedImportFilesInUse(); + expect(inUse).not.toBeNull(); + expect([...(inUse?.paths ?? [])].sort()).toEqual([ + "/tmp/a.bin", + "/tmp/b.bin", + ]); + expect(inUse?.xmlInUse).toBe(true); + + await handleImportJobReconcileTick([] as never); + expect(sweepStaleImportStaging).toHaveBeenCalledWith( + undefined, + undefined, + expect.objectContaining({ xmlInUse: true }), + ); + }); + + it("sweeps nothing when the queue cannot be read", async () => { + const rows = [{ status: "queued", pgBossJobId: "boss-1" }]; + mocks.findMany + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce(rows) + .mockResolvedValueOnce([]); + mocks.getGlobalBoss.mockReturnValue(null); + expect(await stagedImportFilesInUse()).toBeNull(); + + await handleImportJobReconcileTick([] as never); + expect(sweepStaleImportStaging).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 77ce5506e..5aa3d9b6c 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -49,6 +49,7 @@ describe("evaluateMedicationRunway", () => { dosesRemaining: 6, unitsRemaining: 6, expiredUnits: 0, + asNeeded: false, }); }); @@ -444,6 +445,73 @@ describe("runMedicationLowStockTick", () => { expect(prisma.medication.update).not.toHaveBeenCalled(); }); + it("an as-needed medication warns once when two doses or fewer are left", async () => { + const prn = { ...dailySchedule, scheduleType: "PRN" }; + const dispatch = okDispatch(); + + // Three doses left: no cadence, so no runway, and above the dose floor. + const above = prismaMock({ + users: [userRow()], + medications: [medRow({ schedules: [prn], inventoryItems: [item(3)] })], + }); + const quiet = await runMedicationLowStockTick( + above as unknown as PrismaClient, + now, + { dispatch }, + ); + expect(quiet.skippedAboveThreshold).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + + // Two left: warns, names what is left, and stamps the dose floor. + const low = prismaMock({ + users: [userRow()], + medications: [medRow({ schedules: [prn], inventoryItems: [item(2)] })], + }); + const summary = await runMedicationLowStockTick( + low as unknown as PrismaClient, + now, + { dispatch }, + ); + expect(summary.notified).toBe(1); + const payload = dispatch.mock.calls[0]?.[0] as unknown as { + message: string; + metadata: Record; + }; + expect(payload.message).toBe( + "Metformin: only 2 units left. Reorder before you need the next dose.", + ); + expect(payload.metadata).toMatchObject({ + runwayDays: null, + triggerDays: null, + asNeeded: true, + dosesRemaining: 2, + }); + expect(low.medication.update).toHaveBeenCalledWith({ + where: { id: "med-1" }, + data: { lowStockNotifiedAt: now, lowStockNotifiedThresholdDays: 2 }, + }); + + // Still two left the next day: no repeat. + const again = prismaMock({ + users: [userRow()], + medications: [ + medRow({ + schedules: [prn], + inventoryItems: [item(1)], + lowStockNotifiedAt: daysAgo(1), + lowStockNotifiedThresholdDays: 2, + }), + ], + }); + const repeat = await runMedicationLowStockTick( + again as unknown as PrismaClient, + now, + { dispatch }, + ); + expect(repeat.skippedAlreadyNotified).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + it("an exhausted supply (runway 0) notifies with the depleted copy", async () => { const dispatch = okDispatch(); const prisma = prismaMock({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 7bc66a553..89d87ce9e 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -108,7 +108,10 @@ vi.mock("@/lib/notifications/reminder-dedup", () => ({ REMINDER_DEDUP_LOOKBACK_MS: 48 * 60 * 60 * 1000, })); -import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; +import { + handleReminderCheck, + nextSlotHeldBySnooze, +} from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; function medicationWithSchedule( @@ -891,4 +894,61 @@ describe("handleReminderCheck — a snooze holds only the slot it answered", () expect(dispatchNotification).not.toHaveBeenCalled(); }); + + it("holds the next slot when the snooze was set before its window opened", async () => { + // "Remind me later" from the app at 06:00 for the 08:00 dose, until + // 09:30: no window was open, so the snooze answers the next one. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + { + ...snoozed("2026-07-28T06:00:00.000Z"), + snoozedUntil: new Date("2026-07-28T09:30:00.000Z"), + }, + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("holds only that next slot, not the ones after it", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T18:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + snoozed("2026-07-28T06:00:00.000Z"), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ timeOfDay: "18:00" }), + }), + ); + }); +}); + +describe("nextSlotHeldBySnooze", () => { + const w = (start: string, end: string) => ({ + start: new Date(`2026-07-28T${start}:00.000Z`), + end: new Date(`2026-07-28T${end}:00.000Z`), + }); + const windows = [w("18:00", "19:00"), w("08:00", "09:00")]; + + it("is nothing while a window is open at the snooze", () => { + expect( + nextSlotHeldBySnooze(windows, new Date("2026-07-28T08:30:00.000Z")), + ).toBeNull(); + }); + + it("is the next window to open when none is", () => { + expect( + nextSlotHeldBySnooze(windows, new Date("2026-07-28T06:00:00.000Z")), + ).toEqual(new Date("2026-07-28T08:00:00.000Z")); + expect( + nextSlotHeldBySnooze(windows, new Date("2026-07-28T12:00:00.000Z")), + ).toEqual(new Date("2026-07-28T18:00:00.000Z")); + }); }); diff --git a/src/lib/jobs/__tests__/offhost-purge.test.ts b/src/lib/jobs/__tests__/offhost-purge.test.ts index eb6224df9..4a7b958e8 100644 --- a/src/lib/jobs/__tests__/offhost-purge.test.ts +++ b/src/lib/jobs/__tests__/offhost-purge.test.ts @@ -27,6 +27,7 @@ interface Row { function makeLedger(existingUsers: string[] = []) { const rows: Row[] = []; + const walked = new Map(); let seq = 0; const prisma = { offhostPurgeRequest: { @@ -60,11 +61,15 @@ function makeLedger(existingUsers: string[] = []) { data, }: { where: { id: { in: string[] } }; - data: { lastAttemptAt: Date; lastFailure: string }; + data: { + lastAttemptAt: Date; + lastFailure: string | null; + attempts?: unknown; + }; }) => { for (const row of rows) { if (!where.id.in.includes(row.id)) continue; - row.attempts += 1; + if (data.attempts) row.attempts += 1; row.lastAttemptAt = data.lastAttemptAt; row.lastFailure = data.lastFailure; } @@ -72,12 +77,23 @@ function makeLedger(existingUsers: string[] = []) { ), }, user: { - count: vi.fn(async ({ where }: { where: { id: string } }) => - existingUsers.includes(where.id) ? 1 : 0, + findMany: vi.fn(async ({ where }: { where: { id: { in: string[] } } }) => + existingUsers + .filter((id) => where.id.in.includes(id)) + .map((id) => ({ id })), + ), + }, + // When the nightly run last walked each account. + offhostBackupState: { + findMany: vi.fn(async () => + [...walked].map(([userId, lastAttemptAt]) => ({ + userId, + lastAttemptAt, + })), ), }, }; - return { rows, prisma }; + return { rows, prisma, walked }; } function makeBucket(keys: string[]) { @@ -149,7 +165,7 @@ describe("off-host purge", () => { }); it("keeps the request, with the bucket's answer, when a delete is refused", async () => { - const { rows, prisma } = makeLedger(["gone"]); + const { rows, prisma, walked } = makeLedger(["gone"]); await requestOffhostPurge(prisma as never, "gone", "data_wiped"); const bucket = makeBucket(BUCKET); bucket.deleteObject.mockRejectedValueOnce(new Error("AccessDenied")); @@ -160,8 +176,9 @@ describe("off-host purge", () => { expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ attempts: 1, lastFailure: "AccessDenied" }); - // The next run finishes it, and the account that still exists keeps the - // receipt. + // The next run finishes it once a nightly run has walked the account + // since the wipe, and the account that still exists keeps the receipt. + walked.set("gone", new Date(Date.now() + 60_000)); const again = await processOffhostPurges(prisma as never, bucket); expect(again.completed).toBe(1); expect(rows).toHaveLength(0); @@ -171,6 +188,54 @@ describe("off-host purge", () => { ); }); + it("keeps a wipe's request until a nightly run has walked the account since", async () => { + // The upload in flight during the wipe reads the request to throw its + // pre-wipe copy away; removing the request on the first purge let that + // copy land afterwards and stay. + const { rows, prisma, walked } = makeLedger(["gone"]); + walked.set("gone", new Date(Date.now() - 3_600_000)); + await requestOffhostPurge(prisma as never, "gone", "data_wiped"); + const wipeDay = rows[0]!.requestedAt.toISOString().slice(0, 10); + const bucket = makeBucket(BUCKET); + + const first = await processOffhostPurges(prisma as never, bucket); + expect(first).toMatchObject({ completed: 0, awaitingRun: 1, failed: 0 }); + expect(first.objectsDeleted).toBe(2); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ attempts: 0, lastFailure: null }); + expect(auditLog).toHaveBeenCalledTimes(1); + + // A pass before the run reached the account keeps waiting and writes no + // second receipt. + const waiting = await processOffhostPurges(prisma as never, bucket); + expect(waiting).toMatchObject({ completed: 0, awaitingRun: 1 }); + expect(auditLog).toHaveBeenCalledTimes(1); + + // The next night walks the account and writes the copy of what it holds + // after the wipe. The purge then closes the request and leaves that copy. + const nextDay = new Date(Date.parse(`${wipeDay}T00:00:00Z`) + 86_400_000) + .toISOString() + .slice(0, 10); + bucket.store.add(`${nextDay}/user-gone.json.enc`); + walked.set("gone", new Date(Date.now() + 60_000)); + const done = await processOffhostPurges(prisma as never, bucket); + expect(done).toMatchObject({ completed: 1, awaitingRun: 0 }); + expect(rows).toHaveLength(0); + expect(bucket.store.has(`${nextDay}/user-gone.json.enc`)).toBe(true); + }); + + it("removes every copy of an account deleted after a wipe", async () => { + const { rows, prisma } = makeLedger(); + await requestOffhostPurge(prisma as never, "gone", "data_wiped"); + await requestOffhostPurge(prisma as never, "gone", "account_deleted"); + const bucket = makeBucket([...BUCKET, "2999-01-01/user-gone.json.enc"]); + + const report = await processOffhostPurges(prisma as never, bucket); + expect(report).toMatchObject({ completed: 2, awaitingRun: 0 }); + expect(rows).toHaveLength(0); + expect([...bucket.store].some((k) => k.includes("user-gone."))).toBe(false); + }); + it("records nothing on a host without off-host backup", async () => { vi.stubEnv("BACKUP_S3_ENDPOINT", ""); const { rows, prisma } = makeLedger(); diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index ee4d6af78..c803627e7 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -49,7 +49,11 @@ import { } from "@/lib/rollups/measurement-rollups"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { sweepStaleImportStaging } from "@/lib/import/apple-health-staging"; +import { + ACTIVE_IMPORT_STATUSES, + sweepStaleImportStaging, + type StagingInUse, +} from "@/lib/import/apple-health-staging"; /** * Queue + cron for the periodic orphan-ImportJob sweep. v1.32.1 @@ -94,7 +98,14 @@ export async function handleImportJobReconcileTick( // Staged uploads and extracted XML that no running import can still // own: whatever exit left them (a crash, a restart mid-import, a // deploy), the person's export does not stay in /tmp past this. - const swept = await sweepStaleImportStaging().catch(() => 0); + // A queued or running import keeps its files whatever their age; + // when that cannot be read, nothing is swept this tick. + const inUse = await stagedImportFilesInUse().catch(() => null); + const swept = inUse + ? await sweepStaleImportStaging(undefined, undefined, inUse).catch( + () => 0, + ) + : 0; evt.addMeta("import_staging_swept", swept); try { await reconcileOrphanImportJobs(); @@ -655,6 +666,43 @@ const IMPORT_HEARTBEAT_STALE_MS = 30 * 60 * 1000; /** pg-boss job states from which a mid-run import can still make progress. */ const LIVE_PG_BOSS_STATES = new Set(["active", "created", "retry"]); +/** + * The staged files a queued or running import still owns: the upload each + * live pg-boss job names, and whether any import is past the queue (it has + * an extracted XML open). `null` when that cannot be read, so the caller + * sweeps nothing rather than guess. + */ +export async function stagedImportFilesInUse(): Promise { + const prisma = getWorkerPrisma(); + const rows = await prisma.importJob.findMany({ + where: { status: { in: [...ACTIVE_IMPORT_STATUSES] } }, + select: { status: true, pgBossJobId: true }, + }); + const paths = new Set(); + if (rows.length === 0) return { paths, xmlInUse: false }; + const boss = getGlobalBoss(); + if (!boss) return null; + for (const row of rows) { + if (!row.pgBossJobId) continue; + let job: { state: string; data: unknown } | null = null; + for (const queue of [ + APPLE_HEALTH_IMPORT_V2_QUEUE, + APPLE_HEALTH_IMPORT_LEGACY_QUEUE, + ]) { + job = await boss.getJobById(queue, row.pgBossJobId); + if (job) break; + } + if (!job || !LIVE_PG_BOSS_STATES.has(job.state)) continue; + const uploadPath = (job.data as { uploadPath?: unknown } | null) + ?.uploadPath; + if (typeof uploadPath === "string") paths.add(uploadPath); + } + return { + paths, + xmlInUse: rows.some((row) => row.status !== "queued"), + }; +} + /** * Reconcile orphan `ImportJob` rows on worker startup. A row stuck in * `unpacking` / `parsing` / `upserting` means a worker was mid-run when diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 7d67fa3f3..808cbea1b 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -147,6 +147,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "offhost_backup_oversized", "offhost_backup_total_users", "offhost_backup_uploaded", + "offhost_purge_awaiting_run", "offhost_purge_completed", "offhost_purge_failed", "offhost_purge_objects_deleted", diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 86df5a22c..ea44a842d 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -53,6 +53,12 @@ * * Inactive / paused medications are skipped: nothing consumes their * stock, so their runway is not falling. + * + * As-needed (PRN) medications have no cadence and so no runway in days. + * Once the runway math stopped counting a PRN schedule as a daily dose, + * a medication taken only as needed never warned at all. It now warns on + * an absolute floor instead: `AS_NEEDED_LOW_STOCK_DOSES` doses or fewer + * left, once per crossing, with the same stamp and re-arm rule. */ import type { PrismaClient } from "@/generated/prisma/client"; import { @@ -89,6 +95,12 @@ export const MEDICATION_LOW_STOCK_QUEUE = "medication-low-stock"; */ export const MEDICATION_LOW_STOCK_CRON = "0 9 * * *"; +/** + * An as-needed medication warns when this many doses or fewer are left: no + * cadence says how many days that is, so the floor is a dose count. + */ +export const AS_NEEDED_LOW_STOCK_DOSES = 2; + export interface MedicationLowStockSummary { usersScanned: number; skippedThresholdOff: number; @@ -114,6 +126,11 @@ export interface LowStockEvaluation { * able to tell them apart. */ expiredUnits: number; + /** + * Taken only as needed: no schedule consumes on a cadence, and at least + * one is PRN. Such a medication warns on the dose floor, not on days. + */ + asNeeded: boolean; } /** @@ -156,6 +173,7 @@ export function evaluateMedicationRunway( dosesRemaining: supply.dosesRemaining, unitsRemaining: supply.unitsRemaining, expiredUnits: supply.expiredUnits, + asNeeded: schedules.some((sched) => sched.scheduleType === "PRN"), }; } const runwayDays = @@ -167,6 +185,7 @@ export function evaluateMedicationRunway( dosesRemaining: supply.dosesRemaining, unitsRemaining: supply.unitsRemaining, expiredUnits: supply.expiredUnits, + asNeeded: false, }; } @@ -249,10 +268,25 @@ export function buildLowStockPayload(input: { triggerDays: number; schedules: RunwaySchedule[]; today: Date; + /** Taken as needed: no dates to name, only what is left. */ + asNeeded?: boolean; }): { title: string; body: string } { const t = getServerTranslator(coerceLocale(input.locale)).t; const title = t("lowStockReminders.title", { medName: input.medName }); + if ( + input.asNeeded && + !(input.unitsRemaining <= 0 && input.expiredUnits > 0) + ) { + return { + title, + body: t("lowStockReminders.bodyAsNeeded", { + medName: input.medName, + units: input.unitsRemaining, + }), + }; + } + if (input.runwayDays < 1) { // Nothing usable left. Whether that means the packs are empty or // that every remaining unit was written off is the difference @@ -464,14 +498,21 @@ export async function runMedicationLowStockTick( user.notificationPrefs, med.reorderLeadDays, ); - const triggerDays = lowStockTriggerDays({ - lowStockRunwayDays: thresholdDays, - leadDays, - schedules: runwaySchedules, - }); + // An as-needed medication has no runway in days: it crosses on the + // dose floor, and the same stamp records that floor. + const asNeeded = evaluation.runwayDays === null && evaluation.asNeeded; + const triggerDays = asNeeded + ? AS_NEEDED_LOW_STOCK_DOSES + : lowStockTriggerDays({ + lowStockRunwayDays: thresholdDays, + leadDays, + schedules: runwaySchedules, + }); const decision = decideLowStockAction({ - runwayDays: evaluation.runwayDays, + runwayDays: asNeeded + ? evaluation.dosesRemaining + : evaluation.runwayDays, triggerDays, notifiedAt: med.lowStockNotifiedAt, notifiedThresholdDays: med.lowStockNotifiedThresholdDays, @@ -522,6 +563,7 @@ export async function runMedicationLowStockTick( triggerDays, schedules: runwaySchedules, today: now, + asNeeded, }); const renderForRecipient = (locale: Locale) => { const rendered = buildLowStockPayload({ @@ -534,6 +576,7 @@ export async function runMedicationLowStockTick( triggerDays, schedules: runwaySchedules, today: now, + asNeeded, }); return { title: rendered.title, message: rendered.body }; }; @@ -550,7 +593,10 @@ export async function runMedicationLowStockTick( thresholdDays, // v1.17.0 — the effective trigger + reorder lead so the // client can render the same dates the push body names. - triggerDays, + // An as-needed medication has no trigger in days; it + // crossed the dose floor instead. + triggerDays: asNeeded ? null : triggerDays, + asNeeded, leadDays, unitsRemaining: evaluation.unitsRemaining, dosesRemaining: evaluation.dosesRemaining, diff --git a/src/lib/jobs/offhost-purge.ts b/src/lib/jobs/offhost-purge.ts index 15585fb03..a42fcb78f 100644 --- a/src/lib/jobs/offhost-purge.ts +++ b/src/lib/jobs/offhost-purge.ts @@ -17,6 +17,13 @@ * the job runs again every night and after every nightly upload, so a late * copy the upload wrote for an account deleted while it ran is caught too. * + * A wiped account keeps its request until a nightly run has walked it since + * the wipe. The upload that was in flight when the data went reads the + * request to know its copy is from before the wipe; removing the request on + * the first purge let that copy land afterwards and stay. Until then each + * purge removes only the copies dated up to the wipe's day, so the copy of + * what the account holds after the wipe is left alone. + * * The admin wipe of every account writes no request, on purpose: it is the * operator clearing their own host, the bucket is theirs, and its copies are * the documented way back from a wipe pressed by mistake. The health-check @@ -57,6 +64,11 @@ const MAX_DELETES_PER_RUN = 50_000; type Db = Pick; +type PurgeDb = Pick< + PrismaClient, + "offhostPurgeRequest" | "offhostBackupState" | "user" +>; + /** * Record that `subjectId`'s off-host copies have to go. Call it with the * transaction that deletes the account or wipes its data, so the request and @@ -101,6 +113,8 @@ export interface OffhostPurgeReport { pending: number; completed: number; failed: number; + /** Wipe requests kept until a nightly run has walked the account since. */ + awaitingRun: number; objectsDeleted: number; } @@ -114,7 +128,7 @@ function trimError(err: unknown): string { * gone. Answers what it did. */ export async function processOffhostPurges( - prisma: PrismaClient, + prisma: PurgeDb, s3Override?: S3Like, ): Promise { const requests = await prisma.offhostPurgeRequest.findMany({ @@ -125,6 +139,7 @@ export async function processOffhostPurges( pending: requests.length, completed: 0, failed: 0, + awaitingRun: 0, objectsDeleted: 0, }; if (requests.length === 0) return report; @@ -163,12 +178,56 @@ export async function processOffhostPurges( } const subjects = new Set(requests.map((r) => r.subjectId)); + const [liveUsers, ledger] = await Promise.all([ + prisma.user.findMany({ + where: { id: { in: [...subjects] } }, + select: { id: true }, + }), + prisma.offhostBackupState.findMany({ + where: { userId: { in: [...subjects] } }, + select: { userId: true, lastAttemptAt: true }, + }), + ]); + const live = new Set(liveUsers.map((u) => u.id)); + const lastWalked = new Map(ledger.map((r) => [r.userId, r.lastAttemptAt])); + + // A wipe of an account that still exists: its request stays until a + // nightly run has walked the account since, because the upload in flight + // at the wipe reads the request to throw its pre-wipe copy away. + const awaitsRun = (request: (typeof requests)[number]) => + request.reason === "data_wiped" && + live.has(request.subjectId) && + !( + (lastWalked.get(request.subjectId)?.getTime() ?? 0) >= + request.requestedAt.getTime() + ); + + // Where every request of a subject is a wipe of a live account, only the + // copies dated up to the latest wipe's day go; a copy from a later night + // holds what the account has recorded since, and stays. An account that is + // gone loses every copy. + const lastDayToDelete = new Map(); + for (const request of requests) { + const subject = request.subjectId; + const day = + request.reason === "data_wiped" && live.has(subject) + ? request.requestedAt.toISOString().slice(0, 10) + : null; + const prior = lastDayToDelete.get(subject); + if (prior === undefined) lastDayToDelete.set(subject, day); + else if (prior !== null) { + lastDayToDelete.set(subject, day === null || day > prior ? day : prior); + } + } + const bySubject = new Map(); for (const { key } of objects) { const match = ACCOUNT_OBJECT.exec(key); if (!match) continue; const subject = match[1]; if (!subjects.has(subject)) continue; + const lastDay = lastDayToDelete.get(subject); + if (lastDay && key.slice(0, 10) > lastDay) continue; const list = bySubject.get(subject) ?? []; list.push(key); bySubject.set(subject, list); @@ -205,14 +264,29 @@ export async function processOffhostPurges( continue; } const deleted = bySubject.get(request.subjectId)?.length ?? 0; - await prisma.offhostPurgeRequest.delete({ where: { id: request.id } }); - report.completed++; + // A wipe that already waited a pass had its copies removed, and its + // receipt written, on that pass. + const alreadyReceipted = + request.lastAttemptAt !== null && request.lastFailure === null; + if (awaitsRun(request)) { + // Its copies up to the wipe are gone; what is left is the wait for + // the next run. Marked so the admin card stops counting it as copies + // still in the bucket. + await prisma.offhostPurgeRequest.updateMany({ + where: { id: { in: [request.id] } }, + data: { lastAttemptAt: now, lastFailure: null }, + }); + report.awaitingRun++; + } else { + await prisma.offhostPurgeRequest.delete({ where: { id: request.id } }); + report.completed++; + } + if (alreadyReceipted && deleted === 0) continue; // The account a deleted subject named is gone, and so is its audit // history; the receipt names the reason and the count, not the account. // A wiped account still exists and keeps the receipt as its own. const accountRemains = - request.reason === "data_wiped" && - (await prisma.user.count({ where: { id: request.subjectId } })) > 0; + request.reason === "data_wiped" && live.has(request.subjectId); await auditLog("offhost.backup.purged", { userId: accountRemains ? request.subjectId : null, actorUserId: null, @@ -236,6 +310,7 @@ export async function handleOffhostPurge( evt.addMeta("offhost_purge_pending", report.pending); evt.addMeta("offhost_purge_completed", report.completed); evt.addMeta("offhost_purge_failed", report.failed); + evt.addMeta("offhost_purge_awaiting_run", report.awaitingRun); evt.addMeta("offhost_purge_objects_deleted", report.objectsDeleted); if (report.failed > 0) { await reportWorkerError( @@ -249,6 +324,7 @@ export async function handleOffhostPurge( offhost_purge_pending: report.pending, offhost_purge_completed: report.completed, offhost_purge_failed: report.failed, + offhost_purge_awaiting_run: report.awaitingRun, offhost_purge_objects_deleted: report.objectsDeleted, }); } catch (err) { diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index d14f25c94..41958f516 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -144,20 +144,51 @@ export async function cleanupScheduledTelegramDeletions(): Promise { /** * Whether a medication-level snooze holds the reminder of one slot. It - * holds only while it runs (`now < snoozedUntil`) and only for a slot whose + * holds only while it runs (`now < snoozedUntil`), and only for a slot whose * reminder window had opened when the snooze was set (`snoozedAt`, the - * medication row's @updatedAt of the snooze write). A later edit to the - * row moves `snoozedAt` forward, which can only widen the hold back to the - * old medication-wide behaviour, never lose a snooze. + * medication row's @updatedAt of the snooze write), or for the slot the + * snooze was set ahead of (`heldNextWindowStart`, see + * {@link nextSlotHeldBySnooze}). A later edit to the row moves `snoozedAt` + * forward, which can only widen the hold back to the old medication-wide + * behaviour, never lose a snooze. */ export function snoozeHoldsSlot( snoozedUntil: Date | null, snoozedAt: Date, slotWindowStart: Date, now: Date, + heldNextWindowStart: Date | null = null, ): boolean { if (!snoozedUntil || now.getTime() >= snoozedUntil.getTime()) return false; - return slotWindowStart.getTime() <= snoozedAt.getTime(); + if (slotWindowStart.getTime() <= snoozedAt.getTime()) return true; + return ( + heldNextWindowStart !== null && + slotWindowStart.getTime() === heldNextWindowStart.getTime() + ); +} + +/** + * The window start of the slot a snooze was set ahead of, or `null`. A + * snooze set while one of the medication's reminder windows was open answers + * that slot, and a later slot stays reminded (a morning "skip" that snoozes + * to the end of the day must not silence the evening dose). A snooze set + * while no window was open, such as "remind me later" on a dose that is + * not due yet, answers the next slot to open, so that one is held too. + */ +export function nextSlotHeldBySnooze( + windows: ReadonlyArray<{ start: Date; end: Date }>, + snoozedAt: Date, +): Date | null { + const at = snoozedAt.getTime(); + let next: Date | null = null; + for (const window of windows) { + const start = window.start.getTime(); + if (start <= at && at <= window.end.getTime()) return null; + if (start > at && (next === null || start < next.getTime())) { + next = window.start; + } + } + return next; } /** @@ -416,6 +447,31 @@ export async function handleReminderCheck( : null; }); + // The slot a running snooze was set ahead of, if any (see + // `nextSlotHeldBySnooze`), read over every slot of the medication. + const snoozeRunning = + med.snoozedUntil !== null && + now.getTime() < med.snoozedUntil.getTime(); + const heldNextWindowStart = snoozeRunning + ? nextSlotHeldBySnooze( + sortedSchedules.flatMap((schedule, index) => { + const canonicalSchedule = canonicalSchedules[index]; + return ( + occurrencesByScheduleId.get(canonicalSchedule.id) ?? [] + ).map((occurrence) => + resolveSlotPhaseWindow( + schedule, + occurrence.timeOfDay, + canonicalSchedule.doseWindows ?? null, + occurrence.at, + userTz, + ), + ); + }), + med.updatedAt, + ) + : null; + for ( let scheduleIndex = 0; scheduleIndex < sortedSchedules.length; @@ -569,14 +625,21 @@ export async function handleReminderCheck( } // A snooze answers the reminder of the slot that was open when it - // was set, so it holds only slots whose window had opened by then. + // was set, so it holds only slots whose window had opened by then, + // or, set while none was open, the next slot to open. // `snoozedUntil` lives on the medication row and the snooze write // stamps the row's @updatedAt, which dates the decision (the same // reading the skip / pin suppression above uses). A later slot of // the same medication, such as the evening dose after a morning // "skip" that snoozes to the end of the day, is still reminded. if ( - snoozeHoldsSlot(med.snoozedUntil, med.updatedAt, windowStart, now) + snoozeHoldsSlot( + med.snoozedUntil, + med.updatedAt, + windowStart, + now, + heldNextWindowStart, + ) ) { continue; } diff --git a/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts index 560d08bbc..7cd923cc4 100644 --- a/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts +++ b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from "vitest"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; -import { computeReminderNextDueAt } from "../scheduling"; +import { computeReminderNextDueAt, parseReminderAnchor } from "../scheduling"; const ZONES = [ "America/New_York", @@ -75,3 +75,29 @@ describe.each(ZONES)("reminder anchors in %s", (tz) => { expect(localLabel(next, tz)).toBe("2026-09-20 19:00 w0"); }); }); + +describe.each(ZONES)("a first-due date sent as a calendar date in %s", (tz) => { + it("is that day in the user's zone", () => { + const anchor = parseReminderAnchor("2026-10-01", tz); + expect(anchor).toEqual(localMidnight(tz, 2026, 10, 1)); + const next = computeReminderNextDueAt( + { + intervalDays: 30, + rrule: null, + anchorDate: anchor, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-09-01T12:00:00Z"), + }, + tz, + new Date("2026-09-26T12:00:00Z"), + ); + expect(localLabel(next, tz)?.slice(0, 16)).toBe("2026-10-01 09:00"); + }); + + it("keeps a date-time as the instant it names", () => { + expect(parseReminderAnchor("2026-10-01T00:00:00.000Z", tz)).toEqual( + new Date("2026-10-01T00:00:00.000Z"), + ); + }); +}); diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 2227d0008..49cbf8aaf 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -31,6 +31,7 @@ import { } from "@/lib/medications/scheduling/recurrence"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; /** * The reminder fields this module reads. A subset of the Prisma row so @@ -52,6 +53,18 @@ export interface ReminderScheduleInput { endsOn?: Date | null; } +/** + * A first-due date from a request: a calendar date (`YYYY-MM-DD`) is that + * day in the user's zone, stored as its local midnight; a date-time is the + * instant it names. A date sent as UTC midnight would read as the evening + * before west of UTC, which is why a bare date is accepted at all. + */ +export function parseReminderAnchor(value: string, timeZone: string): Date { + return /^\d{4}-\d{2}-\d{2}$/.test(value) + ? startOfLocalDayKey(value, timeZone || DEFAULT_TIMEZONE) + : new Date(value); +} + /** The calendar day `instant` falls on in `tz`, as UTC midnight of that date. */ function calendarDayInZone(instant: Date, tz: string): Date { const p = wallClockInTz(instant, tz); diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 936f20730..bfca576c4 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -213,7 +213,33 @@ describe("readBestGranularityRollups", () => { it("returns null when the window holds no buckets", async () => { const result = await readBestGranularityRollups("user", "WEIGHT", 1095); expect(result).toBeNull(); - expect(queryRaw).toHaveBeenCalledTimes(1); + // YEAR, MONTH and WEEK folds, then the DAY tier. + expect(queryRaw).toHaveBeenCalledTimes(3); + expect(findMany).toHaveBeenCalledTimes(1); + }); + + it("steps one tier finer when the data sits only in the leading partial bucket", async () => { + // A 731-day window on 2026-09-26 starts 2024-09-25. October to December + // 2024 fold into the YEAR bucket 2024-01-01, which starts before the + // window and is left out; the MONTH tier starts inside it. + vi.setSystemTime(new Date("2026-09-26T12:00:00.000Z")); + const readings: Array<[string, number]> = [ + ["2024-10-05", 80], + ["2024-11-05", 81], + ["2024-12-05", 82], + ]; + queryRaw + .mockResolvedValueOnce([folded("2024-01-01T00:00:00.000Z", readings)]) + .mockResolvedValueOnce( + readings.map(([day, value]) => + folded(`${day.slice(0, 7)}-01T00:00:00.000Z`, [[day, value]]), + ), + ); + const result = await readBestGranularityRollups("user", "WEIGHT", 731); + expect(result?.granularity).toBe("MONTH"); + expect(result?.rows.map((r) => r.mean)).toEqual([80, 81, 82]); + expect(sqlOf(queryRaw.mock.calls[0])).toContain("'year'"); + expect(sqlOf(queryRaw.mock.calls[1])).toContain("'month'"); }); it("composes a coarse bucket's mean, spread and slope from its days", async () => { diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index ece63f589..d40941945 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -125,25 +125,32 @@ export async function readBestGranularityRollups( userPriorityJson !== undefined ? userPriorityJson : await loadUserSourcePriority(userId); - // Every tier is folded from the same canonical DAY buckets, so a tier - // either has coverage or none has: pick the coarsest floor the window - // clears and read it once. - const floor = GRANULARITY_FLOORS.find((f) => windowDays >= f.minWindowDays); - if (!floor) return null; - const rows = await readGranularity( - userId, - type, - floor.granularity, - since, - // Trailing-window semantics: no upper bound. This router serves the - // "last N days to now" probes (summaries-slice / health-score); the - // requested-window bounding lives on `readTieredRollupSeries`. - null, - priority, + // Every tier is folded from the same canonical DAY buckets, but a coarse + // bucket that starts before `since` is left out (it holds only part of its + // days). Data that sits only in that leading partial bucket, say three + // months at the start of a 731-day window, reads as empty at YEAR: step one + // tier finer until a tier has buckets, down to DAY, which starts inside the + // window by construction. + const floors = GRANULARITY_FLOORS.filter( + (f) => windowDays >= f.minWindowDays, ); - return rows && rows.length > 0 - ? { granularity: floor.granularity, rows } - : null; + for (const floor of floors) { + const rows = await readGranularity( + userId, + type, + floor.granularity, + since, + // Trailing-window semantics: no upper bound. This router serves the + // "last N days to now" probes (summaries-slice / health-score); the + // requested-window bounding lives on `readTieredRollupSeries`. + null, + priority, + ); + if (rows && rows.length > 0) { + return { granularity: floor.granularity, rows }; + } + } + return null; } /** diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts index aa013d421..78881ba55 100644 --- a/src/lib/validations/__tests__/measurement-reminders.test.ts +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -118,3 +118,19 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => { expect(parsed.success).toBe(true); }); }); + +describe("createMeasurementReminderSchema — first-due date", () => { + const base = { label: "Blood test", intervalDays: 365 }; + it.each([ + ["2026-10-01", true], + ["2026-10-01T00:00:00.000Z", true], + ["2026-10-01T00:00:00-07:00", true], + ["2026-10-01T00:00:00", false], + ["01.10.2026", false], + ])("anchorDate %s is accepted: %s", (anchorDate, ok) => { + expect( + createMeasurementReminderSchema.safeParse({ ...base, anchorDate }) + .success, + ).toBe(ok); + }); +}); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 724e2b2d4..a329afa46 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -143,13 +143,22 @@ const cadenceRefinement = ( } }; +/** + * The first-due date: a calendar date (`YYYY-MM-DD`, read as that day in the + * user's zone) or a date-time (the instant it names). + */ +const anchorDateField = z.union([ + z.iso.date(), + z.iso.datetime({ offset: true }), +]); + export const createMeasurementReminderSchema = z .object({ label: z.string().trim().min(1).max(120), measurementType: measurementReminderTypeEnum.nullish(), intervalDays: z.number().int().min(1).max(3650).nullish(), rrule: rruleField.nullish(), - anchorDate: z.iso.datetime({ offset: true }).nullish(), + anchorDate: anchorDateField.nullish(), notifyHour: z.number().int().min(0).max(23).default(9), location: z.string().trim().max(200).nullish(), enabled: z.boolean().default(true), @@ -177,7 +186,7 @@ export const updateMeasurementReminderSchema = z measurementType: measurementReminderTypeEnum.nullish(), intervalDays: z.number().int().min(1).max(3650).nullish(), rrule: rruleField.nullish(), - anchorDate: z.iso.datetime({ offset: true }).nullish(), + anchorDate: anchorDateField.nullish(), notifyHour: z.number().int().min(0).max(23).optional(), location: z.string().trim().max(200).nullish(), enabled: z.boolean().optional(), diff --git a/tests/integration/doctor-report-fixed-window.test.ts b/tests/integration/doctor-report-fixed-window.test.ts index 72116145a..e42ceb95c 100644 --- a/tests/integration/doctor-report-fixed-window.test.ts +++ b/tests/integration/doctor-report-fixed-window.test.ts @@ -138,4 +138,46 @@ describe("doctor report with a fixed end date", () => { expect(glp1?.currentDose?.value).toBe(0.25); expect(glp1?.lastInjection?.date).toBe("2026-03-20T08:05:00.000Z"); }); + it("compares a course's start and end dates as days in the report zone", async () => { + // West of UTC the window's instants sit hours after UTC midnight, and the + // course dates are calendar days stored as UTC midnight. + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "west-window", + email: "west-window@example.test", + timezone: "America/New_York", + }, + }); + const course = (name: string, startsOn: string, endsOn: string | null) => + prisma.medication.create({ + data: { + userId: user.id, + name, + dose: "1 mg", + startsOn: new Date(`${startsOn}T00:00:00.000Z`), + endsOn: endsOn ? new Date(`${endsOn}T00:00:00.000Z`) : null, + createdAt: new Date("2025-06-01T00:00:00.000Z"), + }, + }); + await course("Ended on the first day", "2025-10-01", "2026-01-01"); + await course("Ended the day before", "2025-10-01", "2025-12-31"); + await course("Started on the last day", "2026-03-31", null); + await course("Started the day after", "2026-04-01", null); + + const data = await collectDoctorReportData( + user.id, + { + start: new Date("2026-01-01T05:00:00.000Z"), + end: new Date("2026-04-01T03:59:59.999Z"), + days: 90, + }, + selectionFromLeaves(ALL_LEAF_IDS), + ); + + expect(data.medications?.map((m) => m.name).sort()).toEqual([ + "Ended on the first day", + "Started on the last day", + ]); + }); }); diff --git a/tests/integration/offhost-purge-on-deletion.test.ts b/tests/integration/offhost-purge-on-deletion.test.ts index 9c96deb75..3cf7ac76d 100644 --- a/tests/integration/offhost-purge-on-deletion.test.ts +++ b/tests/integration/offhost-purge-on-deletion.test.ts @@ -153,5 +153,18 @@ describe("off-host copies leave with the account", () => { where: { action: "offhost.backup.purged", userId }, }); expect(receipt).not.toBeNull(); + + // The request outlives the purge until a nightly run has walked the + // account since the wipe: the upload in flight at the wipe reads it to + // throw its pre-wipe copy away. + expect(await prisma.offhostPurgeRequest.count()).toBe(1); + await prisma.offhostBackupState.upsert({ + where: { userId }, + create: { userId, lastAttemptAt: new Date(Date.now() + 1000) }, + update: { lastAttemptAt: new Date(Date.now() + 1000) }, + }); + const report = await processOffhostPurges(prisma); + expect(report).toMatchObject({ completed: 1, awaitingRun: 0 }); + expect(await prisma.offhostPurgeRequest.count()).toBe(0); }); }); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index f4512b636..27d284385 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -63,6 +63,11 @@ export async function truncateAllTables(client: PrismaClient): Promise { "mood_entries", "notification_channels", "notification_preferences", + // Host-wide off-host backup state: not keyed to a user, so the users + // cascade does not reach it, and a file that reads it whole (the purge + // job, the encryption view) saw what earlier files left behind. + "offhost_backup_key_use", + "offhost_purge_requests", "passkeys", "personal_records", "push_subscriptions",