Merge branch 'fix/v1393-review-data' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 00:18:47 +02:00
commit 72a34b3b47
39 files changed
+1118 -188

No files matched your search

+14 -6
View File
@@ -27456,9 +27456,13 @@ components:
- type: "null"
anchorDate:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- anyOf:
- type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
default: 9
@@ -27530,9 +27534,13 @@ components:
- type: "null"
anchorDate:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- anyOf:
- type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
type: integer
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "기분 기록하기",
+2 -1
View File
@@ -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",
+12 -1
View File
@@ -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 },
}),
+51 -2
View File
@@ -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();
+87 -71
View File
@@ -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),
@@ -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);
+5 -1
View File
@@ -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<Response> {
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).
@@ -181,11 +181,13 @@ function toDateInputValue(iso: string | null): string {
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
}
/** `<input type="date">` value → an ISO instant at local midnight, or null. */
/**
* `<input type="date">` 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 {
@@ -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" })]),
@@ -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
@@ -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 () => {
+70 -21
View File
@@ -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<Array<{ at: Date; value: number }>> {
const rows = await readRollupBuckets(
userId,
type,
"DAY",
startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)),
new Date(now),
endKey: string,
): Promise<Array<{ day: string; value: number }>> {
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<RangeDeltaResult> {
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,
+9 -3
View File
@@ -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: {
@@ -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);
});
+17 -2
View File
@@ -81,14 +81,27 @@ export async function discardStagedUpload(path: string): Promise<void> {
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<string>;
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<number> {
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;
@@ -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();
});
});
@@ -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<string, unknown>;
};
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({
@@ -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"));
});
});
+73 -8
View File
@@ -27,6 +27,7 @@ interface Row {
function makeLedger(existingUsers: string[] = []) {
const rows: Row[] = [];
const walked = new Map<string, Date>();
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();
+50 -2
View File
@@ -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<StagingInUse | null> {
const prisma = getWorkerPrisma();
const rows = await prisma.importJob.findMany({
where: { status: { in: [...ACTIVE_IMPORT_STATUSES] } },
select: { status: true, pgBossJobId: true },
});
const paths = new Set<string>();
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
+1
View File
@@ -147,6 +147,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = 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",
+53 -7
View File
@@ -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,
+81 -5
View File
@@ -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<Prisma.TransactionClient, "offhostPurgeRequest">;
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<OffhostPurgeReport> {
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<string, string | null>();
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<string, string[]>();
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) {
@@ -144,20 +144,51 @@ export async function cleanupScheduledTelegramDeletions(): Promise<void> {
/**
* 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;
}
@@ -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"),
);
});
});
@@ -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);
@@ -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 () => {
+25 -18
View File
@@ -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;
}
/**
@@ -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);
});
});
+11 -2
View File
@@ -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(),
@@ -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",
]);
});
});
@@ -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);
});
});
+5
View File
@@ -63,6 +63,11 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"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",