Skip to content

Timetable Activities — named non-curriculum slots with per-slot rosters

1. Problem distillation

  • Not everything on a school's weekly grid is a curriculum subject: "Friday fun activities", assemblies, form-time meets. Today the timetable can only place SubjectGroup/CombinedClass lessons and audience-scoped breaks — a named, roster-scoped, teacher-supervised activity is inexpressible.
  • Admins need to place an activity slot in a timetable specifying: a roster (the usual selector kinds + track + explicit students + everyone), the attending teachers, an optional room, start/end time, and the weekdays it repeats on.
  • Activities are real occupancy: they must conflict-check against lessons/breaks/duties for students, teachers, and rooms, and count toward teacher budgets — via the existing diagnostics engine, never blocking DRAFT authoring.
  • Every activity behaves like a subject group for attendance (product decision — no opt-out flag): an activity slot on the PUBLISHED timetable is a take anchor; PERIOD-mode students get a per-slot cell, DAILY-mode students get their daily cell anchored there.
  • One activity kind only. The earlier per-homeroom "morning meet" shape was deliberately extracted out of this iteration into a future domain concept (Homeroom flag + first-slot rule) — see §8/§9.

Success criteria (observable behavior that proves this works): - Admin creates "Friday Activities" on a DRAFT with audience grade: G9 + G10, teachers [T1], weekdays: [FRIDAY], 14:00–16:00 → the slot appears in whole-school and all matching cohort/teacher/room views with its resolved roster semantics; diagnostics recompute in the mutation envelope. - Placing a lesson for a G9 student over that interval fires STUDENT_CONFLICT; giving T1 a lesson there fires TEACHER_CONFLICT; publish is blocked until resolved. - After publish, a teacher in the activity's teacher set (or an admin) takes attendance at the slot: PERIOD-mode students receive cells with startTick = slot tick, DAILY-mode students receive/update their daily cell; cells carry activityId/activityName snapshots. - An activity whose audience resolves to zero students fires the ACTIVITY_SCOPE_EMPTY WARNING (non-blocking). - duplicate copies activities with audience + teacher rows; deleting the timetable sweeps them.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Morning meet (Homeroom.hasMorningMeet, tutor preset, "must be first slot each day" rule) — future US, its own spec. - Activities as generation/wizard input (solver-aware pre-placed intervals) — deferred; generated drafts contain no activities (admin adds them on the draft, or duplicates a draft that has them). - Staff-only slots (teacher meetings without students), student self-enrollment/choice activities, week-A/B rotation, date-anchored one-offs. - Grades on activities — grades remain scorable only on curriculum subjects (product decision). - Any FE bulk-create sugar beyond the weekdays[] set on the row.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
ScheduledBreak + ScheduledBreakAudience + ScheduledBreakDuty (prisma/schema.prisma, src/timetables/timetables.service.ts, iter-3 spec 2026-07-09-timetable-manual-management-iteration-3-design.md) Timetable-scoped slot with stored durationMinutes, audience selector rows (union, live-resolved, never materialized), supervising teachers as real occupancy, mutateAndDiagnose envelopes, duplicate copying children, day-axis floor check Breaks are free time (they bridge gaps, share rooms, are invisible to attendance); activities are obligations (they fill student occupancy, anchor attendance) and carry a name + weekday set on one row
Attendance take path (src/attendance/attendance.service.ts, ch19; combined-classes iter-3 dual anchor) TakeAttendanceDto anchor XOR (@ExactlyOneAnchor) extended to a third anchor; snapshot-column pattern (soft ids + names); Y-set clauses; published-timetable + school-day gates Lessons resolve DAILY/PERIOD from one department; an activity roster can span departments → per-student mode resolution (new)
Timetable views viewWhere/breaksForView (src/timetables/timetables.service.ts, ch18 §6) Per-view filtering of a non-lesson collection: whole-school = all, teacher = supervision match, room = room match, student-anchored views = roster intersection activities[] needs its own view DTO carrying name/teachers/roster-independent fields
Break audience resolution (resolveBreakAudiences in src/timetables/timetables.queries.ts) Selector→students resolution: department → Student.departmentId, grade → Student.gradeId, curriculum → StudentCurriculumSelection, homeroom → HomeroomAssignment Two new kinds: TRACK (→ StudentCurriculumSelection.trackId, same confirmedness rule as the curriculum kind) and EVERYONE / STUDENT; break rows infer their kind from the single non-null ref — an all-null EVERYONE row can't, so activities use an explicit kind enum column
Diagnostics engine categories (src/timetables/timetables.diagnostics.ts, ch18 §3) + in-flight localized-cards spec Activities enter existing ERROR categories as a third occupancy family (as breaks did in iter-3); new categories get card catalog entries (title + messages) One new WARNING (ACTIVITY_SCOPE_EMPTY); TEACHER_BUDGET_EXCEEDED split gains an activityMinutes bucket

