From 9455b64375cce46da1e6e2695cb94f0838761281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:50:35 +0200 Subject: [PATCH] feat(dashboard): send the glucose card in the owner's glucose unit /api/dashboard/summary sent the glucose card's value in mg/dL with unit null and a unitKey that reads mg/dL, so a reader on mmol/L saw the mg/dL number. The card now carries latestValue, secondaryValue and sparkline in the record owner's glucose unit and names it in unit, the field that wins over unitKey, as the sleep card already does. Applied after the summary cache read, so a unit change shows on the next request. --- docs/api/openapi.yaml | 6 ++- .../dashboard/summary/__tests__/route.test.ts | 26 ++++++++++ src/app/api/dashboard/summary/route.ts | 10 +++- .../dashboard/__tests__/glucose-card.test.ts | 47 +++++++++++++++++++ src/lib/dashboard/glucose-card.ts | 39 +++++++++++++++ src/lib/openapi/routes/dashboard.ts | 2 +- 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/lib/dashboard/__tests__/glucose-card.test.ts create mode 100644 src/lib/dashboard/glucose-card.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4e702ad03..611091141 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -71078,8 +71078,10 @@ components: anyOf: - type: string - type: "null" - description: Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours - rather than the canonical SLEEP_DURATION minutes). Null on every other kind. + description: "Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a + per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the + `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` + and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind." sleepStages: anyOf: - type: object diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index e61286e5b..b1c5dc30e 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -561,6 +561,32 @@ describe("GET /api/dashboard/summary", () => { expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString()); }); + it("names the owner's glucose unit on the glucose card", async () => { + // Applied after the summary cache: the unit is the record's own choice + // and the card's numbers are in it (the conversion is pinned in + // `src/lib/dashboard/__tests__/glucose-card.test.ts`). + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, glucoseUnit: "mmol/L" }, + } as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "BLOOD_GLUCOSE", + _count: { _all: 4 }, + _max: { measuredAt: new Date(Date.now() - 10 * 86_400_000) }, + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { metrics: Array<{ id: string; unit: string | null }> }; + }; + expect(body.data.metrics.find((m) => m.id === "glucose")?.unit).toBe( + "mmol/L", + ); + // Every other card keeps its own unit contract. + expect(body.data.metrics.find((m) => m.id === "weight")?.unit).toBeNull(); + }); + it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => { // SLEEP_DURATION is stored one row per STAGE per night (minutes). The // tile must SUM the asleep stages of the latest night and convert to diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 26d76552f..40d0f9994 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -75,6 +75,8 @@ import { } from "@/lib/dashboard/summary-reads"; import { resolveModuleMap } from "@/lib/modules/gate"; import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules"; +import { glucoseCardInDisplayUnit } from "@/lib/dashboard/glucose-card"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { summarizeSleepNights, reconstructSleepNights, @@ -144,6 +146,9 @@ interface MetricCard { * `latestValue` is a per-NIGHT total expressed in HOURS (a float), * not the canonical `SLEEP_DURATION` minutes — see the sleep block * below for why the night total replaced the single-stage value. + * v1.40.0 — the `glucose` tile sets it to the owner's glucose unit + * (`"mg/dL"` or `"mmol/L"`), with its values converted to match + * (`glucoseCardInDisplayUnit`, applied after the cache read). */ unit: string | null; /** @@ -325,7 +330,10 @@ export const GET = apiHandler(async () => { // it off. Filtering the cached body makes the toggle effective on the very // next request. const modules = await resolveModuleMap(user.id); - const metrics = gateMetricCardsByModules(body.metrics, modules); + const glucoseUnit = resolveGlucoseUnit(user.glucoseUnit); + const metrics = gateMetricCardsByModules(body.metrics, modules).map((card) => + glucoseCardInDisplayUnit(card, glucoseUnit), + ); annotate({ meta: { metrics_emitted: metrics.length, diff --git a/src/lib/dashboard/__tests__/glucose-card.test.ts b/src/lib/dashboard/__tests__/glucose-card.test.ts new file mode 100644 index 000000000..1257e2f64 --- /dev/null +++ b/src/lib/dashboard/__tests__/glucose-card.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { glucoseCardInDisplayUnit } from "../glucose-card"; + +const card = (over: Record = {}) => ({ + kind: "glucose", + latestValue: 101 as number | null, + secondaryValue: null as number | null, + sparkline: [90, 101, 126], + unit: null as string | null, + unitKey: "dashboard.metric.unit.glucose", + ...over, +}); + +describe("glucoseCardInDisplayUnit", () => { + it("converts the card to mmol/L and names the unit", () => { + expect(glucoseCardInDisplayUnit(card(), "mmol/L")).toMatchObject({ + latestValue: 5.6, + secondaryValue: null, + sparkline: [5, 5.6, 7], + unit: "mmol/L", + unitKey: "dashboard.metric.unit.glucose", + }); + }); + + it("keeps mg/dL values and names that unit too", () => { + expect(glucoseCardInDisplayUnit(card(), "mg/dL")).toMatchObject({ + latestValue: 101, + sparkline: [90, 101, 126], + unit: "mg/dL", + }); + }); + + it("leaves an empty card empty", () => { + expect( + glucoseCardInDisplayUnit( + card({ latestValue: null, sparkline: [] }), + "mmol/L", + ), + ).toMatchObject({ latestValue: null, sparkline: [], unit: "mmol/L" }); + }); + + it("passes every other card through untouched", () => { + const weight = card({ kind: "weight", latestValue: 80 }); + expect(glucoseCardInDisplayUnit(weight, "mmol/L")).toBe(weight); + }); +}); diff --git a/src/lib/dashboard/glucose-card.ts b/src/lib/dashboard/glucose-card.ts new file mode 100644 index 000000000..e8ca5ab2b --- /dev/null +++ b/src/lib/dashboard/glucose-card.ts @@ -0,0 +1,39 @@ +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; + +/** The fields of a dashboard summary card the glucose unit touches. */ +interface GlucoseConvertibleCard { + kind: string; + latestValue: number | null; + secondaryValue: number | null; + sparkline: number[]; + unit: string | null; +} + +/** + * Put the glucose card in the record owner's display unit. + * + * Glucose is stored in mg/dL. The summary card used to send that number with + * `unit: null` and a `unitKey` that reads "mg/dL", so a reader who chose + * mmol/L was shown 101 "mg/dL" on a client that honours the card. The card now + * carries its value, its secondary value and its sparkline in the chosen unit + * and names that unit in `unit`, the field that wins over `unitKey` (as the + * sleep card's `"h"` already does). Every other card passes through untouched. + * + * Applied after the summary cache is read, so changing the unit takes effect + * on the next request rather than after the cache window. + */ +export function glucoseCardInDisplayUnit( + card: T, + unit: GlucoseUnit, +): T { + if (card.kind !== "glucose") return card; + const show = (value: number | null) => + value === null ? null : convertGlucose(value, unit); + return { + ...card, + latestValue: show(card.latestValue), + secondaryValue: show(card.secondaryValue), + sparkline: card.sparkline.map((value) => convertGlucose(value, unit)), + unit, + }; +} diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 2779f7448..b006ee658 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -186,7 +186,7 @@ const dashboardMetricCard = z .string() .nullable() .describe( - "Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.", + "Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind.", ), sleepStages: z .record(z.string(), z.number())