mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385
# Conflicts: # CHANGELOG.md
This commit is contained in:
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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user