Skip to content

Timetable manual flow — template decouple, day bounds, break/lunch slots + duties

1. Problem distillation

  • Product splits timetabling into two flows: manual creation (now) and automatic generation (deferred). The manual flow must not depend on day/week templates — the admin freely places lessons on a 5-minute-tick grid; consistency is returned on every mutation; the publish gate blocks on any ERROR.
  • Today the manual path is template-coupled in five places: the structural floor (assertResolvableCoordinate), the duration terminal fallback (envelope default), two ERROR checks (NO_EFFECTIVE_TEMPLATE, DAY_OVERFLOW), STUDENT_GAP's break-band adjacency, and the sequenceGrids read surface. All five must be severed.
  • Breaks and lunches stop being envelope bands and become first-class placed slots with an explicit audience (who participates) and duties (teachers supervising). Duties consume teacher hour budget and must be visible as a distinct bucket.
  • The FE keeps one lightweight structural concept: day bounds{start, end} only, cascaded Department → Curriculum → Grade with optional per-weekday overrides, all levels optional. It bounds validity (a new ERROR diagnostic), not authoring.
  • Generation stays in the tree untouched (endpoint, prechecks, solver, templates modules) as the future second flow; nothing in the new shapes may preclude it.

Success criteria (observable behavior that proves this works): - With zero templates and zero template-assignments configured, an admin can create a DRAFT, place/move/delete lessons and breaks at any 5-minute tick, see whole-timetable diagnostics in every mutation response, and publish once no ERROR remains. - A lesson's duration comes exclusively from the curriculum cascade (never a template), and is never null. - A lunch slot placed across a student's midday makes STUDENT_GAP not fire for that student; a lesson overlapping that lunch for a shared student fires STUDENT_CONFLICT. - A teacher assigned duty on a break overlapping their own lesson fires TEACHER_CONFLICT; their view=teacher grid returns both lessons and duty slots. - A lesson dragged outside its grade's effective day bounds fires OUTSIDE_DAY_BOUNDS (ERROR) but the write is not rejected. - GET /timetables/:id/placement-plan reports per-SG expected/placed/remaining minutes matching HOUR_BUDGET math.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Any change to the generation pipeline, solver, prechecks, timetable-templates module, or week-template assignments (dormant, future flow). - Lunch-shift linkage on break slots (lunchShiftId) — a lunch in the canteen is a break slot with the canteen roomId. - Per-lesson teacher selection (teachers derive from the SG). - Duty flavors beyond break/lunch supervision (invigilation, hall duty…) — the duty accounting is general; the anchors are not, yet. - A combined-class placement fan-out convenience endpoint (FE issues one POST per member). - A "by subject name" read view (FE derives it client-side from the whole-school read).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
2026-07-07-subject-period-durations-design.md 4-level periodDuration cascade + absolute-tick coordinates; resolveEffectivePeriodDuration in src/common/utils/period-math.ts Its terminal fallback is the envelope default — this iteration deletes that leg by making Curriculum.periodDuration NOT NULL
2026-06-11-timetable-manual-management-iteration-2-design.md Pure diagnostics engine + snapshot builder + mutation envelope + PUBLISHED-edit tx rollback + publish gate (src/timetables/timetables.diagnostics.ts, timetables.service.ts) Engine assumes envelopes per grade; five checks/floors are template-coupled and get removed or re-based
src/evaluation-scales/ (four-level cascade onto the curriculum grid) Cascade-with-inheritance shape for day bounds (define at a level, children inherit, effective resolution with source) Day bounds cascade over Department→Curriculum→Grade (siblings, not a strict tree) and add a per-weekday dimension
School-relationship flags live-derive (memory project_school_relationship_flags_live_derive) + eligibility classifier spec 2026-06-16-eligibility-classifier-pickers-design.md Live resolution of break audiences to student sets at diagnostics time — no materialized rosters to drift Audience selectors are new rows (one-of ref), not computed flags
Timetable partial-unique migration (timetables_tenant_ay_published_unique, ch12) Raw-SQL constraint precedent for CHECK one-of + NULLS NOT DISTINCT uniques on the new tables fits cleanly
src/timetables/timetables.policy.ts + ch18 §7 RBAC Admin-only pass-through policy + single descriptor-only configuration scope; breaks/day-bounds ride TIMETABLES with zero new scopes/actions fits cleanly
prisma/seed/rls-coverage.ts (memory project_rls_full_coverage) Every new tenanted table must be enrolled in RLS coverage fits cleanly

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes tenantId on ScheduledBreak, ScheduledBreakAudience, ScheduledBreakDuty, DayBounds; all writes through tenant-scoped Prisma; RLS policies added via rls-coverage.ts standard
Academic-year scope yes (indirect) Breaks ride Timetable.academicYearId; DayBounds inherits AY through its ref (Department/Curriculum/Grade are all AY-scoped) same as ScheduledLesson
RBAC entity key existing EntityKey.TIMETABLES no delta breaks/day-bounds/placement-plan are timetable surface
Scopes existing configuration (descriptor-only) no new scopes; timetables stays in FLAT_DTO_ENTITIES flat DTOs
Actions none new create/delete/publish/generate unchanged; break + day-bounds writes use @RequireScopes(TIMETABLES, 'write') like lessons update implicit
Service base custom (existing TimetablesService + new DayBoundsService) same module; day-bounds gets its own controller (route-collision with /timetables/:id avoided) wired to TimetablesPolicy matches module foldering convention
queries.ts shape named functions in timetables.queries.ts (+ day-bounds.queries.ts) insertBreak/updateBreak/deleteBreak, breaksForTimetable, resolveBreakAudienceStudents, upsertDayBounds/deleteDayBounds/listDayBounds, placementPlanRows; buildDiagnosticsSnapshot extended with breaks/duties/bounds, envelope loading removed mandatory queries convention
Error codes new SCHEDULED_BREAK_INVALID_SLOT (tick/duration off the day axis), SCHEDULED_BREAK_AUDIENCE_EMPTY (audience must have ≥1 selector), DAY_BOUNDS_INVALID_RANGE (end ≤ start); en+it messages in the catalog (self-contained per error-i18n rules). SCHEDULED_LESSON_INVALID_SLOT retained, re-meaning "tick/duration off the day axis" (messages updated) BE error i18n
DTO conventions flat DTOs under src/timetables/dto/ CreateScheduledBreakDto, UpdateScheduledBreakDto (full-replace audience[], dutyTeacherIds[]), ScheduledBreakResponseDto, ScheduledBreakMutationResultDto, DayBoundsDto/EffectiveDayBoundsDto, PlacementPlanRowDto; TimetableLessonsViewDto reshaped (drop sequenceGrids, add breaks[]) swagger from DTOs
File-backed sub-resources n/a no files involved
Custom fields no none of the new entities are custom-field bearers
Profile completeness no no people-scope fields touched (totalHoursPerWeek already exists)

