jongwony/shift-schedule
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Generic shift scheduling feasibility checker - validates 28-day (4-week) schedules against 16 constraints (6 hard + 10 soft) in real-time. Shift types: D (Day), E (Evening), N (Night), OFF.
Commands
npm run dev # Development server (localhost:5173)
npm run build # TypeScript + Vite production build
npm run preview # Preview production build
npm run lint # ESLint
npm test # Run all tests once
npm run test:watch # Watch mode
npx vitest run src/constraints/__tests__/staffing.test.ts # Single test fileTesting
Config: vitest.config.ts - jsdom environment, globals enabled, setup in src/test/setup.ts.
Pattern: Tests use createTestContext() factory:
function createTestContext(assignments: ShiftAssignment[], staffList?: Staff[]): ConstraintContextStandard test date: 2025-01-06 (Monday) for predictable weekday/weekend patterns.
Architecture
App.tsx
└── useSchedule hook (central state, localStorage persistence)
├── checkFeasibility (solver/feasibilityChecker.ts)
│ └── constraintRegistry (src/constraints/index.ts)
├── generateAutoSchedule → solverApi.ts → Backend API
└── affectedCells → impactCalculator.ts (cascading visualization)
Constraint System: Each constraint implements Constraint interface with check(context) => {satisfied, violations[]}. Severity is user-configurable via config.constraintSeverity (hard → error, soft → warning). Registry pattern in src/constraints/index.ts.
6 Hard Constraints (법정 규제 - 위반 시 INFEASIBLE):
| Constraint | Description |
|---|---|
staffing |
Min/max staff per shift type (supports date-specific overrides) |
shiftOrder |
Forbidden transitions (N→D, N→E, E→D) |
consecutiveNight |
Max consecutive night shifts |
nightOffDay |
N-OFF-D pattern forbidden |
weeklyOff |
Min OFF days per week (based on weeklyWorkHours) |
monthlyNight |
Required night shifts per month (configurable hard/soft) |
Backend-Only Constraints (Backend solver에서만 처리):
| Constraint | Description |
|---|---|
locked |
User-locked cell assignments (via lockedAssignments in API request) |
prevPeriodWork |
Previous period trailing work + current must not exceed maxDays |
주휴일 자동 결정 (Solver-Determined JuhuDay):
- Backend solver가 각 직원의 최적 주휴일을 IntVar로 결정
- 요일별 주휴 인원 분산 최적화 (min-max spread, T1 weight=95)
- 28일 기간 내 동일 주휴일 유지
- Response의
staffJuhuDays로 결정된 주휴일 반환, UI 행 헤더에 표시
10 Soft Constraints (위반 시 페널티):
근무자 관점 (Worker Perspective):
| Constraint | Tier | Weight | Description |
|---|---|---|---|
maxConsecutiveWork |
T1 | 100 | Max consecutive work days (default: 5) |
nightBlockPolicy |
T1 | 90 | Prevent isolated night shifts (min block: 2) |
gradualShiftProgression |
T2 | 70 | Prevent D→N direct transition |
maxSameShiftConsecutive |
T2 | 65 | Prevent same shift 5+ consecutive days |
restClustering |
T2 | 60 | Prevent isolated OFF days |
postRestDayShift |
T2 | 50 | Prevent OFF→N transition |
weekendFairness |
T3 | 30 | Fair weekend work distribution |
shiftContinuity |
T3 | 20 | Prevent excessive shift type changes |
관리자 관점 (Manager Perspective):
| Constraint | Tier | Weight | Description |
|---|---|---|---|
maxPeriodOff |
T1 | 85 | Max OFF days per period (default: 9) |
maxConsecutiveOff |
T1 | 80 | Max consecutive OFF days (default: 2) |
Tier System: T1×1000 > T2×100 > T3×10 - Higher tier always dominates in optimization.
- T1 근무자: 건강 (maxConsecutiveWork, nightBlockPolicy)
- T1 관리자: 운영 효율 (maxPeriodOff, maxConsecutiveOff)
- T2: 회복 (gradualShiftProgression, restClustering, etc.)
- T3: 삶의 질 (weekendFairness, shiftContinuity)
Compromise Point: restClustering (T2, promotes 2+ consecutive OFF) + maxConsecutiveOff (T1, penalizes 3+ consecutive OFF) converge on 2-day consecutive OFF as optimal.
UX Features:
- Completeness threshold (50%): Staffing errors suppressed until schedule is half-filled
- Hard/Soft distinction: Soft violations shown with toggle after auto-generation, filtered by cell during manual editing
- Cascading visualization: Hover shows affected cells (staffing/sequence)
- Auto-generation: Backend API integration (
VITE_SOLVER_API_URL) - Cell Lock (고정): Right-click (desktop) or long-press 500ms (mobile) to lock cells; locked cells preserved during auto-generation
- Previous Period Input: 7-day input window for boundary constraint checking
- Staffing Override: Click D/E/N count row in grid footer to set date-specific staffing requirements (min=max=input)
- Export: TSV to clipboard (Ctrl+V into spreadsheet); Import: JSON file for full state restore
Core Types (src/types/):
Staff: {id, name}ShiftAssignment: {staffId, date, shift, isLocked?}Schedule: {id, name, startDate, assignments[], staffJuhuDays?}SoftConstraintConfig: Per-constraint{enabled, maxDays?, minBlockSize?, maxOff?}StaffingOverrides:Record<string, {D?: number, E?: number, N?: number}>- date-specific staffing counts
API Types (src/types/api.ts):
GenerateRequest: {staff, startDate, constraints, previousPeriodEnd?, lockedAssignments?}GenerateResponse: {success, schedule?, error?, staffJuhuDays?}FeasibilityCheckRequest/Response: Pre-generation feasibility checkApiError: {code:INFEASIBLE|TIMEOUT|INVALID_INPUT, message}
Services (src/services/solverApi.ts):
generateSchedule(),checkFeasibilityApi(),isApiConfigured()
Day-of-Week Convention:
Frontend uses JavaScript getDay() (0=Sunday), Backend uses Python weekday() (0=Monday). Backend converts: python_weekday = (js_day - 1) % 7
localStorage Schema Migration:
useLocalStoragehook deep-mergesinitialValuewith stored data- Schema version 2: juhuDay removed from Staff (solver auto-determines)
- Migration clears staff/schedule/previousPeriod data and removes juhu-related config fields
Utilities
Date (src/utils/dateUtils.ts): formatDateKorean(), getWeekBoundaries(), forEachDateInRange(), isWeekend()
Day (src/utils/dayUtils.ts): DAY_NAMES - Korean day-of-week names (일/월/화/수/목/금/토) keyed by DayOfWeek (0=Sunday)
Shift (src/utils/shiftUtils.ts): calculateScheduleCompleteness(), getShiftSequence(), countShiftsByType(), countStaffByShiftPerDate()
Impact (src/utils/impactCalculator.ts): Cascading visualization rules:
staffing: All other staff on same datesequence: Same staff ±2 days (for shiftOrder, consecutiveNight)
State Management
useSchedule hook (src/hooks/useSchedule.ts):
showAllViolationstoggle (auto-enabled after auto-generation)editingCellstate for soft violation cell-based filtering- Session recovery toast on mount (shows once per session)
beforeunloadwarning when unsaved data exists
Storage keys: shift-schedule-staff, shift-schedule-current, shift-schedule-config, shift-schedule-previous
Backend Integration
Production:
- Frontend: https://shift-schedule.connects.im (GitHub Pages)
- Backend: https://4jyos1w157.execute-api.ap-northeast-2.amazonaws.com (Lambda Container + HTTP API)
Endpoints:
POST /generate- Auto-generate schedule using OR-Tools CP-SAT solverPOST /check-feasibility- Pre-check mathematical feasibility before generation
Solver: ../api/chalicelib/schedule_generator.py (OR-Tools CP-SAT)
- Request:
{staff, startDate, constraints, previousPeriodEnd?}(constraints includessoftConstraints) - Response:
{success, schedule?, staffJuhuDays?, error?}or{feasible, reasons[], analysis?}
Backend Soft Constraints (../api/chalicelib/soft_constraints/):
types.py:PenaltyTermdataclass,SoftConstraintprotocolobjective_builder.py:ObjectiveBuilderwithTIER_SCALES = {1: 1000, 2: 100, 3: 10}__init__.py:SOFT_CONSTRAINT_CLASSESregistry,create_constraint(id, config)factory- Boundary Pattern: All soft constraints support 7-day previous period via
context["previous_period_end"]
Backend Soft Constraint Context (passed to build(model, context)):
shifts: CP-SAT decision variables{(s, d, sh): BoolVar}indices:{D: 0, E: 1, N: 2, OFF: 3}num_staff,num_days,start_date,configstaff_list:[{id, name}]previous_period_end:[{staffId, date, shift}](up to 7 days)
Local development:
/dev # Start both frontend and backend (background)
/dev-stop # Stop all dev servers
/tasks # Check running serversManual start:
# Terminal 1: Frontend
npm run dev
# Terminal 2: Backend (from ../api/)
source .venv/bin/activate && chalice localEnvironment:
.env:VITE_SOLVER_API_URL=http://localhost:8000(local, usesexportfor direnv compatibility).env.production: Production API URL (auto-loaded by Vite build)
constraintSeverity flow: Frontend config → API → CP-SAT solver. Severity determines enforcement level (hard → INFEASIBLE on violation, soft → penalty only).
Backend deployment: Lambda Container Image (ARM64) due to ortools binary size. See ../api/Dockerfile.lambda.
Deployment
GitHub Actions (.github/workflows/deploy.yml): Pushes to main auto-deploy to GitHub Pages.
- Custom domain via
public/CNAME - Vite builds with
.env.productionvalues
Conventions
- Path alias:
@/*→./src/* - UI: Radix UI + shadcn/ui pattern (components.json)
- State: localStorage keys prefixed
shift-schedule- - Tests:
src/constraints/__tests__/*.test.ts - Commands:
.claude/commands/*.md(e.g.,/dev,/dev-stop) - Insights:
.claude/.insights/*.md:constraint-architecture-evolution.md: 3D constraint model (Authority/Mutability/Strength)infeasible-diagnosis-strategy.md: Two-phase solve + UNSAT core extraction viaSufficientAssumptionsForInfeasibility()soft-constraint-scaling.md: Tier-based objective function, PenaltyTerm abstraction for future soft constraints
- When adding a hard constraint:
- Create constraint file with
check()usinggetSeverityFromConfig(config, 'constraintId') - Add to
enabledConstraintsandconstraintSeverityinConstraintConfigtype - Add default values in
getDefaultConfig() - Register in
src/constraints/index.ts - Add CP-SAT constraint in
../api/chalicelib/schedule_generator.py
- Create constraint file with
- When adding a soft constraint:
- Frontend: Create
src/constraints/{id}.tswithseverityType: 'soft', checkconfig.softConstraints?.{id}?.enabled - Add type to
SoftConstraintConfiginsrc/types/constraint.ts - Add default in
getDefaultConfig()undersoftConstraints - Register in
src/constraints/index.ts - Backend: Create
../api/chalicelib/soft_constraints/{snake_case}.pyimplementingSoftConstraintprotocol - Register in
soft_constraints/__init__.py
- Frontend: Create
- When adding boundary-aware soft constraint (considers previous period):
- Frontend: Use Map-based lookup with
_count_trailing_*or similar helper - Backend: Extract
previous_period_endfrom context, buildshift_by_dateMap - Handle two boundary cases:
- Case A: Day 0 triggered by previous period (e.g., prev trailing work + day 0 exceeds limit)
- Case B: Prev day -1 was isolated (confirm at day 0 whether pattern completes)
- Use
_get_prev_boundary_shifts()or_get_prev_day_shift()helper pattern
- Frontend: Use Map-based lookup with