Skip to main content

Ruby Reception Cockpit — Phase 1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Build the Phase-1 Reception Cockpit surfaces (Morning Brief, per-appointment Prep Hints, EOD Reconciliation; Patient Snapshot reuses existing) on top of the deterministic-first architecture defined in docs/superpowers/specs/2026-05-23-ruby-reception-cockpit-design.md. Architecture: Deterministic SQL aggregators pick who/what/when and pass a JSON payload to a Ruby agent that narrates. Each surface = one aggregator + one Ruby agent + one Hono route. UI extends ReceptionistOverview.tsx rather than replacing it. WhatsApp send is gated for Phase 2. Tech Stack: Hono (Cloudflare Workers), Drizzle ORM, Neon Postgres, Langfuse prompt management, DeepSeek v4-flash via OpenAI-compatible SDK, React + TanStack Query in ui/. Reference tenant: Dental Square (dfffc93f-82eb-47fd-a276-b79c24dccb80) — use it for every smoke test.

Scope guardrails

In scope (Phase 1):
  • Morning Brief surface (1 deterministic aggregator + 1 Ruby agent + 1 route + 1 UI card)
  • Per-appointment Prep Hints (1 deterministic aggregator + 1 Ruby agent + 1 route + 1 UI column)
  • EOD Reconciliation (1 deterministic aggregator + 1 Ruby agent + 2 routes — read + batch-close — + 1 UI modal)
  • No-show risk scoring SQL helper (shared by Morning Brief + Prep Hints)
  • Feature flag reception_cockpit_v1 on clinic_modules
  • wrangler.toml DEEPSEEK_MODEL flip with smoke test gate
Out of scope (Phase 2 / separate plan):
  • Patient Snapshot is already covered by the existing patient-brief-summary prompt and GET /api/v1/protected/ai/patient-brief/:id route. No new code needed in Phase 1 — just verify it surfaces in the reception drawer; if the existing drawer doesn’t show it, add a <PatientBriefCard patientId={...} /> reuse, but do not fork the prompt.
  • Follow-Up Drafter, Triage Inbox (WhatsApp-gated)
  • D (missed-appointment auto-recovery) — subset of Phase 2 Drafter
  • DICOM, GPT prompts, smart booking flow (C)

File structure

Create

