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

+2 -4
View File
@@ -71896,10 +71896,8 @@ components:
anyOf:
- type: string
- type: "null"
description: "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."
description: 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:
anyOf:
- type: object
+43 -4
View File
@@ -46,10 +46,12 @@ staging file. The import then fails with a staging-file-missing error
("the import staging file is not visible to the worker"). Two things to
know:
- A pure `web` container has no pg-boss handle, so an import kicked off
there currently returns `503 Background worker is not running`. Run
imports from a container that also runs the worker until send-only
boss support lands.
- A pure `web` container opens a send-only pg-boss connection at boot
(`src/instrumentation.ts`, `startGlobalBossProducer`), so an import
kicked off there is accepted and queued for the worker like any other
job. It answers `503 Background worker is not running` only when that
producer connection could not be opened (the boot log then shows
`Failed to start pg-boss producer`).
- If you run web and worker as separate containers, they **must share
the import staging directory** — mount the same named volume at the
temp path on both — or run imports in single-container (`all`) mode.
@@ -86,6 +88,43 @@ boot and every 15 minutes, flipping orphaned rows to `failed`
(`interrupted_by_restart`) so the UI offers a retry instead of an
endless spinner.
## Document and lab-scan reads run on the worker (v1.40)
Since v1.40, every AI read of a document or a lab report runs as a
background job on the worker, not inside the HTTP request:
- reading a document for search (the content index),
- a document summary, stored or shown once,
- filing suggestions (title, type, date),
- extracting facts from a stored document for review,
- a lab scan (photo or PDF of a lab report).
The request only queues the read and answers `202`; the client then
polls the run until it ends. That is what keeps a
slow model from being cut off by a proxy timeout in front of the
server. When the enqueue itself fails (no pg-boss connection at all),
the request answers `503` straight away, and nothing is charged.
**"Waiting for the background worker"** is the line the app shows when
a read has sat in the queue for more than 30 seconds without a worker
picking it up. On a single `all` container that is rare and short
(the worker is busy with another job). If it does not go away, no
worker process is consuming the queue: the worker container is
stopped, crashing on boot, or pointed at another database. A run that
no worker picks up within 15 minutes fails as
`aiRuns.workerUnavailable` and its AI budget reservation is handed
back. That sweep is itself a worker job, so with no worker running at
all the run simply stays queued until one starts.
**In a web/worker split, the worker must run the same version as the
web containers.** The web container queues the read; only a worker
that knows the `document-ai-run` queue can execute it. A worker left
on a pre-v1.40 image never takes these jobs, so every document read
and lab scan waits and then fails as above, while everything else
looks healthy. Deploy both from the same image tag
(`HEALTHLOG_IMAGE_REF` is shared by `app` and the `app-worker` block)
and restart the worker together with the web containers.
## Healthchecks
- `app` (web) healthcheck: `wget /api/health` every 30s.
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "Name",
"namePlaceholder": "z. B. Aura",
"iconLabel": "Symbol",
"icons": {
"Tag": "Etikett",
"Activity": "Aktivität",
"Heart": "Herz",
"HeartPulse": "Herzschlag",
"Brain": "Gehirn",
"Zap": "Blitz",
"Flame": "Flamme",
"Snowflake": "Schneeflocke",
"Droplet": "Tropfen",
"CircleDot": "Punkt",
"BatteryLow": "Leerer Akku",
"MoonStar": "Mond und Stern",
"PersonStanding": "Person",
"Drama": "Masken",
"Frown": "Trauriges Gesicht",
"Cookie": "Keks",
"Soup": "Suppe",
"Pill": "Tablette",
"Thermometer": "Fieberthermometer",
"Stethoscope": "Stethoskop"
},
"limitReached": "Du hast 8 Symptome. Blende eins aus, um ein neues anzulegen.",
"saveError": "Das Symptom konnte nicht gespeichert werden. Bitte versuch es noch einmal.",
"nameRequired": "Gib dem Symptom einen Namen.",
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "Name",
"namePlaceholder": "e.g. Aura",
"iconLabel": "Icon",
"icons": {
"Tag": "Tag",
"Activity": "Activity",
"Heart": "Heart",
"HeartPulse": "Heartbeat",
"Brain": "Brain",
"Zap": "Lightning",
"Flame": "Flame",
"Snowflake": "Snowflake",
"Droplet": "Drop",
"CircleDot": "Dot",
"BatteryLow": "Low battery",
"MoonStar": "Moon and star",
"PersonStanding": "Person",
"Drama": "Masks",
"Frown": "Frowning face",
"Cookie": "Cookie",
"Soup": "Soup",
"Pill": "Pill",
"Thermometer": "Thermometer",
"Stethoscope": "Stethoscope"
},
"limitReached": "You have 8 symptoms. Hide one to add another.",
"saveError": "Couldn't save the symptom. Please try again.",
"nameRequired": "Give the symptom a name.",
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "Nombre",
"namePlaceholder": "p. ej. Aura",
"iconLabel": "Icono",
"icons": {
"Tag": "Etiqueta",
"Activity": "Actividad",
"Heart": "Corazón",
"HeartPulse": "Latido",
"Brain": "Cerebro",
"Zap": "Rayo",
"Flame": "Llama",
"Snowflake": "Copo de nieve",
"Droplet": "Gota",
"CircleDot": "Punto",
"BatteryLow": "Batería baja",
"MoonStar": "Luna y estrella",
"PersonStanding": "Persona",
"Drama": "Máscaras",
"Frown": "Cara triste",
"Cookie": "Galleta",
"Soup": "Sopa",
"Pill": "Pastilla",
"Thermometer": "Termómetro",
"Stethoscope": "Estetoscopio"
},
"limitReached": "Tienes 8 síntomas. Oculta uno para añadir otro.",
"saveError": "No se pudo guardar el síntoma. Inténtalo de nuevo.",
"nameRequired": "Ponle un nombre al síntoma.",
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "Nom",
"namePlaceholder": "p. ex. Aura",
"iconLabel": "Icône",
"icons": {
"Tag": "Étiquette",
"Activity": "Activité",
"Heart": "Cœur",
"HeartPulse": "Battement de cœur",
"Brain": "Cerveau",
"Zap": "Éclair",
"Flame": "Flamme",
"Snowflake": "Flocon de neige",
"Droplet": "Goutte",
"CircleDot": "Point",
"BatteryLow": "Batterie faible",
"MoonStar": "Lune et étoile",
"PersonStanding": "Personne",
"Drama": "Masques",
"Frown": "Visage triste",
"Cookie": "Biscuit",
"Soup": "Soupe",
"Pill": "Comprimé",
"Thermometer": "Thermomètre",
"Stethoscope": "Stéthoscope"
},
"limitReached": "Tu as 8 symptômes. Masques-en un pour en ajouter un autre.",
"saveError": "Impossible d'enregistrer le symptôme. Réessaie.",
"nameRequired": "Donne un nom au symptôme.",
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "Nome",
"namePlaceholder": "es. Aura",
"iconLabel": "Icona",
"icons": {
"Tag": "Etichetta",
"Activity": "Attività",
"Heart": "Cuore",
"HeartPulse": "Battito",
"Brain": "Cervello",
"Zap": "Fulmine",
"Flame": "Fiamma",
"Snowflake": "Fiocco di neve",
"Droplet": "Goccia",
"CircleDot": "Punto",
"BatteryLow": "Batteria scarica",
"MoonStar": "Luna e stella",
"PersonStanding": "Persona",
"Drama": "Maschere",
"Frown": "Viso triste",
"Cookie": "Biscotto",
"Soup": "Zuppa",
"Pill": "Pillola",
"Thermometer": "Termometro",
"Stethoscope": "Stetoscopio"
},
"limitReached": "Hai 8 sintomi. Nascondine uno per aggiungerne un altro.",
"saveError": "Impossibile salvare il sintomo. Riprova.",
"nameRequired": "Dai un nome al sintomo.",
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "이름",
"namePlaceholder": "예: 전조",
"iconLabel": "아이콘",
"icons": {
"Tag": "태그",
"Activity": "활동",
"Heart": "하트",
"HeartPulse": "심장 박동",
"Brain": "뇌",
"Zap": "번개",
"Flame": "불꽃",
"Snowflake": "눈송이",
"Droplet": "물방울",
"CircleDot": "점",
"BatteryLow": "배터리 부족",
"MoonStar": "달과 별",
"PersonStanding": "사람",
"Drama": "가면",
"Frown": "찡그린 얼굴",
"Cookie": "쿠키",
"Soup": "수프",
"Pill": "알약",
"Thermometer": "체온계",
"Stethoscope": "청진기"
},
"limitReached": "증상이 8개입니다. 하나를 숨기면 새로 추가할 수 있습니다.",
"saveError": "증상을 저장하지 못했습니다. 다시 시도하세요.",
"nameRequired": "증상 이름을 입력하세요.",
+22
View File
@@ -11530,6 +11530,28 @@
"nameLabel": "Nazwa",
"namePlaceholder": "np. Aura",
"iconLabel": "Ikona",
"icons": {
"Tag": "Etykieta",
"Activity": "Aktywność",
"Heart": "Serce",
"HeartPulse": "Bicie serca",
"Brain": "Mózg",
"Zap": "Błyskawica",
"Flame": "Płomień",
"Snowflake": "Płatek śniegu",
"Droplet": "Kropla",
"CircleDot": "Kropka",
"BatteryLow": "Słaba bateria",
"MoonStar": "Księżyc i gwiazda",
"PersonStanding": "Osoba",
"Drama": "Maski",
"Frown": "Smutna twarz",
"Cookie": "Ciastko",
"Soup": "Zupa",
"Pill": "Tabletka",
"Thermometer": "Termometr",
"Stethoscope": "Stetoskop"
},
"limitReached": "Masz 8 objawów. Ukryj jeden, aby dodać kolejny.",
"saveError": "Nie udało się zapisać objawu. Spróbuj ponownie.",
"nameRequired": "Nadaj objawowi nazwę.",
@@ -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())
@@ -11,6 +11,9 @@
* - a second submit of the same form is a `duplicate`, not a second row;
* - a submit after an edit is `updated` and the one row carries the edit,
* while a synced id with changed values stays first-write-wins;
* - an edit drops the paragraph written about the old values, lets go of
* its generation claim without freeing the day's slot, and raises the
* workout's arrival again so the paragraph is written from the new ones;
* - delete removes a hand-entered workout and refuses a synced one.
*/
import { NextRequest } from "next/server";
@@ -45,6 +48,11 @@ vi.mock("next/headers", async () => {
};
});
// The arrival seam is observed, not run: the edit test asserts the route
// raises one for the edited workout; what the spine then does is its own suite.
const emitDataArrival = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@/lib/arrivals/emit-shared", () => ({ emitDataArrival }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -216,6 +224,97 @@ describe("a workout entered by hand", () => {
]);
});
it("drops the paragraph about the old values when an edit lands", async () => {
const prisma = getPrismaClient();
const entry = formEntry(newManualWorkoutExternalId());
await post({ workouts: [entry] });
const stored = await prisma.workout.findFirstOrThrow({
where: { userId: TEST_USER_ID },
});
// What the worker leaves behind once it wrote the paragraph.
const claim = await prisma.workoutInsightGenerationClaim.create({
data: {
userId: TEST_USER_ID,
workoutId: stored.id,
localDate: "2026-10-03",
providerInvokedAt: new Date(),
completedAt: new Date(),
},
});
await prisma.workoutInsight.create({
data: {
userId: TEST_USER_ID,
workoutId: stored.id,
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
inputHash: "hash-of-the-old-values",
promptVersion: "1.0.0",
providerType: "local",
locale: "en",
generatedAt: new Date(),
},
});
emitDataArrival.mockClear();
// A run corrected from 8 km to 1.2 km.
const res = await post({
workouts: [{ ...entry, totalDistanceM: 1200 }],
});
expect((await res.json()).data.entries).toEqual([
{ index: 0, status: "updated" },
]);
expect(
await prisma.workoutInsight.count({ where: { workoutId: stored.id } }),
).toBe(0);
// The claim stays as the day's ledger, detached from the workout, so the
// edit frees no slot and a fresh claim for the workout is possible.
const ledger = await prisma.workoutInsightGenerationClaim.findUniqueOrThrow(
{ where: { id: claim.id } },
);
expect(ledger.workoutId).toBeNull();
expect(ledger.providerInvokedAt).not.toBeNull();
await vi.waitFor(() =>
expect(emitDataArrival).toHaveBeenCalledWith(
expect.objectContaining({
userId: TEST_USER_ID,
kind: "workout",
refId: stored.id,
}),
),
);
});
it("keeps the paragraph when the same form is sent again unchanged", async () => {
const prisma = getPrismaClient();
const entry = formEntry(newManualWorkoutExternalId());
await post({ workouts: [entry] });
const stored = await prisma.workout.findFirstOrThrow({
where: { userId: TEST_USER_ID },
});
await prisma.workoutInsight.create({
data: {
userId: TEST_USER_ID,
workoutId: stored.id,
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
inputHash: "hash-of-the-values",
promptVersion: "1.0.0",
providerType: "local",
locale: "en",
generatedAt: new Date(),
},
});
emitDataArrival.mockClear();
const res = await post({ workouts: [entry] });
expect((await res.json()).data.entries).toEqual([
{ index: 0, status: "duplicate" },
]);
expect(
await prisma.workoutInsight.count({ where: { workoutId: stored.id } }),
).toBe(1);
expect(emitDataArrival).not.toHaveBeenCalled();
});
it("keeps a synced workout first-write-wins under the same resend", async () => {
const prisma = getPrismaClient();
const entry = {