feat(glucose): read glucose in one unit across series, CSV, and detail page

Glucose is stored canonical mg/dL but the series DTO, CSV export, and detail
page emitted raw mg/dL while FHIR already converted to the user preference.

Convert at serialization through the single convertGlucose helper:
- series route sets unit from the user's glucoseUnit and converts each point
- CSV export converts BLOOD_GLUCOSE rows and emits the unit in the unit column
- the detail page + stat strip honour the preference via the chart value scale
- declare the trailing-90-day window in the glucose median label across locales

mmol/L-preference users now see the blood-glucose page, stat strip, iOS series,
and CSV in mmol/L; mg/dL users are unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:16:48 +02:00
1 parent a2260d5bb8
commit 25944ed57a
17 files changed
+398 -26

No files matched your search

+1
View File
@@ -2791,6 +2791,7 @@
"title": "Blutzucker",
"description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.",
"chartTitle": "Blutzucker",
"medianLabel": "Median Blutzucker (90 Tage)",
"emptyState": {
"title": "Noch keine Glukosedaten",
"description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert."
+1
View File
@@ -2791,6 +2791,7 @@
"title": "Blood Glucose",
"description": "Your blood-glucose readings over time, in mg/dL.",
"chartTitle": "Blood glucose",
"medianLabel": "Median glucose (90 days)",
"emptyState": {
"title": "No glucose data yet",
"description": "Blood-glucose readings appear here once you log one or a connected device syncs."
+1
View File
@@ -2791,6 +2791,7 @@
"title": "Glucosa en sangre",
"description": "Tus lecturas de glucosa en el tiempo, en mg/dL.",
"chartTitle": "Glucosa en sangre",
"medianLabel": "Glucosa mediana (90 días)",
"emptyState": {
"title": "Aún no hay datos de glucosa",
"description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza."
+1
View File
@@ -2791,6 +2791,7 @@
"title": "Glycémie",
"description": "Vos relevés de glycémie au fil du temps, en mg/dL.",
"chartTitle": "Glycémie",
"medianLabel": "Glycémie médiane (90 jours)",
"emptyState": {
"title": "Aucune donnée de glycémie",
"description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise."
+1
View File
@@ -2791,6 +2791,7 @@
"title": "Glicemia",
"description": "Le tue letture di glicemia nel tempo, in mg/dL.",
"chartTitle": "Glicemia",
"medianLabel": "Glicemia mediana (90 giorni)",
"emptyState": {
"title": "Nessun dato di glicemia",
"description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza."
+1
View File
@@ -2791,6 +2791,7 @@
"title": "Poziom glukozy",
"description": "Twoje odczyty glukozy w czasie, w mg/dL.",
"chartTitle": "Poziom glukozy",
"medianLabel": "Mediana glukozy (90 dni)",
"emptyState": {
"title": "Brak danych o glukozie",
"description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia."
@@ -135,6 +135,66 @@ describe("GET /api/export/measurements", () => {
}),
);
});
// v1.16.16 — glucose unit-at-source. A mmol/L-preference user's CSV emits
// BLOOD_GLUCOSE converted (100 → 5.5) with `mmol/L` in the unit column.
it("exports BLOOD_GLUCOSE in the user's mmol/L preference", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
source: "MANUAL",
notes: null,
glucoseContext: "FASTING",
},
] as never);
const { GET } = await import("../measurements/route");
const res = await GET(mkReq("http://localhost/api/export/measurements"));
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("BLOOD_GLUCOSE,5.5,mmol/L");
expect(body).not.toContain("100,mg/dL");
});
it("exports BLOOD_GLUCOSE in raw mg/dL for a mg/dL-preference user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
source: "MANUAL",
notes: null,
glucoseContext: "FASTING",
},
] as never);
const { GET } = await import("../measurements/route");
const res = await GET(mkReq("http://localhost/api/export/measurements"));
const body = await res.text();
expect(body).toContain("BLOOD_GLUCOSE,100,mg/dL");
});
});
describe("GET /api/export/medications", () => {
+18 -8
View File
@@ -25,6 +25,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -45,20 +46,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
? "raw"
: "night";
const [measurements, userTz, sourcePriorityJson] = await Promise.all([
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
}),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
]);
const [measurements, userTz, sourcePriorityJson, profile] =
await Promise.all([
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
}),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
// v1.16.16 — resolve the glucose display unit so BLOOD_GLUCOSE rows
// export in the user's unit (matching FHIR), not raw stored mg/dL.
prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
}),
]);
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const csv = toCSV(
formatMeasurementsForExport(measurements, userTz, {
granularity,
sleepTz: userTz,
sourcePriorityJson,
glucoseUnit,
}),
);
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
@@ -66,6 +67,9 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
} as never);
});
describe("GET /api/measurements/series", () => {
@@ -173,6 +177,67 @@ describe("GET /api/measurements/series", () => {
expect(body.data.unit).toBe("kg");
});
it("returns glucose in mg/dL with raw values for a mg/dL-preference user (v1.16.16)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
{ id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { unit: string; points: Array<{ value: number }> };
};
expect(body.data.unit).toBe("mg/dL");
expect(body.data.points.map((p) => p.value)).toEqual([100, 126]);
});
it("converts glucose to mmol/L (value + unit) for a mmol/L-preference user (v1.16.16)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
{ id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
unit: string;
points: Array<{ value: number }>;
stats: { min: number; max: number };
};
};
expect(body.data.unit).toBe("mmol/L");
// 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L (1-decimal).
expect(body.data.points[0].value).toBe(5.5);
expect(body.data.points[1].value).toBe(7);
// Stats are derived from the converted points → also in mmol/L.
expect(body.data.stats.min).toBe(5.5);
expect(body.data.stats.max).toBe(7);
});
it("defaults glucose to mg/dL when the user has no preference (v1.16.16)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: { unit: string; points: Array<{ value: number }> };
};
expect(body.data.unit).toBe("mg/dL");
expect(body.data.points[0].value).toBe(100);
});
it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => {
// v1.5.5 — the previous 365-day cap rejected the iOS app's
// "Alle"-range request with a 422, painting an error banner on
+33 -6
View File
@@ -25,6 +25,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
@@ -87,6 +88,11 @@ const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
* the systolic type's unit (mmHg, same for both lines). `sleep` is
* overridden at request time to `"h"` because the route returns per-night
* TIME-ASLEEP in hours rather than the canonical per-stage minutes.
*
* v1.16.16 — `glucose` is the canonical-stored unit (mg/dL) here; it is
* overridden at request time to the user's `glucoseUnit` preference and the
* point values are converted to match, so the wire DTO reads in ONE unit
* across dashboard, detail page, CSV, FHIR, and the iOS series chart.
*/
const SERIES_UNIT: Record<z.infer<typeof kindEnum>, string> = {
weight: "kg",
@@ -300,12 +306,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
});
points = rows.map((r) => ({
id: r.id,
at: r.measuredAt.toISOString(),
value: r.value,
secondary: null,
}));
if (kind === "glucose") {
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
// the user's display unit AT SERIALIZATION so the wire DTO is unit-
// coherent with the CSV + FHIR exports (one number, one engine). The
// top-level `unit` token follows. mg/dL-preference users are unchanged
// (convertGlucose rounds the integer); mmol/L users see 1-decimal
// values (100 → 5.6) and `unit: "mmol/L"`.
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
});
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
unit = glucoseUnit;
points = rows.map((r) => ({
id: r.id,
at: r.measuredAt.toISOString(),
value: convertGlucose(r.value, glucoseUnit),
secondary: null,
}));
} else {
points = rows.map((r) => ({
id: r.id,
at: r.measuredAt.toISOString(),
value: r.value,
secondary: null,
}));
}
}
const dataPoints: DataPoint[] = points.map((p) => ({
+26 -2
View File
@@ -3,6 +3,9 @@
import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { resolveGlucoseUnit } from "@/lib/glucose";
/**
* v1.7.0 — `/insights/blood-glucose`.
@@ -11,8 +14,23 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*
* v1.16.16 — glucose unit-at-source. The chart + stat strip honour the
* user's `glucoseUnit` preference: stored canonical mg/dL is converted to
* mmol/L (÷18.0182, 1-decimal) for mmol/L-preference users via the chart's
* `valueScale` (which the page also folds into the stat-strip summary), so
* the detail page reads in the SAME unit as the series DTO, CSV, and FHIR
* exports. mg/dL-preference users are unaffected (scale = 1, integer
* precision).
*/
const MGDL_PER_MMOL = 18.0182;
export default function InsightsBlutzuckerPage() {
const { user } = useAuth();
const { t } = useTranslations();
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const isMmol = glucoseUnit === "mmol/L";
return (
<HealthKitMetricPage
measurementType="BLOOD_GLUCOSE"
@@ -22,8 +40,14 @@ export default function InsightsBlutzuckerPage() {
i18nPrefix="insights.bloodGlucose"
explainerMetric="bloodGlucose"
color="#ff79c6"
unit="mg/dL"
yAxisUnit="mg/dL"
unit={glucoseUnit}
yAxisUnit={glucoseUnit}
// mmol/L = mg/dL ÷ 18.0182; the chart's valueScale multiplies, so pass
// the reciprocal. mg/dL keeps identity scale (1).
valueScale={isMmol ? 1 / MGDL_PER_MMOL : 1}
// Integer mg/dL readings; one decimal for the mmol/L SI scale.
statFractionDigits={isMmol ? 1 : 0}
statMedianLabel={t("insights.bloodGlucose.medianLabel")}
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
targetSummarySlug="blood-glucose"
@@ -70,6 +70,37 @@ describe("<MetricStatStrip>", () => {
expect(html).toBe("");
});
it("renders the default 'Median' label when no override is given", () => {
const html = render(<MetricStatStrip summary={populated} unit="bpm" />);
expect(html).toContain("Median");
});
// v1.16.16 — glucose declares the trailing-90-day p50 window in the label
// so the median is not read as an all-time central value.
it("renders an overridden median label when medianLabel is provided", () => {
const html = render(
<MetricStatStrip
summary={populated}
unit="mg/dL"
medianLabel="Median glucose (90 days)"
/>,
);
expect(html).toContain("Median glucose (90 days)");
});
it("honours fractionDigits for integer-unit metrics (0 decimals)", () => {
const html = render(
<MetricStatStrip
summary={{ ...populated, min: 100, max: 180, median: 126 }}
unit="mg/dL"
fractionDigits={0}
/>,
);
// No "100.0" — integer precision.
expect(html).toContain("100 mg/dL");
expect(html).not.toContain("100.0");
});
it("falls back to mean when median is absent (older payload)", () => {
const noMedian: DataSummary = { ...populated, median: null };
const html = render(<MetricStatStrip summary={noMedian} unit="bpm" />);
@@ -142,6 +142,18 @@ export interface HealthKitMetricPageProps {
* "no trend to project yet" state rather than a line.
*/
forecast?: boolean;
/**
* v1.16.16 — decimal precision for the stat-strip values. Defaults to the
* strip's own default (1). Blood glucose passes 0 for mg/dL (integer
* readings) and 1 for mmol/L so the numbers and the unit agree.
*/
statFractionDigits?: number;
/**
* v1.16.16 — optional override for the stat strip's "Median" label. Blood
* glucose passes a window- + context-declaring string so the trailing
* 90-day p50 is not read as an all-time central value.
*/
statMedianLabel?: string;
}
export function HealthKitMetricPage({
@@ -162,6 +174,8 @@ export function HealthKitMetricPage({
statusMetric,
statIcon,
forecast = false,
statFractionDigits,
statMedianLabel,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -289,9 +303,11 @@ export function HealthKitMetricPage({
<MetricStatStrip
summary={summary}
unit={yAxisUnit ?? unit}
fractionDigits={statFractionDigits}
seriesLabel={title}
icon={statIcon}
windowStats={statsByType?.[measurementType] ?? null}
medianLabel={statMedianLabel}
/>
}
diversityNudge={
+13 -1
View File
@@ -92,6 +92,15 @@ export interface MetricStatSeries {
* single-series mode omits it.
*/
dataKey?: string;
/**
* v1.16.16 — optional override for the "Median" cell label. The strip's
* median is a trailing-90-day p50 (see `DataSummary.median` /
* `summaries-slice.ts`); the default generic "Median" label leaves that
* window undeclared. Blood glucose passes a window- + context-declaring
* string (e.g. "Median glucose (90 days)") so the number is not read as an
* all-time central value. Omit it to keep the generic label.
*/
medianLabel?: string;
}
type MetricStatStripProps = Partial<MetricStatSeries> & {
@@ -129,6 +138,7 @@ function SeriesBlock({
icon,
windowStats,
dataKey,
medianLabel,
}: MetricStatSeries) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -154,7 +164,7 @@ function SeriesBlock({
{ key: "max", label: t("insights.subPage.stats.max"), value: source.max },
{
key: "median",
label: t("insights.subPage.stats.median"),
label: medianLabel ?? t("insights.subPage.stats.median"),
value: source.median,
},
{
@@ -203,6 +213,7 @@ export function MetricStatStrip({
icon,
windowStats,
dataKey,
medianLabel,
series,
groupLabel,
}: MetricStatStripProps) {
@@ -264,6 +275,7 @@ export function MetricStatStrip({
icon={icon}
windowStats={windowStats}
dataKey={dataKey}
medianLabel={medianLabel}
/>
</CardContent>
</Card>
+71
View File
@@ -128,6 +128,77 @@ describe("formatMeasurementsForExport", () => {
});
});
// v1.16.16 — glucose unit-at-source. BLOOD_GLUCOSE is stored mg/dL; the
// CSV export must convert to the user's display unit (matching FHIR) and
// emit that unit in the `unit` column. mg/dL-preference users unchanged.
it("keeps BLOOD_GLUCOSE in mg/dL when the user prefers mg/dL", () => {
const measurements = [
{
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
measuredAt: new Date("2025-01-15T08:00:00Z"),
source: "MANUAL",
notes: null,
glucoseContext: "FASTING",
},
];
const result = formatMeasurementsForExport(measurements, undefined, {
glucoseUnit: "mg/dL",
});
expect(result[0].value).toBe(100);
expect(result[0].unit).toBe("mg/dL");
});
it("converts BLOOD_GLUCOSE value + unit to mmol/L when the user prefers mmol/L", () => {
const measurements = [
{
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
measuredAt: new Date("2025-01-15T08:00:00Z"),
source: "MANUAL",
notes: null,
glucoseContext: "FASTING",
},
{
type: "BLOOD_GLUCOSE",
value: 126,
unit: "mg/dL",
measuredAt: new Date("2025-01-16T08:00:00Z"),
source: "MANUAL",
notes: null,
glucoseContext: "FASTING",
},
];
const result = formatMeasurementsForExport(measurements, undefined, {
glucoseUnit: "mmol/L",
});
// 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L.
expect(result[0].value).toBe(5.5);
expect(result[0].unit).toBe("mmol/L");
expect(result[1].value).toBe(7);
expect(result[1].unit).toBe("mmol/L");
});
it("does not touch non-glucose rows when a glucose unit is set", () => {
const measurements = [
{
type: "WEIGHT",
value: 80.4,
unit: "kg",
measuredAt: new Date("2025-01-15T08:00:00Z"),
source: "MANUAL",
notes: null,
},
];
const result = formatMeasurementsForExport(measurements, undefined, {
glucoseUnit: "mmol/L",
});
expect(result[0].value).toBe(80.4);
expect(result[0].unit).toBe("kg");
});
// v1.4.25 W7 — per-user timezone offset in exports (issue #167).
it("emits ISO-8601 with user-tz offset when userTz is provided", () => {
const measurements = [
+34
View File
@@ -63,6 +63,40 @@ describe("glucose conversion", () => {
expect(mmolToMgdl(3.9)).toBe(70); // hypoglycemia threshold
});
// v1.16.16 — "one number, one engine" coherence. The wire series DTO, the
// CSV export, and the FHIR export each convert a stored mg/dL reading to the
// user's display unit through the SAME `convertGlucose` helper, so the three
// surfaces emit an identical value + unit. This guards against any surface
// drifting back to a render-time or hard-coded conversion.
it("emits a coherent value+unit across series / CSV / FHIR for mmol/L", () => {
const storedMgdl = 126; // ADA diabetes threshold
const unit = resolveGlucoseUnit("mmol/L");
// Series route: point value = convertGlucose(row.value, unit).
const seriesValue = convertGlucose(storedMgdl, unit);
// CSV export: BLOOD_GLUCOSE row value = convertGlucose(m.value, unit).
const csvValue = convertGlucose(storedMgdl, unit);
// FHIR export: valueQuantity.value = convertGlucose(stat.latest, unit).
const fhirValue = convertGlucose(storedMgdl, unit);
expect(unit).toBe("mmol/L");
expect(seriesValue).toBe(7);
expect(csvValue).toBe(seriesValue);
expect(fhirValue).toBe(seriesValue);
});
it("emits a coherent value+unit across series / CSV / FHIR for mg/dL", () => {
const storedMgdl = 100;
const unit = resolveGlucoseUnit("mg/dL");
const seriesValue = convertGlucose(storedMgdl, unit);
const csvValue = convertGlucose(storedMgdl, unit);
const fhirValue = convertGlucose(storedMgdl, unit);
expect(unit).toBe("mg/dL");
expect(seriesValue).toBe(100);
expect(csvValue).toBe(seriesValue);
expect(fhirValue).toBe(seriesValue);
});
it("toCanonicalMgdl is the inverse of convertGlucose at the editor boundary", () => {
// mg/dL display unit is already canonical — only rounds.
expect(toCanonicalMgdl(126, "mg/dL")).toBe(126);
+25 -9
View File
@@ -20,6 +20,7 @@ import {
pickMainNightAndNaps,
type SleepStageRow,
} from "./analytics/sleep-night";
import { convertGlucose, type GlucoseUnit } from "./glucose";
import type { SleepStage } from "@/generated/prisma/client";
export interface ExportableRecord {
@@ -160,18 +161,33 @@ export function formatMeasurementsForExport(
* night — without it the export silently fell back to the default ladder.
*/
sourcePriorityJson?: unknown;
/**
* v1.16.16 — the user's resolved glucose display unit. BLOOD_GLUCOSE is
* stored canonical mg/dL; when this is `"mmol/L"` the export converts the
* value (1 decimal) and emits `mmol/L` in the `unit` column, matching the
* FHIR export (`src/lib/fhir/resources.ts`) so the CSV reads in ONE unit.
* Defaults to `"mg/dL"` (the canonical-stored unit) → byte-identical for
* mg/dL-preference users.
*/
glucoseUnit?: GlucoseUnit;
} = {},
): ExportableRecord[] {
const granularity = opts.granularity ?? "night";
const toRecord = (m: ExportMeasurement): ExportableRecord => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: formatTimestamp(m.measuredAt, userTz),
source: m.source,
notes: m.notes ?? "",
glucoseContext: m.glucoseContext ?? "",
});
const glucoseUnit: GlucoseUnit = opts.glucoseUnit ?? "mg/dL";
const toRecord = (m: ExportMeasurement): ExportableRecord => {
// BLOOD_GLUCOSE is stored mg/dL; convert to the user's display unit at
// serialization so the value + unit column agree and match FHIR.
const isGlucose = m.type === "BLOOD_GLUCOSE";
return {
type: m.type,
value: isGlucose ? convertGlucose(m.value, glucoseUnit) : m.value,
unit: isGlucose ? glucoseUnit : m.unit,
measuredAt: formatTimestamp(m.measuredAt, userTz),
source: m.source,
notes: m.notes ?? "",
glucoseContext: m.glucoseContext ?? "",
};
};
if (granularity === "raw") {
return measurements.map(toRecord);