4. Data model plan

Schema deltas

  • Curriculum.periodDuration Int?Int NOT NULL (minutes). Backfill existing NULLs with 60. Comment updated: terminal level of the cascade — the envelope-default leg is gone.
  • New enum BreakKind { BREAK, LUNCH }.
  • New model ScheduledBreak: id, tenantId, timetableId (FK → Timetable, Cascade), kind BreakKind, weekday DayOfWeek, startTick Int (0..287, absolute 5-min frame — same axis as lessons), durationMinutes Int (stored; 5-min multiple, > 0 — breaks have no curriculum source to derive from), roomId String? (FK → Room, Restrict, consistent with ScheduledLesson.room), timestamps. Relation Timetable.breaks.
  • New model ScheduledBreakAudience: id, tenantId, breakId (FK Cascade), exactly-one-of departmentId? | curriculumId? | gradeId? | homeroomId? (all FK Cascade — deleting the ref entity removes the selector row). Union semantics across rows; resolved live to students at read/diagnostics time (dept → Student.departmentId, grade → Student.gradeId, curriculum → StudentCurriculumSelection, homeroom → HomeroomAssignment). ≥1 row enforced in service (SCHEDULED_BREAK_AUDIENCE_EMPTY). "Whole school" = one row per department (no empty-means-everyone magic).
  • New model ScheduledBreakDuty: id, tenantId, breakId (FK Cascade), teacherId (FK → Teacher, Cascade). A teacher's duty minutes = Σ durationMinutes of their duty breaks.
  • New model DayBounds: id, tenantId, exactly-one-of departmentId? | curriculumId? | gradeId? (FK Cascade), weekday DayOfWeek? (NULL = all weekdays; a weekday row overrides the all-days row at the same level), startMinute Int, endMinute Int (minutes from midnight, 5-min multiples; CHECK end > start). Resolution for a lesson's scope (departmentId, curriculumId, gradeId) derived from its SG (grade.departmentId, curriculumSubject.curriculumId, gradeId): per-weekday row then all-days row, at grade, then curriculum, then department (grade > curriculum by fiat — they are siblings under department; mock's convention). No row anywhere ⇒ unbounded, check skipped.

Migration shape

  • Additive + one column-constraint tightening: UPDATE curricula SET period_duration = 60 WHERE period_duration IS NULL; then ALTER COLUMN ... SET NOT NULL (safe: backfill precedes constraint in one migration).
  • Raw SQL in the migration: CHECK one-of on scheduled_break_audiences and day_bounds; CHECK end_minute > start_minute and 5-min-multiple checks; UNIQUE NULLS NOT DISTINCT on day_bounds (tenant_id, department_id, curriculum_id, grade_id, weekday) and on scheduled_break_audiences (break_id, department_id, curriculum_id, grade_id, homeroom_id); @@unique([breakId, teacherId]) on duties (plain Prisma).
  • Hazards (ch12): NOT NULL addition (mitigated by in-migration backfill); raw-SQL constraints (audited like timetables_tenant_ay_published_unique); new tenanted tables must be added to rls-coverage.ts (RLS policies as app_user), or the boot drift guard fails.
  • Seed ripple: any Tier-2 fixture creating curricula must pass periodDuration (now required at create).

Indexes and uniqueness

  • ScheduledBreak: @@index([tenantId, timetableId]), @@index([tenantId, roomId, weekday, startTick]). No coordinate-uniqueness — two breaks at one coordinate with different audiences are legitimate (canteen shifts).
  • ScheduledBreakAudience: @@index([breakId]) + the NULLS-NOT-DISTINCT unique above.
  • ScheduledBreakDuty: @@unique([breakId, teacherId]), @@index([teacherId]).
  • DayBounds: the NULLS-NOT-DISTINCT unique above + @@index([tenantId]).

5. API surface

