Skip to main content

Doctor Schedule & True Slot Availability — 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 per-doctor weekly availability schedules and replace the free-form TimePicker in the patient booking form with a slot grid that shows only genuinely open, un-booked 30-minute slots. Architecture: New doctor_schedules table (created via ensureAppointmentsSchema) stores per-doctor weekly hours. A new GET /protected/appointments/available-slots endpoint intersects clinic hours + doctor schedule + existing bookings to return open slots. The patient portal replaces <TimePicker> with a <SlotPicker> component that calls this endpoint. Staff Management gets a DoctorScheduleEditor shown for doctor-role staff. Tech Stack: Hono (server), Drizzle ORM, Neon HTTP driver, React + TanStack Query, Tailwind / shadcn/ui

File Map

FileActionPurpose
server/src/schema/doctor_schedules.tsCreateDrizzle table definition
server/src/schema/index.tsModifyExport new schema
server/src/lib/schema-ensure.tsModifyAdd CREATE TABLE IF NOT EXISTS DDL to ensureAppointmentsSchema
server/src/lib/pkt.tsModifyAdd nowPKTTimeString() helper
server/src/routes/appointments.tsModifyAdd 3 new endpoints: available-slots GET, doctor-schedules GET, doctor-schedules PUT
ui/src/lib/serverComm.tsModifyAdd getAvailableSlots, getDoctorSchedule, updateDoctorSchedule
ui/src/lib/queryKeys.tsModifyAdd slots and doctorSchedules query key namespaces
ui/src/components/ui/slot-picker.tsxCreateSlot grid component
ui/src/components/settings/DoctorScheduleEditor.tsxCreateWeekly availability editor
ui/src/components/settings/StaffManagement.tsxModifyAdd “Availability” tab for doctors
ui/src/components/dashboards/PatientPortal.tsxModifySwap TimePickerSlotPicker, add doctor dropdown

Task 1: Drizzle Schema — doctor_schedules

Files:
  • Create: server/src/schema/doctor_schedules.ts
  • Modify: server/src/schema/index.ts
  • Step 1: Create schema file
// server/src/schema/doctor_schedules.ts
import { pgTable, text, time, boolean, timestamp, unique } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const doctorSchedules = appSchema.table('doctor_schedules', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull(),
  doctorId: text('doctor_id').notNull(),
  dayOfWeek: text('day_of_week').notNull(), // 'monday' | 'tuesday' | ... | 'sunday'
  startTime: time('start_time').notNull(),
  endTime: time('end_time').notNull(),
  isOff: boolean('is_off').notNull().default(false),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  clinicDoctorDayUnique: unique('doctor_schedules_clinic_doctor_day_unique').on(
    table.clinicId, table.doctorId, table.dayOfWeek
  ),
}));

export type DoctorSchedule = typeof doctorSchedules.$inferSelect;
export type NewDoctorSchedule = typeof doctorSchedules.$inferInsert;
  • Step 2: Export from schema index
In server/src/schema/index.ts, add:
export * from './doctor_schedules';
  • Step 3: Commit
git add server/src/schema/doctor_schedules.ts server/src/schema/index.ts
git commit -m "feat(schema): add doctor_schedules table definition"

Task 2: Schema-Ensure DDL + PKT Time Helper

Files:
  • Modify: server/src/lib/schema-ensure.ts (line ~386, inside ensureAppointmentsSchema)
  • Modify: server/src/lib/pkt.ts
  • Step 1: Add DDL to ensureAppointmentsSchema
In server/src/lib/schema-ensure.ts, inside ensureAppointmentsSchema before the appointmentsEnsured = true line, add:
    // Doctor weekly schedules
    await db.execute(sql`
      CREATE TABLE IF NOT EXISTS app.doctor_schedules (
        id          text PRIMARY KEY,
        clinic_id   text NOT NULL,
        doctor_id   text NOT NULL,
        day_of_week text NOT NULL,
        start_time  time NOT NULL,
        end_time    time NOT NULL,
        is_off      boolean NOT NULL DEFAULT false,
        created_at  timestamp DEFAULT now() NOT NULL,
        updated_at  timestamp DEFAULT now() NOT NULL
      )
    `);
    await db.execute(sql`
      CREATE UNIQUE INDEX IF NOT EXISTS doctor_schedules_clinic_doctor_day_unique
      ON app.doctor_schedules (clinic_id, doctor_id, day_of_week)
    `);
    await db.execute(sql`
      CREATE INDEX IF NOT EXISTS doctor_schedules_clinic_idx
      ON app.doctor_schedules (clinic_id)
    `);
  • Step 2: Add nowPKTTimeString to pkt.ts
