Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/api/src/refill/time.ts
Original file line number Diff line number Diff line change
@@ -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'}`;
}
5 changes: 4 additions & 1 deletion apps/api/src/voice/voice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand Down
26 changes: 26 additions & 0 deletions apps/api/test/time.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
2 changes: 2 additions & 0 deletions apps/api/test/voice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading