Plan Scheduling Redesign — Detailed Changes (2026-09-29)¶
Summary¶
A full audit of how workouts are created, scheduled and rescheduled, followed by a redesign so that what a clinician prescribes is what continues. Plans now carry a stored schedule (which days, and what happens when the plan runs out or a session is missed); one scheduling engine replaces the four paths that each used to guess "consecutive days"; the AI never chooses dates any more; and every plan editor (web classic modals, web HEP Builder, mobile Manage HEP) shows the schedule and lets the clinician change it.
Repos: OktaPT-API (engine, endpoints, migration), OktaPT-FE (schedule control in three editors, pre-fill on edit), Okta-Mobile (schedule sheet in Manage HEP), docs. Branch plan-cadence in each.
User-facing description: ../plan-scheduling.md.
Audit findings¶
Before this change there was no "plan" in the data model — only Workout rows with an original and a current date — so every system path had to guess the clinician's intent, and they all guessed consecutive days.
| # | Finding | Effect |
|---|---|---|
| 1 | Auto-generation always scheduled back-to-back days: the AI prompt said "all four workouts should be on back to back days", and the repeat fallback added one day per clone | A Mon/Wed/Fri plan turned into daily sessions, weekends included, once the patient got down to two remaining |
| 2 | The AI returned the dates, which were written unvalidated | Nothing enforced the prescribed schedule; a bad exercise id failed the loop half-way, leaving a partial plan |
| 3 | AI content ignored the plan's shape ("3 exercises max"; notes, weight, targets, blocks, session names and 2×/day structure dropped) | A six-exercise prescription became three different exercises |
| 4 | Repeat mode cloned the last four completed workouts, not the current plan | A clinician who had just progressed the upcoming sessions saw the patient regress |
| 5 | Missed sessions were packed onto consecutive days from today, pushing everything behind them | Rest days disappeared after one missed session |
| 6 | The personalized-video injection topped up to five sessions on consecutive days | Recording one video added sessions on days the clinician never scheduled |
| 7 | The buffer was counted in workouts, not days | A 2×/day plan had about two days of runway |
| 8 | Generation ran from an in-process setTimeout(5000) with no lock; an OpenAI error left the plan to run dry with no retry |
Lost on restart; double generation on rapid completions |
| 9 | Edits reached the ten-day window only; generated sessions beyond it kept old content | Stale sessions surfaced after an edit |
| 10 | Reminders looked up "today's workout" by the original date, as an instant, while clinician rows are stored at 00:00Z | US timezones matched the wrong day; moved sessions were never matched |
| 11 | No provenance on sessions | Neither the system nor the clinician could tell what was prescribed from what was generated |
Design¶
Stored schedule. PatientPlanSchedule (one row per patient): WEEKLY with weekdays, or INTERVAL every N days with a content rotation length (cycleDays); autoContinue; origin EXPLICIT (clinician chose) or INFERRED (read off the sessions); planStartDate, the floor below which continuation never copies. Workout.source and Workout.continuedFromWorkoutId record provenance.
One engine (OktaPT-API/src/services/planSchedule/): pure calendar rules on integer epoch days (cadence.ts) with a thin database layer. Every path — completion top-up, patient-view safety net, editor pre-fill, personalized-video injection, missed-session roll-forward, standalone schedule change — goes through it, under one per-patient advisory lock, with a re-check after any AI call.
Rules
- New session days come from the schedule. WEEKLY days repeat the most recent session on the same weekday; INTERVAL days rotate through the last
cycleDayssession days. Copies carry planned fields only. - Missed sessions keep their order and move to the next session days; a later session moves only when an earlier one takes its day. "Every other day" counts its gap from the last session actually done.
- Fewer than three upcoming session days → four more (same buffer as before, in days). Triggered on completion (2 s coalesce per patient, in-process) and, as a safety net, when the patient opens a plan that is running low.
- AI mode adapts the copies conservatively: dose changes within bounds, at most one swap per session from the library, validated per slot; anything unusable falls back to the plain copy. Dates are never the model's to choose.
- Every editor works on one week (today .. today + 6): the classic modals moved from ten days, mobile from ten sessions. Editors pre-fill the week before editing (
prepare-editing; the mobile GET does it itself and returns one slot per day). A clinician save records the schedule (explicit from new clients; inferred from the saved days otherwise, unless they still fit the inferred schedule on file), movesplanStartDateto the window, and drops system-scheduled sessions after the window. - The personalized-video injection appends the exercise to every upcoming session and tops the plan up by the same rule as a completion (fewer than three session days → four more on the schedule).
- Notifications look up "today's workout" by the session's current date as a calendar date.
Reading the schedule off a plan. An unbroken run → every day (exactly Mon–Fri → weekdays); week-aligned gaps → those weekdays; evenly spaced days that weekly repetition would crowd (days 1,3,5,7) → every N days. Patients from before this change get theirs read off the clinician's most recent sessions by original date, ignoring the old generator's 13:00Z rows.
Decisions taken (all isolated in the engine, easy to flip):
- Missed sessions roll forward to the next scheduled day rather than being skipped or packed onto consecutive days.
- AI continuation is conservative (structure kept, ≤1 swap per session) instead of free-form.
- A per-patient "stop when these sessions are done" switch exists; default on.
- System copies beyond a saved window are dropped and regenerated.
- No tenant kill-switch flag: patients without a stored schedule behave as before (every day), so the change is invisible to daily plans.
Changes by repo¶
OktaPT-API¶
| File | Change |
|---|---|
prisma/schema.prisma, prisma/migrations/20260930003229_plan_schedule/ |
PatientPlanSchedule, Workout.source, Workout.continuedFromWorkoutId, enums WorkoutSource, PlanCadenceKind, PlanScheduleOrigin (additive) |
src/services/planSchedule/cadence.ts |
Pure rules: firstSessionDayOnOrAfter, sessionDayAfter, planRollForward, planRealign, planNewSessionDays, pickSourceDays, inferCadence, daysFitCadence, smallestPeriod, parseCadence |
src/services/planSchedule/schedule.ts |
Load/save the stored schedule; legacy inference; DTO |
src/services/planSchedule/rollForward.ts |
Missed sessions inside a transaction |
src/services/planSchedule/continuation.ts |
extendPlan (TOP_UP / FILL_WINDOW), resolveContinuationMode, readPlanState, planSessionsForDays |
src/services/planSchedule/aiAdaptation.ts + lib/openai_response_formats/pt_plan_continuation_schema.ts |
Constrained AI adaptation, validated per slot; logs under the existing autoGenerateWorkouts control name |
src/services/planSchedule/capture.ts |
recordClinicianPlanSave, recordInjectionPlanChange, setPatientPlanSchedule (explicit or auto, realign) |
src/services/planSchedule/lock.ts |
The per-patient plan lock (same key the injection and HEP save already used) |
src/services/workoutReschedulingService.ts |
Rewritten on the engine; same export, never throws |
src/jobs/workoutCreationJobs.ts |
The old generator replaced by the trigger + clinician email; queueTopUpForPatientView |
src/services/personalizedExerciseInjectionService.ts |
Appends to every upcoming session; tops up via the engine's rule; moves the plan start; VIDEO_CONTINUATION provenance |
src/routes/doctor/planSchedule.controllers.ts, src/routes/doctor/index.ts |
GET/PUT plan-schedule, POST plan-schedule/prepare-editing |
src/routes/doctor/controller.ts |
create-initial-plan, edit-plan, HEP PUT accept schedule/autoContinue, mark CLINICIAN, record the save; HEP GET pre-fills the week and returns one slot per day plus planSchedule and source |
src/helpers/workoutProtection.ts |
HEP_MANAGEMENT_WINDOW_DAYS = 7, HEP_MANAGEMENT_MAX_SESSIONS = 21 |
src/routes/workouts/patientUpcoming.ts |
Patient-view safety net |
src/routes/legacy.ts, src/routes/ai/controllers.ts, src/routes/workouts/templates.ts, src/jobs/processAppEntrance.ts |
Provenance on the remaining creation paths |
src/services/smartNotifications/{dispatcher,antiAnnoyanceFilter,contentPreGeneration,timezoneUtils}.ts, src/jobs/notificationJobs.ts |
"Today's workout" by current date as a calendar date (getScheduledDayBounds) |
| Removed | lib/openai_response_formats/pt_generate_new_workouts_schema.ts |
Tests: src/services/planSchedule/__tests__/cadence.test.ts (38 pure-rule cases), service.test.ts (28 scenarios on an in-memory fake), updated injection and HEP-management tests. Whole suite: 89 suites, 1,070 tests. An end-to-end script against a local copy of the dev database drove the real endpoints (27 checks): explicit schedule on create, preview, completion top-up on Mon/Wed/Fri with same-weekday content, missed-Monday roll-forward, window pre-fill, old-client edit keeping the explicit schedule and dropping system copies beyond the window, schedule change with realign, stop switch, mobile GET/PUT, injection on the schedule, and legacy inference.
OktaPT-FE¶
| File | Change |
|---|---|
components/plan-creation/PlanScheduleControl.tsx |
The control (card and compact/popover variants); seeds from the stored schedule, live "Matches plan" preview, presets, custom weekdays, stop switch, next-dates preview |
lib/utils/planSchedule.ts (+ .test.ts) |
Preview rules mirroring the server; projectSessionDates |
lib/types/planSchedule.ts, lib/api/planScheduleApi.ts |
Types and client |
lib/hooks/useInitialPlanState.ts, useEditPlanState.ts |
plannedDates; saves carry the schedule; submitPlanChanges(patientId, schedule, windowDays); loadExistingPlan(workouts, maxDay) |
components/plan-creation/InitialPlanCreationModal.tsx, EditPlanModal.tsx, PlanDaySelector.tsx, DayWorkoutPreview.tsx |
Control under the day preview; schedule changes count as unsaved changes; the day picker is Day 1–7 (PLAN_WINDOW_DAYS = 7) |
components/plan-creation/hep-builder/HepBuilder.tsx |
Compact control in the footer; loads and saves Day 1–7 (the week is the template; the schedule carries it forward) |
pages/doctor/dashboard.tsx, lib/stores/doctorDashboard.ts |
Edit opens with prepare-editing then a forced workout refetch |
public/locales/{en,ru}/common.json |
planSchedule.* |
Okta-Mobile¶
| File | Change |
|---|---|
components/doctor/hep/HepScheduleSheet.tsx |
Bottom sheet: presets, custom weekdays, "matches plan", stop switch, next-dates preview; Apply |
app/(doctor)/(hep)/manage.tsx |
The header's second line shows the schedule and opens the sheet; Apply flushes local edits, calls the PUT, reloads |
hooks/useDoctorHepEditor.ts, services/doctorHepService.ts, types/doctorHep.ts, lib/planSchedule.ts |
planSchedule from the GET, fetchPlanSchedule/updatePlanSchedule, types, preview rules |
lib/doctorHepUtils.ts |
The editor window is one week (HEP_WINDOW_DAYS = 7); header reads "Editing the next 7 days" |
locales/{en,ru}.json |
hepManagement.schedule.* |
__tests__/hepScheduleSheet.test.tsx, __tests__/planSchedule.test.ts |
Component and rule tests |
The native UI was not run on a device (no simulator on the build machine); verified with tsc, ESLint on the changed files (the repo's expo lint reports two pre-existing errors in an unrelated file) and Jest (58 suites, 394 tests).
Post-audit fixes (2026-09-30)¶
A code and product audit before the development merge led to these changes:
- API: the AI adaptation's exercise library is tenant-scoped again (the query runs on the tenant-less client); roll-forward no longer moves a clinician's session on a non-schedule day that nobody displaced (the every-other-day recovery gap still applies); a dormant patient (no stored schedule, nothing completed in 30 days) is not revived by a patient view or an editor pre-fill; plan saves accept
schedule: { kind: "AUTO" }so "Matches plan" also works over a rule chosen earlier; changing onlyautoContinuekeeps the schedule's origin and start; an explicit interval cadence gets its content rotation from the upcoming sessions; the roll-forward, realign and rescheduler transactions allow 20 s (lock wait included); a completion that arrives during a run re-runs afterwards; the cron lock gives up after 5 s and runs the tick anyway. - Web: "Matches plan" sends
AUTO; a control that never loaded sends nothing; the classic modals ignore a late seed after the AI review; custom weekday chips use short labels (the narrow Russian ones collapse); "Edit the next 7 days". - Mobile: the week window, gap filling and session-card dates use the UTC calendar day the server means (US devices lost day 7 and mislabelled 00:00Z rows); the schedule sheet records the rule without moving the week (
realign: false), as on the web; short weekday chips.
Compatibility¶
- Older mobile builds keep working: the saves send no schedule and the server infers it (or keeps a clinician's explicit one); the HEP GET's new fields are additive.
- Patients with no stored schedule and no clinician-scheduled sessions to read one off behave exactly as before (every day, rotating through the last four session days).
- Migration is additive;
sourceisnullon existing rows and the legacy inference treats 13:00Z rows as system-generated.
Open items¶
- Reminders still fire on rest days for patients who have not opened the app since missing a session (their dates settle on the next read). A nightly settle job would make reminders and the app agree without a read.
- Interval schedules other than "every other day" are supported by the engine and the API but have no preset in the UIs.
Schedule UI removed (2026-09-30, later the same day)¶
After testing on dev, the product owner dropped the schedule UI: therapists already build a plan expecting it to repeat on the days they lay out, so a control ("Repeats …", presets, "Stop when these sessions are done") only added complexity. Removed on web (PlanScheduleControl, lib/utils/planSchedule, lib/types/planSchedule; the editors send no schedule/autoContinue) and on mobile (HepScheduleSheet, lib/planSchedule, the header line and the schedule service calls). The engine, migration and endpoints stay; the apps only call prepare-editing. At the same time the new HEP builder became the default plan editor (the dashboard checkbox now opts back into the classic editor), and the sample patient's "Ease the plan with AI" became "Edit plan", opening the builder with a pulsing pointer to its AI assistant.