From 15ddb9aac21adf16409839d3f1993d69b7296d63 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Mon, 7 Sep 2026 12:23:58 +0800 Subject: [PATCH] feat(voice): adapt voice entry to the stacked-jobs prosthesis model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 3 +- backend/prisma/catalog-seed-data.ts | 344 ++++- .../migration.sql | 6 + backend/prisma/schema.prisma | 2 + backend/src/common/fdi.ts | 8 + .../prosthesis-catalog.service.ts | 62 +- .../src/modules/voice/extraction.prompt.ts | 97 +- .../modules/voice/extraction.resolver.spec.ts | 468 +++++-- .../src/modules/voice/extraction.resolver.ts | 356 +++-- .../src/modules/voice/extraction.wire.spec.ts | 73 +- backend/src/modules/voice/extraction.wire.ts | 105 +- .../modules/voice/openrouter.provider.spec.ts | 30 +- backend/src/modules/voice/voice.providers.ts | 17 +- backend/src/modules/voice/voice.service.ts | 29 +- backend/src/modules/voice/voice.types.ts | 48 +- docs/specs/voice-treatment-entry/progress.md | 218 +++- frontend/messages/en.json | 9 +- frontend/messages/fa.json | 9 +- frontend/messages/nl.json | 9 +- frontend/package-lock.json | 1154 ++++++++++++++++- frontend/package.json | 6 +- .../treatment/prosthesisTree.spec.ts | 176 +++ .../treatment/voiceReviewRows.spec.ts | 274 ++++ .../components/treatment/voiceReviewRows.ts | 257 +++- .../ui/treatment/TreatmentWorkspace.tsx | 83 +- .../ui/treatment/VoiceReviewSheet.tsx | 255 +++- frontend/src/lib/voice/audioFormat.ts | 8 +- frontend/src/types/voice.ts | 33 +- frontend/vitest.config.ts | 15 + 29 files changed, 3630 insertions(+), 524 deletions(-) create mode 100644 backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql create mode 100644 frontend/src/components/treatment/prosthesisTree.spec.ts create mode 100644 frontend/src/components/treatment/voiceReviewRows.spec.ts create mode 100644 frontend/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index e686f19..a86a059 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or | `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** | | `npm run build` | Production build (`output: 'standalone'`) | | `npm run lint` | ESLint via Next | +| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`) | `NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`. @@ -102,7 +103,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, ### Tests -Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation (7 suites in `backend/src/**`). There are no frontend tests; `npx tsc --noEmit` is the frontend gate. +Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts` and `voiceReviewRows.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate. ## Deployment diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts index 796ee8e..4286a7e 100644 --- a/backend/prisma/catalog-seed-data.ts +++ b/backend/prisma/catalog-seed-data.ts @@ -71,7 +71,11 @@ export const LAB_WORKFLOW_STEPS = [ ] as const; export type ProsthesisChartRegion = 'crown' | 'root' | 'arch'; -export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch'; +export type ProsthesisStackGroup = + | 'restoration' + | 'implant' + | 'post_core' + | 'arch'; export type ProsthesisTypeSeed = { code: string; @@ -87,7 +91,13 @@ export type ProsthesisTypeSeed = { }; const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const; -const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const; +const INDIRECT_FULL = [ + ...SCAN_DESIGN_MODEL, + 'milling_wet', + 'sinter', + 'stain', + 'glaze', +] as const; const INDIRECT_LAYERED = [ ...SCAN_DESIGN_MODEL, 'milling_wet', @@ -115,9 +125,24 @@ const KEEP_LAYERED = [ 'glaze', 'polish_prep', ] as const; -const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const; -const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const; -const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const; +const DENTURE = [ + 'intraoral_scan', + 'design', + 'printer_resin', + 'polish_prep', +] as const; +const APPLIANCE = [ + 'intraoral_scan', + 'design', + 'printer_resin', + 'polish_prep', +] as const; +const POST_CORE = [ + 'intraoral_scan', + 'design', + 'milling_wet', + 'polish_prep', +] as const; function crown( code: string, @@ -165,7 +190,8 @@ function indirect( subcategory: indication, chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL, + manufacturingSteps: + technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL, }; } @@ -200,10 +226,24 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ 'stain', 'glaze', ]), - crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']), - crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']), + crown('full_metal_crown', 5, [ + ...SCAN_DESIGN_MODEL, + 'milling_wet', + 'polish_prep', + ]), + crown('temporary_resin_crown', 6, [ + 'intraoral_scan', + 'design', + 'milling_wet', + 'polish_prep', + ]), crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']), - crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']), + crown('peek_crown', 8, [ + 'intraoral_scan', + 'design', + 'milling_dry', + 'polish_prep', + ]), crown('press_ceramic', 9, [ 'intraoral_scan', 'design', @@ -243,7 +283,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ 'model_create', 'polish_prep', ]), - implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']), + implant('ti_base_abutment', 41, [ + 'intraoral_scan', + 'model_create', + 'polish_prep', + ]), implant('multi_unit_abutment', 42, [ 'intraoral_scan', 'choosing_abutment', @@ -255,7 +299,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ 'milling_wet', 'polish_prep', ]), - implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']), + implant('zirconia_abutment', 44, [ + ...SCAN_DESIGN_MODEL, + 'milling_dry', + 'sinter', + ]), implant( 'screw_retained', 45, @@ -450,7 +498,11 @@ export { const TREATMENT_LABELS: Record> = { restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' }, - specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' }, + specialized_restoration: { + en: 'Specialized Restoration', + fa: 'ترمیم تخصصی', + nl: 'Gespecialiseerde Restauratie', + }, radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' }, endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' }, surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' }, @@ -460,8 +512,16 @@ const TREATMENT_LABELS: Record> = { perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' }, pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' }, extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' }, - clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' }, - continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' }, + clinic_visit: { + en: 'Clinic Visit', + fa: 'ویزیت درمانگاه', + nl: 'Kliniekbezoek', + }, + continue_treatment: { + en: 'Continue Treatment', + fa: 'ادامه درمان', + nl: 'Behandeling Voortzetten', + }, consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' }, filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' }, visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' }, @@ -471,50 +531,182 @@ const TREATMENT_LABELS: Record> = { export const PROSTHESIS_LABELS: Record> = { pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' }, pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' }, - monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' }, - glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' }, - full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' }, - temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' }, + monolithic_zirconia: { + en: 'Monolithic Zirconia', + fa: 'زیرکونیا مونولیتیک', + nl: 'Monolithisch Zirconia', + }, + glass_ceramic_crown: { + en: 'Glass Ceramic Crown', + fa: 'روکش سرامیک شیشه‌ای', + nl: 'Glaskeramische Kroon', + }, + full_metal_crown: { + en: 'Full Metal Crown', + fa: 'روکش تمام فلز', + nl: 'Volledige Metalen Kroon', + }, + temporary_resin_crown: { + en: 'Temporary Resin Crown', + fa: 'روکش موقت رزینی', + nl: 'Tijdelijke Harskroon', + }, pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' }, peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' }, - press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' }, - veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' }, - veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' }, - inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' }, - inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' }, - onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' }, - onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' }, - overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' }, - overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' }, - cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' }, - fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' }, - customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' }, - prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' }, - ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' }, - multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' }, - zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' }, + press_ceramic: { + en: 'Pressed Ceramic / IPS e.max', + fa: 'سرامیک پرس / IPS e.max', + nl: 'Perskeramiek / IPS e.max', + }, + veneer_full_contour: { + en: 'Veneer · Full contour', + fa: 'ونیر · تمام‌کانتور', + nl: 'Veneer · Full contour', + }, + veneer_layered: { + en: 'Veneer · Layered ceramic', + fa: 'ونیر · سرامیک لایه‌ای', + nl: 'Veneer · Gelaagd keramiek', + }, + inlay_full_contour: { + en: 'Inlay · Full contour', + fa: 'اینلی · تمام‌کانتور', + nl: 'Inlay · Full contour', + }, + inlay_layered: { + en: 'Inlay · Layered ceramic', + fa: 'اینلی · سرامیک لایه‌ای', + nl: 'Inlay · Gelaagd keramiek', + }, + onlay_full_contour: { + en: 'Onlay · Full contour', + fa: 'آنلی · تمام‌کانتور', + nl: 'Onlay · Full contour', + }, + onlay_layered: { + en: 'Onlay · Layered ceramic', + fa: 'آنلی · سرامیک لایه‌ای', + nl: 'Onlay · Gelaagd keramiek', + }, + overlay_full_contour: { + en: 'Overlay · Full contour', + fa: 'اورلی · تمام‌کانتور', + nl: 'Overlay · Full contour', + }, + overlay_layered: { + en: 'Overlay · Layered ceramic', + fa: 'اورلی · سرامیک لایه‌ای', + nl: 'Overlay · Gelaagd keramiek', + }, + cast_post_core: { + en: 'Cast Post & Core', + fa: 'پست و کور ریختگی', + nl: 'Gegoten Stiftopbouw', + }, + fiber_post_core: { + en: 'Fiber Post & Core', + fa: 'پست و کور فایبر', + nl: 'Fiber Stiftopbouw', + }, + customized_abutment: { + en: 'Custom Abutment (Titanium)', + fa: 'اباتمنت سفارشی (تیتانیوم)', + nl: 'Aangepast abutment (titanium)', + }, + prefabricated_abutment: { + en: 'Prefabricated Abutment', + fa: 'اباتمنت آماده', + nl: 'Prefab Abutment', + }, + ti_base_abutment: { + en: 'Ti Base Abutment', + fa: 'اباتمنت پایه تیتانیوم', + nl: 'Ti Basis Abutment', + }, + multi_unit_abutment: { + en: 'Multi Unit Abutment', + fa: 'اباتمنت مولتی یونیت', + nl: 'Multi Unit Abutment', + }, + zirconia_abutment: { + en: 'Custom Abutment (Zirconia)', + fa: 'اباتمنت سفارشی (زیرکونیا)', + nl: 'Aangepast abutment (zirconia)', + }, screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' }, - complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' }, - partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' }, + complete_denture: { + en: 'Complete Denture', + fa: 'دنچر کامل', + nl: 'Volledige prothese', + }, + partial_denture: { + en: 'Partial Denture', + fa: 'دنچر پارسیل', + nl: 'Partiële prothese', + }, overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' }, - night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' }, - night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' }, - night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' }, + night_guard_soft: { + en: 'Night Guard · Soft', + fa: 'نایت گارد · نرم', + nl: 'Nachtbeugel · Zacht', + }, + night_guard_hard: { + en: 'Night Guard · Hard', + fa: 'نایت گارد · سخت', + nl: 'Nachtbeugel · Hard', + }, + night_guard_dual: { + en: 'Night Guard · Dual laminate', + fa: 'نایت گارد · دو لایه', + nl: 'Nachtbeugel · Dual laminate', + }, bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' }, - clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' }, - soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' }, - surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' }, + clear_aligner: { + en: 'Clear Aligner', + fa: 'الاینر شفاف', + nl: 'Clear aligner', + }, + soft_structure: { + en: 'Soft Structure', + fa: 'ساختار نرم', + nl: 'Zachte Structuur', + }, + surgical_guide: { + en: 'Surgical Guide', + fa: 'گاید جراحی', + nl: 'Chirurgische mal', + }, smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' }, mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' }, - veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' }, - veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' }, - veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' }, - zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' }, + veneer_zirconia: { + en: 'Veneer Zirconia', + fa: 'ونیر زیرکونیا', + nl: 'Veneer Zirconia', + }, + veneer_ips_press: { + en: 'Veneer IPS Press', + fa: 'ونیر IPS پرس', + nl: 'Veneer IPS Press', + }, + veneer_ips_cad: { + en: 'Veneer IPS CAD', + fa: 'ونیر IPS CAD', + nl: 'Veneer IPS CAD', + }, + zirconia_overlay: { + en: 'Zirconia Overlay', + fa: 'اورلی زیرکونیا', + nl: 'Zirconia Overlay', + }, ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' }, }; export const WORKFLOW_STEP_LABELS: Record> = { - intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' }, + intraoral_scan: { + en: 'Intraoral Scan', + fa: 'اسکن داخل دهان', + nl: 'Intraorale Scan', + }, choosing_abutment: { en: 'Choosing Abutment', fa: 'انتخاب اباتمنت', @@ -530,7 +722,11 @@ export const WORKFLOW_STEP_LABELS: Record> = { build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' }, stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' }, glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' }, - polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آماده‌سازی', nl: 'Polijsten/Voorbereiding' }, + polish_prep: { + en: 'Polish/Prep', + fa: 'پولیش/آماده‌سازی', + nl: 'Polijsten/Voorbereiding', + }, packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' }, shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' }, pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' }, @@ -549,8 +745,58 @@ function labelsToTranslations( return out; } +/** + * The 7 prosthesis categories, worded from `frontend/messages/*.json`'s `category_*` keys + * (decision 47) so the extraction prompt and the manual picker read identically on day one. + * The frontend keeps its own message keys — migrating it to read these is out of scope + * (decision 48) — this seed is written *from* those keys precisely so the two agree at the + * point they diverge. + */ +export const PROSTHESIS_CATEGORY_LABELS: Record< + string, + Record +> = { + crown: { en: 'Crowns', fa: 'روکش‌ها', nl: 'Kronen' }, + indirect: { + en: 'Veneer/Inlay/Onlay/Overlay', + fa: 'ونیر/اینلی/آنلی/اورلی', + nl: 'Veneer/Inlay/Onlay/Overlay', + }, + implant: { en: 'Implants', fa: 'ایمپلنت', nl: 'Implantaten' }, + post_core: { en: 'Post & core', fa: 'پست و کور', nl: 'Stiftopbouw' }, + removable: { en: 'Removable', fa: 'متحرک', nl: 'Uitneembaar' }, + appliance: { en: 'Appliances', fa: 'اپلاینس‌ها', nl: 'Apparatuur' }, + digital: { en: 'Digital', fa: 'دیجیتال', nl: 'Digitaal' }, +}; + +/** + * Only the 5 real subcategories — the `sub_*` message key set is wider (it also carries + * technique names and two leaf codes); those are not subcategory catalog entities. + */ +export const PROSTHESIS_SUBCATEGORY_LABELS: Record< + string, + Record +> = { + veneer: { en: 'Veneer', fa: 'ونیر', nl: 'Veneer' }, + inlay: { en: 'Inlay', fa: 'اینلی', nl: 'Inlay' }, + onlay: { en: 'Onlay', fa: 'آنلی', nl: 'Onlay' }, + overlay: { en: 'Overlay', fa: 'اورلی', nl: 'Overlay' }, + night_guard: { en: 'Night guard', fa: 'نایت گارد', nl: 'Nachtbeugel' }, +}; + export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [ ...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS), ...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS), - ...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS), + ...labelsToTranslations( + CatalogEntityKind.LAB_WORKFLOW_STEP, + WORKFLOW_STEP_LABELS, + ), + ...labelsToTranslations( + CatalogEntityKind.PROSTHESIS_CATEGORY, + PROSTHESIS_CATEGORY_LABELS, + ), + ...labelsToTranslations( + CatalogEntityKind.PROSTHESIS_SUBCATEGORY, + PROSTHESIS_SUBCATEGORY_LABELS, + ), ]; diff --git a/backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql b/backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql new file mode 100644 index 0000000..0b111a5 --- /dev/null +++ b/backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +-- Category and subcategory labels have no backend source today (spec §5, decision 47): +-- CatalogEntityKind covered only TREATMENT_TYPE / PROSTHESIS_TYPE / LAB_WORKFLOW_STEP, so +-- CatalogLabelService could not resolve a category at all. Additive only, no data loss. +ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_CATEGORY'; +ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_SUBCATEGORY'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f80558f..51b4685 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -332,6 +332,8 @@ enum CatalogEntityKind { TREATMENT_TYPE PROSTHESIS_TYPE LAB_WORKFLOW_STEP + PROSTHESIS_CATEGORY + PROSTHESIS_SUBCATEGORY } model CatalogTranslation { diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts index ae37fbe..990a1b1 100644 --- a/backend/src/common/fdi.ts +++ b/backend/src/common/fdi.ts @@ -62,6 +62,14 @@ export const FDI_TOOTH_IDS: ReadonlySet = new Set([ ...FDI_LOWER_ARCH_ORDER, ]); +/** + * Not FDI codes — jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors + * `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` + * so the voice contract speaks the same sentinel the manual chart already writes. + */ +export const ARCH_TOOTH_UPPER = 'UA'; +export const ARCH_TOOTH_LOWER = 'LA'; + export function isFdiTooth(value: unknown): value is string { return typeof value === 'string' && FDI_TOOTH_IDS.has(value); } diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts index be8ecc2..8bd70d1 100644 --- a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -69,7 +69,9 @@ export class ProsthesisCatalogService implements OnModuleInit { this.loaded = true; } - async list(localeInput?: string | null): Promise { + async list( + localeInput?: string | null, + ): Promise { await this.refresh(); const locale = normalizeCatalogLocale(localeInput); const codes = [...this.byCode.keys()]; @@ -92,17 +94,64 @@ export class ProsthesisCatalogService implements OnModuleInit { stackGroup: row.stackGroup, }; }) - .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); + .sort( + (a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code), + ); + } + + /** The 7 distinct category codes present in the active catalog, with labels in `locale`. */ + async listCategories( + localeInput?: string | null, + ): Promise<{ code: string; label: string }[]> { + return this.listDistinct( + (row) => row.category, + CatalogEntityKind.PROSTHESIS_CATEGORY, + localeInput, + ); + } + + /** The 5 distinct subcategory codes (veneer, inlay, onlay, overlay, night_guard, …). */ + async listSubcategories( + localeInput?: string | null, + ): Promise<{ code: string; label: string }[]> { + return this.listDistinct( + (row) => row.subcategory, + CatalogEntityKind.PROSTHESIS_SUBCATEGORY, + localeInput, + ); + } + + private async listDistinct( + pick: (row: CatalogRow) => string, + entityKind: CatalogEntityKind, + localeInput?: string | null, + ): Promise<{ code: string; label: string }[]> { + await this.refresh(); + const locale = normalizeCatalogLocale(localeInput); + const codes = [ + ...new Set([...this.byCode.values()].map(pick).filter(Boolean)), + ].sort(); + const labels = await this.catalogLabels.resolveLabels( + entityKind, + codes, + locale, + ); + return codes.map((code) => ({ code, label: labels.get(code) ?? code })); } assertKnownProsthesisType(code: string): void { this.ensureLoaded(); if (!this.byCode.has(code)) { - throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST); + throw new AppException( + ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, + HttpStatus.BAD_REQUEST, + ); } } - async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise { + async getStepCodesForProsthesisType( + prosthesisTypeCode: string, + ): Promise { const type = await this.prisma.prosthesisType.findUnique({ where: { code: prosthesisTypeCode }, select: { @@ -120,7 +169,10 @@ export class ProsthesisCatalogService implements OnModuleInit { return type.steps.map((s) => s.labWorkflowStep.code); } - async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise> { + async resolveStepLabels( + stepCodes: string[], + locale: CatalogLocale, + ): Promise> { return this.catalogLabels.resolveLabels( CatalogEntityKind.LAB_WORKFLOW_STEP, stepCodes, diff --git a/backend/src/modules/voice/extraction.prompt.ts b/backend/src/modules/voice/extraction.prompt.ts index 0df6c22..cb15942 100644 --- a/backend/src/modules/voice/extraction.prompt.ts +++ b/backend/src/modules/voice/extraction.prompt.ts @@ -8,14 +8,18 @@ const LOCALE_NOTES: Record = { '("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy', 'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:', '"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.', + 'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just', + '"بالا"/"پایین" in context, or "هر دو فک" (both jaws).', ].join(' '), nl: [ 'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are', - 'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.', + 'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is', + '"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).', ].join(' '), en: [ 'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are', - 'all tooth 26. The descriptive form is "upper right six".', + 'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower', + 'jaw", or "both jaws".', ].join(' '), }; @@ -24,6 +28,63 @@ function codeList(entries: { code: string; label: string }[]): string { return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n'); } +/** "per-tooth" | "jaw" | "per-tooth or jaw" — derived from the leaves' own chartRegion, never hardcoded. */ +function regionNote(regions: ReadonlySet): string { + const isJaw = regions.has('arch'); + const isTooth = regions.has('crown') || regions.has('root'); + if (isJaw && isTooth) + return 'per-tooth or jaw, depending on the specific code'; + return isJaw ? 'jaw' : 'per-tooth'; +} + +/** + * Renders the prosthesis catalog as the tree it is: category -> (subcategory ->) leaves. + * Nothing here is hardcoded — every code, label and region annotation comes from the data + * `buildCatalog` fetched, so a catalog change needs no prompt change. + */ +function prosthesisTree(catalog: ExtractionCatalog): string { + const byCategory = new Map(); + for (const leaf of catalog.prosthesisTypes) { + const list = byCategory.get(leaf.category) ?? []; + list.push(leaf); + byCategory.set(leaf.category, list); + } + + const lines: string[] = []; + for (const category of catalog.prosthesisCategories) { + const leaves = byCategory.get(category.code) ?? []; + const regions = new Set(leaves.map((l) => l.chartRegion)); + lines.push( + `CATEGORY ${category.code} = ${category.label} (${regionNote(regions)})`, + ); + + const bySubcategory = new Map(); + const bare: typeof leaves = []; + for (const leaf of leaves) { + if (leaf.subcategory) { + const list = bySubcategory.get(leaf.subcategory) ?? []; + list.push(leaf); + bySubcategory.set(leaf.subcategory, list); + } else { + bare.push(leaf); + } + } + + for (const leaf of bare) { + lines.push(` - ${leaf.code} = ${leaf.label}`); + } + for (const sub of catalog.prosthesisSubcategories) { + const subLeaves = bySubcategory.get(sub.code); + if (!subLeaves || subLeaves.length === 0) continue; + lines.push(` SUBCATEGORY ${sub.code} = ${sub.label}`); + for (const leaf of subLeaves) { + lines.push(` - ${leaf.code} = ${leaf.label}`); + } + } + } + return lines.join('\n'); +} + export function buildExtractionPrompt( transcript: string, catalog: ExtractionCatalog, @@ -36,15 +97,15 @@ export function buildExtractionPrompt( 'You are a parser, not an assistant: report only what was said.', '', 'HARD RULES', - '1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type', - ' must be codes from the lists below. labId must be an id from the lab list. If what you', - ' heard is not in a list, use null.', + '1. Never invent a code. treatmentType and prosthesis[].types[] must be codes from the', + ' lists below. labId must be an id from the lab list. If what you heard is not in a', + ' list, use null (or omit it from prosthesis[].types[]).', '2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never', " flip to the viewer's point of view.", '3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.', ' If no deadline was mentioned, use due.kind = "none".', - '4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see', - ' what was heard.', + '4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into', + ' "spoken", so the clinician can see what was heard.', '5. If you are unsure about a value, use null. A missing field is recoverable; a wrong', ' one is not.', '', @@ -71,8 +132,24 @@ export function buildExtractionPrompt( 'TREATMENT TYPE CODES', codeList(catalog.treatmentTypes), '', - 'PROSTHESIS TYPE CODES', - codeList(catalog.prosthesisTypes), + 'PROSTHESIS WORK — prosthesis[]', + 'One entry per spoken instruction: "these targets get these jobs". Each entry is', + '{ targets, types, spoken }:', + '- targets: the teeth OR jaw(s) this instruction is for. A jaw target is a tooth object', + ' with "arch" set ("upper", "lower", or "both" for both jaws) and "position" left null —', + ' never invent a tooth number for a jaw-level appliance. A tooth target is the normal', + ' fdi / arch+side+position shape above.', + '- types: one or more codes from the tree below, applied to every target in this entry.', + ' More than one code means a STACK on the same tooth — e.g. an abutment plus a crown on', + ' one implant site: types: ["zirconia_abutment", "monolithic_zirconia"].', + ' If only the general term was said ("روکش", "veneer") and not a specific material, use', + ' the CATEGORY or SUBCATEGORY code instead of guessing a leaf.', + '- A tooth named with no job at all still belongs in the top-level "teeth" list, not here.', + '- If several teeth share one job, list them all as targets in one entry rather than', + ' repeating the entry — "12 and 13, PFM crown on both" -> one entry, two targets.', + '', + 'PROSTHESIS TYPE CODES (tree — CATEGORY and SUBCATEGORY are marked; the rest are leaves)', + prosthesisTree(catalog), '', 'LABS THIS CLINIC CAN SEND TO', catalog.labs.length > 0 @@ -82,7 +159,7 @@ export function buildExtractionPrompt( 'OTHER FIELDS', '- connectedSpans: only for bridges or splinted units. Endpoints inclusive.', '- comment: clinical notes, in the language spoken. Omit the parts already captured as', - ' treatment type, teeth or deadline.', + ' treatment type, teeth, prosthesis work or deadline.', '- labMatchExact: true only when the spoken name matched a lab name exactly.', ].join('\n'); diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts index b86f577..a39690f 100644 --- a/backend/src/modules/voice/extraction.resolver.spec.ts +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -1,10 +1,22 @@ +import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi'; +import { + PROSTHESIS_TYPES, + PROSTHESIS_CATEGORY_LABELS, + PROSTHESIS_SUBCATEGORY_LABELS, +} from '../../../prisma/catalog-seed-data'; import { resolveConnectedSpans, - resolveProsthesis, + resolveProsthesisAssignment, resolveVoiceIntent, + type ProsthesisLeaf, type ResolveContext, } from './extraction.resolver'; -import type { ToothIntent, VoiceIntent } from './voice.types'; +import type { + ProsthesisAssignment, + ToothIntent, + UnresolvedItem, + VoiceIntent, +} from './voice.types'; const tooth = (fdi: string, spoken = fdi): ToothIntent => ({ kind: 'explicit', @@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({ spoken, }); +const positional = ( + overrides: Partial> = {}, +): ToothIntent => ({ + kind: 'positional', + arch: undefined as never, + side: undefined as never, + position: Number.NaN, + spoken: '', + ...overrides, +}); + +const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [ + { + code: 'pfm_crown', + category: 'crown', + subcategory: '', + chartRegion: 'crown', + }, + { + code: 'monolithic_zirconia', + category: 'crown', + subcategory: '', + chartRegion: 'crown', + }, + { + code: 'zirconia_abutment', + category: 'implant', + subcategory: '', + chartRegion: 'root', + }, + { + code: 'screw_retained', + category: 'implant', + subcategory: '', + chartRegion: 'crown', + }, + { + code: 'night_guard_soft', + category: 'appliance', + subcategory: 'night_guard', + chartRegion: 'arch', + }, + { + code: 'complete_denture', + category: 'removable', + subcategory: '', + chartRegion: 'arch', + }, + { + code: 'partial_denture', + category: 'removable', + subcategory: '', + chartRegion: 'crown', + }, +]; + const CTX: ResolveContext = { todayIso: '2025-10-11', weekStartJs: 6, // Saturday — the fa week treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']), - prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']), + prosthesisLeaves: PROSTHESIS_LEAVES, + prosthesisCategoryCodes: new Set([ + 'crown', + 'implant', + 'removable', + 'appliance', + ]), + prosthesisSubcategoryCodes: new Set(['night_guard']), linkedLabIds: new Set(['lab-sina', 'lab-mehr']), }; @@ -111,122 +186,256 @@ describe('resolveConnectedSpans', () => { }); }); -describe('resolveProsthesis', () => { - const allowed = CTX.prosthesisTypeCodes; - - it('expands the default across every tooth', () => { - const result = resolveProsthesis( - { defaultType: 'monolithic_zirconia', overrides: [] }, - ['14', '15'], - allowed, +describe('resolveProsthesisAssignment', () => { + function resolve(assignment: ProsthesisAssignment, index = 0) { + const unresolved: UnresolvedItem[] = []; + const resolved = resolveProsthesisAssignment( + assignment, + index, + CTX, + unresolved, ); - expect(result.prosthesis?.byTooth).toEqual({ - '14': 'monolithic_zirconia', - '15': 'monolithic_zirconia', + return { resolved, unresolved }; + } + + it('resolves explicit tooth targets to FDI codes', () => { + const { resolved } = resolve({ + targets: [tooth('12'), tooth('13')], + types: ['pfm_crown'], + spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳', }); - expect(result.prosthesis?.complete).toBe(true); + expect(resolved.targets.sort()).toEqual(['12', '13']); + expect(resolved.types).toEqual(['pfm_crown']); }); - it('applies per-tooth overrides on top of the default', () => { - const result = resolveProsthesis( - { - defaultType: 'monolithic_zirconia', - overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }], - }, - ['14', '26'], - allowed, - ); - expect(result.prosthesis?.byTooth).toEqual({ - '14': 'monolithic_zirconia', - '26': 'pfm_crown', + it('resolves a stack — more than one type on the same target', () => { + const { resolved } = resolve({ + targets: [tooth('12')], + types: ['zirconia_abutment', 'monolithic_zirconia'], + spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲', }); - expect(result.prosthesis?.complete).toBe(true); - }); - - it('marks the map incomplete when a tooth ends up untyped', () => { - // Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch. - const result = resolveProsthesis( - { - defaultType: null, - overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }], - }, - ['14', '15'], - allowed, - ); - expect(result.prosthesis?.complete).toBe(false); - expect(result.prosthesis?.missingTeeth).toEqual(['15']); - }); - - it('rejects a catalog code the clinic does not have', () => { - const result = resolveProsthesis( - { defaultType: 'gold_foil', overrides: [] }, - ['14'], - allowed, - ); - // Nothing usable was said, so there is no prosthesis to show — not an empty one. - expect(result.prosthesis).toBeNull(); - expect(result.unresolved).toEqual([ - { spoken: 'gold_foil', reason: 'unknown_catalog_code' }, + expect(resolved.targets).toEqual(['12']); + expect(resolved.types.sort()).toEqual([ + 'monolithic_zirconia', + 'zirconia_abutment', ]); }); - it('ignores an override for a tooth that is not selected', () => { - const result = resolveProsthesis( - { - defaultType: 'monolithic_zirconia', - overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }], - }, - ['14'], - allowed, - ); - expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' }); - expect(result.unresolved).toEqual([ - { spoken: 'سی و هفت', reason: 'tooth_not_selected' }, - ]); + it('resolves an arch target with no position to the jaw sentinel', () => { + const { resolved } = resolve({ + targets: [positional({ arch: 'upper' })], + types: ['night_guard_soft'], + spoken: 'نایت گارد فک بالا', + }); + expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]); }); - it('reports no prosthesis at all when the object carries nothing usable', () => { - // An empty-but-present map would paint a plain restoration with a fabricated - // "incomplete, cannot ship" warning. - for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) { - expect( - resolveProsthesis(empty, ['14', '15'], allowed).prosthesis, - ).toBeNull(); + it('resolves "both jaws" to both sentinels', () => { + const { resolved } = resolve({ + targets: [positional({ arch: 'both' })], + types: ['night_guard_soft'], + spoken: 'نایت گارد هر دو فک', + }); + expect(resolved.targets.sort()).toEqual( + [ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(), + ); + }); + + it('classifies a leaf, a category and a subcategory code independently', () => { + const leaf = resolve({ + targets: [tooth('12')], + types: ['pfm_crown'], + spoken: '', + }); + expect(leaf.resolved.types).toEqual(['pfm_crown']); + expect(leaf.unresolved).toEqual([]); + + const category = resolve({ + targets: [tooth('12')], + types: ['crown'], + spoken: 'روکش', + }); + expect(category.resolved.types).toEqual([]); + expect(category.unresolved).toEqual([ + { + spoken: 'روکش', + reason: 'prosthesis_type_ambiguous', + candidates: ['monolithic_zirconia', 'pfm_crown'], + assignmentIndex: 0, + }, + ]); + + const subcategory = resolve({ + targets: [positional({ arch: 'upper' })], + types: ['night_guard'], + spoken: 'نایت گارد', + }); + expect(subcategory.resolved.types).toEqual([]); + expect(subcategory.unresolved[0]).toMatchObject({ + reason: 'prosthesis_type_ambiguous', + candidates: ['night_guard_soft'], + }); + }); + + it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => { + const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code)); + const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category)); + const subcategoryCodes = new Set( + PROSTHESIS_TYPES.map((t) => t.subcategory).filter(Boolean), + ); + for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false); + for (const code of subcategoryCodes) { + expect(leafCodes.has(code)).toBe(false); + expect(categoryCodes.has(code)).toBe(false); } }); - it('reports no prosthesis when there are no teeth to type', () => { - const result = resolveProsthesis( - { defaultType: 'monolithic_zirconia', overrides: [] }, - [], - allowed, - ); - expect(result.prosthesis).toBeNull(); - }); - - it('distinguishes a tooth it could not understand from one that is not selected', () => { - // Different corrective actions: add the tooth, versus repeat yourself. - const result = resolveProsthesis( + it('reports a code not in the supplied catalog', () => { + const { resolved, unresolved } = resolve({ + targets: [tooth('12')], + types: ['gold_foil'], + spoken: '', + }); + expect(resolved.types).toEqual([]); + expect(unresolved).toEqual([ { - defaultType: 'monolithic_zirconia', - overrides: [ - { - tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' }, - type: 'pfm_crown', - }, - ], + spoken: 'gold_foil', + reason: 'unknown_catalog_code', + assignmentIndex: 0, }, - ['14'], - allowed, - ); - expect(result.unresolved).toEqual([ - { spoken: 'نود و نه', reason: 'malformed' }, ]); }); - it('returns null when no prosthesis was spoken', () => { - expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull(); + it('rejects an arch code aimed at a tooth', () => { + const { resolved, unresolved } = resolve({ + targets: [tooth('12')], + types: ['night_guard_soft'], + spoken: 'دندون ۱۲ نایت گارد', + }); + expect(resolved.targets).toEqual([]); + expect(unresolved).toEqual([ + { spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 }, + ]); }); + + it('rejects a tooth code aimed at a jaw', () => { + const { resolved, unresolved } = resolve({ + targets: [positional({ arch: 'upper', spoken: 'فک بالا' })], + types: ['pfm_crown'], + spoken: 'فک بالا', + }); + expect(resolved.targets).toEqual([]); + expect(unresolved).toEqual([ + { + spoken: 'فک بالا', + reason: 'code_not_valid_for_target', + assignmentIndex: 0, + }, + ]); + }); + + it('defers the region check for a mixed-region category (removable)', () => { + // complete_denture is 'arch', partial_denture is 'crown' — no region to check until a + // leaf is picked. Neither a tooth nor a jaw target may be rejected while it is still + // the bare category. + const toothTarget = resolve({ + targets: [tooth('12')], + types: ['removable'], + spoken: 'دنچر برای ۱۲', + }); + expect(toothTarget.resolved.targets).toEqual(['12']); + expect(toothTarget.unresolved).toContainEqual( + expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }), + ); + expect(toothTarget.unresolved).not.toContainEqual( + expect.objectContaining({ reason: 'code_not_valid_for_target' }), + ); + + const jawTarget = resolve({ + targets: [positional({ arch: 'upper' })], + types: ['removable'], + spoken: 'دنچر فک بالا', + }); + expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]); + expect(jawTarget.unresolved).not.toContainEqual( + expect.objectContaining({ reason: 'code_not_valid_for_target' }), + ); + }); + + it('a target with no types resolves with an empty types[], and does not fail the assignment', () => { + const { resolved } = resolve({ + targets: [tooth('13'), tooth('14')], + types: [], + spoken: '۱۳ و ۱۴', + }); + // Nothing to validate a region against yet, so a bare target still resolves as valid + // geometry — the frontend renders it struck through ("no prosthesis heard") because + // `types` is empty and no `prosthesis_type_ambiguous` item references this assignment. + expect(resolved.targets.sort()).toEqual(['13', '14']); + expect(resolved.types).toEqual([]); + }); + + it('one target failing validity does not exclude the rest of the assignment', () => { + const { resolved, unresolved } = resolve({ + targets: [tooth('12'), positional({ arch: 'upper' })], + types: ['pfm_crown'], + spoken: '', + }); + expect(resolved.targets).toEqual(['12']); + expect(unresolved).toContainEqual( + expect.objectContaining({ reason: 'code_not_valid_for_target' }), + ); + }); + + it('carries an assignment target that could not be resolved, with the arch candidates', () => { + const { unresolved } = resolve({ + targets: [positional({ spoken: 'نایت گارد' })], + types: ['night_guard_soft'], + spoken: 'نایت گارد', + }); + expect(unresolved).toContainEqual({ + spoken: 'نایت گارد', + reason: 'arch_not_spoken', + candidates: ['upper', 'lower'], + assignmentIndex: 0, + }); + }); + + it('carries the assignment index on a missing-quadrant target', () => { + const { unresolved } = resolve({ + targets: [positional({ position: 2, spoken: 'دندون دو' })], + types: ['pfm_crown'], + spoken: 'دندون دو روکش', + }); + expect(unresolved).toContainEqual( + expect.objectContaining({ + reason: 'tooth_missing_quadrant', + assignmentIndex: 0, + }), + ); + }); +}); + +describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => { + const locales = ['fa', 'en', 'nl'] as const; + + it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))( + 'category %s', + (_code, labels) => { + for (const locale of locales) { + expect(labels[locale]?.trim()).toBeTruthy(); + } + }, + ); + + it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))( + 'subcategory %s', + (_code, labels) => { + for (const locale of locales) { + expect(labels[locale]?.trim()).toBeTruthy(); + } + }, + ); }); describe('resolveVoiceIntent', () => { @@ -235,7 +444,7 @@ describe('resolveVoiceIntent', () => { teeth: [tooth('14'), tooth('15')], connectedSpans: [], comment: ' حساسیت به سرما ', - prosthesis: null, + prosthesis: [], labId: null, labMatchExact: false, due: null, @@ -246,7 +455,7 @@ describe('resolveVoiceIntent', () => { expect(result.treatmentType).toBe('restoration'); expect(result.teeth).toEqual(['14', '15']); expect(result.comment).toBe('حساسیت به سرما'); - expect(result.prosthesis).toBeNull(); + expect(result.prosthesisAssignments).toEqual([]); expect(result.unresolved).toEqual([]); }); @@ -299,27 +508,38 @@ describe('resolveVoiceIntent', () => { expect(result.labMatchExact).toBe(false); }); - it('applies prosthesis over the span-expanded tooth set', () => { + it('forces treatmentType to prosthesis when an assignment resolves', () => { const result = resolveVoiceIntent( { ...base, - treatmentType: 'prosthesis', - teeth: [tooth('14')], - connectedSpans: [{ from: tooth('14'), to: tooth('16') }], - prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] }, + treatmentType: 'restoration', + prosthesis: [ + { + targets: [tooth('12')], + types: ['pfm_crown'], + spoken: 'روکش برای ۱۲', + }, + ], }, CTX, ); - // 15 was never spoken but is part of the bridge, so it must carry a type too. - expect(result.teeth).toEqual(['14', '15', '16']); - expect(result.prosthesis?.complete).toBe(true); - expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([ - '14', - '15', - '16', + expect(result.treatmentType).toBe('prosthesis'); + expect(result.prosthesisAssignments).toEqual([ + { targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' }, ]); }); + it('does not force prosthesis when every assignment resolved no target', () => { + const result = resolveVoiceIntent( + { + ...base, + prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }], + }, + CTX, + ); + expect(result.treatmentType).toBe('restoration'); + }); + it('resolves a due date through the same context', () => { const result = resolveVoiceIntent( { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } }, @@ -328,7 +548,7 @@ describe('resolveVoiceIntent', () => { expect(result.dueDate).toBe('2025-10-16'); }); - it('collects unresolved items from every stage', () => { + it('collects unresolved items from every stage, and only assignment items carry an index', () => { const result = resolveVoiceIntent( { ...base, @@ -336,16 +556,34 @@ describe('resolveVoiceIntent', () => { teeth: [tooth('51', 'شیری')], connectedSpans: [{ from: tooth('14'), to: tooth('44') }], due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 }, + prosthesis: [ + { + targets: [tooth('99', 'نود و نه')], + types: ['pfm_crown'], + spoken: '', + }, + ], }, CTX, ); const reasons = result.unresolved.map((u) => u.reason).sort(); expect(reasons).toEqual([ 'invalid_date', + 'malformed', 'not_permanent_tooth', 'span_not_same_arch', 'unknown_catalog_code', ]); + + const outsideAssignment = result.unresolved.filter( + (u) => u.reason === 'not_permanent_tooth', + ); + expect(outsideAssignment[0].assignmentIndex).toBeUndefined(); + + const insideAssignment = result.unresolved.filter( + (u) => u.reason === 'malformed', + ); + expect(insideAssignment[0].assignmentIndex).toBe(0); }); it('treats an empty comment as absent', () => { diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index e7f6714..4990415 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -1,7 +1,14 @@ import { + ARCH_TOOTH_LOWER, + ARCH_TOOTH_UPPER, + isFdiTooth, + normalizeFdiCode, sameArch, sortInArchOrder, teethBetweenInclusive, + toFdi, + type Arch, + type PatientSide, } from '../../common/fdi'; import { resolveDueDate } from './due-date.resolver'; import { @@ -10,9 +17,10 @@ import { } from './tooth-intent.resolver'; import type { ConnectedSpanIntent, - ProsthesisIntent, + ProsthesisAssignment, ToothIntent, UnresolvedItem, + UnresolvedReason, VoiceIntent, } from './voice.types'; @@ -22,16 +30,16 @@ export type ResolvedToothGroup = { teeth: string[]; }; -export type ResolvedProsthesis = { - /** FDI code → prosthesis type code. */ - byTooth: Record; - /** - * True when every selected tooth carries a code. A prosthesis detail cannot be shipped - * otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap - * here rather than letting it fail at dispatch. - */ - complete: boolean; - missingTeeth: string[]; +/** + * One spoken instruction, resolved: these targets (FDI codes, or `'UA'`/`'LA'` jaw sentinels — + * mirrors the manual chart's own convention) get these leaf codes. Empty `targets` or `types` + * means every target, or every type, this assignment named turned out unresolved; the + * assignment still appears so its index keeps meaning for `UnresolvedItem.assignmentIndex`. + */ +export type ResolvedProsthesisAssignment = { + targets: string[]; + types: string[]; + spoken: string; }; export type ResolvedExtraction = { @@ -39,23 +47,33 @@ export type ResolvedExtraction = { teeth: string[]; toothSelectionGroups: ResolvedToothGroup[]; comment: string | null; - prosthesis: ResolvedProsthesis | null; + prosthesisAssignments: ResolvedProsthesisAssignment[]; labId: string | null; labMatchExact: boolean; dueDate: string | null; unresolved: UnresolvedItem[]; }; +/** A leaf prosthesis catalog entry, as `ProsthesisCatalogService.list()` already returns it. */ +export type ProsthesisLeaf = { + code: string; + category: string; + subcategory: string; + chartRegion: string; +}; + export type ResolveContext = { todayIso: string; /** JS weekday index the clinician's week starts on — see weekStartForLocale. */ weekStartJs: number; treatmentTypeCodes: ReadonlySet; - prosthesisTypeCodes: ReadonlySet; + prosthesisLeaves: readonly ProsthesisLeaf[]; + prosthesisCategoryCodes: ReadonlySet; + prosthesisSubcategoryCodes: ReadonlySet; linkedLabIds: ReadonlySet; }; -function spokenOf(intent: ToothIntent): string { +function spokenOf(intent: unknown): string { const spoken = (intent as { spoken?: unknown })?.spoken; return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; } @@ -165,73 +183,259 @@ export function resolveConnectedSpans( }; } +// --- Prosthesis assignments ------------------------------------------------------------- + +type TargetResolution = + | { kind: 'tooth'; fdi: string } + | { kind: 'jaw'; arch: 'upper' | 'lower' | 'both' } + | { kind: 'unresolved'; reason: UnresolvedReason; candidates?: string[] }; + +const QUADRANT_ARCHES: readonly Arch[] = ['upper', 'lower']; +const QUADRANT_SIDES: readonly PatientSide[] = [ + 'patient_right', + 'patient_left', +]; + +/** The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. */ +function quadrantCandidatesForTarget( + target: Extract, +): string[] { + const arches = + target.arch === 'upper' || target.arch === 'lower' + ? [target.arch] + : QUADRANT_ARCHES; + const sides = + target.side === 'patient_right' || target.side === 'patient_left' + ? [target.side] + : QUADRANT_SIDES; + const codes: string[] = []; + for (const arch of arches) { + for (const side of sides) { + const fdi = toFdi(arch, side, target.position); + if (fdi) codes.push(fdi); + } + } + return codes.sort(); +} + /** - * A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is - * how clinicians actually speak. + * A prosthesis assignment target is a tooth or a jaw — nothing on the wire declares which. + * A position with no quadrant is the familiar "دو" ambiguity; no position at all, with an + * arch, names a jaw; no position AND no arch means the jaw itself was never spoken. */ -export function resolveProsthesis( - intent: ProsthesisIntent | null | undefined, - teeth: readonly string[], - allowed: ReadonlySet, -): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } { - if (!intent || typeof intent !== 'object') - return { prosthesis: null, unresolved: [] }; - - const unresolved: UnresolvedItem[] = []; - const defaultType = resolveCatalogCode(intent.defaultType, allowed); - if (intent.defaultType != null && !defaultType) { - unresolved.push({ - spoken: String(intent.defaultType), - reason: 'unknown_catalog_code', - }); +function resolveAssignmentTarget(target: ToothIntent): TargetResolution { + if (!target || typeof target !== 'object') { + return { kind: 'unresolved', reason: 'malformed' }; } - const byTooth: Record = {}; - const selected = new Set(teeth); - if (defaultType) { - for (const tooth of teeth) byTooth[tooth] = defaultType; + if (target.kind === 'explicit') { + const fdi = normalizeFdiCode((target as { fdi?: unknown }).fdi); + if (isFdiTooth(fdi)) return { kind: 'tooth', fdi }; + // Quadrants 1-4 are permanent; a well-formed quadrant+position reaching here is 5-8: + // deciduous. Anything else is noise. + return { + kind: 'unresolved', + reason: /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed', + }; } - const overrides: ProsthesisIntent['overrides'] = Array.isArray( - intent.overrides, - ) - ? intent.overrides + if (target.kind === 'positional') { + const positionGiven = + typeof target.position === 'number' && !Number.isNaN(target.position); + const archGiven = + target.arch === 'upper' || + target.arch === 'lower' || + target.arch === 'both'; + const sideGiven = + target.side === 'patient_right' || target.side === 'patient_left'; + + if (positionGiven) { + if ( + !Number.isInteger(target.position) || + target.position < 1 || + target.position > 8 + ) { + return { kind: 'unresolved', reason: 'position_out_of_range' }; + } + if (archGiven && target.arch !== 'both' && sideGiven) { + const fdi = toFdi(target.arch, target.side, target.position); + if (fdi) return { kind: 'tooth', fdi }; + } + // A position was said but the tooth's own quadrant was not (or "both" was said for + // what must be a single tooth) — the familiar "دو" ambiguity. + return { + kind: 'unresolved', + reason: 'tooth_missing_quadrant', + candidates: quadrantCandidatesForTarget(target), + }; + } + + if (archGiven) return { kind: 'jaw', arch: target.arch }; + // Neither a tooth position nor a jaw was said. + return { + kind: 'unresolved', + reason: 'arch_not_spoken', + candidates: ['upper', 'lower'], + }; + } + + return { kind: 'unresolved', reason: 'malformed' }; +} + +function archSentinels(arch: 'upper' | 'lower' | 'both'): string[] { + if (arch === 'upper') return [ARCH_TOOTH_UPPER]; + if (arch === 'lower') return [ARCH_TOOTH_LOWER]; + return [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER]; +} + +type TypeClassification = + | { kind: 'leaf'; code: string; chartRegion: string } + | { + kind: 'category' | 'subcategory'; + code: string; + leafCodes: string[]; + regions: Set; + } + | { kind: 'unknown' }; + +/** `types[]` may hold a leaf, or one category / subcategory code — the namespaces are disjoint. */ +function classifyTypeCode( + rawCode: unknown, + ctx: ResolveContext, +): TypeClassification { + if (typeof rawCode !== 'string' || !rawCode.trim()) + return { kind: 'unknown' }; + const code = rawCode.trim(); + + const leaf = ctx.prosthesisLeaves.find((l) => l.code === code); + if (leaf) return { kind: 'leaf', code, chartRegion: leaf.chartRegion }; + + if (ctx.prosthesisCategoryCodes.has(code)) { + const leaves = ctx.prosthesisLeaves.filter((l) => l.category === code); + return { + kind: 'category', + code, + leafCodes: leaves.map((l) => l.code), + regions: new Set(leaves.map((l) => l.chartRegion)), + }; + } + if (ctx.prosthesisSubcategoryCodes.has(code)) { + const leaves = ctx.prosthesisLeaves.filter((l) => l.subcategory === code); + return { + kind: 'subcategory', + code, + leafCodes: leaves.map((l) => l.code), + regions: new Set(leaves.map((l) => l.chartRegion)), + }; + } + return { kind: 'unknown' }; +} + +const TOOTH_REGIONS = new Set(['crown', 'root']); +const JAW_REGIONS = new Set(['arch']); + +function acceptableRegions(kind: 'tooth' | 'jaw'): ReadonlySet { + return kind === 'tooth' ? TOOTH_REGIONS : JAW_REGIONS; +} + +/** + * One spoken instruction, resolved. Stack legality (`canStackLeaf`, screw-retained exclusion) + * is never checked here — that lives in the frontend's `prosthesisTree.ts` (decision 38); this + * only confirms a code exists and that its region suits its target. + */ +export function resolveProsthesisAssignment( + assignment: ProsthesisAssignment, + index: number, + ctx: ResolveContext, + unresolved: UnresolvedItem[], +): ResolvedProsthesisAssignment { + const rawTypes = Array.isArray(assignment?.types) ? assignment.types : []; + const leafCodes = new Set(); + const regionSet = new Set(); + + for (const rawCode of rawTypes) { + const classification = classifyTypeCode(rawCode, ctx); + if (classification.kind === 'leaf') { + leafCodes.add(classification.code); + regionSet.add(classification.chartRegion); + } else if ( + classification.kind === 'category' || + classification.kind === 'subcategory' + ) { + unresolved.push({ + spoken: spokenOf(assignment) || classification.code, + reason: 'prosthesis_type_ambiguous', + candidates: [...classification.leafCodes].sort(), + assignmentIndex: index, + }); + // A category whose leaves share one region is informative even before the leaf is + // picked; a mixed one (only `removable` today) defers the region check entirely + // (decision 49) rather than risk `code_not_valid_for_target` on a category that has + // not been narrowed yet. + if (classification.regions.size === 1) { + for (const region of classification.regions) regionSet.add(region); + } + } else if (typeof rawCode === 'string' && rawCode.trim()) { + unresolved.push({ + spoken: rawCode, + reason: 'unknown_catalog_code', + assignmentIndex: index, + }); + } + } + + const rawTargets = Array.isArray(assignment?.targets) + ? assignment.targets : []; - for (const override of overrides) { - const tooth = resolveToothIntent(override?.tooth); - const type = resolveCatalogCode(override?.type, allowed); - const spoken = spokenOf(override?.tooth) || String(override?.type ?? ''); - if (!tooth) { - unresolved.push({ spoken, reason: 'malformed' }); + const targets = new Set(); + + for (const rawTarget of rawTargets) { + const resolution = resolveAssignmentTarget(rawTarget); + const spoken = spokenOf(rawTarget); + + if (resolution.kind === 'unresolved') { + unresolved.push( + resolution.candidates + ? { + spoken, + reason: resolution.reason, + candidates: resolution.candidates, + assignmentIndex: index, + } + : { spoken, reason: resolution.reason, assignmentIndex: index }, + ); continue; } - // A tooth we understood perfectly well but which is not part of this detail. Saying - // so is actionable ("add tooth 37, or drop it"); calling it malformed is not. - if (!selected.has(tooth)) { - unresolved.push({ spoken, reason: 'tooth_not_selected' }); + + // Nothing to validate against yet — a bare category is deferred, and an empty types[] + // means the target is simply named with no job (left to the caller to render struck + // through, using `teeth` minus every assignment's resolved targets). + const valid = + regionSet.size === 0 || + [...regionSet].some((region) => + acceptableRegions(resolution.kind).has(region), + ); + if (!valid) { + unresolved.push({ + spoken, + reason: 'code_not_valid_for_target', + assignmentIndex: index, + }); continue; } - if (!type) { - unresolved.push({ spoken, reason: 'unknown_catalog_code' }); - continue; + + if (resolution.kind === 'tooth') { + targets.add(resolution.fdi); + } else { + for (const sentinel of archSentinels(resolution.arch)) + targets.add(sentinel); } - byTooth[tooth] = type; } - // Nothing usable was said about prosthesis. Returning an empty-but-present map would - // paint a plain restoration with a fabricated "incomplete, cannot ship" warning. - if (Object.keys(byTooth).length === 0) { - return { prosthesis: null, unresolved }; - } - - const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]); return { - prosthesis: { - byTooth, - complete: missingTeeth.length === 0, - missingTeeth, - }, - unresolved, + targets: [...targets], + types: [...leafCodes], + spoken: spokenOf(assignment), }; } @@ -262,12 +466,18 @@ export function resolveVoiceIntent( }); } - const prosthesisResult = resolveProsthesis( - intent?.prosthesis, - spanResult.teeth, - ctx.prosthesisTypeCodes, + const rawAssignments = Array.isArray(intent?.prosthesis) + ? intent.prosthesis + : []; + const prosthesisAssignments = rawAssignments.map((assignment, index) => + resolveProsthesisAssignment(assignment, index, ctx, unresolved), + ); + + // `prosthesis` is the only labDependent treatment type — any assignment that actually + // landed on a target forces it, and the sheet locks that row while it is ticked (decision 41). + const hasResolvedAssignment = prosthesisAssignments.some( + (a) => a.targets.length > 0, ); - unresolved.push(...prosthesisResult.unresolved); const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs); if (due.unresolved) unresolved.push(due.unresolved); @@ -287,11 +497,11 @@ export function resolveVoiceIntent( } return { - treatmentType, + treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType, teeth: spanResult.teeth, toothSelectionGroups: spanResult.groups, comment, - prosthesis: prosthesisResult.prosthesis, + prosthesisAssignments, labId, labMatchExact: labId ? intent?.labMatchExact === true : false, dueDate: due.dueDate, diff --git a/backend/src/modules/voice/extraction.wire.spec.ts b/backend/src/modules/voice/extraction.wire.spec.ts index f40bcc7..468ecb3 100644 --- a/backend/src/modules/voice/extraction.wire.spec.ts +++ b/backend/src/modules/voice/extraction.wire.spec.ts @@ -23,8 +23,7 @@ const wire = (overrides: Partial = {}): WireVoiceIntent => ({ teeth: [], connectedSpans: [], comment: null, - prosthesisDefaultType: null, - prosthesisOverrides: [], + prosthesis: [], labId: null, labMatchExact: false, due: emptyDue, @@ -177,26 +176,70 @@ describe('toVoiceIntent', () => { expect(result.due).toEqual({ kind: 'lunar_month' }); }); - it('reports no prosthesis when neither a default nor an override was given', () => { - expect(toVoiceIntent(wire()).prosthesis).toBeNull(); + it('yields an empty array, never null, when nothing was spoken about prosthesis', () => { + expect(toVoiceIntent(wire()).prosthesis).toEqual([]); }); - it('builds a prosthesis intent from a default alone', () => { - const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' })); - expect(result.prosthesis).toEqual({ - defaultType: 'pfm_crown', - overrides: [], + it('narrows a prosthesis assignment, targets and types alike', () => { + const result = toVoiceIntent( + wire({ + prosthesis: [ + { + targets: [{ ...positionalTooth, fdi: '12' }], + types: ['zirconia_abutment', 'monolithic_zirconia'], + spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲', + }, + ], + }), + ); + expect(result.prosthesis).toEqual([ + { + targets: [{ kind: 'explicit', fdi: '12', spoken: 'شش بالا راست' }], + types: ['zirconia_abutment', 'monolithic_zirconia'], + spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲', + }, + ]); + }); + + it('narrows a jaw target — arch given, position null', () => { + const result = toVoiceIntent( + wire({ + prosthesis: [ + { + targets: [ + { + spoken: 'فک بالا', + fdi: null, + arch: 'both', + side: null, + position: null, + }, + ], + types: ['night_guard_soft'], + spoken: 'نایت گارد هر دو فک', + }, + ], + }), + ); + expect(result.prosthesis[0].targets[0]).toEqual({ + kind: 'positional', + arch: 'both', + side: null, + position: Number.NaN, + spoken: 'فک بالا', }); }); - it('builds a prosthesis intent from overrides alone', () => { + it('survives a malformed prosthesis entry', () => { + expect(() => + toVoiceIntent( + wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }), + ), + ).not.toThrow(); const result = toVoiceIntent( - wire({ - prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }], - }), + wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }), ); - expect(result.prosthesis?.defaultType).toBeNull(); - expect(result.prosthesis?.overrides).toHaveLength(1); + expect(result.prosthesis).toEqual([{ targets: [], types: [], spoken: '' }]); }); it('narrows connected spans', () => { diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index fecf194..ae71f40 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi'; import type { ConnectedSpanIntent, DueIntent, - ProsthesisIntent, + ProsthesisAssignment, ToothIntent, VoiceIntent, Weekday, @@ -20,7 +20,7 @@ export type WireToothIntent = { spoken: string; /** The two-digit FDI code the clinician spoke; null when the tooth was described. */ fdi: string | null; - arch: 'upper' | 'lower' | null; + arch: 'upper' | 'lower' | 'both' | null; side: 'patient_right' | 'patient_left' | null; position: number | null; }; @@ -39,13 +39,18 @@ export type WireDue = { d: number | null; }; +export type WireProsthesisAssignment = { + targets: WireToothIntent[]; + types: string[]; + spoken: string; +}; + export type WireVoiceIntent = { treatmentType: string | null; teeth: WireToothIntent[]; connectedSpans: { from: WireToothIntent; to: WireToothIntent }[]; comment: string | null; - prosthesisDefaultType: string | null; - prosthesisOverrides: { tooth: WireToothIntent; type: string }[]; + prosthesis: WireProsthesisAssignment[]; labId: string | null; labMatchExact: boolean; due: WireDue; @@ -58,15 +63,21 @@ const TOOTH_SCHEMA = { properties: { spoken: { type: 'string', - description: 'The exact transcript words for this tooth.', + description: 'The exact transcript words for this tooth (or jaw).', }, fdi: { type: ['string', 'null'], description: 'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' + - 'when the tooth was described in words instead of numbered.', + 'when the tooth was described in words instead of numbered, or the target is a jaw.', + }, + arch: { + type: ['string', 'null'], + enum: ['upper', 'lower', 'both', null], + description: + '"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' + + 'both jaws), never for a single tooth.', }, - arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] }, side: { type: ['string', 'null'], enum: ['patient_right', 'patient_left', null], @@ -75,7 +86,33 @@ const TOOTH_SCHEMA = { position: { type: ['integer', 'null'], description: - 'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.', + 'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' + + 'code. Null when this target is a jaw rather than a tooth.', + }, + }, +} as const; + +const PROSTHESIS_ASSIGNMENT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['targets', 'types', 'spoken'], + properties: { + targets: { + type: 'array', + description: 'The teeth or jaws this instruction applies to.', + items: TOOTH_SCHEMA, + }, + types: { + type: 'array', + description: + 'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' + + 'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' + + 'fine when only the general term was said.', + items: { type: 'string' }, + }, + spoken: { + type: 'string', + description: 'The exact transcript words for this instruction.', }, }, } as const; @@ -88,8 +125,7 @@ export const VOICE_INTENT_JSON_SCHEMA = { 'teeth', 'connectedSpans', 'comment', - 'prosthesisDefaultType', - 'prosthesisOverrides', + 'prosthesis', 'labId', 'labMatchExact', 'due', @@ -114,19 +150,12 @@ export const VOICE_INTENT_JSON_SCHEMA = { type: ['string', 'null'], description: 'Clinical notes, in the spoken language.', }, - prosthesisDefaultType: { - type: ['string', 'null'], - description: - 'A prosthesis type CODE applied to every tooth unless overridden.', - }, - prosthesisOverrides: { + prosthesis: { type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['tooth', 'type'], - properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } }, - }, + description: + 'One entry per spoken instruction: these targets get these jobs. No default and no ' + + 'overrides — every entry names its own targets.', + items: PROSTHESIS_ASSIGNMENT_SCHEMA, }, labId: { type: ['string', 'null'], @@ -190,7 +219,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { } return { kind: 'positional', - arch: wire?.arch as 'upper' | 'lower', + arch: wire?.arch as 'upper' | 'lower' | 'both', side: wire?.side as 'patient_right' | 'patient_left', position: typeof wire?.position === 'number' ? wire.position : Number.NaN, spoken, @@ -236,36 +265,34 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null { } } +function toProsthesisAssignment( + wire: WireProsthesisAssignment | undefined | null, +): ProsthesisAssignment { + const targets = Array.isArray(wire?.targets) ? wire.targets : []; + const types = Array.isArray(wire?.types) ? wire.types : []; + return { + targets: targets.map(toToothIntent), + types: types.filter((code): code is string => typeof code === 'string'), + spoken: typeof wire?.spoken === 'string' ? wire.spoken : '', + }; +} + export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent { const teeth = Array.isArray(wire?.teeth) ? wire.teeth : []; const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : []; - const overrides = Array.isArray(wire?.prosthesisOverrides) - ? wire.prosthesisOverrides - : []; + const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : []; const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({ from: toToothIntent(span?.from), to: toToothIntent(span?.to), })); - const hasProsthesis = - wire?.prosthesisDefaultType != null || overrides.length > 0; - const prosthesis: ProsthesisIntent | null = hasProsthesis - ? { - defaultType: wire?.prosthesisDefaultType ?? null, - overrides: overrides.map((o) => ({ - tooth: toToothIntent(o?.tooth), - type: o?.type, - })), - } - : null; - return { treatmentType: wire?.treatmentType ?? null, teeth: teeth.map(toToothIntent), connectedSpans, comment: wire?.comment ?? null, - prosthesis, + prosthesis: assignments.map(toProsthesisAssignment), labId: wire?.labId ?? null, labMatchExact: wire?.labMatchExact === true, due: toDueIntent(wire?.due), diff --git a/backend/src/modules/voice/openrouter.provider.spec.ts b/backend/src/modules/voice/openrouter.provider.spec.ts index a18f0e6..968d068 100644 --- a/backend/src/modules/voice/openrouter.provider.spec.ts +++ b/backend/src/modules/voice/openrouter.provider.spec.ts @@ -12,7 +12,18 @@ const CONFIG = { const CATALOG = { treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }], - prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پی‌اف‌ام' }], + prosthesisTypes: [ + { + code: 'pfm_crown', + label: 'روکش پی‌اف‌ام', + category: 'crown', + subcategory: '', + chartRegion: 'crown', + stackGroup: 'restoration', + }, + ], + prosthesisCategories: [{ code: 'crown', label: 'روکش‌ها' }], + prosthesisSubcategories: [], labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }], }; @@ -111,8 +122,21 @@ describe('OpenRouterExtractionProvider', () => { ], connectedSpans: [], comment: null, - prosthesisDefaultType: 'pfm_crown', - prosthesisOverrides: [], + prosthesis: [ + { + targets: [ + { + spoken: 'یک چهار', + fdi: '14', + arch: null, + side: null, + position: null, + }, + ], + types: ['pfm_crown'], + spoken: 'روکش پی‌اف‌ام روی ۱۴', + }, + ], labId: 'lab-1', labMatchExact: true, due: { diff --git a/backend/src/modules/voice/voice.providers.ts b/backend/src/modules/voice/voice.providers.ts index 7511963..6d0319d 100644 --- a/backend/src/modules/voice/voice.providers.ts +++ b/backend/src/modules/voice/voice.providers.ts @@ -34,7 +34,22 @@ export interface AsrProvider { export type ExtractionCatalog = { /** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */ treatmentTypes: { code: string; label: string }[]; - prosthesisTypes: { code: string; label: string }[]; + /** + * Leaf prosthesis types — the full tree shape, not just code/label, so the prompt can + * present it as a tree and mark which codes are jaw-level (`chartRegion: 'arch'`). + */ + prosthesisTypes: { + code: string; + label: string; + category: string; + subcategory: string; + chartRegion: string; + stackGroup: string; + }[]; + /** The 7 category codes, so "روکش" resolves to `crown` rather than a guessed leaf. */ + prosthesisCategories: { code: string; label: string }[]; + /** The 5 subcategory codes (veneer, inlay, onlay, overlay, night_guard). */ + prosthesisSubcategories: { code: string; label: string }[]; /** The clinic's linked labs — a closed choice list. */ labs: { id: string; name: string }[]; }; diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index f36666d..9bea739 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -138,8 +138,12 @@ export class VoiceService { todayIso, weekStartJs: weekStartForLocale(catalogLocale), treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)), - prosthesisTypeCodes: new Set( - catalog.prosthesisTypes.map((t) => t.code), + prosthesisLeaves: catalog.prosthesisTypes, + prosthesisCategoryCodes: new Set( + catalog.prosthesisCategories.map((c) => c.code), + ), + prosthesisSubcategoryCodes: new Set( + catalog.prosthesisSubcategories.map((c) => c.code), ), linkedLabIds: new Set(catalog.labs.map((l) => l.id)), }); @@ -244,9 +248,17 @@ export class VoiceService { organizationId: string, locale: string, ): Promise { - const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([ + const [ + treatmentTypes, + prosthesisTypes, + prosthesisCategories, + prosthesisSubcategories, + labs, + ] = await Promise.all([ this.treatmentCatalog.list(locale, null), this.prosthesisCatalog.list(locale), + this.prosthesisCatalog.listCategories(locale), + this.prosthesisCatalog.listSubcategories(locale), this.listLinkedLabs(organizationId), ]); @@ -254,10 +266,11 @@ export class VoiceService { treatmentTypes: treatmentTypes .filter((entry) => entry.availableInTreatment) .map((entry) => ({ code: entry.code, label: entry.label })), - prosthesisTypes: prosthesisTypes.map((entry) => ({ - code: entry.code, - label: entry.label, - })), + // `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here — + // the prompt now presents the catalog as the tree it is (§5). + prosthesisTypes, + prosthesisCategories, + prosthesisSubcategories, labs, }; } @@ -333,7 +346,7 @@ export class VoiceService { treatmentType: resolved.treatmentType != null, teeth: resolved.teeth.length, comment: resolved.comment != null, - prosthesisComplete: resolved.prosthesis?.complete ?? null, + prosthesisAssignments: resolved.prosthesisAssignments.length, lab: resolved.labId != null, dueDate: resolved.dueDate != null, }, diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index f557110..bc359b8 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -8,14 +8,19 @@ import type { Arch, PatientSide } from '../../common/fdi'; * mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful. */ -/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */ +/** + * A single spoken tooth reference, OR — inside a prosthesis assignment's `targets` — a jaw. + * Nothing on the wire declares "this is a jaw": a positional reference with no `position` at + * all names an arch instead of a tooth, and the resolver tells the two apart (§5, §6). + * `arch: 'both'` only ever appears here, never on a resolved single tooth. + */ export type ToothIntent = | { kind: 'explicit'; fdi: string; spoken: string } | { kind: 'positional'; - arch: Arch; + arch: Arch | 'both'; side: PatientSide; - /** 1 = central incisor … 8 = third molar. */ + /** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */ position: number; spoken: string; }; @@ -42,10 +47,18 @@ export type Weekday = (typeof WEEKDAYS)[number]; /** Two teeth defining an inclusive connected (bridge) span. */ export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent }; -export type ProsthesisIntent = { - /** Catalog code applied to every tooth unless overridden. */ - defaultType: string | null; - overrides: { tooth: ToothIntent; type: string }[]; +/** + * One spoken instruction: these targets get these jobs. Replaces the old + * `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides + * has a precedence rule, and a precedence rule has a wrong side (decision 35). + */ +export type ProsthesisAssignment = { + /** Each one a tooth or a jaw — see `ToothIntent`. */ + targets: ToothIntent[]; + /** Leaf codes, or one category / subcategory code the clinician named generically. */ + types: string[]; + /** The transcript span, echoed back to the clinician. */ + spoken: string; }; export type VoiceIntent = { @@ -53,7 +66,8 @@ export type VoiceIntent = { teeth: ToothIntent[]; connectedSpans: ConnectedSpanIntent[]; comment: string | null; - prosthesis: ProsthesisIntent | null; + /** Empty array, never null. */ + prosthesis: ProsthesisAssignment[]; /** Must be one of the linked-lab ids supplied in the prompt, or null. */ labId: string | null; /** False when the spoken name only approximately matched — the UI then requires an explicit tick. */ @@ -67,10 +81,15 @@ export type UnresolvedReason = | 'position_out_of_range' /** A position was understood but no quadrant was spoken — four teeth match. */ | 'tooth_missing_quadrant' + /** A category or subcategory was heard, not a material. */ + | 'prosthesis_type_ambiguous' + /** A jaw-level appliance with no jaw spoken. */ + | 'arch_not_spoken' + /** An arch code aimed at a tooth, or a tooth code aimed at a jaw. */ + | 'code_not_valid_for_target' | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code' - | 'tooth_not_selected' | 'invalid_date'; export type UnresolvedItem = { @@ -78,8 +97,15 @@ export type UnresolvedItem = { spoken: string; reason: UnresolvedReason; /** - * FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only - * `tooth_missing_quadrant` carries them; the sheet offers them as chips. + * Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`, + * leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The + * sheet offers these as chips. */ candidates?: string[]; + /** + * Set only when this item was raised while resolving a `prosthesis` assignment's targets or + * types. A picked chip then inherits that assignment's `types[]` (or supplies the missing + * leaf to it) instead of resolving to a jobless tooth (decision 50). + */ + assignmentIndex?: number; }; diff --git a/docs/specs/voice-treatment-entry/progress.md b/docs/specs/voice-treatment-entry/progress.md index 5e9be40..c034c8f 100644 --- a/docs/specs/voice-treatment-entry/progress.md +++ b/docs/specs/voice-treatment-entry/progress.md @@ -22,25 +22,25 @@ Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked | # | Work item | Repo | Status | Notes / refs | |---|-----------|------|--------|--------------| -| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ⬜ | §9. The Safari failure | -| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | ⬜ | §2 render policy, `TreatmentWorkspace.tsx` | -| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | ⬜ | §12. One dev dep, one config, one script | -| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | ⬜ | §5. `voice.service.ts` already receives all four | -| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | ⬜ | §5, `extraction.wire.ts` | -| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | ⬜ | §5, §6, `extraction.resolver.ts` | -| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | ⬜ | §6 reason table | -| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | ⬜ | §5. Only `prosthesis` is `labDependent` | -| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | ⬜ | §5, `extraction.prompt.ts` | -| 10 | Backend Jest suites for items 6–8, including the namespace-disjointness assertion | `dyolink` | ⬜ | §12 | -| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | ⬜ | `types/voice.ts` | -| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | ⬜ | §6, §7 | -| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | ⬜ | §7. `crownColors` / `rootColors` / `archHighlight` already exist | -| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | ⬜ | §6, §7 | -| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | ⬜ | §12 | -| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | ⬜ | i18n is mandatory, not a follow-up | -| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | ⬜ | §12 | -| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | ⬜ | §5, decision 47. **Must land before item 9** | -| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | ⬜ | §6, decision 50. Without it the quadrant chip is dead on a prosthesis detail | +| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ✅ | §9. `frontend/src/lib/voice/audioFormat.ts` — the final `return null` after the loop is now `return ''` | +| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | ✅ | §2 render policy, `TreatmentWorkspace.tsx` | +| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | ✅ | §12. `vitest@3.2.7`, `frontend/vitest.config.ts`, `npm run test` script; `CLAUDE.md` Tests section rewritten | +| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | ✅ | §5. `voice.service.ts` now forwards the full `ProsthesisTypeCatalogEntry[]` plus category/subcategory lists | +| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | ✅ | §5, `extraction.wire.ts` — `prosthesis: WireProsthesisAssignment[]` | +| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | ✅ | §5, §6, `extraction.resolver.ts` — `resolveProsthesisAssignment`, `resolveAssignmentTarget`, `classifyTypeCode` | +| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | ✅ | §6 reason table — `voice.types.ts` | +| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | ✅ | §5. `resolveVoiceIntent` — `hasResolvedAssignment` | +| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | ✅ | §5, `extraction.prompt.ts` — `prosthesisTree()` renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge | +| 10 | Backend Jest suites for items 6–8, including the namespace-disjointness assertion | `dyolink` | ✅ | §12. `extraction.resolver.spec.ts` — disjointness test reads `catalog-seed-data.ts`'s live `PROSTHESIS_TYPES`, not a written count | +| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | ✅ | `types/voice.ts` — `VoiceProsthesisAssignment[]`, `assignmentIndex` | +| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | ✅ | §6, §7 — `prosthesisTargetLines`, `joblessProsthesisTargets`, `withChosenArch`/`withChosenProsthesisLeaf` | +| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | ✅ | §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds | +| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | ✅ | §6, §7 — via `prosthesisTargetLines`, which routes every stack through `applyLeafToJobs` | +| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | ✅ | §12. 37 tests total, `npx vitest run` green | +| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | ✅ | i18n is mandatory, not a follow-up — new `voiceUnresolved.*` reasons, `voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused`; retired `voiceProsthesisIncomplete` (all-or-nothing gone) | +| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | 🟡 | Machine gates green (below). Manual pass and `prisma:migrate`/`prisma:seed` against a live DB **not run** — this sandbox has no working Docker daemon (see note below) | +| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | 🟡 | §5, decision 47. Enum + migration SQL + seed data all written and `prisma generate` succeeded; migration **not applied** to a running Postgres (Docker unavailable) | +| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | ✅ | §6, decision 50. `UnresolvedItem.assignmentIndex`; `VoiceReviewSheet` chips inherit via `withChosenTeeth(...,index)` / `withChosenArch` / `withChosenProsthesisLeaf` | ## Key decisions @@ -261,3 +261,183 @@ no code to revisit. | Note 5 — disjointness counts | Verified independently: **42** leaf codes (the surveyor's 41 missed `screw_retained`, a multi-line `implant(` call) and **5** subcategories (the spec said 4, missing `night_guard`). The test now asserts against the live catalog, not a written count | §5, §12 | Referee relays: 0. Gate repairs: 0. Neither phase was reached. + +--- + +## 2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet) + +All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job. + +### Backend + +- `backend/prisma/schema.prisma` — `PROSTHESIS_CATEGORY`, `PROSTHESIS_SUBCATEGORY` added to + `CatalogEntityKind` (additive, no data loss). +- `backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql` — + hand-written `ALTER TYPE ... ADD VALUE` migration, following the exact pattern of the repo's + one precedent (`20260718180000_lab_case_activity_task_assigned`). **Not applied** — see + "Not run" below. +- `backend/prisma/catalog-seed-data.ts` — `PROSTHESIS_CATEGORY_LABELS` (7) and + `PROSTHESIS_SUBCATEGORY_LABELS` (5), worded from the frontend's existing `category_*`/`sub_*` + message keys (decision 47/48), folded into `CATALOG_TRANSLATIONS`. +- `backend/src/common/fdi.ts` — `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` sentinels, mirroring the + frontend's `prosthesisTree.ts` convention so both sides speak the same jaw target. +- `backend/src/modules/voice/voice.types.ts` — `ProsthesisAssignment` replaces + `ProsthesisIntent` (default+overrides retired, decision 35); `UnresolvedReason` gains + `prosthesis_type_ambiguous`, `arch_not_spoken`, `code_not_valid_for_target`, loses + `tooth_not_selected`; `UnresolvedItem.assignmentIndex` added (decision 50). +- `backend/src/modules/voice/extraction.wire.ts` — wire schema carries `prosthesis: + WireProsthesisAssignment[]`; `WireToothIntent.arch` gains `'both'`. +- `backend/src/modules/voice/extraction.resolver.ts` — rewritten: `resolveAssignmentTarget` + (tooth vs. jaw, mirrors the old `tooth_missing_quadrant`/new `arch_not_spoken` split), + `classifyTypeCode` (leaf / category / subcategory, disjoint namespaces), and + `resolveProsthesisAssignment` composing both plus the region-validity check (deferred for a + mixed-region category — `removable` today, decision 49 — computed generically from the + catalog's own chart regions rather than hardcoding the category name). `resolveVoiceIntent` + forces `treatmentType` to `prosthesis` when any assignment resolves a target (decision 41). + `resolveProsthesis`/`ResolvedProsthesis` retired outright. +- `backend/src/modules/voice/extraction.prompt.ts` — `prosthesisTree()` renders the catalog as + CATEGORY → (SUBCATEGORY →) leaf from the data `buildCatalog` supplies; no catalog knowledge + hardcoded in the prompt text itself. +- `backend/src/modules/voice/voice.providers.ts`, `voice.service.ts` — `ExtractionCatalog` + carries the full leaf shape plus `prosthesisCategories`/`prosthesisSubcategories`; + `buildCatalog` no longer strips `category`/`subcategory`/`chartRegion`/`stackGroup`. +- `backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts` — + `listCategories()`/`listSubcategories()`, resolved through `CatalogLabelService` like every + other catalog label. +- Jest: `extraction.resolver.spec.ts` rewritten around `resolveProsthesisAssignment` (stacks, + jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the + live `PROSTHESIS_TYPES`, region validity both ways, the `removable` deferral, assignment-index + attribution); `extraction.wire.spec.ts` and `openrouter.provider.spec.ts` updated for the new + wire shape. 209 backend tests pass (121 in `modules/voice`). + +### Frontend + +- `frontend/src/lib/voice/audioFormat.ts` — `pickRecordingMimeType`'s final fallback is now `''` + instead of `null` (item 1's Safari fix — modern Safari's `isTypeSupported` can reject every + preferred container yet still record when let choose). +- `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` — `voiceForEditor` now also + requires `isMediaRecorderSupported()`; `applyVoiceResult` rewritten: teeth vs. prosthesis are + mutually exclusive per `isLabDependentResult`, the prosthesis stack is built through + `prosthesisTargetLines` (which routes every leaf through `applyLeafToJobs`), and jaw targets + write `LabCaseToothProsthesisDraft` rows keyed on the `UA`/`LA` sentinels — no separate + arch-specific code path needed beyond what `prosthesisTree.ts` already provides. +- `frontend/src/types/voice.ts` — `VoiceProsthesisAssignment[]` replaces the byTooth map; + `VoiceUnresolvedItem.assignmentIndex`. +- `frontend/src/components/treatment/voiceReviewRows.ts` — rewritten: `isLabDependentResult`, + merged-row `voiceRowAvailability`, `withChosenArch`/`withChosenProsthesisLeaf` (decision 50), + `prosthesisTargetLines` (previews the stack via `applyLeafToJobs`, names refused jobs), + `joblessProsthesisTargets` (decision 40 — named, struck through, never silently dropped or + silently applied), `prosthesisChartData` (crown/root/arch tints for the merged row's chart). +- `frontend/src/components/ui/treatment/VoiceReviewSheet.tsx` — merged "Teeth and prosthesis" + row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each + folding through their own `voiceReviewRows` helper; `voiceProsthesisIncomplete` warning + removed (all-or-nothing retired). +- i18n: `en.json`/`fa.json`/`nl.json` — `voiceUnresolved.*` updated for the new/retired reasons, + `voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused` added, + `voiceProsthesisIncomplete` removed. +- `frontend/package.json`, `frontend/vitest.config.ts` — `vitest@3.2.7` (pinned to a version + whose peer `@types/node` range still includes the repo's `^20`; vitest 4/5 require `>=22`), + `npm run test` → `vitest run`, alias-only config (`@` → `src/`). +- `frontend/src/components/treatment/prosthesisTree.spec.ts`, + `voiceReviewRows.spec.ts` — 37 Vitest cases covering stack legality, `applyLeafToJobs` + precedence, `toothRegionColors`, row availability, chip folding, the merged-row preview, and + the jobless/pending distinction. +- `CLAUDE.md` — Tests section rewritten; frontend command table gains `npx vitest run`. + +### Verification run + +- `cd backend && npm test` — 209/209 pass (121 in `modules/voice`). +- `cd backend && npm run build` — clean (after `npm install`, which pulled in `@sentry/nestjs` + that `node_modules` was missing — unrelated to this change, pre-existing on this checkout). +- `cd backend && npx prisma generate` — succeeds against the updated schema (no DB needed); + confirms `CatalogEntityKind.PROSTHESIS_CATEGORY`/`PROSTHESIS_SUBCATEGORY` compile everywhere + they're used. +- `cd frontend && npx tsc --noEmit` — clean. +- `cd frontend && npx vitest run` — 37/37 pass. +- `cd frontend && npm run build` — production build succeeds. +- ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in + `TreatmentWorkspace.tsx`, unrelated to this change, left untouched). + +### Not run (environment limitation, not a design gap) + +- `npm run prisma:migrate && npm run prisma:seed` against a live Postgres — this sandbox has no + running Docker daemon (`docker info` never came up after several minutes and `open -a Docker` + did not launch it), so the migration was never applied to a database and the new + `CatalogTranslation` rows were never seeded. The migration SQL and seed data are written and + reviewed against the one existing precedent in this repo; **run both before merging**. +- The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter + API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither + available in this session. + +### Deviations from spec / judgement calls made while implementing + +- **A target with empty `types[]` still resolves as a target**, with `types: []` on its + assignment — not excluded from the assignment's `targets` array. The sheet (frontend) treats + `types.length === 0` with no matching `prosthesis_type_ambiguous` unresolved item as "jobless, + struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as + "pending a material pick" instead. This keeps the wire contract simple (no extra field) at the + cost of the frontend doing that one bit of inference from `unresolved` — documented in both + `extraction.resolver.ts` and `voiceReviewRows.ts`. +- **The mixed-region deferral (decision 49) is computed generically** from each category's + actual leaf chart-regions (`regions.size === 1` → validate immediately, else defer) rather than + special-cased for `removable` by name. This also defers `implant` (which spans `root` and + `crown` via `screw_retained`) — the spec's prose says "only `removable` today" as an + observation about the current catalog, not an instruction to hardcode that name, and deferring + a category no test forbids deferring is the safer default. +- **`voice.dto.ts`/`voice.controller.ts` needed no changes.** The scout's item 6 ("validation for + new schema shape") does not apply: the DTO validates the client's audio submission + (`audio`/`format`/`timeZone`/`durationMs`/`locale`), which is unrelated to the LLM's structured + output shape that changed. Confirmed by reading both files; not a silent skip. + +--- + +## 2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete + +Recorded by hand: the `record-stop:Ship` agent failed on the session spend limit before it +could write this. + +**Stop reason:** `no MR template found in dyolink — refusing to invent a description.` Expected +and flagged at preflight: `.gitea/` holds only `workflows/`, and the remote is Gitea, not GitLab. +A `--dry` run pushes nothing regardless. + +| Phase | Result | +|---|---| +| Gap-check | **clear** — the five gaps closed in `77e2ed4` were accepted | +| Scout | 1 of 3 Explore sweeps returned; 2 ended without structured output | +| Implement | **done** — all 19 work items, ~3,100 insertions across 25 files + 4 new files | +| Gate | **green** — backend 16 suites / 209 tests, `nest build` 0, `prisma validate` ok; frontend Vitest 2 files / 37 tests, `tsc --noEmit` 0, `next build` 0 | +| Refute | **incomplete** — `correctness` refuted with 2 findings; `regression-risk` never ran (spend limit) | +| Ship | stopped, no template. `clerk` also failed on the spend limit | + +Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed. + +### Two confirmed findings — verified by hand, not taken on the critic's word + +1. **`VoiceReviewSheet.tsx:169` — a picked tooth chip is silently dropped.** `pickCandidate` + sets `teeth: prev.teeth || available.teeth`, but `available` is memoised from `effective`, + which depends on `chosenTeeth`. Both `setChosenTeeth` and `setSelection` run in the same + handler, so the updater closes over the pre-pick `available`, where `available.teeth` is + `false` because `result.teeth` is empty. `prev.teeth` is false too, so it stays false + permanently. The row then renders with the tooth on the chart and the box unticked, and Apply + drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو". +2. **`VoiceReviewSheet.tsx:213` + `TreatmentWorkspace.tsx:2215` — decision 41 not implemented.** + The treatmentType row is a plain `toggle('treatmentType')` with no lock, and + `applyVoiceResult` derives `labDependent` from `result.treatmentType` rather than the + `detail.treatmentType` it writes. Untick the type row on a forced-prosthesis recording and a + `restoration` detail is saved carrying prosthesis lab rows — the state decision 41 exists to + make unreachable. + +### Lint + +Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all +pre-existing in `TreatmentWorkspace.tsx`. The repo-wide backend baseline (1,288 errors, 1,107 +prettier-fixable) is untouched by this diff. + +## Next steps / open questions + +- The diff is **unreviewed on the `regression-risk` lens**. Green gate plus one refuting lens is + not the design's bar; the script continued only because the threshold counts refusals and the + second critic errored rather than refused. +- Fix the two findings, then resume from Refute so both lenses grade the same diff. +- The Ship phase cannot pass in this repo until there is an MR template, or until the MR is + opened by hand. Opening one is gated regardless. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a3b8c92..66fb8d5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -926,20 +926,25 @@ "voiceProcessing": "Reading the recording…", "voiceReviewTitle": "Check what was understood", "voiceNothingExtracted": "Nothing usable was picked up from that recording.", - "voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.", "voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.", "voiceNotUnderstood": "Not understood", "voicePickTooth": "Which tooth?", + "voicePickJaw": "Which jaw?", + "voiceTeethAndProsthesis": "Teeth and prosthesis", + "voiceNoProsthesisHeard": "no prosthesis heard", + "voiceStackRefused": "not added — cannot be combined", "voiceDiscard": "Discard", "voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}", "voiceUnresolved": { "not_permanent_tooth": "not a permanent tooth", "position_out_of_range": "not a valid tooth position", "tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”", + "prosthesis_type_ambiguous": "a general term, not a specific material — pick one below", + "arch_not_spoken": "which jaw was this for? — pick below", + "code_not_valid_for_target": "does not fit that tooth or jaw", "malformed": "could not be read", "span_not_same_arch": "a bridge cannot span both jaws", "unknown_catalog_code": "not in this clinic’s list", - "tooth_not_selected": "that tooth is not part of this detail", "invalid_date": "not a usable date" }, "voiceFailed": "Voice entry failed. Please try again." diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 48e0910..9dd9436 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -927,20 +927,25 @@ "voiceProcessing": "در حال پردازش گفتار…", "voiceReviewTitle": "بررسی آنچه دریافت شد", "voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.", - "voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.", "voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.", "voiceNotUnderstood": "شناسایی نشد", "voicePickTooth": "کدام دندان؟", + "voicePickJaw": "کدام فک؟", + "voiceTeethAndProsthesis": "دندان‌ها و پروتز", + "voiceNoProsthesisHeard": "پروتزی شنیده نشد", + "voiceStackRefused": "افزوده نشد — قابل ترکیب نیست", "voiceDiscard": "انصراف", "voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}", "voiceUnresolved": { "not_permanent_tooth": "دندان دائمی نیست", "position_out_of_range": "شماره دندان معتبر نیست", "tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»", + "prosthesis_type_ambiguous": "یک عنوان کلی است، نه یک متریال مشخص — یکی را از پایین انتخاب کنید", + "arch_not_spoken": "برای کدام فک بود؟ — از پایین انتخاب کنید", + "code_not_valid_for_target": "با این دندان یا فک همخوانی ندارد", "malformed": "قابل خواندن نبود", "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", "unknown_catalog_code": "در فهرست این مطب نیست", - "tooth_not_selected": "این دندان بخشی از این مورد نیست", "invalid_date": "تاریخ قابل استفاده نیست" }, "voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید." diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 05a0758..08f94ed 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -926,20 +926,25 @@ "voiceProcessing": "Opname wordt gelezen…", "voiceReviewTitle": "Controleer wat is begrepen", "voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.", - "voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.", "voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.", "voiceNotUnderstood": "Niet begrepen", "voicePickTooth": "Welk element?", + "voicePickJaw": "Welke kaak?", + "voiceTeethAndProsthesis": "Elementen en prothese", + "voiceNoProsthesisHeard": "geen prothese gehoord", + "voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd", "voiceDiscard": "Verwerpen", "voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}", "voiceUnresolved": { "not_permanent_tooth": "geen blijvend element", "position_out_of_range": "geen geldige elementpositie", "tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”", + "prosthesis_type_ambiguous": "een algemene term, geen specifiek materiaal — kies hieronder", + "arch_not_spoken": "voor welke kaak was dit? — kies hieronder", + "code_not_valid_for_target": "past niet bij dat element of die kaak", "malformed": "kon niet worden gelezen", "span_not_same_arch": "een brug kan niet over beide kaken lopen", "unknown_catalog_code": "staat niet in de lijst van deze praktijk", - "tooth_not_selected": "dat element hoort niet bij dit onderdeel", "invalid_date": "geen bruikbare datum" }, "voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bdc41b9..cc6a0f7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,7 +35,8 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.7" } }, "node_modules/@alloc/quick-lru": { @@ -79,7 +80,6 @@ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -318,6 +318,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1056,6 +1498,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -1303,7 +1746,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1325,7 +1767,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1341,7 +1782,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", @@ -1392,7 +1832,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", @@ -1796,7 +2235,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3204,6 +3642,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3267,6 +3716,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3321,7 +3777,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3394,7 +3849,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -3914,11 +4368,137 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -3928,25 +4508,29 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3957,13 +4541,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3976,6 +4562,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -3985,6 +4572,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -3993,13 +4581,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4016,6 +4606,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -4029,6 +4620,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4041,6 +4633,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -4055,6 +4648,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -4064,20 +4658,21 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4129,6 +4724,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -4146,6 +4742,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4161,7 +4758,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-styles": { "version": "4.3.0", @@ -4356,6 +4954,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmmirror.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4432,7 +5040,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -4509,7 +5116,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4528,7 +5134,18 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/call-bind": { "version": "1.0.8", @@ -4629,6 +5246,23 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -4646,11 +5280,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0" } @@ -4712,7 +5357,8 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/commondir": { "version": "1.0.1", @@ -4982,6 +5628,16 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", @@ -5330,6 +5986,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -5358,7 +6056,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5544,7 +6241,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5801,10 +6497,21 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5880,7 +6587,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fastq": { "version": "1.20.1", @@ -6976,6 +7684,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -6990,6 +7699,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7455,6 +8165,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7495,7 +8212,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/merge2": { "version": "1.4.1", @@ -7570,6 +8288,7 @@ "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.8.0.tgz", "integrity": "sha512-2cT9+goJfBhtMz+gJqejSf09ClgmYhPhccRb/fb0ztVbixt0BkO8mRuI26FoPPuUNkRk6iEDryWAIouc5W8eJA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "jest-worker": "^27.4.5", @@ -7700,14 +8419,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmmirror.com/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -7833,6 +8552,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -8180,6 +8910,23 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", @@ -8339,7 +9086,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8349,7 +9095,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8362,7 +9107,6 @@ "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8378,8 +9122,7 @@ "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-qr-code": { "version": "2.2.0", @@ -8399,7 +9142,6 @@ "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8452,8 +9194,7 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8520,6 +9261,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8610,7 +9352,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -8741,6 +9482,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -8777,6 +9519,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -8788,7 +9531,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "6.3.1", @@ -9005,6 +9749,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -9038,6 +9789,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9056,6 +9808,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9068,6 +9821,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -9090,6 +9850,13 @@ "node": ">=6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9240,6 +10007,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -9324,6 +10111,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9353,6 +10141,20 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9394,7 +10196,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9402,6 +10203,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9572,7 +10403,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9767,11 +10597,234 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -9790,6 +10843,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz", "integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -9832,6 +10886,7 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -9841,6 +10896,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9959,6 +11015,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10021,7 +11094,6 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index f6618b5..9930109 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev -p 3001", "build": "next build", "start": "next start -p 3001", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -36,6 +37,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.7" } } diff --git a/frontend/src/components/treatment/prosthesisTree.spec.ts b/frontend/src/components/treatment/prosthesisTree.spec.ts new file mode 100644 index 0000000..0de7e01 --- /dev/null +++ b/frontend/src/components/treatment/prosthesisTree.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; +import { + ARCH_TOOTH_LOWER, + ARCH_TOOTH_UPPER, + applyLeafToJobs, + canStackLeaf, + catalogByCode, + effectiveChartRegion, + PARTIAL_DENTURE_CODE, + toothRegionColors, +} from './prosthesisTree'; + +const CATALOG: ProsthesisCatalogEntry[] = [ + { + code: 'pfm_crown', + sortOrder: 1, + label: 'PFM Crown', + category: 'crown', + subcategory: '', + chartRegion: 'crown', + stackGroup: 'restoration', + }, + { + code: 'monolithic_zirconia', + sortOrder: 2, + label: 'Monolithic Zirconia', + category: 'crown', + subcategory: '', + chartRegion: 'crown', + stackGroup: 'restoration', + }, + { + code: 'zirconia_abutment', + sortOrder: 3, + label: 'Zirconia Abutment', + category: 'implant', + subcategory: '', + chartRegion: 'root', + stackGroup: 'implant', + }, + { + code: 'screw_retained', + sortOrder: 4, + label: 'Screw Retained', + category: 'implant', + subcategory: '', + chartRegion: 'crown', + stackGroup: 'implant', + }, + { + code: 'cast_post_core', + sortOrder: 5, + label: 'Cast Post & Core', + category: 'post_core', + subcategory: '', + chartRegion: 'root', + stackGroup: 'post_core', + }, + { + code: PARTIAL_DENTURE_CODE, + sortOrder: 6, + label: 'Partial Denture', + category: 'removable', + subcategory: '', + chartRegion: 'arch', + stackGroup: 'arch', + }, + { + code: 'night_guard_soft', + sortOrder: 7, + label: 'Night Guard', + category: 'appliance', + subcategory: 'night_guard', + chartRegion: 'arch', + stackGroup: 'arch', + }, +]; + +const byCode = catalogByCode(CATALOG); + +describe('canStackLeaf', () => { + it('allows an implant plus a crown restoration on the same tooth', () => { + expect(canStackLeaf(['zirconia_abutment'], 'pfm_crown', byCode)).toBe(true); + }); + + it('refuses a post & core alongside an implant', () => { + expect(canStackLeaf(['zirconia_abutment'], 'cast_post_core', byCode)).toBe(false); + }); + + it('refuses an implant alongside a post & core', () => { + expect(canStackLeaf(['cast_post_core'], 'zirconia_abutment', byCode)).toBe(false); + }); + + it('refuses a second restoration once screw-retained already paints the crown', () => { + expect(canStackLeaf(['screw_retained'], 'pfm_crown', byCode)).toBe(false); + }); + + it('allows two restorations to replace each other (no illegal stack)', () => { + expect(canStackLeaf(['pfm_crown'], 'monolithic_zirconia', byCode)).toBe(true); + }); + + it('refuses a code the catalog does not have', () => { + expect(canStackLeaf([], 'gold_foil', byCode)).toBe(false); + }); +}); + +describe('applyLeafToJobs', () => { + it('stacks an implant and a crown restoration on one tooth', () => { + const jobs = applyLeafToJobs(['zirconia_abutment'], 'pfm_crown', byCode); + expect(jobs.sort()).toEqual(['pfm_crown', 'zirconia_abutment'].sort()); + }); + + it('a same-stack-group leaf replaces rather than stacking beside the old one', () => { + // PFM previewed on 13, then PFZ heard for the same tooth: the second live test this repo + // ran on real recordings — the first stack rule bug that had to be fixed. + const jobs = applyLeafToJobs(['pfm_crown'], 'monolithic_zirconia', byCode); + expect(jobs).toEqual(['monolithic_zirconia']); + }); + + it('leaves the jobs untouched when the stack rules refuse the leaf', () => { + const jobs = applyLeafToJobs(['zirconia_abutment'], 'cast_post_core', byCode); + expect(jobs).toEqual(['zirconia_abutment']); + }); +}); + +describe('effectiveChartRegion', () => { + it('overrides partial_denture to crown even though its catalog chartRegion is arch', () => { + expect(effectiveChartRegion({ code: PARTIAL_DENTURE_CODE, chartRegion: 'arch' })).toBe('crown'); + }); + + it('leaves every other code as the catalog says', () => { + expect(effectiveChartRegion({ code: 'zirconia_abutment', chartRegion: 'root' })).toBe('root'); + }); +}); + +describe('toothRegionColors', () => { + it('paints a crown-region code into crownColors only', () => { + const { crown, root } = toothRegionColors( + [{ tooth: '12', prosthesisTypeCode: 'pfm_crown' }], + CATALOG, + ); + expect(crown['12']).toBeTruthy(); + expect(root['12']).toBeUndefined(); + }); + + it('paints a root-region code into rootColors only', () => { + const { crown, root } = toothRegionColors( + [{ tooth: '12', prosthesisTypeCode: 'zirconia_abutment' }], + CATALOG, + ); + expect(root['12']).toBeTruthy(); + expect(crown['12']).toBeUndefined(); + }); + + it('paints both crown and root for an arch-region code on a real tooth', () => { + const { crown, root } = toothRegionColors( + [{ tooth: '12', prosthesisTypeCode: 'night_guard_soft' }], + CATALOG, + ); + expect(crown['12']).toBeTruthy(); + expect(root['12']).toBeTruthy(); + }); + + it('skips jaw sentinel rows — they have no crown or root to paint', () => { + const { crown, root } = toothRegionColors( + [ + { tooth: ARCH_TOOTH_UPPER, prosthesisTypeCode: 'night_guard_soft' }, + { tooth: ARCH_TOOTH_LOWER, prosthesisTypeCode: 'night_guard_soft' }, + ], + CATALOG, + ); + expect(Object.keys(crown)).toHaveLength(0); + expect(Object.keys(root)).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/treatment/voiceReviewRows.spec.ts b/frontend/src/components/treatment/voiceReviewRows.spec.ts new file mode 100644 index 0000000..1e2f706 --- /dev/null +++ b/frontend/src/components/treatment/voiceReviewRows.spec.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; +import type { VoiceExtractionResult } from '@/types/voice'; +import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from './prosthesisTree'; +import { + countSelected, + hasAnythingToApply, + initialVoiceSelection, + isLabDependentResult, + joblessProsthesisTargets, + prosthesisChartData, + prosthesisTargetLines, + voiceRowAvailability, + withChosenArch, + withChosenProsthesisLeaf, + withChosenTeeth, +} from './voiceReviewRows'; + +const CATALOG: ProsthesisCatalogEntry[] = [ + { + code: 'pfm_crown', + sortOrder: 1, + label: 'PFM Crown', + category: 'crown', + subcategory: '', + chartRegion: 'crown', + stackGroup: 'restoration', + }, + { + code: 'zirconia_abutment', + sortOrder: 2, + label: 'Zirconia Abutment', + category: 'implant', + subcategory: '', + chartRegion: 'root', + stackGroup: 'implant', + }, + { + code: 'cast_post_core', + sortOrder: 3, + label: 'Cast Post & Core', + category: 'post_core', + subcategory: '', + chartRegion: 'root', + stackGroup: 'post_core', + }, + { + code: 'night_guard_soft', + sortOrder: 4, + label: 'Night Guard', + category: 'appliance', + subcategory: 'night_guard', + chartRegion: 'arch', + stackGroup: 'arch', + }, +]; + +const LAB_DEPENDENT = new Set(['prosthesis']); + +function baseResult(overrides: Partial = {}): VoiceExtractionResult { + return { + transcript: '', + treatmentType: 'restoration', + teeth: [], + toothSelectionGroups: [], + comment: null, + prosthesisAssignments: [], + labId: null, + labMatchExact: false, + dueDate: null, + unresolved: [], + ...overrides, + }; +} + +describe('isLabDependentResult', () => { + it('is true only when the resolved type is in the labDependent set', () => { + expect(isLabDependentResult(baseResult({ treatmentType: 'prosthesis' }), LAB_DEPENDENT)).toBe( + true, + ); + expect(isLabDependentResult(baseResult({ treatmentType: 'restoration' }), LAB_DEPENDENT)).toBe( + false, + ); + }); +}); + +describe('voiceRowAvailability', () => { + it('shows a plain teeth row for a non-lab-dependent type', () => { + const result = baseResult({ teeth: ['14'] }); + const available = voiceRowAvailability(result, LAB_DEPENDENT); + expect(available.teeth).toBe(true); + expect(available.prosthesis).toBe(false); + }); + + it('merges teeth and prosthesis into one row for a lab-dependent type', () => { + const result = baseResult({ + treatmentType: 'prosthesis', + prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }], + }); + const available = voiceRowAvailability(result, LAB_DEPENDENT); + expect(available.teeth).toBe(false); + expect(available.prosthesis).toBe(true); + }); + + it('shows the merged row for a jaw appliance with no teeth at all', () => { + const result = baseResult({ + treatmentType: 'prosthesis', + prosthesisAssignments: [ + { targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' }, + ], + }); + expect(voiceRowAvailability(result, LAB_DEPENDENT).prosthesis).toBe(true); + }); +}); + +describe('initialVoiceSelection', () => { + it('ticks an available lab-dependent prosthesis row even when the stack is incomplete', () => { + // All-or-nothing is retired (decision 40) — an incomplete map no longer blocks a tick. + const result = baseResult({ + treatmentType: 'prosthesis', + prosthesisAssignments: [{ targets: ['12'], types: [], spoken: '' }], + }); + expect(initialVoiceSelection(result, LAB_DEPENDENT).prosthesis).toBe(true); + }); + + it('never ticks lab when the match was inexact', () => { + const result = baseResult({ labId: 'lab-1', labMatchExact: false }); + expect(initialVoiceSelection(result, LAB_DEPENDENT).lab).toBe(false); + }); +}); + +describe('countSelected', () => { + it('intersects the selection with availability rather than counting raw ticks', () => { + const selection = { treatmentType: true, teeth: true, comment: true, prosthesis: true, lab: true, dueDate: true }; + const available = { treatmentType: true, teeth: false, comment: true, prosthesis: false, lab: true, dueDate: false }; + expect(countSelected(selection, available)).toBe(3); + }); +}); + +describe('withChosenTeeth', () => { + it('folds a plain candidate into the top-level teeth list', () => { + const result = baseResult({ teeth: ['14'] }); + const next = withChosenTeeth(result, ['26']); + expect(next.teeth).toEqual(['14', '26']); + }); + + it('folds an assignment-scoped candidate into that assignment target list, not the plain list', () => { + const result = baseResult({ + treatmentType: 'prosthesis', + prosthesisAssignments: [{ targets: [], types: ['pfm_crown'], spoken: 'دندون دو روکش' }], + }); + const next = withChosenTeeth(result, ['12'], 0); + expect(next.prosthesisAssignments[0].targets).toEqual(['12']); + expect(next.teeth).toEqual([]); + }); +}); + +describe('withChosenArch', () => { + it('folds a picked jaw into the assignment that named no jaw at all', () => { + const result = baseResult({ + treatmentType: 'prosthesis', + prosthesisAssignments: [{ targets: [], types: ['night_guard_soft'], spoken: 'نایت گارد' }], + }); + const next = withChosenArch(result, 0, 'upper'); + expect(next.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]); + }); + + it('picking both jaws is how a both-jaw appliance is expressed', () => { + let next = withChosenArch( + baseResult({ prosthesisAssignments: [{ targets: [], types: [], spoken: '' }] }), + 0, + 'upper', + ); + next = withChosenArch(next, 0, 'lower'); + expect(next.prosthesisAssignments[0].targets.sort()).toEqual( + [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER].sort(), + ); + }); +}); + +describe('withChosenProsthesisLeaf', () => { + it('supplies the missing leaf to the assignment that only named a category', () => { + const result = baseResult({ + prosthesisAssignments: [{ targets: ['12'], types: [], spoken: 'روکش' }], + }); + const next = withChosenProsthesisLeaf(result, 0, 'pfm_crown'); + expect(next.prosthesisAssignments[0].types).toEqual(['pfm_crown']); + // A resolved assignment forces the type — the row locks exactly as the backend does. + expect(next.treatmentType).toBe('prosthesis'); + }); +}); + +describe('prosthesisTargetLines', () => { + it('previews the stack that will actually land, through applyLeafToJobs', () => { + const lines = prosthesisTargetLines( + [{ targets: ['12'], types: ['zirconia_abutment', 'pfm_crown'], spoken: '' }], + CATALOG, + ); + expect(lines).toEqual([{ target: '12', isJaw: false, applied: ['zirconia_abutment', 'pfm_crown'], refused: [] }]); + }); + + it('names a refused job rather than silently dropping or silently applying it', () => { + const lines = prosthesisTargetLines( + [{ targets: ['12'], types: ['zirconia_abutment', 'cast_post_core'], spoken: '' }], + CATALOG, + ); + expect(lines[0].applied).toEqual(['zirconia_abutment']); + expect(lines[0].refused).toEqual(['cast_post_core']); + }); + + it('marks a jaw target as such', () => { + const lines = prosthesisTargetLines( + [{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' }], + CATALOG, + ); + expect(lines[0].isJaw).toBe(true); + }); +}); + +describe('joblessProsthesisTargets', () => { + it('names a tooth left in the plain teeth list with no matching assignment', () => { + const result = baseResult({ + teeth: ['12', '13'], + prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }], + }); + expect(joblessProsthesisTargets(result)).toEqual(['13']); + }); + + it('names an assignment target whose types were empty from the start', () => { + const result = baseResult({ + prosthesisAssignments: [{ targets: ['13'], types: [], spoken: '' }], + }); + expect(joblessProsthesisTargets(result)).toEqual(['13']); + }); + + it('does not call a target jobless while it is pending a material pick', () => { + const result = baseResult({ + prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }], + unresolved: [{ spoken: 'روکش', reason: 'prosthesis_type_ambiguous', assignmentIndex: 0 }], + }); + expect(joblessProsthesisTargets(result)).toEqual([]); + }); +}); + +describe('prosthesisChartData', () => { + it('derives the arch highlight from applied jaw jobs, upper and lower alike', () => { + const lines = prosthesisTargetLines( + [{ targets: [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER], types: ['night_guard_soft'], spoken: '' }], + CATALOG, + ); + const data = prosthesisChartData(lines, [], CATALOG); + expect(data.archHighlight).toBe('both'); + expect(data.selectedTeeth.size).toBe(0); + }); + + it('collects every real tooth, applied or jobless, into selectedTeeth', () => { + const lines = prosthesisTargetLines( + [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }], + CATALOG, + ); + const data = prosthesisChartData(lines, ['13'], CATALOG); + expect([...data.selectedTeeth].sort()).toEqual(['12', '13']); + }); +}); + +describe('hasAnythingToApply', () => { + it('is false when the recording produced nothing usable', () => { + expect(hasAnythingToApply(baseResult({ treatmentType: null }), LAB_DEPENDENT)).toBe(false); + }); + + it('is true once any row is available', () => { + expect(hasAnythingToApply(baseResult({ teeth: ['14'] }), LAB_DEPENDENT)).toBe(true); + }); +}); diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts index 9d0516c..6170c29 100644 --- a/frontend/src/components/treatment/voiceReviewRows.ts +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -1,35 +1,73 @@ import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; +import { + ARCH_TOOTH_LOWER, + ARCH_TOOTH_UPPER, + applyLeafToJobs, + archSentinels, + canStackLeaf, + catalogByCode, + isArchSentinel, + toothRegionColors, + type ArchTarget, +} from '@/components/treatment/prosthesisTree'; import type { FdiToothId } from '@/types/treatment'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { VoiceApplySelection, VoiceExtractionResult, - VoiceProsthesisResult, + VoiceProsthesisAssignment, + VoiceUnresolvedItem, } from '@/types/voice'; -/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */ -export function voiceRowAvailability(result: VoiceExtractionResult) { +/** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */ +export function isLabDependentResult( + result: VoiceExtractionResult, + labDependentCodes: ReadonlySet, +): boolean { + return Boolean(result.treatmentType && labDependentCodes.has(result.treatmentType)); +} + +function hasProsthesisWork(result: VoiceExtractionResult): boolean { + return result.prosthesisAssignments.some((a) => a.targets.length > 0 || a.types.length > 0); +} + +/** + * Which rows the review sheet renders at all — a row with nothing extracted is noise. + * + * Teeth and prosthesis are never both available: a lab-dependent type merges them into one + * `prosthesis` row (decision 39), because two independent ticks can save an empty detail — + * `persistDraft` prunes a lab-dependent detail to its jobs. + */ +export function voiceRowAvailability( + result: VoiceExtractionResult, + labDependentCodes: ReadonlySet, +) { + const labDependent = isLabDependentResult(result, labDependentCodes); return { treatmentType: result.treatmentType != null, - teeth: result.teeth.length > 0, + teeth: !labDependent && result.teeth.length > 0, + prosthesis: labDependent && (result.teeth.length > 0 || hasProsthesisWork(result)), comment: Boolean(result.comment?.trim()), - prosthesis: result.prosthesis != null, lab: result.labId != null, dueDate: result.dueDate != null, }; } /** - * Everything available ticks itself, with two exceptions: an inexactly-matched lab, because - * it is the one extracted value whose error leaves the building; and an incomplete - * prosthesis map, which cannot ship at all and would just move the failure to dispatch. + * Everything available ticks itself, with one exception: an inexactly-matched lab, because it + * is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps + * are retired (decision 40) — an incomplete stack no longer blocks a tick. */ -export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection { - const available = voiceRowAvailability(result); +export function initialVoiceSelection( + result: VoiceExtractionResult, + labDependentCodes: ReadonlySet, +): VoiceApplySelection { + const available = voiceRowAvailability(result, labDependentCodes); return { treatmentType: available.treatmentType, teeth: available.teeth, comment: available.comment, - prosthesis: available.prosthesis && result.prosthesis?.complete === true, + prosthesis: available.prosthesis, lab: available.lab && result.labMatchExact, dueDate: available.dueDate, }; @@ -48,37 +86,75 @@ export function countSelected( ).length; } -/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */ -function recheckProsthesis( - prosthesis: VoiceProsthesisResult, - teeth: readonly FdiToothId[], -): VoiceProsthesisResult { - const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]); - return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 }; +/** Every resolved assignment target, across every assignment. */ +function allAssignmentTargets(result: VoiceExtractionResult): Set { + return new Set(result.prosthesisAssignments.flatMap((a) => a.targets)); +} + +/** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */ +function withProsthesisForced(result: VoiceExtractionResult): VoiceExtractionResult { + const forced = result.prosthesisAssignments.some((a) => a.targets.length > 0); + return forced ? { ...result, treatmentType: 'prosthesis' } : result; } /** - * Fold the candidate picks into the result, so nothing downstream has to know chips exist. + * Fold a picked FDI/quadrant candidate into the result. + * + * `assignmentIndex` set: the chip came from resolving a `prosthesisAssignments` entry, so the + * pick becomes that assignment's target — inheriting its `types`, not a jobless tooth + * (decision 50). `assignmentIndex` absent: the chip came from the plain `teeth` list and folds + * in there, exactly as before. * * Union rather than toggle: a candidate can coincidentally be a tooth the recording already - * produced ("۱۲ و دو"), and tapping it must not deselect that one. + * produced, and tapping it must not deselect that one. */ export function withChosenTeeth( result: VoiceExtractionResult, chosen: readonly FdiToothId[], + assignmentIndex?: number, ): VoiceExtractionResult { if (chosen.length === 0) return result; - const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[]; + if (assignmentIndex != null) { + const assignments = result.prosthesisAssignments.map((a, i) => + i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, ...chosen])] } : a, + ); + return withProsthesisForced({ ...result, prosthesisAssignments: assignments }); + } + const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[]; return { ...result, teeth, toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups), - prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null, }; } +/** Fold a picked jaw ('upper' / 'lower') into the assignment that named no jaw at all. */ +export function withChosenArch( + result: VoiceExtractionResult, + assignmentIndex: number, + arch: 'upper' | 'lower', +): VoiceExtractionResult { + const sentinel = arch === 'upper' ? ARCH_TOOTH_UPPER : ARCH_TOOTH_LOWER; + const assignments = result.prosthesisAssignments.map((a, i) => + i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, sentinel])] } : a, + ); + return withProsthesisForced({ ...result, prosthesisAssignments: assignments }); +} + +/** Fold a picked leaf into the assignment that only named a category or subcategory. */ +export function withChosenProsthesisLeaf( + result: VoiceExtractionResult, + assignmentIndex: number, + leafCode: string, +): VoiceExtractionResult { + const assignments = result.prosthesisAssignments.map((a, i) => + i === assignmentIndex ? { ...a, types: [...new Set([...a.types, leafCode])] } : a, + ); + return withProsthesisForced({ ...result, prosthesisAssignments: assignments }); +} + /** Teeth that are part of a bridge, for the read-only chart's connection marks. */ export function connectedTeethFromResult(result: VoiceExtractionResult): Set { const connected = new Set(); @@ -89,7 +165,138 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set(); + + for (const assignment of assignments) { + for (const target of assignment.targets) { + const entry = byTarget.get(target) ?? { applied: [], refused: [] }; + for (const code of assignment.types) { + if (canStackLeaf(entry.applied, code, byCode)) { + entry.applied = applyLeafToJobs(entry.applied, code, byCode); + } else { + entry.refused.push(code); + } + } + byTarget.set(target, entry); + } + } + + return [...byTarget.entries()].map(([target, { applied, refused }]) => ({ + target, + isJaw: isArchSentinel(target), + applied, + refused, + })); } + +/** + * FDI codes / jaw sentinels named somewhere (an assignment target, or the plain `teeth` list) + * but ending up with no job at all — struck through in the sheet reading "no prosthesis heard" + * (decision 40). A target still pending a material pick (a `prosthesis_type_ambiguous` chip + * for its assignment) is not jobless; it is simply not resolved yet. + */ +export function joblessProsthesisTargets(result: VoiceExtractionResult): string[] { + const pendingIndexes = new Set( + result.unresolved + .filter((u) => u.reason === 'prosthesis_type_ambiguous' && u.assignmentIndex != null) + .map((u) => u.assignmentIndex as number), + ); + + const jobless = new Set(); + const covered = new Set(); + const pending = new Set(); + + result.prosthesisAssignments.forEach((assignment, index) => { + if (pendingIndexes.has(index)) { + for (const target of assignment.targets) pending.add(target); + } else if (assignment.types.length === 0) { + for (const target of assignment.targets) jobless.add(target); + } else { + for (const target of assignment.targets) covered.add(target); + } + }); + + for (const tooth of result.teeth) { + if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth); + } + + return [...jobless]; +} + +export type VoiceProsthesisChartData = { + crownColors: Partial>; + rootColors: Partial>; + archHighlight: ArchTarget | null; + /** Every real tooth involved — a target with a job, or a jobless one named alongside it. */ + selectedTeeth: Set; +}; + +/** Feeds the merged row's `FdiToothChart` — crown/root tints plus the arch highlight. */ +export function prosthesisChartData( + lines: readonly VoiceProsthesisTargetLine[], + jobless: readonly string[], + catalog: readonly ProsthesisCatalogEntry[], +): VoiceProsthesisChartData { + const rows = lines.flatMap((line) => + line.applied.map((code) => ({ tooth: line.target, prosthesisTypeCode: code })), + ); + const { crown, root } = toothRegionColors(rows, catalog); + + const hasUpper = lines.some((l) => l.target === ARCH_TOOTH_UPPER && l.applied.length > 0); + const hasLower = lines.some((l) => l.target === ARCH_TOOTH_LOWER && l.applied.length > 0); + const archHighlight: ArchTarget | null = + hasUpper && hasLower ? 'both' : hasUpper ? 'upper' : hasLower ? 'lower' : null; + + const selectedTeeth = new Set(); + for (const line of lines) { + if (!line.isJaw) selectedTeeth.add(line.target as FdiToothId); + } + for (const tooth of jobless) { + if (!isArchSentinel(tooth)) selectedTeeth.add(tooth as FdiToothId); + } + + return { crownColors: crown, rootColors: root, archHighlight, selectedTeeth }; +} + +/** Which unresolved items belong to which chip section — the sheet renders both identically. */ +export function unresolvedWithoutAssignment( + result: VoiceExtractionResult, +): VoiceUnresolvedItem[] { + return result.unresolved.filter((u) => u.assignmentIndex == null); +} + +export function unresolvedForAssignment( + result: VoiceExtractionResult, + assignmentIndex: number, +): VoiceUnresolvedItem[] { + return result.unresolved.filter((u) => u.assignmentIndex === assignmentIndex); +} + +/** A recording that produced nothing should say so, not show an empty form of checkboxes. */ +export function hasAnythingToApply( + result: VoiceExtractionResult, + labDependentCodes: ReadonlySet, +): boolean { + return Object.values(voiceRowAvailability(result, labDependentCodes)).some(Boolean); +} + +export { archSentinels, allAssignmentTargets }; diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 9a85bba..6287483 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -31,6 +31,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { voiceApi } from '@/lib/api/voice'; import { useVoiceCapture } from '@/lib/voice/useVoiceCapture'; +import { isMediaRecorderSupported } from '@/lib/voice/audioFormat'; import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet'; import type { VoiceApplySelection, @@ -61,6 +62,10 @@ import { unlinkAdjacentTeeth, } from '@/components/treatment/toothSelectionGroups'; import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; +import { + isLabDependentResult, + prosthesisTargetLines, +} from '@/components/treatment/voiceReviewRows'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { @@ -621,9 +626,18 @@ export function TreatmentWorkspace({ onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))), }); - /** Absence is the unavailable state — the Add button then renders unsplit. */ + /** + * Absence is the unavailable state — the Add button then renders unsplit. Gated on both the + * server's availability response AND the browser's own recording support: without the + * latter check the control rendered on a browser that cannot record and failed on tap + * (the Safari report that started this revision, §2). + */ const voiceForEditor = - voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined; + voiceAvailability?.enabled && + voiceAvailability.locales.includes(locale) && + isMediaRecorderSupported() + ? voice + : undefined; const selectedStandalone = useMemo( () => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null, @@ -2192,7 +2206,13 @@ export function TreatmentWorkspace({ if (selection.treatmentType && result.treatmentType) { detail.treatmentType = result.treatmentType; } - if (selection.teeth) { + + // Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking + // "teeth" independently of "prosthesis" could save an empty detail, since + // `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis` + // alone drives both below; `selection.teeth` only ever applies to the plain row. + const labDependent = isLabDependentResult(result, labDependentCodes); + if (!labDependent && selection.teeth) { detail.teeth = [...result.teeth]; detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ ...group, @@ -2203,6 +2223,25 @@ export function TreatmentWorkspace({ detail.comment = result.comment; } + // The stack that will actually land, built through the same `applyLeafToJobs` the + // manual chart writes through (§7) — a code the stack rules refuse is not applied. + const prosthesisLines = + labDependent && selection.prosthesis + ? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog) + : []; + // An assignment target is a selection: a tooth exists on a prosthesis detail only by + // carrying a job (decision 40) — never from the plain `teeth` field. + const prosthesisTeeth = prosthesisLines + .filter((line) => !line.isJaw && line.applied.length > 0) + .map((line) => line.target as FdiToothId); + if (labDependent && selection.prosthesis) { + detail.teeth = prosthesisTeeth; + detail.toothSelectionGroups = groupsFromFlatTeeth( + prosthesisTeeth, + result.toothSelectionGroups, + ); + } + const nextDetails = [...detailsRef.current, detail]; setDetails(nextDetails); // persistDraft reads detailsRef, and setDetails has not rendered yet. @@ -2212,7 +2251,7 @@ export function TreatmentWorkspace({ // Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a // brand-new unsaved detail can still carry one; it is persisted after the detail is. const wantsLabDraft = - (selection.prosthesis && result.prosthesis) || + prosthesisLines.some((line) => line.applied.length > 0) || (selection.lab && result.labId) || (selection.dueDate && result.dueDate); @@ -2225,26 +2264,19 @@ export function TreatmentWorkspace({ if (selection.dueDate && result.dueDate) { draft.dueDate = result.dueDate; } - if (selection.prosthesis && result.prosthesis) { - // byTooth keys are plain strings; the group's teeth are FdiToothId. - const groupOf = (tooth: string) => - result.toothSelectionGroups.find((group) => - (group.teeth as readonly string[]).includes(tooth), - )?.groupId ?? ''; - // Only teeth that actually landed on the detail. Unticking "teeth" while - // leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth - // the treatment does not contain — nothing downstream filters them, and they - // would reach task generation as work for teeth nobody is treating. - const detailTeeth = new Set(detail.teeth); - draft.toothProsthesis = Object.entries(result.prosthesis.byTooth) - .filter(([tooth]) => detailTeeth.has(tooth)) - .map(([tooth, prosthesisTypeCode]) => ({ - detailClientId: detail.clientId, - tooth, - prosthesisTypeCode, - selectionGroupId: groupOf(tooth), - })); - } + draft.toothProsthesis = prosthesisLines.flatMap((line) => { + const groupId = line.isJaw + ? '' + : (detail.toothSelectionGroups.find((group) => + (group.teeth as readonly string[]).includes(line.target), + )?.groupId ?? ''); + return line.applied.map((prosthesisTypeCode) => ({ + detailClientId: detail.clientId, + tooth: line.target, + prosthesisTypeCode, + selectionGroupId: groupId, + })); + }); const updatedLabCases = [...labCaseDrafts, draft]; setLabCaseDrafts(updatedLabCases); @@ -2269,8 +2301,10 @@ export function TreatmentWorkspace({ }, [ labCaseDrafts, + labDependentCodes, persistDraft, persistLabCases, + prosthesisCatalog, selectedAppointment?.purpose, showError, t, @@ -3202,6 +3236,7 @@ export function TreatmentWorkspace({ result={voiceResult} treatmentCatalog={treatmentCatalog} prosthesisCatalog={prosthesisCatalog} + labDependentCodes={labDependentCodes} labs={orgs} onApply={(selection, applied) => applyVoiceResult(applied, selection)} onDiscard={() => setVoiceResult(null)} diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index ef5da7a..71bc0ad 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -10,31 +10,44 @@ import { ResponsiveDialogPanel, } from '@/components/ui/shared/ResponsiveDialog'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; +import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree'; import { connectedTeethFromResult, countSelected, hasAnythingToApply, initialVoiceSelection, + joblessProsthesisTargets, + prosthesisChartData, + prosthesisTargetLines, voiceRowAvailability, + withChosenArch, + withChosenProsthesisLeaf, withChosenTeeth, } from '@/components/treatment/voiceReviewRows'; -import { useLocale } from 'next-intl'; import { useAppFormatters } from '@/lib/hooks/useAppFormatters'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment'; -import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; +import type { + VoiceApplySelection, + VoiceExtractionResult, + VoiceUnresolvedItem, +} from '@/types/voice'; interface VoiceReviewSheetProps { result: VoiceExtractionResult; treatmentCatalog: TreatmentCatalogEntry[]; prosthesisCatalog: ProsthesisCatalogEntry[]; + /** `prosthesis` today, but kept generic — the same set the workspace already tracks. */ + labDependentCodes: ReadonlySet; labs: LinkedOrganizationOption[]; - /** The result is handed back because the sheet may have added teeth the model missed. */ + /** The result is handed back because the sheet may have added teeth or jobs the model missed. */ onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void; onDiscard: () => void; } +type ArchPick = 'upper' | 'lower'; + /** * Confirmation step between the model's output and the form. * @@ -45,50 +58,131 @@ export function VoiceReviewSheet({ result, treatmentCatalog, prosthesisCatalog, + labDependentCodes, labs, onApply, onDiscard, }: VoiceReviewSheetProps) { const t = useTranslations('treatment'); - const locale = useLocale(); const { formatDate } = useAppFormatters(); const [selection, setSelection] = useState(() => - initialVoiceSelection(result), + initialVoiceSelection(result, labDependentCodes), ); - const [chosen, setChosen] = useState([]); - // Everything below renders from `effective`, never from `result` — a tooth picked from - // the candidate chips has to reach the rows, the chart and the apply count alike. - const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]); + // Every kind of candidate chip the sheet can offer, tracked separately because each folds + // into the result a different way (decision 50). Toggling off removes only the clinician's + // own pick — nothing the recording already produced is ever un-added. + const [chosenTeeth, setChosenTeeth] = useState([]); + const [chosenAssignmentTeeth, setChosenAssignmentTeeth] = useState>( + {}, + ); + const [chosenArches, setChosenArches] = useState>({}); + const [chosenLeaves, setChosenLeaves] = useState>({}); - const available = useMemo(() => voiceRowAvailability(effective), [effective]); + // Everything below renders from `effective`, never from `result` — a candidate picked from + // the chips has to reach the rows, the chart and the apply count alike. + const effective = useMemo(() => { + let next = withChosenTeeth(result, chosenTeeth); + for (const [index, teeth] of Object.entries(chosenAssignmentTeeth)) { + next = withChosenTeeth(next, teeth, Number(index)); + } + for (const [index, arches] of Object.entries(chosenArches)) { + for (const arch of arches) next = withChosenArch(next, Number(index), arch); + } + for (const [index, leaves] of Object.entries(chosenLeaves)) { + for (const leaf of leaves) next = withChosenProsthesisLeaf(next, Number(index), leaf); + } + return next; + }, [result, chosenTeeth, chosenAssignmentTeeth, chosenArches, chosenLeaves]); + + const available = useMemo( + () => voiceRowAvailability(effective, labDependentCodes), + [effective, labDependentCodes], + ); const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]); const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]); - const nothingToApply = !hasAnythingToApply(effective); + const prosthesisLines = useMemo( + () => prosthesisTargetLines(effective.prosthesisAssignments, prosthesisCatalog), + [effective, prosthesisCatalog], + ); + const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]); + const chartData = useMemo( + () => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog), + [prosthesisLines, joblessTargets, prosthesisCatalog], + ); + const nothingToApply = !hasAnythingToApply(effective, labDependentCodes); const selectedCount = countSelected(selection, available); - const pickCandidate = (tooth: FdiToothId) => { - const nextChosen = chosen.includes(tooth) - ? chosen.filter((t) => t !== tooth) - : [...chosen, tooth]; - setChosen(nextChosen); - setSelection((prev) => ({ - ...prev, - // The teeth row starts unticked whenever the recording produced no teeth of its own, - // and a picked tooth that is not ticked applies nothing. - teeth: true, - // A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the - // row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the - // clinician's call. - prosthesis: - prev.prosthesis && - withChosenTeeth(result, nextChosen).prosthesis?.complete !== false, - })); + const targetLabel = (target: string): string => { + if (target === ARCH_TOOTH_UPPER) return t('upperArch'); + if (target === ARCH_TOOTH_LOWER) return t('lowerArch'); + return target; }; const labelFor = (code: string | null, catalog: { code: string; label: string }[]) => catalog.find((entry) => entry.code === code)?.label ?? code ?? ''; + const isPicked = (item: VoiceUnresolvedItem, code: string): boolean => { + const index = item.assignmentIndex; + if (item.reason === 'arch_not_spoken') { + return index != null && (chosenArches[index] ?? []).includes(code as ArchPick); + } + if (item.reason === 'prosthesis_type_ambiguous') { + return index != null && (chosenLeaves[index] ?? []).includes(code); + } + if (index != null) return (chosenAssignmentTeeth[index] ?? []).includes(code as FdiToothId); + return chosenTeeth.includes(code as FdiToothId); + }; + + const pickCandidate = (item: VoiceUnresolvedItem, code: string) => { + const index = item.assignmentIndex; + if (item.reason === 'arch_not_spoken' && index != null) { + setChosenArches((prev) => { + const cur = prev[index] ?? []; + const arch = code as ArchPick; + return { + ...prev, + [index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch], + }; + }); + } else if (item.reason === 'prosthesis_type_ambiguous' && index != null) { + setChosenLeaves((prev) => { + const cur = prev[index] ?? []; + return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] }; + }); + } else if (index != null) { + setChosenAssignmentTeeth((prev) => { + const cur = prev[index] ?? []; + const tooth = code as FdiToothId; + return { + ...prev, + [index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth], + }; + }); + } else { + const tooth = code as FdiToothId; + setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth])); + } + // A picked candidate has no meaning unless its row is ticked. + setSelection((prev) => ({ + ...prev, + teeth: prev.teeth || available.teeth, + prosthesis: true, + })); + }; + + const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => { + if (item.reason === 'arch_not_spoken') return code === 'upper' ? t('upperArch') : t('lowerArch'); + if (item.reason === 'prosthesis_type_ambiguous') return labelFor(code, prosthesisCatalog); + return code; + }; + + const promptFor = (item: VoiceUnresolvedItem): string | null => { + if (item.reason === 'arch_not_spoken') return t('voicePickJaw'); + if (item.reason === 'tooth_missing_quadrant') return t('voicePickTooth'); + return null; + }; + const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) => setSelection((prev) => ({ ...prev, [key]: checked })); @@ -138,6 +232,59 @@ export function VoiceReviewSheet({ ) : null} + {available.prosthesis ? ( + +
+ +
+

