Merge remote-tracking branch 'origin/main' into fix/key-check-hardening

This commit is contained in:
Marc-André Bombeck committed 2026-10-04 00:01:07 +02:00
commit 363070540b
27 files changed
+786 -310

No files matched your search

@@ -561,32 +561,6 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});
it("names the owner's glucose unit on the glucose card", async () => {
// Applied after the summary cache: the unit is the record's own choice
// and the card's numbers are in it (the conversion is pinned in
// `src/lib/dashboard/__tests__/glucose-card.test.ts`).
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, glucoseUnit: "mmol/L" },
} as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "BLOOD_GLUCOSE",
_count: { _all: 4 },
_max: { measuredAt: new Date(Date.now() - 10 * 86_400_000) },
},
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { metrics: Array<{ id: string; unit: string | null }> };
};
expect(body.data.metrics.find((m) => m.id === "glucose")?.unit).toBe(
"mmol/L",
);
// Every other card keeps its own unit contract.
expect(body.data.metrics.find((m) => m.id === "weight")?.unit).toBeNull();
});
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// tile must SUM the asleep stages of the latest night and convert to
+1 -9
View File
@@ -75,8 +75,6 @@ import {
} from "@/lib/dashboard/summary-reads";
import { resolveModuleMap } from "@/lib/modules/gate";
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
import { glucoseCardInDisplayUnit } from "@/lib/dashboard/glucose-card";
import { resolveGlucoseUnit } from "@/lib/glucose";
import {
summarizeSleepNights,
reconstructSleepNights,
@@ -146,9 +144,6 @@ interface MetricCard {
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
* below for why the night total replaced the single-stage value.
* v1.40.0 — the `glucose` tile sets it to the owner's glucose unit
* (`"mg/dL"` or `"mmol/L"`), with its values converted to match
* (`glucoseCardInDisplayUnit`, applied after the cache read).
*/
unit: string | null;
/**
@@ -330,10 +325,7 @@ export const GET = apiHandler(async () => {
// it off. Filtering the cached body makes the toggle effective on the very
// next request.
const modules = await resolveModuleMap(user.id);
const glucoseUnit = resolveGlucoseUnit(user.glucoseUnit);
const metrics = gateMetricCardsByModules(body.metrics, modules).map((card) =>
glucoseCardInDisplayUnit(card, glucoseUnit),
);
const metrics = gateMetricCardsByModules(body.metrics, modules);
annotate({
meta: {
metrics_emitted: metrics.length,
+29 -6
View File
@@ -194,7 +194,8 @@ interface EntryResult {
}
/**
* v1.31.1 — emit one data arrival per workout this batch actually inserted.
* v1.31.1 — emit one data arrival per workout this batch actually inserted,
* and per hand-entered workout whose edit it actually wrote.
*
* `createManyAndReturn` gives the transaction the exact inserted ids. The
* index-keyed map avoids a second lookup and keeps equal external ids from
@@ -541,6 +542,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
let enrichedCount = 0;
let updatedCount = 0;
const insertedIdByIndex = new Map<number, string>();
/** Workout ids of the manual edits this request actually wrote. */
const overwrittenIdByIndex = new Map<number, string>();
if (survivors.length > 0) {
// v1.4.42 W5 — only the write-time dedup survivors are probed
@@ -709,6 +712,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: o.externalId,
},
});
// The paragraph described the old values (a 12 km run corrected
// to 1.2 km must not keep its 12 km story). It goes with them,
// and the claim is let go of its workout the way a delete does
// (`ON DELETE SET NULL`): the row stays as the day's ledger, so
// the edit buys no slot back, and the arrival emitted below can
// claim a fresh one through the ordinary path, under the same cap.
await tx.workoutInsight.deleteMany({
where: { userId: user.id, workoutId: o.workoutId },
});
await tx.workoutInsightGenerationClaim.updateMany({
where: { userId: user.id, workoutId: o.workoutId },
data: { workoutId: null },
});
}
}
@@ -841,7 +857,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
index: o.index,
status: landed ? "updated" : "duplicate",
};
if (landed) updatedCount += 1;
if (landed) {
updatedCount += 1;
overwrittenIdByIndex.set(o.index, o.workoutId);
}
duplicateCount += 1;
}
}
@@ -914,13 +933,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
invalidateUserMeasurements(user.id);
}
if (insertedCount > 0) {
if (insertedCount > 0 || overwrittenIdByIndex.size > 0) {
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
// stop at the shared salience classifier before any queue work.
// An enrichment raises none: the workout arrived when it was stored.
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
() => {},
);
// A hand-entered workout whose edit landed raises one again, so its
// paragraph (dropped with the old values) is written from the new ones.
void emitWorkoutArrivals(
user.id,
prepared,
new Map([...insertedIdByIndex, ...overwrittenIdByIndex]),
).catch(() => {});
}
return apiSuccess({
@@ -0,0 +1,56 @@
/**
* The cycle sheet's "add a custom symptom" popover offers the same icon
* picker as the symptoms feature, and must look and behave the same: 44 px
* targets on a phone, 36 px from `sm`, a translated name per icon.
*
* The popover portals and portals do not render server-side, so it is
* replaced by pass-through wrappers; what is measured is the picker inside.
*
* Mutation check (run, seen red): put the cycle sheet's own inline picker
* back (`size-7`, `aria-label={name}`) → this test goes red.
*/
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../use-cycle", async (importOriginal) => ({
...(await importOriginal<typeof import("../use-cycle")>()),
useCreateCustomSymptom: () => ({
mutateAsync: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { I18nProvider } from "@/lib/i18n/context";
import { AddSymptomChip } from "../log-day-sheet";
describe("cycle custom-symptom icon picker", () => {
it("has 44 px targets on a phone and translated icon names", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<AddSymptomChip onCreated={() => {}} />
</I18nProvider>,
);
const buttons = html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
expect(buttons).toHaveLength(CUSTOM_SYMPTOM_ICON_ALLOWLIST.length);
for (const button of buttons) {
expect(button).toContain("size-11");
expect(button).toContain("sm:size-9");
expect(button).not.toMatch(/\bsize-7\b/);
}
expect(html).toContain('aria-label="Herzschlag"');
expect(html).not.toContain('aria-label="HeartPulse"');
});
});
+11 -30
View File
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { SymptomIconPicker } from "@/components/symptoms/symptom-icon-picker";
import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
@@ -1519,7 +1519,11 @@ export function SymptomRow({
* label + icon popover to mint a new custom symptom. Same height and shape as
* a symptom row so it reads as part of the list, not a foreign button.
*/
function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
export function AddSymptomChip({
onCreated,
}: {
onCreated: (key: string) => void;
}) {
const { t } = useTranslations();
const create = useCreateCustomSymptom();
const [openForm, setOpenForm] = useState(false);
@@ -1594,34 +1598,11 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
<p className="text-xs font-medium">
{t("cycle.symptom.custom.icon")}
</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("cycle.symptom.custom.icon")}
>
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = customIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("cycle.symptom.custom.icon")}
/>
</div>
{limitReached ? (
<p className="text-destructive text-sm" role="alert">
@@ -15,7 +15,10 @@ import {
unitSkippedAnalytes,
} from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
const hookState = vi.hoisted(() => ({
extractPending: false,
runPhase: "idle" as "idle" | "running" | "waitingForWorker",
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
@@ -37,6 +40,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
vi.mock("../use-ocr-extract", () => ({
useOcrExtract: () => ({
isPending: hookState.extractPending,
runPhase: hookState.runPhase,
mutate: vi.fn(),
reset: vi.fn(),
}),
@@ -174,6 +178,35 @@ describe("<OcrReviewDialog> document-reading consent", () => {
});
});
/**
* A lab scan's proposed rows live only in its run: nothing is stored until
* the person confirms them here. So the waiting line must not promise that
* the page can be left (the result would be lost, and a second scan costs
* another rate slot). Mutation check: swap `aiRuns.backgroundScan` for
* `aiRuns.backgroundDocument` in the dialog → both cases go red.
*/
describe("<OcrReviewDialog> background read", () => {
afterEach(() => {
hookState.extractPending = false;
hookState.runPhase = "idle";
});
it.each([
["en", "With a slow model", "leave this page"],
["de", "Mit einem langsamen Modell", "Seite verlassen"],
] as const)(
"(%s) says it reads in the background, never that the page can be left",
(locale, expected, forbidden) => {
hookState.extractPending = true;
hookState.runPhase = "running";
const html = render(locale);
expect(html).toContain('data-slot="ocr-run-phase"');
expect(html).toContain(expected);
expect(html).not.toContain(forbidden);
},
);
});
describe("extractErrorMessage", () => {
const t = (key: string) => key;
const refusal = (errorCode: string, status = 403) =>
@@ -7,13 +7,16 @@
* - drop the `resolvedAt === null` filter on the episodes → "offers no
* episode selector when every episode is resolved" goes red;
* - render the add chip unconditionally → "a delegate who may log but not
* manage gets no add chip" goes red.
* manage gets no add chip" goes red;
* - drop the `definitions.isError` branch → "a failed read shows the error
* row, not the empty hint" goes red (the empty hint renders instead).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const state = vi.hoisted(() => ({
canManage: true,
definitionsFailed: false,
episodes: [] as Array<{
id: string;
label: string;
@@ -34,30 +37,42 @@ vi.mock("@/components/illness/use-illness", () => ({
}));
vi.mock("../use-symptoms", () => ({
useSymptomDefinitions: () => ({
isLoading: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
useSymptomDefinitions: () =>
state.definitionsFailed
? { isLoading: false, isError: true, data: undefined, refetch: vi.fn() }
: {
isLoading: false,
isError: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
},
],
},
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
},
],
},
}),
useLogSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateSymptomDefinition: () => ({
mutateAsync: vi.fn(),
@@ -111,4 +126,19 @@ describe("<SymptomEntryForm>", () => {
];
expect(render()).not.toContain("-episode");
});
it("a failed read shows the error row, not the empty hint", () => {
state.canManage = true;
state.episodes = [];
state.definitionsFailed = true;
try {
const html = render();
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain("load your symptoms");
expect(html).not.toContain("first symptom");
expect(html).not.toContain('data-testid="symptom-add-chip"');
} finally {
state.definitionsFailed = false;
}
});
});
@@ -0,0 +1,122 @@
/**
* Both symptom icon pickers (the add chip's popover and the edit sheet) share
* one shape: 44 px targets on a phone, 36 px from `sm`, and a translated
* accessible name per icon rather than the raw Lucide component name.
*
* The popover and the sheet portal, and portals do not render server-side,
* so both are replaced by pass-through wrappers that render their children
* inline; what is measured is the picker inside them.
*
* Mutation checks (each run, each seen red):
* - put `size-7` back on the add chip's icon buttons → "the add chip's
* icon targets" goes red;
* - put `aria-label={name}` back on either picker → the label assertions
* go red ("HeartPulse" is a raw name, "Heartbeat" its English label).
*/
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) => (
<div>
{children}
{footer}
</div>
),
}));
vi.mock("../use-symptoms", () => {
const mutation = {
mutateAsync: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
error: null,
};
return {
useCreateSymptomDefinition: () => mutation,
useUpdateSymptomDefinition: () => mutation,
useUpdateSymptomEvent: () => mutation,
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { AddSymptomChip } from "../add-symptom-chip";
import { EditSymptomDefinitionSheet } from "../symptom-edit-sheets";
function iconButtons(html: string): string[] {
return html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
}
function assertPicker(html: string) {
const buttons = iconButtons(html);
expect(buttons).toHaveLength(SYMPTOM_ICON_ALLOWLIST.length);
for (const button of buttons) {
expect(button).toContain("size-11");
expect(button).toContain("sm:size-9");
expect(button).not.toMatch(/\bsize-7\b/);
}
expect(html).toContain('aria-label="Heartbeat"');
expect(html).toContain('aria-label="Low battery"');
expect(html).not.toContain('aria-label="HeartPulse"');
expect(html).not.toContain('aria-label="BatteryLow"');
}
describe("symptom icon pickers", () => {
it("the add chip's icon targets are 44 px on a phone, with translated names", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AddSymptomChip onCreated={() => {}} />
</I18nProvider>,
);
assertPicker(html);
});
it("the edit sheet's picker matches, with translated names", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EditSymptomDefinitionSheet
definition={{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
}}
onClose={() => {}}
/>
</I18nProvider>,
);
assertPicker(html);
});
it("every allowlisted icon has a label in the English bundle", async () => {
const en = (await import("../../../../messages/en.json")).default as {
symptoms: { icons: Record<string, string> };
};
for (const name of SYMPTOM_ICON_ALLOWLIST) {
expect(en.symptoms.icons[name], name).toBeTruthy();
}
});
});
+6 -31
View File
@@ -19,10 +19,8 @@ import {
} from "@/components/ui/popover";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
import { SymptomIconPicker } from "./symptom-icon-picker";
import { useCreateSymptomDefinition } from "./use-symptoms";
export function AddSymptomChip({
@@ -101,34 +99,11 @@ export function AddSymptomChip({
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">{t("symptoms.iconLabel")}</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("symptoms.iconLabel")}
/>
</div>
{create.isError ? (
<p className="text-destructive text-sm" role="alert">
@@ -18,15 +18,13 @@ import { SliderField } from "@/components/ui/slider";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
SYMPTOM_ICON_ALLOWLIST,
SYMPTOM_INTENSITY_MAX,
SYMPTOM_INTENSITY_MIN,
type SymptomDefinitionDTO,
type SymptomEventDTO,
} from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
import { SymptomIconPicker } from "./symptom-icon-picker";
import {
useUpdateSymptomDefinition,
useUpdateSymptomEvent,
@@ -227,34 +225,11 @@ export function EditSymptomDefinitionSheet({
</FieldGroup>
<div className="space-y-2">
<Label>{t("symptoms.iconLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("symptoms.iconLabel")}
/>
</div>
{error ? (
<p role="alert" className="text-destructive text-sm">
+65 -53
View File
@@ -27,6 +27,7 @@ import {
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import {
Select,
SelectContent,
@@ -210,59 +211,70 @@ export function SymptomEntryForm({
>
<div className="space-y-2">
<Label id={symptomGroupId}>{t("symptoms.entry.symptom")}</Label>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
{definitions.isError ? (
// A failed read is not an empty list: the empty hint would invite
// the person to define a symptom they may already have.
<QueryErrorRow
message={t("symptoms.section.loadError")}
onRetry={() => void definitions.refetch()}
/>
) : (
<>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
</>
)}
{symptomError ? (
<p
id={`${symptomGroupId}-error`}
@@ -0,0 +1,57 @@
"use client";
/**
* The icon picker a person-named symptom is given an icon with. One source for
* the three places that offer it (the symptoms add chip, the symptom edit
* sheet, and the cycle sheet's custom-symptom popover), which all draw from
* the same allowlist: 44 px targets on a phone, 36 px from `sm`, and each icon
* named in the reader's language rather than by its Lucide component name.
*/
import { useTranslations } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
export function SymptomIconPicker({
value,
onChange,
label,
}: {
value: string;
onChange: (name: string) => void;
/** The radiogroup's accessible name (the visible field label). */
label: string;
}) {
const { t } = useTranslations();
return (
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={label}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = value === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`symptoms.icons.${name}`)}
onClick={() => onChange(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { glucoseCardInDisplayUnit } from "../glucose-card";
const card = (over: Record<string, unknown> = {}) => ({
kind: "glucose",
latestValue: 101 as number | null,
secondaryValue: null as number | null,
sparkline: [90, 101, 126],
unit: null as string | null,
unitKey: "dashboard.metric.unit.glucose",
...over,
});
describe("glucoseCardInDisplayUnit", () => {
it("converts the card to mmol/L and names the unit", () => {
expect(glucoseCardInDisplayUnit(card(), "mmol/L")).toMatchObject({
latestValue: 5.6,
secondaryValue: null,
sparkline: [5, 5.6, 7],
unit: "mmol/L",
unitKey: "dashboard.metric.unit.glucose",
});
});
it("keeps mg/dL values and names that unit too", () => {
expect(glucoseCardInDisplayUnit(card(), "mg/dL")).toMatchObject({
latestValue: 101,
sparkline: [90, 101, 126],
unit: "mg/dL",
});
});
it("leaves an empty card empty", () => {
expect(
glucoseCardInDisplayUnit(
card({ latestValue: null, sparkline: [] }),
"mmol/L",
),
).toMatchObject({ latestValue: null, sparkline: [], unit: "mmol/L" });
});
it("passes every other card through untouched", () => {
const weight = card({ kind: "weight", latestValue: 80 });
expect(glucoseCardInDisplayUnit(weight, "mmol/L")).toBe(weight);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
/** The fields of a dashboard summary card the glucose unit touches. */
interface GlucoseConvertibleCard {
kind: string;
latestValue: number | null;
secondaryValue: number | null;
sparkline: number[];
unit: string | null;
}
/**
* Put the glucose card in the record owner's display unit.
*
* Glucose is stored in mg/dL. The summary card used to send that number with
* `unit: null` and a `unitKey` that reads "mg/dL", so a reader who chose
* mmol/L was shown 101 "mg/dL" on a client that honours the card. The card now
* carries its value, its secondary value and its sparkline in the chosen unit
* and names that unit in `unit`, the field that wins over `unitKey` (as the
* sleep card's `"h"` already does). Every other card passes through untouched.
*
* Applied after the summary cache is read, so changing the unit takes effect
* on the next request rather than after the cache window.
*/
export function glucoseCardInDisplayUnit<T extends GlucoseConvertibleCard>(
card: T,
unit: GlucoseUnit,
): T {
if (card.kind !== "glucose") return card;
const show = (value: number | null) =>
value === null ? null : convertGlucose(value, unit);
return {
...card,
latestValue: show(card.latestValue),
secondaryValue: show(card.secondaryValue),
sparkline: card.sparkline.map((value) => convertGlucose(value, unit)),
unit,
};
}
+42 -1
View File
@@ -71,7 +71,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
it("commits when the stated unit is the marker's unit spelled another way", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 95,
@@ -103,4 +103,45 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z");
expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01");
});
// The comparison is the shared spelling normaliser every other lab write
// path uses, not a case fold. Mutation check: put the lower-cased equality
// back → the first case commits (milli read as mega) and the second refuses
// a µg/L reading the other paths accept.
function observation(unit: string, markerUnit: string) {
vi.mocked(decryptFactData).mockReturnValue({
label: "TSH",
value: 2.1,
unit,
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-2",
name: "TSH",
unit: markerUnit,
lowerBound: null,
upperBound: null,
panel: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-2",
} as never);
}
it("refuses MIU/L against a marker in mIU/L: the capital is mega, not milli", async () => {
observation("MIU/L", "mIU/L");
await expect(
commitApprovedFact("user-1", observationFact()),
).rejects.toMatchObject({ code: "observation.unitMismatch" });
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("accepts ug/L against a marker in µg/L, as the other lab write paths do", async () => {
observation("ug/L", "µg/L");
await expect(
commitApprovedFact("user-1", observationFact()),
).resolves.toEqual({ recordType: "labResult", recordId: "lr-2" });
});
});
+4 -3
View File
@@ -28,6 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
isUnreadableRange,
parseReferenceRange,
@@ -134,10 +135,10 @@ async function commitObservation(
// the stated number under the catalog's unit would corrupt the reading AND
// break the "transcribe verbatim" contract — so reject the fact and let the
// user reconcile on the review screen, exactly like the `unitRequired` gate.
// Spelling is compared through the shared normaliser, the same one the
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
if (!isQualitative) {
const stated = (data.unit as string).trim().toLowerCase();
const target = biomarker.unit.trim().toLowerCase();
if (stated !== target) {
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
+1 -1
View File
@@ -186,7 +186,7 @@ const dashboardMetricCard = z
.string()
.nullable()
.describe(
"Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind.",
"Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.",
),
sleepStages: z
.record(z.string(), z.number())