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())