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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:50:35 +02:00
1 parent cfddf097c7
commit 9455b64375
6 files changed
+126 -4

No files matched your search

+4 -2
View File
@@ -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
@@ -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
+9 -1
View File
@@ -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,
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { glucoseCardInDisplayUnit } from "../glucose-card";
const card = (over: Record<string, unknown> = {}) => ({
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);
});
});
+39
View File
@@ -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<T extends GlucoseConvertibleCard>(
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,
};
}
+1 -1
View File
@@ -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())