mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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) ────────
|
||||
|
||||
Reference in new issue
Block a user