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 thesequenceGridsread 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?→IntNOT 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 withScheduledLesson.room), timestamps. RelationTimetable.breaks. - New model
ScheduledBreakAudience:id,tenantId,breakId(FK Cascade), exactly-one-ofdepartmentId? | 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 = ΣdurationMinutesof their duty breaks. - New model
DayBounds:id,tenantId, exactly-one-ofdepartmentId? | 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; CHECKend > 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;thenALTER COLUMN ... SET NOT NULL(safe: backfill precedes constraint in one migration). - Raw SQL in the migration: CHECK one-of on
scheduled_break_audiencesandday_bounds; CHECKend_minute > start_minuteand 5-min-multiple checks;UNIQUE NULLS NOT DISTINCTonday_bounds (tenant_id, department_id, curriculum_id, grade_id, weekday)and onscheduled_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 torls-coverage.ts(RLS policies asapp_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 ofsequenceGridsand the newbreaks[];targetnullable 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 (@ApiPropertyOptionalper ref + description of the exactly-one contract). - Diagnostics
categoryenum 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.
lunchShiftIdon LUNCH breaks — v1 uses plainroomId; 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_EXCEEDEDparams 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_SPLITkeep 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): deleteDAY_OVERFLOW+NO_EFFECTIVE_TEMPLATEcases;STUDENT_GAPasserted as WARNING and bridged by a covering break;SAME_DAY_CONTIGUITYbridged by a break covering the group's roster (and NOT bridged by a partial-audience break);STUDENT_CONFLICTon break×lesson shared student;ROOM_CONFLICTon break×lesson same room; break×break same room allowed withROOM_CAPACITYunion counted once;TEACHER_CONFLICTon duty×lesson and duty×duty;TEACHER_AVAILABILITYon duty offdaysOn;TEACHER_BUDGET_EXCEEDEDfires above budget / skips null budget, params carry{teachingMinutes, dutyMinutes, budgetMinutes};OUTSIDE_DAY_BOUNDSper level (grade > curriculum > department), per-weekday row beating all-days row, skipped when unresolved, applied to lessons and breaks;HOUR_BUDGETunchanged 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), newdepartment/curriculum/omitted-view filters, per-viewbreaks[]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, readview=teacher(lessons + duty breaks, nosequenceGrids), publish blocked onHOUR_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.htmlC1–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.