In server/src/lib/pkt.ts, append:
/** Returns current PKT time as "HH:MM" */
export function nowPKTTimeString(): string {
  const formatter = new Intl.DateTimeFormat('en-GB', {
    timeZone: PKT_TIMEZONE,
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  });
  return formatter.format(new Date()); // e.g. "14:30"
}
  • Step 3: Commit
git add server/src/lib/schema-ensure.ts server/src/lib/pkt.ts
git commit -m "feat(server): add doctor_schedules DDL to schema-ensure + nowPKTTimeString"

Task 3: Server Endpoints — Available Slots + Doctor Schedule CRUD

Files:
  • Modify: server/src/routes/appointments.ts
Add three new route handlers at the bottom of appointments.ts (before export default appointments).
  • Step 1: Add import for new schema + pkt helper
At the top of server/src/routes/appointments.ts, in the existing imports, add doctorSchedules to the schema import and nowPKTTimeString to the pkt import:
// Change this line:
import { appointments, patients, users, clinics, invoices, clinicalNotes, rooms, clinicOperatingHours } from '../schema';
// To:
import { appointments, patients, users, clinics, invoices, clinicalNotes, rooms, clinicOperatingHours, doctorSchedules } from '../schema';

// Change this line:
import { nowPKTDateString } from '../lib/pkt';
// To:
import { nowPKTDateString, nowPKTTimeString } from '../lib/pkt';
  • Step 2: Add GET /available-slots handler
Add before export default appointments:
// ─── Available Slots ─────────────────────────────────────────────────────────