FileResponsibility
server/src/lib/ai/agents/reception-morning-brief.tsAggregator + Ruby call for reception-morning-brief. Exported: generateReceptionMorningBrief(clinicId, userId)
server/src/lib/ai/agents/reception-prep-hints.tsAggregator + Ruby call for reception-prep-hint. Exported: generateReceptionPrepHints(clinicId, appointmentIds)
server/src/lib/ai/agents/reception-eod-reconcile.tsAggregator + Ruby call for reception-eod-reconcile. Exported: generateReceptionEod(clinicId) and closeConfirmedBatch(clinicId, ids, userId)
server/src/lib/reception/no-show-risk.tsPure SQL helper. Exported: computeNoShowRisk(rows) that maps each appointment row to a 0–100 score.
server/src/routes/reception.tsHono router with 5 endpoints under /reception/*.
ui/src/components/receptionist/MorningBriefCard.tsxCard at top of ReceptionistOverview. Renders Ruby morning brief.
ui/src/components/receptionist/PrepHintBadge.tsxInline badge in today’s appointment table row. Hover/tap tooltip for the hint.
ui/src/components/receptionist/EodReconcileModal.tsxModal for end-of-day wrap-up with batch action buttons.
server/src/__tests__/reception/no-show-risk.test.tsUnit tests for the risk helper.
server/src/__tests__/reception/reception-routes.test.tsRoute tests with mocked Ruby calls.

Modify

FileChange
server/wrangler.toml:48,108,186DEEPSEEK_MODEL = "deepseek-chat"DEEPSEEK_MODEL = "deepseek-v4-flash" (all three envs) — gated by smoke test in Task 8
server/src/index.tsMount /reception router
ui/src/lib/serverComm.tsAdd getReceptionMorningBrief, getReceptionPrepHints, getReceptionEod, closeConfirmedBatch helpers
ui/src/components/receptionist/ReceptionistOverview.tsxAdd MorningBriefCard at top; add Prep column to today’s table with PrepHintBadge; add “Wrap up day” CTA that opens EodReconcileModal
ui/src/components/providers/ModuleProvider.tsx (or wherever module flags live)Add reception_cockpit_v1 to the known module keys if a registry exists

Task 1: No-show risk SQL helper (pure logic, no I/O)

Files:
  • Create: server/src/lib/reception/no-show-risk.ts
  • Test: server/src/__tests__/reception/no-show-risk.test.ts
  • Step 1: Write the failing test
// server/src/__tests__/reception/no-show-risk.test.ts
import { describe, it, expect } from 'vitest';
import { computeNoShowRisk } from '../../lib/reception/no-show-risk';

describe('computeNoShowRisk', () => {
  it('returns 0 for a clean record', () => {
    expect(computeNoShowRisk({
      missed90d: 0, total90d: 5, leadDaysFromBooking: 3, isFirstVisit: false,
      outstandingBalance: 0, lateArrivalsLast3: 0,
    })).toBe(0);
  });

  it('caps at 100 for the worst case', () => {
    expect(computeNoShowRisk({
      missed90d: 3, total90d: 3, leadDaysFromBooking: 30, isFirstVisit: true,
      outstandingBalance: 50_000, lateArrivalsLast3: 3,
    })).toBe(100);
  });

  it('weights past misses most heavily (40%)', () => {
    const s = computeNoShowRisk({
      missed90d: 2, total90d: 4, leadDaysFromBooking: 1, isFirstVisit: false,
      outstandingBalance: 0, lateArrivalsLast3: 0,
    });
    expect(s).toBe(20); // (2/4)*100 = 50; 50*0.4 = 20
  });

  it('treats total=0 as zero historical signal', () => {
    expect(computeNoShowRisk({
      missed90d: 0, total90d: 0, leadDaysFromBooking: 5, isFirstVisit: true,
      outstandingBalance: 0, lateArrivalsLast3: 0,
    })).toBe(15); // first-visit weight only
  });
});
  • Step 2: Run test to verify it fails
cd server && npx vitest run src/__tests__/reception/no-show-risk.test.ts
Expected: FAIL — module not found.
  • Step 3: Implement the helper
// server/src/lib/reception/no-show-risk.ts

export interface NoShowRiskInput {
  missed90d: number;
  total90d: number;
  leadDaysFromBooking: number;  // days between booking_created_at and appointment_date
  isFirstVisit: boolean;
  outstandingBalance: number;   // in PKR
  lateArrivalsLast3: number;    // 0..3
}

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));

export function computeNoShowRisk(i: NoShowRiskInput): number {
  const missRate = i.total90d > 0 ? (i.missed90d / i.total90d) * 100 : 0;
  const longLead = i.leadDaysFromBooking >= 14 ? 100 : 0;
  const firstVisit = i.isFirstVisit ? 100 : 0;
  const balance = i.outstandingBalance > 0 ? 100 : 0;
  const lateness = clamp(i.lateArrivalsLast3 * 33, 0, 100);

  const raw =
    0.40 * missRate +
    0.25 * longLead +
    0.15 * firstVisit +
    0.10 * balance +
    0.10 * lateness;

  return Math.round(clamp(raw, 0, 100));
}
  • Step 4: Run test to verify it passes
cd server && npx vitest run src/__tests__/reception/no-show-risk.test.ts
Expected: PASS all 4 tests.
  • Step 5: Commit
git add server/src/lib/reception/no-show-risk.ts server/src/__tests__/reception/no-show-risk.test.ts
git commit -m "feat(reception): no-show risk scoring helper"

Task 2: Morning Brief agent

Files:
  • Create: server/src/lib/ai/agents/reception-morning-brief.ts
  • Step 1: Add the empty agent shell + types
// server/src/lib/ai/agents/reception-morning-brief.ts
import { callAIJson } from '../client';
import { PROMPTS, PROMPT_NAMES } from '../prompts';
import { getReadDb } from '../../db';
import { getDatabaseUrl } from '../../env';
import { decryptPatientPHI } from '../../encryption';
import { appointments, invoices, patients, patientRecalls, doctorSchedules, inventoryItems, users } from '../../../schema';
import { eq, and, sql, gte, lte, lt, or, not, desc, inArray } from 'drizzle-orm';
import { computeNoShowRisk } from '../../reception/no-show-risk';

export interface MorningBriefPriorityItem {
  action: string;
  rationale: string;
  deepLink: string;
}

export interface MorningBriefResult {
  greeting: string;
  headline: string;
  priorityList: MorningBriefPriorityItem[];
  operationalAlerts: string[];
  generatedAt: string;
}

export async function generateReceptionMorningBrief(
  clinicId: string,
  userId?: string,
  opts: { receptionistName?: string } = {},
): Promise<MorningBriefResult> {
  // implementation in next step
  throw new Error('not implemented');
}
  • Step 2: Implement the deterministic aggregator + Ruby call
Replace the body of generateReceptionMorningBrief with:
const db = getReadDb(getDatabaseUrl());
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const ninetyAgo = new Date(now); ninetyAgo.setDate(ninetyAgo.getDate() - 90);
const ninetyAgoStr = ninetyAgo.toISOString().split('T')[0];

// Today's appointments + denormalised patient signals for risk scoring.
const todayAppts = await db
  .select({
    id: appointments.id,
    patientId: appointments.patientId,
    firstName: patients.firstName,
    lastName: patients.lastName,
    appointmentTime: appointments.appointmentTime,
    appointmentType: appointments.appointmentType,
    status: appointments.status,
    doctorId: appointments.doctorId,
    roomId: appointments.roomId,
    createdAt: appointments.createdAt,
  })
  .from(appointments)
  .innerJoin(patients, eq(patients.id, appointments.patientId))
  .where(and(
    eq(appointments.clinicId, clinicId),
    eq(appointments.appointmentDate, todayStr),
  ))
  .orderBy(appointments.appointmentTime);

// Per-patient 90d miss stats.
const patientIds = Array.from(new Set(todayAppts.map(a => a.patientId)));
const missStats = patientIds.length === 0 ? [] : await db
  .select({
    patientId: appointments.patientId,
    total: sql<number>`COUNT(*)::int`,
    missed: sql<number>`COUNT(*) FILTER (WHERE ${appointments.status} = 'missed')::int`,
    firstAppt: sql<string>`MIN(${appointments.appointmentDate})::text`,
  })
  .from(appointments)
  .where(and(
    eq(appointments.clinicId, clinicId),
    inArray(appointments.patientId, patientIds),
    gte(appointments.appointmentDate, ninetyAgoStr),
  ))
  .groupBy(appointments.patientId);

const statsByPatient = new Map(missStats.map(r => [r.patientId, r]));

// Balances per appointment.
const balances = await db
  .select({
    patientId: invoices.patientId,
    balance: sql<string>`COALESCE(SUM(${invoices.balance}::numeric), 0)::text`,
  })
  .from(invoices)
  .where(and(
    eq(invoices.clinicId, clinicId),
    sql`${invoices.balance}::numeric > 0`,
    not(eq(invoices.status, 'cancelled')),
    patientIds.length > 0 ? inArray(invoices.patientId, patientIds) : sql`false`,
  ))
  .groupBy(invoices.patientId);
const balanceByPatient = new Map(balances.map(r => [r.patientId, parseFloat(r.balance)]));

// Recalls due today + overdue.
const recallCounts = await db
  .select({
    dueToday: sql<number>`COUNT(*) FILTER (WHERE ${patientRecalls.dueDate}::date = ${todayStr})::int`,
    overdue: sql<number>`COUNT(*) FILTER (WHERE ${patientRecalls.dueDate}::date < ${todayStr})::int`,
  })
  .from(patientRecalls)
  .where(and(eq(patientRecalls.clinicId, clinicId), eq(patientRecalls.status, 'pending')));

// Doctor coverage signal.
const docSchedules = await db
  .select({ doctorId: doctorSchedules.doctorId })
  .from(doctorSchedules)
  .where(eq(doctorSchedules.clinicId, clinicId));
const doctorsWithSchedule = new Set(docSchedules.map(r => r.doctorId));

const activeDoctors = await db
  .select({ id: users.id, firstName: users.firstName, lastName: users.lastName })
  .from(users)
  .where(and(eq(users.clinicId, clinicId), eq(users.role, 'doctor'), eq(users.isActive, true)));

// Inventory low/out signal (count only).
const inv = await db
  .select({ outOfStock: sql<number>`COUNT(*) FILTER (WHERE quantity = 0 AND min_stock > 0)::int` })
  .from(inventoryItems)
  .where(eq(inventoryItems.clinicId, clinicId));

// Build derived per-appointment info.
const enriched = todayAppts.map(a => {
  const stats = statsByPatient.get(a.patientId);
  const isFirstVisit = !stats || stats.total === 1;
  const leadDays = a.createdAt
    ? Math.max(0, Math.floor((new Date(todayStr).getTime() - new Date(a.createdAt).getTime()) / 86_400_000))
    : 0;
  const risk = computeNoShowRisk({
    missed90d: stats?.missed ?? 0,
    total90d: stats?.total ?? 0,
    leadDaysFromBooking: leadDays,
    isFirstVisit,
    outstandingBalance: balanceByPatient.get(a.patientId) ?? 0,
    lateArrivalsLast3: 0, // not tracked yet
  });
  const decrypted = decryptPatientPHI({ firstName: a.firstName, lastName: a.lastName, phone: null });
  return {
    id: a.id,
    first: decrypted.firstName,
    time: a.appointmentTime?.slice(0, 5) || '?',
    type: a.appointmentType,
    doctorAssigned: !!a.doctorId,
    roomAssigned: !!a.roomId,
    status: a.status,
    noShowRisk: risk,
    balance: balanceByPatient.get(a.patientId) ?? 0,
    isFirstVisit,
    missedLastTwo: (stats?.missed ?? 0) >= 2,
  };
});

const totalToday = enriched.length;
const confirmed = enriched.filter(e => e.status === 'confirmed').length;
const unconfirmed = enriched.filter(e => e.status === 'scheduled').length;
const firstVisits = enriched.filter(e => e.isFirstVisit).length;
const withBalance = enriched.filter(e => e.balance > 0).length;
const highRisk = enriched.filter(e => e.noShowRisk >= 60).length;
const unassignedDoctor = enriched.filter(e => !e.doctorAssigned).length;

const topRiskPatients = enriched
  .filter(e => e.noShowRisk >= 60)
  .sort((a, b) => b.noShowRisk - a.noShowRisk)
  .slice(0, 3)
  .map(e => ({
    first: e.first,
    time: e.time,
    reason: e.missedLastTwo ? 'missed last 2 visits' : 'high no-show risk',
  }));

const topOwed = enriched
  .filter(e => e.balance > 0)
  .sort((a, b) => b.balance - a.balance)
  .slice(0, 3)
  .map(e => ({ first: e.first, balance: e.balance }));

const expectedCollections = enriched.reduce((acc, e) => acc + e.balance, 0);

const doctorScheduleGaps = activeDoctors
  .filter(d => !doctorsWithSchedule.has(d.id))
  .slice(0, 3)
  .map(d => {
    const dec = decryptPatientPHI({ firstName: d.firstName ?? '', lastName: d.lastName ?? '', phone: null });
    return `No schedule defined for Dr. ${dec.firstName} ${dec.lastName} — slot picker will be unconstrained`;
  });

const context = {
  date: todayStr,
  dayName: now.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'Asia/Karachi' }),
  receptionistName: opts.receptionistName ?? null,
  appointments: {
    totalToday, confirmed, unconfirmed, firstVisits, withBalance, highNoShowRisk: highRisk, unassignedDoctor,
    topRiskPatients,
  },
  balances: { expectedCollections, patientsOwing: withBalance, topOwed },
  recalls: { dueToday: recallCounts[0]?.dueToday ?? 0, overdue: recallCounts[0]?.overdue ?? 0 },
  operations: {
    doctorsAvailable: activeDoctors.length,
    doctorScheduleGaps,
    inventoryLow: inv[0]?.outOfStock ?? 0,
  },
};

const result = await callAIJson<MorningBriefResult>({
  agentName: 'reception-morning-brief-agent',
  promptName: PROMPT_NAMES.receptionMorningBrief,
  systemPrompt: PROMPTS.receptionMorningBrief,
  input: JSON.stringify(context),
  clinicId, userId,
  temperature: 0.3, maxTokens: 1200,
  metadata: { surface: 'reception-morning-brief' },
});

return {
  greeting: result.data.greeting ?? '',
  headline: result.data.headline ?? '',
  priorityList: Array.isArray(result.data.priorityList) ? result.data.priorityList.slice(0, 6) : [],
  operationalAlerts: Array.isArray(result.data.operationalAlerts) ? result.data.operationalAlerts.slice(0, 4) : [],
  generatedAt: new Date().toISOString(),
};
  • Step 3: Type-check + lint
cd server && npx tsc --noEmit 2>&1 | grep -E "reception-morning-brief|error" | head -10
Expected: no errors. (patientRecalls, doctorSchedules already exist in schema.)
  • Step 4: Commit
git add server/src/lib/ai/agents/reception-morning-brief.ts
git commit -m "feat(reception): morning brief Ruby agent + aggregator"

Task 3: Prep Hints agent

Files:
  • Create: server/src/lib/ai/agents/reception-prep-hints.ts
  • Step 1: Implement aggregator + Ruby call
// server/src/lib/ai/agents/reception-prep-hints.ts
import { callAIJson } from '../client';
import { PROMPTS, PROMPT_NAMES } from '../prompts';
import { getReadDb } from '../../db';
import { getDatabaseUrl } from '../../env';
import { decryptPatientPHI } from '../../encryption';
import { appointments, invoices, patients } from '../../../schema';
import { eq, and, sql, inArray, gte, lt, desc, not } from 'drizzle-orm';
import { computeNoShowRisk } from '../../reception/no-show-risk';

export interface PrepHint {
  appointmentId: string;
  tone: 'ok' | 'info' | 'warn' | 'urgent';
  hint: string;
  flags: string[];
}

export interface PrepHintsResult {
  hints: PrepHint[];
  generatedAt: string;
}

export async function generateReceptionPrepHints(
  clinicId: string,
  appointmentIds: string[],
  userId?: string,
): Promise<PrepHintsResult> {
  if (appointmentIds.length === 0) {
    return { hints: [], generatedAt: new Date().toISOString() };
  }
  const db = getReadDb(getDatabaseUrl());
  const now = new Date();
  const ninetyAgo = new Date(now); ninetyAgo.setDate(ninetyAgo.getDate() - 90);
  const ninetyAgoStr = ninetyAgo.toISOString().split('T')[0];

  const appts = await db
    .select({
      id: appointments.id,
      patientId: appointments.patientId,
      firstName: patients.firstName,
      lastName: patients.lastName,
      appointmentTime: appointments.appointmentTime,
      appointmentType: appointments.appointmentType,
      doctorId: appointments.doctorId,
      roomId: appointments.roomId,
      createdAt: appointments.createdAt,
      status: appointments.status,
    })
    .from(appointments)
    .innerJoin(patients, eq(patients.id, appointments.patientId))
    .where(and(eq(appointments.clinicId, clinicId), inArray(appointments.id, appointmentIds)));

  const patientIds = Array.from(new Set(appts.map(a => a.patientId)));
  const missStats = patientIds.length === 0 ? [] : await db
    .select({
      patientId: appointments.patientId,
      total: sql<number>`COUNT(*)::int`,
      missed: sql<number>`COUNT(*) FILTER (WHERE ${appointments.status} = 'missed')::int`,
    })
    .from(appointments)
    .where(and(
      eq(appointments.clinicId, clinicId),
      inArray(appointments.patientId, patientIds),
      gte(appointments.appointmentDate, ninetyAgoStr),
    ))
    .groupBy(appointments.patientId);
  const statsByPatient = new Map(missStats.map(r => [r.patientId, r]));

  // Last completed procedure per patient (for continuity hints)
  const lastProcedures = patientIds.length === 0 ? [] : await db
    .select({
      patientId: appointments.patientId,
      lastProcedure: appointments.appointmentType,
      lastDate: appointments.appointmentDate,
    })
    .from(appointments)
    .where(and(
      eq(appointments.clinicId, clinicId),
      inArray(appointments.patientId, patientIds),
      eq(appointments.status, 'completed'),
    ))
    .orderBy(desc(appointments.appointmentDate))
    .limit(patientIds.length * 5);
  const lastProcByPatient = new Map<string, { lastProcedure: string; lastDate: string }>();
  for (const r of lastProcedures) {
    if (!lastProcByPatient.has(r.patientId)) lastProcByPatient.set(r.patientId, { lastProcedure: r.lastProcedure, lastDate: r.lastDate });
  }

  const balances = patientIds.length === 0 ? [] : await db
    .select({
      patientId: invoices.patientId,
      balance: sql<string>`COALESCE(SUM(${invoices.balance}::numeric), 0)::text`,
    })
    .from(invoices)
    .where(and(
      eq(invoices.clinicId, clinicId),
      sql`${invoices.balance}::numeric > 0`,
      not(eq(invoices.status, 'cancelled')),
      inArray(invoices.patientId, patientIds),
    ))
    .groupBy(invoices.patientId);
  const balanceByPatient = new Map(balances.map(r => [r.patientId, parseFloat(r.balance)]));

  const today = new Date().toISOString().split('T')[0];
  const enriched = appts.map(a => {
    const stats = statsByPatient.get(a.patientId);
    const isFirstVisit = !stats || stats.total === 0;
    const leadDays = a.createdAt
      ? Math.max(0, Math.floor((new Date(today).getTime() - new Date(a.createdAt).getTime()) / 86_400_000))
      : 0;
    const noShowRisk = computeNoShowRisk({
      missed90d: stats?.missed ?? 0,
      total90d: stats?.total ?? 0,
      leadDaysFromBooking: leadDays,
      isFirstVisit,
      outstandingBalance: balanceByPatient.get(a.patientId) ?? 0,
      lateArrivalsLast3: 0,
    });
    const decrypted = decryptPatientPHI({ firstName: a.firstName, lastName: a.lastName, phone: null });
    const last = lastProcByPatient.get(a.patientId);
    return {
      id: a.id,
      first: decrypted.firstName,
      time: a.appointmentTime?.slice(0, 5) || '?',
      type: a.appointmentType,
      isFirstVisit,
      hasConsent: true, // consent tracking lives elsewhere; placeholder until that surfaces
      balanceOwed: balanceByPatient.get(a.patientId) ?? 0,
      doctorAssigned: !!a.doctorId,
      roomAssigned: !!a.roomId,
      noShowRisk,
      lastVisit: last?.lastDate ?? null,
      lastProcedure: last?.lastProcedure ?? null,
    };
  });

  const result = await callAIJson<PrepHintsResult>({
    agentName: 'reception-prep-hints-agent',
    promptName: PROMPT_NAMES.receptionPrepHint,
    systemPrompt: PROMPTS.receptionPrepHint,
    input: JSON.stringify({ appointments: enriched }),
    clinicId, userId,
    temperature: 0.3, maxTokens: 1500,
    metadata: { surface: 'reception-prep-hint', count: enriched.length },
  });

  return {
    hints: Array.isArray(result.data.hints) ? result.data.hints : [],
    generatedAt: new Date().toISOString(),
  };
}
  • Step 2: Type-check
cd server && npx tsc --noEmit 2>&1 | grep -E "reception-prep-hints|error" | head -10
Expected: no errors.
  • Step 3: Commit
git add server/src/lib/ai/agents/reception-prep-hints.ts
git commit -m "feat(reception): per-appointment prep hints agent"

Task 4: EOD Reconciliation agent + batch close

Files:
  • Create: server/src/lib/ai/agents/reception-eod-reconcile.ts
  • Step 1: Implement aggregator + Ruby call + batch close
// server/src/lib/ai/agents/reception-eod-reconcile.ts
import { callAIJson } from '../client';
import { PROMPTS, PROMPT_NAMES } from '../prompts';
import { getReadDb, getWriteDb } from '../../db';
import { getDatabaseUrl } from '../../env';
import { decryptPatientPHI } from '../../encryption';
import { appointments, invoices, patients } from '../../../schema';
import { eq, and, sql, inArray, not, isNull, or } from 'drizzle-orm';

export interface EodBatchAction {
  label: string;
  ids: string[];
  endpoint: string;
  deepLink: string;
  tone: 'info' | 'warn' | 'urgent';
  disabledReason: string;
}

export interface EodResult {
  summary: string;
  batchActions: EodBatchAction[];
  collectionsNote: string;
  tomorrowSetup: string;
  generatedAt: string;
}

export async function generateReceptionEod(clinicId: string, userId?: string): Promise<EodResult> {
  const db = getReadDb(getDatabaseUrl());
  const now = new Date();
  const todayStr = now.toISOString().split('T')[0];
  const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
  const tomorrowStr = tomorrow.toISOString().split('T')[0];

  const unclosed = await db
    .select({
      id: appointments.id, firstName: patients.firstName, lastName: patients.lastName,
      appointmentTime: appointments.appointmentTime, appointmentType: appointments.appointmentType,
    })
    .from(appointments)
    .innerJoin(patients, eq(patients.id, appointments.patientId))
    .where(and(
      eq(appointments.clinicId, clinicId),
      eq(appointments.appointmentDate, todayStr),
      or(eq(appointments.status, 'scheduled'), eq(appointments.status, 'confirmed')),
      sql`${appointments.appointmentTime}::time < NOW()::time`,
    ));

  const completedNoInvoice = await db
    .select({
      id: appointments.id, firstName: patients.firstName, lastName: patients.lastName,
      appointmentType: appointments.appointmentType,
    })
    .from(appointments)
    .innerJoin(patients, eq(patients.id, appointments.patientId))
    .where(and(
      eq(appointments.clinicId, clinicId),
      eq(appointments.appointmentDate, todayStr),
      eq(appointments.status, 'completed'),
      sql`NOT EXISTS (SELECT 1 FROM app.invoices inv WHERE inv.appointment_id = ${appointments.id})`,
    ));

  const missed = await db
    .select({
      id: appointments.id, firstName: patients.firstName, lastName: patients.lastName,
      appointmentTime: appointments.appointmentTime, phone: patients.phone,
    })
    .from(appointments)
    .innerJoin(patients, eq(patients.id, appointments.patientId))
    .where(and(
      eq(appointments.clinicId, clinicId),
      eq(appointments.appointmentDate, todayStr),
      eq(appointments.status, 'missed'),
    ));

  // Collections today
  const [collections] = await db
    .select({
      collected: sql<string>`COALESCE(SUM(${invoices.totalPaid}::numeric), 0)::text`,
      expected: sql<string>`COALESCE(SUM(${invoices.totalAmount}::numeric), 0)::text`,
    })
    .from(invoices)
    .innerJoin(appointments, eq(appointments.id, invoices.appointmentId))
    .where(and(
      eq(invoices.clinicId, clinicId),
      eq(appointments.appointmentDate, todayStr),
    ));

  // Tomorrow signal
  const [tomorrowCounts] = await db
    .select({
      total: sql<number>`COUNT(*)::int`,
      unconfirmed: sql<number>`COUNT(*) FILTER (WHERE ${appointments.status} = 'scheduled')::int`,
    })
    .from(appointments)
    .where(and(eq(appointments.clinicId, clinicId), eq(appointments.appointmentDate, tomorrowStr)));

  const dec = (r: { firstName: string; lastName: string }) => decryptPatientPHI({ firstName: r.firstName, lastName: r.lastName, phone: null }).firstName;

  const context = {
    date: todayStr,
    unclosedConfirmed: unclosed.map(r => ({ id: r.id, first: dec(r), time: r.appointmentTime?.slice(0, 5) || '?', type: r.appointmentType })),
    completedNoInvoice: completedNoInvoice.map(r => ({ id: r.id, first: dec(r), type: r.appointmentType, estimatedFee: 0 })),
    missedToday: missed.map(r => ({ id: r.id, first: dec(r), time: r.appointmentTime?.slice(0, 5) || '?', phoneOnFile: !!r.phone })),
    balancesCollectedToday: parseFloat(collections?.collected ?? '0'),
    balancesExpectedToday: parseFloat(collections?.expected ?? '0'),
    tomorrowUnconfirmedCount: tomorrowCounts?.unconfirmed ?? 0,
    tomorrowTotalCount: tomorrowCounts?.total ?? 0,
  };

  const result = await callAIJson<EodResult>({
    agentName: 'reception-eod-reconcile-agent',
    promptName: PROMPT_NAMES.receptionEodReconcile,
    systemPrompt: PROMPTS.receptionEodReconcile,
    input: JSON.stringify(context),
    clinicId, userId,
    temperature: 0.2, maxTokens: 1500,
    metadata: { surface: 'reception-eod-reconcile' },
  });

  return {
    summary: result.data.summary ?? '',
    batchActions: Array.isArray(result.data.batchActions) ? result.data.batchActions.slice(0, 6) : [],
    collectionsNote: result.data.collectionsNote ?? '',
    tomorrowSetup: result.data.tomorrowSetup ?? '',
    generatedAt: new Date().toISOString(),
  };
}

export async function closeConfirmedBatch(
  clinicId: string,
  appointmentIds: string[],
  userId: string,
): Promise<{ updated: number }> {
  if (appointmentIds.length === 0) return { updated: 0 };
  const db = getWriteDb(getDatabaseUrl());
  const todayStr = new Date().toISOString().split('T')[0];

  const result = await db
    .update(appointments)
    .set({ status: 'completed', updatedAt: new Date() })
    .where(and(
      eq(appointments.clinicId, clinicId),
      inArray(appointments.id, appointmentIds),
      eq(appointments.appointmentDate, todayStr),
      or(eq(appointments.status, 'scheduled'), eq(appointments.status, 'confirmed')),
      sql`${appointments.appointmentTime}::time < NOW()::time`,
    ))
    .returning({ id: appointments.id });

  return { updated: result.length };
}
  • Step 2: Type-check
cd server && npx tsc --noEmit 2>&1 | grep -E "reception-eod|error" | head -10
Expected: no errors. Note: if invoices.totalPaid / totalAmount column names differ in our schema, substitute with the actual fields (check server/src/schema/invoices.ts).
  • Step 3: Commit
git add server/src/lib/ai/agents/reception-eod-reconcile.ts
git commit -m "feat(reception): EOD reconciliation agent + batch close"

Task 5: Hono routes

Files:
  • Create: server/src/routes/reception.ts
  • Modify: server/src/index.ts
  • Step 1: Create the route file
// server/src/routes/reception.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { handleError, AppError } from '../lib/errors';
import { requireClinicContext } from '../middleware/clinic-context';
import { requireRole } from '../middleware/role-guard';
import { generateReceptionMorningBrief } from '../lib/ai/agents/reception-morning-brief';
import { generateReceptionPrepHints } from '../lib/ai/agents/reception-prep-hints';
import { generateReceptionEod, closeConfirmedBatch } from '../lib/ai/agents/reception-eod-reconcile';

const reception = new Hono();

const ALLOWED_ROLES = ['receptionist', 'admin', 'doctor'] as const;

reception.use('*', requireClinicContext);
reception.use('*', requireRole(ALLOWED_ROLES));

reception.get('/morning-brief', async (c) => {
  try {
    const clinicId = c.get('clinicId') as string;
    const userId = c.get('userId') as string | undefined;
    const receptionistName = c.get('userFirstName') as string | undefined;
    const result = await generateReceptionMorningBrief(clinicId, userId, { receptionistName });
    return c.json({ success: true, data: result });
  } catch (e) {
    return handleError(c, e);
  }
});

reception.post('/morning-brief/refresh', async (c) => {
  try {
    const clinicId = c.get('clinicId') as string;
    const userId = c.get('userId') as string | undefined;
    const receptionistName = c.get('userFirstName') as string | undefined;
    const result = await generateReceptionMorningBrief(clinicId, userId, { receptionistName });
    return c.json({ success: true, data: result });
  } catch (e) {
    return handleError(c, e);
  }
});

const PrepHintsBody = z.object({ appointmentIds: z.array(z.string()).max(100) });
reception.post('/prep-hints', async (c) => {
  try {
    const clinicId = c.get('clinicId') as string;
    const userId = c.get('userId') as string | undefined;
    const body = PrepHintsBody.parse(await c.req.json());
    const result = await generateReceptionPrepHints(clinicId, body.appointmentIds, userId);
    return c.json({ success: true, data: result });
  } catch (e) {
    return handleError(c, e);
  }
});

reception.get('/eod', async (c) => {
  try {
    const clinicId = c.get('clinicId') as string;
    const userId = c.get('userId') as string | undefined;
    const result = await generateReceptionEod(clinicId, userId);
    return c.json({ success: true, data: result });
  } catch (e) {
    return handleError(c, e);
  }
});

const CloseBody = z.object({ appointmentIds: z.array(z.string()).min(1).max(50) });
reception.post('/eod/close-confirmed', async (c) => {
  try {
    const clinicId = c.get('clinicId') as string;
    const userId = c.get('userId') as string;
    const body = CloseBody.parse(await c.req.json());
    const result = await closeConfirmedBatch(clinicId, body.appointmentIds, userId);
    return c.json({ success: true, data: result });
  } catch (e) {
    return handleError(c, e);
  }
});

export default reception;
  • Step 2: Mount the router in server/src/index.ts
Find the existing route mounts (search for app.route('/api/v1/protected/). Add:
import reception from './routes/reception';
// ... existing mounts
app.route('/api/v1/protected/reception', reception);
  • Step 3: Confirm middleware names match the existing convention
ls server/src/middleware/ | grep -E "role|clinic"
Adjust import paths in reception.ts to match the actual middleware filenames (role-guard vs roleGuard, etc). If requireRole doesn’t exist, find the equivalent and use it.
  • Step 4: Type-check
cd server && npx tsc --noEmit 2>&1 | grep -E "reception|error" | head -20
Expected: no errors.
  • Step 5: Commit
git add server/src/routes/reception.ts server/src/index.ts
git commit -m "feat(reception): /reception/* Hono routes"

Task 6: Route tests

Files:
  • Create: server/src/__tests__/reception/reception-routes.test.ts
  • Step 1: Write integration tests with mocked Ruby
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';

vi.mock('../../lib/ai/agents/reception-morning-brief', () => ({
  generateReceptionMorningBrief: vi.fn(async () => ({
    greeting: 'Good morning',
    headline: '5 today',
    priorityList: [],
    operationalAlerts: [],
    generatedAt: new Date().toISOString(),
  })),
}));

vi.mock('../../lib/ai/agents/reception-prep-hints', () => ({
  generateReceptionPrepHints: vi.fn(async () => ({ hints: [], generatedAt: new Date().toISOString() })),
}));

vi.mock('../../lib/ai/agents/reception-eod-reconcile', () => ({
  generateReceptionEod: vi.fn(async () => ({
    summary: 'all clear', batchActions: [], collectionsNote: '', tomorrowSetup: '',
    generatedAt: new Date().toISOString(),
  })),
  closeConfirmedBatch: vi.fn(async () => ({ updated: 3 })),
}));

// Bypass auth middleware for these tests — adjust to your test harness pattern.
vi.mock('../../middleware/clinic-context', () => ({
  requireClinicContext: async (c: any, next: any) => { c.set('clinicId', 'clinic_test'); c.set('userId', 'user_test'); c.set('userFirstName', 'Aisha'); await next(); },
}));
vi.mock('../../middleware/role-guard', () => ({
  requireRole: () => (async (_c: any, next: any) => next()),
}));

import reception from '../../routes/reception';

describe('reception routes', () => {
  let app: Hono;
  beforeEach(() => { app = new Hono(); app.route('/reception', reception); });

  it('GET /morning-brief returns 200 with brief', async () => {
    const res = await app.request('/reception/morning-brief');
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.data.headline).toBe('5 today');
  });

  it('POST /prep-hints rejects >100 ids', async () => {
    const res = await app.request('/reception/prep-hints', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ appointmentIds: Array(101).fill('a') }),
    });
    expect(res.status).toBeGreaterThanOrEqual(400);
  });

  it('POST /eod/close-confirmed returns updated count', async () => {
    const res = await app.request('/reception/eod/close-confirmed', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ appointmentIds: ['a', 'b', 'c'] }),
    });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.data.updated).toBe(3);
  });
});
  • Step 2: Run tests
cd server && npx vitest run src/__tests__/reception/reception-routes.test.ts
Expected: 3 PASS. If middleware import paths don’t match the codebase, fix and re-run.
  • Step 3: Commit
git add server/src/__tests__/reception/reception-routes.test.ts
git commit -m "test(reception): route smoke tests"

Task 7: UI client helpers

Files:
  • Modify: ui/src/lib/serverComm.ts
  • Step 1: Add the four helpers
Find the file’s existing helper style (search for export async function getReceptionistStats to anchor the pattern). Append:
export interface MorningBriefPriorityItem { action: string; rationale: string; deepLink: string; }
export interface MorningBriefResponse {
  greeting: string;
  headline: string;
  priorityList: MorningBriefPriorityItem[];
  operationalAlerts: string[];
  generatedAt: string;
}

export async function getReceptionMorningBrief(): Promise<MorningBriefResponse> {
  const r = await fetchProtected('/reception/morning-brief');
  return r.data;
}

export async function refreshReceptionMorningBrief(): Promise<MorningBriefResponse> {
  const r = await fetchProtected('/reception/morning-brief/refresh', { method: 'POST' });
  return r.data;
}

export interface PrepHint { appointmentId: string; tone: 'ok' | 'info' | 'warn' | 'urgent'; hint: string; flags: string[]; }
export async function getReceptionPrepHints(appointmentIds: string[]): Promise<{ hints: PrepHint[] }> {
  const r = await fetchProtected('/reception/prep-hints', {
    method: 'POST',
    body: JSON.stringify({ appointmentIds }),
  });
  return r.data;
}

export interface EodBatchAction { label: string; ids: string[]; endpoint: string; deepLink: string; tone: 'info' | 'warn' | 'urgent'; disabledReason: string; }
export interface EodResponse { summary: string; batchActions: EodBatchAction[]; collectionsNote: string; tomorrowSetup: string; generatedAt: string; }

export async function getReceptionEod(): Promise<EodResponse> {
  const r = await fetchProtected('/reception/eod');
  return r.data;
}

export async function closeConfirmedAppointments(appointmentIds: string[]): Promise<{ updated: number }> {
  const r = await fetchProtected('/reception/eod/close-confirmed', {
    method: 'POST',
    body: JSON.stringify({ appointmentIds }),
  });
  return r.data;
}
If fetchProtected is named differently in this codebase (e.g. apiFetch, protectedFetch), use the actual name — search for an existing getReceptionistStats implementation to mirror its call style exactly.
  • Step 2: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(reception/ui): client helpers for reception endpoints"

Task 8: UI components

Files:
  • Create: ui/src/components/receptionist/MorningBriefCard.tsx
  • Create: ui/src/components/receptionist/PrepHintBadge.tsx
  • Create: ui/src/components/receptionist/EodReconcileModal.tsx
  • Modify: ui/src/components/receptionist/ReceptionistOverview.tsx
  • Step 1: MorningBriefCard
// ui/src/components/receptionist/MorningBriefCard.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { RefreshCw, AlertTriangle, ArrowRight } from 'lucide-react';
import OdontoXAIIcon from '@/components/icons/OdontoXAIIcon';
import { getReceptionMorningBrief, refreshReceptionMorningBrief } from '@/lib/serverComm';
import { useNavigate } from 'react-router-dom';
import { toast } from '@/lib/toast';

export function MorningBriefCard() {
  const qc = useQueryClient();
  const nav = useNavigate();
  const { data, isLoading, isError } = useQuery({
    queryKey: ['reception', 'morning-brief'],
    queryFn: getReceptionMorningBrief,
    staleTime: 1000 * 60 * 30,
    retry: 1,
  });

  const refresh = useMutation({
    mutationFn: refreshReceptionMorningBrief,
    onSuccess: (fresh) => qc.setQueryData(['reception', 'morning-brief'], fresh),
    onError: () => toast.error("Couldn't refresh — try again in a moment."),
  });

  if (isLoading) return null;
  if (isError || !data) return null;

  return (
    <Card>
      <CardHeader className="flex flex-row items-start justify-between gap-3">
        <div className="flex items-start gap-3">
          <OdontoXAIIcon className="size-5 mt-1" />
          <div>
            <CardTitle className="text-base">{data.greeting || 'Good morning'}</CardTitle>
            <p className="text-sm text-muted-foreground mt-1">{data.headline}</p>
          </div>
        </div>
        <Button variant="ghost" size="sm" onClick={() => refresh.mutate()} disabled={refresh.isPending} aria-label="Refresh brief">
          <RefreshCw className={`size-4 ${refresh.isPending ? 'animate-spin' : ''}`} />
        </Button>
      </CardHeader>
      <CardContent className="space-y-3">
        {data.priorityList.length > 0 && (
          <ul className="space-y-2">
            {data.priorityList.map((p, i) => (
              <li key={i} className="flex items-start gap-2">
                <ArrowRight className="size-4 mt-0.5 text-primary" />
                <button
                  type="button"
                  className="text-left text-sm hover:underline disabled:opacity-50"
                  onClick={() => p.deepLink && nav(p.deepLink)}
                  disabled={!p.deepLink}
                >
                  <span className="font-medium">{p.action}</span>
                  {p.rationale && <span className="text-muted-foreground">{p.rationale}</span>}
                </button>
              </li>
            ))}
          </ul>
        )}
        {data.operationalAlerts.length > 0 && (
          <ul className="space-y-1 border-t pt-3">
            {data.operationalAlerts.map((a, i) => (
              <li key={i} className="flex items-start gap-2 text-sm text-amber-700 dark:text-amber-300">
                <AlertTriangle className="size-4 mt-0.5" />
                {a}
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
  • Step 2: PrepHintBadge
// ui/src/components/receptionist/PrepHintBadge.tsx
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { Badge } from '../ui/badge';
import type { PrepHint } from '@/lib/serverComm';

const toneClass: Record<PrepHint['tone'], string> = {
  ok: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
  info: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
  warn: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
  urgent: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
};
const toneLabel: Record<PrepHint['tone'], string> = { ok: 'OK', info: 'Info', warn: 'Prep', urgent: 'Urgent' };

export function PrepHintBadge({ hint }: { hint?: PrepHint }) {
  if (!hint || hint.tone === 'ok') return <span className="text-muted-foreground text-xs"></span>;
  return (
    <TooltipProvider delayDuration={150}>
      <Tooltip>
        <TooltipTrigger asChild>
          <Badge className={toneClass[hint.tone]} variant="secondary">{toneLabel[hint.tone]}</Badge>
        </TooltipTrigger>
        <TooltipContent side="left" className="max-w-xs">
          <p className="text-sm">{hint.hint}</p>
          {hint.flags.length > 0 && (
            <p className="text-xs text-muted-foreground mt-1">{hint.flags.join(' · ')}</p>
          )}
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}
  • Step 3: EodReconcileModal
// ui/src/components/receptionist/EodReconcileModal.tsx
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
import { Button } from '../ui/button';
import { Loader2 } from 'lucide-react';
import { getReceptionEod, closeConfirmedAppointments } from '@/lib/serverComm';
import { toast } from '@/lib/toast';
import { useNavigate } from 'react-router-dom';

export function EodReconcileModal({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
  const qc = useQueryClient();
  const nav = useNavigate();
  const [busy, setBusy] = useState<string | null>(null);

  const { data, isLoading } = useQuery({
    queryKey: ['reception', 'eod'],
    queryFn: getReceptionEod,
    enabled: open,
    staleTime: 1000 * 60 * 5,
  });

  const closeBatch = useMutation({
    mutationFn: (ids: string[]) => closeConfirmedAppointments(ids),
    onSuccess: (r) => {
      toast.success(`Closed ${r.updated} appointment${r.updated === 1 ? '' : 's'}.`);
      qc.invalidateQueries({ queryKey: ['reception'] });
    },
    onError: () => toast.error("Couldn't close — try one at a time from the list."),
  });

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-lg">
        <DialogHeader>
          <DialogTitle>Wrap up today</DialogTitle>
        </DialogHeader>
        {isLoading || !data ? (
          <div className="flex items-center justify-center py-10"><Loader2 className="animate-spin size-5" /></div>
        ) : (
          <div className="space-y-4">
            <p className="text-sm">{data.summary}</p>
            {data.collectionsNote && <p className="text-sm text-muted-foreground">{data.collectionsNote}</p>}
            <ul className="space-y-2">
              {data.batchActions.map((a, i) => {
                const isEndpoint = a.endpoint === '/reception/eod/close-confirmed';
                const disabled = !!a.disabledReason || (isEndpoint && a.ids.length === 0);
                return (
                  <li key={i}>
                    <Button
                      className="w-full justify-between"
                      variant={a.tone === 'urgent' ? 'destructive' : a.tone === 'warn' ? 'default' : 'secondary'}
                      disabled={disabled || busy === String(i)}
                      onClick={async () => {
                        if (isEndpoint) {
                          setBusy(String(i));
                          await closeBatch.mutateAsync(a.ids);
                          setBusy(null);
                        } else if (a.deepLink) {
                          nav(a.deepLink);
                          onOpenChange(false);
                        }
                      }}
                    >
                      <span>{a.label}</span>
                      {a.disabledReason && <span className="text-xs text-muted-foreground">{a.disabledReason}</span>}
                    </Button>
                  </li>
                );
              })}
            </ul>
            {data.tomorrowSetup && <p className="text-sm text-muted-foreground border-t pt-3">{data.tomorrowSetup}</p>}
          </div>
        )}
        <DialogFooter>
          <Button variant="ghost" onClick={() => onOpenChange(false)}>Close</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
  • Step 4: Wire into ReceptionistOverview
Add the imports at the top of ui/src/components/receptionist/ReceptionistOverview.tsx:
import { MorningBriefCard } from './MorningBriefCard';
import { PrepHintBadge } from './PrepHintBadge';
import { EodReconcileModal } from './EodReconcileModal';
import { getReceptionPrepHints } from '@/lib/serverComm';
Render <MorningBriefCard /> near the top of the dashboard (above the KPI grid). Add a header button:
const [eodOpen, setEodOpen] = useState(false);
// ... in the header row:
<Button variant="outline" size="sm" onClick={() => setEodOpen(true)}>Wrap up day</Button>
<EodReconcileModal open={eodOpen} onOpenChange={setEodOpen} />
For the today’s appointment table, fetch prep hints in bulk:
const todayIds = (todaysAppointments || []).map((a: any) => a.id);
const { data: prep } = useQuery({
  queryKey: ['reception', 'prep-hints', todayIds],
  queryFn: () => getReceptionPrepHints(todayIds),
  enabled: todayIds.length > 0,
  staleTime: 1000 * 60 * 5,
});
const hintById = useMemo(() => {
  const m = new Map<string, any>();
  (prep?.hints || []).forEach((h) => m.set(h.appointmentId, h));
  return m;
}, [prep]);
Add a Prep column to the appointments table:
<TableCell><PrepHintBadge hint={hintById.get(appt.id)} /></TableCell>
  • Step 5: Build + smoke-test
cd ui && npm run build 2>&1 | tail -10
Expected: build succeeds. Then npm run dev, sign in as a receptionist on Dental Square, verify Morning Brief renders + Prep column populates + Wrap-up modal opens.
  • Step 6: Commit
git add ui/src/components/receptionist/MorningBriefCard.tsx \
        ui/src/components/receptionist/PrepHintBadge.tsx \
        ui/src/components/receptionist/EodReconcileModal.tsx \
        ui/src/components/receptionist/ReceptionistOverview.tsx
git commit -m "feat(reception/ui): cockpit cards + prep badges + EOD modal"

Task 9: Smoke-test the model swap, then flip wrangler.toml

This task is intentionally last and gated. Don’t flip prod to v4-flash until a real deepseek-v4-flash request succeeds.
  • Step 1: Smoke-test via a one-off script
node -e "
const { OpenAI } = require('./server/node_modules/openai');
const c = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: 'https://api.deepseek.com' });
(async () => {
  const r = await c.chat.completions.create({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: 'Return the json {\"ok\": true}' }],
    response_format: { type: 'json_object' },
    max_tokens: 100,
  });
  console.log('OK:', r.choices[0].message.content, 'usage:', r.usage);
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
"
Required env: DEEPSEEK_API_KEY=.... Use the same key the worker uses. Expected: prints OK: {"ok": true} usage: { prompt_tokens, completion_tokens, ... } and exits 0. If the API returns “model not available” or similar, stop here. The model isn’t on our account yet. Open a ticket or fall back to deepseek-chat.
  • Step 2: Flip wrangler.toml
Only if Step 1 passed. Edit server/wrangler.toml:
# replace at lines 48, 108, 186
DEEPSEEK_MODEL = "deepseek-v4-flash"
  • Step 3: Deploy the worker
cd server && npx wrangler deploy --env production 2>&1 | tail -15
Verify the deploy log shows DEEPSEEK_MODEL = "deepseek-v4-flash".
  • Step 4: Live verify on Dental Square
Hit the /reception/morning-brief endpoint via the UI logged in as a Dental Square receptionist (or curl with a session cookie). In Langfuse, find the trace for reception-morning-brief-agent — confirm model: 'deepseek-v4-flash' and prompt_cache_hit_tokens > 0 on the second call.
  • Step 5: Commit
git add server/wrangler.toml
git commit -m "build(ai): flip prod DEEPSEEK_MODEL to deepseek-v4-flash"
  • Step 6: Deploy + force-promote per odontox-commit-deploy skill
(Already deployed in Step 3 — for UI, follow the canonical-promotion step from the skill.)

Self-review checklist

Spec coverage:
  • ✅ Morning Brief — Tasks 2, 5, 7, 8
  • ✅ Prep Hints — Tasks 3, 5, 7, 8
  • ✅ EOD Reconciliation — Tasks 4, 5, 7, 8
  • ✅ No-show risk SQL — Task 1
  • ✅ Patient Snapshot — explicit scope guardrail: reuses existing patient-brief-summary, no new code in Phase 1
  • ✅ Model swap + smoke gate — Task 9
  • ✅ Tests — Tasks 1 (unit), 6 (route smoke)
  • ✅ Langfuse prompts already pushed in the prior commit (no plan task needed)
Placeholder scan: None — every step has either code, command, or precise file location. Type consistency: MorningBriefPriorityItem, PrepHint, EodBatchAction are defined identically in the server agent files and re-declared in serverComm.ts for the UI side. MorningBriefResult is the server-internal shape; UI helper renames to MorningBriefResponse — both point at the same wire shape. Known caveats engineers will hit:
  • requireRole middleware path may not exist by that exact name — Task 5 Step 3 calls this out
  • invoices.totalPaid column may be named differently — Task 4 Step 2 calls this out
  • fetchProtected may be apiFetch or similar — Task 7 Step 1 calls this out

Out-of-scope follow-ups (file a new spec for these)

  • Patient Snapshot reception variant (if reception explicitly requests different framing after 2 weeks)
  • Phase 2 prompts: reception-followup-drafter, reception-triage-classifier
  • Missed-appointment auto-recovery (Phase 2, subset of follow-up drafter)
  • Reception feature flag UI in superadmin (controls reception_cockpit_v1 per clinic)
  • Eval dataset construction in Langfuse for the 3 new prompts