mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/v1393-review-data' into release/v1.39.3
This commit is contained in:
commit
72a34b3b47
39 files changed
+1118
-188
No files matched your search
+14
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user