feat(frontend): split Add detail into a segmented control with voice

The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.

The two halves share a wrapper and nothing else. Add keeps its exact behaviour.

The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.

Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.

Fixes from review of this commit:

- mountedRef was set false on unmount and never re-armed, so under StrictMode
  the hook was permanently "unmounted" in dev and recording silently never
  started.
- onStart guarded only on `phase`, which does not change until getUserMedia
  resolves; a second click during the permission prompt orphaned the first
  MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
  Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
  a bare weekday carries no qualifier, and rejecting it discarded a real
  deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
  now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
  Safari's mp4/aac clips are not mislabelled.

Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 19:47:10 +03:30
parent ff2bd09669
commit 8757a8952c
13 changed files with 672 additions and 146 deletions

View File

@@ -309,6 +309,13 @@ Both roles, one API key.
**no transcode dependency is required**.
- Limits: 25 MB; 60s upstream *processing* timeout. The 2-minute recording cap (§2) sits
comfortably inside both.
- Request shape **verified** against OpenRouter's STT docs: base64 JSON `input_audio` is
the documented primary path (multipart `file` is the OpenAI-compatible alternative), and
both default model slugs exist on the live models API.
- The cap is enforced twice: the client's reported `durationMs`, and again against the
vendor's own `usage.seconds` — the client's figure is a claim, not enforcement. The
server allows a 2s tolerance, because the client measures length *after* the recorder
stops and a recording that runs to the cap always reports slightly over it.
- Response: `{ text, usage: { seconds, total_tokens, cost } }`.
- Price: `openai/whisper-1` is **$0.006/minute, billed to the nearest second** → $0.002 for
a typical 20s utterance, **$0.012 at the 2-minute cap**. OpenRouter forwards this model
@@ -417,7 +424,12 @@ that justified this whole design.
### `resolveDueDate()`
- Takes `clientTodayIso` + IANA `timeZone`; reuses `common/zoned-civil-time.ts`.
- Week starts **Saturday** (Iranian week) — one place, tested.
- Week start is **per locale**, because "next Thursday" is week-relative: `fa` starts
Saturday, `en` and `nl` start Monday. Hardcoding Saturday put an en/nl clinician's
deadline a week out. One place (`weekStartForLocale`), tested in both directions.
- `'this'` is occurrence-anchored (soonest strictly-future, never resolves into the past);
`'next'` is week-anchored. A missing qualifier is read as `'this'` — a bare weekday
carries none, and failing would discard a real spoken deadline.
- Jalali conversion is arithmetic, not inference: port `jalaliToGregorian` and
`toLatinDigits` from `frontend/src/lib/i18n/persianCalendar.ts` into
`backend/src/common/jalali.ts` with a spec. It is dependency-free integer math
@@ -645,7 +657,7 @@ enabling this for real clinics.
- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all
four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate`
(Saturday week start, "this" vs "next" weekday, Jalali leap year, month-end), the
(per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the
Jalali port, prosthesis expansion + completeness, and connected-span validation.
- `cd backend && npm run build` — cross-cutting backend gate.
- `cd frontend && npx tsc --noEmit` — frontend gate.