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 93c6513df6
commit c07f550e00
13 changed files with 672 additions and 146 deletions

View File

@@ -96,12 +96,28 @@ function describe(intent: DueIntent): string {
}
}
/** The Iranian week starts Saturday. */
const WEEK_START_JS = WEEKDAY_TO_JS.saturday;
/**
* Which weekday starts the week, per locale.
*
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
* Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday
* would put an en/nl clinician's deadline a week out.
*/
const WEEK_START_BY_LOCALE: Record<string, number> = {
fa: WEEKDAY_TO_JS.saturday,
en: WEEKDAY_TO_JS.monday,
nl: WEEKDAY_TO_JS.monday,
};
/** Most recent Saturday, counting today if today is Saturday. */
function startOfWeek(iso: string): string {
const back = (civilDateJsWeekday(iso) - WEEK_START_JS + 7) % 7;
const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday;
export function weekStartForLocale(locale: string): number {
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
}
/** Most recent week-start day, counting today if today is that day. */
function startOfWeek(iso: string, weekStartJs: number): string {
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
return addDays(iso, -back);
}
@@ -119,20 +135,25 @@ function startOfWeek(iso: string): string {
function resolveWeekday(
intent: Extract<DueIntent, { kind: 'weekday' }>,
todayIso: string,
weekStartJs: number,
) {
const targetJs = WEEKDAY_TO_JS[intent.weekday];
if (targetJs === undefined) return null;
if (intent.which === 'this') {
// A bare weekday ("پنجشنبه") carries no qualifier, and the model may leave `which`
// null. Treat that as 'this' rather than failing an utterance that named a real day.
const which = intent.which === 'next' ? 'next' : 'this';
if (which === 'this') {
const todayJs = civilDateJsWeekday(todayIso);
let delta = (targetJs - todayJs + 7) % 7;
if (delta === 0) delta = 7;
return addDays(todayIso, delta);
}
if (intent.which === 'next') {
const offsetInWeek = (targetJs - WEEK_START_JS + 7) % 7;
return addDays(startOfWeek(todayIso), 7 + offsetInWeek);
if (which === 'next') {
const offsetInWeek = (targetJs - weekStartJs + 7) % 7;
return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek);
}
return null;
@@ -156,6 +177,7 @@ function resolveOffset(
export function resolveDueDate(
intent: DueIntent | null | undefined,
todayIso: string,
weekStartJs: number = DEFAULT_WEEK_START,
): DueResolution {
// Absent is not an error — most utterances carry no deadline. Anything else that is not
// an intent object is a deadline we failed to understand, and must be flagged rather
@@ -179,7 +201,7 @@ export function resolveDueDate(
let resolved: string | null = null;
switch (intent.kind) {
case 'weekday':
resolved = resolveWeekday(intent, todayIso);
resolved = resolveWeekday(intent, todayIso, weekStartJs);
break;
case 'offset':
resolved = resolveOffset(intent, todayIso);