Scheduling Rule Engine + Doctor-Aware Day View + Unassigned Assignment
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: Extract all scheduling business rules from appointments.ts into a typed SchedulingRulesService, constrain the DoctorDayView time grid to the selected doctor’s working hours, and add an inline “Assign to doctor” action on unassigned appointment cards.
Architecture: Three coordinated changes: (1) 11 new server-side rule files in server/src/lib/rules/ that return structured RuleResult objects, wired into appointments.ts one validation block at a time; (2) a useEffect inside SchedulerProvider that computes doctor-effective hours from filterDoctorId + doctorSchedules + currentDate and exposes them via context; (3) a Popover + Select component in DoctorDayView for the __unassigned__ column.
Tech Stack: TypeScript, Drizzle ORM (Neon HTTP), Hono, React 18, TanStack Query v5, shadcn/ui (Popover, Select), Framer Motion, lucide-react.
File Map
New server files
| File | Responsibility |
|---|---|
server/src/lib/rules/types.ts | RuleResult, ConflictDetail, AppointmentRuleContext |
server/src/lib/rules/index.ts | createSchedulingRules(db) factory |
server/src/lib/rules/scheduling/appointmentStateMachine.ts | validStatusTransitions, isValidTransition, isFinalState |
server/src/lib/rules/scheduling/detectConflicts.ts | Pure conflict checker over pre-fetched appointments |
server/src/lib/rules/scheduling/canCreateAppointment.ts | All creation validators (past date, clinic hours, doctor schedule, duplicate, conflict) |
server/src/lib/rules/scheduling/canUpdateAppointment.ts | Same as canCreate but excludes self from conflict scan |
server/src/lib/rules/scheduling/canCancelAppointment.ts | 48-hour window + role ownership |
server/src/lib/rules/scheduling/canChangeStatus.ts | State machine + time restriction + role matrix |
server/src/lib/rules/scheduling/calculateAvailableSlots.ts | Extracted available-slot generation |
server/src/lib/rules/billing/canCompleteAppointment.ts | Invoice-required-to-complete rule |
server/src/lib/rules/access/canEditAppointment.ts | Role-based write guard |
Modified files
| File | Change |
|---|---|
server/src/routes/appointments.ts | Inline validation replaced rule-by-rule with service calls |
ui/src/types/index.ts | Add DoctorEffectiveHours type + extend SchedulerContextType |
ui/src/providers/schedular-provider.tsx | Accept doctorSchedules, currentDate props; compute + expose doctor-aware hours |
ui/src/components/appointments/AppointmentCalendar.tsx | Add getDoctorSchedules query; pass doctorSchedules + currentDate to SchedulerProvider |
ui/src/components/schedule/_components/view/day/doctor-day-view.tsx | Doctor-off banner + unassigned assign action |
Task 1: Core types
Files:-
Create:
server/src/lib/rules/types.ts - Step 1: Create the directories
- Step 2: Write
types.ts
- Step 3: Verify TypeScript compiles
rules/types.ts
- Step 4: Commit
Task 2: State machine
Files:-
Create:
server/src/lib/rules/scheduling/appointmentStateMachine.ts -
Step 1: Write the file (extracted from
appointments.ts:52-63)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 3: Conflict detector
Files:-
Create:
server/src/lib/rules/scheduling/detectConflicts.ts - Step 1: Write the file (pure function — caller pre-fetches the appointments)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 4: canCreateAppointment
Files:-
Create:
server/src/lib/rules/scheduling/canCreateAppointment.ts -
Step 1: Write the file (extracted from
appointments.ts:273-472)
- Step 2: Verify TypeScript compiles
rules/scheduling/canCreateAppointment.ts
- Step 3: Commit
Task 5: canUpdateAppointment
Files:-
Create:
server/src/lib/rules/scheduling/canUpdateAppointment.ts - Step 1: Write the file
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 6: canCancelAppointment
Files:-
Create:
server/src/lib/rules/scheduling/canCancelAppointment.ts -
Step 1: Write the file (extracted from
appointments.ts:1420-1451)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 7: canChangeStatus
Files:-
Create:
server/src/lib/rules/scheduling/canChangeStatus.ts -
Step 1: Write the file (extracted from
appointments.ts:1326-1417)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 8: calculateAvailableSlots
Files:-
Create:
server/src/lib/rules/scheduling/calculateAvailableSlots.ts -
Step 1: Write the file (extracted from
appointments.ts:1649-1783)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 9: canCompleteAppointment
Files:-
Create:
server/src/lib/rules/billing/canCompleteAppointment.ts -
Step 1: Write the file (extracted from
appointments.ts:1457-1487)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 10: canEditAppointment
Files:-
Create:
server/src/lib/rules/access/canEditAppointment.ts -
Step 1: Write the file (from
appointments.ts:1420-1436)
- Step 2: Verify TypeScript compiles
- Step 3: Commit
Task 11: Rules service factory
Files:-
Create:
server/src/lib/rules/index.ts - Step 1: Write the file
- Step 2: Verify TypeScript compiles cleanly
- Step 3: Commit
Task 12: Wire rules into appointments.ts (Phase 2)
Files:- Modify:
server/src/routes/appointments.ts
12a — POST / creation validation
- Step 1: Add import at top of appointments.ts
- Step 2: Replace the inline creation validators
appointmentsRoute.post('/', ...), find the block starting at // Validation: Check if appointment date is in the past (line ~273) and ending at the closing } of the doctor/room conflict loop (line ~472). Replace the entire block with:
- Step 3: Verify TypeScript compiles
- Step 4: Commit
12b — PATCH /status state machine + role matrix
- Step 1: In
appointmentsRoute.patch('/:id/status', ...), replace the time-based restriction block, state machine check, and role permission block
// Prevent completing, starting, or marking no-show for future appointments (line ~1326) to the end of the role permission block } (line ~1417). Replace with:
rules was already declared above in the handler body. If not, add const rules = createSchedulingRules(db); before this block.
- Step 2: Verify TypeScript compiles
- Step 3: Commit
12c — PATCH /status patient cancellation + completion invoice
- Step 1: Replace patient cancellation block (line ~1419-1451)
// Patient-specific restrictions and replace with:
- Step 2: Replace invoice check inside the transaction (lines ~1457-1487)
if (newStatus === 'completed') { inside db.transaction(...). Replace with:
- Step 3: Verify TypeScript compiles
- Step 4: Commit
12d — GET /available-slots route body
- Step 1: Replace the entire route body of
appointmentsRoute.get('/available-slots', ...)with:
- Step 2: Verify TypeScript compiles
- Step 3: Verify the available-slots endpoint still works via wrangler dev
- Step 4: Commit
Task 13: UI types — extend SchedulerContextType
Files:-
Modify:
ui/src/types/index.ts -
Step 1: Add
DoctorEffectiveHourstype and extendSchedulerContextType
ui/src/types/index.ts, after the line export type ClinicOperatingHours = Record<string, ClinicDayHours>; (line 98), add:
SchedulerContextType interface (starting at line 100), add two new fields after operatingHours:
SchedulerContextType should read:
- Step 2: Verify TypeScript compiles
doctorOffToday/doctorEffectiveHours not provided — that’s expected, will be fixed in Task 14.
- Step 3: Commit
Task 14: SchedulerProvider — accept doctorSchedules + currentDate, compute doctor-aware hours
Files:-
Modify:
ui/src/providers/schedular-provider.tsx - Step 1: Add new props and internal state
ui/src/providers/schedular-provider.tsx, update the SchedulerProvider component:
- Add import for
DoctorScheduleRowtype at the top of the file (after existing imports):
- Add the new props to the destructuring signature (after
operatingHours):
SchedulerProvider (replacing the existing {...} parameter list) is:
- After
const [filterDoctorId, setFilterDoctorId] = useState<string | null>(null);(line 105), add:
- In the context
valueobject (the<SchedulerContext.Provider value={{ ... }}>at line ~361), add the two new fields:
- Step 2: Verify TypeScript compiles
doctorOffToday and doctorEffectiveHours are now provided. Remaining errors are about the AppointmentCalendar not yet passing the new props.
- Step 3: Commit
Task 15: AppointmentCalendar — add doctorSchedules query + pass to SchedulerProvider
Files:-
Modify:
ui/src/components/appointments/AppointmentCalendar.tsx - Step 1: Add getDoctorSchedules import
updateAppointment and others from @/lib/serverComm (line 18). Add getDoctorSchedules to the same import:
qk query key reference if not already present (it’s already imported at line 3 via import { qk } from '@/lib/queryKeys').
- Step 2: Add the doctorSchedules query
useQuery calls (or near the doctors state, around line 681), add:
- Step 3: Pass
doctorSchedulesandcurrentDatetoSchedulerProvider
<SchedulerProvider ...> is rendered (around line 885), add two new props:
- Step 4: Verify TypeScript compiles
- Step 5: Commit
Task 16: DoctorDayView — doctor-off banner + unassigned assign action
Files:-
Modify:
ui/src/components/schedule/_components/view/day/doctor-day-view.tsx - Step 1: Add new imports
doctor-day-view.tsx at the top, after the existing imports:
useState is already imported on line 1 via import React, { useRef, useState, ... }. Do not duplicate it — just add the other imports.
- Step 2: Add
AssignButtoncomponent (inside the file, before theDoctorDayViewfunction)
- Step 3: Update the
useSchedulerdestructure to include new context values
- Step 4: Add effective hours override
useScheduler line, add:
startHour and endHour in the component body with effectiveStartHour and effectiveEndHour:
-
In
const totalHours = endHour - startHour;→const totalHours = effectiveEndHour - effectiveStartHour; -
In
Array.from({ length: totalHours }, (_, i) => { const hour = (startHour + i) ...→(effectiveStartHour + i) -
In
const currentHour = now.getHours() + now.getMinutes() / 60; if (currentHour < startHour || currentHour >= endHour)→< effectiveStartHourand>= effectiveEndHour -
In
return (currentHour - startHour) * hourHeight;→(currentHour - effectiveStartHour) -
In
const hourIndex = startHour + i;insideArray.from(...)for click slots →effectiveStartHour + i - Step 5: Add doctor-off banner
return (...) block in DoctorDayView, add an early return for the off-today case. Insert before the existing return ( at the top of the render:
- Step 6: Add AssignButton to unassigned event cards
<motion.div> that wraps <EventStyled> (inside the colEvents?.map((event) => {...}) call). The current structure ends with:
<motion.div> already has className="... absolute" so positioning the button with absolute bottom-1 right-1 will work within it.
- Step 7: Verify TypeScript compiles
- Step 8: Build UI to verify no bundle errors
✓ built in ... with no errors
- Step 9: Commit
Task 17: Deploy and verify
- Step 1: Deploy server
✅ Deployed ... Successfully with no migration errors
- Step 2: Build and deploy UI
- Step 3: Force-promote canonical deployment (per
odontox-commit-deployskill, Step 7)
- Step 4: Smoke tests
- Open the clinic portal, go to Appointments → Day view → switch to “By Doctor” layout
- Select a doctor from the filter dropdown who has restricted hours (e.g. starts at 13:00) — verify the time grid starts at 13:00, not 09:00
- Navigate to a day that doctor is marked as off — verify the “off today” banner appears
- Create an appointment with no doctor assigned — verify it appears in the “Unassigned” column
- Click the person-plus icon on an unassigned card — verify the doctor popover opens with doctor list
- Select a doctor — verify the card moves to that doctor’s column
- Go to Staff Management → pick a doctor → save schedule — verify no 500 errors in console (cold-start fix from previous session)
- Try to book a past appointment via the API — verify 409 with
code: "PAST_DATE"in the response body
Spec coverage check
| Spec section | Covered by |
|---|---|
| SchedulingRulesService file structure (§1.1) | Tasks 1–11 |
| Core types (§1.2) | Task 1 |
| Rules extracted (§1.3 table) | Tasks 4–10 |
| Migration strategy Phase 1 + 2 (§1.4) | Tasks 1–11 (phase 1), Task 12 (phase 2) |
| Structured error responses (§1.5) | Task 12 — c.json({ message, code, conflicts }, ...) |
| Doctor-aware day view (§2) | Tasks 13–15 |
| Day-of-week mapping (§2.3) | Task 14 — DAY_NAMES_LOWER[currentDate.getDay()] |
| Unassigned assignment (§3) | Task 16 |
| No new API endpoint for assignment (§3.2) | Confirmed — uses existing PUT /appointments/:id |
| GET /doctor-schedules 400 fix (§4) | Already deployed (previous session) |

