From 96b98cb71966074bcd4bbe4aad2f36319bb3398f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:05:37 +0200 Subject: [PATCH 01/12] fix(insights): compare completed days in the step-total range delta The current half of a cumulative metric's range delta counted today's still-growing total, so the comparison read lower every morning. Both halves now end on the last completed day: yesterday on the user's calendar, and never a UTC day bucket that has not ended yet. --- .../__tests__/range-delta-per-period.test.ts | 46 ++++++++-- src/lib/analytics/range-delta.ts | 91 ++++++++++++++----- 2 files changed, 110 insertions(+), 27 deletions(-) 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, From 19edda66c047715baeafbe158c0a856255f855b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:06:39 +0200 Subject: [PATCH 02/12] fix(import): re-fold every day a CSV import touched, even when it fails A re-import that moved a reading to another instant re-folded only the day it moved to, so the day it left kept the reading in its rollups. And a write chunk that failed part-way ended the request before the re-fold ran; a retry then read the landed rows as duplicates and touched nothing, so those days kept their pre-import rollups for good. The re-fold now covers the old instant of a moved row and runs whether or not the writes finish. --- .../api/import/csv/__tests__/route.test.ts | 53 +++++- src/app/api/import/csv/route.ts | 158 ++++++++++-------- 2 files changed, 138 insertions(+), 73 deletions(-) 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), From bb12d942c0e61d4b36b2c69955656bf5a9525f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:08:51 +0200 Subject: [PATCH 03/12] fix(backup): keep a wipe's purge request until the next off-host run A data wipe during the nightly upload could leave the pre-wipe copy in the bucket: the purge the wipe started removed its request at once, and the upload still in flight checked that request after finishing and found nothing. A wipe's request now stays until a nightly run has walked the account since the wipe. 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 stays, and the admin card no longer counts the waiting request as copies still in the bucket. --- src/app/api/admin/backups/route.ts | 13 ++- src/lib/jobs/__tests__/offhost-purge.test.ts | 81 +++++++++++++++-- src/lib/jobs/offhost-purge.ts | 86 +++++++++++++++++-- .../offhost-purge-on-deletion.test.ts | 13 +++ 4 files changed, 179 insertions(+), 14 deletions(-) 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/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/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/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); }); }); From 6769f90f4c180c812144837432a6497eadcb2b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:10:45 +0200 Subject: [PATCH 04/12] fix(rollups): step one tier finer when the coarse tier comes back empty The granularity router read only the coarsest tier the window cleared, and a coarse bucket that starts before the window is left out. Data that sat only in that leading partial bucket, such as three months at the start of a 731-day window, read as no data at all. The router now steps one tier finer until a tier has buckets, down to DAY. --- .../__tests__/measurement-read-wmy.test.ts | 28 +++++++++++- src/lib/rollups/measurement-read-wmy.ts | 43 +++++++++++-------- 2 files changed, 52 insertions(+), 19 deletions(-) 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; } /** From 04eb8dd7f8660bcd5796d5c7ccee032c04970001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:11:27 +0200 Subject: [PATCH 05/12] fix(doctor-report): compare course dates as days in the report zone A medication's start and end dates are calendar dates, but the report compared them against the window's instants. West of UTC the window begins hours after UTC midnight, so a course that ended on the first day was left out, and one that started the day after the window was listed. Both now compare against the window's first and last day in the report zone. --- src/lib/doctor-report/collect.ts | 12 ++++-- .../doctor-report-fixed-window.test.ts | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) 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/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", + ]); + }); }); From 612eedcbbb17c1bdf71c657b1993896e5361b789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:14:03 +0200 Subject: [PATCH 06/12] fix(medications): warn when an as-needed medication runs low Since the supply runway stopped counting an as-needed schedule as a daily dose, a medication taken only as needed had no runway and never raised a low-supply notice. It now warns once when two doses or fewer are left, with its own line naming what is left, and re-arms after a refill like any other medication. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../__tests__/medication-low-stock.test.ts | 68 +++++++++++++++++++ src/lib/jobs/medication-low-stock.ts | 60 ++++++++++++++-- 9 files changed, 135 insertions(+), 14 deletions(-) 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/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/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, From 4d3a98ed43481928d0fbc50bff542e114013de6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:15:16 +0200 Subject: [PATCH 07/12] fix(medications): count every weekday a monthly rule names in the supply runway FREQ=MONTHLY;BYDAY=MO means every Monday of the month, but the runway counted it as one dose a month, so a Monday medication's supply read about four times longer than it lasts and its low-supply notice came late. A weekday without an ordinal now counts as every such day in the month (yearly rules per BYMONTH month), a weekday with one (1MO, -1FR) as one day, and BYSETPOS as the number of positions it picks. --- .../__tests__/supply-runway.test.ts | 20 +++++++++ .../medications/detail/supply-runway.ts | 45 ++++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) 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 From b6b51fcb5e148753ac9b0eac799ffd9e8e6c6435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:17:14 +0200 Subject: [PATCH 08/12] fix(medications): let a snooze set before a dose's window hold that dose Since a snooze holds only the slots whose window had opened when it was set, a snooze set ahead of a dose, such as "remind me later" in the app before the dose is due, held nothing and the reminder came anyway. A snooze set while no window of the medication is open now holds the next slot to open. One set while a window is open still holds only that slot, so a morning skip that snoozes to the end of the day leaves the evening dose reminded. --- .../medication-reminder-check.test.ts | 62 ++++++++++++++- .../reminder/medication-reminder-check.ts | 77 +++++++++++++++++-- 2 files changed, 131 insertions(+), 8 deletions(-) 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/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; } From 93113379977e869e8860a3cdadeb9be605dbd9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:19:49 +0200 Subject: [PATCH 09/12] fix(reminders): read a first-due date sent as a date as that day in the user's zone A reminder's first-due date could only be sent as a date-time, and the web form sent the browser's local midnight, so a browser set to a zone other than the profile's could move the day. The API now also takes a bare calendar date and stores it as that day's midnight in the user's zone, and the web form sends the date itself. A date-time still means the instant it names. --- docs/api/openapi.yaml | 20 +++++++++---- .../api/measurement-reminders/[id]/route.ts | 15 ++++++---- src/app/api/measurement-reminders/route.ts | 6 +++- .../vorsorge-section.tsx | 10 ++++--- .../__tests__/scheduling-timezones.test.ts | 28 ++++++++++++++++++- src/lib/measurement-reminders/scheduling.ts | 13 +++++++++ .../__tests__/measurement-reminders.test.ts | 16 +++++++++++ src/lib/validations/measurement-reminders.ts | 13 +++++++-- 8 files changed, 101 insertions(+), 20 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5953a81d8..a739c5947 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -27454,9 +27454,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 @@ -27528,9 +27532,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/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/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/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(), From 382c44ee88cbcd151f51e1a7e3f5e491fe9351a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:21:46 +0200 Subject: [PATCH 10/12] fix(import): keep a queued Apple Health upload when sweeping staged files The staging sweep removed any staged upload older than seven hours. An import can wait in the queue behind another account's import for longer than that, and when its turn came the file was gone and the import failed. The sweep now keeps every upload a queued or running import still names, and the extracted XML while an import is past the queue. When the queue cannot be read, it removes nothing that tick. --- .../__tests__/apple-health-staging.test.ts | 22 ++++++ src/lib/import/apple-health-staging.ts | 19 +++++- .../apple-health-import-reconcile.test.ts | 67 ++++++++++++++++++- src/lib/jobs/apple-health-import-worker.ts | 52 +++++++++++++- 4 files changed, 155 insertions(+), 5 deletions(-) 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/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 From f4b9d8a0e1d1f9528ada350ae4eeefc759ce75d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:35:49 +0200 Subject: [PATCH 11/12] fix(backup): list the purge job's awaiting-run count among its outcome facts --- src/lib/jobs/job-outcome.ts | 1 + 1 file changed, 1 insertion(+) 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", From 1bf84149f8cb29c6dc250240fa627e2f8f948672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 00:17:01 +0200 Subject: [PATCH 12/12] test(integration): clear the host-wide off-host tables between tests The purge requests and the off-host key-use ledger are not keyed to a user, so truncating users never reached them. A file that reads them whole, the purge job or the encryption status view, saw rows earlier files had left and failed in a full serial run. --- tests/integration/setup.ts | 5 +++++ 1 file changed, 5 insertions(+) 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",