diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 05180b2c4..f40c76d79 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 940abdc4d..8bde98d1b 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -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. diff --git a/messages/de.json b/messages/de.json index 720463289..2e7a699c9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index a181746f7..4fb883bc5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 11b4babc8..7127934cf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index b90e7c26f..1a82ab757 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 883f22bc2..09464fbf9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index 1e388d50f..ab049b462 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "증상 이름을 입력하세요.", diff --git a/messages/pl.json b/messages/pl.json index c45f77f06..731890d25 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ę.", diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index b1c5dc30e..e61286e5b 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 40d0f9994..26d76552f 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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, diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 5b885aa9e..6d3e30d85 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -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 { let enrichedCount = 0; let updatedCount = 0; const insertedIdByIndex = new Map(); + /** Workout ids of the manual edits this request actually wrote. */ + const overwrittenIdByIndex = new Map(); 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 { 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 { 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 { 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({ diff --git a/src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx b/src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx new file mode 100644 index 000000000..e828dc7cb --- /dev/null +++ b/src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx @@ -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 }) => ( +
{children}
+ ), +})); + +vi.mock("../use-cycle", async (importOriginal) => ({ + ...(await importOriginal()), + 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( + + {}} /> + , + ); + const buttons = html.match(/]*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"'); + }); +}); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 3cf04f61d..b15617d7a 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -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 }) {

{t("cycle.symptom.custom.icon")}

-
- {CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => { - const IconC = customIcon(name); - const selected = icon === name; - return ( - - ); - })} -
+ {limitReached ? (

diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index 88d61a6e3..81679d4f3 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -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(" 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(" 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) => diff --git a/src/components/symptoms/__tests__/symptom-entry-form.test.tsx b/src/components/symptoms/__tests__/symptom-entry-form.test.tsx index 1fa6cf5fe..a5f67d51f 100644 --- a/src/components/symptoms/__tests__/symptom-entry-form.test.tsx +++ b/src/components/symptoms/__tests__/symptom-entry-form.test.tsx @@ -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("", () => { ]; 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; + } + }); }); diff --git a/src/components/symptoms/__tests__/symptom-icon-picker.test.tsx b/src/components/symptoms/__tests__/symptom-icon-picker.test.tsx new file mode 100644 index 000000000..fbd578152 --- /dev/null +++ b/src/components/symptoms/__tests__/symptom-icon-picker.test.tsx @@ -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 }) => ( +

{children}
+ ), +})); + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ + children, + footer, + }: { + children: ReactNode; + footer?: ReactNode; + }) => ( +
+ {children} + {footer} +
+ ), +})); + +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(/]*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( + + {}} /> + , + ); + assertPicker(html); + }); + + it("the edit sheet's picker matches, with translated names", () => { + const html = renderToStaticMarkup( + + {}} + /> + , + ); + 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 }; + }; + for (const name of SYMPTOM_ICON_ALLOWLIST) { + expect(en.symptoms.icons[name], name).toBeTruthy(); + } + }); +}); diff --git a/src/components/symptoms/add-symptom-chip.tsx b/src/components/symptoms/add-symptom-chip.tsx index f51dd1fee..4f613a754 100644 --- a/src/components/symptoms/add-symptom-chip.tsx +++ b/src/components/symptoms/add-symptom-chip.tsx @@ -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({

{t("symptoms.iconLabel")}

-
- {SYMPTOM_ICON_ALLOWLIST.map((name) => { - const IconC = symptomIcon(name); - const selected = icon === name; - return ( - - ); - })} -
+
{create.isError ? (

diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index 0d0b94661..3a1ec5076 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -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({

-
- {SYMPTOM_ICON_ALLOWLIST.map((name) => { - const IconC = symptomIcon(name); - const selected = icon === name; - return ( - - ); - })} -
+
{error ? (

diff --git a/src/components/symptoms/symptom-entry-form.tsx b/src/components/symptoms/symptom-entry-form.tsx index 7198e8825..cca703874 100644 --- a/src/components/symptoms/symptom-entry-form.tsx +++ b/src/components/symptoms/symptom-entry-form.tsx @@ -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({ >

-
- {list.map((definition) => { - const selected = definitionId === definition.id; - return ( - - ); - })} - {canDefine && !atLimit ? ( - { - setDefinitionId(id); - setSymptomError(null); - }} - /> - ) : null} -
- {list.length === 0 && !definitions.isLoading ? ( -

- {canDefine - ? t("symptoms.entry.emptyHint") - : t("symptoms.entry.emptyHintNoManage")} -

- ) : atLimit && canDefine ? ( -

- {t("symptoms.entry.limitHint", { limit })} -

- ) : 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. + void definitions.refetch()} + /> + ) : ( + <> +
+ {list.map((definition) => { + const selected = definitionId === definition.id; + return ( + + ); + })} + {canDefine && !atLimit ? ( + { + setDefinitionId(id); + setSymptomError(null); + }} + /> + ) : null} +
+ {list.length === 0 && !definitions.isLoading ? ( +

+ {canDefine + ? t("symptoms.entry.emptyHint") + : t("symptoms.entry.emptyHintNoManage")} +

+ ) : atLimit && canDefine ? ( +

+ {t("symptoms.entry.limitHint", { limit })} +

+ ) : null} + + )} {symptomError ? (

void; + /** The radiogroup's accessible name (the visible field label). */ + label: string; +}) { + const { t } = useTranslations(); + return ( +

+ {SYMPTOM_ICON_ALLOWLIST.map((name) => { + const IconC = symptomIcon(name); + const selected = value === name; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/dashboard/__tests__/glucose-card.test.ts b/src/lib/dashboard/__tests__/glucose-card.test.ts deleted file mode 100644 index 1257e2f64..000000000 --- a/src/lib/dashboard/__tests__/glucose-card.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { glucoseCardInDisplayUnit } from "../glucose-card"; - -const card = (over: Record = {}) => ({ - 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); - }); -}); diff --git a/src/lib/dashboard/glucose-card.ts b/src/lib/dashboard/glucose-card.ts deleted file mode 100644 index e8ca5ab2b..000000000 --- a/src/lib/dashboard/glucose-card.ts +++ /dev/null @@ -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( - 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, - }; -} diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 4a54f40d9..5563913eb 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -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" }); + }); }); diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 1bc271dfd..4645acc0f 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -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.`, diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index b006ee658..2779f7448 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -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()) diff --git a/tests/integration/workout-manual-entry.test.ts b/tests/integration/workout-manual-entry.test.ts index e6ee0bd20..434d4839c 100644 --- a/tests/integration/workout-manual-entry.test.ts +++ b/tests/integration/workout-manual-entry.test.ts @@ -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 = {