Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 06:35:35 +02:00
commit 7c5f49c18e
36 files changed
+1613 -45

No files matched your search

@@ -5,6 +5,11 @@
* provider type, failing counted from the LAST result only, the worst
* uninterrupted failure run taken across failing users, central types
* sorted first, and no per-user data in the response.
*
* It also pins the HTTP status the ledger has recorded on every failure
* since v1.11.0 and nothing read until now: it must describe the SAME
* failure the row's instant names, or the card tells the operator to go
* looking at the wrong thing.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -49,11 +54,13 @@ function group(
consecutiveFailures?: number;
lastFailureAt?: Date | null;
lastOkAt?: Date | null;
lastStatus?: number | null;
} = {},
) {
return {
providerType,
lastResult,
lastStatus: max.lastStatus ?? null,
_count: { _all: count },
_max: {
consecutiveFailures: max.consecutiveFailures ?? 0,
@@ -86,6 +93,7 @@ describe("GET /api/admin/provider-health", () => {
group("admin-openai", "hard_failed", 2, {
consecutiveFailures: 3334,
lastFailureAt: new Date("2026-08-27T06:00:00Z"),
lastStatus: 503,
}),
] as never);
@@ -99,10 +107,64 @@ describe("GET /api/admin/provider-health", () => {
maxConsecutiveFailures: 3334,
lastOkAt: "2026-08-27T07:00:00.000Z",
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: 503,
},
]);
});
it("reports the status of the newest failure, not the highest number", async () => {
// A 500 an hour ago and a 401 a minute ago: the operator needs the
// 401, because that is the one that will not clear on its own. Taking
// a MAX over the column would hand back the 500.
groupBy.mockResolvedValue([
group("openai", "hard_failed", 4, {
consecutiveFailures: 2,
lastFailureAt: new Date("2026-08-27T05:00:00Z"),
lastStatus: 500,
}),
group("openai", "auth_failed", 1, {
consecutiveFailures: 1,
lastFailureAt: new Date("2026-08-27T06:59:00Z"),
lastStatus: 401,
}),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.providers[0]).toMatchObject({
lastFailureAt: "2026-08-27T06:59:00.000Z",
lastFailureStatus: 401,
});
});
it("leaves the status out for a network-class failure that never had one", async () => {
groupBy.mockResolvedValue([
group("local", "hard_failed", 1, {
consecutiveFailures: 9,
lastFailureAt: new Date("2026-08-27T06:00:00Z"),
lastStatus: null,
}),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.providers[0]).toMatchObject({
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: null,
});
});
it("asks the ledger to split the fold by status rather than aggregate it", async () => {
groupBy.mockResolvedValue([] as never);
await GET();
expect(groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ["providerType", "lastResult", "lastStatus"],
}),
);
});
it("counts auth_failed as failing and keeps an all-ok type at zero", async () => {
groupBy.mockResolvedValue([
group("codex", "auth_failed", 1, { consecutiveFailures: 7 }),
+23 -1
View File
@@ -15,6 +15,12 @@ export const dynamic = "force-dynamic";
* folds the ledger into one row per provider type so the console can show
* it. Read-only, admin-cookie-only, and it exposes counts and timestamps
* only — never which user a row belongs to.
*
* The ledger also records the HTTP status each failure came back with, and
* that is the difference between the two questions an operator actually has
* when a provider goes red: a 401 means the key is dead and only the operator
* can fix it, a 429 or a 5xx means the provider is having a day and waiting is
* the right move. The fold carries it beside the instant it belongs to.
*/
interface ProviderHealthSummary {
@@ -27,6 +33,13 @@ interface ProviderHealthSummary {
maxConsecutiveFailures: number;
lastOkAt: string | null;
lastFailureAt: string | null;
/**
* HTTP status the failure at `lastFailureAt` came back with. Null when
* that failure was a network-class one (no response to read a status
* from), and null when the user it belongs to has since recovered — a
* success clears the status along with the rest of the failure state.
*/
lastFailureStatus: number | null;
}
/** The two operator-managed tags sort first — they affect every user on the chain. */
@@ -37,7 +50,11 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "admin.provider-health.get" } });
const groups = await prisma.providerHealth.groupBy({
by: ["providerType", "lastResult"],
// `lastStatus` joins the grouping key rather than an aggregate: the
// MAX of two HTTP statuses is not a status, it is arithmetic on a
// label. Grouping by it splits each result bucket per status and the
// fold below picks the one belonging to the newest failure.
by: ["providerType", "lastResult", "lastStatus"],
_count: { _all: true },
_max: {
consecutiveFailures: true,
@@ -55,6 +72,7 @@ export const GET = apiHandler(async () => {
maxConsecutiveFailures: 0,
lastOkAt: null,
lastFailureAt: null,
lastFailureStatus: null,
};
row.tracked += g._count._all;
if (g.lastResult !== "ok") {
@@ -69,6 +87,10 @@ export const GET = apiHandler(async () => {
const fail = g._max.lastFailureAt?.toISOString() ?? null;
if (fail && (!row.lastFailureAt || fail > row.lastFailureAt)) {
row.lastFailureAt = fail;
// Tied to the instant, not taken from whichever group happens to
// have one: a status that describes a different failure than the
// one on screen is worse than no status at all.
row.lastFailureStatus = g.lastStatus;
}
byType.set(g.providerType, row);
}
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The blood-glucose display unit — the writer the column never had.
*
* `User.glucoseUnit` shipped in v1.2 and about thirty surfaces read it, but
* no route, form or schema could ever set it, so every account stayed on the
* mg/dL default and the mmol/L half of the app was dead code. This pins the
* missing end: a PATCH writes the column and echoes the resolved state, a GET
* reads it back, and an unrecognised unit is refused rather than stored —
* a stray string in that column would resolve to mg/dL and quietly put every
* mmol/L reader back on the wrong number.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkGet(): Request {
return new Request("http://localhost/api/auth/me/glucose-unit");
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/glucose-unit", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("resolves an account that never chose to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mg/dL");
});
it("reads back the unit the account chose", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
});
});
describe("PATCH /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("moves an account to mmol/L, echoes it, and audits the transition", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
// The write must actually land, scoped to the caller, field-by-field.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mmol/L" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.glucose-unit.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: "mg/dL",
next: "mmol/L",
}),
}),
);
});
it("moves an account back to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mg/dL" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mg/dL" },
});
});
it("refuses a unit outside the two the app renders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: bad }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a missing glucoseUnit field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ otherField: "mmol/L" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Per-user blood-glucose display unit.
*
* GET /api/auth/me/glucose-unit — current unit.
* PATCH /api/auth/me/glucose-unit — body
* `{ glucoseUnit: "mg/dL" | "mmol/L" }`.
*
* The column has existed since v1.2 and roughly thirty surfaces read it —
* the dashboard tiles, the glucose insight page, the targets panel, the CSV
* and FHIR exports, the doctor report, the Coach snapshot, the safety-floor
* push copy. None of it was reachable, because nothing could ever write the
* column: every account was NULL, and NULL resolves to mg/dL. For anyone in
* a country that reads glucose in mmol/L that was the wrong number on every
* screen. This is the missing end.
*
* Canonical storage stays mg/dL on every row and every ingest path. The unit
* selects a display branch, exactly like `unit-preference` next door; the
* entry form inverts through `toCanonicalMgdl` so a value TYPED in mmol/L
* still persists as mg/dL. Nothing already stored is reinterpreted.
*
* Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate limit,
* `safeJson` with a 1 KB cap, Zod safeParse → 422 via `returnAllZodIssues`,
* audit-log row, field-by-field write. Idempotent — the endpoint always
* returns the resolved next state so the client can hard-set the optimistic
* update without an extra round-trip.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type GlucoseUnitResponse = {
glucoseUnit: GlucoseUnit;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.glucose-unit.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
});
const payload: GlucoseUnitResponse = {
glucoseUnit: resolveGlucoseUnit(row?.glucoseUnit),
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`glucose-unit:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = glucoseUnitPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.glucose-unit.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.glucoseUnit;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
});
await prisma.user.update({
where: { id: user.id },
data: { glucoseUnit: next },
});
await auditLog("user.glucose-unit.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: resolveGlucoseUnit(previous?.glucoseUnit),
next,
},
});
annotate({
action: { name: "auth.me.glucose-unit.patch" },
meta: { glucoseUnit: next },
});
const payload: GlucoseUnitResponse = { glucoseUnit: next };
return apiSuccess(payload);
});
@@ -59,6 +59,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => {
import { NextRequest } from "next/server";
import { POST } from "../route";
import { MGDL_PER_MMOL } from "@/lib/glucose";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
@@ -369,7 +370,7 @@ describe("POST /api/import/csv — contextless blood glucose", () => {
externalId: "sensor-1",
glucoseContext: null,
});
expect(row.value).toBeCloseTo(95.4848, 3);
expect(row.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
expect((row.measuredAt as Date).toISOString()).toBe(
"2024-04-03T02:15:00.000Z",
);
+1 -3
View File
@@ -7,7 +7,7 @@ import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clin
import { useAuth } from "@/hooks/use-auth";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { useTranslations } from "@/lib/i18n/context";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
/**
* v1.7.0 — `/insights/blood-glucose`.
@@ -25,8 +25,6 @@ import { resolveGlucoseUnit } from "@/lib/glucose";
* 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();
@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The provider-health card and the HTTP status of the last failure.
*
* The ledger has recorded a status on every failure since v1.11.0 and no
* surface read it, so the card could say a provider was failing without
* saying whose problem it was. A 401 is a credential the operator has to
* replace; a 503 is the provider's afternoon. These pin that the status
* reaches the row, that it is left out when there was none to record,
* and that a healthy provider does not carry a stale one.
*/
const mockQueryState = vi.hoisted(() => ({
data: null as null | object,
isPending: false,
isError: false,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: mockQueryState.data,
isPending: mockQueryState.isPending,
isError: mockQueryState.isError,
}),
}));
import { ProviderHealthSection } from "../provider-health-section";
function row(over: Record<string, unknown> = {}) {
return {
providerType: "admin-openai",
tracked: 5,
failing: 2,
maxConsecutiveFailures: 4,
lastOkAt: null,
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: 401,
...over,
};
}
function render(providers: object[]) {
mockQueryState.data = { providers };
mockQueryState.isPending = false;
mockQueryState.isError = false;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ProviderHealthSection />
</I18nProvider>,
);
}
describe("<ProviderHealthSection> — the last failure's HTTP status", () => {
it("names the status beside the failure it belongs to", () => {
expect(render([row()])).toContain("HTTP 401");
});
it("says nothing where the failure never had a status to record", () => {
expect(render([row({ lastFailureStatus: null })])).not.toContain("HTTP");
});
it("carries no status for a provider that is not failing", () => {
expect(
render([
row({ failing: 0, lastFailureAt: null, lastFailureStatus: 500 }),
]),
).not.toContain("HTTP");
});
});
@@ -18,6 +18,12 @@ import { formatDateTime } from "@/lib/format";
* read the database. The card shows one row per provider type: how many
* users' chains touch it, how many of them are currently failing, the worst
* uninterrupted failure run, and the last success / failure instants.
*
* The failure line also carries the HTTP status the ledger recorded for that
* failure, because "failing" on its own does not say whose problem it is. A
* 401 is a dead credential and waiting will not fix it; a 429 or a 503 is the
* provider's day and waiting is exactly right. Absent for a network-class
* failure, which never had a status to record.
*/
interface ProviderHealthRow {
@@ -27,6 +33,7 @@ interface ProviderHealthRow {
maxConsecutiveFailures: number;
lastOkAt: string | null;
lastFailureAt: string | null;
lastFailureStatus: number | null;
}
export function ProviderHealthSection() {
@@ -106,6 +113,13 @@ export function ProviderHealthSection() {
at: formatDateTime(p.lastFailureAt),
})}`
: ""}
{p.failing > 0 &&
p.lastFailureAt &&
p.lastFailureStatus !== null
? ` · ${t("admin.providerHealth.lastFailureStatus", {
status: p.lastFailureStatus,
})}`
: ""}
</span>
</div>
{p.failing === 0 ? (
@@ -0,0 +1,98 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The manual entry form and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
* account picked. Until the preference could be set at all, the form could
* hardcode "mg/dL" and be right by accident. It no longer can: a reader on
* mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
* plausibility band (20–800) accepts without a word and every surface then
* reads back as real. So the label and the stored number have to move
* together — the field asks in the account's unit and the submit path
* inverts to canonical mg/dL.
*
* The label half is rendered. The submit half is asserted against the source:
* this project runs SSR-only component tests (`@testing-library/react` is not
* a dependency), so nothing here can type into the field and press save. What
* the source assertion pins is the wiring — that the glucose branch inverts
* through `toCanonicalMgdl` rather than sending the typed number on — and
* `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
*/
let glucoseUnit: string | null = null;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: "metric",
glucoseUnit,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
}
const formSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
"utf8",
);
describe("MeasurementForm — blood-glucose entry unit", () => {
it("asks in mg/dL for an account that never chose", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
expect(html).toContain('placeholder="95"');
});
it("asks in mmol/L once the account chose it", () => {
glucoseUnit = "mmol/L";
const html = render();
expect(html).toContain("mmol/L");
// A mmol/L reader must not be shown a three-digit mg/dL example.
expect(html).toContain('placeholder="5.3"');
expect(html).not.toContain('placeholder="95"');
});
it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
expect(formSource).toMatch(
/if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
);
});
it("resolves the unit from the account rather than assuming one", () => {
expect(formSource).toMatch(
/const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
);
});
});
@@ -0,0 +1,117 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The measurements list and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and rendered in the unit the account chose.
* The list is not part of the metric/imperial transform registry, so
* without a branch of its own a reader on mmol/L would see the dashboard
* tile say 5.3 and this list say 95 for the same reading.
*
* The edit sheet is the half that could do damage: its label follows the
* same resolution, so the number in the input is in the displayed unit and
* has to be inverted before the PATCH. Display and inversion are asserted
* together on purpose — converting one without the other rewrites a
* 95 mg/dL reading as 5.3 the first time somebody corrects a typo.
*/
const rows = [
{
id: "m1",
type: "BLOOD_GLUCOSE",
value: 95,
unit: "mg/dL",
source: "MANUAL",
measuredAt: "2026-05-15T10:00:00.000Z",
notes: null,
},
];
let glucoseUnit: string | null = null;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { measurements: rows, meta: { total: 1 } },
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "USER", glucoseUnit },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
const listSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-list.tsx"),
"utf8",
);
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList />
</I18nProvider>,
);
}
describe("MeasurementList — blood-glucose display unit", () => {
it("shows the stored mg/dL reading as it stands for an mg/dL account", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("95");
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
});
it("converts the same reading for an mmol/L account", () => {
glucoseUnit = "mmol/L";
const html = render();
// 95 mg/dL ÷ 18.0182 = 5.3 mmol/L.
expect(html).toContain("5.3");
expect(html).toContain("mmol/L");
expect(html).not.toContain("95 mg/dL");
});
it("inverts an edited glucose value back to mg/dL before the save", () => {
expect(listSource).toMatch(
/editsConvertedGlucose\(editing\)[\s\S]{0,400}?canonicalValue = toCanonicalMgdl\(parsedValue, "mmol\/L"\)/,
);
});
it("seeds the edit input in the unit its own label names", () => {
expect(listSource).toMatch(
/const seedValue = editsConvertedGlucose\(measurement\)\s*\?\s*rd\.value/,
);
});
it("leaves an mg/dL edit on the identity path so a note fix cannot round the value", () => {
// `toCanonicalMgdl(v, "mg/dL")` rounds, so routing the mg/dL branch
// through it would quantise a stored 95.48 on an unrelated edit.
expect(listSource).toMatch(
/editsConvertedGlucose[\s\S]{0,300}?glucoseUnit === "mmol\/L"/,
);
});
});
@@ -26,6 +26,8 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useAuth } from "@/hooks/use-auth";
import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose";
import {
entryValueToCanonical,
parseDecimalEntry,
@@ -246,6 +248,14 @@ export function MeasurementForm({
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const unitDisplay = useUnitDisplay();
const { user } = useAuth();
// Glucose is its own display axis, separate from metric/imperial: it is
// stored in mg/dL and rendered in whichever of mg/dL and mmol/L the
// account chose. The form has to ask in the same unit it labels, and
// invert on the way out, or a reader on mmol/L would file "5.5" as five
// and a half mg/dL — a number the plausibility band happily accepts and
// every surface then reads back as a severe hypo.
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
// Normalize legacy BP types to combined mode
const normalizedDefault =
@@ -359,7 +369,9 @@ export function MeasurementForm({
return;
}
let canonicalValue = typed;
if (unitDisplay.isTransformed(type)) {
if (isGlucoseMode) {
canonicalValue = toCanonicalMgdl(typed, glucoseUnit);
} else if (unitDisplay.isTransformed(type)) {
const inverted = unitDisplay.fromDisplay(type, typed);
canonicalValue =
unitDisplay.preference === "imperial"
@@ -537,13 +549,15 @@ export function MeasurementForm({
// For a type with a metric/imperial transform the label follows the
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
// its static catalogue unit.
unit: typeInfo
? unitDisplay.isTransformed(type)
? unitDisplay.unitFor(type)
: "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
unit: isGlucoseMode
? glucoseUnit
: typeInfo
? unitDisplay.isTransformed(type)
? unitDisplay.unitFor(type)
: "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
})}
>
<Input
@@ -558,14 +572,16 @@ export function MeasurementForm({
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
typeInfo
? unitDisplay.preference === "imperial" &&
"placeholderImperial" in typeInfo
? typeInfo.placeholderImperial
: "placeholder" in typeInfo
? typeInfo.placeholder
: undefined
: undefined
isGlucoseMode && glucoseUnit === "mmol/L"
? "5.3"
: typeInfo
? unitDisplay.preference === "imperial" &&
"placeholderImperial" in typeInfo
? typeInfo.placeholderImperial
: "placeholder" in typeInfo
? typeInfo.placeholder
: undefined
: undefined
}
required
aria-required="true"
@@ -72,6 +72,11 @@ import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
import { getUnitForType } from "@/lib/validations/measurement";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
convertGlucose,
resolveGlucoseUnit,
toCanonicalMgdl,
} from "@/lib/glucose";
import {
invalidateKeys,
measurementDependentKeys,
@@ -303,17 +308,40 @@ export function MeasurementList({
// transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the
// type's canonical unit) is shown verbatim so its stored unit is never
// silently relabelled. Metric users take the identity path — unchanged.
// Glucose sits on its own display axis: stored mg/dL, rendered in whichever
// of mg/dL and mmol/L the account chose. It is not part of the
// metric/imperial registry, so it needs its own branch or a reader on
// mmol/L would see the dashboard tile and this list disagree about the
// same reading.
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
// An edit whose input is NOT in the stored unit. Only the mmol/L branch
// qualifies: an mg/dL account edits in the unit the column holds, and
// routing it through the converter anyway would quantise a stored 95.48 to
// 95 the moment somebody opened the sheet to fix a note.
const editsConvertedGlucose = useCallback(
(m: { type: string; unit: string }) =>
m.type === "BLOOD_GLUCOSE" &&
glucoseUnit === "mmol/L" &&
m.unit === getUnitForType(m.type),
[glucoseUnit],
);
const rowDisplay = useCallback(
(m: { type: string; value: number; unit: string }) => {
if (m.unit !== getUnitForType(m.type)) {
return { value: m.value, unit: m.unit };
}
if (m.type === "BLOOD_GLUCOSE") {
return {
value: convertGlucose(m.value, glucoseUnit),
unit: glucoseUnit,
};
}
return {
value: unitDisplay.toDisplay(m.type, m.value),
unit: unitDisplay.unitFor(m.type),
};
},
[unitDisplay],
[unitDisplay, glucoseUnit],
);
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
@@ -791,10 +819,11 @@ export function MeasurementList({
// stored value, unchanged. Round the converted imperial seed so the input
// shows "210" rather than a long float.
const rd = rowDisplay(measurement);
const seedValue =
unitDisplay.preference === "imperial" &&
unitDisplay.isTransformed(measurement.type) &&
measurement.unit === getUnitForType(measurement.type)
const seedValue = editsConvertedGlucose(measurement)
? rd.value
: unitDisplay.preference === "imperial" &&
unitDisplay.isTransformed(measurement.type) &&
measurement.unit === getUnitForType(measurement.type)
? Math.round(rd.value * 100) / 100
: measurement.value;
const seed = {
@@ -852,7 +881,11 @@ export function MeasurementList({
// the PATCH so an imperial edit persists in the stored unit. Metric users
// and legacy non-canonical rows are the identity path — value unchanged.
let canonicalValue = parsedValue;
if (
if (editsConvertedGlucose(editing)) {
// The label above the input said mmol/L, so the number in it is
// mmol/L. Sending it on would rewrite a 95 mg/dL reading as 5.3.
canonicalValue = toCanonicalMgdl(parsedValue, "mmol/L");
} else if (
unitDisplay.isTransformed(editing.type) &&
editing.unit === getUnitForType(editing.type)
) {
@@ -0,0 +1,145 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
/**
* Settings → Profile blood-glucose unit dropdown.
*
* The control the preference column never had. It sits beside the
* metric/imperial select and is deliberately not a branch of it: metric
* countries are split on which unit they read glucose in, so one dropdown
* would get one of them wrong.
*
* SSR-only, like the unit-system select next to it: the render pass pins the
* options and the selected value, and the mutation contract — endpoint,
* method, body — is asserted against the source, because nothing here can
* dispatch a change event.
*/
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser(null), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
function buildUser(glucoseUnit: string | null): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
} as AuthUser;
}
import { GlucoseUnitSelect } from "../glucose-unit-select";
const componentSource = readFileSync(
join(process.cwd(), "src/components/settings/glucose-unit-select.tsx"),
"utf8",
);
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser(null),
isAuthenticated: true,
}));
});
function render(isAuthenticated = true): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — GlucoseUnitSelect", () => {
it("offers both units the app renders", () => {
const html = render();
expect(html).toContain('data-testid="settings-glucose-unit-select"');
expect(html).toContain('value="mg/dL"');
expect(html).toContain('value="mmol/L"');
});
// React renders a controlled select's current value as `selected` on the
// option under SSR, not as an attribute on the `<select>`.
function selectedOption(html: string): string | null {
return /<option[^>]*selected[^>]*>/.exec(html)?.[0] ?? null;
}
it("shows mg/dL for an account that never chose", () => {
expect(selectedOption(render())).toContain('value="mg/dL"');
});
it("shows the unit an account already chose", () => {
authSpy.mockImplementation(() => ({
user: buildUser("mmol/L"),
isAuthenticated: true,
}));
expect(selectedOption(render())).toContain('value="mmol/L"');
});
it("disables the select when unauthenticated", () => {
const select = /<select[^>]*settings-glucose-unit-select[^>]*>/.exec(
render(false),
);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
it("PATCHes the glucose-unit endpoint field-by-field", () => {
expect(componentSource).toMatch(
/apiFetchRaw\(\s*"\/api\/auth\/me\/glucose-unit"[\s\S]{0,200}?method: "PATCH"[\s\S]{0,200}?JSON\.stringify\(\{ glucoseUnit: next \}\)/,
);
});
it("refreshes the account payload every glucose surface reads from", () => {
expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/);
});
it("is actually mounted on the settings page", () => {
// A control nobody can reach is the same defect as no control.
const accountSection = readFileSync(
join(process.cwd(), "src/components/settings/account-section/index.tsx"),
"utf8",
);
expect(accountSection).toMatch(/<GlucoseUnitSelect\s/);
});
});
@@ -32,6 +32,7 @@ import { storeTimezone } from "@/lib/timezone-mirror";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
@@ -421,14 +422,17 @@ export function AccountSection() {
</div>
</div>
{/* Timezone + unit system + hour format share one grid block —
all personal display preferences (like language above). The
unit and time-format dropdowns PATCH their own endpoints on
change; the timezone saves through the form's submit
handler. */}
{/* Timezone, unit system, glucose unit and the date/hour formats
share one grid block — all personal display preferences (like
language above). The glucose unit is its own dropdown rather
than a branch of the unit system because metric countries are
split on which unit they read glucose in. Every dropdown here
PATCHes its own endpoint on change; the timezone saves through
the form's submit handler. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
/**
* Blood-glucose display unit as a Profile dropdown.
*
* The preference column and its thirty-odd readers shipped in v1.2 with no
* control to set them, so every account stayed on the mg/dL default and the
* mmol/L half of the app was unreachable. This is the control.
*
* It sits beside the metric/imperial dropdown because it is the same kind of
* setting — how a number is shown, not what is stored — but it is a separate
* choice: metric countries are split on which unit they read glucose in, so
* folding it into metric/imperial would get one of them wrong. The option
* labels are the unit symbols themselves, which read the same in every
* locale.
*
* Persistence mirrors the unit-system select: a change PATCHes
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`
* plus `queryKeys.userGlucoseUnit()`, so the dashboard tiles, the glucose
* insight page and the targets panel re-render on the next /me refetch.
*/
export function GlucoseUnitSelect({
isAuthenticated,
id = "glucose-unit",
}: {
isAuthenticated: boolean;
id?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: GlucoseUnit =
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
const mutation = useMutation({
mutationFn: async (next: GlucoseUnit) => {
const res = await apiFetchRaw("/api/auth/me/glucose-unit", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ glucoseUnit: next }),
});
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? `HTTP ${res.status}`);
}
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.glucoseUnit.saved"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userGlucoseUnit(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.glucoseUnit.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleSelect(next: GlucoseUnit) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.dashboard.glucoseUnit.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-glucose-unit-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as GlucoseUnit)}
>
{/* Unit symbols, not translated copy — they read the same
everywhere, and a locale that "translated" one would be
naming a different measurement. */}
<option value="mg/dL">mg/dL</option>
<option value="mmol/L">mmol/L</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.glucoseUnit.description")}
</p>
</div>
);
}
+9 -1
View File
@@ -9,7 +9,15 @@
export type GlucoseUnit = "mg/dL" | "mmol/L";
const MGDL_PER_MMOL = 18.0182;
/**
* mg/dL per mmol/L, per the DGIM / DDG S3 guideline. Exported because it is
* the one factor for this conversion in the tree: the inbound alias resolver
* normalises a mmol/L cell with it, the glucose insight page scales its chart
* by its reciprocal, and the helpers below round with it. Two nearly-equal
* copies of a health conversion is how the same reading comes out as two
* different numbers on two screens.
*/
export const MGDL_PER_MMOL = 18.0182;
export function mgdlToMmol(mgdl: number): number {
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { MGDL_PER_MMOL, mgdlToMmol } from "@/lib/glucose";
import {
parseCsvMeasurements,
@@ -98,7 +99,12 @@ describe("parseCsvMeasurements — unit conversion", () => {
]);
expect(out.rows[0].status).toBe("ok");
expect(out.rows[0].row?.unit).toBe("mg/dL");
expect(out.rows[0].row?.value).toBeCloseTo(5.3 * 18.016, 3);
// The one factor the app converts glucose by, both directions. The
// literal that used to sit here was 18.016 while every display path
// divided by 18.0182, so a reading imported in mmol/L did not read back
// as the number it went in as.
expect(out.rows[0].row?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
expect(mgdlToMmol(out.rows[0].row?.value ?? 0)).toBe(5.3);
});
it("converts weight lb to canonical kg", () => {
@@ -176,7 +182,7 @@ describe("parseCsvMeasurements — glucose context", () => {
for (const result of out.rows) {
expect(result.row).not.toHaveProperty("glucoseContext");
expect(result.row?.unit).toBe("mg/dL");
expect(result.row?.value).toBeCloseTo(95.4848, 3);
expect(result.row?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
}
expect(out.rows[0].row?.measuredAt.toISOString()).toBe(
"2024-04-03T02:15:00.000Z",
@@ -0,0 +1,69 @@
/**
* `ImportJob.exportedAt` — the worker end of the wiring.
*
* The parse side is proved behaviourally in
* `src/lib/measurements/__tests__/import-apple-health-export.test.ts`:
* the parser hands the archive's own stamp to `onExportDate`. What that
* cannot show is whether anything persists it. The column, the status
* endpoint that returns it and the published contract that requires it
* all existed for a year while the one line that would produce the value
* was a `return`, so the half of the pipe worth pinning is this one.
*
* Source-shaped rather than run-shaped: driving the worker needs a real
* `export.zip`, a queue and a database, which the ECG contract test
* beside this one already declines to do for the same reason.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const workerSource = readFileSync(
join(process.cwd(), "src/lib/jobs/apple-health-import-worker.ts"),
"utf8",
);
const parserSource = readFileSync(
join(process.cwd(), "src/lib/measurements/import-apple-health-export.ts"),
"utf8",
);
const statusRouteSource = readFileSync(
join(
process.cwd(),
"src/app/api/import/apple-health-export/[jobId]/status/route.ts",
),
"utf8",
);
describe("Apple Health import — the archive's export stamp reaches the column", () => {
it("subscribes to the parser's export-date hook", () => {
expect(workerSource).toMatch(/onExportDate:\s*async\s*\(exportedAt\)/);
});
it("writes the stamp onto the job row it is running", () => {
expect(workerSource).toMatch(
/onExportDate:[\s\S]{0,400}?prisma\s*\.?\s*importJob[\s\S]{0,200}?\.update\([\s\S]{0,200}?exportedAt/,
);
});
it("keeps the write best-effort so provenance cannot fail an import", () => {
expect(workerSource).toMatch(
/onExportDate:[\s\S]{0,400}?exportedAt[\s\S]{0,120}?\.catch\(/,
);
});
it("no longer discards the element that carries the stamp", () => {
// The ignore list is where `ExportDate` spent its first year.
const ignoreBlock = /Known elements we intentionally ignore/.exec(
parserSource,
);
expect(ignoreBlock).not.toBeNull();
const before = parserSource.slice(
Math.max(0, (ignoreBlock?.index ?? 0) - 400),
ignoreBlock?.index ?? 0,
);
expect(before).not.toMatch(/name === "ExportDate" \|\|/);
});
it("still surfaces the column the contract promises", () => {
expect(statusRouteSource).toMatch(/exportedAt:\s*row\.exportedAt/);
});
});
@@ -417,6 +417,16 @@ export async function handleAppleHealthImport(
},
});
},
// The instant the Health app stamped on the archive. Persisted as
// soon as the parser reads it, so the status endpoint answers with
// it for the rest of the run and a job that later fails still
// records which export it was working from. Best-effort: the
// stamp is provenance, and losing it must not fail an import.
onExportDate: async (exportedAt) => {
await prisma.importJob
.update({ where: { id: importJobId }, data: { exportedAt } })
.catch(() => {});
},
})),
ecg: {
discovered: 0,
@@ -1226,3 +1226,55 @@ describe("streamParseExportXml — cycle module off", () => {
expect(result.totals.recordsRead).toBe(2);
});
});
/**
* `ImportJob.exportedAt` — the instant the Health app stamped on the
* archive. The status endpoint and the published contract have both
* promised it since v1.4.34, but `<ExportDate>` sat on the parser's
* ignore list, so the column could only ever answer null. These pin the
* capture: remove the `onExportDate` call from the parser and the first
* case goes red; drop the NaN guard and the third does.
*/
describe("streamParseExportXml — the archive's own export stamp", () => {
async function parseFor(xml: string) {
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-export-date-"));
const xmlPath = join(tmp, "export.xml");
writeFileSync(xmlPath, xml);
const stamps: Date[] = [];
await streamParseExportXml({
xmlPath,
userId: "user-1",
userTimezone: "Europe/Berlin",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: makeFakePrisma() as any,
onExportDate: (exportedAt) => {
stamps.push(exportedAt);
},
});
return stamps;
}
it("hands the caller the instant Apple wrote on the export", async () => {
const stamps = await parseFor(tinyExportXml());
expect(stamps).toHaveLength(1);
// "2026-05-15 14:32:01 +0200" — the fixture's own stamp.
expect(stamps[0]?.toISOString()).toBe("2026-05-15T12:32:01.000Z");
});
it("stays silent for an archive that carries no stamp", async () => {
const stamps = await parseFor(
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData locale="en_US"></HealthData>`,
);
expect(stamps).toEqual([]);
});
it("stays silent rather than guessing at an unreadable stamp", async () => {
const stamps = await parseFor(
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData locale="en_US">\n <ExportDate value="not a date"/>\n</HealthData>`,
);
expect(stamps).toEqual([]);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { resolveToCanonicalUnit } from "../unit-aliases";
import { MGDL_PER_MMOL, mgdlToMmol } from "@/lib/glucose";
describe("resolveToCanonicalUnit", () => {
it("passes the canonical unit through case-insensitively", () => {
@@ -53,10 +54,17 @@ describe("resolveToCanonicalUnit", () => {
});
});
it("converts glucose mmol/L to canonical mg/dL", () => {
it("converts glucose mmol/L to canonical mg/dL on the app's one factor", () => {
// This used to assert 18.016 while every display path divided by
// 18.0182, so a reading imported as mmol/L and read back as mmol/L did
// not return the number it went in as. Both ends take the same factor
// now, and the assertion names it rather than repeating a literal —
// pinning the constant twice is how the two drifted apart.
const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L");
expect(out?.unit).toBe("mg/dL");
expect(out?.value).toBeCloseTo(5.3 * 18.016, 3);
expect(out?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
// The round trip a mmol/L reader actually experiences.
expect(mgdlToMmol(out?.value ?? 0)).toBe(5.3);
});
it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => {
@@ -307,6 +307,13 @@ export interface StreamParseInput {
* continues even if the hook throws.
*/
onProgress?: (snapshot: ImportJobProgress) => Promise<void> | void;
/**
* Called once, mid-run, with the instant Apple stamped on the archive
* (`<ExportDate value="..."/>`, the first child of `<HealthData>`).
* Never called for an archive that omits the element or writes an
* unreadable value. Best-effort, like `onProgress`.
*/
onExportDate?: (exportedAt: Date) => Promise<void> | void;
/**
* Optional override of the spot-row flush batch size. Defaults to
* `SPOT_FLUSH_BATCH`. Lower values make the upsert path more
@@ -339,6 +346,7 @@ export async function streamParseExportXml(
userTimezone,
prisma,
onProgress,
onExportDate,
spotBatchSize = SPOT_FLUSH_BATCH,
workoutBatchSize = WORKOUT_FLUSH_BATCH,
} = input;
@@ -775,6 +783,21 @@ export async function streamParseExportXml(
let pendingError: Error | null = null;
let currentWorkout: PreparedWorkout | null = null;
// Apple stamps the archive with its own export instant as the first
// child of `<HealthData>`. The element used to be on the ignore list,
// so `ImportJob.exportedAt` — a column the status endpoint and the
// published contract both promise — could only ever answer null. The
// value is captured in the synchronous tag handler and handed to
// `onExportDate` from the async drain, so the worker can persist it
// while the run is still going rather than only on the terminal write.
let pendingExportDate: Date | null = null;
const flushExportDate = async (): Promise<void> => {
const stamped = pendingExportDate;
if (!stamped) return;
pendingExportDate = null;
if (onExportDate) await onExportDate(stamped);
};
parser.onerror = (err) => {
pendingError = err instanceof Error ? err : new Error(String(err));
};
@@ -983,8 +1006,17 @@ export async function streamParseExportXml(
return;
}
if (name === "ExportDate") {
// `value` carries Apple's own format ("2026-05-15 14:32:01 +0200"),
// the same shape every `Record` start/end date uses. An archive
// without the element, or with a value the runtime cannot read,
// leaves the column null rather than guessing at an instant.
const stamped = new Date(attrs.value ?? "");
if (!Number.isNaN(stamped.getTime())) pendingExportDate = stamped;
return;
}
if (
name === "ExportDate" ||
name === "Me" ||
name === "Correlation" ||
name === "ActivitySummary" ||
@@ -1049,9 +1081,11 @@ export async function streamParseExportXml(
}
// Drain any batches the parser filled to keep memory bounded.
const shouldFlush =
pendingExportDate !== null ||
spotBatch.length >= spotBatchSize ||
workoutBatch.length >= workoutBatchSize;
const drain = async (): Promise<void> => {
await flushExportDate();
if (spotBatch.length >= spotBatchSize) await flushSpotBatch();
if (workoutBatch.length >= workoutBatchSize)
await flushWorkoutBatch();
@@ -1090,6 +1124,10 @@ export async function streamParseExportXml(
await pipeline(readable, sink);
if (pendingError) throw pendingError;
// An archive small enough to arrive in a single chunk can finish
// before any drain ran; the stamp still has to reach the caller.
await flushExportDate();
await emitProgress("upserting");
// Drain any partial spot/workout batches that did not hit the flush
// threshold during the parse.
+2 -4
View File
@@ -17,9 +17,7 @@
* Pure + side-effect-free; safe to import from an API route or a script.
*/
import { getUnitForType } from "@/lib/validations/measurement";
/** Glucose mmol/L → mg/dL. */
const GLUCOSE_MMOL_TO_MGDL = 18.016;
import { MGDL_PER_MMOL } from "@/lib/glucose";
/** Exact pound → kilogram (1 lb = 0.45359237 kg). */
const LB_TO_KG = 0.45359237;
/** Exact inch → centimetre (1 in = 2.54 cm). */
@@ -87,7 +85,7 @@ export function resolveToCanonicalUnit(
// Glucose: mmol/L → mg/dL.
if (type === "BLOOD_GLUCOSE" && MMOL_ALIASES.has(lower)) {
return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical };
return { value: value * MGDL_PER_MMOL, unit: canonical };
}
// Body mass: lb → kg.
+65
View File
@@ -26,6 +26,7 @@ import {
injectionSitePrefsPatchSchema,
labsLocalOcrPatchSchema,
unitPreferencePatchSchema,
glucoseUnitPatchSchema,
useCentralCodexPatchSchema,
} from "@/lib/validations/user-prefs";
import {
@@ -555,6 +556,16 @@ const unitPreferenceResponse = z
.object({ unitPreference: z.enum(["metric", "imperial"]) })
.meta({ id: "UnitPreferenceResponse" });
const glucoseUnitPatchRequest = glucoseUnitPatchSchema.meta({
id: "GlucoseUnitPatchRequest",
description:
"Which unit blood-glucose readings are rendered in. Presentation only — every stored reading is mg/dL either way.",
});
const glucoseUnitResponse = z
.object({ glucoseUnit: z.enum(["mg/dL", "mmol/L"]) })
.meta({ id: "GlucoseUnitResponse" });
const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({
id: "DocumentsAutoAiReadPatchRequest",
description:
@@ -1577,6 +1588,60 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/glucose-unit": {
get: {
tags: ["Auth"],
summary: "Read the blood-glucose display unit",
description:
"Which unit blood-glucose readings are rendered in — `mg/dL` or `mmol/L`. A separate choice from the metric/imperial preference, because metric countries are split on which one they read glucose in. Canonical storage is mg/dL on every reading and every ingest path; this changes presentation only. Any stored value other than `mmol/L` resolves to `mg/dL`, including a null on an account that never set it.",
responses: {
"200": {
description: "The resolved unit.",
content: {
"application/json": {
schema: dataEnvelope(
glucoseUnitResponse,
"GetGlucoseUnitEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Auth"],
summary: "Set the blood-glucose display unit",
description:
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
"Nothing already stored is converted or reinterpreted: readings stay mg/dL, and a value typed into the entry form in mmol/L is inverted to mg/dL before it is written.\n\n" +
"The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group answer.",
requestBody: {
required: true,
content: {
"application/json": { schema: glucoseUnitPatchRequest },
},
},
responses: {
"200": {
description: "The resolved next unit.",
content: {
"application/json": {
schema: dataEnvelope(
glucoseUnitResponse,
"PatchGlucoseUnitEnvelope",
),
},
},
},
"413": {
description: "Request body exceeds 1024 bytes. Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/auth/me/documents-auto-ai-read": {
get: {
tags: ["Auth"],
+9
View File
@@ -31,6 +31,15 @@ export const authKeys = {
* without a manual reload.
*/
userUnitPreference: () => ["user", "unit-preference"] as const,
/**
* The mg/dL vs mmol/L glucose display control behind
* `GET /api/auth/me/glucose-unit`. A separate axis from the
* metric/imperial preference above — metric countries are split on
* which one they read glucose in — so it carries its own key. The
* PATCH also invalidates `authMe()`, since every glucose surface
* resolves the unit from the account payload rather than from here.
*/
userGlucoseUnit: () => ["user", "glucose-unit"] as const,
passkeys: () => ["passkeys"] as const,
+11
View File
@@ -20,6 +20,17 @@ export const unitPreferencePatchSchema = z.object({
unitPreference: z.enum(["metric", "imperial"]),
});
/**
* `PATCH /api/auth/me/glucose-unit` — mg/dL vs mmol/L display branch.
*
* Not the metric/imperial preference: the two split along different lines.
* Germany is metric and reads glucose in mg/dL; the UK and the Nordics read
* it in mmol/L. Canonical storage stays mg/dL either way.
*/
export const glucoseUnitPatchSchema = z.object({
glucoseUnit: z.enum(["mg/dL", "mmol/L"]),
});
/** `PATCH /api/auth/me/documents-auto-ai-read` — the auto-read opt-in. */
export const documentsAutoAiReadPatchSchema = z.object({
documentsAutoAiRead: z.boolean(),