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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ The dev command builds packages, starts the API, Vite and TypeScript watchers. S
6. Explicitly agree to 15:30. `accept_slot` goes through the manager, saves Jordan's replacement booking, and then waives Chris's fee. Within one second, the dashboard shows **Jordan at 3:30 PM**, **$45 recovered**, **$15 waived**, and the ordered activity log. Connecting or ending a call does not book anything.
7. The agent says goodbye and ends the call after a successful booking or decline. Reset to repeat. Alternatively, decline or end without accepting: the fee stays pending and the manager prepares Sam, then Taylor. Wait for the current audio call to finish before answering the next one. An exhausted waitlist leaves the fee pending. For an older ElevenLabs agent, run `npm run voice:update` once to enable automatic hangup.

No credentials are required to use the calendar, reset, or run automated tests. A real microphone conversation requires ElevenLabs credentials. For an explicitly simulated, audio-free backend demo, `POST /api/refills/:runId/simulate` accepts `{ "type": "accepted", "startsAt": "2026-09-19T15:30:00-07:00" }` or a declined/no_answer outcome; it is labeled simulated in the log and cannot run during an open web call.
No credentials are required to use the calendar, reset, or run automated tests. A real microphone conversation requires ElevenLabs credentials. For an explicitly simulated, audio-free backend demo, `POST /api/refills/:runId/simulate` accepts `{ "type": "accepted", "startsAt": "<ISO timestamp from the current offer>" }` or a declined/no_answer outcome; it is labeled simulated in the log and cannot run during an open web call.

## Data and reset

The seed is a fixed demo day, **September 19, 2026**, America/Los_Angeles, one barber, five 45-minute $45 bookings and three waitlist clients. Original bookings have a $15 simulated cancellation fee. Alternative starts are the cancelled start or 30 minutes later, subject to business hours, service duration and calendar collisions.
The seed uses **tomorrow in America/Los_Angeles**, calculated when the API starts with that date’s UTC offset, one barber, five 45-minute $45 bookings and three waitlist clients. Original bookings have a $15 simulated cancellation fee. Alternative starts are the cancelled start or 30 minutes later, subject to business hours, service duration and calendar collisions.

SQLite is created automatically at **`.demo/dispatch.sqlite` in the repo root**, independent of the API working directory. It is ignored by Git. Set `DEMO_DB_PATH` to an absolute path to override it (`:memory:` for tests). No database installation or migrations command is needed.

**Reset demo** calls `POST /api/demo/reset`, reseeds the calendar/clients, clears manager runs and logs, and invalidates old voice sessions. Any open old voice page closes its audio on the next session check. Bookings, waitlist status, fees and activity survive API restarts. Manager runs and browser sessions are in memory: after a restart, an unfinished cancellation starts a fresh offer from the remaining waitlist. Previous declines are not persisted as exclusions. Reset before a presentation for a predictable starting state.
**Reset demo** calls `POST /api/demo/reset`, reseeds the calendar/clients, clears manager runs and logs, and invalidates old voice sessions. Any open old voice page closes its audio on the next session check. Bookings, waitlist status, fees and activity survive API restarts on the same demo day. Starting the API on a new day automatically reseeds the stored calendar for tomorrow. Restart the API before a demo if it has been running overnight. Manager runs and browser sessions are in memory: after a restart, an unfinished cancellation starts a fresh offer from the remaining waitlist. Previous declines are not persisted as exclusions. Reset before a presentation for a predictable starting state.

Brand colors and export formats live in [docs/design](docs/design/README.md).

Expand Down
32 changes: 32 additions & 0 deletions apps/api/test/time.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { spawnSync } from 'node:child_process';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { spokenDate } from '@dispatch/voice';
import { DEMO_DATE, demoDate, demoTime } from '@dispatch/data';

const zone = 'America/Los_Angeles';

Expand All @@ -24,3 +26,33 @@ test('tomorrow follows the local calendar across daylight-saving and year bounda
assert.equal(spokenDate('2027-01-01', zone, new Date('2026-12-31T23:30:00-08:00')),
'tomorrow');
});

