From 651b02bc1be54108f7cc59890e2256c0dff453f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 20:15:52 +0200 Subject: [PATCH 01/27] fix(ci): gate the coach-eval live judge on env, not the secrets context --- .github/workflows/coach-eval.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coach-eval.yml b/.github/workflows/coach-eval.yml index 01e82c450..356ee8432 100644 --- a/.github/workflows/coach-eval.yml +++ b/.github/workflows/coach-eval.yml @@ -27,6 +27,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 + # Map the opt-in secret to a job-level env so the live-judge steps can gate + # on it. The `secrets` context is NOT available in `if:` expressions — using + # it there is a workflow-file validation error that surfaces as a 0-second + # startup failure on every push. `env` is allowed in `if:`, so gate on that. + env: + COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }} + steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -51,14 +58,12 @@ jobs: # non-blocking either way. With the secret absent the script no-ops with a # clear line and exits 0. - name: Live judge (opt-in, non-blocking) - if: ${{ secrets.COACH_EVAL_API_KEY != '' }} + if: ${{ env.COACH_EVAL_API_KEY != '' }} continue-on-error: true - env: - COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }} run: pnpm dlx tsx scripts/run-coach-eval.ts # When the secret is absent, surface a clear no-op line so the run log # explains why the live judge did not execute. - name: Live judge skipped (no secret) - if: ${{ secrets.COACH_EVAL_API_KEY == '' }} + if: ${{ env.COACH_EVAL_API_KEY == '' }} run: echo "COACH_EVAL_API_KEY not set — live Coach judge skipped (deterministic floor still gated above)." From 6e204d054ad42f4943bbfe222845b6d7661dd63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 20:22:24 +0200 Subject: [PATCH 02/27] feat(insights): age-stratify grip strength and waist reference bands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grip strength and waist circumference resolved against a single sex-aware bracket for every adult age. Replace each with age x sex norm bands: - Grip strength: age x sex bands from Dodds et al. 2014 (PLOS ONE 9(12):e113637) British normative data — band low is the 10th centile floored at the EWGSOP2 probable-sarcopenia cut-off (men 27 kg, women 16 kg), band high is the 50th-centile median. Strength peaks in the 30s and declines from midlife, so the bands step down with age. - Waist circumference: WHO increased-risk threshold (men 94 cm, women 80 cm) held flat through midlife and raised for older adults toward the WHO substantially-increased-risk line, since the WHO cut-points derive from cohorts aged 20-74. The standard threshold is never lowered. The norms resolver gains an age-independent clinical cut-off row per sex, used only as the no-age fallback so a profile with a known sex but no date of birth keeps the sex cut-off rather than the coarse registry anchor. The signal registry structure is unchanged — only the resolver tables move. --- .../insights/derived/__tests__/norms.test.ts | 83 ++++++++++ src/lib/insights/derived/norms.ts | 154 ++++++++++++++---- 2 files changed, 208 insertions(+), 29 deletions(-) diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts index 221c46e83..6851f634e 100644 --- a/src/lib/insights/derived/__tests__/norms.test.ts +++ b/src/lib/insights/derived/__tests__/norms.test.ts @@ -89,6 +89,89 @@ describe("lookupNormalRange — the age/sex reference-range enabler", () => { }); }); +describe("grip strength — age × sex bands (Dodds 2014 / EWGSOP2 floor)", () => { + it("steps the band down with age (younger reads against a higher norm)", () => { + // Male 30s centre 34.5 → {38,51}; male 70s centre 74.5 → {29,39}. + const young = lookupNormalRange("GRIP_STRENGTH", 34.5, "MALE")!; + const old = lookupNormalRange("GRIP_STRENGTH", 74.5, "MALE")!; + expect(young).toEqual({ low: 38, high: 51 }); + expect(old).toEqual({ low: 29, high: 39 }); + expect(young.low).toBeGreaterThan(old.low); + expect(young.high).toBeGreaterThan(old.high); + }); + + it("classifies by sex within the same age (male band sits above female)", () => { + const male = lookupNormalRange("GRIP_STRENGTH", 44.5, "MALE")!; + const female = lookupNormalRange("GRIP_STRENGTH", 44.5, "FEMALE")!; + expect(male.low).toBeGreaterThan(female.low); + expect(male.high).toBeGreaterThan(female.high); + }); + + it("never drops the low below the EWGSOP2 cut-off at the oldest band", () => { + // 80+ centre 100: male low floored at 27, female low floored at 16. + expect(lookupNormalRange("GRIP_STRENGTH", 100, "MALE")!.low).toBe(27); + expect(lookupNormalRange("GRIP_STRENGTH", 100, "FEMALE")!.low).toBe(16); + // Even past the oldest centre the clamp holds the floor. + expect(lookupNormalRange("GRIP_STRENGTH", 110, "MALE")!.low).toBe(27); + }); + + it("falls back to the sex cut-off band when age is absent", () => { + expect(lookupNormalRange("GRIP_STRENGTH", null, "MALE")).toEqual({ + low: 27, + high: 60, + }); + expect(lookupNormalRange("GRIP_STRENGTH", undefined, "FEMALE")).toEqual({ + low: 16, + high: 60, + }); + }); + + it("returns null when sex is absent (no honest sex-specific band)", () => { + expect(lookupNormalRange("GRIP_STRENGTH", 40, null)).toBeNull(); + expect(lookupNormalRange("GRIP_STRENGTH", null, null)).toBeNull(); + }); +}); + +describe("waist circumference — age × sex bands (WHO, raised for elderly)", () => { + it("holds the WHO threshold through midlife, raising it for older adults", () => { + // Male 18-49 centre 33.5 → {0,94}; 80+ centre 100 → {0,102}. + const adult = lookupNormalRange("WAIST_CIRCUMFERENCE", 33.5, "MALE")!; + const elderly = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "MALE")!; + expect(adult).toEqual({ low: 0, high: 94 }); + expect(elderly).toEqual({ low: 0, high: 102 }); + expect(elderly.high).toBeGreaterThan(adult.high); + }); + + it("classifies by sex (the female threshold sits below the male)", () => { + const male = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "MALE")!; + const female = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "FEMALE")!; + expect(female.high).toBeLessThan(male.high); + expect(female).toEqual({ low: 0, high: 88 }); + }); + + it("never lowers the threshold below the standard WHO floor", () => { + for (const age of [18, 25, 40, 55, 65, 75, 90]) { + expect( + lookupNormalRange("WAIST_CIRCUMFERENCE", age, "MALE")!.high, + ).toBeGreaterThanOrEqual(94); + expect( + lookupNormalRange("WAIST_CIRCUMFERENCE", age, "FEMALE")!.high, + ).toBeGreaterThanOrEqual(80); + } + }); + + it("falls back to the standard WHO threshold when age is absent", () => { + expect(lookupNormalRange("WAIST_CIRCUMFERENCE", null, "MALE")).toEqual({ + low: 0, + high: 94, + }); + expect(lookupNormalRange("WAIST_CIRCUMFERENCE", null, "FEMALE")).toEqual({ + low: 0, + high: 80, + }); + }); +}); + describe("hasSharpenedNorm", () => { it("mirrors lookupNormalRange truthiness", () => { expect(hasSharpenedNorm("VO2_MAX", 35, "MALE")).toBe(true); diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index e43fc8979..5e5c0f0d2 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -31,6 +31,20 @@ * "ATS Statement: Guidelines for the Six-Minute Walk Test", * Am J Respir Crit Care Med 166(1):111–117. Surfaced as a * percent-of-predicted re-frame, never a HealthLog-derived equation. + * - Grip strength by age × sex — Dodds et al. 2014, "Grip Strength + * across the Life Course: Normative Data from Twelve British Studies", + * PLOS ONE 9(12):e113637 (n≈49,964). The age×sex band low is the 10th + * centile, FLOORED at the EWGSOP2 "probable sarcopenia" cut-off + * (Cruz-Jentoft et al., Age Ageing 2019: men < 27 kg, women < 16 kg) so + * the clinical floor is never crossed; the band high is the 50th-centile + * (median) for the age×sex cell. + * - Waist circumference by age × sex — WHO increased-risk thresholds + * (WHO 2008/2011; men > 94 cm, women > 80 cm) as the adult floor, raised + * for older adults: the WHO cut-points derive from cohorts aged 20–74, + * and evidence in the elderly supports cut-points set at higher waist + * values (toward the WHO substantially-increased-risk line, men > 102 cm / + * women > 88 cm). No ethnicity adjustment — HealthLog does not collect + * ancestry. * * Client-safe — pure data + a pure lookup, no server imports. */ @@ -54,6 +68,15 @@ interface NormRow { /** Sex this row applies to; `null` = applies to either. */ sex: "MALE" | "FEMALE" | null; range: NormRange; + /** + * Age-INDEPENDENT clinical cut-off band (the EWGSOP2 grip floor / the WHO + * waist threshold). It is excluded from the age-interpolation set and used + * only as the no-age fallback for a sex-specific table — so a profile with + * a known sex but no date of birth still gets the sex cut-off rather than + * the coarse sex-flat registry anchor. The age-stratified rows already bake + * this floor into their bounds, so it never widens the band for a known age. + */ + ageIndependent?: boolean; } /** @@ -102,28 +125,85 @@ const RESPIRATORY_RATE_NORMS: NormRow[] = [ ]; /** - * Grip-strength normal band (kg) by sex — the EWGSOP2 "probable sarcopenia" - * cut-off (Cruz-Jentoft et al., Age Ageing 2019): men < 27 kg, women < 16 kg. - * The band brackets the at-or-above-cut-off "normal" placement; `direction` - * is `higher-better`, so the read flags a value below the low bound. A single - * adult bracket per sex — the cut-off is sex-specific, not age-banded — so the - * resolver returns it whenever the profile sex is known, even with no age. + * Grip-strength bands (kg) by age × sex — Dodds et al. 2014 (PLOS ONE + * 9(12):e113637). `direction` is `higher-better`, so the read flags a value + * below the low bound. Per age×sex cell: low = the Dodds 10th centile floored + * at the EWGSOP2 "probable sarcopenia" cut-off (men 27 kg, women 16 kg; + * Cruz-Jentoft et al., Age Ageing 2019) so the clinical floor is never + * crossed; high = the Dodds 50th-centile (median). Strength peaks in the 30s + * and declines from midlife, so the bands step down with age — a 70-year-old + * is no longer measured against a 30-year-old's typical grip. The trailing + * `ageIndependent` row is the EWGSOP2 sex cut-off used when the profile has a + * sex but no date of birth (preserves the pre-v1.25.1 no-age behaviour). */ const GRIP_STRENGTH_NORMS: NormRow[] = [ - { minAge: 0, maxAge: 120, sex: "MALE", range: { low: 27, high: 60 } }, - { minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 60 } }, + { minAge: 20, maxAge: 29, sex: "MALE", range: { low: 30, high: 40 } }, + { minAge: 30, maxAge: 39, sex: "MALE", range: { low: 38, high: 51 } }, + { minAge: 40, maxAge: 49, sex: "MALE", range: { low: 38, high: 50 } }, + { minAge: 50, maxAge: 59, sex: "MALE", range: { low: 35, high: 48 } }, + { minAge: 60, maxAge: 69, sex: "MALE", range: { low: 33, high: 45 } }, + { minAge: 70, maxAge: 79, sex: "MALE", range: { low: 29, high: 39 } }, + { minAge: 80, maxAge: 120, sex: "MALE", range: { low: 27, high: 32 } }, + { minAge: 20, maxAge: 29, sex: "FEMALE", range: { low: 21, high: 28 } }, + { minAge: 30, maxAge: 39, sex: "FEMALE", range: { low: 24, high: 31 } }, + { minAge: 40, maxAge: 49, sex: "FEMALE", range: { low: 23, high: 31 } }, + { minAge: 50, maxAge: 59, sex: "FEMALE", range: { low: 21, high: 29 } }, + { minAge: 60, maxAge: 69, sex: "FEMALE", range: { low: 18, high: 27 } }, + { minAge: 70, maxAge: 79, sex: "FEMALE", range: { low: 16, high: 24 } }, + { minAge: 80, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 19 } }, + // EWGSOP2 sex cut-off — no-age fallback only (not age-interpolated). + { + minAge: 18, + maxAge: 120, + sex: "MALE", + range: { low: 27, high: 60 }, + ageIndependent: true, + }, + { + minAge: 18, + maxAge: 120, + sex: "FEMALE", + range: { low: 16, high: 60 }, + ageIndependent: true, + }, ]; /** - * Waist-circumference normal band (cm) by sex — the WHO European-origin - * increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998): men > 94 cm, - * women > 80 cm. `direction` is `lower-better`, so the normal band runs from - * 0 up to the sex cut-off. Single adult bracket per sex (sex-specific, not - * age-banded). No ethnicity adjustment — HealthLog does not collect ancestry. + * Waist-circumference bands (cm) by age × sex — `direction` is `lower-better`, + * so the normal band runs from 0 up to the sex cut-off (the `high`). The WHO + * increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998: men > 94 cm, + * women > 80 cm) is the adult floor and is held flat through midlife. The WHO + * cut-points derive from cohorts aged 20–74; evidence in the elderly supports + * higher cut-points, so the threshold rises with age toward the WHO + * substantially-increased-risk line (men > 102 cm, women > 88 cm). The floor + * is never lowered — the standard threshold is the minimum. The trailing + * `ageIndependent` row is the standard WHO threshold used when the profile has + * a sex but no date of birth. No ethnicity adjustment. */ const WAIST_CIRCUMFERENCE_NORMS: NormRow[] = [ - { minAge: 0, maxAge: 120, sex: "MALE", range: { low: 0, high: 94 } }, - { minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 80 } }, + { minAge: 18, maxAge: 49, sex: "MALE", range: { low: 0, high: 94 } }, + { minAge: 50, maxAge: 64, sex: "MALE", range: { low: 0, high: 94 } }, + { minAge: 65, maxAge: 79, sex: "MALE", range: { low: 0, high: 98 } }, + { minAge: 80, maxAge: 120, sex: "MALE", range: { low: 0, high: 102 } }, + { minAge: 18, maxAge: 49, sex: "FEMALE", range: { low: 0, high: 80 } }, + { minAge: 50, maxAge: 64, sex: "FEMALE", range: { low: 0, high: 80 } }, + { minAge: 65, maxAge: 79, sex: "FEMALE", range: { low: 0, high: 84 } }, + { minAge: 80, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 88 } }, + // Standard WHO threshold — no-age fallback only (not age-interpolated). + { + minAge: 18, + maxAge: 120, + sex: "MALE", + range: { low: 0, high: 94 }, + ageIndependent: true, + }, + { + minAge: 18, + maxAge: 120, + sex: "FEMALE", + range: { low: 0, high: 80 }, + ageIndependent: true, + }, ]; /** @@ -135,7 +215,8 @@ const NORM_TABLES: Partial> = { VO2_MAX: VO2_MAX_NORMS, RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS, RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS, - // v1.25 — sex-specific (age-independent) cut-offs. + // v1.25.1 — age × sex bands, with a sex-specific clinical cut-off as the + // floor + the no-age fallback. GRIP_STRENGTH: GRIP_STRENGTH_NORMS, WAIST_CIRCUMFERENCE: WAIST_CIRCUMFERENCE_NORMS, }; @@ -174,6 +255,13 @@ function resolveSexRows(rows: NormRow[], sex: NormSex): NormRow[] | null { * sex; when the profile sex is absent it falls back to the sex-agnostic * rows; a sex-specific-only table with no profile sex yields no honest band. * + * Clinical-floor fallback: a table may carry an `ageIndependent` cut-off row + * per sex (the EWGSOP2 grip floor / the WHO waist threshold). It is excluded + * from age interpolation and returned only when no usable age is present, so a + * grip / waist read still classifies against the sex cut-off for a profile + * with a known sex but no date of birth. A table with no such row (VO₂max, …) + * returns `null` without an age, keeping the flat registry anchor. + * * Age handling: the bracket tables are coarse (decade / paediatric bands), * so a fractional age that lands near a bracket edge would otherwise read a * hard step at the boundary (e.g. 39.9 → 30s band, 40.0 → 40s band). Instead @@ -195,30 +283,38 @@ export function lookupNormalRange( const rows = resolveSexRows(table, sex); if (!rows || rows.length === 0) return null; - // Single band — no neighbour to interpolate against. A sex-specific, - // age-independent cut-off (grip strength, waist circumference) resolves - // here without an age, so the sex-aware band still applies when the - // profile carries sex but no date of birth. - if (rows.length === 1) return { ...rows[0].range }; + // Split the age-independent clinical cut-off (the EWGSOP2 grip floor / the + // WHO waist threshold) from the age-stratified bands. The cut-off is never + // interpolated — it is the no-age fallback for a sex-specific table. + const ageRows = rows.filter((row) => !row.ageIndependent); + const floorBand = rows.find((row) => row.ageIndependent)?.range ?? null; - // Multi-bracket tables are age-banded — without a usable age there is no - // honest placement, so the caller keeps the flat registry anchor. + // No usable age. A sex-specific clinical cut-off still applies (grip / waist + // are flagged against the sex threshold even with no date of birth); a + // purely age-banded metric (VO₂max, …) has no honest placement, so the + // caller keeps the flat registry anchor. if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) { - return null; + return floorBand ? { ...floorBand } : null; } + // No age-stratified rows (a cut-off-only table) — return the cut-off band. + if (ageRows.length === 0) return floorBand ? { ...floorBand } : null; + + // Single band — no neighbour to interpolate against. + if (ageRows.length === 1) return { ...ageRows[0].range }; + // Clamp below the youngest centre / above the oldest centre: hold the // nearest cited band flat rather than extrapolate past the table. - const first = rows[0]; - const last = rows[rows.length - 1]; + const first = ageRows[0]; + const last = ageRows[ageRows.length - 1]; if (ageYears <= bracketCentre(first)) return { ...first.range }; if (ageYears >= bracketCentre(last)) return { ...last.range }; // Find the two adjacent brackets whose centres bracket the age, then blend // their ranges by the fractional position between the centres. - for (let i = 0; i < rows.length - 1; i++) { - const lo = rows[i]; - const hi = rows[i + 1]; + for (let i = 0; i < ageRows.length - 1; i++) { + const lo = ageRows[i]; + const hi = ageRows[i + 1]; const loCentre = bracketCentre(lo); const hiCentre = bracketCentre(hi); if (ageYears >= loCentre && ageYears <= hiCentre) { From d15dd26ebf566ea682d73a4e20ee8a673ef13dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 20:29:10 +0200 Subject: [PATCH 03/27] Drop the Coach-hero medical disclaimer The new-chat hero carried a standing self-description line. Remove that instance; the greeting and composer stay. The MedicalDisclaimer component and its insights/labs/medications placements are unchanged. --- src/components/insights/coach-panel/coach-hero.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx index 5cc06c4c9..cd67c0d54 100644 --- a/src/components/insights/coach-panel/coach-hero.tsx +++ b/src/components/insights/coach-panel/coach-hero.tsx @@ -2,7 +2,6 @@ import { Sparkles } from "lucide-react"; -import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; @@ -122,15 +121,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) { {scopeHint} ) : null} - - {/* v1.25 — the Coach standing self-description: works from your own - data, not a doctor, not medical advice. Sits once on the calm - new-chat surface so it frames the conversation without nagging - every reply. */} - ); From cab0e50fff8c1a0cefb380aa0dc1fbcd5e79f6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 20:29:15 +0200 Subject: [PATCH 04/27] Collapse the active-sessions and security-activity lists by default Both account-security lists now render behind a disclosure control and open only on click. The trigger carries aria-expanded and aria-controls and is keyboard-operable; the region is gated with hidden. State is UI-only and not persisted. --- .../settings/security-activity-card.tsx | 41 ++++++++++++++---- .../settings/security-sessions-card.tsx | 43 +++++++++++++++---- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 7f7426984..bc3450dbb 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -8,14 +8,15 @@ * shared `GET /api/auth/me/security-activity` endpoint. Each row shows a * readable action label, the resolved location, the masked IP, and the time. */ +import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Loader2, ShieldCheck } from "lucide-react"; +import { ChevronDown, Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +import { cn } from "@/lib/utils"; interface ActivityRow { action: string; @@ -53,6 +54,10 @@ export function SecurityActivityCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + // Collapsed by default — the feed opens only on demand, keeping the security + // surface skimmable. UI-only state; nothing is persisted across reloads. + const [open, setOpen] = useState(false); + const regionId = useId(); const { data, isLoading, isError } = useQuery({ queryKey: queryKeys.securityActivity(), @@ -65,12 +70,32 @@ export function SecurityActivityCard({ return ( - -
+ + +
+ + + setUploadKind(e.target.value as InboundDocumentKindValue) + } + disabled={upload.isPending} + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setUploadDate(e.target.value)} + disabled={upload.isPending} + /> +
+
+ + +
+
+
+ +
- {/* Document list */} -
+ {/* Toolbar — search / category filter / sort. */} +
+
+ +
+ + setQ(e.target.value)} + placeholder={t("documents.toolbar.searchPlaceholder")} + className="pl-8" + /> +
+
+
+ + + setKindFilter(e.target.value as "" | InboundDocumentKindValue) + } + > + + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ +
+ setSort(e.target.value as DocumentListSort)} + > + {DOCUMENT_LIST_SORTS.map((s) => ( + + ))} + + +
+
+
+ + {/* Library list — grouped by date, keyset-paginated. */} +
{list.isLoading ? ( // Skeleton rows (not a centered spinner) so the resolved list lands // in place without a layout jump — the app's loading convention. - <> +
{[0, 1, 2].map((i) => ( - + ))} - - ) : !list.data || list.data.documents.length === 0 ? ( +
+ ) : documents.length === 0 ? (

- {t("documents.list.empty")} + {isFiltered + ? t("documents.list.noResults") + : t("documents.list.empty")}

) : ( - list.data.documents.map((doc) => ( - - )) + + ))} + + {list.hasNextPage ? ( + + ) : null} + )}
- - {selectedId ? ( - setSelectedId(null)} - /> - ) : null} ); } -function DocumentReview({ +/** A single library row — title (or filename), kind + status badges, counts. */ +function DocumentRow({ + doc, + expanded, + onToggle, +}: { + doc: InboundDocumentDto; + expanded: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + const display = doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`); + + return ( + + ); +} + +export function DocumentDetail({ documentId, onClosed, }: { @@ -199,6 +460,7 @@ function DocumentReview({ const { t } = useTranslations(); const queryClient = useQueryClient(); const [decisions, setDecisions] = useState>({}); + const [editOpen, setEditOpen] = useState(false); const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId), @@ -209,6 +471,23 @@ function DocumentReview({ ), }); + const extract = useMutation({ + // Empty body → vision mode over the stored original. + mutationFn: () => + apiPost(`/api/documents/inbound/${documentId}/extract`, undefined), + onSuccess: () => { + toast.success(t("documents.toast.extracted")); + void invalidateKeys(queryClient, [queryKeys.documents()]); + }, + onError: (err) => { + // A missing provider is not an error the user can fix here — surface the + // calm inline note (rendered below) instead of an alarming toast. + if (!isProviderUnsupportedError(err)) { + toast.error(t("documents.toast.extractFailed")); + } + }, + }); + const confirm = useMutation({ mutationFn: () => apiPost(`/api/documents/inbound/${documentId}/confirm`, { @@ -250,65 +529,93 @@ function DocumentReview({ } if (!detail.data) return null; - const pending = detail.data.facts.filter((f) => f.status === "PENDING"); + const doc = detail.data; + const pending = doc.facts.filter((f) => f.status === "PENDING"); const decidedCount = Object.keys(decisions).length; + const canExtract = doc.status !== "CONFIRMED"; + const showProviderNote = isProviderUnsupportedError(extract.error); return ( - - - - {t("documents.review.title")} - -
- {/* View the original uploaded document. The route is same-origin and - cookie-authenticated, so a new tab carries the session; a PDF / - image renders inline, other types download. */} + + + {doc.title ?? doc.filename} +
+ + {canExtract ? ( + + ) : null}
- {pending.length === 0 ? ( -

- {t("documents.empty.noFacts")} -

- ) : ( - pending.map((fact) => ( - - setDecisions((prev) => { - const next = { ...prev }; - if (d === null) delete next[fact.id]; - else next[fact.id] = d; - return next; - }) - } - /> - )) - )} + {showProviderNote ? : null} {pending.length > 0 ? ( <> +

+ {t("documents.review.title")} +

+ {pending.map((fact) => ( + + setDecisions((prev) => { + const next = { ...prev }; + if (d === null) delete next[fact.id]; + else next[fact.id] = d; + return next; + }) + } + /> + ))} - ) : null} + ) : ( +

+ {doc.factCount > 0 + ? t("documents.empty.noFacts") + : t("documents.detail.notExtracted")} +

+ )}
+ + + + + {t("documents.edit.title")} + + {t("documents.edit.subtitle")} + + + setEditOpen(false)} + onCancel={() => setEditOpen(false)} + /> + +
); } +/** The calm inline note shown when extraction needs a provider that's absent. */ +export function ProviderUnsupportedNote() { + const { t } = useTranslations(); + return ( +
+ +

{t("documents.note.providerUnsupported")}

+
+ ); +} + +/** Metadata edit (rename / recategorise / set the filing date). PATCHes. */ +export function DocumentMetaEditForm({ + doc, + onSaved, + onCancel, +}: { + doc: InboundDocumentDto; + onSaved: () => void; + onCancel: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [title, setTitle] = useState(doc.title ?? ""); + const [kind, setKind] = useState(doc.kind); + const [date, setDate] = useState(doc.documentDate ?? ""); + + const save = useMutation({ + mutationFn: () => + apiPatch(`/api/documents/inbound/${doc.id}`, { + title: title.trim() === "" ? null : title.trim(), + kind, + documentDate: date === "" ? null : date, + }), + onSuccess: () => { + toast.success(t("documents.toast.updated")); + void invalidateKeys(queryClient, [queryKeys.documents()]); + onSaved(); + }, + onError: () => toast.error(t("documents.toast.updateFailed")), + }); + + return ( +
+
+ + setTitle(e.target.value)} + placeholder={t("documents.upload.titlePlaceholder")} + /> +
+
+ + setKind(e.target.value as InboundDocumentKindValue)} + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setDate(e.target.value)} + /> +
+ + + + +
+ ); +} + function FactCard({ documentId, fact, diff --git a/src/components/documents/library-utils.ts b/src/components/documents/library-utils.ts new file mode 100644 index 000000000..6400c735d --- /dev/null +++ b/src/components/documents/library-utils.ts @@ -0,0 +1,96 @@ +/** + * v1.25 — pure helpers for the documents library (search-param building, date + * grouping, provider-unsupported classification). Kept out of the client + * component so they stay trivially unit-testable without a render harness. + */ +import { ApiError } from "@/lib/api/api-fetch"; +import type { DocumentListParams } from "@/lib/query-keys/documents"; +import { + DOCUMENT_LIST_DEFAULT_LIMIT, + type InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +/** + * Build the `/api/documents/inbound` query string from the active filters and + * a keyset cursor. Empty / undefined fields are omitted so the URL stays clean + * and the server applies its own defaults. The cursor is the `nextCursor` the + * previous page returned (null on the first page). + */ +export function buildDocumentListSearch( + params: DocumentListParams, + cursor: string | null, + limit: number = DOCUMENT_LIST_DEFAULT_LIMIT, +): string { + const sp = new URLSearchParams(); + if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim()); + if (params.kind) sp.set("kind", params.kind); + if (params.from) sp.set("from", params.from); + if (params.to) sp.set("to", params.to); + sp.set("sort", params.sort); + sp.set("order", params.order); + sp.set("limit", String(limit)); + if (cursor) sp.set("cursor", cursor); + return sp.toString(); +} + +/** The date a document files under — its user filing date, else its upload day. */ +export function documentDateKey(doc: InboundDocumentDto): string { + if (doc.documentDate) return doc.documentDate; + // createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day. + return doc.createdAt.slice(0, 10); +} + +export interface DocumentDateGroup { + /** The YYYY-MM-DD key the group files under. */ + key: string; + documents: InboundDocumentDto[]; +} + +/** + * Group an already-sorted document list into consecutive date buckets, + * preserving the server's sort order. A document's bucket is its `documentDate` + * (fallback: its upload day). The first time a date appears it opens a new + * group; later documents on the same date join it. Order is preserved exactly + * as received, so the server stays the single source of truth for sorting. + */ +export function groupDocumentsByDate( + documents: InboundDocumentDto[], +): DocumentDateGroup[] { + const groups: DocumentDateGroup[] = []; + let current: DocumentDateGroup | null = null; + for (const doc of documents) { + const key = documentDateKey(doc); + if (!current || current.key !== key) { + current = { key, documents: [doc] }; + groups.push(current); + } else { + current.documents.push(doc); + } + } + return groups; +} + +/** + * True when an error is the extract route's 422 "no document-scan provider + * configured" signal. The UI surfaces this as a calm inline note (configure a + * provider) rather than a hard error toast — the stored document is untouched. + */ +export function isProviderUnsupportedError(error: unknown): boolean { + return ( + error instanceof ApiError && + error.meta?.errorCode === "documents.inbound.providerUnsupported" + ); +} + +/** Format a YYYY-MM-DD group key into a locale-aware medium date label. */ +export function formatDateGroupLabel(key: string, locale: string): string { + const date = new Date(`${key}T00:00:00`); + if (Number.isNaN(date.getTime())) return key; + try { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format( + date, + ); + } catch { + return key; + } +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 46289f42e..73774354f 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -1,12 +1,42 @@ /** - * Query keys — inbound clinical documents (v1.25, W-DOCS-IN): the document - * list and a single document's review detail (document + staged facts). - * Part of the centralized factory; aggregated in `./index.ts`. Any inbound - * write (upload / edit / confirm / discard) invalidates the `["documents"]` - * prefix so the list and the open review repaint in lockstep. + * Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable + * document list (search / category filter / sort, keyset-paginated) and a + * single document's detail (document + staged facts). Part of the centralized + * factory; aggregated in `./index.ts`. Any document write (upload / edit / + * extract / confirm / discard) invalidates the `["documents"]` prefix so the + * list and the open detail repaint in lockstep. */ +import type { + DocumentListSort, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; + +/** + * The parameter set that distinguishes one library list view from another. + * Lives in the key so two different filter/sort combinations never share a + * cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a + * bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }` + * resolve to the same key. + */ +export interface DocumentListParams { + q?: string; + kind?: InboundDocumentKindValue; + from?: string; + to?: string; + sort: DocumentListSort; + order: "asc" | "desc"; +} + export const documentKeys = { documents: () => ["documents"] as const, inboundDocuments: () => ["documents", "inbound"] as const, + /** + * The parameterised library list (keyset-paginated via `useInfiniteQuery`). + * The literal `"list"` segment keeps it distinct from a single-document key + * (`inboundDocument(id)`); cuid ids never collide with it. Stays under the + * `["documents", "inbound"]` prefix so prefix invalidation still reaches it. + */ + inboundDocumentList: (params: DocumentListParams) => + ["documents", "inbound", "list", params] as const, inboundDocument: (id: string) => ["documents", "inbound", id] as const, }; From 7214b95982f1846fb73ac1b1bf942235cfefffcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 23:42:17 +0200 Subject: [PATCH 17/27] fix(auth,settings): clear offline caches on login and tighten settings a11y Login only wiped the in-memory query cache; the IndexedDB query snapshot and the service-worker data/page caches from the previous account stayed on disk. Logout and the 401/expiry redirect already drop all three. Run the same offline-cache wipe on a successful login too, before the new user's dashboard prefetch warms, so a fresh sign-in on a shared browser inherits nothing from the prior session. The per-account restore guard still stands; this removes the stale data at the source. Settings shell: move focus to the section heading when the active section changes within the mounted shell, so keyboard and screen-reader users are placed in the new content instead of being left on the nav link they activated. The first mount is skipped to avoid an unexpected focus move on a deep link. Raise the inactive sub-tab text from muted-foreground to foreground/70 so it clears the WCAG AA contrast floor over the tinted sub-tab strip. --- src/app/auth/login/page.tsx | 11 +++++ src/components/settings/settings-shell.tsx | 50 +++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e0b1e170b..a1f3caac4 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -22,6 +22,7 @@ import { import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; export default function LoginPage() { const router = useRouter(); @@ -92,6 +93,16 @@ export default function LoginPage() { // stale `["auth","me"]`, so the shell refetches it fresh on mount and the // former explicit `invalidateQueries({ queryKey: auth() })` is redundant. queryClient.clear(); + // Belt-and-suspenders to match the session-END wipe (logout / 401-expiry): + // also drop the OFFLINE layers — the IndexedDB query snapshot and the SW + // data/page caches — so a previous account's persisted dashboard never + // lingers on disk for the new session. The restore is already per-account + // bound, but a fresh login on a shared browser should leave nothing of the + // prior account behind. Order is clear → prefetch → navigate: the wipe is + // started here (best-effort, like logout) before the prefetch warms THIS + // user's data, and the persister's 1 s debounce gives the single IDB delete + // ample headroom to land before the new snapshot is written. + void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); if (target === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 7211a8f40..e4036657e 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -585,6 +585,39 @@ export function SettingsShell({ } : null); + // v1.25.1 (A11Y-L6) — move focus to the section heading when the active + // section changes WITHIN the mounted shell (a sub-tab tap or a rail click; + // the shell instance is reused across `/settings/[section]` param changes, + // which is also why the mobile-strip effect above keys on `activeSlug`). + // Without this, keyboard / screen-reader users stay focused on the nav link + // they activated and the new content is never announced. The very first + // mount (a deep link or a fresh entry into Settings) is skipped on purpose: + // auto-focusing on initial page load is itself a jarring, unexpected focus + // move. The heading id is shared by two instances (mobile above the strip, + // desktop in the grid) — only one is visible per breakpoint — so we focus + // the visible one. `preventScroll` leaves the global scroll-reset in charge. + const headingId = resolvedHeading?.headingId; + const firstHeadingFocusRef = React.useRef(true); + React.useEffect(() => { + if (firstHeadingFocusRef.current) { + firstHeadingFocusRef.current = false; + return; + } + if (!headingId || typeof document === "undefined") return; + const candidates = document.querySelectorAll( + `[data-settings-heading="${headingId}"]`, + ); + for (const el of candidates) { + // `offsetParent === null` ⇒ the element (or an ancestor) is + // `display:none` — i.e. the hidden-for-this-breakpoint instance. Skip it + // and focus the visible heading. + if (el.offsetParent !== null) { + el.focus({ preventScroll: true }); + break; + } + } + }, [activeSlug, headingId]); + const headingBlock = resolvedHeading ? (
{resolvedHeading.topSlot ?
{resolvedHeading.topSlot}
: null} @@ -592,7 +625,14 @@ export function SettingsShell({

{resolvedHeading.title}

@@ -773,7 +813,13 @@ export function SettingsShell({ "flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors", isActive ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground", + : // v1.25.1 (A11Y-L3) — inactive tab text over the + // `bg-muted/50` strip. `text-muted-foreground` is tuned + // for ~AA on the page background; over the tinted strip + // the effective ratio drops below 4.5:1 in light mode. + // `text-foreground/70` keeps the de-emphasised look but + // clears the WCAG AA floor on the strip. + "text-foreground/70 hover:text-foreground", )} > {t(`settings.sections.${slug}.title`)} From 884323d7878a2ee5a039c9777cafd3c12ff5bace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 23:47:23 +0200 Subject: [PATCH 18/27] fix(documents): accessibility and review-flow polish for the library UI Tighten the documents library review surface after a QA pass: - Surface the provider-unsupported and already-confirmed notes as polite live regions (role=status / aria-live) so keyboard and screen-reader users hear them when the suppressed-toast paths fire. - Render a document's detail panel directly after the row that opened it, and tie the row's aria-controls to the panel id, fixing reading and tab order. - Gate Discard behind a confirm dialog and toast on failure. - Disable Upload until a file is chosen, with an inline hint. - Namespace per-fact editor field ids so simultaneously-open editors no longer share DOM ids (label-for associations stay correct). - Hide Extract once any fact is approved, mirroring the server guard, and treat the already-confirmed response as a calm inline note. - Add a document-date range filter, a kind fallback for the detail title, and a screen-reader hint on the new-tab original link. - Calmer, neutral needs-review badge and more legible row badges. Adds the new strings across all six locales. --- messages/de.json | 20 +- messages/en.json | 20 +- messages/es.json | 20 +- messages/fr.json | 20 +- messages/it.json | 20 +- messages/pl.json | 20 +- .../__tests__/inbound-documents-view.test.tsx | 103 +++++++- .../documents/__tests__/library-utils.test.ts | 22 ++ .../documents/inbound-documents-view.tsx | 241 ++++++++++++++---- src/components/documents/library-utils.ts | 13 + 10 files changed, 417 insertions(+), 82 deletions(-) diff --git a/messages/de.json b/messages/de.json index ccbf7c61c..ab9cdbf3e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7841,7 +7841,8 @@ "fileLabel": "Datei", "titleLabel": "Titel (optional)", "titlePlaceholder": "z. B. Kardiologie-Bericht", - "dateLabel": "Dokumentdatum (optional)" + "dateLabel": "Dokumentdatum (optional)", + "fileRequired": "Wähle eine Datei zum Hochladen." }, "kind": { "DOCTOR_REPORT": "Arztbericht", @@ -7905,7 +7906,8 @@ "extracted": "Fakten extrahiert. Prüfe sie unten.", "extractFailed": "Fakten konnten nicht extrahiert werden.", "updated": "Dokument aktualisiert.", - "updateFailed": "Dokument konnte nicht aktualisiert werden." + "updateFailed": "Dokument konnte nicht aktualisiert werden.", + "discardFailed": "Dokument konnte nicht verworfen werden." }, "toolbar": { "searchLabel": "Suche", @@ -7919,7 +7921,9 @@ "title": "Titel" }, "orderAsc": "Aufsteigend", - "orderDesc": "Absteigend" + "orderDesc": "Absteigend", + "fromLabel": "Von", + "toLabel": "Bis" }, "detail": { "viewOriginal": "Original ansehen", @@ -7927,7 +7931,12 @@ "extract": "Fakten extrahieren", "extracting": "Wird extrahiert…", "discard": "Verwerfen", - "notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, um dieses Dokument zu lesen." + "notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, um dieses Dokument zu lesen.", + "viewOriginalNewTab": "(öffnet in einem neuen Tab)", + "discardConfirmTitle": "Dieses Dokument verwerfen?", + "discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Fakten dauerhaft. Das lässt sich nicht rückgängig machen.", + "discardConfirmAction": "Verwerfen", + "discardCancel": "Abbrechen" }, "edit": { "title": "Dokument bearbeiten", @@ -7940,7 +7949,8 @@ "cancel": "Abbrechen" }, "note": { - "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren." + "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren.", + "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index deb367851..5370375de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7841,7 +7841,8 @@ "fileLabel": "File", "titleLabel": "Title (optional)", "titlePlaceholder": "e.g. Cardiology report", - "dateLabel": "Document date (optional)" + "dateLabel": "Document date (optional)", + "fileRequired": "Choose a file to upload." }, "kind": { "DOCTOR_REPORT": "Doctor report", @@ -7905,7 +7906,8 @@ "extracted": "Facts extracted. Review them below.", "extractFailed": "Couldn't extract facts.", "updated": "Document updated.", - "updateFailed": "Couldn't update the document." + "updateFailed": "Couldn't update the document.", + "discardFailed": "Couldn't discard the document." }, "toolbar": { "searchLabel": "Search", @@ -7919,7 +7921,9 @@ "title": "Title" }, "orderAsc": "Ascending", - "orderDesc": "Descending" + "orderDesc": "Descending", + "fromLabel": "From", + "toLabel": "To" }, "detail": { "viewOriginal": "View original", @@ -7927,7 +7931,12 @@ "extract": "Extract facts", "extracting": "Extracting…", "discard": "Discard", - "notExtracted": "No facts extracted yet. Use Extract facts to read this document." + "notExtracted": "No facts extracted yet. Use Extract facts to read this document.", + "viewOriginalNewTab": "(opens in a new tab)", + "discardConfirmTitle": "Discard this document?", + "discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.", + "discardConfirmAction": "Discard", + "discardCancel": "Cancel" }, "edit": { "title": "Edit document", @@ -7940,7 +7949,8 @@ "cancel": "Cancel" }, "note": { - "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically." + "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.", + "alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index da3648da8..ba7cdc3f3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7841,7 +7841,8 @@ "fileLabel": "Archivo", "titleLabel": "Título (opcional)", "titlePlaceholder": "p. ej. Informe de cardiología", - "dateLabel": "Fecha del documento (opcional)" + "dateLabel": "Fecha del documento (opcional)", + "fileRequired": "Elige un archivo para subir." }, "kind": { "DOCTOR_REPORT": "Informe médico", @@ -7905,7 +7906,8 @@ "extracted": "Datos extraídos. Revísalos abajo.", "extractFailed": "No se pudieron extraer los datos.", "updated": "Documento actualizado.", - "updateFailed": "No se pudo actualizar el documento." + "updateFailed": "No se pudo actualizar el documento.", + "discardFailed": "No se pudo descartar el documento." }, "toolbar": { "searchLabel": "Buscar", @@ -7919,7 +7921,9 @@ "title": "Título" }, "orderAsc": "Ascendente", - "orderDesc": "Descendente" + "orderDesc": "Descendente", + "fromLabel": "Desde", + "toLabel": "Hasta" }, "detail": { "viewOriginal": "Ver original", @@ -7927,7 +7931,12 @@ "extract": "Extraer datos", "extracting": "Extrayendo…", "discard": "Descartar", - "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento." + "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.", + "viewOriginalNewTab": "(se abre en una pestaña nueva)", + "discardConfirmTitle": "¿Descartar este documento?", + "discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.", + "discardConfirmAction": "Descartar", + "discardCancel": "Cancelar" }, "edit": { "title": "Editar documento", @@ -7940,7 +7949,8 @@ "cancel": "Cancelar" }, "note": { - "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente." + "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.", + "alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 9950fc359..028940112 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7841,7 +7841,8 @@ "fileLabel": "Fichier", "titleLabel": "Titre (facultatif)", "titlePlaceholder": "p. ex. Compte rendu de cardiologie", - "dateLabel": "Date du document (facultatif)" + "dateLabel": "Date du document (facultatif)", + "fileRequired": "Choisissez un fichier à téléverser." }, "kind": { "DOCTOR_REPORT": "Compte rendu médical", @@ -7905,7 +7906,8 @@ "extracted": "Éléments extraits. Vérifiez-les ci-dessous.", "extractFailed": "Impossible d'extraire les éléments.", "updated": "Document mis à jour.", - "updateFailed": "Impossible de mettre à jour le document." + "updateFailed": "Impossible de mettre à jour le document.", + "discardFailed": "Impossible de supprimer le document." }, "toolbar": { "searchLabel": "Rechercher", @@ -7919,7 +7921,9 @@ "title": "Titre" }, "orderAsc": "Croissant", - "orderDesc": "Décroissant" + "orderDesc": "Décroissant", + "fromLabel": "Du", + "toLabel": "Au" }, "detail": { "viewOriginal": "Voir l'original", @@ -7927,7 +7931,12 @@ "extract": "Extraire les éléments", "extracting": "Extraction…", "discard": "Écarter", - "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document." + "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.", + "viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)", + "discardConfirmTitle": "Supprimer ce document ?", + "discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.", + "discardConfirmAction": "Supprimer", + "discardCancel": "Annuler" }, "edit": { "title": "Modifier le document", @@ -7940,7 +7949,8 @@ "cancel": "Annuler" }, "note": { - "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement." + "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.", + "alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index b614b73d9..a8831d3a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7841,7 +7841,8 @@ "fileLabel": "File", "titleLabel": "Titolo (facoltativo)", "titlePlaceholder": "es. Referto di cardiologia", - "dateLabel": "Data del documento (facoltativa)" + "dateLabel": "Data del documento (facoltativa)", + "fileRequired": "Scegli un file da caricare." }, "kind": { "DOCTOR_REPORT": "Referto medico", @@ -7905,7 +7906,8 @@ "extracted": "Dati estratti. Rivedili qui sotto.", "extractFailed": "Impossibile estrarre i dati.", "updated": "Documento aggiornato.", - "updateFailed": "Impossibile aggiornare il documento." + "updateFailed": "Impossibile aggiornare il documento.", + "discardFailed": "Impossibile eliminare il documento." }, "toolbar": { "searchLabel": "Cerca", @@ -7919,7 +7921,9 @@ "title": "Titolo" }, "orderAsc": "Crescente", - "orderDesc": "Decrescente" + "orderDesc": "Decrescente", + "fromLabel": "Dal", + "toLabel": "Al" }, "detail": { "viewOriginal": "Vedi originale", @@ -7927,7 +7931,12 @@ "extract": "Estrai dati", "extracting": "Estrazione…", "discard": "Scarta", - "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento." + "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.", + "viewOriginalNewTab": "(si apre in una nuova scheda)", + "discardConfirmTitle": "Eliminare questo documento?", + "discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.", + "discardConfirmAction": "Elimina", + "discardCancel": "Annulla" }, "edit": { "title": "Modifica documento", @@ -7940,7 +7949,8 @@ "cancel": "Annulla" }, "note": { - "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente." + "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.", + "alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 3a0864934..64065d200 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7841,7 +7841,8 @@ "fileLabel": "Plik", "titleLabel": "Tytuł (opcjonalnie)", "titlePlaceholder": "np. Raport kardiologiczny", - "dateLabel": "Data dokumentu (opcjonalnie)" + "dateLabel": "Data dokumentu (opcjonalnie)", + "fileRequired": "Wybierz plik do przesłania." }, "kind": { "DOCTOR_REPORT": "Raport lekarski", @@ -7905,7 +7906,8 @@ "extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.", "extractFailed": "Nie udało się wyodrębnić faktów.", "updated": "Dokument zaktualizowany.", - "updateFailed": "Nie udało się zaktualizować dokumentu." + "updateFailed": "Nie udało się zaktualizować dokumentu.", + "discardFailed": "Nie udało się odrzucić dokumentu." }, "toolbar": { "searchLabel": "Szukaj", @@ -7919,7 +7921,9 @@ "title": "Tytuł" }, "orderAsc": "Rosnąco", - "orderDesc": "Malejąco" + "orderDesc": "Malejąco", + "fromLabel": "Od", + "toLabel": "Do" }, "detail": { "viewOriginal": "Zobacz oryginał", @@ -7927,7 +7931,12 @@ "extract": "Wyodrębnij fakty", "extracting": "Wyodrębnianie…", "discard": "Odrzuć", - "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument." + "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.", + "viewOriginalNewTab": "(otwiera się w nowej karcie)", + "discardConfirmTitle": "Odrzucić ten dokument?", + "discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.", + "discardConfirmAction": "Odrzuć", + "discardCancel": "Anuluj" }, "edit": { "title": "Edytuj dokument", @@ -7940,7 +7949,8 @@ "cancel": "Anuluj" }, "note": { - "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty." + "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.", + "alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić." } }, "selfDescription": { diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx index f414314c9..0d8142429 100644 --- a/src/components/documents/__tests__/inbound-documents-view.test.tsx +++ b/src/components/documents/__tests__/inbound-documents-view.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }); import { + AlreadyConfirmedNote, DocumentDetail, DocumentMetaEditForm, InboundDocumentsView, @@ -125,6 +126,42 @@ describe(" library", () => { expect(html).toContain("2 to review"); // nextCursor → load-more button. expect(html).toContain("Load more"); + // A11Y-M2: the row's aria-controls points at the detail region id. + expect(html).toContain('aria-controls="documents-detail-d1"'); + }); + + it("disables the upload button and shows a hint until a file is chosen", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + // A11Y-M4: with no file staged, an inline hint explains why upload is + // disabled (the hint only renders on the !hasFile / not-pending path). + expect(html).toContain("Choose a file to upload."); + }); + + it("renders the document-date range filter inputs", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + // PERF-L: from/to date inputs are wired into the toolbar. + expect(html).toContain('id="doc-from"'); + expect(html).toContain('id="doc-to"'); }); it("shows the empty state when there are no documents", () => { @@ -208,6 +245,58 @@ describe("", () => { ); expect(html).not.toContain('data-slot="documents-extract"'); }); + + it("hides the extract button once any fact is approved (PERF-H1 gate)", () => { + const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }), + ); + }, + ); + expect(html).not.toContain('data-slot="documents-extract"'); + }); + + it("threads the region id onto the detail card for aria-controls", () => { + const html = render( + {}} + />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + expect(html).toContain('id="documents-detail-d1"'); + }); + + it("falls back to the kind label when title and filename are absent", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ title: null, filename: null, kind: "IMAGING" }), + ); + }, + ); + expect(html).toContain("Imaging"); + }); + + it("offers a confirm step before discarding", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + // A11Y-M3: discard is gated behind an AlertDialog trigger, not one click. + expect(html).toContain('data-slot="documents-discard-trigger"'); + }); }); describe("", () => { @@ -227,9 +316,21 @@ describe("", () => { }); describe("", () => { - it("renders the calm inline note, not a hard error", () => { + it("renders the calm inline note as a polite live region (A11Y-H1)", () => { const html = render(); expect(html).toContain('data-slot="documents-provider-note"'); expect(html).toContain("Configure a document-scan provider"); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + }); +}); + +describe("", () => { + it("renders a calm polite live region when extraction is refused", () => { + const html = render(); + expect(html).toContain('data-slot="documents-already-confirmed-note"'); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain("already been reviewed"); }); }); diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts index 249280a35..5f7892347 100644 --- a/src/components/documents/__tests__/library-utils.test.ts +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -6,6 +6,7 @@ import { buildDocumentListSearch, documentDateKey, groupDocumentsByDate, + isAlreadyConfirmedError, isProviderUnsupportedError, } from "../library-utils"; @@ -123,3 +124,24 @@ describe("isProviderUnsupportedError", () => { expect(isProviderUnsupportedError(null)).toBe(false); }); }); + +describe("isAlreadyConfirmedError", () => { + it("matches the extract 422 already-confirmed signal", () => { + const err = new ApiError("already confirmed", 422, { + errorCode: "documents.inbound.alreadyConfirmed", + }); + expect(isAlreadyConfirmedError(err)).toBe(true); + }); + + it("rejects other API errors and non-errors", () => { + expect( + isAlreadyConfirmedError( + new ApiError("no provider", 422, { + errorCode: "documents.inbound.providerUnsupported", + }), + ), + ).toBe(false); + expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false); + expect(isAlreadyConfirmedError(null)).toBe(false); + }); +}); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index e67388ef7..5afcfb77f 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -18,7 +18,7 @@ * calm inline note (configure one) — never a hard error; the stored document is * untouched. */ -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, @@ -39,6 +39,17 @@ import { Upload, } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -75,6 +86,7 @@ import { buildDocumentListSearch, formatDateGroupLabel, groupDocumentsByDate, + isAlreadyConfirmedError, isProviderUnsupportedError, } from "./library-utils"; @@ -94,6 +106,9 @@ export function InboundDocumentsView() { const [uploadKind, setUploadKind] = useState("DOCTOR_REPORT"); const [uploadDate, setUploadDate] = useState(""); + // Tracks whether a file is staged in the (uncontrolled) file input so the + // upload button can stay disabled until there's something to upload. + const [hasFile, setHasFile] = useState(false); // Library toolbar state. const [q, setQ] = useState(""); @@ -101,6 +116,8 @@ export function InboundDocumentsView() { const [kindFilter, setKindFilter] = useState<"" | InboundDocumentKindValue>( "", ); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); const [sort, setSort] = useState("documentDate"); const [order, setOrder] = useState<"asc" | "desc">("desc"); @@ -115,6 +132,8 @@ export function InboundDocumentsView() { const filters: DocumentListParams = { q: debouncedQ.trim() || undefined, kind: kindFilter || undefined, + from: fromDate || undefined, + to: toDate || undefined, sort, order, }; @@ -132,7 +151,9 @@ export function InboundDocumentsView() { const documents = list.data?.pages.flatMap((p) => p.documents) ?? []; const groups = groupDocumentsByDate(documents); - const isFiltered = Boolean(filters.q || filters.kind); + const isFiltered = Boolean( + filters.q || filters.kind || filters.from || filters.to, + ); const upload = useMutation({ mutationFn: async (file: File) => { @@ -153,6 +174,7 @@ export function InboundDocumentsView() { setSelectedId(doc.id); setUploadTitle(""); setUploadDate(""); + setHasFile(false); if (fileRef.current) fileRef.current.value = ""; }, onError: () => toast.error(t("documents.toast.uploadFailed")), @@ -232,16 +254,17 @@ export function InboundDocumentsView() { type="file" accept="image/jpeg,image/png,image/webp,application/pdf" disabled={upload.isPending} + onChange={(e) => setHasFile(Boolean(e.target.files?.length))} />
-
+
@@ -301,6 +329,26 @@ export function InboundDocumentsView() { ))}
+
+ + setFromDate(e.target.value)} + /> +
+
+ + setToDate(e.target.value)} + /> +
@@ -361,23 +409,30 @@ export function InboundDocumentsView() { > {formatDateGroupLabel(group.key, locale)} - {group.documents.map((doc) => ( - - setSelectedId(selectedId === doc.id ? null : doc.id) - } - /> - ))} - {selectedId && - group.documents.some((d) => d.id === selectedId) ? ( - setSelectedId(null)} - /> - ) : null} + {group.documents.map((doc) => { + const expanded = selectedId === doc.id; + // Shared id ties the row's aria-controls to the detail + // region so screen readers know the row owns the panel; the + // panel renders directly after its row for correct order. + const detailId = `documents-detail-${doc.id}`; + return ( + + setSelectedId(expanded ? null : doc.id)} + /> + {expanded ? ( + setSelectedId(null)} + /> + ) : null} + + ); + })}
))} @@ -409,10 +464,12 @@ export function InboundDocumentsView() { function DocumentRow({ doc, expanded, + regionId, onToggle, }: { doc: InboundDocumentDto; expanded: boolean; + regionId: string; onToggle: () => void; }) { const { t } = useTranslations(); @@ -422,6 +479,7 @@ function DocumentRow({ + + + + + + + + {t("documents.detail.discardConfirmTitle")} + + + {t("documents.detail.discardConfirmBody")} + + + + + {t("documents.detail.discardCancel")} + + discard.mutate()} + > + {t("documents.detail.discardConfirmAction")} + + + +
{showProviderNote ? : null} + {showAlreadyConfirmedNote ? : null} {pending.length > 0 ? ( <> @@ -654,17 +760,42 @@ export function DocumentDetail({ ); } +/** + * A calm inline note. Carries `role="status"` + `aria-live="polite"` so that + * keyboard / screen-reader users hear it when it appears — these notes stand in + * for a toast that the mutation deliberately suppresses. + */ +function InlineNote({ slot, children }: { slot: string; children: string }) { + return ( +
+ +

{children}

+
+ ); +} + /** The calm inline note shown when extraction needs a provider that's absent. */ export function ProviderUnsupportedNote() { const { t } = useTranslations(); return ( -
- -

{t("documents.note.providerUnsupported")}

-
+ + {t("documents.note.providerUnsupported")} + + ); +} + +/** Calm inline note when extraction is refused — the document is confirmed. */ +export function AlreadyConfirmedNote() { + const { t } = useTranslations(); + return ( + + {t("documents.note.alreadyConfirmed")} + ); } @@ -786,11 +917,12 @@ function FactCard({ {t(`documents.factType.${fact.factType}`)} {fact.needsReview ? ( - // Calm warning (amber), not an alarming destructive red — a - // low-confidence fact is a "please check", not an error. + // A calm, neutral "please check" — not an alarming red, and not + // amber (the app is moving away from amber warning tints). A + // low-confidence fact is a gentle nudge to review, not an error. {t("documents.review.needsReview")} @@ -866,6 +998,7 @@ function FactEditForm({ const d = fact.data as ConditionFactData; return ( {fields.map((f) => (
-
))}
-