Verb Path Decorators Request DTO Response DTO
POST /timetables/:id/breaks @RequireScopes(TIMETABLES, 'write') CreateScheduledBreakDto {kind, weekday, startTick, durationMinutes, roomId?, audience[], dutyTeacherIds?[]} ScheduledBreakMutationResultDto {break, diagnostics, canPublish}
PATCH /timetables/:id/breaks/:breakId @RequireScopes(TIMETABLES, 'write') UpdateScheduledBreakDto (sparse fields; audience[]/dutyTeacherIds[] full-replace when present) ScheduledBreakMutationResultDto
DELETE /timetables/:id/breaks/:breakId @RequireScopes(TIMETABLES, 'write') ScheduledBreakMutationResultDto (no break)
GET /timetables/:id/lessons @RequireScopes(TIMETABLES, 'read') view/targetId now optional (omitted = whole school); enum += department, curriculum TimetableLessonsViewDto {target?, lessons, breaks}sequenceGrids removed (FE-breaking)
GET /timetables/:id/placement-plan @RequireScopes(TIMETABLES, 'read') same optional view/targetId query PlacementPlanDto {rows: PlacementPlanRowDto[]} — per SG in scope: {subjectGroup, subjectName, department, curriculum, grade, teachers[], studentCount, weeklyHours, expectedMinutes, slotDurationMinutes, placedMinutes, remainingMinutes, baseRoom?, combinedClassId?} (minutes-first; FE derives slot counts; FE groups combined members by combinedClassId)
GET /timetables/:id/diagnostics @RequireScopes(TIMETABLES, 'read') gains canPublish: boolean (also added to both mutation envelopes)
GET /day-bounds @RequireScopes(TIMETABLES, 'read') DayBoundsDto[] (every override row — the FE's editing state)
GET /day-bounds/effective @RequireScopes(TIMETABLES, 'read') ?departmentId=&curriculumId=&gradeId= (any subset) EffectiveDayBoundsDto[] — 7 rows {weekday, startMinute, endMinute, wallStart, wallEnd, source: DEPARTMENT\|CURRICULUM\|GRADE, sourceId} \| null
PUT /day-bounds @RequireScopes(TIMETABLES, 'write') UpsertDayBoundsDto {level, refId, weekday?, startMinute, endMinute} (upsert per unique key) DayBoundsDto
DELETE /day-bounds @RequireScopes(TIMETABLES, 'write') {level, refId, weekday?} (query params) 204 — falls back to parent level

Existing routes unchanged: timetable CRUD/duplicate/status, lessons CRUD, POST /timetables/generate (dormant). Lesson mutations keep the envelope {lesson?, diagnostics, canPublish}; break mutations run through the same mutateAndDiagnose machinery — DRAFT applies then recomputes; PUBLISHED applies + recomputes in one tx and rolls back with 422 TIMETABLE_EDIT_WOULD_VIOLATE on any new ERROR.

Small DTO additions elsewhere: combinedClassId? on SubjectGroupListItemDto and GroupedCourseSubjectGroupDto (mock C5). view=teacher response includes the teacher's duty breaks in breaks[] — per-view break filter: audience-students ∩ view cohort (student/homeroom/grade/curriculum/department), duty teacher (teacher view), room (room view), all (school view).

Swagger considerations

  • TimetableLessonsViewDto: document the removal of sequenceGrids and the new breaks[]; target nullable for the whole-school read.
  • Error examples for the three new codes on break/day-bounds routes; ScheduledBreakResponseDto.audience[] documented as one-of ref rows (@ApiPropertyOptional per ref + description of the exactly-one contract).
  • Diagnostics category enum in the swagger description must be updated to the new catalogue (below) — FE renders i18n off these strings.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none — timetables.configuration covers everything
PermissionAction (rbac-catalogue.ts) none — no new actions
ScopeFieldMapping (rbac-catalogue.ts) none — timetables is flat (FLAT_DTO_ENTITIES)
Role grants (roles.ts) none — ADMIN auto-grants via ALL_WRITE/ALL; no other role sees timetables in v1
*_SCOPES runtime constant none — no scope changes, drift spec unaffected

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
SPD's "duration is never stored" ScheduledBreak.durationMinutes is stored Breaks have no curriculum cell to derive from; extent is an authoring decision A curriculum edit can't reflow breaks (nothing to reflow from) — correct by construction
Diagnostics engine floor (assertResolvableCoordinate against envelope) Floor shrinks to FK integrity + coordinate uniqueness (lessons) + tick/duration-inside-day-axis Manual flow must not depend on templates A lesson can be placed outside day bounds; OUTSIDE_DAY_BOUNDS (ERROR) gates publish instead — consistent with the soft-diagnostics philosophy
STUDENT_GAP as ERROR Demoted to WARNING Without envelope break bands, compactness is advisory; breaks bridge gaps but the admin owns the shape of the day A gappy published timetable is possible — accepted by product
Cascade levels strictly nested (evaluation-scales precedent) Day-bounds levels Grade > Curriculum are siblings ordered by fiat Grade and Curriculum both hang off Department; the mock fixed grade as most specific A grade-level override wins even for students of a curriculum with its own override — documented convention
One-scope-per-row audience (mock stores a single scopeLevel) Audience = multiple one-of selector rows, union Superset of the mock; "whole school" without a sentinel; matches live-derive philosophy FE derives its scope label from the rows
Teacher occupancy = lessons only Duties enter TEACHER_CONFLICT/TEACHER_AVAILABILITY; new TEACHER_BUDGET_EXCEEDED (WARNING) sums teaching + duty minutes vs (totalHoursPerWeek + extraHours) × 60 (skip if totalHoursPerWeek null) Duties are real occupancy and consume the hour budget; budget data is HR-quality so it warns, not blocks TEACHER_DAILY_CAP stays lesson-count-based (duty load visible via budget params instead)

8. Pushback log

US says (FE mock, API & BE tab) Conflicts with Proposed instead Status
§B: POST /timetables/generate "eliminable", week-templates/assignments obsolete Product directive: generation deferred, not dropped Everything generation-side stays dormant and untouched Resolved (user 2026-07-09)
C1b: LUNCH carries lunchShiftId from the CANTEEN room Uniform slot model; capacity-union already covers shift math Optional roomId for both kinds; lunch-in-canteen = break slot with canteen room; shift linkage deferred Resolved (user 2026-07-09)
C4/C6 category names (ROOM_DOUBLE_BOOKED, CLASS_DOUBLE_BOOKED, HOURS_NOT_FULLY_PLACED) Existing catalogue + homeroom/course decoupling (CLASS_* no longer exists) Keep BE names: ROOM_CONFLICT, TEACHER_CONFLICT, STUDENT_CONFLICT, HOUR_BUDGET Resolved — FE adapts
Publish report: over-placement = WARNING (OVERPLACED), under = ERROR User's own product statement ("too many hours … error") + current HOUR_BUDGET HOUR_BUDGET stays ERROR on any ≠ (params carry deltaMinutes, FE can phrase over/under) Resolved (user 2026-07-09: "everything checks out")
"By subject" perspective (subject-name view) Subject identity across curricula is name-string matching Defer; FE filters the whole-school read client-side Resolved — deferred
Mock stores per-lesson teacherId Teachers derive from SG (SubjectGroupTeacher) No per-lesson teacher in v1 Resolved — deferred

9. Deferrals

  • Generation flow rework (templates as inputs inside the creation flow; breaks/duties/day-bounds as solver constraints) — deferred with the whole generation flow — follow-up: next generation iteration spec.
  • lunchShiftId on LUNCH breaks — v1 uses plain roomId; revisit if shift semantics (auto-times, per-shift capacity) earn their keep — follow-up: revisit on FE feedback.
  • Duty anchors beyond breaks (invigilation, hall duty, substitution) — the duty bucket in TEACHER_BUDGET_EXCEEDED params is already general — follow-up: future "duties" iteration.
  • Break×break student overlap — two breaks overlapping for one student is not a violation in v1 (non-teaching time; no pedagogical meaning) — follow-up: revisit if product asks.
  • Combined-class one-gesture placement (BE fan-out on POST) — FE issues one POST per member; COMBINED_SHARED_MISCOUNT/ROOM_SPLIT keep them honest — follow-up: convenience endpoint if FE friction demands.
  • Timetable audit-log records — status quo (no auditing on timetables module) — follow-up: none.
  • Notifying impacted users on PUBLISHED edits — pre-existing deferral, unchanged.

10. Open questions

None — all forks resolved in chat 2026-07-09 (duration source + required curriculum level, backfill 60, kind enum, optional room, no student-level audience, break×lesson = ERROR, duties + budget warning, no teacher-load endpoint, day-bounds adoption + timetables-module home, HOUR_BUDGET stays symmetric ERROR).


11. Verification plan

  • Unit specs:
  • timetables.diagnostics.spec.ts (rework): delete DAY_OVERFLOW + NO_EFFECTIVE_TEMPLATE cases; STUDENT_GAP asserted as WARNING and bridged by a covering break; SAME_DAY_CONTIGUITY bridged by a break covering the group's roster (and NOT bridged by a partial-audience break); STUDENT_CONFLICT on break×lesson shared student; ROOM_CONFLICT on break×lesson same room; break×break same room allowed with ROOM_CAPACITY union counted once; TEACHER_CONFLICT on duty×lesson and duty×duty; TEACHER_AVAILABILITY on duty off daysOn; TEACHER_BUDGET_EXCEEDED fires above budget / skips null budget, params carry {teachingMinutes, dutyMinutes, budgetMinutes}; OUTSIDE_DAY_BOUNDS per level (grade > curriculum > department), per-weekday row beating all-days row, skipped when unresolved, applied to lessons and breaks; HOUR_BUDGET unchanged both directions.
  • day-bounds.service.spec.ts (new): upsert/delete per unique key, effective resolution matrix, DAY_BOUNDS_INVALID_RANGE.
  • timetables.service.spec.ts: break CRUD envelope ({break?, diagnostics, canPublish}), PUBLISHED-edit rollback on a break edit introducing an ERROR, structural floor for breaks (SCHEDULED_BREAK_INVALID_SLOT, SCHEDULED_BREAK_AUDIENCE_EMPTY), placement-plan minutes math (incl. combined members as separate rows), new department/curriculum/omitted-view filters, per-view breaks[] filtering (teacher-duty case included).
  • period-math.spec.ts: cascade resolution now terminal at curriculum (non-null guaranteed) — update the all-null expectation.
  • E2E specs: extend timetables.e2e-spec.ts — build a DRAFT with no templates configured at all: place lessons + a lunch with audience + duty, read view=teacher (lessons + duty breaks, no sequenceGrids), publish blocked on HOUR_BUDGET/STUDENT_CONFLICT, then resolved and published. RBAC drift e2e (rbac-grants.db-sync) untouched — no grant changes.
  • Manual verification: npm run docker:reset + seed; drive the FE mock's flow against the API with curl/Scalar (create draft → place → PATCH diagnostics inline → day-bounds PUT → publish report). Run only when the user asks.

Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-09
  • Chat reference: signed off in chat 2026-07-09 after FE-mock reconciliation (tools/orario-manuale.html C1–C6) and resolution of all forks; timetable-library question resolved as no-BE-change

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.