import { jalaliToIsoDate } from '../../common/jalali'; import { civilDateJsWeekday } from '../../common/zoned-civil-time'; import type { DueIntent, UnresolvedItem, Weekday } from './voice.types'; /** * Spoken deadline → ISO civil date. * * The model never does calendar arithmetic; it says what it heard and this decides what * that means. Jalali conversion in particular is arithmetic here, not inference — an LLM * asked to turn "۲۵ مهر" into ISO answers confidently and is often wrong, and * `@IsDateString()` would accept the wrong answer. * * Works entirely in civil dates. The caller derives `todayIso` from the actor's IANA zone * (see `civilDateInZone`) rather than passing a zone in here, so nothing in this file has * to reason about instants. */ const WEEKDAY_TO_JS: Record = { saturday: 6, sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, }; const MAX_DAYS_AHEAD = 365 * 5; export type DueResolution = { dueDate: string | null; unresolved: UnresolvedItem | null; }; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; function isRealCivilDate(iso: string): boolean { if (!ISO_DATE.test(iso)) return false; const [y, m, d] = iso.split('-').map(Number); if (m < 1 || m > 12 || d < 1 || d > 31) return false; const probe = new Date(Date.UTC(y, m - 1, d)); return ( probe.getUTCFullYear() === y && probe.getUTCMonth() === m - 1 && probe.getUTCDate() === d ); } function toIso(utcMs: number): string { return new Date(utcMs).toISOString().slice(0, 10); } function utcMsOf(iso: string): number { const [y, m, d] = iso.split('-').map(Number); return Date.UTC(y, m - 1, d); } function addDays(iso: string, days: number): string { return toIso(utcMsOf(iso) + days * 86_400_000); } /** Calendar-month addition with end-of-month clamping (31 Jan + 1 month = 28/29 Feb). */ function addMonths(iso: string, months: number): string { const [y, m, d] = iso.split('-').map(Number); const targetMonthIndex = m - 1 + months; const targetYear = y + Math.floor(targetMonthIndex / 12); const targetMonth = ((targetMonthIndex % 12) + 12) % 12; const lastDay = new Date( Date.UTC(targetYear, targetMonth + 1, 0), ).getUTCDate(); return toIso(Date.UTC(targetYear, targetMonth, Math.min(d, lastDay))); } function unresolved(spoken: string): DueResolution { return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } }; } /** * What to quote back when a deadline could not be resolved. Every field is nullable on the * wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim — * so a half-classified deadline must fall back to '', not to `"null null"`. */ function describe(intent: DueIntent): string { const usable = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); switch (intent?.kind) { case 'weekday': // `which` is legitimately null (it means "this"), the weekday is not. return [intent.which, intent.weekday] .filter((part) => typeof part === 'string') .join(' '); case 'offset': return usable(intent.amount) ? `+${intent.amount} ${intent.unit ?? ''}`.trim() : ''; case 'jalali': return [intent.jy, intent.jm, intent.jd].every(usable) ? `${intent.jy}/${intent.jm}/${intent.jd}` : ''; case 'gregorian': return [intent.y, intent.m, intent.d].every(usable) ? `${intent.y}-${intent.m}-${intent.d}` : ''; default: { // An unrecognised `kind`, already established as a string — echo what was heard. const kind = (intent as { kind?: unknown })?.kind; return typeof kind === 'string' ? kind : ''; } } } /** * "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts * Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's * deadline a week out. */ const WEEK_START_BY_LOCALE: Record = { fa: WEEKDAY_TO_JS.saturday, en: WEEKDAY_TO_JS.monday, nl: WEEKDAY_TO_JS.monday, }; const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday; export function weekStartForLocale(locale: string): number { return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START; } function startOfWeek(iso: string, weekStartJs: number): string { const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7; return addDays(iso, -back); } /** * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can * never resolve into the past. * * `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots * by seven days whenever `'this'` has already rolled into next week. The two legitimately * coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day. */ function resolveWeekday( intent: Extract, todayIso: string, weekStartJs: number, ) { const targetJs = WEEKDAY_TO_JS[intent.weekday]; if (targetJs === undefined) return null; // 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 (which === 'next') { const offsetInWeek = (targetJs - weekStartJs + 7) % 7; return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek); } return null; } function resolveOffset( intent: Extract, todayIso: string, ) { const { amount, unit } = intent; if (!Number.isInteger(amount) || amount < 0) return null; if (unit === 'day') return amount <= MAX_DAYS_AHEAD ? addDays(todayIso, amount) : null; if (unit === 'week') return amount <= 260 ? addDays(todayIso, amount * 7) : null; if (unit === 'month') return amount <= 60 ? addMonths(todayIso, amount) : null; return null; } 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. if (intent === null || intent === undefined) { return { dueDate: null, unresolved: null }; } if (typeof intent !== 'object') { return unresolved(String(intent).slice(0, 120)); } // No `kind` at all says nothing about a deadline, so it is not "heard but lost". An // *unrecognised* kind did try to say something, and is flagged below. if (typeof (intent as { kind?: unknown }).kind !== 'string') { return { dueDate: null, unresolved: null }; } if (!isRealCivilDate(todayIso)) { return unresolved(describe(intent)); } let resolved: string | null = null; switch (intent.kind) { case 'weekday': resolved = resolveWeekday(intent, todayIso, weekStartJs); break; case 'offset': resolved = resolveOffset(intent, todayIso); break; case 'jalali': resolved = jalaliToIsoDate(intent.jy, intent.jm, intent.jd); break; case 'gregorian': { const candidate = `${String(intent.y).padStart(4, '0')}-${String(intent.m).padStart(2, '0')}-${String(intent.d).padStart(2, '0')}`; resolved = isRealCivilDate(candidate) ? candidate : null; break; } default: resolved = null; } if (!resolved) return unresolved(describe(intent)); // A date the model invented can land anywhere; past or decades away is not a deadline. const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000; if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD) return unresolved(describe(intent)); return { dueDate: resolved, unresolved: null }; }