diff --git a/.python-version b/.python-version index 2c0733315..e4fba2183 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d7c7b71..a89409f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Annual-planning readiness now evaluates effective program policy snapshots, recurring program calendar anchors, and policy-layered institutional events together, so coordinators see the full DB-backed policy stack instead of only institutional-event coverage before annual optimization or repair work. +- The annual-planning hub's policy rollover action now refreshes program policy snapshots and recurring calendar anchors alongside institutional events, keeping academic-year policy carry-forward aligned across all current DB-backed policy sources. - The annual proving-pass runner now records AY readiness preflight results in its markdown/JSON report and stops before any writes when resident, faculty-shape, or policy-anchor blockers are present, unless the operator explicitly overrides that guard. - Annual planner lifecycle endpoints now require scheduler-level access on the backend, matching the scheduler-only annual-planning UI so residents cannot enumerate or mutate year-level plans directly through the API. - The annual-planning hub now surfaces the 14-sheet academic-year workbook export from the existing `/export/schedule/year/xlsx` backend path, so coordinators can download current AY schedule truth straight from the planning UI while they review year-level generation and repair work. @@ -26,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added admin rollover endpoints for `ProgramPolicySnapshot` and `ProgramCalendarAnchor`, plus annual-planning hub views for the effective program-policy snapshot and effective recurring calendar anchors for a selected academic year. - Added a recent proving-pass report panel to the annual-planning hub so schedulers can review baseline/shock/repair drill outcomes for the selected academic year without leaving the coordinator surface. - Added a scheduler-only annual-planner proving-pass report feed sourced from the native `docs/reports/automation/annual_proving_pass_*.json` artifacts, so recent baseline/repair drill outcomes can be surfaced in the app instead of staying trapped in local files. - Added an annual-planning readiness preflight API for AY-scoped resident roster, faculty weekly-shape, and policy-anchor checks so schedulers can see hard blockers before attempting yearly optimization. diff --git a/TODO.md b/TODO.md index 206a93c48..5aaaee203 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO — Actionable Items -> **Updated:** 2026-03-21 (annual proving pass reaches actionable repair drafts) +> **Updated:** 2026-03-21 (policy-layer readiness/review/rollover actualization in progress) > **Source:** Extracted from architecture docs, planning docs, code TODOs, and Explore agent audit. > **Companion:** `docs/planning/ROADMAP.md` (macro vision), `docs/planning/TECHNICAL_DEBT.md` (debt tracker) > **Cutover tracker:** `docs/planning/ROTATION_SHAPE_CUTOVER_STATUS_20260320.md` (merged vs open PR vs untouched rotation-shape/constraint cutover work) @@ -24,7 +24,7 @@ - [x] **DB-backed constraint binding cutover** — Merged [#1409](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1409) and [#1410](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1410); mutable hard/soft policy now lives in DB-backed scoped bindings on `main`. - [x] **Faculty weekly shapes** — Primary operational blocker from `docs/reviews/FULL_STACK_AUDIT_20260320.md`. Replaced heuristic-first faculty scheduling with baseline weekly shapes, role drivers, person deltas, and week-specific overrides across merged PRs [#1412](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1412) through [#1419](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1419). -- [ ] **Program / regulatory / institution policy tables** — Add DB-backed layers above rotation shapes for PGY-wide rules, ACGME policy, military/institution policy, and program calendar anchors. +- [ ] **Program / regulatory / institution policy tables** — `ProgramPolicySnapshot`, `ProgramCalendarAnchor`, and policy-layered `InstitutionalEvent` tables/admin surfaces now exist, and the annual-planning hub can now assess, review, and roll all three sources together. Remaining: move the surviving higher-order policy still trapped in Python into these DB-backed layers without making ad hoc scheduling-logic changes. - [ ] **13-block AY 26-27 draft / validate / publish workflow** — Core lifecycle is merged on `main` via [#1425](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1425) through [#1428](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1428). Current hardening stack is [#1452](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1452) through [#1457](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1457), plus [#1468](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1468), [#1484](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1484), and [#1485](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1485): publish aliases, naive UTC timestamps, idempotent block-draft regeneration, annual optimization leave pressure, resilience-helper proving-pass fixes, and exact shock-impact assignment review. Native proving passes now reach baseline publish, repair publish, and repair-draft publish. Remaining: merge the open stack and keep tightening annual diff/review ergonomics. - [ ] **Shock-event model + targeted regeneration** — Initial `Absence`-driven shock preview/draft slice is merged on `main`. Current extensions are [#1457](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1457), [#1468](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1468), and [#1485](https://github.com/Euda1mon1a/Autonomous-Assignment-Program-Manager/pull/1485), which feed approved/confirmed `Absence` rows into annual optimization as leave requests, persist shock repair scope even when the annual rotation diff is unchanged, keep repair generation from aborting on archived combo templates, legacy encrypted absence fields, or fully absent repair residents, and now expose the exact resident/block assignments affected by a shock before draft creation. Remaining: broader blast-radius analysis and repair publish flow. @@ -78,7 +78,7 @@ - [ ] **ACGME call duty validation gap** — `call_assignments` excluded from 24+4/rest checks. **Blocked on MEDCOM ruling.** - [ ] **Faculty weekly-shape gaps** — Current faculty scheduling still leans on incomplete weekly templates and heuristics. Rebuild this around reusable baseline weekly shapes, role drivers, and overrides. See `docs/architecture/FACULTY_WEEKLY_SHAPE_REFERENCE_20260320.md`. - [ ] **Closed-loop validation pipeline** — Automated generate → validate → diagnose → fix → regenerate loop. Not yet implemented. -- [ ] **Program calendar / holiday anchors** — Add a Postgres-backed calendar layer for holiday-anchored program events, deadlines, and equity checkpoints above rotation shapes. Do not copy these anchors into outpatient weekly patterns. See `docs/planning/HOLIDAY_ANCHORED_RESIDENCY_CALENDAR.md` and `docs/planning/HANDBOOK_ADDENDUM_HOLIDAY_ANCHORED_CALENDAR.md`. +- [ ] **Program calendar / holiday anchors** — Base Postgres-backed anchor tables and annual-planning review/rollover surfaces now exist. Remaining: cut the live preload, validation, and downstream operator workflows over to the new calendar layer instead of leaving those semantics split between DB rows and Python-owned policy. See `docs/planning/HOLIDAY_ANCHORED_RESIDENCY_CALENDAR.md` and `docs/planning/HANDBOOK_ADDENDUM_HOLIDAY_ANCHORED_CALENDAR.md`. - [ ] **DEBT-025: 5 pre-existing failing tests** — `test_min_limit_enforcement`, `test_engine_calls_faculty_expansion`, `test_pcat_do_created_for_each_call`, `test_cpsat_allows_templates_requiring_procedure_credential`, `test_cpsat_respects_locked_blocks`. ### Infrastructure diff --git a/backend/app/api/routes/program_calendar_anchors.py b/backend/app/api/routes/program_calendar_anchors.py index f3b28e435..a5e50bd53 100644 --- a/backend/app/api/routes/program_calendar_anchors.py +++ b/backend/app/api/routes/program_calendar_anchors.py @@ -16,6 +16,7 @@ from app.schemas.program_calendar_anchor import ( ProgramCalendarAnchorCreate, ProgramCalendarAnchorListResponse, + ProgramCalendarAnchorRolloverResponse, ProgramCalendarAnchorResponse, ProgramCalendarAnchorUpdate, ) @@ -50,6 +51,28 @@ async def list_program_calendar_anchors( ) +@router.post("/rollover", response_model=ProgramCalendarAnchorRolloverResponse) +async def rollover_program_calendar_anchors( + source_academic_year: int = Query(..., ge=2020, le=2100), + target_academic_year: int = Query(..., ge=2020, le=2100), + db: AsyncSession = Depends(get_async_db), + current_user: User = Depends(get_admin_user), +): + del current_user + try: + return await db.run_sync( + lambda sync_db: ProgramCalendarAnchorService(sync_db).rollover_anchors( + source_academic_year=source_academic_year, + target_academic_year=target_academic_year, + ) + ) + except ValueError as exc: + raise HTTPException( + status_code=400, + detail="Invalid program calendar anchor rollover parameters.", + ) from exc + + @router.get("/{anchor_id}", response_model=ProgramCalendarAnchorResponse) async def get_program_calendar_anchor( anchor_id: UUID, diff --git a/backend/app/api/routes/program_policy_snapshots.py b/backend/app/api/routes/program_policy_snapshots.py index ad402c1b3..3a27935a7 100644 --- a/backend/app/api/routes/program_policy_snapshots.py +++ b/backend/app/api/routes/program_policy_snapshots.py @@ -11,6 +11,7 @@ from app.schemas.program_policy_snapshot import ( ProgramPolicySnapshotCreate, ProgramPolicySnapshotListResponse, + ProgramPolicySnapshotRolloverResponse, ProgramPolicySnapshotResponse, ProgramPolicySnapshotUpdate, ) @@ -40,6 +41,27 @@ async def list_program_policy_snapshots( ) +@router.post("/rollover", response_model=ProgramPolicySnapshotRolloverResponse) +async def rollover_program_policy_snapshots( + source_academic_year: int = Query(..., ge=2020, le=2100), + target_academic_year: int = Query(..., ge=2020, le=2100), + db: AsyncSession = Depends(get_async_db), + _current_user: User = Depends(get_admin_user), +): + try: + return await db.run_sync( + lambda sync_db: ProgramPolicySnapshotService(sync_db).rollover_snapshots( + source_academic_year=source_academic_year, + target_academic_year=target_academic_year, + ) + ) + except ValueError as exc: + raise HTTPException( + status_code=400, + detail="Invalid program policy snapshot rollover parameters.", + ) from exc + + @router.get("/{snapshot_id}", response_model=ProgramPolicySnapshotResponse) async def get_program_policy_snapshot( snapshot_id: UUID, diff --git a/backend/app/schemas/annual_rotation.py b/backend/app/schemas/annual_rotation.py index ae913b916..a59d4fc95 100644 --- a/backend/app/schemas/annual_rotation.py +++ b/backend/app/schemas/annual_rotation.py @@ -255,10 +255,20 @@ class AnnualRotationPlanningReadinessFacultyResponse(BaseModel): class AnnualRotationPlanningReadinessPolicyResponse(BaseModel): - """Institutional-event policy-anchor readiness for annual planning.""" + """DB-backed policy-layer readiness for annual planning.""" status: Literal["ready", "warning", "blocked"] + has_effective_program_policy: bool + effective_program_policy_source: Literal["default", "academic_year"] | None = None + active_anchor_count: int + active_calendar_anchor_count: int active_event_count: int + program_anchor_count: int + regulatory_anchor_count: int + institution_anchor_count: int + program_calendar_anchor_count: int + regulatory_calendar_anchor_count: int + institution_calendar_anchor_count: int program_event_count: int regulatory_event_count: int institution_event_count: int diff --git a/backend/app/schemas/program_calendar_anchor.py b/backend/app/schemas/program_calendar_anchor.py index da41c04fb..721f88ea2 100644 --- a/backend/app/schemas/program_calendar_anchor.py +++ b/backend/app/schemas/program_calendar_anchor.py @@ -129,3 +129,13 @@ class ProgramCalendarAnchorEffectiveListResponse(BaseModel): total: int page: int page_size: int + + +class ProgramCalendarAnchorRolloverResponse(BaseModel): + source_academic_year: int = Field(..., ge=2020, le=2100) + target_academic_year: int = Field(..., ge=2020, le=2100) + source_anchor_count: int = Field(..., ge=0) + created_count: int = Field(..., ge=0) + skipped_count: int = Field(..., ge=0) + created_names: list[str] = Field(default_factory=list) + skipped_names: list[str] = Field(default_factory=list) diff --git a/backend/app/schemas/program_policy_snapshot.py b/backend/app/schemas/program_policy_snapshot.py index a6e6c5c12..ae5c1fc1e 100644 --- a/backend/app/schemas/program_policy_snapshot.py +++ b/backend/app/schemas/program_policy_snapshot.py @@ -84,3 +84,13 @@ class ProgramPolicySnapshotListResponse(BaseModel): class ProgramPolicySnapshotEffectiveResponse(ProgramPolicySnapshotResponse): requested_academic_year: int source: Literal["default", "academic_year"] + + +class ProgramPolicySnapshotRolloverResponse(BaseModel): + source_academic_year: int = Field(..., ge=2020, le=2100) + target_academic_year: int = Field(..., ge=2020, le=2100) + source_snapshot_count: int = Field(..., ge=0) + created_count: int = Field(..., ge=0) + skipped_count: int = Field(..., ge=0) + created_names: list[str] = Field(default_factory=list) + skipped_names: list[str] = Field(default_factory=list) diff --git a/backend/app/services/annual_rotation_service.py b/backend/app/services/annual_rotation_service.py index f36e75f61..a659a34a4 100644 --- a/backend/app/services/annual_rotation_service.py +++ b/backend/app/services/annual_rotation_service.py @@ -17,6 +17,7 @@ from fastapi import HTTPException, status from sqlalchemy import and_, func, or_, select +from sqlalchemy import case from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased, selectinload @@ -30,6 +31,8 @@ ) from app.models.person import Person from app.models.person_academic_year import PersonAcademicYear +from app.models.program_calendar_anchor import ProgramCalendarAnchor +from app.models.program_policy_snapshot import ProgramPolicySnapshot from app.models.rotation_template import RotationTemplate from app.models.schedule_draft import DraftSourceType, ScheduleDraft from app.models.user import User @@ -1286,10 +1289,10 @@ async def _assess_faculty_readiness( async def _assess_policy_anchor_readiness( *, academic_year: int, db: AsyncSession ) -> AnnualRotationPlanningReadinessPolicyResponse: - """Summarize effective institutional-event anchors for an AY.""" + """Summarize DB-backed policy coverage for an AY.""" academic_year_start = date(academic_year, 7, 1) academic_year_end = date(academic_year + 1, 6, 30) - stmt = ( + event_stmt = ( select( InstitutionalEvent.policy_layer, func.count(InstitutionalEvent.id), @@ -1305,25 +1308,94 @@ async def _assess_policy_anchor_readiness( ) .group_by(InstitutionalEvent.policy_layer) ) - result = await db.execute(stmt) - counts_by_layer = { + event_result = await db.execute(event_stmt) + event_counts_by_layer = { InstitutionalEventPolicyLayer.PROGRAM.value: 0, InstitutionalEventPolicyLayer.REGULATORY.value: 0, InstitutionalEventPolicyLayer.INSTITUTION.value: 0, } - for policy_layer, count in result.all(): + for policy_layer, count in event_result.all(): layer_key = ( policy_layer.value if isinstance(policy_layer, InstitutionalEventPolicyLayer) else str(policy_layer) ) - counts_by_layer[layer_key] = int(count) + event_counts_by_layer[layer_key] = int(count) + + calendar_stmt = ( + select( + ProgramCalendarAnchor.policy_layer, + func.count(ProgramCalendarAnchor.id), + ) + .where( + ProgramCalendarAnchor.is_active.is_(True), + or_( + ProgramCalendarAnchor.academic_year.is_(None), + ProgramCalendarAnchor.academic_year == academic_year, + ), + ) + .group_by(ProgramCalendarAnchor.policy_layer) + ) + calendar_result = await db.execute(calendar_stmt) + calendar_counts_by_layer = { + InstitutionalEventPolicyLayer.PROGRAM.value: 0, + InstitutionalEventPolicyLayer.REGULATORY.value: 0, + InstitutionalEventPolicyLayer.INSTITUTION.value: 0, + } + for policy_layer, count in calendar_result.all(): + layer_key = ( + policy_layer.value + if isinstance(policy_layer, InstitutionalEventPolicyLayer) + else str(policy_layer) + ) + calendar_counts_by_layer[layer_key] = int(count) + + policy_stmt = ( + select( + ProgramPolicySnapshot.is_default, + ProgramPolicySnapshot.academic_year, + ) + .where( + ProgramPolicySnapshot.is_active.is_(True), + or_( + ProgramPolicySnapshot.is_default.is_(True), + ProgramPolicySnapshot.academic_year == academic_year, + ), + ) + .order_by( + case((ProgramPolicySnapshot.academic_year == academic_year, 0), else_=1), + ProgramPolicySnapshot.academic_year.desc().nullslast(), + ) + .limit(1) + ) + policy_result = await db.execute(policy_stmt) + effective_policy_row = policy_result.first() + has_effective_program_policy = effective_policy_row is not None + effective_program_policy_source: str | None = None + if effective_policy_row is not None: + is_default, _row_academic_year = effective_policy_row + effective_program_policy_source = "default" if is_default else "academic_year" + + combined_counts_by_layer = { + layer: calendar_counts_by_layer[layer] + event_counts_by_layer[layer] + for layer in ( + InstitutionalEventPolicyLayer.PROGRAM.value, + InstitutionalEventPolicyLayer.REGULATORY.value, + InstitutionalEventPolicyLayer.INSTITUTION.value, + ) + } warnings: list[str] = [] - active_event_count = sum(counts_by_layer.values()) - if active_event_count == 0: + active_calendar_anchor_count = sum(calendar_counts_by_layer.values()) + active_event_count = sum(event_counts_by_layer.values()) + active_anchor_count = active_calendar_anchor_count + active_event_count + if not has_effective_program_policy: + warnings.append( + f"No active program policy snapshot applies to AY {academic_year}-{academic_year + 1}." + ) + if active_anchor_count == 0: warnings.append( - f"No active policy anchors apply to AY {academic_year}-{academic_year + 1}." + f"No active calendar anchors or institutional events apply to AY {academic_year}-{academic_year + 1}." ) else: for layer in ( @@ -1331,22 +1403,44 @@ async def _assess_policy_anchor_readiness( InstitutionalEventPolicyLayer.REGULATORY.value, InstitutionalEventPolicyLayer.INSTITUTION.value, ): - if counts_by_layer[layer] == 0: + if combined_counts_by_layer[layer] == 0: warnings.append( - f"No active {layer} policy anchors apply to AY {academic_year}-{academic_year + 1}." + f"No active {layer} calendar anchors or institutional events apply to AY {academic_year}-{academic_year + 1}." ) blockers: list[str] = [] return AnnualRotationPlanningReadinessPolicyResponse( status=_derive_readiness_status(blockers=blockers, warnings=warnings), + has_effective_program_policy=has_effective_program_policy, + effective_program_policy_source=effective_program_policy_source, + active_anchor_count=active_anchor_count, + active_calendar_anchor_count=active_calendar_anchor_count, active_event_count=active_event_count, - program_event_count=counts_by_layer[ + program_anchor_count=combined_counts_by_layer[ + InstitutionalEventPolicyLayer.PROGRAM.value + ], + regulatory_anchor_count=combined_counts_by_layer[ + InstitutionalEventPolicyLayer.REGULATORY.value + ], + institution_anchor_count=combined_counts_by_layer[ + InstitutionalEventPolicyLayer.INSTITUTION.value + ], + program_calendar_anchor_count=calendar_counts_by_layer[ + InstitutionalEventPolicyLayer.PROGRAM.value + ], + regulatory_calendar_anchor_count=calendar_counts_by_layer[ + InstitutionalEventPolicyLayer.REGULATORY.value + ], + institution_calendar_anchor_count=calendar_counts_by_layer[ + InstitutionalEventPolicyLayer.INSTITUTION.value + ], + program_event_count=event_counts_by_layer[ InstitutionalEventPolicyLayer.PROGRAM.value ], - regulatory_event_count=counts_by_layer[ + regulatory_event_count=event_counts_by_layer[ InstitutionalEventPolicyLayer.REGULATORY.value ], - institution_event_count=counts_by_layer[ + institution_event_count=event_counts_by_layer[ InstitutionalEventPolicyLayer.INSTITUTION.value ], blockers=blockers, diff --git a/backend/app/services/program_calendar_anchor_service.py b/backend/app/services/program_calendar_anchor_service.py index 9d8fb3269..c27bb9dcc 100644 --- a/backend/app/services/program_calendar_anchor_service.py +++ b/backend/app/services/program_calendar_anchor_service.py @@ -15,6 +15,7 @@ from app.schemas.program_calendar_anchor import ( ProgramCalendarAnchorCreate, ProgramCalendarAnchorEffectiveResponse, + ProgramCalendarAnchorRolloverResponse, ProgramCalendarAnchorUpdate, ) from app.utils.holidays import get_academic_year_holidays @@ -199,6 +200,116 @@ def delete_anchor(self, anchor_id: UUID) -> bool: self.db.commit() return True + def rollover_anchors( + self, + *, + source_academic_year: int, + target_academic_year: int, + ) -> ProgramCalendarAnchorRolloverResponse: + if target_academic_year <= source_academic_year: + raise ValueError( + "target_academic_year must be greater than source_academic_year" + ) + + source_anchors = ( + self.db.query(ProgramCalendarAnchor) + .filter( + ProgramCalendarAnchor.academic_year == source_academic_year, + ProgramCalendarAnchor.is_active.is_(True), + ) + .order_by( + ProgramCalendarAnchor.policy_layer, + ProgramCalendarAnchor.anchor_kind, + ProgramCalendarAnchor.name, + ) + .all() + ) + + existing_target_anchors = ( + self.db.query(ProgramCalendarAnchor) + .filter(ProgramCalendarAnchor.academic_year == target_academic_year) + .all() + ) + existing_keys = { + ( + anchor.name, + anchor.policy_layer, + anchor.anchor_kind, + anchor.anchor_rule, + anchor.federal_holiday, + anchor.month, + anchor.day, + anchor.day_offset, + anchor.duration_days, + anchor.applies_to, + anchor.applies_to_inpatient, + anchor.activity_id, + anchor.time_of_day, + anchor.notes, + anchor.is_active, + ) + for anchor in existing_target_anchors + } + + created_names: list[str] = [] + skipped_names: list[str] = [] + + for anchor in source_anchors: + anchor_key = ( + anchor.name, + anchor.policy_layer, + anchor.anchor_kind, + anchor.anchor_rule, + anchor.federal_holiday, + anchor.month, + anchor.day, + anchor.day_offset, + anchor.duration_days, + anchor.applies_to, + anchor.applies_to_inpatient, + anchor.activity_id, + anchor.time_of_day, + anchor.notes, + anchor.is_active, + ) + if anchor_key in existing_keys: + skipped_names.append(anchor.name) + continue + + self.db.add( + ProgramCalendarAnchor( + name=anchor.name, + policy_layer=anchor.policy_layer, + anchor_kind=anchor.anchor_kind, + anchor_rule=anchor.anchor_rule, + academic_year=target_academic_year, + federal_holiday=anchor.federal_holiday, + month=anchor.month, + day=anchor.day, + day_offset=anchor.day_offset, + duration_days=anchor.duration_days, + applies_to=anchor.applies_to, + applies_to_inpatient=anchor.applies_to_inpatient, + activity_id=anchor.activity_id, + time_of_day=anchor.time_of_day, + notes=anchor.notes, + is_active=anchor.is_active, + ) + ) + created_names.append(anchor.name) + existing_keys.add(anchor_key) + + self.db.commit() + return ProgramCalendarAnchorRolloverResponse( + source_academic_year=source_academic_year, + target_academic_year=target_academic_year, + source_anchor_count=len(source_anchors), + created_count=len(created_names), + skipped_count=len(skipped_names), + created_names=created_names, + skipped_names=skipped_names, + ) + def _materialize_anchor( self, *, diff --git a/backend/app/services/program_policy_snapshot_service.py b/backend/app/services/program_policy_snapshot_service.py index 5c0e77f23..7752fdd77 100644 --- a/backend/app/services/program_policy_snapshot_service.py +++ b/backend/app/services/program_policy_snapshot_service.py @@ -12,6 +12,7 @@ from app.schemas.program_policy_snapshot import ( ProgramPolicySnapshotCreate, ProgramPolicySnapshotEffectiveResponse, + ProgramPolicySnapshotRolloverResponse, ProgramPolicySnapshotResponse, ProgramPolicySnapshotUpdate, ) @@ -172,6 +173,114 @@ def delete_snapshot(self, snapshot_id: UUID) -> bool: self.db.commit() return True + def rollover_snapshots( + self, + *, + source_academic_year: int, + target_academic_year: int, + ) -> ProgramPolicySnapshotRolloverResponse: + if target_academic_year <= source_academic_year: + raise ValueError( + "target_academic_year must be greater than source_academic_year" + ) + + source_snapshots = ( + self.db.query(ProgramPolicySnapshot) + .filter( + ProgramPolicySnapshot.academic_year == source_academic_year, + ProgramPolicySnapshot.is_default.is_(False), + ProgramPolicySnapshot.is_active.is_(True), + ) + .order_by(ProgramPolicySnapshot.name) + .all() + ) + + existing_target_snapshots = ( + self.db.query(ProgramPolicySnapshot) + .filter( + ProgramPolicySnapshot.academic_year == target_academic_year, + ProgramPolicySnapshot.is_default.is_(False), + ) + .all() + ) + existing_keys = { + ( + snapshot.name, + snapshot.pgy1_inpatient_clinic_day_of_week, + snapshot.pgy1_inpatient_clinic_time_of_day, + snapshot.pgy2_inpatient_clinic_day_of_week, + snapshot.pgy2_inpatient_clinic_time_of_day, + snapshot.pgy3_inpatient_clinic_day_of_week, + snapshot.pgy3_inpatient_clinic_time_of_day, + snapshot.sm_academic_day_of_week, + snapshot.sm_academic_time_of_day, + snapshot.faculty_didactic_day_of_week, + snapshot.faculty_didactic_time_of_day, + snapshot.skip_final_week_for_faculty_didactic, + snapshot.notes, + snapshot.is_active, + ) + for snapshot in existing_target_snapshots + } + + created_names: list[str] = [] + skipped_names: list[str] = [] + + for snapshot in source_snapshots: + snapshot_key = ( + snapshot.name, + snapshot.pgy1_inpatient_clinic_day_of_week, + snapshot.pgy1_inpatient_clinic_time_of_day, + snapshot.pgy2_inpatient_clinic_day_of_week, + snapshot.pgy2_inpatient_clinic_time_of_day, + snapshot.pgy3_inpatient_clinic_day_of_week, + snapshot.pgy3_inpatient_clinic_time_of_day, + snapshot.sm_academic_day_of_week, + snapshot.sm_academic_time_of_day, + snapshot.faculty_didactic_day_of_week, + snapshot.faculty_didactic_time_of_day, + snapshot.skip_final_week_for_faculty_didactic, + snapshot.notes, + snapshot.is_active, + ) + if snapshot_key in existing_keys: + skipped_names.append(snapshot.name) + continue + + self.db.add( + ProgramPolicySnapshot( + name=snapshot.name, + academic_year=target_academic_year, + is_default=False, + is_active=snapshot.is_active, + pgy1_inpatient_clinic_day_of_week=snapshot.pgy1_inpatient_clinic_day_of_week, + pgy1_inpatient_clinic_time_of_day=snapshot.pgy1_inpatient_clinic_time_of_day, + pgy2_inpatient_clinic_day_of_week=snapshot.pgy2_inpatient_clinic_day_of_week, + pgy2_inpatient_clinic_time_of_day=snapshot.pgy2_inpatient_clinic_time_of_day, + pgy3_inpatient_clinic_day_of_week=snapshot.pgy3_inpatient_clinic_day_of_week, + pgy3_inpatient_clinic_time_of_day=snapshot.pgy3_inpatient_clinic_time_of_day, + sm_academic_day_of_week=snapshot.sm_academic_day_of_week, + sm_academic_time_of_day=snapshot.sm_academic_time_of_day, + faculty_didactic_day_of_week=snapshot.faculty_didactic_day_of_week, + faculty_didactic_time_of_day=snapshot.faculty_didactic_time_of_day, + skip_final_week_for_faculty_didactic=snapshot.skip_final_week_for_faculty_didactic, + notes=snapshot.notes, + ) + ) + created_names.append(snapshot.name) + existing_keys.add(snapshot_key) + + self.db.commit() + return ProgramPolicySnapshotRolloverResponse( + source_academic_year=source_academic_year, + target_academic_year=target_academic_year, + source_snapshot_count=len(source_snapshots), + created_count=len(created_names), + skipped_count=len(skipped_names), + created_names=created_names, + skipped_names=skipped_names, + ) + def _ensure_default_fallback_preserved( self, *, diff --git a/backend/tests/routes/test_program_calendar_anchors.py b/backend/tests/routes/test_program_calendar_anchors.py index e63a89647..8f07c1615 100644 --- a/backend/tests/routes/test_program_calendar_anchors.py +++ b/backend/tests/routes/test_program_calendar_anchors.py @@ -213,3 +213,100 @@ def test_protected_time_anchor_requires_activity_id( ) assert response.status_code == 422 assert "activity_id is required" in response.text + + +def test_rollover_program_calendar_anchors_clones_ay_rows( + client: TestClient, + auth_headers, + db, +) -> None: + db.add( + ProgramCalendarAnchor( + id=uuid4(), + name="Board Deadline", + policy_layer="regulatory", + anchor_kind="workflow_deadline", + anchor_rule="fixed_date", + academic_year=2026, + month=12, + day=1, + day_offset=0, + duration_days=1, + applies_to="resident", + applies_to_inpatient=False, + is_active=True, + ) + ) + db.commit() + + response = client.post( + "/api/v1/admin/program-calendar-anchors/rollover" + "?source_academic_year=2026&target_academic_year=2027", + headers=auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["source_anchor_count"] == 1 + assert payload["created_count"] == 1 + assert payload["skipped_count"] == 0 + assert payload["created_names"] == ["Board Deadline"] + + target_rows = ( + db.query(ProgramCalendarAnchor) + .filter(ProgramCalendarAnchor.academic_year == 2027) + .all() + ) + assert len(target_rows) == 1 + assert target_rows[0].name == "Board Deadline" + assert target_rows[0].policy_layer == "regulatory" + + +def test_rollover_program_calendar_anchors_skips_duplicates( + client: TestClient, + auth_headers, + db, +) -> None: + source_anchor = ProgramCalendarAnchor( + id=uuid4(), + name="Board Deadline", + policy_layer="regulatory", + anchor_kind="workflow_deadline", + anchor_rule="fixed_date", + academic_year=2026, + month=12, + day=1, + day_offset=0, + duration_days=1, + applies_to="resident", + applies_to_inpatient=False, + is_active=True, + ) + target_anchor = ProgramCalendarAnchor( + id=uuid4(), + name="Board Deadline", + policy_layer="regulatory", + anchor_kind="workflow_deadline", + anchor_rule="fixed_date", + academic_year=2027, + month=12, + day=1, + day_offset=0, + duration_days=1, + applies_to="resident", + applies_to_inpatient=False, + is_active=True, + ) + db.add_all([source_anchor, target_anchor]) + db.commit() + + response = client.post( + "/api/v1/admin/program-calendar-anchors/rollover" + "?source_academic_year=2026&target_academic_year=2027", + headers=auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["source_anchor_count"] == 1 + assert payload["created_count"] == 0 + assert payload["skipped_count"] == 1 + assert payload["skipped_names"] == ["Board Deadline"] diff --git a/backend/tests/routes/test_program_policy_snapshots.py b/backend/tests/routes/test_program_policy_snapshots.py index 77b11520e..c1b169e35 100644 --- a/backend/tests/routes/test_program_policy_snapshots.py +++ b/backend/tests/routes/test_program_policy_snapshots.py @@ -269,3 +269,112 @@ def test_program_policy_snapshot_validates_scope( ) assert response.status_code == 422 assert "require academic_year" in response.text + + +def test_rollover_program_policy_snapshots_clones_ay_rows( + client: TestClient, + auth_headers, + db, +) -> None: + db.add( + ProgramPolicySnapshot( + id=uuid4(), + name="AY 2026 Program Policy", + academic_year=2026, + is_default=False, + is_active=True, + pgy1_inpatient_clinic_day_of_week=2, + pgy1_inpatient_clinic_time_of_day="AM", + pgy2_inpatient_clinic_day_of_week=1, + pgy2_inpatient_clinic_time_of_day="PM", + pgy3_inpatient_clinic_day_of_week=0, + pgy3_inpatient_clinic_time_of_day="PM", + sm_academic_day_of_week=2, + sm_academic_time_of_day="AM", + faculty_didactic_day_of_week=2, + faculty_didactic_time_of_day="PM", + skip_final_week_for_faculty_didactic=True, + notes="Carry forward", + ) + ) + db.commit() + + response = client.post( + "/api/v1/admin/program-policy-snapshots/rollover" + "?source_academic_year=2026&target_academic_year=2027", + headers=auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["source_snapshot_count"] == 1 + assert payload["created_count"] == 1 + assert payload["skipped_count"] == 0 + assert payload["created_names"] == ["AY 2026 Program Policy"] + + target_rows = ( + db.query(ProgramPolicySnapshot) + .filter(ProgramPolicySnapshot.academic_year == 2027) + .all() + ) + assert len(target_rows) == 1 + assert target_rows[0].name == "AY 2026 Program Policy" + assert target_rows[0].notes == "Carry forward" + + +def test_rollover_program_policy_snapshots_skips_duplicates( + client: TestClient, + auth_headers, + db, +) -> None: + source_snapshot = ProgramPolicySnapshot( + id=uuid4(), + name="AY 2026 Program Policy", + academic_year=2026, + is_default=False, + is_active=True, + pgy1_inpatient_clinic_day_of_week=2, + pgy1_inpatient_clinic_time_of_day="AM", + pgy2_inpatient_clinic_day_of_week=1, + pgy2_inpatient_clinic_time_of_day="PM", + pgy3_inpatient_clinic_day_of_week=0, + pgy3_inpatient_clinic_time_of_day="PM", + sm_academic_day_of_week=2, + sm_academic_time_of_day="AM", + faculty_didactic_day_of_week=2, + faculty_didactic_time_of_day="PM", + skip_final_week_for_faculty_didactic=True, + notes="Carry forward", + ) + target_snapshot = ProgramPolicySnapshot( + id=uuid4(), + name="AY 2026 Program Policy", + academic_year=2027, + is_default=False, + is_active=True, + pgy1_inpatient_clinic_day_of_week=2, + pgy1_inpatient_clinic_time_of_day="AM", + pgy2_inpatient_clinic_day_of_week=1, + pgy2_inpatient_clinic_time_of_day="PM", + pgy3_inpatient_clinic_day_of_week=0, + pgy3_inpatient_clinic_time_of_day="PM", + sm_academic_day_of_week=2, + sm_academic_time_of_day="AM", + faculty_didactic_day_of_week=2, + faculty_didactic_time_of_day="PM", + skip_final_week_for_faculty_didactic=True, + notes="Carry forward", + ) + db.add_all([source_snapshot, target_snapshot]) + db.commit() + + response = client.post( + "/api/v1/admin/program-policy-snapshots/rollover" + "?source_academic_year=2026&target_academic_year=2027", + headers=auth_headers, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["source_snapshot_count"] == 1 + assert payload["created_count"] == 0 + assert payload["skipped_count"] == 1 + assert payload["skipped_names"] == ["AY 2026 Program Policy"] diff --git a/backend/tests/services/test_annual_rotation_service.py b/backend/tests/services/test_annual_rotation_service.py index bad6122e6..3147f9942 100644 --- a/backend/tests/services/test_annual_rotation_service.py +++ b/backend/tests/services/test_annual_rotation_service.py @@ -30,6 +30,8 @@ ) from app.models.person import Person from app.models.person_academic_year import PersonAcademicYear +from app.models.program_calendar_anchor import ProgramCalendarAnchor +from app.models.program_policy_snapshot import ProgramPolicySnapshot from app.models.rotation_template import RotationTemplate from app.models.schedule_draft import ( DraftSourceType, @@ -980,6 +982,40 @@ def test_coordinator_can_get_annual_planning_readiness( pgy_level=3, is_graduated=True, ), + ProgramPolicySnapshot( + id=uuid4(), + name="AY 2026 Program Policy", + academic_year=2026, + is_default=False, + is_active=True, + pgy1_inpatient_clinic_day_of_week=2, + pgy1_inpatient_clinic_time_of_day="AM", + pgy2_inpatient_clinic_day_of_week=1, + pgy2_inpatient_clinic_time_of_day="PM", + pgy3_inpatient_clinic_day_of_week=0, + pgy3_inpatient_clinic_time_of_day="PM", + sm_academic_day_of_week=2, + sm_academic_time_of_day="AM", + faculty_didactic_day_of_week=2, + faculty_didactic_time_of_day="PM", + skip_final_week_for_faculty_didactic=True, + ), + ProgramCalendarAnchor( + id=uuid4(), + name="Board Deadline", + policy_layer=InstitutionalEventPolicyLayer.REGULATORY, + anchor_kind="workflow_deadline", + anchor_rule="fixed_date", + academic_year=2026, + month=12, + day=1, + day_offset=0, + duration_days=1, + applies_to=InstitutionalEventScope.RESIDENT, + applies_to_inpatient=False, + activity_id=None, + is_active=True, + ), InstitutionalEvent( id=uuid4(), name="Program Retreat", @@ -1022,8 +1058,18 @@ def test_coordinator_can_get_annual_planning_readiness( assert data["faculty"]["solver_shaped_faculty_count"] == 1 assert data["faculty"]["missing_weekly_shape_count"] == 1 assert data["faculty"]["adjunct_faculty_count"] == 0 - assert data["policy_anchors"]["status"] == "warning" + assert data["policy_anchors"]["status"] == "ready" + assert data["policy_anchors"]["has_effective_program_policy"] is True + assert data["policy_anchors"]["effective_program_policy_source"] == "academic_year" + assert data["policy_anchors"]["active_anchor_count"] == 3 + assert data["policy_anchors"]["active_calendar_anchor_count"] == 1 assert data["policy_anchors"]["active_event_count"] == 2 + assert data["policy_anchors"]["program_anchor_count"] == 1 + assert data["policy_anchors"]["regulatory_anchor_count"] == 1 + assert data["policy_anchors"]["institution_anchor_count"] == 1 + assert data["policy_anchors"]["program_calendar_anchor_count"] == 0 + assert data["policy_anchors"]["regulatory_calendar_anchor_count"] == 1 + assert data["policy_anchors"]["institution_calendar_anchor_count"] == 0 assert data["policy_anchors"]["program_event_count"] == 1 assert data["policy_anchors"]["regulatory_event_count"] == 0 assert data["policy_anchors"]["institution_event_count"] == 1 @@ -1043,10 +1089,7 @@ def test_coordinator_can_get_annual_planning_readiness( "Missing Shape Faculty" in blocker for blocker in data["faculty"]["blockers"] ) - assert any( - "No active regulatory policy anchors" in warning - for warning in data["policy_anchors"]["warnings"] - ) + assert data["policy_anchors"]["warnings"] == [] def test_coordinator_can_list_recent_proving_pass_reports( self, diff --git a/docs/development/BEST_PRACTICES_AND_GOTCHAS.md b/docs/development/BEST_PRACTICES_AND_GOTCHAS.md index 0b8b23993..df3660f94 100644 --- a/docs/development/BEST_PRACTICES_AND_GOTCHAS.md +++ b/docs/development/BEST_PRACTICES_AND_GOTCHAS.md @@ -803,6 +803,12 @@ docker exec scheduler-local-backend alembic revision --autogenerate -m "20260109 docker ps --format "table {{.Names}}\t{{.Status}}" | grep scheduler ``` +### Python Version Note (March 2026 laptop migration) + +The repo `.python-version` was updated from `3.11` to `3.12` during the M5 Max migration. +The backend venv (`backend/.venv`) uses Python 3.12.12 via pyenv. All services run on 3.12. +If you hit import or compatibility issues, this change is the first place to look. + ### Why Some Tools Need Docker - **pytest**: Imports FastAPI, SQLAlchemy, all backend code - needs full environment diff --git a/docs/planning/HARDCODED_TO_POSTGRES_ROADMAP.md b/docs/planning/HARDCODED_TO_POSTGRES_ROADMAP.md index 56971f593..280b96299 100644 --- a/docs/planning/HARDCODED_TO_POSTGRES_ROADMAP.md +++ b/docs/planning/HARDCODED_TO_POSTGRES_ROADMAP.md @@ -1,6 +1,6 @@ # Hardcoded-to-Postgres Migration Roadmap -> **Created:** 2026-03-14 | **Updated:** 2026-03-14 | **Status:** Tracks 1-7 Complete | **Priority:** FOUNDATION +> **Created:** 2026-03-14 | **Updated:** 2026-03-21 | **Status:** Tracks 1-7 Complete; higher-order policy actualization remains | **Priority:** FOUNDATION > > **Problem:** Almost nothing that is changeable by a human should be hardcoded in Python. > Multiple audits (Gemini, Claude) found that scheduling policy, preload rules, constraint @@ -9,6 +9,12 @@ --- +## March 21 Status Update + +- `ProgramPolicySnapshot`, `ProgramCalendarAnchor`, and policy-layered `InstitutionalEvent` surfaces now exist in Postgres with API/admin coverage. +- Annual-planning readiness, review, and rollover now consume those three DB-backed policy sources together instead of treating institutional events as the only policy authority. +- The remaining backlog is no longer "create policy tables." The remaining backlog is cutting the still-hardcoded higher-order policy out of Python and into those surviving DB-backed layers without changing scheduling behavior opportunistically. + ## Severity Legend | Icon | Meaning | @@ -135,7 +141,7 @@ Role-based defaults (clinic limits, call preferences) are computed as Python pro ## Track 6: Calendar/Timetable Policy → Postgres -**Status:** ✅ Complete (PR #1318) — overnight call weekdays + FMIT week start in `application_settings`, GUI-editable +**Status:** ✅ Base calendar settings complete (PR #1318); higher-order policy/calendar layer now exists in DB, but live-behavior cutover is still incomplete | What | Current Location | Target | Priority | |------|-----------------|--------|----------| @@ -144,6 +150,8 @@ Role-based defaults (clinic limits, call preferences) are computed as Python pro | Wednesday AM intern-only | 🟡 `temporal.py:27` | `weekly_patterns` or requirements | Low | | Last Wednesday LEC/ADV | 🟡 `rotation_codes.py:205` | `weekly_patterns` | Low | +**Current note (2026-03-21):** The repo now also has DB-backed `ProgramCalendarAnchor` and `ProgramPolicySnapshot` tables plus policy-layered `InstitutionalEvent` rows. Those surfaces can drive annual-planning readiness/review/rollover today, but preload/runtime logic still owns some higher-order calendar semantics in Python. + --- ## What's Already Correct (No Action) diff --git a/docs/planning/POLICY_LAYER_ACTUALIZATION_20260321.md b/docs/planning/POLICY_LAYER_ACTUALIZATION_20260321.md new file mode 100644 index 000000000..c5ca6c3e4 --- /dev/null +++ b/docs/planning/POLICY_LAYER_ACTUALIZATION_20260321.md @@ -0,0 +1,83 @@ +# Policy Layer Actualization — 2026-03-21 + +## Scope + +Close the remaining annual-planning gap in the existing DB-backed higher-order policy layer without changing scheduling logic, ACGME interpretation, or solver behavior. + +This slice is limited to: + +- annual-planning readiness +- annual-planning review visibility +- policy rollover workflows +- backend/frontend API coverage +- tests, changelog, and tracker updates + +## Problem + +The repo already had three real policy sources: + +- `ProgramPolicySnapshot` +- `ProgramCalendarAnchor` +- policy-layered `InstitutionalEvent` + +But annual planning still treated institutional events as the only operational policy authority. That left the DB-backed policy layer partially real and partially invisible. + +## Approach + +1. Add rollover endpoints for `ProgramPolicySnapshot` and `ProgramCalendarAnchor`. +2. Broaden annual-planning readiness to assess all three DB-backed policy sources together. +3. Extend the annual-planning hub so coordinators can review and roll the effective program-policy snapshot, recurring calendar anchors, and institutional events for a target academic year. +4. Keep the branch clear of scheduling-engine policy changes. + +## Key Decisions + +- Do not alter solver logic or preload semantics in this branch. +- Treat missing program-policy coverage as a readiness concern, not an automatic runtime fallback change. +- Keep the rollout additive: expose and validate the existing DB-backed policy layer before attempting deeper hardcoded-policy cutover. + +## Files Touched + +Backend: + +- routes for program-policy-snapshot and program-calendar-anchor rollover +- annual-planning readiness service/schema updates +- service-layer rollover helpers +- route and service tests + +Frontend: + +- annual-planning hub policy review and rollover UX +- program-policy and calendar-anchor hooks +- generated API types +- hub and hook tests + +Docs: + +- `TODO.md` +- `CHANGELOG.md` +- `docs/planning/HARDCODED_TO_POSTGRES_ROADMAP.md` +- `docs/planning/ROTATION_SHAPE_CUTOVER_STATUS_20260320.md` + +## Validation + +- Backend `ruff` on touched files +- Backend route tests for both rollover surfaces +- Annual-planning readiness test coverage +- Frontend unit tests for hub + remediation + new hooks +- Frontend `eslint` +- Frontend `npm run type-check` +- Frontend API type generation + +Local DB validation: + +- confirmed local Postgres was behind at `20260319_neuro_selective` +- wrote ignored backup: `backups/manual/20260321_225800_pre_alembic_head_policy_layer.dump` +- upgraded local DB to `20260321_program_policy_snapshots` + +## Residual Risk + +One broader annual-rotation-service test still fails outside this slice: + +- `backend/tests/services/test_annual_rotation_service.py::TestAnnualBlockDraftGeneration::test_generate_repair_block_drafts_targets_only_changed_blocks` + +This appears pre-existing and outside the policy-layer readiness/review/rollover path changed here. diff --git a/docs/planning/ROTATION_SHAPE_CUTOVER_STATUS_20260320.md b/docs/planning/ROTATION_SHAPE_CUTOVER_STATUS_20260320.md index b9354a705..286c51459 100644 --- a/docs/planning/ROTATION_SHAPE_CUTOVER_STATUS_20260320.md +++ b/docs/planning/ROTATION_SHAPE_CUTOVER_STATUS_20260320.md @@ -131,6 +131,7 @@ These areas have real implementation work landed, but are not complete end-state | Faculty weekly-shape cutover | Merged on `main`; further normalization and UI polish still remain, but faculty scheduling now has a real weekly-shape authority path | | Weekly clinic policy visibility | Merged on `main`; further admin refinement still possible | | Constraint scope foundations | DB-first binding cutover is merged; GUI and higher-order policy layers remain | +| Higher-order policy layers | `ProgramPolicySnapshot`, `ProgramCalendarAnchor`, and policy-layered `InstitutionalEvent` surfaces now exist, and annual-planning readiness/review/rollover can consume all three together; live policy cutover out of Python still remains | | Annual workflow foundations | Merged on `main`; the current in-flight slice is publish/draft hardening plus absence-driven annual optimization in `#1452`-`#1457`, `#1468`, `#1484`, and `#1485`, with the native proving pass now completing baseline publish, repair publish, and exact shock-impact review | | Repo-wide terminology cutover | Major user-facing surfaces merged; deeper comments/tests/docs remain incomplete | @@ -141,8 +142,6 @@ These are the important pieces that still have no real implementation PR in the | Status | Work item | Why it matters | |---|---|---| | `NOT STARTED` | GUI for editing scoped constraint bindings and parameters | Required for the stated goal that humans manipulate hard/soft reality through the GUI | -| `NOT STARTED` | DB-backed program / regulatory / institution policy layers | Needed for PGY-wide rules, ACGME policy, military/institution policy, and holiday/program anchors above rotation shapes | -| `NOT STARTED` | Program calendar tables and GUI | The holiday-anchor idea is documented, but not implemented | | 13-block annual draft / validate / publish workflow | Core lifecycle is merged on `main` via `#1425`-`#1428`. The current open slice in `#1452`-`#1456` hardens publish aliases, timestamp semantics, and idempotent block-draft regeneration; `#1468` and `#1484` prove the native annual drill now reaches successful repair-plan and repair-draft generation without stale resilience helper failures. Remaining: merge the open stack and keep tightening review/reporting ergonomics | | Shock-event model + blast-radius analysis + targeted regeneration | The first real implementation slice is merged on `main`; `Absence` already drives shock input, impact preview, and shock draft creation. `#1457` extends that to annual optimization pressure, `#1468` keeps fully absent shock residents from aborting repair generation, and `#1485` adds exact resident/block blast-radius rows to shock preview. Broader blast-radius analysis and downstream repair publish flows still remain | | `NOT STARTED` | Full implementation rename from `rotation_templates` to `rotation_shapes` in code/table names | Wording is moving first; the actual model rename has not started | @@ -155,7 +154,7 @@ If the goal is to keep momentum after the merged groundwork: 1. implement faculty weekly shapes 2. finish the annual workflow hardening stack (`#1452`-`#1457`, `#1468`, `#1484`, `#1485`) -3. add the program / regulatory / institution policy tables +3. actualize the higher-order policy layers that now exist in DB 4. extend shock handling into broader blast-radius analysis and repair publish flows 5. then build the GUI/editor surfaces for scoped constraints and the higher-order policy layers @@ -163,7 +162,7 @@ Reason: - the DB-first constraint model is now in place on `main` - faculty weekly-shape actualization is now merged -- the immediate north-star gap is completing end-to-end repair on top of the annual workflow foundation, not designing another event source +- the higher-order policy surfaces now exist in DB, so the immediate gap is cutting live behavior and operator workflows onto those sources instead of designing another event table ## What To Use This Document For diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef55353fe..e3a1bba64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -691,6 +691,18 @@ "react": ">=16.8.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -701,6 +713,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2162,6 +2185,19 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@next/env": { "version": "15.5.12", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", @@ -3609,6 +3645,17 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4478,6 +4525,34 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", @@ -4492,6 +4567,62 @@ "darwin" ] }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", @@ -4520,6 +4651,167 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -12325,7 +12617,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/frontend/src/app/hub/annual-planning/AnnualPlanningHubClient.tsx b/frontend/src/app/hub/annual-planning/AnnualPlanningHubClient.tsx index 04aaad3a2..cf37721ae 100644 --- a/frontend/src/app/hub/annual-planning/AnnualPlanningHubClient.tsx +++ b/frontend/src/app/hub/annual-planning/AnnualPlanningHubClient.tsx @@ -73,6 +73,16 @@ import { useRolloverInstitutionalEvents, type InstitutionalEvent, } from "@/hooks/useInstitutionalEvents"; +import { + useEffectiveProgramCalendarAnchors, + useRolloverProgramCalendarAnchors, + type ProgramCalendarAnchorEffective, +} from "@/hooks/useProgramCalendarAnchors"; +import { + useEffectiveProgramPolicySnapshot, + useRolloverProgramPolicySnapshots, + type ProgramPolicySnapshotEffective, +} from "@/hooks/useProgramPolicySnapshots"; import { useBulkCreatePeople, usePeople } from "@/hooks/usePeople"; import { useAuth } from "@/contexts/AuthContext"; import { getErrorMessage } from "@/lib/errors"; @@ -168,26 +178,55 @@ function formatPolicyLayerLabel(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } -export function groupPolicyAnchorsByLayer(events: InstitutionalEvent[]) { +type PolicyLayerScopedItem = { + policyLayer: "program" | "regulatory" | "institution"; +}; + +function groupPolicyItemsByLayer(items: T[]) { const grouped = { - program: [] as InstitutionalEvent[], - regulatory: [] as InstitutionalEvent[], - institution: [] as InstitutionalEvent[], + program: [] as T[], + regulatory: [] as T[], + institution: [] as T[], }; - for (const event of events) { + for (const item of items) { if ( - event.policyLayer === "program" || - event.policyLayer === "regulatory" || - event.policyLayer === "institution" + item.policyLayer === "program" || + item.policyLayer === "regulatory" || + item.policyLayer === "institution" ) { - grouped[event.policyLayer].push(event); + grouped[item.policyLayer].push(item); } } return grouped; } +export function groupPolicyAnchorsByLayer(events: InstitutionalEvent[]) { + return groupPolicyItemsByLayer(events); +} + +function groupProgramCalendarAnchorsByLayer( + anchors: ProgramCalendarAnchorEffective[], +) { + return groupPolicyItemsByLayer(anchors); +} + +function formatProgramPolicySourceLabel( + policy: Pick, +): string { + return policy.source === "academic_year" + ? `AY ${policy.requestedAcademicYear}-${policy.requestedAcademicYear + 1} override` + : "Global default"; +} + +function formatProgramCalendarAnchorKind(value: string): string { + return value + .split("_") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + function buildInstitutionalEventsAdminHref( academicYear: number | null, policyLayer: "all" | "program" | "regulatory" | "institution" = "all", @@ -511,6 +550,8 @@ export function AnnualPlanningHubClient() { const clonePlan = useCloneAnnualPlan(); const rolloverAcademicYear = useRolloverAcademicYear(); const seedMissingResidentAcademicYear = useSeedMissingResidentAcademicYear(); + const rolloverProgramPolicySnapshots = useRolloverProgramPolicySnapshots(); + const rolloverProgramCalendarAnchors = useRolloverProgramCalendarAnchors(); const rolloverInstitutionalEvents = useRolloverInstitutionalEvents(); const generateBlockDrafts = useGenerateAnnualBlockDrafts(); const generateRepairBlockDrafts = useGenerateAnnualRepairBlockDrafts(); @@ -760,6 +801,8 @@ export function AnnualPlanningHubClient() { clonePlan.isPending || rolloverAcademicYear.isPending || seedMissingResidentAcademicYear.isPending || + rolloverProgramPolicySnapshots.isPending || + rolloverProgramCalendarAnchors.isPending || rolloverInstitutionalEvents.isPending || generateBlockDrafts.isPending || generateRepairBlockDrafts.isPending || @@ -777,7 +820,14 @@ export function AnnualPlanningHubClient() { const readinessQuery = useAnnualPlanningReadiness( Number.isNaN(targetAcademicYear) ? null : targetAcademicYear, ); - const policyAnchorsQuery = useEffectiveInstitutionalEvents( + const effectiveProgramPolicyQuery = useEffectiveProgramPolicySnapshot( + Number.isNaN(targetAcademicYear) ? null : targetAcademicYear, + ); + const programCalendarAnchorsQuery = useEffectiveProgramCalendarAnchors( + Number.isNaN(targetAcademicYear) ? null : targetAcademicYear, + { pageSize: 200 }, + ); + const institutionalEventsQuery = useEffectiveInstitutionalEvents( Number.isNaN(targetAcademicYear) ? null : targetAcademicYear, { pageSize: 200 }, ); @@ -790,9 +840,16 @@ export function AnnualPlanningHubClient() { : null; const solverTimeLimitValue = parseSolverSeconds(solverTimeLimit); + const programCalendarAnchorsByLayer = useMemo( + () => + groupProgramCalendarAnchorsByLayer( + programCalendarAnchorsQuery.data?.items ?? [], + ), + [programCalendarAnchorsQuery.data?.items], + ); const policyAnchorsByLayer = useMemo( - () => groupPolicyAnchorsByLayer(policyAnchorsQuery.data?.items ?? []), - [policyAnchorsQuery.data?.items], + () => groupPolicyAnchorsByLayer(institutionalEventsQuery.data?.items ?? []), + [institutionalEventsQuery.data?.items], ); useEffect(() => { @@ -882,19 +939,38 @@ export function AnnualPlanningHubClient() { } try { - const response = await rolloverInstitutionalEvents.mutateAsync({ - sourceAcademicYear: targetAcademicYear - 1, - targetAcademicYear, - }); - if (response.sourceEventCount === 0) { + const [ + policyResponse, + calendarResponse, + eventResponse, + ] = await Promise.all([ + rolloverProgramPolicySnapshots.mutateAsync({ + sourceAcademicYear: targetAcademicYear - 1, + targetAcademicYear, + }), + rolloverProgramCalendarAnchors.mutateAsync({ + sourceAcademicYear: targetAcademicYear - 1, + targetAcademicYear, + }), + rolloverInstitutionalEvents.mutateAsync({ + sourceAcademicYear: targetAcademicYear - 1, + targetAcademicYear, + }), + ]); + const totalSourceRows = + policyResponse.sourceSnapshotCount + + calendarResponse.sourceAnchorCount + + eventResponse.sourceEventCount; + + if (totalSourceRows === 0) { setFeedback( - `No AY-scoped policy anchors were found for AY ${targetAcademicYear - 1}-${targetAcademicYear}.`, + `No AY-scoped policy rows were found for AY ${targetAcademicYear - 1}-${targetAcademicYear}.`, ); return; } setFeedback( - `Rolled policy anchors AY ${targetAcademicYear - 1}-${targetAcademicYear} forward: created ${response.createdCount}, skipped ${response.skippedCount}.`, + `Rolled policy layers AY ${targetAcademicYear - 1}-${targetAcademicYear} forward: snapshots ${policyResponse.createdCount} created/${policyResponse.skippedCount} skipped, calendar anchors ${calendarResponse.createdCount}/${calendarResponse.skippedCount}, events ${eventResponse.createdCount}/${eventResponse.skippedCount}.`, ); } catch (error) { setFeedback(getErrorMessage(error)); @@ -1628,7 +1704,9 @@ export function AnnualPlanningHubClient() { disabled={isBusy || isExportingYearWorkbook} className="inline-flex items-center gap-2 rounded-md border border-sky-200 px-4 py-2 text-sm font-medium text-sky-800 hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-50" > - {rolloverInstitutionalEvents.isPending ? ( + {rolloverProgramPolicySnapshots.isPending || + rolloverProgramCalendarAnchors.isPending || + rolloverInstitutionalEvents.isPending ? ( ) : null} Roll Policy Anchors{" "} @@ -1654,8 +1732,9 @@ export function AnnualPlanningHubClient() {

