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/CombinedClasslessons 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 (
Homeroomflag + 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 timetableId → Timetable.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 refsdepartmentId / curriculumId / gradeId / trackId / homeroomId / studentId(all FK Cascade). Raw-SQL CHECKscheduled_activity_audiences_kind_ref_chk: the ref matchingkindis 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; reuseDayOfWeekfor the array.
Audience resolution (live, union across rows, never materialized): EVERYONE → all students of the timetable's AY; DEPARTMENT → Student.departmentId; GRADE → Student.gradeId; CURRICULUM → StudentCurriculumSelection.curriculumId; TRACK → StudentCurriculumSelection.trackId (same confirmedness rule as the CURRICULUM kind in resolveBreakAudiences); HOMEROOM → HomeroomAssignment; 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 <= 1440mirroringscheduled_breaks); RLS policy per new table +rls-coverage.ts+tenanted-models.tsentries (drift specs fail otherwise); check for an uncommitted migration to fold beforemigrate 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_recordspartial-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 bySTUDENT_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¶
TakeAttendanceDtoanchor 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 (derivedERROR_EXAMPLESper the error-i18n iteration). ScheduledActivityViewDtoand the widenedTimetableLessonsViewDtoneed@ApiPropertycoverage;weekdaysdocuments theDayOfWeekenum 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×breakSTUDENT_CONFLICT; activityTEACHER_CONFLICT/TEACHER_AVAILABILITY;activityMinutesin theTEACHER_BUDGET_EXCEEDEDsplit; lesson×activityROOM_CONFLICTvs activity×activity/break room-sharing + union-clusterROOM_CAPACITY;OUTSIDE_DAY_BOUNDSper selector scope (incl. STUDENT → student's scope, EVERYONE → all departments);STUDENT_GAPfill+bridge; full-rosterSAME_DAY_CONTIGUITYbridge;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,duplicatecopying children,activitiesForViewper 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.querieslevel 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.tsentries 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.