On-axis check (ch16): this lands on the paved "new placed-slot family through the diagnostics engine" path that breaks opened in iteration-3 — the survey rows above are precedents, not inventions. The only genuinely new seam is the attendance third anchor + per-student mode resolution, which extends the combined-classes dual-anchor seam rather than inventing one.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes tenantId (required) on all 3 new tables + RLS policies + rls-coverage.ts + tenanted-models.ts entries ch12 new-model checklist
Academic-year scope indirect via timetableIdTimetable.academicYearId (same as lessons/breaks); no own academicYearId column timetable-scoped entity
RBAC entity key existing EntityKey.TIMETABLES no delta to entity-keys.ts activities are timetable content, like breaks
Scopes configuration (existing) no new scope keys flat-DTO entity, already in FLAT_DTO_ENTITIES
Actions none new activity routes use @RequireScopes(TIMETABLES, 'write') exactly like break routes breaks precedent (timetables.controller.ts:211-242)
Service base custom (TimetablesService + AttendanceService) slot CRUD joins the timetables service (break pattern); attendance deltas in the attendance service no BaseTenantedCrudService anywhere in this module family
queries.ts shape named functions timetables.queries.ts: activity CRUD loaders, resolveActivityAudiences, snapshot threading; attendance.queries.ts: loadActivityForTake, operative-weekday extension mandatory queries.ts convention
Error codes new SCHEDULED_ACTIVITY_INVALID_SLOT (422), SCHEDULED_ACTIVITY_AUDIENCE_EMPTY (422), SCHEDULED_ACTIVITY_NOT_FOUND (404); i18n catalog entries (en_US + it_IT) §5 table
DTO conventions flat DTOs under src/timetables/dto/ create/update/view/mutation-envelope DTOs; attendance DTO deltas under src/attendance/dto/ matches break DTOs
File-backed sub-resources n/a no files on activities
Custom fields no not a custom-fields entity
Profile completeness n/a no person fields

4. Data model plan

Schema deltas

  • ScheduledActivity (scheduled_activities): id, tenantId (FK Cascade), timetableId (FK Cascade), name VarChar(150), weekdays DayOfWeek[] (≥1, deduped — validated at DTO, not DB), startTick Int, durationMinutes Int (5′ multiple > 0), roomId? (FK Restrict, nullable — roomless is legitimate: courtyard/off-site), createdAt/updatedAt. Relations: audience[], teachers[].
  • ScheduledActivityAudience (scheduled_activity_audiences): id, tenantId, activityId (FK Cascade), kind ScheduledActivityAudienceKind { EVERYONE, DEPARTMENT, CURRICULUM, GRADE, TRACK, HOMEROOM, STUDENT }, nullable refs departmentId / curriculumId / gradeId / trackId / homeroomId / studentId (all FK Cascade). Raw-SQL CHECK scheduled_activity_audiences_kind_ref_chk: the ref matching kind is non-null and all others null; EVERYONE ⇒ all refs null.
  • ScheduledActivityTeacher (scheduled_activity_teachers): id, tenantId, activityId (FK Cascade), teacherId (FK Cascade), @@unique([activityId, teacherId]). Zero rows allowed (external instructor).
  • AttendanceRecord: + activityId String? @db.Uuid (soft, NO FK) + activityName String? @db.VarChar(200) — snapshot pair, same discipline as every other soft ref.
  • New enum ScheduledActivityAudienceKind; reuse DayOfWeek for the array.

