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:
Marc-André Bombeck authored and GitHub committed 2026-09-30 23:57:44 +02:00
commit c777e9e9e4
14 files changed
+437 -33

No files matched your search

@@ -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);
+14 -11
View File
@@ -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>
+2 -2
View File
@@ -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) ────────