Annual optimization requires AY-scoped roster rows. If the target year has not been rolled forward yet, run the AY rollover first. If the - readiness check shows missing policy anchors, roll the prior academic - year's institutional events forward before trusting the proving pass. + readiness check shows missing policy layers, roll the prior academic + year's program policy, calendar anchors, and institutional events + forward before trusting the proving pass. The workbook download exports current schedule truth for the selected academic year as a 14-sheet Excel file.

@@ -1676,7 +1755,7 @@ export function AnnualPlanningHubClient() {

Preflight the target academic year before you optimize. This checks AY-scoped resident roster rows, non-adjunct faculty weekly shapes, and - active policy anchors that shape the proving pass. + active policy layers that shape the proving pass.

{Number.isNaN(targetAcademicYear) ? (
@@ -1977,7 +2056,7 @@ export function AnnualPlanningHubClient() {
- Policy Anchors + Policy Layers
- Active:{" "} + Policy snapshot:{" "} + {readinessQuery.data.policyAnchors.hasEffectiveProgramPolicy + ? readinessQuery.data.policyAnchors + .effectiveProgramPolicySource === "academic_year" + ? "AY override" + : "Global default" + : "Missing"} + + + Calendar anchors:{" "} + { + readinessQuery.data.policyAnchors + .activeCalendarAnchorCount + } + + + Institutional events:{" "} {readinessQuery.data.policyAnchors.activeEventCount} - Program:{" "} - {readinessQuery.data.policyAnchors.programEventCount} + Combined anchors:{" "} + {readinessQuery.data.policyAnchors.activeAnchorCount} - Regulatory:{" "} - {readinessQuery.data.policyAnchors.regulatoryEventCount} + Program coverage:{" "} + {readinessQuery.data.policyAnchors.programAnchorCount} - Institution:{" "} - {readinessQuery.data.policyAnchors.institutionEventCount} + Regulatory coverage:{" "} + {readinessQuery.data.policyAnchors.regulatoryAnchorCount} + + + Institution coverage:{" "} + { + readinessQuery.data.policyAnchors + .institutionAnchorCount + }
@@ -2056,8 +2158,8 @@ export function AnnualPlanningHubClient() { {readinessQuery.data.policyAnchors.blockers.length === 0 && readinessQuery.data.policyAnchors.warnings.length === 0 ? (
- Program, regulatory, and institution anchors are present - for this AY. + Program policy, calendar anchors, and institutional + events are present for this AY.
) : null}
@@ -2196,117 +2298,343 @@ export function AnnualPlanningHubClient() {
- Policy Anchors for AY{" "} + Policy Layers for AY{" "} {Number.isNaN(targetAcademicYear) ? "—" : `${targetAcademicYear}-${targetAcademicYear + 1}`}
- - Open Calendar Admin - +
+ + Open Policy Admin + + + Open Calendar Admin + + + Open Events Admin + +

- These active program, regulatory, and institutional events are the - calendar anchors that currently apply to the selected academic year. - Global events are shown alongside AY-scoped events because both affect - preload and repair behavior. + Review the effective program policy snapshot, recurring calendar + anchors, and institutional events that currently apply to the + selected academic year. Global rows are shown alongside AY-scoped rows + because both affect preload, proving-pass expectations, and repair + behavior.

{Number.isNaN(targetAcademicYear) ? (
- Enter a valid academic year to review effective policy anchors. -
- ) : policyAnchorsQuery.isLoading ? ( -
- - Loading policy anchors… + Enter a valid academic year to review effective policy layers.
- ) : policyAnchorsQuery.isError ? ( -
- Failed to load policy anchors:{" "} - {getErrorMessage(policyAnchorsQuery.error)} -
- ) : policyAnchorsQuery.data?.items.length ? ( -
- {(["program", "regulatory", "institution"] as const).map( - (layer) => ( -
-
-
-
-
- {formatPolicyLayerLabel(layer)} -
-
- {policyAnchorsByLayer[layer].length} active event - {policyAnchorsByLayer[layer].length === 1 ? "" : "s"} -
-
- +
+
+
+
+
+ Effective Program Policy +
+
+ One effective snapshot should exist before trusting the + proving pass. +
+
+ + Manage + +
+
+
+ {effectiveProgramPolicyQuery.isLoading ? ( +
+ + Loading effective program policy… +
+ ) : effectiveProgramPolicyQuery.isError ? ( +
+ Failed to load effective program policy:{" "} + {getErrorMessage(effectiveProgramPolicyQuery.error)} +
+ ) : effectiveProgramPolicyQuery.data ? ( +
+
+ + {effectiveProgramPolicyQuery.data.name} + + + {formatProgramPolicySourceLabel( + effectiveProgramPolicyQuery.data, )} - className="inline-flex items-center gap-1 rounded-md border border-slate-300 px-2.5 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-50" - > - Manage - +
+
+ + PGY-1 inpatient clinic:{" "} + {effectiveProgramPolicyQuery.data.pgy1InpatientClinicDayOfWeek} /{" "} + { + effectiveProgramPolicyQuery.data + .pgy1InpatientClinicTimeOfDay + } + + + PGY-2 inpatient clinic:{" "} + {effectiveProgramPolicyQuery.data.pgy2InpatientClinicDayOfWeek} /{" "} + { + effectiveProgramPolicyQuery.data + .pgy2InpatientClinicTimeOfDay + } + + + PGY-3 inpatient clinic:{" "} + {effectiveProgramPolicyQuery.data.pgy3InpatientClinicDayOfWeek} /{" "} + { + effectiveProgramPolicyQuery.data + .pgy3InpatientClinicTimeOfDay + } + + + SM academic:{" "} + {effectiveProgramPolicyQuery.data.smAcademicDayOfWeek} /{" "} + {effectiveProgramPolicyQuery.data.smAcademicTimeOfDay} + + + Faculty didactic:{" "} + {effectiveProgramPolicyQuery.data.facultyDidacticDayOfWeek} /{" "} + { + effectiveProgramPolicyQuery.data + .facultyDidacticTimeOfDay + } + + + Skip final faculty week:{" "} + {effectiveProgramPolicyQuery.data + .skipFinalWeekForFacultyDidactic + ? "yes" + : "no"} + +
+ {effectiveProgramPolicyQuery.data.notes ? ( +
+ {effectiveProgramPolicyQuery.data.notes} +
+ ) : null} +
+ ) : ( +
+ No active program policy snapshot applies to AY{" "} + {targetAcademicYear}-{targetAcademicYear + 1}.
-
- {policyAnchorsByLayer[layer].length === 0 ? ( -
- No active {layer} anchors apply to this academic year. + )} +
+
+ +
+
+
+
+
+
+ Effective Calendar Anchors
- ) : ( - policyAnchorsByLayer[layer].map((event) => ( -
-
- - {event.name} - - - {formatEventTypeLabel(event.eventType)} - - - {event.academicYear == null - ? "Global" - : `AY ${event.academicYear}-${event.academicYear + 1}`} - -
-
- {formatDateRange(event.startDate, event.endDate)} - {event.timeOfDay ? ` · ${event.timeOfDay}` : ""} -
-
- Scope: {event.appliesTo} - - {event.appliesToInpatient - ? "Includes inpatient" - : "Outpatient only"} - -
- {event.notes ? ( +
+ Recurring AY-aware anchors from the program calendar. +
+
+ + Manage + +
+
+
+ {programCalendarAnchorsQuery.isLoading ? ( +
+ + Loading effective calendar anchors… +
+ ) : programCalendarAnchorsQuery.isError ? ( +
+ Failed to load effective calendar anchors:{" "} + {getErrorMessage(programCalendarAnchorsQuery.error)} +
+ ) : ( + (["program", "regulatory", "institution"] as const).map( + (layer) => ( +
+
+
+ {formatPolicyLayerLabel(layer)} +
- {event.notes} + {programCalendarAnchorsByLayer[layer].length} anchor + {programCalendarAnchorsByLayer[layer].length === 1 + ? "" + : "s"}
- ) : null} +
+ {programCalendarAnchorsByLayer[layer].length === 0 ? ( +
+ No active {layer} calendar anchors apply. +
+ ) : ( +
+ {programCalendarAnchorsByLayer[layer].map((anchor) => ( +
+
+ + {anchor.name} + + + {formatProgramCalendarAnchorKind( + anchor.anchorKind, + )} + + + {anchor.academicYear == null + ? "Global" + : `AY ${anchor.academicYear}-${anchor.academicYear + 1}`} + +
+
+ {formatDateRange( + anchor.effectiveStartDate, + anchor.effectiveEndDate, + )} + {anchor.timeOfDay + ? ` · ${anchor.timeOfDay}` + : ""} +
+
+ Scope: {anchor.appliesTo} + + {anchor.appliesToInpatient + ? "Includes inpatient" + : "Outpatient only"} + +
+ {anchor.notes ? ( +
+ {anchor.notes} +
+ ) : null} +
+ ))} +
+ )}
- )) - )} + ), + ) + )} +
+
+ +
+
+
+
+
+ Effective Institutional Events +
+
+ AY-overlapping events that shape preload and repair. +
+
+ + Manage +
- ), - )} -
- ) : ( -
- No active policy anchors apply to AY {targetAcademicYear}- - {targetAcademicYear + 1}. +
+ {institutionalEventsQuery.isLoading ? ( +
+ + Loading effective institutional events… +
+ ) : institutionalEventsQuery.isError ? ( +
+ Failed to load effective institutional events:{" "} + {getErrorMessage(institutionalEventsQuery.error)} +
+ ) : ( + (["program", "regulatory", "institution"] as const).map( + (layer) => ( +
+
+
+ {formatPolicyLayerLabel(layer)} +
+
+ {policyAnchorsByLayer[layer].length} event + {policyAnchorsByLayer[layer].length === 1 ? "" : "s"} +
+
+ {policyAnchorsByLayer[layer].length === 0 ? ( +
+ No active {layer} events apply. +
+ ) : ( +
+ {policyAnchorsByLayer[layer].map((event) => ( +
+
+ + {event.name} + + + {formatEventTypeLabel(event.eventType)} + + + {event.academicYear == null + ? "Global" + : `AY ${event.academicYear}-${event.academicYear + 1}`} + +
+
+ {formatDateRange(event.startDate, event.endDate)} + {event.timeOfDay ? ` · ${event.timeOfDay}` : ""} +
+
+ Scope: {event.appliesTo} + + {event.appliesToInpatient + ? "Includes inpatient" + : "Outpatient only"} + +
+ {event.notes ? ( +
+ {event.notes} +
+ ) : null} +
+ ))} +
+ )} +
+ ), + ) + )} +
+
+
)} diff --git a/frontend/src/app/hub/annual-planning/__tests__/AnnualPlanningHubClient.test.tsx b/frontend/src/app/hub/annual-planning/__tests__/AnnualPlanningHubClient.test.tsx index 637a1305b..d371fd6a3 100644 --- a/frontend/src/app/hub/annual-planning/__tests__/AnnualPlanningHubClient.test.tsx +++ b/frontend/src/app/hub/annual-planning/__tests__/AnnualPlanningHubClient.test.tsx @@ -53,13 +53,23 @@ const mockPolicyAnchorReadiness = { warnings: [], }, policyAnchors: { - status: 'warning', + status: 'ready', + hasEffectiveProgramPolicy: true, + effectiveProgramPolicySource: 'academic_year', + activeAnchorCount: 3, + activeCalendarAnchorCount: 1, activeEventCount: 2, + programAnchorCount: 1, + regulatoryAnchorCount: 1, + institutionAnchorCount: 1, + programCalendarAnchorCount: 0, + regulatoryCalendarAnchorCount: 1, + institutionCalendarAnchorCount: 0, programEventCount: 1, regulatoryEventCount: 0, institutionEventCount: 1, blockers: [], - warnings: ['No active regulatory policy anchors'], + warnings: [], }, } as const; @@ -96,6 +106,10 @@ const mockUsePublishScheduleDraft = jest.fn(); const mockUseEffectiveInstitutionalEvents = jest.fn(); const mockUseRolloverInstitutionalEvents = jest.fn(); +const mockUseEffectiveProgramCalendarAnchors = jest.fn(); +const mockUseRolloverProgramCalendarAnchors = jest.fn(); +const mockUseEffectiveProgramPolicySnapshot = jest.fn(); +const mockUseRolloverProgramPolicySnapshots = jest.fn(); const mockUsePeople = jest.fn(); const mockUseBulkCreatePeople = jest.fn(); @@ -153,6 +167,20 @@ jest.mock("@/hooks/useInstitutionalEvents", () => ({ mockUseRolloverInstitutionalEvents(...args), })); +jest.mock("@/hooks/useProgramCalendarAnchors", () => ({ + useEffectiveProgramCalendarAnchors: (...args: unknown[]) => + mockUseEffectiveProgramCalendarAnchors(...args), + useRolloverProgramCalendarAnchors: (...args: unknown[]) => + mockUseRolloverProgramCalendarAnchors(...args), +})); + +jest.mock("@/hooks/useProgramPolicySnapshots", () => ({ + useEffectiveProgramPolicySnapshot: (...args: unknown[]) => + mockUseEffectiveProgramPolicySnapshot(...args), + useRolloverProgramPolicySnapshots: (...args: unknown[]) => + mockUseRolloverProgramPolicySnapshots(...args), +})); + jest.mock("@/hooks/useScheduleDrafts", () => ({ useScheduleDraftPreview: (...args: unknown[]) => mockUseScheduleDraftPreview(...args), @@ -248,6 +276,14 @@ describe('AnnualPlanningHubClient', () => { makeQueryResult({ items: [], total: 0, page: 1, pageSize: 200 }), ); mockUseRolloverInstitutionalEvents.mockReturnValue(makeMutationResult()); + mockUseEffectiveProgramCalendarAnchors.mockReturnValue( + makeQueryResult({ items: [], total: 0, page: 1, pageSize: 200 }), + ); + mockUseRolloverProgramCalendarAnchors.mockReturnValue(makeMutationResult()); + mockUseEffectiveProgramPolicySnapshot.mockReturnValue( + makeQueryResult(null), + ); + mockUseRolloverProgramPolicySnapshots.mockReturnValue(makeMutationResult()); mockUsePeople.mockReturnValue( makeQueryResult({ items: [], total: 0, page: 1, pageSize: 200 }), diff --git a/frontend/src/app/hub/annual-planning/__tests__/readinessRemediation.test.ts b/frontend/src/app/hub/annual-planning/__tests__/readinessRemediation.test.ts index a27b5bc5b..a8cfb255e 100644 --- a/frontend/src/app/hub/annual-planning/__tests__/readinessRemediation.test.ts +++ b/frontend/src/app/hub/annual-planning/__tests__/readinessRemediation.test.ts @@ -44,7 +44,17 @@ function buildReadiness( }, policyAnchors: { status: 'ready', + hasEffectiveProgramPolicy: true, + effectiveProgramPolicySource: 'academic_year', + activeAnchorCount: 4, + activeCalendarAnchorCount: 2, activeEventCount: 5, + programAnchorCount: 2, + regulatoryAnchorCount: 2, + institutionAnchorCount: 1, + programCalendarAnchorCount: 1, + regulatoryCalendarAnchorCount: 1, + institutionCalendarAnchorCount: 0, programEventCount: 2, regulatoryEventCount: 2, institutionEventCount: 1, @@ -131,7 +141,17 @@ describe('readiness remediation helpers', () => { const readiness = buildReadiness({ policyAnchors: { status: 'warning', + hasEffectiveProgramPolicy: false, + effectiveProgramPolicySource: null, + activeAnchorCount: 2, + activeCalendarAnchorCount: 1, activeEventCount: 2, + programAnchorCount: 0, + regulatoryAnchorCount: 1, + institutionAnchorCount: 1, + programCalendarAnchorCount: 0, + regulatoryCalendarAnchorCount: 1, + institutionCalendarAnchorCount: 0, programEventCount: 0, regulatoryEventCount: 1, institutionEventCount: 1, @@ -148,7 +168,17 @@ describe('readiness remediation helpers', () => { const readiness = buildReadiness({ policyAnchors: { status: 'warning', + hasEffectiveProgramPolicy: true, + effectiveProgramPolicySource: 'academic_year', + activeAnchorCount: 4, + activeCalendarAnchorCount: 2, activeEventCount: 4, + programAnchorCount: 2, + regulatoryAnchorCount: 2, + institutionAnchorCount: 0, + programCalendarAnchorCount: 1, + regulatoryCalendarAnchorCount: 1, + institutionCalendarAnchorCount: 0, programEventCount: 2, regulatoryEventCount: 2, institutionEventCount: 0, diff --git a/frontend/src/app/hub/annual-planning/readinessRemediation.ts b/frontend/src/app/hub/annual-planning/readinessRemediation.ts index f14036b58..b840589e5 100644 --- a/frontend/src/app/hub/annual-planning/readinessRemediation.ts +++ b/frontend/src/app/hub/annual-planning/readinessRemediation.ts @@ -32,12 +32,10 @@ export function shouldOfferIncomingResidentIntake( export function shouldOfferPolicyAnchorRollover( readiness: AnnualRotationPlanningReadiness, ): boolean { - // Institution-layer anchors are optional for some programs, so their absence - // alone should not trigger a rollover action that cannot resolve itself. return ( - readiness.policyAnchors.activeEventCount === 0 || - readiness.policyAnchors.programEventCount === 0 || - readiness.policyAnchors.regulatoryEventCount === 0 + !readiness.policyAnchors.hasEffectiveProgramPolicy || + readiness.policyAnchors.programAnchorCount === 0 || + readiness.policyAnchors.regulatoryAnchorCount === 0 ); } diff --git a/frontend/src/hooks/__tests__/useProgramCalendarAnchors.test.tsx b/frontend/src/hooks/__tests__/useProgramCalendarAnchors.test.tsx new file mode 100644 index 000000000..19ff622ff --- /dev/null +++ b/frontend/src/hooks/__tests__/useProgramCalendarAnchors.test.tsx @@ -0,0 +1,185 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +import { + programCalendarAnchorQueryKeys, + useCreateProgramCalendarAnchor, + useEffectiveProgramCalendarAnchors, + useProgramCalendarAnchors, + useRolloverProgramCalendarAnchors, + useUpdateProgramCalendarAnchor, +} from '../useProgramCalendarAnchors'; + +jest.mock('@/lib/api', () => ({ + del: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), +})); + +import { get, post, put } from '@/lib/api'; + +const mockGet = get as jest.Mock; +const mockPost = post as jest.Mock; +const mockPut = put as jest.Mock; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('programCalendarAnchorQueryKeys', () => { + it('builds a stable effective key', () => { + expect(programCalendarAnchorQueryKeys.effective(2026, { policyLayer: 'program' })).toEqual([ + 'program-calendar', + 'effective', + 2026, + { policyLayer: 'program' }, + ]); + }); +}); + +describe('useProgramCalendarAnchors', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses snake_case query params for admin filters', async () => { + mockGet.mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 100 }); + + const { result } = renderHook( + () => + useProgramCalendarAnchors({ + policyLayer: 'regulatory', + academicYear: 2026, + anchorKind: 'workflow_deadline', + appliesTo: 'resident', + isActive: true, + page: 1, + pageSize: 100, + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGet).toHaveBeenCalledWith( + '/admin/program-calendar-anchors?policy_layer=regulatory&academic_year=2026&anchor_kind=workflow_deadline&applies_to=resident&is_active=true&page=1&page_size=100', + ); + }); + + it('loads effective program calendar anchors for an academic year', async () => { + mockGet.mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 200 }); + + const { result } = renderHook( + () => + useEffectiveProgramCalendarAnchors(2026, { + policyLayer: 'regulatory', + pageSize: 200, + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGet).toHaveBeenCalledWith( + '/program-calendar/effective?academic_year=2026&policy_layer=regulatory&page_size=200', + ); + }); +}); + +describe('program calendar anchor mutations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a program calendar anchor via the admin endpoint', async () => { + mockPost.mockResolvedValueOnce({ id: 'anchor-1', name: 'Board Deadline' }); + + const { result } = renderHook(() => useCreateProgramCalendarAnchor(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + name: 'Board Deadline', + policyLayer: 'regulatory', + anchorKind: 'workflow_deadline', + anchorRule: 'fixed_date', + academicYear: 2026, + federalHoliday: null, + month: 12, + day: 1, + dayOffset: 0, + durationDays: 1, + appliesTo: 'resident', + appliesToInpatient: false, + activityId: null, + timeOfDay: null, + notes: null, + isActive: true, + }); + }); + + expect(mockPost).toHaveBeenCalledWith('/admin/program-calendar-anchors', expect.any(Object)); + }); + + it('updates a program calendar anchor via the admin endpoint', async () => { + mockPut.mockResolvedValueOnce({ id: 'anchor-1', name: 'Updated Deadline' }); + + const { result } = renderHook(() => useUpdateProgramCalendarAnchor(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + anchorId: 'anchor-1', + payload: { name: 'Updated Deadline' }, + }); + }); + + expect(mockPut).toHaveBeenCalledWith('/admin/program-calendar-anchors/anchor-1', { + name: 'Updated Deadline', + }); + }); + + it('rolls program calendar anchors forward through the admin endpoint', async () => { + mockPost.mockResolvedValueOnce({ + sourceAcademicYear: 2026, + targetAcademicYear: 2027, + sourceAnchorCount: 1, + createdCount: 1, + skippedCount: 0, + createdNames: ['Board Deadline'], + skippedNames: [], + }); + + const { result } = renderHook(() => useRolloverProgramCalendarAnchors(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + sourceAcademicYear: 2026, + targetAcademicYear: 2027, + }); + }); + + expect(mockPost).toHaveBeenCalledWith( + '/admin/program-calendar-anchors/rollover?source_academic_year=2026&target_academic_year=2027', + ); + }); +}); diff --git a/frontend/src/hooks/__tests__/useProgramPolicySnapshots.test.tsx b/frontend/src/hooks/__tests__/useProgramPolicySnapshots.test.tsx new file mode 100644 index 000000000..3e18a7e9c --- /dev/null +++ b/frontend/src/hooks/__tests__/useProgramPolicySnapshots.test.tsx @@ -0,0 +1,175 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +import { + programPolicySnapshotQueryKeys, + useCreateProgramPolicySnapshot, + useEffectiveProgramPolicySnapshot, + useProgramPolicySnapshots, + useRolloverProgramPolicySnapshots, + useUpdateProgramPolicySnapshot, +} from '../useProgramPolicySnapshots'; + +jest.mock('@/lib/api', () => ({ + del: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), +})); + +import { get, post, put } from '@/lib/api'; + +const mockGet = get as jest.Mock; +const mockPost = post as jest.Mock; +const mockPut = put as jest.Mock; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('programPolicySnapshotQueryKeys', () => { + it('builds a stable effective key', () => { + expect(programPolicySnapshotQueryKeys.effective(2026)).toEqual([ + 'program-policy', + 'effective', + 2026, + ]); + }); +}); + +describe('useProgramPolicySnapshots', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses snake_case query params for admin filters', async () => { + mockGet.mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 100 }); + + const { result } = renderHook( + () => + useProgramPolicySnapshots({ + academicYear: 2026, + isDefault: false, + isActive: true, + page: 1, + pageSize: 100, + }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGet).toHaveBeenCalledWith( + '/admin/program-policy-snapshots?academic_year=2026&is_default=false&is_active=true&page=1&page_size=100', + ); + }); + + it('loads the effective program policy snapshot for an academic year', async () => { + mockGet.mockResolvedValueOnce({ id: 'policy-1', requestedAcademicYear: 2026 }); + + const { result } = renderHook(() => useEffectiveProgramPolicySnapshot(2026), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGet).toHaveBeenCalledWith('/program-policies/effective?academic_year=2026'); + }); +}); + +describe('program policy snapshot mutations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a program policy snapshot via the admin endpoint', async () => { + mockPost.mockResolvedValueOnce({ id: 'policy-1', name: 'AY 2026 Policy' }); + + const { result } = renderHook(() => useCreateProgramPolicySnapshot(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + name: 'AY 2026 Policy', + academicYear: 2026, + isDefault: false, + isActive: true, + pgy1InpatientClinicDayOfWeek: 2, + pgy1InpatientClinicTimeOfDay: 'AM', + pgy2InpatientClinicDayOfWeek: 1, + pgy2InpatientClinicTimeOfDay: 'PM', + pgy3InpatientClinicDayOfWeek: 0, + pgy3InpatientClinicTimeOfDay: 'PM', + smAcademicDayOfWeek: 2, + smAcademicTimeOfDay: 'AM', + facultyDidacticDayOfWeek: 2, + facultyDidacticTimeOfDay: 'PM', + skipFinalWeekForFacultyDidactic: true, + notes: null, + }); + }); + + expect(mockPost).toHaveBeenCalledWith('/admin/program-policy-snapshots', expect.any(Object)); + }); + + it('updates a program policy snapshot via the admin endpoint', async () => { + mockPut.mockResolvedValueOnce({ id: 'policy-1', name: 'Updated Policy' }); + + const { result } = renderHook(() => useUpdateProgramPolicySnapshot(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + snapshotId: 'policy-1', + payload: { name: 'Updated Policy' }, + }); + }); + + expect(mockPut).toHaveBeenCalledWith('/admin/program-policy-snapshots/policy-1', { + name: 'Updated Policy', + }); + }); + + it('rolls program policy snapshots forward through the admin endpoint', async () => { + mockPost.mockResolvedValueOnce({ + sourceAcademicYear: 2026, + targetAcademicYear: 2027, + sourceSnapshotCount: 1, + createdCount: 1, + skippedCount: 0, + createdNames: ['AY 2026 Policy'], + skippedNames: [], + }); + + const { result } = renderHook(() => useRolloverProgramPolicySnapshots(), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + sourceAcademicYear: 2026, + targetAcademicYear: 2027, + }); + }); + + expect(mockPost).toHaveBeenCalledWith( + '/admin/program-policy-snapshots/rollover?source_academic_year=2026&target_academic_year=2027', + ); + }); +}); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 9a7ce2147..9ea984671 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -306,17 +306,60 @@ export { useDeleteInstitutionalEvent, useInstitutionalEvent, useInstitutionalEvents, + useRolloverInstitutionalEvents, useUpdateInstitutionalEvent, type InstitutionalEvent, type InstitutionalEventCreate, type InstitutionalEventFilters, type InstitutionalEventListResponse, + type InstitutionalEventRolloverResponse, type InstitutionalEventPolicyLayer, type InstitutionalEventScope, type InstitutionalEventType, type InstitutionalEventUpdate, } from "./useInstitutionalEvents"; +// ============================================================================ +// Policy Layer Hooks +// ============================================================================ +export { + programPolicySnapshotQueryKeys, + useCreateProgramPolicySnapshot, + useDeleteProgramPolicySnapshot, + useEffectiveProgramPolicySnapshot, + useProgramPolicySnapshots, + useRolloverProgramPolicySnapshots, + useUpdateProgramPolicySnapshot, + type Meridiem, + type ProgramPolicySnapshot, + type ProgramPolicySnapshotCreate, + type ProgramPolicySnapshotEffective, + type ProgramPolicySnapshotFilters, + type ProgramPolicySnapshotListResponse, + type ProgramPolicySnapshotRolloverResponse, + type ProgramPolicySnapshotUpdate, +} from "./useProgramPolicySnapshots"; +export { + programCalendarAnchorQueryKeys, + useCreateProgramCalendarAnchor, + useDeleteProgramCalendarAnchor, + useEffectiveProgramCalendarAnchors, + useProgramCalendarAnchors, + useRolloverProgramCalendarAnchors, + useUpdateProgramCalendarAnchor, + type ProgramCalendarAnchor, + type ProgramCalendarAnchorCreate, + type ProgramCalendarAnchorEffective, + type ProgramCalendarAnchorEffectiveListResponse, + type ProgramCalendarAnchorFilters, + type ProgramCalendarAnchorKind, + type ProgramCalendarAnchorListResponse, + type ProgramCalendarAnchorRolloverResponse, + type ProgramCalendarAnchorRule, + type ProgramCalendarAnchorUpdate, + type ProgramCalendarFederalHoliday, +} from "./useProgramCalendarAnchors"; + // ============================================================================ // Procedure Credentialing Hooks // ============================================================================ diff --git a/frontend/src/hooks/useProgramCalendarAnchors.ts b/frontend/src/hooks/useProgramCalendarAnchors.ts index 6a1e71d08..ad945c90a 100644 --- a/frontend/src/hooks/useProgramCalendarAnchors.ts +++ b/frontend/src/hooks/useProgramCalendarAnchors.ts @@ -12,6 +12,8 @@ export type ProgramCalendarAnchorEffective = components['schemas']['ProgramCalendarAnchorEffectiveResponse']; export type ProgramCalendarAnchorEffectiveListResponse = components['schemas']['ProgramCalendarAnchorEffectiveListResponse']; +export type ProgramCalendarAnchorRolloverResponse = + components['schemas']['ProgramCalendarAnchorRolloverResponse']; export type ProgramCalendarAnchorKind = components['schemas']['ProgramCalendarAnchorKind']; export type ProgramCalendarAnchorRule = components['schemas']['ProgramCalendarAnchorRule']; export type ProgramCalendarFederalHoliday = @@ -179,3 +181,28 @@ export function useDeleteProgramCalendarAnchor() { }, }); } + +export function useRolloverProgramCalendarAnchors() { + const queryClient = useQueryClient(); + + return useMutation< + ProgramCalendarAnchorRolloverResponse, + ApiError, + { sourceAcademicYear: number; targetAcademicYear: number } + >({ + mutationFn: ({ sourceAcademicYear, targetAcademicYear }) => + post( + `/admin/program-calendar-anchors/rollover?source_academic_year=${sourceAcademicYear}&target_academic_year=${targetAcademicYear}`, + ), + onSuccess: (_, { sourceAcademicYear, targetAcademicYear }) => { + queryClient.invalidateQueries({ queryKey: programCalendarAnchorQueryKeys.all() }); + queryClient.invalidateQueries({ + queryKey: programCalendarAnchorQueryKeys.effective(sourceAcademicYear), + }); + queryClient.invalidateQueries({ + queryKey: programCalendarAnchorQueryKeys.effective(targetAcademicYear), + }); + queryClient.invalidateQueries({ queryKey: programCalendarAnchorQueryKeys.effectiveAll() }); + }, + }); +} diff --git a/frontend/src/hooks/useProgramPolicySnapshots.ts b/frontend/src/hooks/useProgramPolicySnapshots.ts index 8a2bd3851..63e525e54 100644 --- a/frontend/src/hooks/useProgramPolicySnapshots.ts +++ b/frontend/src/hooks/useProgramPolicySnapshots.ts @@ -18,6 +18,8 @@ export type ProgramPolicySnapshotListResponse = components["schemas"]["ProgramPolicySnapshotListResponse"]; export type ProgramPolicySnapshotEffective = components["schemas"]["ProgramPolicySnapshotEffectiveResponse"]; +export type ProgramPolicySnapshotRolloverResponse = + components["schemas"]["ProgramPolicySnapshotRolloverResponse"]; export type Meridiem = "AM" | "PM"; export interface ProgramPolicySnapshotFilters { @@ -180,3 +182,32 @@ export function useDeleteProgramPolicySnapshot() { }, }); } + +export function useRolloverProgramPolicySnapshots() { + const queryClient = useQueryClient(); + + return useMutation< + ProgramPolicySnapshotRolloverResponse, + ApiError, + { sourceAcademicYear: number; targetAcademicYear: number } + >({ + mutationFn: ({ sourceAcademicYear, targetAcademicYear }) => + post( + `/admin/program-policy-snapshots/rollover?source_academic_year=${sourceAcademicYear}&target_academic_year=${targetAcademicYear}`, + ), + onSuccess: (_, { sourceAcademicYear, targetAcademicYear }) => { + queryClient.invalidateQueries({ + queryKey: programPolicySnapshotQueryKeys.all(), + }); + queryClient.invalidateQueries({ + queryKey: programPolicySnapshotQueryKeys.effective(sourceAcademicYear), + }); + queryClient.invalidateQueries({ + queryKey: programPolicySnapshotQueryKeys.effective(targetAcademicYear), + }); + queryClient.invalidateQueries({ + queryKey: programPolicySnapshotQueryKeys.effectiveAll(), + }); + }, + }); +} diff --git a/frontend/src/types/.api-generated.hash b/frontend/src/types/.api-generated.hash index e9f97a7d6..88620c388 100644 --- a/frontend/src/types/.api-generated.hash +++ b/frontend/src/types/.api-generated.hash @@ -1 +1 @@ -a8a0327e15c39a8eba0421b6f3f0b06b +83a211452a66e4f2aef51df8b333bb3c diff --git a/frontend/src/types/api-generated.ts b/frontend/src/types/api-generated.ts index 55c5fd1d1..18b890796 100644 --- a/frontend/src/types/api-generated.ts +++ b/frontend/src/types/api-generated.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY * * Generated from: backend/scripts/export_openapi.py - * Generated at: 2026-03-22T01:15:58Z + * Generated at: 2026-03-22T08:52:51Z * Generator: openapi-typescript + smart camelCase post-processing * * To regenerate: @@ -6663,6 +6663,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/program-calendar-anchors/rollover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rollover Program Calendar Anchors */ + post: operations["rollover_program_calendar_anchors_api_v1_admin_program_calendar_anchors_rollover_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/program-calendar-anchors/{anchor_id}": { parameters: { query?: never; @@ -6717,6 +6734,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/program-policy-snapshots/rollover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rollover Program Policy Snapshots */ + post: operations["rollover_program_policy_snapshots_api_v1_admin_program_policy_snapshots_rollover_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/program-policy-snapshots/{snapshot_id}": { parameters: { query?: never; @@ -23752,7 +23786,7 @@ export interface components { }; /** * AnnualRotationPlanningReadinessPolicyResponse - * @description Institutional-event policy-anchor readiness for annual planning. + * @description DB-backed policy-layer readiness for annual planning. */ AnnualRotationPlanningReadinessPolicyResponse: { /** @@ -23760,8 +23794,28 @@ export interface components { * @enum {string} */ status: "ready" | "warning" | "blocked"; + /** Has Effective Program Policy */ + hasEffectiveProgramPolicy: boolean; + /** Effective Program Policy Source */ + effectiveProgramPolicySource?: ("default" | "academic_year") | null; + /** Active Anchor Count */ + activeAnchorCount: number; + /** Active Calendar Anchor Count */ + activeCalendarAnchorCount: number; /** Active Event Count */ activeEventCount: number; + /** Program Anchor Count */ + programAnchorCount: number; + /** Regulatory Anchor Count */ + regulatoryAnchorCount: number; + /** Institution Anchor Count */ + institutionAnchorCount: number; + /** Program Calendar Anchor Count */ + programCalendarAnchorCount: number; + /** Regulatory Calendar Anchor Count */ + regulatoryCalendarAnchorCount: number; + /** Institution Calendar Anchor Count */ + institutionCalendarAnchorCount: number; /** Program Event Count */ programEventCount: number; /** Regulatory Event Count */ @@ -41849,6 +41903,23 @@ export interface components { */ id: string; }; + /** ProgramCalendarAnchorRolloverResponse */ + ProgramCalendarAnchorRolloverResponse: { + /** Source Academic Year */ + sourceAcademicYear: number; + /** Target Academic Year */ + targetAcademicYear: number; + /** Source Anchor Count */ + sourceAnchorCount: number; + /** Created Count */ + createdCount: number; + /** Skipped Count */ + skippedCount: number; + /** Created Names */ + createdNames?: string[]; + /** Skipped Names */ + skippedNames?: string[]; + }; /** * ProgramCalendarAnchorRule * @description How an anchor date is resolved for an academic year. @@ -42156,6 +42227,23 @@ export interface components { */ id: string; }; + /** ProgramPolicySnapshotRolloverResponse */ + ProgramPolicySnapshotRolloverResponse: { + /** Source Academic Year */ + sourceAcademicYear: number; + /** Target Academic Year */ + targetAcademicYear: number; + /** Source Snapshot Count */ + sourceSnapshotCount: number; + /** Created Count */ + createdCount: number; + /** Skipped Count */ + skippedCount: number; + /** Created Names */ + createdNames?: string[]; + /** Skipped Names */ + skippedNames?: string[]; + }; /** ProgramPolicySnapshotUpdate */ ProgramPolicySnapshotUpdate: { /** Name */ @@ -62413,6 +62501,38 @@ export interface operations { }; }; }; + rollover_program_calendar_anchors_api_v1_admin_program_calendar_anchors_rollover_post: { + parameters: { + query: { + source_academic_year: number; + target_academic_year: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProgramCalendarAnchorRolloverResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_program_calendar_anchor_api_v1_admin_program_calendar_anchors__anchor_id__get: { parameters: { query?: never; @@ -62612,6 +62732,38 @@ export interface operations { }; }; }; + rollover_program_policy_snapshots_api_v1_admin_program_policy_snapshots_rollover_post: { + parameters: { + query: { + source_academic_year: number; + target_academic_year: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProgramPolicySnapshotRolloverResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_program_policy_snapshot_api_v1_admin_program_policy_snapshots__snapshot_id__get: { parameters: { query?: never;