Audience resolution (live, union across rows, never materialized): EVERYONE → all students of the timetable's AY; DEPARTMENTStudent.departmentId; GRADEStudent.gradeId; CURRICULUMStudentCurriculumSelection.curriculumId; TRACKStudentCurriculumSelection.trackId (same confirmedness rule as the CURRICULUM kind in resolveBreakAudiences); HOMEROOMHomeroomAssignment; STUDENT → the student.

Migration shape

  • Additive / destructive / renaming: purely additive (3 tables, 1 enum, 2 nullable columns on attendance_records).
  • Data backfill: none.
  • Hazards from chapter 12 checklist: raw-SQL CHECK constraints (kind↔ref, duration positivity/5′-multiple, day-axis start_tick*5 + duration_minutes <= 1440 mirroring scheduled_breaks); RLS policy per new table + rls-coverage.ts + tenanted-models.ts entries (drift specs fail otherwise); check for an uncommitted migration to fold before migrate dev.

Indexes and uniqueness

  • scheduled_activities: @@index([tenantId, timetableId]), @@index([tenantId, roomId]).
  • scheduled_activity_audiences: @@index([activityId]), @@index([tenantId]).
  • scheduled_activity_teachers: @@unique([activityId, teacherId]), @@index([teacherId]), @@index([tenantId]).
  • No coordinate unique on activities (breaks precedent): two activities may overlap; wrongness is diagnostics' job, not the DB's.
  • attendance_records partial-unique indexes: unchanged — activity cells obey the same (student, date) / (student, date, start_tick) keys. A same-tick lesson+activity roster overlap is prevented at publish by STUDENT_CONFLICT; if post-publish roster drift (live-resolved audiences) creates one anyway, the second take degenerates to the existing upsert-by-key behavior (value fields updated, first anchor's snapshot kept) — same as today's roster-drift edge on lessons.

5. API surface

Verb Path Decorators Request DTO Response DTO
POST /timetables/:id/activities @RequireScopes(TIMETABLES, 'write') CreateScheduledActivityDto { name, weekdays[], startTick, durationMinutes, roomId?, teacherIds?, audience[] } ScheduledActivityMutationResultDto { activity, diagnostics }
PATCH /timetables/:id/activities/:activityId @RequireScopes(TIMETABLES, 'write') UpdateScheduledActivityDto — scalars sparse; audience / teacherIds full-replace when present (break-PATCH convention) ScheduledActivityMutationResultDto
DELETE /timetables/:id/activities/:activityId @RequireScopes(TIMETABLES, 'write') ScheduledActivityMutationResultDto { diagnostics } (no activity)

All three ride mutateAndDiagnose: DRAFT edits never blocked; PUBLISHED edits roll back on any new ERROR (422 TIMETABLE_EDIT_WOULD_VIOLATE). duplicate copies activities + audience + teacher rows. Existing reads extended (no new routes): GET …/lessons?view=&targetId= gains activities: ScheduledActivityViewDto[] ({ id, name, weekdays, startTick, wallStart, wallEnd, durationMinutes, room?, teachers[] }) filtered per view by a new activitiesForView (whole-school → all; teacher → teacher-set match; room → room match; student-anchored views → live-resolved roster ∩ view cohort); GET …/diagnostics picks up the new checks automatically.

Attendance deltas (existing routes, extended DTOs):

Route Delta
POST /attendance/take TakeAttendanceDto anchor becomes subjectGroupId ⊕ combinedClassId ⊕ activityId (@ExactlyOneAnchor widened). Activity form: startTick must be omitted (the slot has one tick; 400 VALIDATION_FAILED if sent). Gates in ch19 §4 order: future-date (422) → published timetable (409) → activity row on it with weekday(date) ∈ weekdays (404 SCHEDULED_ACTIVITY_NOT_FOUND) → per-entry: roster membership against the live-resolved audience (422 ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER), per-student school-day gate (student's department calendar + operative weekdays, 422 ATTENDANCE_NOT_A_SCHOOL_DAY), Y-set (403). Mode resolves per student from the student's department: PERIOD → cell at startTick = activity.startTick; DAILY → daily cell (startTick NULL), upsert-unified with lesson-anchored daily takes.
snapshot activity cells: activityId/activityName set; subjectGroup*/subject*/combinedClass* NULL; roomId/Name = activity room; lessonTeacherIds/Names = activity teacher set (may be empty); periodLabel = activity name; periodStart/EndTime from tick + stored duration; per-student department/grade/homeroom columns as today.
Y-set unchanged mechanics — the "lesson teacher" clause reads the activity teacher set (stored snapshot on edits); tutor + teaches-today clauses and admin pass-through untouched.
GET /attendance/grid, /cohorts, history, PATCH /records/:id no route changes; AttendanceCellDto gains activityId/activityName; AttendancePolicy untouched (activity cells carry the student's departmentId snapshot, department narrowing just works). FE derives takeable activity columns from the timetable views.
loadOperativeWeekdaysForGrade a weekday is operative for a grade if it has ≥1 placed lesson or ≥1 activity whose live-resolved roster contains a student of that grade ("Saturday has only activities" is a school day for attending grades only).

Swagger considerations

  • TakeAttendanceDto anchor docs: extend the -anchor description + error examples for the activity form (JSDoc is FE-facing copy — contract only).
  • New error-code examples for the three SCHEDULED_ACTIVITY_* codes (derived ERROR_EXAMPLES per the error-i18n iteration).
  • ScheduledActivityViewDto and the widened TimetableLessonsViewDto need @ApiProperty coverage; weekdays documents the DayOfWeek enum array.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none (timetables is flat-DTO)
Role grants (roles.ts) none — admin already holds timetables.configuration write via ALL_WRITE; attendance grants unchanged
*_SCOPES runtime constant none

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Placed-slot rows carry one coordinate (ScheduledLesson, ScheduledBreak) weekdays DayOfWeek[] array on one row; diagnostics/attendance expand per weekday Product UX: "repeats on (mon, tue, …)" is a property of the slot; N rows would re-split the roster/teacher children Per-weekday variation (different time on Friday) needs a second activity row; coordinate-keyed code must expand the array
Break audience rows infer kind from the single non-null ref Explicit kind enum column + CHECK EVERYONE has no ref to infer from Slightly wider table; CHECK keeps kind↔ref honest
Attendance mode resolves from the lesson's department (ch19 §1) Per-student mode resolution on activity takes Activity rosters legitimately span departments (EVERYONE) One take batch can write mixed daily/period cell shapes — the partial uniques already model this
periodLabel = "Period N" rank (ch19 §4.6) periodLabel = activity name on activity cells The rank is a lesson-grid concept; generalizing it across slot families buys nothing Label semantics differ by anchor family; FE renders what it gets
Breaks bridge STUDENT_GAP / share rooms freely Activities are obligations: they fill student occupancy (and also bridge); activity×lesson in one room conflicts, activity×activity and activity×break share (union-capacity cluster) An activity is attended time, not released time Two non-lesson families with different gap/room semantics live side by side in the engine

8. Pushback log

US says Conflicts with Proposed instead Status
Morning meet as an activity use-case ("each homeroom attends its own") Generic activities would need per-homeroom fan-out, tutor presets, per-instance overrides, and a "first slot of the day" rule — heavy speculative machinery Morning meet extracted as a future first-class domain concept (Homeroom flag + first-slot rule + tutor preset); this iteration ships the single custom-roster activity type only Resolved (product decision, chat 2026-07-13)
Roster kinds "dep/grade/cv/track/homeroom, the usual" Earlier chat decisions added EVERYONE and explicit-student kinds Keep all 7 kinds — EVERYONE avoids stale whole-school selectors; STUDENT keeps hand-picked rosters expressible Resolved (confirmed in chat)
"start/end" times on the slot Slot geometry everywhere else is startTick + durationMinutes Store tick + duration; FE converts wall times Resolved

9. Deferrals

  • Morning meet (Homeroom.hasMorningMeet, tutor preset, first-slot placement rule, daily-attendance anchoring) — product wants it as a domain concept, not a generic activity — follow-up: future US + its own spec.
  • Activities as generation input (wizard step, solver pre-placed fixed intervals) — generation flow is dormant; regenerating produces a draft without activities — follow-up: generation-wizard iteration (project_timetable_generation_wizard_mock).
  • Staff-only slots (teacher meeting without students) — bends audience/attendance invariants ("a duty without a break") — follow-up: revisit when product asks.
  • Choice/enrollment-based activities (student self-signup into parallel offerings) — enrollment is a different primitive; STUDENT selector is the manual escape hatch — follow-up: revisit on product ask.
  • Week-A/B rotation and date-anchored one-off events — no week-parity or date axis exists anywhere in the timetable; wrong iteration to invent one — follow-up: none planned.
  • Teacher/student self-serve read surfaces for activities beyond the existing view/attendance plumbing — visibility rides whatever the timetable views already grant — follow-up: with the broader timetable read-RBAC iteration.

10. Open questions

None — all resolved in chat 2026-07-13 (scoping: timetable-scoped; selectors: all 7 kinds; teachers: optional set with full occupancy semantics; attendance: always-on, per-student mode).


11. Verification plan

  • Unit specs:
  • timetables.diagnostics.spec.ts — per touched category: activity×lesson/activity×activity/activity×break STUDENT_CONFLICT; activity TEACHER_CONFLICT/TEACHER_AVAILABILITY; activityMinutes in the TEACHER_BUDGET_EXCEEDED split; lesson×activity ROOM_CONFLICT vs activity×activity/break room-sharing + union-cluster ROOM_CAPACITY; OUTSIDE_DAY_BOUNDS per selector scope (incl. STUDENT → student's scope, EVERYONE → all departments); STUDENT_GAP fill+bridge; full-roster SAME_DAY_CONTIGUITY bridge; ACTIVITY_SCOPE_EMPTY; weekday-array expansion.
  • timetables.service.spec.ts — activity CRUD floor checks (day axis, audience empty, kind↔ref), mutation envelopes on DRAFT vs PUBLISHED, duplicate copying children, activitiesForView per view.
  • attendance.service.spec.ts — activity-anchored take: gate order, per-student mode resolution (mixed batch), snapshot columns (activityId/Name, empty teacher set, periodLabel = name), Y-set via activity teacher set, startTick-must-be-omitted validation, operative-weekday extension.
  • Audience resolution unit (timetables.queries level or extracted pure helper) — all 7 kinds + union + dedupe.
  • E2E specs:
  • timetables.e2e-spec.ts — activity lifecycle on a draft: create → diagnostics envelope → publish blocked by activity conflict → resolve → publish; PUBLISHED edit rollback (TIMETABLE_EDIT_WOULD_VIOLATE).
  • attendance.e2e-spec.ts — take at an activity slot in a PERIOD department and a DAILY department (daily-cell upsert unification with a lesson-anchored take), roster gate, non-published rejection.
  • RLS/tenanted drift specs cover the three new tables automatically once rls-coverage.ts + tenanted-models.ts entries exist; db-constraint e2e for the kind↔ref CHECK.
  • Manual verification: dev-server pass — create an EVERYONE activity, view it in student/teacher views, take attendance at it (user-triggered; no auto build/test per repo rule).

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-13
  • Chat reference: approved in chat 2026-07-13 ("go on") after section-by-section walkthrough + product re-cut (single custom-roster kind, morning meet extracted)

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