From 5668f8888086a66bbb511f24ff623440dcc5a75b Mon Sep 17 00:00:00 2001 From: lucasyoo1 Date: Sat, 19 Sep 2026 13:57:17 -0700 Subject: [PATCH 1/2] feat: speak the demo date naturally to the voice agent The `date` dynamic variable was the raw ISO string, so ElevenLabs read it aloud as "2026-09-19". Format it as "today, Saturday, September 19th" ("tomorrow, ..." the next day, plain weekday and date after that), computed in the session's timezone with correct ordinal suffixes. Only the `date` dynamic variable changes. session.context.date stays the ISO string, so the dashboard and demoTime are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/src/refill/time.ts | 29 +++++++++++++++++++++++++++++ apps/api/src/voice/voice.service.ts | 5 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/refill/time.ts diff --git a/apps/api/src/refill/time.ts b/apps/api/src/refill/time.ts new file mode 100644 index 0000000..76fc7e3 --- /dev/null +++ b/apps/api/src/refill/time.ts @@ -0,0 +1,29 @@ +// 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)}`; + if (date === localDate(now, timeZone)) return `today, ${spoken}`; + if (date === localDate(new Date(now.getTime() + 24 * 60 * 60 * 1000), timeZone)) 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'); From 8113679745ebf9e413587b2d83c197974b67fc12 Mon Sep 17 00:00:00 2001 From: davidtilser Date: Sat, 19 Sep 2026 14:02:58 -0700 Subject: [PATCH 2/2] fix: calculate spoken tomorrow by local calendar date --- apps/api/src/refill/time.ts | 8 ++++++-- apps/api/test/time.test.ts | 26 ++++++++++++++++++++++++++ apps/api/test/voice.test.ts | 2 ++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 apps/api/test/time.test.ts diff --git a/apps/api/src/refill/time.ts b/apps/api/src/refill/time.ts index 76fc7e3..172b4ed 100644 --- a/apps/api/src/refill/time.ts +++ b/apps/api/src/refill/time.ts @@ -13,8 +13,12 @@ export function spokenDate(date: string, timeZone: string, now = new Date()): st const weekday = parts.find((part) => part.type === 'weekday')?.value ?? ''; const monthName = parts.find((part) => part.type === 'month')?.value ?? ''; const spoken = `${weekday}, ${monthName} ${ordinal(day)}`; - if (date === localDate(now, timeZone)) return `today, ${spoken}`; - if (date === localDate(new Date(now.getTime() + 24 * 60 * 60 * 1000), timeZone)) return `tomorrow, ${spoken}`; + 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; } 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');