appointments.get('/available-slots', requireClinicContext, async (c) => {
  try {
    const db = getReadDb();
    await ensureAppointmentsSchema(db);

    const clinicContext = c.get('clinicContext');
    const currentClinicId = clinicContext?.currentClinicId || '';
    if (!currentClinicId) throw new AppError('Clinic ID required', 400);

    const dateParam = c.req.query('date');
    const doctorIdParam = c.req.query('doctorId') || null;

    if (!dateParam || !/^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
      throw new AppError('date query param required (YYYY-MM-DD)', 400);
    }

    const toMinutes = (t: string): number => {
      const [h, m] = t.split(':').map(Number);
      return (h || 0) * 60 + (m || 0);
    };

    const DAY_NAMES = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;
    const dayOfWeekKey = DAY_NAMES[new Date(`${dateParam}T00:00:00`).getDay()];

    // 1. Clinic operating hours for that day
    const [clinicHours] = await db.select({
      openTime: clinicOperatingHours.openTime,
      closeTime: clinicOperatingHours.closeTime,
      isClosed: clinicOperatingHours.isClosed,
    })
      .from(clinicOperatingHours)
      .where(and(
        eq(clinicOperatingHours.clinicId, currentClinicId),
        eq(clinicOperatingHours.dayOfWeek, dayOfWeekKey as any),
      ))
      .limit(1);

    // Fetch clinic phone for the closed-day response
    const [clinicRow] = await db.select({ phone: clinics.phone })
      .from(clinics)
      .where(eq(clinics.id, currentClinicId))
      .limit(1);
    const clinicPhone = clinicRow?.phone || null;

    if (clinicHours?.isClosed) {
      return c.json({ slots: [], clinicClosed: true, doctorOff: false, clinicPhone });
    }

    // Clinic window in minutes (default full day if no hours set)
    const clinicOpen = clinicHours?.openTime ? toMinutes(clinicHours.openTime) : 0;
    const clinicClose = clinicHours?.closeTime ? toMinutes(clinicHours.closeTime) : 24 * 60;

    // 2. Doctor schedule for that day (if doctorId provided)
    let effectiveOpen = clinicOpen;
    let effectiveClose = clinicClose;
    let doctorOff = false;

    if (doctorIdParam) {
      // Validate doctor belongs to this clinic
      const [doctorUser] = await db.select({ id: users.id })
        .from(users)
        .where(and(eq(users.id, doctorIdParam), eq(users.primaryClinicId, currentClinicId)))
        .limit(1);

      if (!doctorUser) throw new AppError('Doctor not found in this clinic', 403);

      const [docSchedule] = await db.select()
        .from(doctorSchedules)
        .where(and(
          eq(doctorSchedules.clinicId, currentClinicId),
          eq(doctorSchedules.doctorId, doctorIdParam),
          eq(doctorSchedules.dayOfWeek, dayOfWeekKey),
        ))
        .limit(1);

      if (docSchedule) {
        if (docSchedule.isOff) {
          return c.json({ slots: [], clinicClosed: false, doctorOff: true, clinicPhone });
        }
        // Intersect doctor hours with clinic hours
        effectiveOpen = Math.max(clinicOpen, toMinutes(docSchedule.startTime));
        effectiveClose = Math.min(clinicClose, toMinutes(docSchedule.endTime));
      }
      // If no schedule row exists for this doctor, fall back to clinic hours
    }

    if (effectiveOpen >= effectiveClose) {
      return c.json({ slots: [], clinicClosed: false, doctorOff: false, clinicPhone });
    }

    // 3. Generate 30-minute slots
    const allSlots: string[] = [];
    for (let m = effectiveOpen; m + 30 <= effectiveClose; m += 30) {
      const hh = String(Math.floor(m / 60)).padStart(2, '0');
      const mm = String(m % 60).padStart(2, '0');
      allSlots.push(`${hh}:${mm}`);
    }

    // 4. Remove already-booked slots for this doctor+date
    const bookedFilter = [
      eq(appointments.clinicId, currentClinicId),
      eq(appointments.appointmentDate, dateParam),
      sql`${appointments.status} NOT IN ('cancelled', 'rejected')`,
    ];
    if (doctorIdParam) bookedFilter.push(eq(appointments.doctorId, doctorIdParam));

    const booked = await db.select({ time: appointments.appointmentTime, duration: appointments.durationMinutes })
      .from(appointments)
      .where(and(...bookedFilter));

    const bookedMinutes = new Set<number>();
    for (const b of booked) {
      if (!b.time) continue;
      const start = toMinutes(b.time);
      const dur = Number(b.duration || 30);
      for (let m = start; m < start + dur; m += 30) bookedMinutes.add(m);
    }

    // 5. Remove past slots if date is today (PKT)
    const todayPKT = nowPKTDateString();
    const nowTimeStr = nowPKTTimeString();
    const nowMin = toMinutes(nowTimeStr);

    const availableSlots = allSlots.filter(slot => {
      const slotMin = toMinutes(slot);
      if (bookedMinutes.has(slotMin)) return false;
      if (dateParam === todayPKT && slotMin <= nowMin) return false;
      return true;
    });

    return c.json({ slots: availableSlots, clinicClosed: false, doctorOff: false, clinicPhone });
  } catch (error) {
    return handleError(error, c);
  }
});
  • Step 3: Add GET /doctor-schedules and PUT /doctor-schedules/:doctorId handlers
// ─── Doctor Schedule CRUD ─────────────────────────────────────────────────────

appointments.get('/doctor-schedules', requireClinicContext, async (c) => {
  try {
    const db = getReadDb();
    await ensureAppointmentsSchema(db);
    const clinicContext = c.get('clinicContext');
    const currentClinicId = clinicContext?.currentClinicId || '';
    if (!currentClinicId) throw new AppError('Clinic ID required', 400);

    const schedules = await db.select()
      .from(doctorSchedules)
      .where(eq(doctorSchedules.clinicId, currentClinicId));

    return c.json(schedules);
  } catch (error) {
    return handleError(error, c);
  }
});

const doctorScheduleRowSchema = z.object({
  dayOfWeek: z.enum(['monday','tuesday','wednesday','thursday','friday','saturday','sunday']),
  startTime: z.string().regex(/^\d{2}:\d{2}$/),
  endTime: z.string().regex(/^\d{2}:\d{2}$/),
  isOff: z.boolean().default(false),
});

const doctorScheduleBodySchema = z.object({
  schedule: z.array(doctorScheduleRowSchema),
});

