mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a2260d5bb8
commit
25944ed57a
17 files changed
+398
-26
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user