From d644db1975502d92427bfc7345ff322f4b5704fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:03:02 +0200 Subject: [PATCH 01/10] Revert "feat(dashboard): send the glucose card in the owner's glucose unit" This reverts commit 9455b64375cce46da1e6e2695cb94f0838761281. --- docs/api/openapi.yaml | 6 +-- .../dashboard/summary/__tests__/route.test.ts | 26 ---------- src/app/api/dashboard/summary/route.ts | 10 +--- .../dashboard/__tests__/glucose-card.test.ts | 47 ------------------- src/lib/dashboard/glucose-card.ts | 39 --------------- src/lib/openapi/routes/dashboard.ts | 2 +- 6 files changed, 4 insertions(+), 126 deletions(-) delete mode 100644 src/lib/dashboard/__tests__/glucose-card.test.ts delete mode 100644 src/lib/dashboard/glucose-card.ts 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/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/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/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()) From 1eab6145a919c6cad89f5140811ca28408cc7482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:07:16 +0200 Subject: [PATCH 02/10] fix(symptoms): show the load error in the quick entry instead of the empty hint --- .../__tests__/symptom-entry-form.test.tsx | 78 ++++++++---- .../symptoms/symptom-entry-form.tsx | 118 ++++++++++-------- 2 files changed, 119 insertions(+), 77 deletions(-) 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/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 ? (

Date: Sat, 3 Oct 2026 23:10:56 +0200 Subject: [PATCH 03/10] fix(symptoms): size the icon pickers for touch and name each icon in the reader's language --- messages/de.json | 22 ++++ messages/en.json | 22 ++++ messages/es.json | 22 ++++ messages/fr.json | 22 ++++ messages/it.json | 22 ++++ messages/ko.json | 22 ++++ messages/pl.json | 22 ++++ .../__tests__/symptom-icon-picker.test.tsx | 122 ++++++++++++++++++ src/components/symptoms/add-symptom-chip.tsx | 4 +- .../symptoms/symptom-edit-sheets.tsx | 2 +- 10 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 src/components/symptoms/__tests__/symptom-icon-picker.test.tsx diff --git a/messages/de.json b/messages/de.json index 26ece40e6..656a89983 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11529,6 +11529,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": "Thermometer", + "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 e94c08bce..bfef0d002 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11529,6 +11529,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 a2fff14e5..473e61765 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11529,6 +11529,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 572487db8..d19c0b067 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11529,6 +11529,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 58d510fb7..da25525b6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11529,6 +11529,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 47f95296b..fd5cc5b55 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11529,6 +11529,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 3cee9f5c9..5600fd0bc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11529,6 +11529,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/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..4fb88f272 100644 --- a/src/components/symptoms/add-symptom-chip.tsx +++ b/src/components/symptoms/add-symptom-chip.tsx @@ -115,10 +115,10 @@ export function AddSymptomChip({ type="button" role="radio" aria-checked={selected} - aria-label={name} + aria-label={t(`symptoms.icons.${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", + "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", diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index 0d0b94661..c2d5c1dc8 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -241,7 +241,7 @@ export function EditSymptomDefinitionSheet({ type="button" role="radio" aria-checked={selected} - aria-label={name} + aria-label={t(`symptoms.icons.${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", From 0100256c60a94ec30e432595cb323e0e180bc57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:13:59 +0200 Subject: [PATCH 04/10] test(labs): pin the lab scan's waiting line to the copy that does not promise the page can be left --- .../labs/__tests__/ocr-review-dialog.test.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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) => From e811c64bfb9217571f56bf1e4b7f425b90d8b984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:26:48 +0200 Subject: [PATCH 05/10] fix(i18n): name the thermometer icon the German way --- messages/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index 656a89983..1653d342d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11548,7 +11548,7 @@ "Cookie": "Keks", "Soup": "Suppe", "Pill": "Tablette", - "Thermometer": "Thermometer", + "Thermometer": "Fieberthermometer", "Stethoscope": "Stethoskop" }, "limitReached": "Du hast 8 Symptome. Blende eins aus, um ein neues anzulegen.", From 62d47d70560ab1f21d00df5c24b3bbc3648f1518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:26:48 +0200 Subject: [PATCH 06/10] fix(workouts): drop a hand-entered workout's paragraph when an edit changes its values The paragraph described the old values. It is deleted with them, its generation claim is detached the way a delete detaches it (the day's slot stays spent), and the workout's arrival is raised again so the paragraph is written from the corrected values through the ordinary path, under the same daily cap. --- src/app/api/workouts/batch/route.ts | 35 +++++-- .../integration/workout-manual-entry.test.ts | 99 +++++++++++++++++++ 2 files changed, 128 insertions(+), 6 deletions(-) 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/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 = { From 6c262f2099d5a5c658c068c9e19801836f2bf8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:29:13 +0200 Subject: [PATCH 07/10] fix(documents): compare a confirmed lab fact's unit with the shared spelling normaliser --- src/lib/documents/__tests__/commit.test.ts | 43 +++++++++++++++++++++- src/lib/documents/commit.ts | 7 ++-- 2 files changed, 46 insertions(+), 4 deletions(-) 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.`, From c809a811e6379091213ed78437cebdf1f96eab79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:32:04 +0200 Subject: [PATCH 08/10] docs(self-hosting): say which reads run on the worker and why a split worker must match the web version --- docs/self-hosting/scaling.md | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 940abdc4d..01b1c950f 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -86,6 +86,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. From b74c6de7ce95128de949f0e2fe3d9a48458370d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:38:12 +0200 Subject: [PATCH 09/10] fix(cycle): give the custom-symptom popover the shared symptom icon picker --- .../custom-symptom-icon-picker.test.tsx | 56 ++++++++++++++++++ src/components/cycle/log-day-sheet.tsx | 41 ++++--------- src/components/symptoms/add-symptom-chip.tsx | 37 ++---------- .../symptoms/symptom-edit-sheets.tsx | 37 ++---------- .../symptoms/symptom-icon-picker.tsx | 57 +++++++++++++++++++ 5 files changed, 136 insertions(+), 92 deletions(-) create mode 100644 src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx create mode 100644 src/components/symptoms/symptom-icon-picker.tsx 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/symptoms/add-symptom-chip.tsx b/src/components/symptoms/add-symptom-chip.tsx index 4fb88f272..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 c2d5c1dc8..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-icon-picker.tsx b/src/components/symptoms/symptom-icon-picker.tsx new file mode 100644 index 000000000..4b766a73f --- /dev/null +++ b/src/components/symptoms/symptom-icon-picker.tsx @@ -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 ( +

+ {SYMPTOM_ICON_ALLOWLIST.map((name) => { + const IconC = symptomIcon(name); + const selected = value === name; + return ( + + ); + })} +
+ ); +} From 17bced4fdfbdbd80bf9c8a957d3ae4f0f1d8b110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:38:13 +0200 Subject: [PATCH 10/10] docs(self-hosting): an Apple Health import from a web-only container is queued, not refused --- docs/self-hosting/scaling.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 01b1c950f..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.