appointments.put('/doctor-schedules/:doctorId', requireClinicContext, async (c) => {
  try {
    const db = getReadDb();
    await ensureAppointmentsSchema(db);
    const clinicContext = c.get('clinicContext');
    const currentClinicId = clinicContext?.currentClinicId || '';
    if (!currentClinicId) throw new AppError('Clinic ID required', 400);

    const targetDoctorId = c.req.param('doctorId');
    const body = doctorScheduleBodySchema.parse(await c.req.json());

    // Upsert all days using INSERT ... ON CONFLICT
    for (const row of body.schedule) {
      await db.execute(sql`
        INSERT INTO app.doctor_schedules
          (id, clinic_id, doctor_id, day_of_week, start_time, end_time, is_off, created_at, updated_at)
        VALUES (
          ${crypto.randomUUID()},
          ${currentClinicId},
          ${targetDoctorId},
          ${row.dayOfWeek},
          ${row.startTime},
          ${row.endTime},
          ${row.isOff},
          now(), now()
        )
        ON CONFLICT (clinic_id, doctor_id, day_of_week)
        DO UPDATE SET
          start_time = EXCLUDED.start_time,
          end_time   = EXCLUDED.end_time,
          is_off     = EXCLUDED.is_off,
          updated_at = now()
      `);
    }

    const updated = await db.select()
      .from(doctorSchedules)
      .where(and(
        eq(doctorSchedules.clinicId, currentClinicId),
        eq(doctorSchedules.doctorId, targetDoctorId),
      ));

    return c.json(updated);
  } catch (error) {
    return handleError(error, c);
  }
});
Note: z is already imported in appointments.ts. sql and crypto are already imported. Ensure doctorSchedules is added to the schema import from step 1.
  • Step 4: Commit
git add server/src/routes/appointments.ts
git commit -m "feat(server): add available-slots endpoint + doctor schedule CRUD"

Task 4: serverComm.ts API Functions + Query Keys

Files:
  • Modify: ui/src/lib/serverComm.ts
  • Modify: ui/src/lib/queryKeys.ts
  • Step 1: Add types and functions to serverComm.ts
At the end of ui/src/lib/serverComm.ts (before the final closing), add:
// ─── Doctor Schedules ─────────────────────────────────────────────────────────

export interface DoctorScheduleRow {
  id: string;
  clinicId: string;
  doctorId: string;
  dayOfWeek: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
  startTime: string; // "HH:MM"
  endTime: string;   // "HH:MM"
  isOff: boolean;
}

export interface AvailableSlotsResponse {
  slots: string[];        // ["09:00", "09:30", ...]
  clinicClosed: boolean;
  doctorOff: boolean;
  clinicPhone: string | null;
}

export async function getAvailableSlots(
  date: string,
  doctorId?: string
): Promise<AvailableSlotsResponse> {
  const params = new URLSearchParams({ date });
  if (doctorId) params.set('doctorId', doctorId);
  const response = await fetchWithAuth(`/api/v1/protected/appointments/available-slots?${params}`);
  return response.json();
}

export async function getDoctorSchedules(): Promise<DoctorScheduleRow[]> {
  const response = await fetchWithAuth('/api/v1/protected/appointments/doctor-schedules');
  return response.json();
}

export async function updateDoctorSchedule(
  doctorId: string,
  schedule: Array<{ dayOfWeek: DoctorScheduleRow['dayOfWeek']; startTime: string; endTime: string; isOff: boolean }>
): Promise<DoctorScheduleRow[]> {
  const response = await fetchWithAuth(`/api/v1/protected/appointments/doctor-schedules/${doctorId}`, {
    method: 'PUT',
    body: JSON.stringify({ schedule }),
  });
  if (!response.ok) {
    const body = await response.json().catch(() => null) as { message?: string } | null;
    throw new Error(body?.message || 'Failed to save schedule');
  }
  return response.json();
}
  • Step 2: Add query keys to queryKeys.ts
In ui/src/lib/queryKeys.ts, inside the qk object, add:
  slots: {
    forDate: (date: string, doctorId?: string) =>
      ['slots', clinicScope(), date, doctorId ?? 'any'] as const,
  },
  doctorSchedules: {
    all: () => ['doctorSchedules', clinicScope()] as const,
    forDoctor: (doctorId: string) => ['doctorSchedules', clinicScope(), doctorId] as const,
  },
  • Step 3: Commit
git add ui/src/lib/serverComm.ts ui/src/lib/queryKeys.ts
git commit -m "feat(ui): add serverComm functions + query keys for slots and doctor schedules"

Task 5: SlotPicker Component

Files:
  • Create: ui/src/components/ui/slot-picker.tsx
  • Step 1: Create the component
