Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.
Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.
Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.
Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.
Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
warning on a plain restoration
- an override naming a tooth outside the selection now reports
tooth_not_selected rather than malformed; the clinician was understood, the
tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
"heard but lost" row; an unrecognised kind is still flagged, and named
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects found by review of the preceding commits.
"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.
The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.
- a non-object `due` (the model emitting a bare string) was treated as "no
deadline spoken" and silently discarded; only null/undefined mean absent now,
anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
table, contradicting the module's own "degrade to null" contract; they now
return false / 0, which also makes isValidJalaliDate's day check naturally
false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
RangeError before any fallback; it now validates and backstops to UTC, so a
bad zone costs at most a day rather than a 500
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jalali conversion is arithmetic here, not inference. A model asked to turn
"۲۵ مهر" into ISO answers confidently and is often wrong, and @IsDateString()
accepts the wrong answer — so the model emits a date intent and this decides
what it means.
Deviation from the spec, deliberately: the resolver takes todayIso rather than
an IANA zone. Working in civil dates means nothing here reasons about instants.
The zone is used one level up, where civilDateInZone() derives "today" from the
actor's zone server-side — better than the spec's client-supplied date, which
the client could set arbitrarily.
Conventions pinned by tests:
- "this <weekday>" is the soonest occurrence strictly after today, so "by
Thursday" said on a Thursday means the next one; a deadline of today is
almost never what was meant. "next" adds a further week.
- month offsets clamp to the end of shorter months (31 Jan + 1 = 28/29 Feb)
- a resolved date in the past, or more than five years out, is treated as
unresolved however it was arrived at — an absolute date the model invented
can land anywhere
- no due date at all is not an error; an unparseable one is, and echoes what
was heard so the review sheet can show it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.
resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.
Everything here parses untrusted model output, so nothing may throw:
- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
show) while '99' reports as malformed — the clinician should not be told a
deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
collapsing them would hide a lost tooth behind a single blank review row
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).
toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).
Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Voice extraction resolves spoken Jalali dates into ISO dates server-side, so
the backend needs the conversion the frontend already had. The resolvers live
here rather than in the frontend precisely because this half of the repo has a
test runner.
Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by
differential test: every day from 1900-2100 (73,414 days), zero mismatches on
conversion, leap years and month lengths.
Two deliberate divergences from the original:
- jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied
values, which may be nonsense, and an invalid date must degrade to
"unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth
so the throwing jalCal is unreachable from it.
- toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just
Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with
ASCII in one transcript; the frontend version only parses keystrokes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logical API errors throw stable codes so users see translated messages instead of a generic bad request.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extend the Today summary API with treatment mix and workflow task breakdowns, and render permission-aware chart cards on the Today page using Recharts.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace hardcoded Today cards with a backend summary endpoint and composable frontend widgets filtered by org type and tab permissions.
Co-authored-by: Cursor <cursoragent@cursor.com>