Files
HealthLog/src/lib/validations/user-prefs.ts
T
Marc-André Bombeck 5078867203 feat: let an account choose the unit its glucose is shown in
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.

This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.

Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.

The contract gains the endpoint and nothing else; the account payload
already carried the field.
2026-09-03 06:05:58 +02:00

61 lines
2.2 KiB
TypeScript

/**
* Request schemas for the per-user scalar preference endpoints under
* `/api/auth/me/*`. Single-sourced here so the runtime `safeParse` and the
* OpenAPI registry (`src/lib/openapi/routes/profile.ts`) describe the identical
* wire contract — the same reason `mfa.ts` exists next door.
*
* They all follow the same shape: a one-field PATCH that hard-sets the value
* and answers with the resolved next state, so a client can replace its
* optimistic update rather than re-read.
*/
import { z } from "zod/v4";
import {
injectionSiteEnum,
INJECTION_SITE_VALUES,
} from "@/lib/validations/medication";
/** `PATCH /api/auth/me/unit-preference` — metric/imperial display branch. */
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(),
});
/** `PATCH /api/auth/me/labs-local-ocr` — browser-side OCR opt-in. */
export const labsLocalOcrPatchSchema = z.object({
labsLocalOcrEnabled: z.boolean(),
});
/** `PATCH /api/auth/me/use-central-codex` — the operator's shared Codex. */
export const useCentralCodexPatchSchema = z.object({
useCentralCodex: z.boolean(),
});
/** `PATCH /api/auth/me/injection-site-prefs` — the user-level deny-list. */
export const injectionSitePrefsPatchSchema = z.object({
// Dedup at write time; cap at the full enum size. An empty array
// clears the exclusion.
globalExcludedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.meta({
description:
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
}),
});