mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #1065 from MBombeck/fix/v1396-small-fixes
Quick-add follows intakeActionable; hourly buckets for dense CGM inside 90 days
This commit is contained in:
14 files changed
+437
-33
No files matched your search
@@ -41067,8 +41067,8 @@ components:
|
||||
id:
|
||||
type: string
|
||||
description: Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` /
|
||||
`glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` within 90 days when the
|
||||
window holds more than 10 000 readings).
|
||||
`glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` / `glucose` within 90 days
|
||||
when the window holds more than 10 000 readings).
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -41142,9 +41142,11 @@ components:
|
||||
additionalProperties: false
|
||||
description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an
|
||||
explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and
|
||||
`glucose` beyond 90 days per local day, and `pulse` within 90 days per local hour when the window holds more
|
||||
than 10 000 readings (a bucket carries the mean as `value` and its range as `valueMin` / `valueMax`). `stats` is
|
||||
always computed over the underlying readings, never over the buckets."
|
||||
`glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window
|
||||
holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff
|
||||
or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range
|
||||
as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats`
|
||||
is always computed over the underlying readings, never over the buckets."
|
||||
ListMeasurementsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "Einnahme konnte nicht gespeichert werden.",
|
||||
"emptyTitle": "Noch keine Medikamente angelegt",
|
||||
"emptyDescription": "Lege zuerst ein Medikament mit Zeitfenster an, damit du die Einnahme protokollieren kannst.",
|
||||
"emptyCta": "Medikament hinzufügen"
|
||||
"emptyCta": "Medikament hinzufügen",
|
||||
"noneActionableTitle": "Heute ist nichts einzunehmen",
|
||||
"noneActionableDescription": "Deine Medikamente sind pausiert, sind nur dokumentiert, oder ihr Einnahmezeitraum hat noch nicht begonnen oder ist schon vorbei. Eine vergangene Einnahme kannst du weiterhin im Verlauf des Medikaments nachtragen.",
|
||||
"noneActionableCta": "Zu den Medikamenten"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Eintrag verwerfen?",
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "Could not save medication intake.",
|
||||
"emptyTitle": "No medications yet",
|
||||
"emptyDescription": "Create a medication with a schedule first, then come back here to log the intake.",
|
||||
"emptyCta": "Add medication"
|
||||
"emptyCta": "Add medication",
|
||||
"noneActionableTitle": "Nothing to take today",
|
||||
"noneActionableDescription": "Your medications are paused, kept as a record, or their course has not started or has already ended. A past dose can still be added from a medication's history.",
|
||||
"noneActionableCta": "Open medications"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Discard this entry?",
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "No se ha podido guardar la toma.",
|
||||
"emptyTitle": "Aún no hay medicamentos",
|
||||
"emptyDescription": "Crea primero un medicamento con horario, después vuelve aquí para registrar la toma.",
|
||||
"emptyCta": "Añadir medicamento"
|
||||
"emptyCta": "Añadir medicamento",
|
||||
"noneActionableTitle": "Nada que tomar hoy",
|
||||
"noneActionableDescription": "Tus medicamentos están en pausa, se guardan solo como registro, o su tratamiento aún no ha empezado o ya ha terminado. Puedes seguir añadiendo una toma pasada desde el historial del medicamento.",
|
||||
"noneActionableCta": "Abrir medicamentos"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "¿Descartar esta entrada?",
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "Impossible d’enregistrer la prise.",
|
||||
"emptyTitle": "Aucun médicament pour l’instant",
|
||||
"emptyDescription": "Crée d’abord un médicament avec un horaire, puis reviens ici pour saisir la prise.",
|
||||
"emptyCta": "Ajouter un médicament"
|
||||
"emptyCta": "Ajouter un médicament",
|
||||
"noneActionableTitle": "Rien à prendre aujourd’hui",
|
||||
"noneActionableDescription": "Tes médicaments sont en pause, conservés comme simple fiche, ou leur traitement n’a pas encore commencé ou est déjà terminé. Tu peux toujours ajouter une prise passée depuis l’historique du médicament.",
|
||||
"noneActionableCta": "Ouvrir les médicaments"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Abandonner cette saisie ?",
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "Impossibile salvare l’assunzione.",
|
||||
"emptyTitle": "Ancora nessun farmaco",
|
||||
"emptyDescription": "Crea prima un farmaco con un orario, poi torna qui per registrarne l’assunzione.",
|
||||
"emptyCta": "Aggiungi farmaco"
|
||||
"emptyCta": "Aggiungi farmaco",
|
||||
"noneActionableTitle": "Niente da prendere oggi",
|
||||
"noneActionableDescription": "I tuoi farmaci sono in pausa, conservati solo come scheda, oppure la terapia non è ancora iniziata o è già terminata. Puoi comunque aggiungere un’assunzione passata dalla cronologia del farmaco.",
|
||||
"noneActionableCta": "Apri i farmaci"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Scartare questa voce?",
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "복용 기록을 저장하지 못했어요.",
|
||||
"emptyTitle": "등록된 약이 없어요",
|
||||
"emptyDescription": "먼저 일정이 있는 약을 만든 뒤, 여기로 돌아와 복용을 기록하세요.",
|
||||
"emptyCta": "약 추가"
|
||||
"emptyCta": "약 추가",
|
||||
"noneActionableTitle": "오늘 복용할 약이 없어요",
|
||||
"noneActionableDescription": "약이 일시 중지되었거나 기록용으로만 보관 중이거나, 복용 기간이 아직 시작되지 않았거나 이미 끝났어요. 지난 복용은 약의 기록에서 계속 추가할 수 있어요.",
|
||||
"noneActionableCta": "약 목록 열기"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "이 기록을 버릴까요?",
|
||||
|
||||
+4
-1
@@ -533,7 +533,10 @@
|
||||
"saveError": "Nie udało się zapisać przyjęcia.",
|
||||
"emptyTitle": "Brak leków",
|
||||
"emptyDescription": "Najpierw dodaj lek z harmonogramem, a potem wróć tutaj, aby zarejestrować jego przyjęcie.",
|
||||
"emptyCta": "Dodaj lek"
|
||||
"emptyCta": "Dodaj lek",
|
||||
"noneActionableTitle": "Dziś nic do przyjęcia",
|
||||
"noneActionableDescription": "Twoje leki są wstrzymane, zapisane tylko jako wpis albo ich kuracja jeszcze się nie zaczęła lub już się skończyła. Przeszłą dawkę nadal możesz dodać w historii leku.",
|
||||
"noneActionableCta": "Otwórz leki"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Odrzucić ten wpis?",
|
||||
|
||||
@@ -631,6 +631,85 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
|
||||
expect(bucketSql.slice(1)).toContain("hour");
|
||||
});
|
||||
|
||||
it("hour-buckets a CGM glucose window too dense to send raw, in the user's unit", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
// A sensor reading every five minutes for 90 days.
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(25_920 as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bucket_start: new Date("2026-06-01T08:00:00Z"),
|
||||
mean: 100,
|
||||
min_value: 90,
|
||||
max_value: 112,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ n: 25_920, mean: 118.004, min: 48, max: 260, sd: 36.004 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(req("kind=glucose&days=90"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<Record<string, unknown>>;
|
||||
stats: {
|
||||
count: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
stdDev: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(prisma.measurement.count).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ type: "BLOOD_GLUCOSE" }),
|
||||
}),
|
||||
);
|
||||
expect(body.data.unit).toBe("mmol/L");
|
||||
// Converted once at serialization; no band on a glucose point.
|
||||
expect(body.data.points).toEqual([
|
||||
{
|
||||
id: "hour:2026-06-01T08:00:00.000Z",
|
||||
at: "2026-06-01T08:00:00.000Z",
|
||||
value: 5.5,
|
||||
secondary: null,
|
||||
},
|
||||
]);
|
||||
// Stats describe every reading (mg/dL aggregate, converted once).
|
||||
expect(body.data.stats).toEqual({
|
||||
count: 25_920,
|
||||
mean: 6.5,
|
||||
min: 2.7,
|
||||
max: 14.4,
|
||||
stdDev: 2,
|
||||
});
|
||||
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
|
||||
expect(bucketSql.slice(1)).toContain("hour");
|
||||
});
|
||||
|
||||
it("keeps fingerstick glucose at or under the row cap raw", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(360 as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 104, measuredAt: new Date("2026-06-01T07:12:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=90"));
|
||||
const body = (await res.json()) as {
|
||||
data: { points: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(body.data.points).toEqual([
|
||||
{ id: "g1", at: "2026-06-01T07:12:00.000Z", value: 104, secondary: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a pulse window at or under the row cap raw", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(10_000 as never);
|
||||
|
||||
@@ -52,17 +52,20 @@ const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, a pulse series with more rows than this is
|
||||
* bucketed per local hour in SQL instead of sent raw.
|
||||
* #1023 — inside the raw window, a pulse or glucose series with more rows
|
||||
* than this is bucketed per local hour in SQL instead of sent raw.
|
||||
*
|
||||
* A watch that records heart rate once a minute puts 43 000 rows in the
|
||||
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
|
||||
* could not draw at that resolution anyway, built from as many objects on the
|
||||
* server. Hourly buckets carry the hour's mean with its low/high band, the
|
||||
* shape an hourly-bucket import already has, and cap the answer at 24 points
|
||||
* a day. The cap sits far above what sparse sources produce (a cuff, hourly
|
||||
* buckets over 90 days, a watch sampling every five minutes over 30 days), so
|
||||
* those series stay raw and unchanged.
|
||||
* server. A CGM reading every five minutes puts 26 000 glucose rows in 90
|
||||
* days, the same problem at a smaller scale. Hourly buckets carry the hour's
|
||||
* mean (pulse also its low/high band, the shape an hourly-bucket import
|
||||
* already has) and cap the answer at 24 points a day; the statistics stay
|
||||
* computed over every raw reading. The cap sits far above what sparse sources
|
||||
* produce (a cuff, fingerstick glucose, hourly buckets over 90 days, a watch
|
||||
* sampling every five minutes over 30 days), so those series stay raw and
|
||||
* unchanged.
|
||||
*/
|
||||
const DENSE_SERIES_RAW_ROW_CAP = 10_000;
|
||||
|
||||
@@ -368,18 +371,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
} else if (
|
||||
(DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) ||
|
||||
(kind === "pulse" &&
|
||||
(DENSE_SERIES_KINDS.has(kind) &&
|
||||
(await prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
type: KIND_TO_TYPE[kind],
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
})) > DENSE_SERIES_RAW_ROW_CAP)
|
||||
) {
|
||||
// Day buckets past the raw window; hour buckets for a pulse stream too
|
||||
// dense to send raw inside it.
|
||||
// Day buckets past the raw window; hour buckets for a pulse or CGM
|
||||
// stream too dense to send raw inside it.
|
||||
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
|
||||
|
||||
@@ -71,6 +71,7 @@ function makeMed(
|
||||
name: `Med ${id}`,
|
||||
dose: "5 mg",
|
||||
active: true,
|
||||
intakeActionable: true,
|
||||
schedules: [],
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
@@ -316,6 +317,48 @@ describe("<MedicationIntakeQuickAdd> — SSR contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// #1040 — the picker offers what the server calls actionable, nothing
|
||||
// it re-derives. An ended course is still `active` (nobody paused it),
|
||||
// which is exactly the case the old `active && trackIntake` filter let
|
||||
// through.
|
||||
it("does not offer an ended course, even though it is still active (#1040)", () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: [
|
||||
makeMed("ended", {
|
||||
name: "Alpha",
|
||||
active: true,
|
||||
intakeActionable: false,
|
||||
}),
|
||||
makeMed("current", { name: "Zulu" }),
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
const html = renderSSR(<MedicationIntakeQuickAdd />);
|
||||
// Alphabetical fallback would land on "Alpha" if it were offered.
|
||||
expect(html).toMatch(
|
||||
/data-slot="select-value"[^>]*>[\s\S]*?Zulu[\s\S]*?<\/span>/,
|
||||
);
|
||||
expect(html).not.toContain("Alpha");
|
||||
});
|
||||
|
||||
it("shows the empty state when no medication is actionable today (#1040)", () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: [
|
||||
makeMed("ended", { name: "Alpha", intakeActionable: false }),
|
||||
makeMed("upcoming", { name: "Beta", intakeActionable: false }),
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
const html = renderSSR(<MedicationIntakeQuickAdd />);
|
||||
expect(html).toContain('data-testid="medication-intake-quick-add-empty"');
|
||||
expect(html).not.toContain(
|
||||
'data-testid="medication-intake-quick-add-form"',
|
||||
);
|
||||
// The user has medications, so "no medications yet" would be untrue.
|
||||
expect(html).toContain("Nothing to take today");
|
||||
expect(html).not.toContain("No medications yet");
|
||||
});
|
||||
|
||||
it("does not leak raw i18n keys in either locale", () => {
|
||||
mockUseQuery.mockReturnValue({ data: [], isLoading: false });
|
||||
const en = renderSSR(<MedicationIntakeQuickAdd />, "en");
|
||||
|
||||
@@ -81,8 +81,13 @@ interface Schedule extends ScheduleWindowInput {
|
||||
export interface MedicationOption extends DefaultMedicationOption {
|
||||
dose: string;
|
||||
schedules: Schedule[];
|
||||
/** v1.39.1 (#1033) — false: kept as a record, not offered for a dose. */
|
||||
trackIntake?: boolean;
|
||||
/**
|
||||
* The server's answer to "does this medication offer a dose today"
|
||||
* (active, intake tracked, course includes today; see
|
||||
* `src/lib/medications/intake-actionable.ts`). The list read always
|
||||
* carries it, so the picker renders from it and re-derives nothing.
|
||||
*/
|
||||
intakeActionable: boolean;
|
||||
}
|
||||
|
||||
interface MedicationIntakeQuickAddProps {
|
||||
@@ -141,9 +146,10 @@ export function MedicationIntakeQuickAdd({
|
||||
const medications = useMemo(
|
||||
() =>
|
||||
Array.isArray(medicationsRaw)
|
||||
? // v1.39.1 (#1033) — a medication with intake tracking off is
|
||||
// kept as a record; the dose capture does not offer it.
|
||||
medicationsRaw.filter((m) => m.active && m.trackIntake !== false)
|
||||
? // #1040 — offer exactly what the cards, the table and "take
|
||||
// all" offer: a paused, record-only, not-yet-started or ended
|
||||
// course is not a dose to capture now.
|
||||
medicationsRaw.filter((m) => m.intakeActionable === true)
|
||||
: [],
|
||||
[medicationsRaw],
|
||||
);
|
||||
@@ -314,6 +320,10 @@ export function MedicationIntakeQuickAdd({
|
||||
// anlegen</Link>` so the footer-slot promise holds across both
|
||||
// branches.
|
||||
if (!medicationsLoading && medications.length === 0) {
|
||||
// Medications exist, none offers a dose today: say that, rather than
|
||||
// "no medications yet", which would be untrue.
|
||||
const noneActionable =
|
||||
Array.isArray(medicationsRaw) && medicationsRaw.length > 0;
|
||||
const emptyFooter = (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{onCancel && (
|
||||
@@ -328,7 +338,9 @@ export function MedicationIntakeQuickAdd({
|
||||
)}
|
||||
<Button asChild className="min-h-11 sm:min-h-9">
|
||||
<Link href="/medications">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
|
||||
{noneActionable
|
||||
? t("dashboard.medicationIntakeQuickAdd.noneActionableCta")
|
||||
: t("dashboard.medicationIntakeQuickAdd.emptyCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -344,10 +356,16 @@ export function MedicationIntakeQuickAdd({
|
||||
<Pill className="text-muted-foreground h-6 w-6" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyTitle")}
|
||||
{noneActionable
|
||||
? t("dashboard.medicationIntakeQuickAdd.noneActionableTitle")
|
||||
: t("dashboard.medicationIntakeQuickAdd.emptyTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
|
||||
{noneActionable
|
||||
? t(
|
||||
"dashboard.medicationIntakeQuickAdd.noneActionableDescription",
|
||||
)
|
||||
: t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -696,7 +696,7 @@ const seriesPointSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
"Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` within 90 days when the window holds more than 10 000 readings).",
|
||||
"Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` / `glucose` within 90 days when the window holds more than 10 000 readings).",
|
||||
),
|
||||
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
|
||||
value: z.number().describe("Primary value in the top-level `unit`."),
|
||||
@@ -751,7 +751,7 @@ const seriesResponse = z
|
||||
.meta({
|
||||
id: "MeasurementsSeriesResponse",
|
||||
description:
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` within 90 days per local hour when the window holds more than 10 000 readings (a bucket carries the mean as `value` and its range as `valueMin` / `valueMax`). `stats` is always computed over the underlying readings, never over the buckets.",
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
|
||||
});
|
||||
|
||||
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The per-kind glucose series inside the 90-day raw window, on a continuous
|
||||
* sensor stream.
|
||||
*
|
||||
* Past 90 days glucose already folded per day in SQL. Inside it, a sensor
|
||||
* reading every five minutes sent every row raw: 26 000 points in 90 days,
|
||||
* the same class as the per-minute heart-rate stream in
|
||||
* `dense-pulse-raw-window-reads.test.ts`. Over the row cap the series now
|
||||
* folds per local hour; a fingerstick meter stays far under it and keeps its
|
||||
* exact old shape.
|
||||
*
|
||||
* The doctor report deliberately keeps glucose raw inside 90 days: its
|
||||
* clinical panel (time in range, GMI, CV and the risk indices) is computed
|
||||
* from the raw readings of every source, and the day path computes it from
|
||||
* canonical-source day buckets, which is not the same figure when a meter and
|
||||
* a sensor overlap. The last case pins that the panel is still the raw
|
||||
* engine's answer.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { convertGlucose } from "@/lib/glucose";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "America/New_York";
|
||||
const DENSE = "dense-glucose-owner";
|
||||
const SPARSE = "sparse-glucose-owner";
|
||||
/** Forty days of one reading every five minutes: over the raw-window cap. */
|
||||
const DENSE_ROWS = 40 * 288;
|
||||
const NOW = new Date(Math.floor(Date.now() / 300_000) * 300_000 - 300_000);
|
||||
|
||||
/** A fingerstick meter: a handful of readings a day at irregular times. */
|
||||
function sparseReadings(): Array<{ at: Date; value: number }> {
|
||||
const out: Array<{ at: Date; value: number }> = [];
|
||||
for (let day = 1; day <= 30; day++) {
|
||||
const perDay = 2 + (day % 4);
|
||||
for (let i = 0; i < perDay; i++) {
|
||||
out.push({
|
||||
at: new Date(
|
||||
NOW.getTime() - day * 86_400_000 + (i * 211 + day * 17) * 60_000,
|
||||
),
|
||||
value: 78 + ((day * 13 + i * 29) % 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function session(userId: string): Promise<void> {
|
||||
const s = await prisma.session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", s.id);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: DENSE, username: DENSE, timezone: TZ, glucoseUnit: "mmol/L" },
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: { id: SPARSE, username: SPARSE, timezone: TZ },
|
||||
});
|
||||
invalidateUserTimezone(DENSE);
|
||||
invalidateUserTimezone(SPARSE);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO measurements (
|
||||
id, user_id, type, value, unit, source, measured_at,
|
||||
created_at, updated_at, sync_version)
|
||||
SELECT 'cg' || lpad(g::text, 7, '0'), $1, 'BLOOD_GLUCOSE'::measurement_type,
|
||||
62 + ((g * 37) % 170), 'mg/dL', 'NIGHTSCOUT'::measurement_source,
|
||||
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '5 minutes'),
|
||||
now(), now(), 1
|
||||
FROM generate_series(0, $3::int - 1) g`,
|
||||
DENSE,
|
||||
NOW.toISOString(),
|
||||
DENSE_ROWS,
|
||||
);
|
||||
await prisma.measurement.createMany({
|
||||
data: sparseReadings().map((r, i) => ({
|
||||
id: `fs${String(i).padStart(4, "0")}`,
|
||||
userId: SPARSE,
|
||||
type: "BLOOD_GLUCOSE" as const,
|
||||
value: r.value,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: r.at,
|
||||
})),
|
||||
});
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
}, 120_000);
|
||||
|
||||
async function series(userId: string, days: number) {
|
||||
await session(userId);
|
||||
const { GET } = await import("@/app/api/measurements/series/route");
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/measurements/series?kind=glucose&days=${days}`,
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return (
|
||||
(await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<Record<string, unknown> & { id: string; at: string }>;
|
||||
stats: {
|
||||
count: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
stdDev: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
).data;
|
||||
}
|
||||
|
||||
describe("per-kind glucose series inside the raw window", () => {
|
||||
it("sends a sensor stream as hour buckets in the user's unit, statistics over every reading", async () => {
|
||||
const data = await series(DENSE, 90);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: DENSE, type: "BLOOD_GLUCOSE" },
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
expect(rows).toHaveLength(DENSE_ROWS);
|
||||
|
||||
// Forty days of hours, give or take the partial hours at either end.
|
||||
expect(data.points.length).toBeLessThanOrEqual(40 * 24 + 2);
|
||||
expect(data.points.length).toBeGreaterThanOrEqual(40 * 24 - 2);
|
||||
expect(data.points.every((p) => p.id.startsWith("hour:"))).toBe(true);
|
||||
expect(data.unit).toBe("mmol/L");
|
||||
|
||||
// Every bucket is the mean of its hour's readings, converted once. New
|
||||
// York sits a whole number of hours off UTC, so its local hours are UTC
|
||||
// hours.
|
||||
const byHour = new Map<number, number[]>();
|
||||
for (const r of rows) {
|
||||
const hour = Math.floor(r.measuredAt.getTime() / 3_600_000) * 3_600_000;
|
||||
(byHour.get(hour) ?? byHour.set(hour, []).get(hour)!).push(r.value);
|
||||
}
|
||||
for (const p of data.points) {
|
||||
const values = byHour.get(new Date(p.at).getTime());
|
||||
expect(values, p.at).toBeDefined();
|
||||
const mean = values!.reduce((a, b) => a + b, 0) / values!.length;
|
||||
expect(p).toEqual({
|
||||
id: `hour:${p.at}`,
|
||||
at: p.at,
|
||||
value: convertGlucose(mean, "mmol/L"),
|
||||
secondary: null,
|
||||
});
|
||||
}
|
||||
|
||||
const values = rows.map((r) => r.value);
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
expect(data.stats.count).toBe(DENSE_ROWS);
|
||||
expect(data.stats.min).toBe(convertGlucose(Math.min(...values), "mmol/L"));
|
||||
expect(data.stats.max).toBe(convertGlucose(Math.max(...values), "mmol/L"));
|
||||
expect(data.stats.mean).toBe(
|
||||
convertGlucose(Math.round(mean * 100) / 100, "mmol/L"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps fingerstick readings raw, one point per reading", async () => {
|
||||
const data = await series(SPARSE, 90);
|
||||
const expected = sparseReadings()
|
||||
.map((r, i) => ({
|
||||
id: `fs${String(i).padStart(4, "0")}`,
|
||||
at: r.at.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
}))
|
||||
.sort((a, b) => a.at.localeCompare(b.at));
|
||||
expect(data.unit).toBe("mg/dL");
|
||||
expect(data.points).toEqual(expected);
|
||||
expect(data.stats.count).toBe(expected.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor report keeps glucose raw inside the raw window", () => {
|
||||
it("computes the clinical panel from every raw reading", async () => {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 60 * 86_400_000);
|
||||
const data = await collectDoctorReportData(
|
||||
DENSE,
|
||||
{ start, end, days: 60 },
|
||||
selectionFromLeaves(["BLOOD_GLUCOSE", "GLUCOSE_PANEL"]),
|
||||
);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: DENSE,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
expect(data.measurements.BLOOD_GLUCOSE).toHaveLength(rows.length);
|
||||
expect(data.glucoseClinical).toEqual(
|
||||
computeGlucoseClinicalMetrics(
|
||||
rows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: 60, now: end },
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user