mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/key-check-hardening
This commit is contained in:
commit
363070540b
27 files changed
+786
-310
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "증상 이름을 입력하세요.",
|
||||
|
||||
@@ -11530,6 +11530,28 @@
|
||||
"nameLabel": "Nazwa",
|
||||
"namePlaceholder": "np. Aura",
|
||||
"iconLabel": "Ikona",
|
||||
"icons": {
|
||||
"Tag": "Etykieta",
|
||||
"Activity": "Aktywność",
|
||||
"Heart": "Serce",
|
||||
"HeartPulse": "Bicie serca",
|
||||
"Brain": "Mózg",
|
||||
"Zap": "Błyskawica",
|
||||
"Flame": "Płomień",
|
||||
"Snowflake": "Płatek śniegu",
|
||||
"Droplet": "Kropla",
|
||||
"CircleDot": "Kropka",
|
||||
"BatteryLow": "Słaba bateria",
|
||||
"MoonStar": "Księżyc i gwiazda",
|
||||
"PersonStanding": "Osoba",
|
||||
"Drama": "Maski",
|
||||
"Frown": "Smutna twarz",
|
||||
"Cookie": "Ciastko",
|
||||
"Soup": "Zupa",
|
||||
"Pill": "Tabletka",
|
||||
"Thermometer": "Termometr",
|
||||
"Stethoscope": "Stetoskop"
|
||||
},
|
||||
"limitReached": "Masz 8 objawów. Ukryj jeden, aby dodać kolejny.",
|
||||
"saveError": "Nie udało się zapisać objawu. Spróbuj ponownie.",
|
||||
"nameRequired": "Nadaj objawowi nazwę.",
|
||||
|
||||
@@ -561,32 +561,6 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
});
|
||||
|
||||
it("names the owner's glucose unit on the glucose card", async () => {
|
||||
// Applied after the summary cache: the unit is the record's own choice
|
||||
// and the card's numbers are in it (the conversion is pinned in
|
||||
// `src/lib/dashboard/__tests__/glucose-card.test.ts`).
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, glucoseUnit: "mmol/L" },
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
_count: { _all: 4 },
|
||||
_max: { measuredAt: new Date(Date.now() - 10 * 86_400_000) },
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ id: string; unit: string | null }> };
|
||||
};
|
||||
expect(body.data.metrics.find((m) => m.id === "glucose")?.unit).toBe(
|
||||
"mmol/L",
|
||||
);
|
||||
// Every other card keeps its own unit contract.
|
||||
expect(body.data.metrics.find((m) => m.id === "weight")?.unit).toBeNull();
|
||||
});
|
||||
|
||||
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
|
||||
// tile must SUM the asleep stages of the latest night and convert to
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -194,7 +194,8 @@ interface EntryResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.31.1 — emit one data arrival per workout this batch actually inserted.
|
||||
* v1.31.1 — emit one data arrival per workout this batch actually inserted,
|
||||
* and per hand-entered workout whose edit it actually wrote.
|
||||
*
|
||||
* `createManyAndReturn` gives the transaction the exact inserted ids. The
|
||||
* index-keyed map avoids a second lookup and keeps equal external ids from
|
||||
@@ -541,6 +542,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
let enrichedCount = 0;
|
||||
let updatedCount = 0;
|
||||
const insertedIdByIndex = new Map<number, string>();
|
||||
/** Workout ids of the manual edits this request actually wrote. */
|
||||
const overwrittenIdByIndex = new Map<number, string>();
|
||||
|
||||
if (survivors.length > 0) {
|
||||
// v1.4.42 W5 — only the write-time dedup survivors are probed
|
||||
@@ -709,6 +712,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: o.externalId,
|
||||
},
|
||||
});
|
||||
// The paragraph described the old values (a 12 km run corrected
|
||||
// to 1.2 km must not keep its 12 km story). It goes with them,
|
||||
// and the claim is let go of its workout the way a delete does
|
||||
// (`ON DELETE SET NULL`): the row stays as the day's ledger, so
|
||||
// the edit buys no slot back, and the arrival emitted below can
|
||||
// claim a fresh one through the ordinary path, under the same cap.
|
||||
await tx.workoutInsight.deleteMany({
|
||||
where: { userId: user.id, workoutId: o.workoutId },
|
||||
});
|
||||
await tx.workoutInsightGenerationClaim.updateMany({
|
||||
where: { userId: user.id, workoutId: o.workoutId },
|
||||
data: { workoutId: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,7 +857,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
index: o.index,
|
||||
status: landed ? "updated" : "duplicate",
|
||||
};
|
||||
if (landed) updatedCount += 1;
|
||||
if (landed) {
|
||||
updatedCount += 1;
|
||||
overwrittenIdByIndex.set(o.index, o.workoutId);
|
||||
}
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
@@ -914,13 +933,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
if (insertedCount > 0) {
|
||||
if (insertedCount > 0 || overwrittenIdByIndex.size > 0) {
|
||||
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
|
||||
// stop at the shared salience classifier before any queue work.
|
||||
// An enrichment raises none: the workout arrived when it was stored.
|
||||
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
|
||||
() => {},
|
||||
);
|
||||
// A hand-entered workout whose edit landed raises one again, so its
|
||||
// paragraph (dropped with the old values) is written from the new ones.
|
||||
void emitWorkoutArrivals(
|
||||
user.id,
|
||||
prepared,
|
||||
new Map([...insertedIdByIndex, ...overwrittenIdByIndex]),
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The cycle sheet's "add a custom symptom" popover offers the same icon
|
||||
* picker as the symptoms feature, and must look and behave the same: 44 px
|
||||
* targets on a phone, 36 px from `sm`, a translated name per icon.
|
||||
*
|
||||
* The popover portals and portals do not render server-side, so it is
|
||||
* replaced by pass-through wrappers; what is measured is the picker inside.
|
||||
*
|
||||
* Mutation check (run, seen red): put the cycle sheet's own inline picker
|
||||
* back (`size-7`, `aria-label={name}`) → this test goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../use-cycle", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../use-cycle")>()),
|
||||
useCreateCustomSymptom: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AddSymptomChip } from "../log-day-sheet";
|
||||
|
||||
describe("cycle custom-symptom icon picker", () => {
|
||||
it("has 44 px targets on a phone and translated icon names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<AddSymptomChip onCreated={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const buttons = html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
|
||||
expect(buttons).toHaveLength(CUSTOM_SYMPTOM_ICON_ALLOWLIST.length);
|
||||
for (const button of buttons) {
|
||||
expect(button).toContain("size-11");
|
||||
expect(button).toContain("sm:size-9");
|
||||
expect(button).not.toMatch(/\bsize-7\b/);
|
||||
}
|
||||
expect(html).toContain('aria-label="Herzschlag"');
|
||||
expect(html).not.toContain('aria-label="HeartPulse"');
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { SymptomIconPicker } from "@/components/symptoms/symptom-icon-picker";
|
||||
import { FieldInfo } from "./field-info";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
|
||||
@@ -1519,7 +1519,11 @@ export function SymptomRow({
|
||||
* label + icon popover to mint a new custom symptom. Same height and shape as
|
||||
* a symptom row so it reads as part of the list, not a foreign button.
|
||||
*/
|
||||
function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
|
||||
export function AddSymptomChip({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateCustomSymptom();
|
||||
const [openForm, setOpenForm] = useState(false);
|
||||
@@ -1594,34 +1598,11 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
|
||||
<p className="text-xs font-medium">
|
||||
{t("cycle.symptom.custom.icon")}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("cycle.symptom.custom.icon")}
|
||||
>
|
||||
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = customIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SymptomIconPicker
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
label={t("cycle.symptom.custom.icon")}
|
||||
/>
|
||||
</div>
|
||||
{limitReached ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
unitSkippedAnalytes,
|
||||
} from "../ocr-review-dialog";
|
||||
|
||||
const hookState = vi.hoisted(() => ({ extractPending: false }));
|
||||
const hookState = vi.hoisted(() => ({
|
||||
extractPending: false,
|
||||
runPhase: "idle" as "idle" | "running" | "waitingForWorker",
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
@@ -37,6 +40,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
vi.mock("../use-ocr-extract", () => ({
|
||||
useOcrExtract: () => ({
|
||||
isPending: hookState.extractPending,
|
||||
runPhase: hookState.runPhase,
|
||||
mutate: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
@@ -174,6 +178,35 @@ describe("<OcrReviewDialog> document-reading consent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A lab scan's proposed rows live only in its run: nothing is stored until
|
||||
* the person confirms them here. So the waiting line must not promise that
|
||||
* the page can be left (the result would be lost, and a second scan costs
|
||||
* another rate slot). Mutation check: swap `aiRuns.backgroundScan` for
|
||||
* `aiRuns.backgroundDocument` in the dialog → both cases go red.
|
||||
*/
|
||||
describe("<OcrReviewDialog> background read", () => {
|
||||
afterEach(() => {
|
||||
hookState.extractPending = false;
|
||||
hookState.runPhase = "idle";
|
||||
});
|
||||
|
||||
it.each([
|
||||
["en", "With a slow model", "leave this page"],
|
||||
["de", "Mit einem langsamen Modell", "Seite verlassen"],
|
||||
] as const)(
|
||||
"(%s) says it reads in the background, never that the page can be left",
|
||||
(locale, expected, forbidden) => {
|
||||
hookState.extractPending = true;
|
||||
hookState.runPhase = "running";
|
||||
const html = render(locale);
|
||||
expect(html).toContain('data-slot="ocr-run-phase"');
|
||||
expect(html).toContain(expected);
|
||||
expect(html).not.toContain(forbidden);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("extractErrorMessage", () => {
|
||||
const t = (key: string) => key;
|
||||
const refusal = (errorCode: string, status = 403) =>
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
* - drop the `resolvedAt === null` filter on the episodes → "offers no
|
||||
* episode selector when every episode is resolved" goes red;
|
||||
* - render the add chip unconditionally → "a delegate who may log but not
|
||||
* manage gets no add chip" goes red.
|
||||
* manage gets no add chip" goes red;
|
||||
* - drop the `definitions.isError` branch → "a failed read shows the error
|
||||
* row, not the empty hint" goes red (the empty hint renders instead).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
canManage: true,
|
||||
definitionsFailed: false,
|
||||
episodes: [] as Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -34,30 +37,42 @@ vi.mock("@/components/illness/use-illness", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../use-symptoms", () => ({
|
||||
useSymptomDefinitions: () => ({
|
||||
isLoading: false,
|
||||
data: {
|
||||
limit: 8,
|
||||
definitions: [
|
||||
{
|
||||
id: "d1",
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
|
||||
useSymptomDefinitions: () =>
|
||||
state.definitionsFailed
|
||||
? { isLoading: false, isError: true, data: undefined, refetch: vi.fn() }
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
limit: 8,
|
||||
definitions: [
|
||||
{
|
||||
id: "d1",
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
recent: {
|
||||
count30d: 0,
|
||||
maxIntensity30d: null,
|
||||
lastOccurredAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
label: "Headache",
|
||||
icon: "Brain",
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
recent: {
|
||||
count30d: 0,
|
||||
maxIntensity30d: null,
|
||||
lastOccurredAt: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
label: "Headache",
|
||||
icon: "Brain",
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
useLogSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useCreateSymptomDefinition: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
@@ -111,4 +126,19 @@ describe("<SymptomEntryForm>", () => {
|
||||
];
|
||||
expect(render()).not.toContain("-episode");
|
||||
});
|
||||
|
||||
it("a failed read shows the error row, not the empty hint", () => {
|
||||
state.canManage = true;
|
||||
state.episodes = [];
|
||||
state.definitionsFailed = true;
|
||||
try {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="query-error-row"');
|
||||
expect(html).toContain("load your symptoms");
|
||||
expect(html).not.toContain("first symptom");
|
||||
expect(html).not.toContain('data-testid="symptom-add-chip"');
|
||||
} finally {
|
||||
state.definitionsFailed = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Both symptom icon pickers (the add chip's popover and the edit sheet) share
|
||||
* one shape: 44 px targets on a phone, 36 px from `sm`, and a translated
|
||||
* accessible name per icon rather than the raw Lucide component name.
|
||||
*
|
||||
* The popover and the sheet portal, and portals do not render server-side,
|
||||
* so both are replaced by pass-through wrappers that render their children
|
||||
* inline; what is measured is the picker inside them.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - put `size-7` back on the add chip's icon buttons → "the add chip's
|
||||
* icon targets" goes red;
|
||||
* - put `aria-label={name}` back on either picker → the label assertions
|
||||
* go red ("HeartPulse" is a raw name, "Heartbeat" its English label).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../use-symptoms", () => {
|
||||
const mutation = {
|
||||
mutateAsync: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
};
|
||||
return {
|
||||
useCreateSymptomDefinition: () => mutation,
|
||||
useUpdateSymptomDefinition: () => mutation,
|
||||
useUpdateSymptomEvent: () => mutation,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
|
||||
import { AddSymptomChip } from "../add-symptom-chip";
|
||||
import { EditSymptomDefinitionSheet } from "../symptom-edit-sheets";
|
||||
|
||||
function iconButtons(html: string): string[] {
|
||||
return html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
|
||||
}
|
||||
|
||||
function assertPicker(html: string) {
|
||||
const buttons = iconButtons(html);
|
||||
expect(buttons).toHaveLength(SYMPTOM_ICON_ALLOWLIST.length);
|
||||
for (const button of buttons) {
|
||||
expect(button).toContain("size-11");
|
||||
expect(button).toContain("sm:size-9");
|
||||
expect(button).not.toMatch(/\bsize-7\b/);
|
||||
}
|
||||
expect(html).toContain('aria-label="Heartbeat"');
|
||||
expect(html).toContain('aria-label="Low battery"');
|
||||
expect(html).not.toContain('aria-label="HeartPulse"');
|
||||
expect(html).not.toContain('aria-label="BatteryLow"');
|
||||
}
|
||||
|
||||
describe("symptom icon pickers", () => {
|
||||
it("the add chip's icon targets are 44 px on a phone, with translated names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AddSymptomChip onCreated={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
assertPicker(html);
|
||||
});
|
||||
|
||||
it("the edit sheet's picker matches, with translated names", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EditSymptomDefinitionSheet
|
||||
definition={{
|
||||
id: "d1",
|
||||
label: "Aura",
|
||||
icon: "Zap",
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
recent: {
|
||||
count30d: 0,
|
||||
maxIntensity30d: null,
|
||||
lastOccurredAt: null,
|
||||
},
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
assertPicker(html);
|
||||
});
|
||||
|
||||
it("every allowlisted icon has a label in the English bundle", async () => {
|
||||
const en = (await import("../../../../messages/en.json")).default as {
|
||||
symptoms: { icons: Record<string, string> };
|
||||
};
|
||||
for (const name of SYMPTOM_ICON_ALLOWLIST) {
|
||||
expect(en.symptoms.icons[name], name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { symptomIcon } from "./symptom-icons";
|
||||
import { SymptomIconPicker } from "./symptom-icon-picker";
|
||||
import { useCreateSymptomDefinition } from "./use-symptoms";
|
||||
|
||||
export function AddSymptomChip({
|
||||
@@ -101,34 +99,11 @@ export function AddSymptomChip({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">{t("symptoms.iconLabel")}</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("symptoms.iconLabel")}
|
||||
>
|
||||
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = symptomIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SymptomIconPicker
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
label={t("symptoms.iconLabel")}
|
||||
/>
|
||||
</div>
|
||||
{create.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
|
||||
@@ -18,15 +18,13 @@ import { SliderField } from "@/components/ui/slider";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SYMPTOM_ICON_ALLOWLIST,
|
||||
SYMPTOM_INTENSITY_MAX,
|
||||
SYMPTOM_INTENSITY_MIN,
|
||||
type SymptomDefinitionDTO,
|
||||
type SymptomEventDTO,
|
||||
} from "@/lib/symptoms/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { symptomIcon } from "./symptom-icons";
|
||||
import { SymptomIconPicker } from "./symptom-icon-picker";
|
||||
import {
|
||||
useUpdateSymptomDefinition,
|
||||
useUpdateSymptomEvent,
|
||||
@@ -227,34 +225,11 @@ export function EditSymptomDefinitionSheet({
|
||||
</FieldGroup>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("symptoms.iconLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("symptoms.iconLabel")}
|
||||
>
|
||||
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = symptomIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SymptomIconPicker
|
||||
value={icon}
|
||||
onChange={setIcon}
|
||||
label={t("symptoms.iconLabel")}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -210,59 +211,70 @@ export function SymptomEntryForm({
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label id={symptomGroupId}>{t("symptoms.entry.symptom")}</Label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby={symptomGroupId}
|
||||
aria-describedby={
|
||||
symptomError ? `${symptomGroupId}-error` : undefined
|
||||
}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{list.map((definition) => {
|
||||
const selected = definitionId === definition.id;
|
||||
return (
|
||||
<button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-testid="symptom-chip"
|
||||
onClick={() => {
|
||||
setDefinitionId(selected ? null : definition.id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<SymptomIcon name={definition.icon} className="size-4" />
|
||||
{definition.label ?? t("symptoms.unreadableLabel")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canDefine && !atLimit ? (
|
||||
<AddSymptomChip
|
||||
onCreated={(id) => {
|
||||
setDefinitionId(id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{list.length === 0 && !definitions.isLoading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{canDefine
|
||||
? t("symptoms.entry.emptyHint")
|
||||
: t("symptoms.entry.emptyHintNoManage")}
|
||||
</p>
|
||||
) : atLimit && canDefine ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("symptoms.entry.limitHint", { limit })}
|
||||
</p>
|
||||
) : null}
|
||||
{definitions.isError ? (
|
||||
// A failed read is not an empty list: the empty hint would invite
|
||||
// the person to define a symptom they may already have.
|
||||
<QueryErrorRow
|
||||
message={t("symptoms.section.loadError")}
|
||||
onRetry={() => void definitions.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby={symptomGroupId}
|
||||
aria-describedby={
|
||||
symptomError ? `${symptomGroupId}-error` : undefined
|
||||
}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{list.map((definition) => {
|
||||
const selected = definitionId === definition.id;
|
||||
return (
|
||||
<button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-testid="symptom-chip"
|
||||
onClick={() => {
|
||||
setDefinitionId(selected ? null : definition.id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<SymptomIcon name={definition.icon} className="size-4" />
|
||||
{definition.label ?? t("symptoms.unreadableLabel")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canDefine && !atLimit ? (
|
||||
<AddSymptomChip
|
||||
onCreated={(id) => {
|
||||
setDefinitionId(id);
|
||||
setSymptomError(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{list.length === 0 && !definitions.isLoading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{canDefine
|
||||
? t("symptoms.entry.emptyHint")
|
||||
: t("symptoms.entry.emptyHintNoManage")}
|
||||
</p>
|
||||
) : atLimit && canDefine ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("symptoms.entry.limitHint", { limit })}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{symptomError ? (
|
||||
<p
|
||||
id={`${symptomGroupId}-error`}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The icon picker a person-named symptom is given an icon with. One source for
|
||||
* the three places that offer it (the symptoms add chip, the symptom edit
|
||||
* sheet, and the cycle sheet's custom-symptom popover), which all draw from
|
||||
* the same allowlist: 44 px targets on a phone, 36 px from `sm`, and each icon
|
||||
* named in the reader's language rather than by its Lucide component name.
|
||||
*/
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { symptomIcon } from "./symptom-icons";
|
||||
|
||||
export function SymptomIconPicker({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (name: string) => void;
|
||||
/** The radiogroup's accessible name (the visible field label). */
|
||||
label: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
>
|
||||
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = symptomIcon(name);
|
||||
const selected = value === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={t(`symptoms.icons.${name}`)}
|
||||
onClick={() => onChange(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { glucoseCardInDisplayUnit } from "../glucose-card";
|
||||
|
||||
const card = (over: Record<string, unknown> = {}) => ({
|
||||
kind: "glucose",
|
||||
latestValue: 101 as number | null,
|
||||
secondaryValue: null as number | null,
|
||||
sparkline: [90, 101, 126],
|
||||
unit: null as string | null,
|
||||
unitKey: "dashboard.metric.unit.glucose",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("glucoseCardInDisplayUnit", () => {
|
||||
it("converts the card to mmol/L and names the unit", () => {
|
||||
expect(glucoseCardInDisplayUnit(card(), "mmol/L")).toMatchObject({
|
||||
latestValue: 5.6,
|
||||
secondaryValue: null,
|
||||
sparkline: [5, 5.6, 7],
|
||||
unit: "mmol/L",
|
||||
unitKey: "dashboard.metric.unit.glucose",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mg/dL values and names that unit too", () => {
|
||||
expect(glucoseCardInDisplayUnit(card(), "mg/dL")).toMatchObject({
|
||||
latestValue: 101,
|
||||
sparkline: [90, 101, 126],
|
||||
unit: "mg/dL",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty card empty", () => {
|
||||
expect(
|
||||
glucoseCardInDisplayUnit(
|
||||
card({ latestValue: null, sparkline: [] }),
|
||||
"mmol/L",
|
||||
),
|
||||
).toMatchObject({ latestValue: null, sparkline: [], unit: "mmol/L" });
|
||||
});
|
||||
|
||||
it("passes every other card through untouched", () => {
|
||||
const weight = card({ kind: "weight", latestValue: 80 });
|
||||
expect(glucoseCardInDisplayUnit(weight, "mmol/L")).toBe(weight);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/** The fields of a dashboard summary card the glucose unit touches. */
|
||||
interface GlucoseConvertibleCard {
|
||||
kind: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
sparkline: number[];
|
||||
unit: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the glucose card in the record owner's display unit.
|
||||
*
|
||||
* Glucose is stored in mg/dL. The summary card used to send that number with
|
||||
* `unit: null` and a `unitKey` that reads "mg/dL", so a reader who chose
|
||||
* mmol/L was shown 101 "mg/dL" on a client that honours the card. The card now
|
||||
* carries its value, its secondary value and its sparkline in the chosen unit
|
||||
* and names that unit in `unit`, the field that wins over `unitKey` (as the
|
||||
* sleep card's `"h"` already does). Every other card passes through untouched.
|
||||
*
|
||||
* Applied after the summary cache is read, so changing the unit takes effect
|
||||
* on the next request rather than after the cache window.
|
||||
*/
|
||||
export function glucoseCardInDisplayUnit<T extends GlucoseConvertibleCard>(
|
||||
card: T,
|
||||
unit: GlucoseUnit,
|
||||
): T {
|
||||
if (card.kind !== "glucose") return card;
|
||||
const show = (value: number | null) =>
|
||||
value === null ? null : convertGlucose(value, unit);
|
||||
return {
|
||||
...card,
|
||||
latestValue: show(card.latestValue),
|
||||
secondaryValue: show(card.secondaryValue),
|
||||
sparkline: card.sparkline.map((value) => convertGlucose(value, unit)),
|
||||
unit,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
|
||||
@@ -186,7 +186,7 @@ const dashboardMetricCard = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind.",
|
||||
"Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.",
|
||||
),
|
||||
sleepStages: z
|
||||
.record(z.string(), z.number())
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* - a second submit of the same form is a `duplicate`, not a second row;
|
||||
* - a submit after an edit is `updated` and the one row carries the edit,
|
||||
* while a synced id with changed values stays first-write-wins;
|
||||
* - an edit drops the paragraph written about the old values, lets go of
|
||||
* its generation claim without freeing the day's slot, and raises the
|
||||
* workout's arrival again so the paragraph is written from the new ones;
|
||||
* - delete removes a hand-entered workout and refuses a synced one.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -45,6 +48,11 @@ vi.mock("next/headers", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// The arrival seam is observed, not run: the edit test asserts the route
|
||||
// raises one for the edited workout; what the spine then does is its own suite.
|
||||
const emitDataArrival = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("@/lib/arrivals/emit-shared", () => ({ emitDataArrival }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -216,6 +224,97 @@ describe("a workout entered by hand", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the paragraph about the old values when an edit lands", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = formEntry(newManualWorkoutExternalId());
|
||||
await post({ workouts: [entry] });
|
||||
const stored = await prisma.workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
// What the worker leaves behind once it wrote the paragraph.
|
||||
const claim = await prisma.workoutInsightGenerationClaim.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
workoutId: stored.id,
|
||||
localDate: "2026-10-03",
|
||||
providerInvokedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await prisma.workoutInsight.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
workoutId: stored.id,
|
||||
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
|
||||
inputHash: "hash-of-the-old-values",
|
||||
promptVersion: "1.0.0",
|
||||
providerType: "local",
|
||||
locale: "en",
|
||||
generatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
emitDataArrival.mockClear();
|
||||
|
||||
// A run corrected from 8 km to 1.2 km.
|
||||
const res = await post({
|
||||
workouts: [{ ...entry, totalDistanceM: 1200 }],
|
||||
});
|
||||
expect((await res.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "updated" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
await prisma.workoutInsight.count({ where: { workoutId: stored.id } }),
|
||||
).toBe(0);
|
||||
// The claim stays as the day's ledger, detached from the workout, so the
|
||||
// edit frees no slot and a fresh claim for the workout is possible.
|
||||
const ledger = await prisma.workoutInsightGenerationClaim.findUniqueOrThrow(
|
||||
{ where: { id: claim.id } },
|
||||
);
|
||||
expect(ledger.workoutId).toBeNull();
|
||||
expect(ledger.providerInvokedAt).not.toBeNull();
|
||||
await vi.waitFor(() =>
|
||||
expect(emitDataArrival).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: TEST_USER_ID,
|
||||
kind: "workout",
|
||||
refId: stored.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the paragraph when the same form is sent again unchanged", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = formEntry(newManualWorkoutExternalId());
|
||||
await post({ workouts: [entry] });
|
||||
const stored = await prisma.workout.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
await prisma.workoutInsight.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
workoutId: stored.id,
|
||||
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
|
||||
inputHash: "hash-of-the-values",
|
||||
promptVersion: "1.0.0",
|
||||
providerType: "local",
|
||||
locale: "en",
|
||||
generatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
emitDataArrival.mockClear();
|
||||
|
||||
const res = await post({ workouts: [entry] });
|
||||
expect((await res.json()).data.entries).toEqual([
|
||||
{ index: 0, status: "duplicate" },
|
||||
]);
|
||||
expect(
|
||||
await prisma.workoutInsight.count({ where: { workoutId: stored.id } }),
|
||||
).toBe(1);
|
||||
expect(emitDataArrival).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a synced workout first-write-wins under the same resend", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const entry = {
|
||||
|
||||
Reference in new issue
Block a user