Files
helix-engage/docs/superpowers/plans/2026-03-20-appointment-availability.md
saridsa2 99bca1e008 feat: telephony overhaul + appointment availability + Force Ready
Telephony:
- Track UCID from SIP headers and ManualDial response
- Submit disposition to Ozonetel via Set Disposition API (ends ACW)
- Fix outboundPending flag lifecycle to prevent inbound poisoning
- Fix render order: post-call UI takes priority over active state
- Pre-select disposition when appointment booked during call

Appointment form:
- Convert from slideout to inline collapsible below call card
- Fetch real doctors from platform, filter by department
- Show time slot availability grid (booked slots greyed + strikethrough)
- Double-check availability before booking
- Support edit and cancel existing appointments

UI:
- Add Force Ready button to profile menu (logout+login to clear ACW)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:24:58 +05:30

13 KiB

Appointment Availability, Edit & Cancel — 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: Add doctor availability checking (grey out booked slots), appointment editing, and appointment cancellation to the inline appointment form.

Architecture: The form queries the platform for real doctors (replacing hardcoded list) and existing appointments for the selected doctor+date. Booked time slots are greyed out. After booking, the agent can edit or cancel via the same inline form. All queries go through the sidecar's GraphQL proxy.

Tech Stack: React 19 + Jotai, platform GraphQL API (Doctor + Appointment entities)

Platform entities (relevant fields):

  • Doctor: id, fullName { firstName lastName }, department (SELECT), specialty, visitingHours, active, clinic { id clinicName }
  • Appointment: id, scheduledAt (DATE_TIME), durationMin (NUMBER, default 30), appointmentType (SELECT), appointmentStatus (SELECT: SCHEDULED/CONFIRMED/CANCELLED/...), doctorName (TEXT), department (TEXT), reasonForVisit (TEXT), patientId (relation), doctorId (relation)

GraphQL field name note: SDK field durationMinutes maps to durationMin in GraphQL. SDK field isActive maps to active in GraphQL.


File Map

File Responsibility Action
src/components/call-desk/appointment-form.tsx Inline appointment form Modify: fetch real doctors, check availability, support edit/cancel mode
src/lib/queries.ts GraphQL queries Modify: add appointment-by-doctor-date query

Only 2 files. The form is self-contained — all logic stays in the component.


Task 1: Fetch real doctors from platform

Replace hardcoded doctorItems and departmentItems with real data from the platform.

Files:

  • Modify: helix-engage/src/components/call-desk/appointment-form.tsx

  • Step 1: Add doctor fetching on mount

Import apiClient at top level (remove the dynamic import in handleSave). Add state for doctors and a useEffect to fetch them:

import { useState, useEffect } from 'react';
import { apiClient } from '@/lib/api-client';

// Inside the component:
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string; clinic: string }>>([]);

useEffect(() => {
    apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
        `{ doctors(first: 50, filter: { active: { eq: true } }) { edges { node {
            id name fullName { firstName lastName } department clinic { id name clinicName }
        } } } }`,
    ).then(data => {
        const docs = data.doctors.edges.map(e => ({
            id: e.node.id,
            name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
            department: e.node.department ?? '',
            clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
        }));
        setDoctors(docs);
    }).catch(() => {});
}, []);
  • Step 2: Derive department and doctor lists from fetched data

Replace hardcoded departmentItems and doctorItems with derived lists:

const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
    .map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));

// Filter doctors by selected department
const filteredDoctors = department
    ? doctors.filter(d => d.department === department)
    : doctors;
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
  • Step 3: Update Select components to use dynamic data

The Department select uses departmentItems (derived). The Doctor select uses doctorSelectItems (filtered by department). Reset doctor selection when department changes.

  • Step 4: Update handleSave to use doctor ID

Instead of sending doctorName as a text label, send the doctorId relation:

const selectedDoctor = doctors.find(d => d.id === doctor);
await apiClient.graphql(
    `mutation CreateAppointment($data: AppointmentCreateInput!) {
        createAppointment(data: $data) { id }
    }`,
    {
        data: {
            scheduledAt,
            durationMin: 30,
            appointmentType: 'CONSULTATION',
            appointmentStatus: 'SCHEDULED',
            doctorName: selectedDoctor?.name ?? '',
            department: selectedDoctor?.department ?? '',
            reasonForVisit: chiefComplaint || null,
            doctorId: doctor,
            ...(leadId ? { patientId: leadId } : {}),
        },
    },
);
  • Step 5: Remove hardcoded doctorItems and departmentItems arrays

Delete the const doctorItems = [...] and const departmentItems = [...] arrays from the top of the file.

  • Step 6: Commit
feat: fetch real doctors from platform for appointment form

Task 2: Add slot availability checking

When the agent selects a doctor and date, query existing appointments and grey out booked slots.

Files:

  • Modify: helix-engage/src/components/call-desk/appointment-form.tsx

  • Step 1: Add booked slots state and fetch logic

const [bookedSlots, setBookedSlots] = useState<string[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);

useEffect(() => {
    if (!doctor || !date) {
        setBookedSlots([]);
        return;
    }

    setLoadingSlots(true);
    const dayStart = `${date}T00:00:00`;
    const dayEnd = `${date}T23:59:59`;

    apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
        `{ appointments(filter: {
            doctorId: { eq: "${doctor}" },
            scheduledAt: { gte: "${dayStart}", lte: "${dayEnd}" },
            appointmentStatus: { in: [SCHEDULED, CONFIRMED, CHECKED_IN, IN_PROGRESS] }
        }) { edges { node { id scheduledAt durationMin } } } }`,
    ).then(data => {
        const slots = data.appointments.edges.map(e => {
            const dt = new Date(e.node.scheduledAt);
            return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
        });
        setBookedSlots(slots);
    }).catch(() => {
        setBookedSlots([]);
    }).finally(() => setLoadingSlots(false));
}, [doctor, date]);
  • Step 2: Grey out booked slots in the time slot Select