// ui/src/components/ui/slot-picker.tsx
import { useQuery } from '@tanstack/react-query';
import { qk } from '@/lib/queryKeys';
import { getAvailableSlots } from '@/lib/serverComm';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Phone } from 'lucide-react';
import { cn } from '@/lib/utils';

interface SlotPickerProps {
  date: string;          // "YYYY-MM-DD", empty string = no date selected
  doctorId?: string;     // undefined = any doctor
  value: string;         // currently selected slot "HH:MM" or ""
  onChange: (slot: string) => void;
  className?: string;
}

export function SlotPicker({ date, doctorId, value, onChange, className }: SlotPickerProps) {
  const enabled = !!date;

  const { data, isLoading } = useQuery({
    queryKey: qk.slots.forDate(date, doctorId),
    queryFn: () => getAvailableSlots(date, doctorId),
    enabled,
    staleTime: 60_000,
  });

  if (!enabled) {
    return (
      <p className="text-sm text-muted-foreground">Select a date to see available times.</p>
    );
  }

  if (isLoading) {
    return (
      <div className="grid grid-cols-4 gap-2">
        {Array.from({ length: 8 }).map((_, i) => (
          <Skeleton key={i} className="h-9 w-full rounded-md" />
        ))}
      </div>
    );
  }

  if (data?.clinicClosed || data?.doctorOff || !data?.slots.length) {
    const reason = data?.clinicClosed
      ? 'Clinic is closed on this day.'
      : data?.doctorOff
      ? 'The doctor is not available on this day.'
      : 'No available slots on this day.';

    return (
      <div className="rounded-lg border border-dashed p-4 text-center space-y-2">
        <p className="text-sm text-muted-foreground">{reason}</p>
        {data?.clinicPhone && (
          <p className="flex items-center justify-center gap-1.5 text-sm font-medium">
            <Phone className="h-3.5 w-3.5 text-primary" />
            Call us: {data.clinicPhone}
          </p>
        )}
      </div>
    );
  }

  return (
    <div className={cn('grid grid-cols-4 gap-2', className)}>
      {data.slots.map((slot) => (
        <Button
          key={slot}
          type="button"
          variant={value === slot ? 'default' : 'outline'}
          size="sm"
          className="text-xs h-9"
          onClick={() => onChange(slot)}
        >
          {slot}
        </Button>
      ))}
    </div>
  );
}
  • Step 2: Commit
git add ui/src/components/ui/slot-picker.tsx
git commit -m "feat(ui): add SlotPicker component"

Task 6: Patient Portal — Swap TimePicker → SlotPicker

Files:
  • Modify: ui/src/components/dashboards/PatientPortal.tsx
  • Step 1: Update imports
At line 29, replace the TimePicker import and add SlotPicker:
// Remove:
import { TimePicker } from '../ui/time-picker';
// Add:
import { SlotPicker } from '../ui/slot-picker';
Also add getStaffMembers to the existing serverComm import block if not already present:
import { ..., getStaffMembers } from '@/lib/serverComm';
  • Step 2: Update AppointmentsView state and doctors query
In AppointmentsView (line ~284), add a doctor selection state and a doctors query just after existing state declarations:
  const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
  const { data: staffData } = useQuery({
    queryKey: ['staff-doctors'],
    queryFn: getStaffMembers,
    select: (staff) => staff.filter(s => s.role === 'doctor' && s.isActive),
    staleTime: 5 * 60_000,
  });
  const doctors = staffData ?? [];
Also add useQuery to the React import if not already imported:
import { useState, useEffect, useRef, useCallback } from 'react';
// add if not present:
import { useQuery } from '@tanstack/react-query';
  • Step 3: Replace the TimePicker block
Find (around line 459):
              <div className="grid gap-2">
                <Label htmlFor="time">Preferred Time</Label>
                <TimePicker
                  value={formData.time}
                  onChange={(time) => setFormData({ ...formData, time })}
                  placeholder="Select time"
                />
              </div>