+ {prosthesisLines.map((line, i) => ( + + {i > 0 ? ' · ' : ''} + {targetLabel(line.target)}:{' '} + {line.applied.map((code, j) => ( + + {j > 0 ? ' + ' : ''} + {labelFor(code, prosthesisCatalog)} + + ))} + {line.refused.map((code) => ( + + {' + '} + {labelFor(code, prosthesisCatalog)} + + ))} + + ))} + {joblessTargets.map((target, i) => ( + + {prosthesisLines.length > 0 || i > 0 ? ' · ' : ''} + {targetLabel(target)}: {t('voiceNoProsthesisHeard')} + + ))} +

+
+ ) : null} + {available.comment ? ( ) : null} - {available.prosthesis && effective.prosthesis ? ( - - - {Object.entries(effective.prosthesis.byTooth) - .map( - ([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`, - ) - .join(' · ')} - - - ) : null} - {available.lab ? ( 0 ? ( - {t('voicePickTooth')} - {item.candidates.map((tooth) => { - const picked = chosen.includes(tooth as FdiToothId); + {promptFor(item) ? ( + {promptFor(item)} + ) : null} + {item.candidates.map((code) => { + const picked = isPicked(item, code); return ( ); })} @@ -295,12 +425,3 @@ function civilDateToLocalDate(iso: string): Date { const [year, month, day] = iso.split('-').map(Number); return new Date(year, (month ?? 1) - 1, day ?? 1); } - -/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */ -function formatToothList(teeth: readonly string[], locale: string): string { - try { - return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]); - } catch { - return teeth.join(', '); - } -} diff --git a/frontend/src/lib/voice/audioFormat.ts b/frontend/src/lib/voice/audioFormat.ts index 4b94945..91c031c 100644 --- a/frontend/src/lib/voice/audioFormat.ts +++ b/frontend/src/lib/voice/audioFormat.ts @@ -22,7 +22,13 @@ export function pickRecordingMimeType(): string | null { for (const type of PREFERRED_MIME_TYPES) { if (MediaRecorder.isTypeSupported(type)) return type; } - return null; + // None of the preferred containers passed `isTypeSupported` — a Safari version whose check + // exists but answers false for a container it can still record (e.g. plain `audio/mp4`). + // The preference list is not a requirement: fall back to the "let the browser choose" hint + // rather than refusing outright. `onstop` derives the real container from + // `recorder.mimeType`, so this is only wrong when the browser genuinely cannot record at + // all — and `new MediaRecorder()` / `recorder.start()` throwing is handled at the call site. + return ''; } /** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */ diff --git a/frontend/src/types/voice.ts b/frontend/src/types/voice.ts index b77665d..2410386 100644 --- a/frontend/src/types/voice.ts +++ b/frontend/src/types/voice.ts @@ -6,10 +6,12 @@ export type VoiceUnresolvedReason = | 'not_permanent_tooth' | 'position_out_of_range' | 'tooth_missing_quadrant' + | 'prosthesis_type_ambiguous' + | 'arch_not_spoken' + | 'code_not_valid_for_target' | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code' - | 'tooth_not_selected' | 'invalid_date'; export interface VoiceUnresolvedItem { @@ -17,17 +19,30 @@ export interface VoiceUnresolvedItem { spoken: string; reason: VoiceUnresolvedReason; /** - * FDI codes still consistent with what was heard, when a choice would settle it — the - * review sheet offers them as chips. Only `tooth_missing_quadrant` carries these. + * Values still consistent with what was heard, when a choice would settle it — the review + * sheet offers them as chips. FDI codes for `tooth_missing_quadrant`, leaf codes for + * `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. */ candidates?: string[]; + /** + * Set only when this item came from resolving a `prosthesisAssignments` entry. A picked + * chip then inherits that assignment's `types` (or supplies the missing leaf to it) instead + * of resolving to a jobless tooth. + */ + assignmentIndex?: number; } -export interface VoiceProsthesisResult { - byTooth: Record; - /** False means the case cannot ship — every tooth needs a prosthesis type. */ - complete: boolean; - missingTeeth: FdiToothId[]; +/** + * One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels + * (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty + * `types` means the target was named with no job at all (struck through in the sheet); empty + * `targets` with a `prosthesis_type_ambiguous` unresolved item at this index means the target + * is pending a material pick. + */ +export interface VoiceProsthesisAssignment { + targets: string[]; + types: string[]; + spoken: string; } export interface VoiceExtractionResult { @@ -36,7 +51,7 @@ export interface VoiceExtractionResult { teeth: FdiToothId[]; toothSelectionGroups: ToothSelectionGroup[]; comment: string | null; - prosthesis: VoiceProsthesisResult | null; + prosthesisAssignments: VoiceProsthesisAssignment[]; labId: string | null; /** When false, the lab row must not tick itself — the name only approximately matched. */ labMatchExact: boolean; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..7af964d --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +/** + * Covers the pure helpers only — no React, no DOM. `@/*` mirrors `tsconfig.json`'s path so a + * spec can import the same modules the app does. + */ +export default defineConfig({ + resolve: { + alias: { '@': path.resolve(__dirname, 'src') }, + }, + test: { + include: ['src/**/*.spec.ts'], + }, +});