Mark booked slots as disabled in the Select items:

const timeSlotSelectItems = timeSlotItems.map(slot => ({
    ...slot,
    isDisabled: bookedSlots.includes(slot.id),
    label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
}));

Use timeSlotSelectItems in the Time Slot Select component. Clear selected timeSlot when doctor or date changes if the slot becomes booked.

  • Step 3: Add double-check before save

In handleSave, before creating the appointment, re-query to confirm the slot is still available:

// Double-check slot availability before booking
const dayStart = `${date}T00:00:00`;
const dayEnd = `${date}T23:59:59`;
const checkResult = await apiClient.graphql<{ appointments: { totalCount: number } }>(
    `{ appointments(filter: {
        doctorId: { eq: "${doctor}" },
        scheduledAt: { gte: "${dayStart}T${timeSlot}:00", lte: "${dayStart}T${timeSlot}:00" },
        appointmentStatus: { in: [SCHEDULED, CONFIRMED] }
    }) { totalCount } }`,
);
if (checkResult.appointments.totalCount > 0) {
    setError('This slot was just booked by someone else. Please select a different time.');
    return;
}
  • Step 4: Commit
feat: check doctor availability and grey out booked time slots

Task 3: Add edit and cancel support

After an appointment is booked (via the disposition flow's APPOINTMENT_BOOKED path), allow the agent to edit or cancel it.

Files:

  • Modify: helix-engage/src/components/call-desk/appointment-form.tsx

  • Step 1: Add optional appointment prop for edit mode

Extend the props type:

type AppointmentFormProps = {
    isOpen: boolean;
    onOpenChange: (open: boolean) => void;
    callerNumber?: string | null;
    leadName?: string | null;
    leadId?: string | null;
    onSaved?: () => void;
    existingAppointment?: {
        id: string;
        scheduledAt: string;
        doctorName: string;
        doctorId?: string;
        department: string;
        reasonForVisit?: string;
        appointmentStatus: string;
    } | null;
};

When existingAppointment is provided, pre-fill the form fields from it. Change the header to "Edit Appointment" and the save button to "Update Appointment".

  • Step 2: Initialize form state from existing appointment
const isEditMode = !!existingAppointment;

// Initialize state from existing appointment if in edit mode
const [date, setDate] = useState(() => {
    if (existingAppointment?.scheduledAt) {
        return existingAppointment.scheduledAt.split('T')[0];
    }
    return '';
});
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
    if (existingAppointment?.scheduledAt) {
        const dt = new Date(existingAppointment.scheduledAt);
        return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
    }
    return null;
});
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
  • Step 3: Add update mutation to handleSave

In handleSave, if isEditMode, use updateAppointment instead of createAppointment:

if (isEditMode && existingAppointment) {
    await apiClient.graphql(
        `mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
            updateAppointment(id: $id, data: $data) { id }
        }`,
        {
            id: existingAppointment.id,
            data: {
                scheduledAt,
                doctorName: selectedDoctor?.name ?? '',
                department: selectedDoctor?.department ?? '',
                doctorId: doctor,
                reasonForVisit: chiefComplaint || null,
            },
        },
    );
} else {
    // existing create logic
}
  • Step 4: Add Cancel Appointment button

In edit mode, add a "Cancel Appointment" button in the footer:

{isEditMode && (
    <Button size="sm" color="primary-destructive" onClick={handleCancel}>
        Cancel Appointment
    </Button>
)}

The handler:

const handleCancel = async () => {
    if (!existingAppointment) return;
    setIsSaving(true);
    try {
        await apiClient.graphql(
            `mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
                updateAppointment(id: $id, data: $data) { id }
            }`,
            {
                id: existingAppointment.id,
                data: { appointmentStatus: 'CANCELLED' },
            },
        );
        notify.success('Appointment Cancelled');
        onSaved?.();
    } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
    } finally {
        setIsSaving(false);
    }
};

Import notify from @/lib/toast.

  • Step 5: Commit
feat: add appointment edit and cancel support

Task 4: Deploy and verify

  • Step 1: Type check
cd helix-engage && npx tsc --noEmit
  • Step 2: Build and deploy frontend
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
VITE_SIP_PASSWORD=523590 \
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
npm run build
  • Step 3: Test availability checking
  1. During a call, click "Book Appt"
  2. Select a department → doctor list filters
  3. Select a doctor + date → booked slots show as "(Booked)" and are disabled
  4. Select an available slot → "Book Appointment" succeeds
  5. Select the same doctor + date again → the slot you just booked should now be greyed out
  • Step 4: Test edit/cancel

This requires passing existingAppointment prop from the parent component. For now, verify the edit/cancel code compiles and the props interface is correct. Wiring the edit flow from the call history or lead 360 panel can be done as a follow-up.


Notes

  • Hardcoded clinicItems and genderItems stay — clinics aren't changing often and gender is static. Only doctors and departments come from the platform.
  • timeSlotItems stay hardcoded — these represent the hospital's standard appointment slots (30-min increments, 9am-4pm). The availability check determines which ones are booked.
  • The appointmentStatus filter uses in: [SCHEDULED, CONFIRMED, CHECKED_IN, IN_PROGRESS] — cancelled and completed appointments don't block the slot.
  • The doctorId relation links the appointment to the Doctor entity on the platform. We also keep doctorName as a denormalized text field for display.