Scheduling & Shifts
Part of the Anaya Care product wiki. See 00-overview.md.
Purpose
This module turns a client's care plan into concrete, trackable work. Admins/care managers define schedules (one-time or RRULE-recurring time slots) per client; the system expands schedules into individual shift assignments, assigns them to care providers (caregivers), and tracks each shift through a formal status state machine — assignment, acceptance, GPS-validated clock-in/out (timesheets), task completion, handover to the next caregiver, end-of-shift reflection, and final completion. It also feeds adjacent flows: unfilled shifts can be converted to job postings, and AI-generated shift summaries report on care delivered over a date range.
Backend code lives mainly in apps/backend/src/shifts/ (shift lifecycle), apps/backend/src/shift-handovers/ (handover records), and apps/backend/src/clients/ (schedules, shift summaries). The status state machine is shared with frontends via packages/shared/src/shift-status-transitions.ts.
Entities & Data Model
ClientSchedule — client_schedules
The recurring template a client's shifts are generated from. (apps/backend/src/clients/entities/client-schedule.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | Tenant scope, required, indexed (L34–40) |
client | ObjectId → Client | Required (L42) |
tzid | string | IANA timezone, required (L45) |
dtstart | Date | First occurrence start, required (L48) |
durationMinutes | number | Required, min 1 (L51) |
rrule | string? | RFC RRULE; absent/empty = one-time schedule (L54) |
exdate | Date[] | Excluded occurrence dates (L57) |
priority | enum SchedulePriority LOW/NORMAL/HIGH/URGENT, default NORMAL | Defined in the same file (L6–11); not consumed in any backend scheduling logic found |
notes | string | (L67) |
endDate (virtual) | Date | null | Computed from rrule until/count + duration; null = never ends (L74–111) |
isActive (virtual) | boolean | "Active" includes schedules starting within the next 7 days (L113–154) |
Indexes: {business, createdAt}, {client, dtstart}, {rrule} (L156–158).
ShiftAssignment — shift_assignments
One concrete shift occurrence. (apps/backend/src/shifts/entities/shift-assignment.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | Required, indexed (L179) |
client | ObjectId → Client | Required, indexed (L187) |
caregiver | ObjectId → User, nullable | null = unassigned (L195–201) |
schedule | ObjectId → ClientSchedule | Required — every shift points back to a schedule (L203–208) |
tzid, dtstart, durationMinutes | string/Date/number | Copied from the schedule at generation time (L210–217) |
currentStatus | enum ShiftStatus, default GENERATED | Indexed (L219–225) |
assignmentMethod | enum AssignmentMethod | AUTO_GENERATED / MANUALLY_ASSIGNED / REASSIGNED / VOLUNTEER_PICKED (packages/shared/src/enums/domain-status.ts L230–235) |
assignedBy, assignedAt, responseDeadline | — | Response deadline for accept/decline (L234–242) |
timesheet | ObjectId → Timesheet | Set at clock-in (L245) |
originalAssignment | ObjectId → ShiftAssignment | If this is a reassignment (L249) |
jobPostingId | ObjectId → JobPosting, nullable | Set when shift converted to a job posting (L253–259) |
statusHistory | ShiftStatusHistory[] | Embedded audit trail: status, timestamp, updatedBy, isSystemAction, notes, method (L21–40) |
reflection | embedded ShiftReflection | 3 required prompts + optional notes (L45–64) |
handoverNotes | embedded HandoverNotes | Legacy single embedded handover; sourceReportId/sourceReportType: 'coc' | 'incident' link to a HealthObservation or IncidentReport (L69–100) |
completionRequirements | embedded CompletionRequirements | tasksRequired/Completed, reflectionRequired/Completed, handoverNotesRequired/Completed, autoClockoutProcessed, allRequirementsMet (L105–148) |
timesheetAutoClockout, reflectionGracePeriodEnds, reflectionReminderSentAt | — | Auto-clock-out + reflection-window bookkeeping (L287–297) |
temporalStatus (virtual) | PAST/CURRENT/UPCOMING/FUTURE | IN_PROGRESS is always CURRENT even past scheduled end (overtime) (L306–337) |
Unique index {client, dtstart, durationMinutes} prevents duplicate shifts for the same slot (L352–355). Additional indexes on caregiver/client/status/schedule + dtstart (L339–349).
Timesheet — timesheets
Clock-in/out record, 1:1 with a worked shift. (apps/backend/src/shifts/entities/timesheet.entity.ts)
| Field | Type | Notes |
|---|---|---|
business | ObjectId → Business | (L104) |
user | ObjectId → User | The caregiver (L118) |
shift | ObjectId → ShiftAssignment | Back-referenced from the shift (L171) |
clockInAt / clockOutAt | Date / Date? | clockOutAt undefined = active session (L131–143) |
clockInLocation / clockOutLocation | {lat, lng} embedded | (L151–163) |
wasAutoClockOut | boolean | System auto-clocked-out after grace (L188) |
isOutOfRange, clockInDistanceMeters | — | Geofence audit at clock-in (L200–211) |
isEarlyClockOut, workedPercent | — | Flagged when worked % < business threshold (L220–228) |
adminNote | string | Set on force clock-out / admin edits (L237) |
CaregiverAssignment — caregiver_assignments (scheduling aspects)
Links a caregiver to a client's care team; this is the pool from which shifts can be assigned/picked up. (apps/backend/src/clients/entities/caregiver-assignment.entity.ts)
| Field | Type | Notes |
|---|---|---|
business, client, caregiver | ObjectIds | Unique per {client, caregiver} (L102) |
currentStatus | enum CaregiverInviteStatus (Invited/Accepted/Declined/Inactive/Assigned) | (L75–81; enum in packages/shared/src/enums/domain-status.ts L197–203) |
statusHistory | embedded | (L83) |
priority | number | null | Used to order the auto-assign caregiver pool (shifts.service.ts L934–947 sorts by priority: 1, createdAt: 1) |
Scheduling rules tied to this entity: auto-generation only assigns caregivers with status ASSIGNED or ACCEPTED (shifts.service.ts L935–941); shift pickup requires status ACCEPTED (shifts.service.ts L815–823); manual assignment requires ASSIGNED or ACCEPTED (shifts.service.ts L3535–3547).
ShiftHandover — shift_handovers
A handover message for the incoming caregiver, either AI-generated from a Change-of-Condition/Incident report or authored by the outgoing caregiver. (apps/backend/src/shift-handovers/entities/shift-handover.entity.ts)
| Field | Type | Notes |
|---|---|---|
business, client | ObjectIds | (L34–48) |
type | enum HandoverType (change_of_condition / incident_report / routine / caregiver_note) | (packages/shared/src/types/shift.ts L18–23) |
sourceReportId / sourceReportType | string / 'coc' | 'incident' | 'caregiver_note' | Points at HealthObservation or IncidentReport; absent for caregiver notes (L57–64) |
shift | ObjectId → ShiftAssignment | Originating shift; used to scope the "pending for next shift" query (L69–74) |
authoredBy | ObjectId → User? | Null for AI-authored handovers (L78–79) |
content, agentReasoning | string | Reasoning stored for audit (L81–87) |
status | enum Pending/Acknowledged/Expired, default Pending | (L8–12, L89–95) |
acknowledgedBy, acknowledgedAt, expiresAt | — | (L97–107). No code found that actually sets Expired/expiresAt — see Gaps. |
ShiftHandoverDecision — shift_handover_decisions
Audit record of every AI handover-decision call, including skips, so managers can ask "why wasn't I alerted?" (apps/backend/src/shift-handovers/entities/shift-handover-decision.entity.ts L10–17). Fields: source report id/type, createHandover boolean, suggestedType, reasoning, handoverId, model, latencyMs.
ShiftSummary — shift_summaries
AI-generated care report over a date range (not per single shift, despite the name). (apps/backend/src/clients/entities/shift-summary.entity.ts)
| Field | Type | Notes |
|---|---|---|
business, client, generatedBy | ObjectIds | (L23–43) |
dateRange | {start, end} | (L45–52) |
audience | enum ExportAudience | Drives content filtering (L54–60) |
data | ShiftSummaryData object | Structured aggregation of task submissions, medication logs, health observations (L62–63) |
aiSummary | AiSummary object | LLM-generated narrative (Anthropic via createAnthropic, shift-summary.service.ts L143–146) |
pdfFile | ObjectId → LockCare, nullable | (L68–73) |
Entity relationships
Workflows & State Machines
1. Schedule creation
- Admin/manager creates a
ClientScheduleviaClientSchedulesService.createClientSchedule— validates dtstart, positive duration, tzid, RRULE parseability (client-schedules.service.tsL430–470, L561–590). Blocked for clients in OnHold/Closed status (L105–122). - Updates go through
updateClientSchedule(L595–621);handleScheduleModificationcan propagate time/duration/tz changes to future shifts in GENERATED/ASSIGNED status, marking themisModified(L127–199; notification fan-out inshifts.service.tsL744–772). - Deletion is a cascade: blocked while any shift from the schedule is IN_PROGRESS; otherwise deletes task submissions, care readiness assessments, health observations, timesheets (clearing
user.activeTimesheetId), shifts, care plans, care provider tasks, then the schedule (L673–784). A deletion-preview endpoint returns counts first (L626–671).
2. Shift generation
POST /shifts/generate/:clientId(permissionSHIFTS_MANAGE) enqueues a BullMQ job on theshiftsqueue and returns a job id immediately (shifts.controller.tsL387–426);ShiftsProcessorhandlesgenerate-shiftsandshift-bulk-canceljobs (shifts.processor.tsL68–88).ShiftsService.generateShiftAssignmentswalks day-by-day in the client's timezone over the date range derived from the selected schedules, creating one shift per applicable schedule-day (shifts.service.tsL190–428). The range is clamped to a 45-day maximum from the earliest schedule start; unbounded RRULEs default to 45 days (L3956–3963, L3993–4001).- Duplicate suppression via lookup on
{client, dtstart, durationMinutes}(L293–297) plus the unique index. - If
autoAssignand the client has an active care team, a caregiver is chosen per shift by strategy:round_robin(default),fair_distribution(least total upcoming hours, L3194–3241), orskill_based(skill-score with fair-distribution fallback, L3246–3314). Assigned shifts start as ASSIGNED with aresponseDeadline; otherwise GENERATED (L304–354). Grouped push notifications go to each caregiver (L380–424). - Manual single-shift creation from a one-time schedule:
POST /shifts/manual(createManualShift, L3500–3739). Recurring schedules are rejected (L3568–3573).
3. Assignment responses & coverage changes
- Caregiver accept/decline:
PUT /shifts/:id/respond— only the assigned caregiver, only from ASSIGNED/GENERATED/REASSIGNED (L433–518). Decline triggersinitiateReassignment, which notifies the rest of the care team that the shift is available for pickup (L1020–1084). - Manager reassignment:
PUT /shifts/:id/reassign(SHIFTS_MANAGE) — validates target is a CareProvider, auto-invites them to the care team if permitted, then runs overlap + daily-hour checks before setting REASSIGNED (L524–666). - Unassign:
PUT /shifts/:id/unassignreverts to GENERATED, clearing caregiver/deadline (L671–739). - Pickup:
POST /shifts/:id/pickup— caregiver self-assigns a GENERATED/DECLINED shift if they're an ACCEPTED care-team member and pass overlap + daily-hour checks; status jumps to ACCEPTED with method VOLUNTEER_PICKED (L800–881). - Job-posting path: an unfilled shift can be converted to a job posting (
convertToJobPosting, L4919–4993; only from GENERATED/DECLINED/ASSIGNED/NO_SHOW, one conversion per shift). When an application is accepted, ajob-application.accepted-for-shiftevent assigns the applicant back onto the shift as ASSIGNED unless it's already filled (apps/backend/src/shifts/listeners/shift-assign-from-job-posting.listener.tsL42–99). See 06-job-postings.md.
4. Clock-in (mobile)
POST /shifts/:id/clock-in → clockInForShift (shifts.service.ts L1717–1936) validates, in order:
- Caller is the assigned caregiver (L1735–1740).
- Status is ASSIGNED or ACCEPTED (L1744–1752) — note clock-in is allowed without explicit acceptance.
- No existing timesheet for the shift (L1756–1758).
- Geofence: distance to client address vs business-configurable radius (default 30 m). Out-of-range blocks only if
clockInGeofenceEnforced(default false); otherwise it's flagged on the timesheet (isOutOfRange) (L1765–1799; defaults inpackages/shared/src/shift-status-transitions.tsL173–186). - Time window: not earlier than
clockInEarlyWindowMinutesbefore start (default 480 = 8 h, per-business) and not later than a hardcoded 13-hour late grace (SHIFT_CLOCK_IN.LATE_GRACE_MINUTES,packages/shared/src/constants/common.tsL316) — but also not after the shift's scheduled end (L1801–1837). - No other open timesheet (one active shift at a time) (L1839–1851).
- Care Readiness Assessment completed, if required for this client (L1853–1865).
On success: timesheet created with GPS + distance, CLOCK_IN location breadcrumb recorded, shift → IN_PROGRESS, notifications + platform audit log (L1867–1926).
5. Clock-out, handover, reflection, completion (mobile)
POST /shifts/:id/clock-out→clockOutFromShift(L1999–2265): must be IN_PROGRESS with an open timesheet. Clock-out geofence (100 m,GEOFENCE.CLOCK_OUT_RADIUS) is a soft check — warning only (L2087–2114). Required scheduled tasks must be submitted unless ≥ 24 h since shift start (SHIFT_CLOCK_OUT.FORCE_CLOCK_OUT_HOURS, L2116–2144); any IN_PROGRESS task submissions are auto-completed (L2146–2148). Early clock-out below the business threshold % (default 50) flags the timesheet (L2159–2173). Shift → SHIFT_ENDED and a reflection grace period is set (default 24 h, per-business) (L2191–2225).- Handover notes: after clock-out the mobile app routes to an optional handover-notes screen (
apps/mobile/app/(shifts)/time-out.tsxL150;apps/mobile/app/(shifts)/handover-notes.tsxL31 — "Optional step between clock-out and daily reflection"), which creates 0..NShiftHandoverrecords viaPOST /handovers(shift-handovers.controller.tsL48–60). Handover is not a completion gate: "Handover creation is no longer a caregiver gate — Anaya generates handovers from COC/incident submissions and the incoming caregiver acknowledges them at login" (shifts.service.tsL4076–4078). - Reflection:
POST /shifts/:id/reflection→submitShiftReflection(L4009–4128). Allowed from SHIFT_ENDED / PENDING_COMPLETION / COMPLETED_WITHOUT_REFLECTION, once, within the grace period. If tasks are also done → COMPLETED and aSHIFT_COMPLETED_EVENTis emitted; else → PENDING_COMPLETION. - Background automations (all cron-driven):
- No-show: every minute, shifts still in ASSIGNED/REASSIGNED/ACCEPTED (GENERATED excluded) with no timesheet past the per-business
noShowThresholdMinutes(default 30) are batch-marked NO_SHOW (shift-monitoring.service.tsL150–251). Managers can also mark/revert no-show manually (shifts.service.tsL3745–3827). - Reminders/alerts: every-minute monitoring cron sends pre-shift reminders and overdue/critical alerts (escalating to managers) using Redis SET NX dedup (
shift-monitoring.service.tsL110–140). - Auto clock-out: every minute, open timesheets past shift end + grace (default 30 min) are clocked out with
wasAutoClockOut: true, thenhandleAutoClockoutSyncsets SHIFT_ENDED, auto-completes running tasks, and starts the reflection window (services/auto-clock-out.service.tsL37–209;shifts.service.tsL4237–4313). - Reflection lifecycle: every 5 minutes, midpoint reminders are pushed, and expired grace periods transition the shift to COMPLETED_WITHOUT_REFLECTION (
services/auto-complete-reflection.service.tsL38–177).
- No-show: every minute, shifts still in ASSIGNED/REASSIGNED/ACCEPTED (GENERATED excluded) with no timesheet past the per-business
6. Handover decision flow (AI)
health-observation.submittedandincident-report.submittedevents enqueue anevaluatejob on the handover queue (shift-handovers/listeners/handover-trigger.listener.tsL31–59). COC events missingbusinessIdare skipped with a warning (L33–42).- The processor/AI decides whether a handover is warranted;
recordAgentDecisionpersists the decision (always) and the handover (when created), deduping AI handovers per source report and attaching them to the in-progress shift or the most recent shift started within 24 h (shift-handovers.service.tsL204–281). - The incoming caregiver sees pending handovers via
GET /handovers/pending?clientId&shiftId— scoped to handovers attached to their upcoming shift, the immediately prior shift for the same client, or legacy shift-less AI handovers (L283–325) — and acknowledges viaPOST /handovers/:id/acknowledge(idempotent, L343–364). - Late-write contract: a caregiver note written after the next shift already clocked in attaches to that active shift and push-notifies the incoming caregiver (L68–158).
7. Shift summary
POST /clients/:clientId/shift-summaries/generate?startDate&endDate&audience (permission CLIENTS_VIEW_ASSIGNED, clients/controllers/shift-summary.controller.ts L30–56) aggregates SUBMITTED task submissions, medication logs, and submitted/reviewed/acknowledged health observations for the range (max 365 days), builds structured data, generates an audience-tailored AI narrative, and persists (shift-summary.service.ts L154–282). Same range+audience returns the cached summary unless force=true (which deletes all summaries for the range across audiences, L188–202). PDF export re-filters the saved data by audience via ShiftSummaryContentFilterService, which maps the shared CONTENT_MATRIX (include/consider/exclude per ExportAudience) onto data sections (shift-summary-content-filter.service.ts L17–64; export at shift-summary.service.ts L377+).
Shift status state machine
Source of truth: packages/shared/src/shift-status-transitions.ts (L14–69). Backend writes go through service-level guards; markShiftAsNoShow validates with isValidShiftTransition (shifts.service.ts L3759), other paths enforce explicit status allow-lists.
Helper sets: PRE_CLOCK_IN_STATUSES (ASSIGNED/REASSIGNED/GENERATED/ACCEPTED), NEEDS_COVERAGE_STATUSES (GENERATED/DECLINED/NO_SHOW), TERMINAL_STATUSES (COMPLETED/COMPLETED_WITHOUT_REFLECTION/CANCELLED) (shift-status-transitions.ts L99–122).
Business Rules & Constraints
- Tenant scoping: every entity carries
business; handover lookups treat cross-tenant shift ids as not-found (shift-handovers.service.tsL75–84). - Client lifecycle gate: schedules and shifts cannot be created for OnHold/Closed clients (
client-schedules.service.tsL100–122;shifts.service.tsL171–184). - No duplicate shifts per
{client, dtstart, durationMinutes}— unique index (shift-assignment.entity.tsL351–355). - Generation horizon: max 45 days per generation run (
shifts.service.tsL3993–4001). - Overlap check (
checkSchedulingConflict): any time-overlap against the caregiver's ASSIGNED/ACCEPTED/IN_PROGRESS shifts in a ±24 h window blocks reassign, pickup, manual assign, job-application acceptance, and job apply (shifts.service.tsL977–1018; callers listed under "16-hour rule" below). - Daily-hour caps: 12 h/day across different clients, 24 h/day for the same client (
packages/shared/src/constants/common.tsL294–306). Computed timezone-aware with midnight-spanning proration and a two-day evaluation for overnight shifts (shifts.service.tsL5155–5286). - One active timesheet per caregiver at a time (
shifts.service.tsL1839–1851). - Clock-in window: per-business early window (default 8 h before start), hardcoded 13 h late grace, never after scheduled shift end (
shifts.service.tsL1801–1837;common.tsL312–325). - Clock-in geofence: default 30 m radius; soft-flag by default, hard-block only when business sets
clockInGeofenceEnforced(shift-status-transitions.tsL140–147;shifts.service.tsL1765–1799). - Clock-out geofence is advisory only (100 m, warning log) (
shifts.service.tsL2087–2114). - Tasks gate clock-out until 24 h after shift start, then incomplete clock-out is allowed with a warning (
shifts.service.tsL2128–2144). - Reflection window: business-configurable grace (default 24 h); expiry auto-completes as COMPLETED_WITHOUT_REFLECTION (
auto-complete-reflection.service.tsL120–176). - No-show: auto-marked after per-business threshold (default 30 min) only for shifts that had a caregiver (GENERATED excluded) (
shift-monitoring.service.tsL150–251). - Pickup eligibility: only ACCEPTED care-team members; reassignment can auto-invite a new caregiver only for users with
SHIFTS_MANAGEor bypass roles (shifts.service.tsL815–823, L552–583). - Job-posting conversion: once per shift, only from GENERATED/DECLINED/ASSIGNED/NO_SHOW; rollback deletes the orphaned posting if linking fails (
shifts.service.tsL4930–4990). - Per-business shift settings (
Business.settings.shift): no-show threshold, clock-in early window, auto-clock-out grace, geofence enforce/radius, early-clock-out %, reflection grace, reminder/alert timings — all defaulted inDEFAULT_SHIFT_SETTINGS(shift-status-transitions.tsL132–186), managed atGET/PATCH /shifts/settings/business(permissionSHIFTS_SETTINGS,shifts.controller.tsL111–137). - Shift summary caching: one summary per (client, range, audience); regeneration requires
force(shift-summary.service.tsL185–202).
The 16-hour rule (pain point)
Stated requirement: "A care provider must NOT have a schedule exceeding 16 hours in a single day; when they apply to a job posting there should be an indicator whether they can be accepted."
Finding: no 16-hour rule exists anywhere in the repo. Searches for 16, sixteen, MAX_HOURS, maxHours, hoursPerDay in scheduling/job contexts find no 16-hour daily cap. What exists instead is a dual cap of 12 h/day cross-client and 24 h/day same-client:
- Constants:
SHIFT_LIMITS.MAX_DAILY_HOURS = 12andSHIFT_LIMITS.MAX_DAILY_HOURS_SAME_CLIENT = 24(packages/shared/src/constants/common.tsL294–306). The comments explicitly allow same-client work above 12 h "up to this cap (e.g. a single client requiring around-the-clock care)". - Engine:
ShiftsService.getCaregiverDailyHours+checkDailyHourLimitpartition a caregiver's committed hours (ASSIGNED/ACCEPTED/IN_PROGRESS shifts) per calendar day in the shift's timezone, handle midnight-spanning shifts, and evaluate both days of an overnight slot (shifts.service.tsL5155–5286).
Where it IS enforced (server-side, hard block):
| Flow | Code |
|---|---|
| Manager reassigns a shift | shifts.service.ts L586–608 |
| Caregiver picks up an open shift | shifts.service.ts L826–848 |
| Manual shift creation (direct caregiver assignment only) | shifts.service.ts L3644–3674 |
Caregiver applies to a job posting (POST /job-postings/:id/apply) → 403 | job-postings.controller.ts L506–526 |
| Admin accepts a job application → 409/400 | job-applications.service.ts L332–364 |
Where it surfaces as an indicator (the "can they be accepted" UX):
- Job feed filtering: featured/search listings hide postings the requesting CareProvider can't take (overlap or daily cap) (
job-postings.controller.tsL358–365, L419–426). - Job detail:
GET /job-postings/:idreturns a structuredunavailabilityReason(shift-conflict,daily-limit-cross-client,daily-limit-same-client, with the hour counts) (job-postings.controller.tsL76–121, L486–500). Mobile disables the Apply button and shows the reason (apps/mobile/app/(jobs)/[id].tsxL175–176, L551–567). - Web hiring UIs:
GET /shifts/availability/checkand the caregiver-finder enrichment return{hasConflict, dailyHoursOnDate, wouldExceedDailyLimit, projectedHours, isEligible}(shifts.controller.tsL228–264;client-caregiver-assignments.service.tsL688–720). Rendered as Available / Conflict / "Exceeds {12}h limit" badges inapps/web/.../care-team/_components/caregiver-card.tsx(L68–78) andapps/web/.../job-postings/_components/caregiver-availability-calendar.tsx(L244–275), plus the quick-assign tab (care-team/find/_components/quick-assign-tab.tsxL83). These web labels read the shared constant, so they say "12h", not 16.
Where it is NOT enforced:
- Bulk shift generation with auto-assign:
selectCaregiverstrategies (round-robin / fair-distribution / skill-based) never callcheckSchedulingConflictorcheckDailyHourLimit(shifts.service.tsL949–975, L3194–3314). Auto-generation can therefore stack a caregiver past any daily cap and even create overlapping shifts. - Caregiver accept (
respondToShift): no re-check at acceptance time (shifts.service.tsL433–518). - Event-driven assignment from job posting: the listener that writes the caregiver onto the shift doesn't re-check (the check happened at application-acceptance time) (
shift-assign-from-job-posting.listener.tsL42–99). - Schedule creation itself: schedules have no caregiver, so no hour validation applies there.
Whether the product intent is 16 h (per the pain-point statement) or the implemented 12/24 split cannot be determined from code — the code is internally consistent at 12/24, and no 16-hour constant, comment, or migration was found. Cross-link: 06-job-postings.md.
Surfaces (Web & Mobile)
Web (admins / care managers) — apps/web
- Business calendar:
app/(app)/(admin)/dashboard/calendar/— combined calendar with shift + appointment detail dialogs (_components/combined-calendar.tsx,shift-detail-dialog.tsx). Backed byGET /clients/schedules/businessdate-range filtering (client-schedules.service.tsL480–529 — conservative recurring filter; frontend does the precise RRULE expansion per the service comment L472–478). - All-shifts dashboard:
app/(app)/(admin)/dashboard/shifts/page.tsxwith filters/list/stats components. - Per-client schedules:
dashboard/clients/[id]/edit/schedules/— create/edit dialog, time-range picker, delete dialog (which consumes the deletion preview). - Per-client shifts:
dashboard/clients/[id]/edit/shifts/— generation form + generation status (polls the BullMQ job), shift list/table, assignment dialog, report sheet (shift-report-sheet.tsxshows handovers viaapps/web/lib/handover-api.tsagainst the management-onlyGET /handovers?shiftId,shift-handovers.controller.tsL69–77), location map/timeline, task gantt. - Care-team hiring:
dashboard/clients/[id]/edit/care-team/— caregiver cards with availability badges;dashboard/job-postings/_components/caregiver-availability-calendar.tsxshows an applicant's existing shifts and eligibility per day. - Shift settings:
dashboard/settings/shifts/page.tsx→GET/PATCH /shifts/settings/business. - Access is permission-based, not hardcoded by role:
SHIFTS_MANAGE(generate, manual, reassign, unassign, cancel, no-show, force clock-out),SHIFTS_MONITOR(overdue monitoring),SHIFTS_SETTINGS,SHIFTS_VIEW_ASSIGNED,JOB_POSTINGS_CONVERT(shifts.controller.tsL112–1463).
Mobile (care providers) — apps/mobile
- My schedule:
app/(schedule)/index.tsx— upcoming shifts with accept/decline mutations (L128, L183).app/(schedule)/available.tsx— open-shift marketplace with pickup (L45–71).app/(schedule)/[id]/details.tsx— accept/decline/pickup from detail view. - Clock-in:
app/(shifts)/time-in.tsx— requires device location; checks Care Readiness Assessment status first and routes to(assessments)if required/not passed (L399–450); computes distance against the backend-resolved per-business geofence (shift.effectiveClockInGeofence) and warns or blocks per the enforced flag (L420–440); confirmation via bottom sheet. Frontend-only nuance: the distance warning UI duplicates the backend check with a shared-constant fallback when settings haven't loaded (L427–430). - Clock-out → handover → reflection:
app/(shifts)/time-out.tsxclock-out confirmation, then routes tohandover-notes.tsx(L150) — an optional, skippable screen that posts 0..N caregiver handover notes — thendaily-reflection.tsx(3 required prompts; client-side grace-period countdown usingshift.reflectionGracePeriodEnds, L137–142, with an expired state at L183–184).shift-completion.tsxshows the requirements checklist (reflection submitted, 24 h reminder copy at L248). - Pending-handover banner/bottom sheet:
components/handovers/pending-handover-banner.tsx— non-blocking banner on the client screen (app/(client)/[id]/index.tsxL117–120, passing the active shift id to scope to current + prior shift). Tapping opens aBottomSheetModallisting each pending handover (type-themed: Incident = red "acknowledge before shift", CoC = amber, caregiver note = neutral) with the AI's reasoning and an Acknowledge button; polls every 60 s (L60–64). Frontend-only rule: the banner hides entirely while loading or when empty (L93–95); acknowledgment is optional — nothing blocks clock-in on unacknowledged handovers (no such check exists inclockInForShift). - Jobs:
app/(jobs)/[id].tsxdisables Apply and explains why viaunavailabilityReason(see 16-hour-rule section).
Family / MedicalProfessional
No scheduling surfaces found for these roles; families receive shift notifications (clock-in/out events fan out via sendShiftNotification, shifts.service.ts L1909, L2232) but cannot manage schedules. Cannot determine from code whether any family-facing schedule view is planned.
Cross-Module Dependencies
- Job Postings (06-job-postings.md): shift → posting conversion (
shifts.service.tsL4805–4993); acceptance → shift assignment event (shift-assign-from-job-posting.listener.ts); availability/daily-hour checks consumed byjob-postings.controller.tsL76–121 andjob-applications.service.tsL332–364. - Care Provider Tasks (04-care-provider-tasks.md): clock-out requires submitted SCHEDULED+required tasks for the shift (
shifts.service.tsL4133–4195 viaClientCareProviderTasksService.getCareProviderTasksDocumentForShift); task submissions referenceshiftAssignmentand are auto-completed at shift end (L4202–4231); schedule deletion cascades them (client-schedules.service.tsL709–715). - Health Observations / Change of Conditions (02-...) and Incident Reports (09-incident-reports.md): submission events trigger the AI handover queue (
shift-handovers/listeners/handover-trigger.listener.ts);ShiftHandover.sourceReportId/Typeand the legacy embeddedHandoverNotes.sourceReportIdpoint back at those records (shift-handover.entity.tsL57–64;shift-assignment.entity.tsL83–90). Health observations also carry ashiftAssignmentref (cascade inclient-schedules.service.tsL725–731). Schedules themselves have no direct link to incidents/CoCs — the connection is shift-mediated. - Notifications: shift assignment/response/clock events, pickup broadcasts, reflection reminders, and manager alerts via
ShiftNotificationService/NotificationService(e.g.,shifts.service.tsL3007+,shift-handovers.service.tsL131–155). - Clients module: client status gates, client address for geofence, client timezone for generation; care readiness assessments gate clock-in (
shifts/care-readiness-assessment.service.ts, checked atshifts.service.tsL1853–1865). - Business module: per-business
settings.shiftoverrides (shifts.service.tsL163–169). - Platform logs: every lifecycle action emits
PLATFORM_LOG_ACTIVITY_EVENTaudit entries (e.g.,shifts.service.tsL503–516, L1912–1926). - AI: handover decisions (
shift-handovers/ai-handover-decision.service.ts), shift-summary narratives (Anthropic,shift-summary.service.tsL143–146), job-posting preview generation (shifts.service.tsL4836–4846).
Open Questions & Gaps
- 16-hour rule absent: the stated business rule (≤ 16 h/day) is implemented as 12 h cross-client / 24 h same-client (
packages/shared/src/constants/common.tsL294–306). Whether 12/24 is a deliberate refinement or a deviation from the 16 h requirement cannot be determined from code. - Auto-generation bypasses all availability checks:
generateShiftAssignmentswithautoAssignnever runscheckSchedulingConflictorcheckDailyHourLimit(shifts.service.tsL304–319, L949–975), so bulk generation can create overlapping shifts and exceed daily caps that every manual path enforces. - Daily-hour check ignores GENERATED-with-caregiver and REASSIGNED shifts:
getCaregiverDailyHourscounts only ASSIGNED/ACCEPTED/IN_PROGRESS (shifts.service.tsL5164–5172), butrespondToShiftaccepts responses on GENERATED/REASSIGNED shifts that have a caregiver (L461–466) — those committed-but-pending hours are invisible to the cap. - Job-feed hiding vs indicator inconsistency: list endpoints silently hide conflicting postings (
job-postings.controller.tsL358–365, L419–426) while the detail endpoint shows a reason — a caregiver following a deep link sees "why", but the same job never appears in search. Sequential per-posting conflict checks in the list loop are also an N+1 performance concern. createManualShiftduplicate check queries non-existent fields: it filters onshiftDate,startTime,endTime(shifts.service.tsL3584–3590), none of which exist onShiftAssignment(which usesdtstart/durationMinutes). The query can never match, so duplicate suppression rests solely on the unique index (which throws instead of skipping).createManualShiftauto-assign skips checks and always sets ASSIGNED: theautoAssign ? ASSIGNED : ASSIGNEDternary is degenerate (L3686), round-robin always picks index 0 (L3633–3639), and no conflict/daily checks run on the auto path (only the manual-caregiver path, L3642–3675).- Handover expiry is dead code:
ShiftHandoverStatus.ExpiredandexpiresAtexist (shift-handover.entity.tsL8–12, L100–107) with a comment that "older handovers get marked Expired", but no cron/job that sets either was found. Pending handovers persist until acknowledged. - Handover acknowledgment is unscoped:
acknowledge()looks up by id only — no business/tenant or caregiver-assignment check on who may acknowledge (shift-handovers.service.tsL343–364), unlike the tenancy-guarded note creation. - CoC events without
businessIdskip handover evaluation entirely (acknowledged TODO inhandover-trigger.listener.tsL33–42) — those submissions never get an AI handover decision or audit record. - Clock-in allowed from ASSIGNED (unaccepted) shifts (
shifts.service.tsL1744–1752) while the state machine's ASSIGNED→IN_PROGRESS transition is not inSHIFT_STATUS_TRANSITIONS(ASSIGNED lists ACCEPTED/DECLINED/REASSIGNED/NO_SHOW/GENERATED/CANCELLED only) — the service path and the shared map disagree. - Late clock-in grace (13 h) is hardcoded (
common.tsL316) and not part ofBusinessShiftSettings, while the early window is configurable — and the "shift already ended" check (L1830–1837) makes the 13 h grace moot for shifts shorter than 13 h. ClientSchedule.priorityappears unused: stored and selected for the calendar (client-schedules.service.tsL523) but no backend logic branches on it; cannot determine intended behavior from code.CompletionRequirements.handoverNotesRequired/Completedare vestigial: defaults false, never set true anywhere; completion is computed from tasks + reflection only (shifts.service.tsL4079–4082).- Schedule updates skip both guards that creation enforces:
updateClientSchedule(client-schedules.service.tsL595–621) calls neitherassertClientCanReceiveOperations(client OnHold/Closed gate) norvalidateScheduleData(RRULE/duration validation) — onlycreateClientScheduledoes (L561–590). An invalid RRULE or an edit on a closed client's schedule passes straight throughfindByIdAndUpdate. ShiftSummarynaming vs scope: summaries are date-range client reports, not per-shift; the aggregation keys offTaskSubmission.createdAt/MedicationLog.occurredAt, not shift boundaries (shift-summary.service.tsL220–246) — partial-day boundary behavior at range edges is timezone-naive (new Date(startDate)server-local, L162–164).