Scheduling Rule Engine + Doctor-Aware Day View + Unassigned Assignment
Date: 2026-05-05Status: Approved
Problem
Business rules for scheduling are scattered across 1865 lines inappointments.ts, partial duplicates in AppointmentForm.tsx, and nowhere else. Consequences:
- Updating a rule (e.g. 48-hour cancellation window) means hunting through a giant route file
- Frontend has no pre-flight checks for doctor schedule conflicts — users get rejected at submit with no warning
- The day view ignores doctor-specific hours when a doctor filter is applied
- Appointments landing in the “Unassigned” column have no way to be assigned to a doctor from the calendar
- No structured return type: rules throw raw errors or return 400/409 status codes with no machine-readable codes
- Server — SchedulingRulesService: extract all 13+ scheduling rules out of the route handler into a typed, testable service in
server/src/lib/rules/ - UI — Doctor-aware day view: use
doctorSchedulesdata to constrain the day grid time bounds when a doctor filter is active - UI — Unassigned appointment assignment: inline “Assign to doctor” action on unassigned appointment cards in DoctorDayView
1. SchedulingRulesService
1.1 File structure
1.2 Core types (types.ts)
1.3 Rules extracted and their new homes
| Rule | Current location | New location |
|---|---|---|
| Past date block | appointments.ts:273 | canCreateAppointment.ts |
| Clinic operating hours | appointments.ts:280-330 | canCreateAppointment.ts |
| Doctor day-off block | appointments.ts:335-345 | canCreateAppointment.ts |
| Doctor outside hours | appointments.ts:347-366 | canCreateAppointment.ts |
| Patient duplicate slot | appointments.ts:368-399 | canCreateAppointment.ts |
| Doctor time conflict | appointments.ts:413-469 | detectConflicts.ts |
| Room time conflict | appointments.ts:428-472 | detectConflicts.ts |
| Buffer time calc | appointments.ts:411,419 | detectConflicts.ts |
| State machine transitions | appointments.ts:52-63 | appointmentStateMachine.ts |
| Valid transition check | appointments.ts:1334 | canChangeStatus.ts |
| Time-based status restriction | appointments.ts:1326-1332 | canChangeStatus.ts |
| Role→status permission matrix | appointments.ts:1400-1417 | canChangeStatus.ts + access/ |
| 48h patient cancellation | appointments.ts:1438-1451 | canCancelAppointment.ts |
| Invoice on completion | appointments.ts:1457-1487 | canCompleteAppointment.ts |
| Patient owns appointment | appointments.ts:1420-1436 | access/canEditAppointment.ts |
| Available slot generation | appointments.ts:1649-1783 | calculateAvailableSlots.ts |
1.4 Migration strategy (zero breakage)
Phase 1 — Create rules alongside existing codeNew files in
lib/rules/ are pure TypeScript functions. Nothing in the route file changes yet. Rules are tested in isolation.
Phase 2 — Route handlers call rules, keep inline as fallbackReplace inline validation blocks with
const result = await schedulingRules.canCreateAppointment(ctx). If !result.allowed, return the structured error. The inline fallback is removed file-by-file once each rule is confirmed working.
Phase 3 — Dead code removalOnce all callers use the rule service, remove the original inline blocks.
appointments.ts becomes a thin HTTP adapter (parse request → call rule → return response).
The route file is never wholesale rewritten — blocks are replaced one at a time. Each replacement is a separate commit.
1.5 Structured error responses
Rules returnRuleResult. The route handler maps this to HTTP:
message field in responses is preserved.
2. Doctor-Aware Day View
2.1 Problem
When a doctor is selected in the day view filter, the time grid still renders 09:00–18:00 (clinic hours). Dr. Ahmed who works 13:00–20:00 shows 9 empty hours at the top.2.2 Data flow
-
AppointmentCalendaralready loadsdoctors. Add auseQueryforgetDoctorSchedules()(already inserverComm.ts+queryKeys.ts). Stale time: 5 minutes. -
Pass
doctorSchedules: DoctorScheduleRow[]down throughSchedulerProvideras a new context value (alongside existingoperatingHours). -
Modify the
useEffectinAppointmentCalendarthat calculatesstartHour/endHour(currently lines 539–591):
-
Pass
doctorOffTodayintoSchedulerProvidercontext. InDoctorDayView, iffilterDoctorIdis set anddoctorOffTodayis true, render a banner instead of the time grid: “Dr. [name] is off today”. - No changes to week/month view — only day view is affected.
2.3 Day-of-week mapping
DoctorScheduleRow.dayOfWeek is lowercase ('monday'). Map from currentDate.getDay() (0=Sunday) using the same DAY_NAMES array already used in the existing codebase.
3. Unassigned Appointment Assignment
3.1 Problem
Appointments in the__unassigned__ column have no in-calendar assignment action. Staff must open the appointment, edit it, find a doctor, and save. Three extra clicks for a routine operation.
3.2 Implementation
InDoctorDayView (doctor-day-view.tsx):
- In the event card render for
columnId === '__unassigned__', show a small “Assign” button (person-plus icon, ghost variant,size="xs"). - Clicking it opens a shadcn
Popoveranchored to the button. - Popover content: a
Selectpopulated from thedoctorsprop (already available in the component as it builds columns). - On doctor selection: call existing
updateAppointment(appointmentId, { doctorId: selectedDoctorId })mutation. - On success: invalidate
qk.appointmentsquery key → card moves to the correct doctor column automatically. - Popover closes on selection or click-away.
PUT /appointments/:id already accepts doctorId.
No modal. Inline popover keeps the user in context.
4. 400 on GET /doctor-schedules (already deployed fix)
Theapp.appointment_status schema prefix bug was fixed in commit 596f17844. The GET now returns [] when clinic context is absent instead of throwing 400. No further action needed.
5. Out of scope
- Soft-warning rules (patient balance, treatment plan phase, lab case pending) — phase 2
- Clinic-level timezone config — phase 2
- Cancellation reason dialog in UI — phase 2
- Frontend pre-flight conflict checking using
/available-slots— phase 2 - Rule config admin UI — phase 3
6. Affected files
Server (new files):server/src/lib/rules/types.tsserver/src/lib/rules/index.tsserver/src/lib/rules/scheduling/appointmentStateMachine.tsserver/src/lib/rules/scheduling/detectConflicts.tsserver/src/lib/rules/scheduling/canCreateAppointment.tsserver/src/lib/rules/scheduling/canUpdateAppointment.tsserver/src/lib/rules/scheduling/canCancelAppointment.tsserver/src/lib/rules/scheduling/canChangeStatus.tsserver/src/lib/rules/scheduling/calculateAvailableSlots.tsserver/src/lib/rules/billing/canCompleteAppointment.tsserver/src/lib/rules/access/canEditAppointment.ts
server/src/routes/appointments.ts— inline validation replaced rule-by-rule with service calls
ui/src/components/appointments/AppointmentCalendar.tsx— add doctorSchedules query + doctorOffToday logicui/src/providers/schedular-provider.tsx— adddoctorSchedules+doctorOffTodayto contextui/src/components/schedule/_components/view/day/doctor-day-view.tsx— doctorOffToday banner + unassigned assign action