test('the demo day is tomorrow in the shop timezone, so offered times are never past', () => {
// Late evening in Los Angeles: a "today" demo would already be offering past times.
assert.equal(demoDate(new Date('2026-09-20T04:30:00Z')), '2026-09-20');
assert.equal(demoDate(new Date('2026-09-19T17:00:00Z')), '2026-09-20');
// Across a DST boundary the local calendar still advances exactly one day.
assert.equal(demoDate(new Date('2026-03-07T23:30:00-08:00')), '2026-03-08');
assert.equal(demoDate(new Date('2026-12-31T23:30:00-08:00')), '2027-01-01');
// Every seeded opening is in the future, with the offset that date really has.
assert.ok(Date.parse(demoTime('09:00')) > Date.now(), 'first slot is still ahead');
assert.ok(demoTime('15:00').startsWith(`${DEMO_DATE}T15:00:00`), 'slots sit on the demo date');
assert.match(demoTime('15:00'), /[+-]\d{2}:\d{2}$/);
});

test('seeded wall-clock times use the correct offset in winter and on DST transition days', () => {
for (const [now, expected] of [
['2026-01-10T20:00:00Z', '2026-01-11T15:00:00-08:00'],
['2026-03-07T20:00:00Z', '2026-03-08T15:00:00-07:00'],
['2026-10-31T20:00:00Z', '2026-11-01T15:00:00-08:00'],
]) {
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
import { mock } from 'node:test';
mock.timers.enable({ apis: ['Date'], now: Date.parse(${JSON.stringify(now)}) });
const { demoTime, localTime } = await import('@dispatch/data');
console.log(JSON.stringify([demoTime('15:00'), localTime(demoTime('15:00'))]));
`], { encoding: 'utf8' });
assert.equal(child.status, 0, child.stderr);
assert.deepEqual(JSON.parse(child.stdout), [expected, '15:00']);
}
});
35 changes: 32 additions & 3 deletions apps/api/test/voice.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'reflect-metadata';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { NestFactory } from '@nestjs/core';
import { VoiceModule } from '../dist/voice/voice.module.js';
import { SqliteBookings, demoTime } from '@dispatch/data';
import { spokenDate } from '@dispatch/voice';
import { DEMO_DATE, SqliteBookings, demoTime } from '@dispatch/data';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -54,8 +56,8 @@ test('shared HTTP demo: cancellation → offer → voice tool → calendar booki
assert.equal(started.dynamicVariables.customer_name, 'Jordan Davis');
assert.equal(started.dynamicVariables.offered_time, '3 PM');
assert.equal(started.dynamicVariables.available_times, '3 PM, 3:30 PM');
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.equal(started.dynamicVariables.date, spokenDate(DEMO_DATE, 'America/Los_Angeles'));
assert.equal(started.session.context.date, DEMO_DATE, '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 Expand Up @@ -148,3 +150,30 @@ test('SQLite persists bookings, clients and fees across reopen; reset restores s
assert.equal(store.waitlist()[0]?.status, 'waiting');
} finally { store.close(); rmSync(directory, { recursive: true, force: true }); }
});


test('opening an older demo database reseeds the calendar, waitlist and activity for tomorrow', async () => {
const directory = mkdtempSync(join(tmpdir(), 'dispatch-stale-day-'));
const path = join(directory, 'demo.sqlite');
let store = new SqliteBookings(path);
try {
await store.cancelBooking('slot_3pm');
await store.bookReplacement({ slotId: 'slot_3pm', contactId: 'client_5', startsAt: demoTime('15:30'), idempotencyKey: 'old-day' });
await store.waiveCancellationFee('slot_3pm');
const oldBookings = store.bookings();
store.close();
const db = new DatabaseSync(path);
try {
for (const booking of oldBookings) {
booking.startsAt = `2000-01-01${booking.startsAt.slice(10)}`;
db.prepare('UPDATE bookings SET data = ? WHERE id = ?').run(JSON.stringify(booking), booking.id);
}
} finally { db.close(); }
store = new SqliteBookings(path);
assert.equal(store.bookings().length, 5);
assert.ok(store.bookings().every(booking => booking.startsAt.startsWith(DEMO_DATE) && booking.status === 'booked' && booking.feeStatus === 'not_due'));
assert.ok(store.waitlist().every(client => client.status === 'waiting'));
assert.equal(store.events().length, 1);
assert.match(store.events()[0]!.message, /Demo reset/);
} finally { store.close(); rmSync(directory, { recursive: true, force: true }); }
});
13 changes: 13 additions & 0 deletions apps/web/src/voice/VoiceDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { CalendarDays, MessagesSquare } from 'lucide-react';
import './voice.css';
import { Signal } from '../MotionUI';

// Long enough for a one-line goodbye, short enough that the demo never stalls.
const GOODBYE_MS = 8000;

async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/voice/${path}`, {
method: body === undefined ? 'GET' : 'POST',
Expand All @@ -32,6 +35,7 @@ export function VoiceDemo() {
const connection = useRef<VoiceConversation | null>(null);
const current = useRef<{ id?: string; cancelled: boolean } | null>(null);
const busy = useRef(false);
const hangup = useRef<ReturnType<typeof setTimeout> | null>(null);
const incomingOffer = status === 'disconnected' && config?.configured && !config.callActive
&& config.attemptId !== answeredOffer ? config?.attemptId ?? null : null;
const ringtone = useRingtone(incomingOffer);
Expand All @@ -51,6 +55,7 @@ export function VoiceDemo() {
const timer = setInterval(refresh, 1000);
return () => {
mounted = false; clearInterval(timer);
if (hangup.current) clearTimeout(hangup.current);
if (current.current) current.current.cancelled = true;
void connection.current?.endSession();
if (current.current?.id) void api(`sessions/${current.current.id}/end`, { reason: 'ended' }).catch(() => {});
Expand Down Expand Up @@ -109,6 +114,13 @@ export function VoiceDemo() {
if (!isCurrent()) return JSON.stringify({ ok: false, message: 'The call ended.' });
const result = await api<VoiceDemoSession | Record<string, unknown>>(`sessions/${attempt.id}/${action}`, parameters);
if ('status' in result && isCurrent()) setSession(result as VoiceDemoSession);
// The agent should say goodbye and call end_call itself. This is the backstop so a
// booked or declined call always hangs up, even if it keeps talking instead.
if ((result.status === 'accepted' || result.status === 'declined') && isCurrent() && !hangup.current) {
hangup.current = setTimeout(() => {
if (isCurrent() && !attempt.closed) void end();
}, GOODBYE_MS);
}
return JSON.stringify({ ok: true, ...result });
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not complete the request';
Expand Down Expand Up @@ -136,6 +148,7 @@ export function VoiceDemo() {
if (!isCurrent()) return;
attempt.closed = true;
connection.current = null;
if (hangup.current) { clearTimeout(hangup.current); hangup.current = null; }
setStatus('disconnecting');
void api<VoiceDemoSession>(`sessions/${attempt.id}/end`, { reason: details.reason === 'error' ? 'failed' : 'ended' })
.then((value) => { if (isCurrent()) setSession(value); })
Expand Down
2 changes: 1 addition & 1 deletion docs/voice.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ On `/voice`, click **Enable ringtone** once before the demo. Browsers require a
- “No thanks.” → `decline_slot` → goodbye → automatic `end_call`; fee remains pending and the next candidate can answer a new web call.
- End a call without accepting → no booking. Ending after an acceptance preserves the booking.

The demo calendar is September 19, 2026, America/Los_Angeles, at Apblendzz. The manager selects the customer from SQLite's waitlist. For the 15:00 cancellation, 15:30 is available; 16:00 is unavailable because the 45-minute service would overlap a 16:30 appointment. Every call uses the same calendar as the shop dashboard. See [the exact flow and reset behavior](../README.md#exact-demo-flow).
The demo calendar is tomorrow in America/Los_Angeles at Apblendzz, calculated when the API starts. A new demo day automatically reseeds an older database. Restart the API before a demo if it has been running overnight. The manager selects the customer from SQLite's waitlist. For the 15:00 cancellation, 15:30 is available; 16:00 is unavailable because the 45-minute service would overlap a 16:30 appointment. Every call uses the same calendar as the shop dashboard. See [the exact flow and reset behavior](../README.md#exact-demo-flow).

The manager's `CallRequest` supplies customer/shop/slot variables and the call brief. The browser forwards that brief as a contextual update using the existing SDK; WebRTC transport and the configured ElevenLabs agent stay unchanged. Booking tools return to the manager and shared repository. A second browser cannot start a concurrent call, and ending a call without acceptance advances the waitlist without creating a booking.

Expand Down
2 changes: 1 addition & 1 deletion packages/data/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export interface BookingRepository {
}): Promise<{ bookingId: string }>;
waiveCancellationFee(slotId: string): Promise<void>;
}
export { SqliteBookings, DEMO_DATE, demoTime, localTime } from './sqlite.js';
export { SqliteBookings, DEMO_DATE, TIMEZONE, demoDate, demoTime, localTime } from './sqlite.js';
33 changes: 29 additions & 4 deletions packages/data/src/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,31 @@ import { randomUUID } from 'node:crypto';
import type { BookingRepository } from './index.js';
import type { DemoBooking, DemoDashboard, DemoEvent } from '@dispatch/contracts';

export const DEMO_DATE = '2026-09-19';
export const demoTime = (time: string) => `${DEMO_DATE}T${time}:00-07:00`;
export const localTime = (iso: string) => new Date(iso).toLocaleTimeString('en-GB', { timeZone: 'America/Los_Angeles', hour: '2-digit', minute: '2-digit' });
export const TIMEZONE = 'America/Los_Angeles';

const localDate = (at: Date) =>
new Intl.DateTimeFormat('en-CA', { timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit' }).format(at);

// The demo day is tomorrow in the shop's timezone, so every offered time is still
// in the future however late in the day the demo runs.
export function demoDate(now = new Date()): string {
const day = new Date(`${localDate(now)}T00:00:00Z`);
day.setUTCDate(day.getUTCDate() + 1);
return day.toISOString().slice(0, 10);
}

// Real offset for that date, so the seeded times survive a DST change.
function utcOffset(date: string): string {
const name = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, timeZoneName: 'longOffset' })
.formatToParts(new Date(`${date}T12:00:00Z`))
.find((part) => part.type === 'timeZoneName')?.value ?? 'GMT';
return name.replace('GMT', '') || '+00:00';
}

export const DEMO_DATE = demoDate();
const DEMO_OFFSET = utcOffset(DEMO_DATE);
export const demoTime = (time: string) => `${DEMO_DATE}T${time}:00${DEMO_OFFSET}`;
export const localTime = (iso: string) => new Date(iso).toLocaleTimeString('en-GB', { timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit' });

// A single-barber calendar, backed by Node 24's built-in SQLite. No external service.
export class SqliteBookings implements BookingRepository {
Expand All @@ -18,7 +40,10 @@ export class SqliteBookings implements BookingRepository {
this.db.exec(`CREATE TABLE IF NOT EXISTS clients (id TEXT PRIMARY KEY, name TEXT, phone TEXT, waitlist INTEGER, booked INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS bookings (id TEXT PRIMARY KEY, data TEXT NOT NULL, idempotency_key TEXT UNIQUE);
CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT, message TEXT);`);
if (!this.db.prepare('SELECT id FROM clients LIMIT 1').get()) this.reset();
// A database kept from an earlier demo day holds past times; reseed it onto today's demo date.
const seeded = this.db.prepare('SELECT data FROM bookings LIMIT 1').get();
const staleDay = !!seeded && !(JSON.parse(String(seeded.data)) as DemoBooking).startsAt.startsWith(DEMO_DATE);
if (staleDay || !this.db.prepare('SELECT id FROM clients LIMIT 1').get()) this.reset();
}
close() { this.db.close(); }
reset() {
Expand Down
Loading
Loading