diff --git a/apps/api/src/refill/time.ts b/apps/api/src/refill/time.ts new file mode 100644 index 0000000..172b4ed --- /dev/null +++ b/apps/api/src/refill/time.ts @@ -0,0 +1,33 @@ +// Speech formatting for dates the voice agent reads aloud. + +// "2026-09-20" in America/Los_Angeles -> "tomorrow, Sunday, September 20th". +// Only the spoken copy changes; stored dates stay ISO for the UI and demoTime. +export function spokenDate(date: string, timeZone: string, now = new Date()): string { + const year = Number(date.slice(0, 4)); + const month = Number(date.slice(5, 7)); + const day = Number(date.slice(8, 10)); + // The date is already a calendar date in timeZone, so read its parts as UTC + // rather than projecting it through the zone a second time. + const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'long', month: 'long' }) + .formatToParts(new Date(Date.UTC(year, month - 1, day))); + const weekday = parts.find((part) => part.type === 'weekday')?.value ?? ''; + const monthName = parts.find((part) => part.type === 'month')?.value ?? ''; + const spoken = `${weekday}, ${monthName} ${ordinal(day)}`; + const today = localDate(now, timeZone); + if (date === today) return `today, ${spoken}`; + // Advance the local calendar date; a local day can be 23 or 25 hours. + const tomorrow = new Date(`${today}T00:00:00Z`); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + if (date === tomorrow.toISOString().slice(0, 10)) return `tomorrow, ${spoken}`; + return spoken; +} + +function localDate(at: Date, timeZone: string): string { + return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(at); +} + +function ordinal(day: number): string { + const teen = day % 100; + if (teen >= 11 && teen <= 13) return `${day}th`; + return `${day}${['th', 'st', 'nd', 'rd'][day % 10] ?? 'th'}`; +} diff --git a/apps/api/src/voice/voice.service.ts b/apps/api/src/voice/voice.service.ts index 6c111aa..263f940 100644 --- a/apps/api/src/voice/voice.service.ts +++ b/apps/api/src/voice/voice.service.ts @@ -2,6 +2,7 @@ import { BadGatewayException, Injectable, ServiceUnavailableException } from '@n import { dynamicVariables, ElevenLabsWebVoice } from '@dispatch/voice'; import type { VoiceDemoConfiguration, VoiceSessionStart } from '@dispatch/contracts'; import { DemoCoordinator } from '../refill/demo-coordinator.js'; +import { spokenDate } from '../refill/time.js'; @Injectable() export class VoiceService { @@ -21,7 +22,9 @@ export class VoiceService { const conversationToken = await voice.createToken(); // Reset/end during token creation must not return a usable stale offer. if ((await this.demo.get(session.id)).status !== 'active') throw new Error('The offer ended before voice connected.'); - return { session, conversationToken, dynamicVariables: dynamicVariables(session.context) }; + // The agent reads the date aloud, so speak it. session.context.date stays ISO for the UI and demoTime. + return { session, conversationToken, + dynamicVariables: { ...dynamicVariables(session.context), date: spokenDate(session.context.date, session.context.timezone) } }; } catch (error) { await this.demo.end(session.id, 'failed').catch(() => {}); throw new BadGatewayException(error instanceof Error ? error.message : 'ElevenLabs unavailable'); diff --git a/apps/api/test/time.test.ts b/apps/api/test/time.test.ts new file mode 100644 index 0000000..f450f57 --- /dev/null +++ b/apps/api/test/time.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { spokenDate } from '../dist/refill/time.js'; + +const zone = 'America/Los_Angeles'; + +test('spoken dates use the business calendar day and natural ordinals', () => { + const now = new Date('2026-09-20T02:00:00Z'); // Still September 19 in Los Angeles. + assert.equal(spokenDate('2026-09-19', zone, now), 'today, Saturday, September 19th'); + assert.equal(spokenDate('2026-09-20', zone, now), 'tomorrow, Sunday, September 20th'); + assert.equal(spokenDate('2026-09-21', zone, now), 'Monday, September 21st'); + for (const [day, suffix] of [[11, 'th'], [12, 'th'], [13, 'th'], [22, 'nd'], [23, 'rd'], [30, 'th']] as const) { + assert.ok(spokenDate(`2026-09-${day}`, zone, now).endsWith(`${day}${suffix}`)); + } +}); + +test('tomorrow follows the local calendar across daylight-saving and year boundaries', () => { + assert.equal(spokenDate('2026-03-08', zone, new Date('2026-03-07T23:30:00-08:00')), + 'tomorrow, Sunday, March 8th'); + assert.equal(spokenDate('2026-03-09', zone, new Date('2026-03-07T23:30:00-08:00')), + 'Monday, March 9th'); + assert.equal(spokenDate('2026-11-02', zone, new Date('2026-11-01T00:30:00-07:00')), + 'tomorrow, Monday, November 2nd'); + assert.equal(spokenDate('2027-01-01', zone, new Date('2026-12-31T23:30:00-08:00')), + 'tomorrow, Friday, January 1st'); +}); diff --git a/apps/api/test/voice.test.ts b/apps/api/test/voice.test.ts index fedc500..9f7b14c 100644 --- a/apps/api/test/voice.test.ts +++ b/apps/api/test/voice.test.ts @@ -52,6 +52,8 @@ test('shared HTTP demo: cancellation → offer → voice tool → calendar booki const started = await response.json(); assert.equal(started.conversationToken, 'test-token'); assert.equal(started.dynamicVariables.customer_name, 'Jordan Davis'); + assert.match(started.dynamicVariables.date, /^(?:(?:today|tomorrow), )?Saturday, September 19th$/); + assert.equal(started.session.context.date, '2026-09-19', 'booking context keeps its ISO date'); assert.match(started.session.managerBrief.disclosure, /Jordan Davis/); assert.match(started.session.managerBrief.offer, /45/); assert.equal((await post('voice/sessions')).status, 409, 'second tab cannot claim the same offer');