Replace with:
              {doctors.length > 1 && (
                <div className="grid gap-2">
                  <Label>Preferred Doctor</Label>
                  <Select
                    value={selectedDoctorId ?? 'any'}
                    onValueChange={(val) => {
                      setSelectedDoctorId(val === 'any' ? undefined : val);
                      setFormData({ ...formData, time: '' });
                    }}
                  >
                    <SelectTrigger>
                      <SelectValue placeholder="Any available doctor" />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="any">Any available doctor</SelectItem>
                      {doctors.map(d => (
                        <SelectItem key={d.id} value={d.id}>
                          Dr. {d.firstName} {d.lastName}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>
              )}
              <div className="grid gap-2">
                <Label>Available Times</Label>
                <SlotPicker
                  date={formData.date}
                  doctorId={selectedDoctorId}
                  value={formData.time}
                  onChange={(time) => setFormData({ ...formData, time })}
                />
              </div>
  • Step 4: Update the validation message
In handleRequestSubmit (line ~311), the check !formData.date || !formData.time stays unchanged — no edit needed.
  • Step 5: Commit
git add ui/src/components/dashboards/PatientPortal.tsx
git commit -m "feat(ui): replace TimePicker with SlotPicker in patient booking form"

Task 7: Doctor Schedule Editor Component

Files:
  • Create: ui/src/components/settings/DoctorScheduleEditor.tsx
  • Step 1: Create the component
// ui/src/components/settings/DoctorScheduleEditor.tsx
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { qk } from '@/lib/queryKeys';
import { getDoctorSchedules, updateDoctorSchedule, type DoctorScheduleRow } from '@/lib/serverComm';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { toast } from '@/lib/toast';
import { Loader2 } from 'lucide-react';

type Day = DoctorScheduleRow['dayOfWeek'];
const DAYS: Day[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const DAY_LABELS: Record<Day, string> = {
  monday: 'Monday', tuesday: 'Tuesday', wednesday: 'Wednesday',
  thursday: 'Thursday', friday: 'Friday', saturday: 'Saturday', sunday: 'Sunday',
};

interface DayRow {
  dayOfWeek: Day;
  startTime: string;
  endTime: string;
  isOff: boolean;
}

function buildDefault(existing: DoctorScheduleRow[]): DayRow[] {
  return DAYS.map(day => {
    const row = existing.find(r => r.dayOfWeek === day);
    return row
      ? { dayOfWeek: day, startTime: row.startTime, endTime: row.endTime, isOff: row.isOff }
      : { dayOfWeek: day, startTime: '09:00', endTime: '17:00', isOff: day === 'sunday' };
  });
}

interface DoctorScheduleEditorProps {
  doctorId: string;
  doctorName: string;
}

export function DoctorScheduleEditor({ doctorId, doctorName }: DoctorScheduleEditorProps) {
  const queryClient = useQueryClient();
  const { data: allSchedules = [], isLoading } = useQuery({
    queryKey: qk.doctorSchedules.all(),
    queryFn: getDoctorSchedules,
    staleTime: 5 * 60_000,
  });

  const doctorRows = allSchedules.filter(r => r.doctorId === doctorId);
  const [rows, setRows] = useState<DayRow[] | null>(null);
  const displayRows = rows ?? buildDefault(doctorRows);

  const updateRow = (day: Day, changes: Partial<DayRow>) => {
    setRows(buildDefault(doctorRows).map(r => r.dayOfWeek === day ? { ...r, ...changes } : r));
  };

  const mutation = useMutation({
    mutationFn: () => updateDoctorSchedule(doctorId, displayRows),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: qk.doctorSchedules.all() });
      toast.success(`${doctorName}'s schedule saved.`);
      setRows(null);
    },
    onError: (err: Error) => toast.error(err.message || 'Failed to save schedule'),
  });

  if (isLoading) return <p className="text-sm text-muted-foreground">Loading schedule…</p>;

  return (
    <div className="space-y-3">
      <div className="rounded-lg border divide-y">
        {displayRows.map(row => (
          <div key={row.dayOfWeek} className="flex items-center gap-4 px-4 py-3">
            <div className="w-24 text-sm font-medium">{DAY_LABELS[row.dayOfWeek]}</div>
            <Switch
              checked={!row.isOff}
              onCheckedChange={(checked) => updateRow(row.dayOfWeek, { isOff: !checked })}
            />
            {!row.isOff ? (
              <div className="flex items-center gap-2 flex-1">
                <Input
                  type="time"
                  value={row.startTime}
                  className="w-32 text-sm"
                  onChange={e => updateRow(row.dayOfWeek, { startTime: e.target.value })}
                />
                <span className="text-muted-foreground text-sm">to</span>
                <Input
                  type="time"
                  value={row.endTime}
                  className="w-32 text-sm"
                  onChange={e => updateRow(row.dayOfWeek, { endTime: e.target.value })}
                />
              </div>
            ) : (
              <span className="text-sm text-muted-foreground">Day off</span>
            )}
          </div>
        ))}
      </div>
      <Button
        size="sm"
        onClick={() => mutation.mutate()}
        disabled={mutation.isPending}
      >
        {mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
        Save availability
      </Button>
    </div>
  );
}
  • Step 2: Commit
git add ui/src/components/settings/DoctorScheduleEditor.tsx
git commit -m "feat(ui): add DoctorScheduleEditor component"

Task 8: Wire DoctorScheduleEditor into StaffManagement

Files:
  • Modify: ui/src/components/settings/StaffManagement.tsx
The goal is to show a collapsible “Availability” section below each doctor card/row.
  • Step 1: Find where doctor staff cards are rendered
Read StaffManagement.tsx around line 226–400 to find where individual staff member rows/cards render. Look for a .map(member => ...) block.
  • Step 2: Add import at top of StaffManagement.tsx
import { DoctorScheduleEditor } from './DoctorScheduleEditor';
  • Step 3: Add availability accordion per doctor
Inside the staff member map, after the existing staff card content, add — conditional on member.role === 'doctor':
{member.role === 'doctor' && (
  <details className="mt-3 border-t pt-3">
    <summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground select-none">
      Weekly Availability
    </summary>
    <div className="mt-3">
      <DoctorScheduleEditor
        doctorId={member.id}
        doctorName={`${member.firstName} ${member.lastName}`}
      />
    </div>
  </details>
)}
The exact insertion point depends on the staff card JSX structure — insert it inside the card’s content div, after name/role display.
  • Step 4: Commit
git add ui/src/components/settings/StaffManagement.tsx
git commit -m "feat(ui): add doctor availability editor to staff management"

Task 9: Build, Deploy, Verify

  • Step 1: TypeScript check (server)
cd server && npx tsc --noEmit 2>&1 | grep -v "scripts/\|emails/" | head -20
Expected: no errors in the files we touched.
  • Step 2: Build UI
cd ui && npm run build 2>&1 | tail -6
Expected: ✓ built in XX.XXs
  • Step 3: Deploy server
cd server && npx wrangler deploy 2>&1 | tail -5
  • Step 4: Deploy UI
cd ui && npx wrangler pages deploy dist --project-name odonto-prod-ui --branch main --commit-dirty=true 2>&1 | tail -4
  • Step 5: Force-promote canonical
ACCT=9da8a2bb48668ff798b91bd00e9ae005
TOKEN=$(grep 'oauth_token' ~/Library/Preferences/.wrangler/config/default.toml | sed 's/.*"\(.*\)"/\1/')
PROJECT=odonto-prod-ui
LATEST=$(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT" \
  -H "Authorization: Bearer $TOKEN" | \
  python3 -c "import sys,json;print(json.load(sys.stdin)['result']['latest_deployment']['id'])")
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT/deployments/$LATEST/rollback" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" | \
  python3 -c "import sys,json; d=json.load(sys.stdin); print('✅' if d.get('result') else '❌', d.get('errors',''))"
  • Step 6: Verify slot endpoint manually
# Test returns slots for a Monday date
curl -s "https://api.odontox.io/api/v1/protected/appointments/available-slots?date=2026-05-11" \
  -H "Authorization: Bearer <token>" \
  -H "X-Clinic-Id: <clinicId>" | python3 -m json.tool
Expected response shape: { "slots": [...], "clinicClosed": false, "doctorOff": false, "clinicPhone": "..." }

Self-Review: Spec vs Plan Coverage

Spec RequirementTask
doctor_schedules tableTask 1
Schema-ensure DDLTask 2
GET /available-slots — clinic hours checkTask 3
GET /available-slots — doctor schedule checkTask 3
GET /available-slots — booked slots removedTask 3
GET /available-slots — past slots removed (PKT)Task 2 + 3
GET /available-slots — clinic phone in responseTask 3
GET/PUT /doctor-schedules CRUDTask 3
SlotPicker componentTask 5
Patient portal replaces TimePickerTask 6
Doctor dropdown (multi-doctor clinics)Task 6
Clinic closed / phone message in UITask 5
Doctor schedule settings UITask 7 + 8
Fallback to clinic hours if no doc scheduleTask 3
Deploy + verifyTask 9