Skip to content

date: 2026-08-12 slug: teacher-attendance-surface-iteration-3 status: Approved # Draft | Approved | Superseded clickup_us: none — two user-provided US texts (IT), 2026-08-12: "Come docente devo vedere nella dashboard … anche i duties e le attività" + "Come docente devo vedere anche le attività assegnate a me nel registro" epic: attendance related_specs: - docs/superpowers/specs/2026-08-03-teacher-attendance-surface-iteration-2-design.md - docs/superpowers/specs/2026-08-02-teacher-attendance-surface-design.md - docs/superpowers/specs/2026-07-13-timetable-activities-design.md - docs/superpowers/specs/2026-07-25-attendance-admin-day-register-design.md supersedes: none


Teacher attendance surface — iteration 3: the whole day (activity cards + duty slots) and the ACTIVITY cohort


1. Problem distillation

  • The teacher agenda shows a partial day. GET /attendance/teacher-day renders LESSON slots only (attendance-teacher.service.ts:69) — activity cards were an explicit iteration-1 deferral. A teacher supervising the Friday-afternoon activity or holding lunch duty sees neither, so the dashboard cannot be their day plan.
  • Activity registers are unreachable by their supervisors. Activities are attendance-bearing exactly like lessons (ch19), and the Y-set's clause 1 (slot teacher as-of-date) already admits a supervisor's cell writes — but the cohort fence added in iteration 2 refuses first: ATTENDANCE_COHORT_TYPES has no ACTIVITY shape, so a supervisor who doesn't otherwise teach or tutor those students can neither open /rows nor get a write past assertTeacherCohortRead. Today only a tutor/teaching-unit path or a school-wide role reaches an activity cell.
  • Activity rosters can span departments, which makes attendanceMode ambiguous — the write path resolves mode per student, but a register card (and a /rows group) needs one mode. Product decision (Fabio, 2026-08-12): activities become single-department. A cross-department activity is modeled as two activities.
  • Duties must answer as-of-D the way lessons do. Break/lunch supervision lives on ScheduledBreak/ScheduledBreakDuty — version content. The agenda must resolve duties from the timetable version effective on the queried date (ExpectedSchedule.timetableId, already computed once per request), not from "the currently published timetable". Same replace+report semantics as lessons on republish; no freeze — the day-shape manifest stays evidence-only.

Success criteria (observable behavior that proves this works):

  • A supervising teacher's today[] carries a card for each activity slot of theirs on the date, with working needsAttendance / recorded / inconsistencies, and its cohort opens GET /attendance/rows and takes writes through the ordinary records routes — supervisor admitted, any other teacher 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT.
  • A teacher on duty sees a duties[] entry for each break/lunch slot they supervise on the date, carrying enough detail to render the timetable slot modal without any timetable read; asking a past date answers with the duties of the version that governed it.
  • An activity's roster and register never mix attendance modes: every activity has exactly one department, its audience resolves inside that department, and the card/group mode is that department's.
  • admin / secretary / principal responses on existing routes are unchanged except where they name activity cohorts (new capability, not altered behavior).

Non-goals (in-scope-shaped things this iteration is explicitly not doing):

  • Activity cards on the admin board (GET /attendance/groups) — deferred (Fabio, 2026-08-12). Admins keep reaching activity cells through class rows, and gain /rows?cohortType=ACTIVITY for free.
  • An otherGroups[] analog for activities ("activities I supervise that don't meet today") — activities always meet on their weekdays; past occurrences are reachable via ?date.
  • Attendance on breaks — duties are informational agenda items, never take anchors.
  • Any change to the family surface, comms loop, or timetable diagnostics semantics beyond the audience narrowing.

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/attendance/attendance-teacher.service.ts (iterations 1–2) The card machinery is complete: DAILY day-opener duty attribution already runs over ALL slot kinds (:113), recorded/inconsistencies fold over authored cells. Activity cards = widening the kind === 'LESSON' filter (:69) + card shaping. modeOf (:103) resolves via SG day meta; ACTIVITY slots need their own meta loader (activity → department → attendanceMode).
src/attendance/attendance-day.queries.ts loadDaySlots (:222-256) ACTIVITY slots already resolve with supervisors as teacherIds and dated rosters; the frozen loader round-trips them. Nothing to invent on the resolver side. Breaks are deliberately absent (no attendance) — the duties loader is a new sibling query on the same timetableId the resolver pinned.
src/attendance/attendance-day.queries.ts loadSubjectCohortGroup (:556) The synthetic-group pattern for cohorts outside the student-derived catalogue: identity from the anchor row, roster = dated membership ∩ placed cohort. loadActivityCohortGroup is its third sibling. An activity's "membership" is audience expansion (expandAudienceOn), not assignment rows; grade/curriculum have no single value (mirror the combined-class curriculum: null treatment).
src/attendance/attendance-access.service.ts assertTeacherCohortRead (:83) Iteration 2's rule: every new cohort shape is a new limb on the ONE gate that fences all four routes (/rows, /inconsistencies, POST, PATCH) at once. Supervisor membership (ScheduledActivityTeacher) is undated version content — the limb asks row existence, not a valid-time episode. Deliberate: same fidelity as the activity row itself.
docs/superpowers/specs/2026-07-13-timetable-activities-design.md The audience model being narrowed (7 kinds, union, live-resolved) and the "activities are lessons for attendance" ruling this iteration completes. Its EVERYONE = whole student body and its DEPARTMENT kind are exactly what single-department removes.
src/attendance/expected/expected-audience.ts expandAudienceOn Pure as-of-D expansion with placement as the candidate universe for every kind — the department fence is one more intersection on the same indexes (placements already carry departmentId). Fits cleanly; the live expander (resolveActivityAudiences, timetables) needs the mirror change, kind semantics stay twinned.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All touched queries already fence on tenantId; the new loaders (loadTeacherDutiesOn, loadActivityCohortGroup, activity roster branch) take tenantId explicitly. ScheduledActivity.departmentId FK stays inside the tenant (validated on write). No new model; existing RLS rows cover every touched table.
Academic-year scope yes Duties and activity cards inherit the resolver's year/date resolution; loadActivityCohortGroup expands audience with the same as-of-D indexes keyed by academicYearId. Same law as every register read: one date resolution per request.
RBAC entity key existing ATTENDANCE + TIMETABLES No delta to entity keys. Agenda/register changes ride attendance.register; the activity department column rides timetables.write.
Scopes existing No new scope keys. New capability is cohort-shape, not permission surface.
Actions none take already covers activity cells.
Service base custom (existing services) AttendanceTeacherService, AttendanceAccessService, AttendanceBoardService, TimetablesService — all pre-existing. No new module.
queries.ts shape named functions New: loadTeacherDutiesOn (attendance-day.queries — breaks of one timetableId+weekday where duties name the teacher, with room/co-duty/audience refs), loadActivityDayMeta (attendance.queries — activity → name + departmentId + attendanceMode), loadActivityCohortGroup (attendance-day.queries), ACTIVITY branch in loadCohortRosterIds (attendance.queries:395). Queries convention: mandatory named functions, no repositories.
Error codes 1 new SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT (422) — an audience ref whose target belongs to another department; params { field, id }. Catalog + examples + i18n (messages{en_US,it_IT}) + swagger notes per ch06. The existing SCHEDULED_ACTIVITY_AUDIENCE_EMPTY stays the empty-audience backstop.
DTO conventions existing files teacher-day-response.dto.ts (card kind, cohort widening, new TeacherDayDutyDto), day-filter-query.dto.ts (ATTENDANCE_COHORT_TYPES + 'ACTIVITY'), scheduled-activity.dto.ts (required departmentId on create, optional on update; selector loses departmentId; response gains department ref). Shared-const rule from iteration 2: the cohort enum widens everywhere at once.
File-backed sub-resources n/a No files.
Custom fields no No person entity touched.
Profile completeness no

4. Data model plan

Schema deltas

  • ScheduledActivity.departmentId — new required uuid FK → Department, onDelete: Cascade (consistent with the audience rows' department refs). Index @@index([tenantId, departmentId]) not needed for current reads — skip.
  • ScheduledActivityAudienceKind enum: drop DEPARTMENT (redundant under a department-scoped activity; EVERYONE now means "everyone in the activity's department").
  • ScheduledActivityAudience: drop departmentId column + relation (the kind that used it is gone).
  • Raw CHECK scheduled_activity_audiences_kind_ref_chk: recreate without the DEPARTMENT limb.
  • No attendance-side schema change: AttendanceDaySlot, records, cohort storage untouched (the cohort type is a DTO const, not a DB enum).

Migration shape

  • Destructive + backfill, one migration, ordered:
  • Add department_id nullable.
  • Backfill per activity: the department of its audience refs (grade→department, curriculum→department, homeroom→department, track→curriculum→department, student→placement department, department ref→itself). Multiple candidate departments: pick the alphabetically-first by department name (tie: id). Only-EVERYONE audiences: the tenant's alphabetically-first department. (Fabio, 2026-08-12: "simply pick one … it's safe, no real prod user with activity yet".)
  • Enforce the invariant on existing rows: DELETE audience rows whose target resolves to a different department; CONVERT DEPARTMENT rows pointing at the chosen department into EVERYONE rows; DELETE the rest of the DEPARTMENT rows.
  • Set department_id NOT NULL + FK.
  • Recreate the kind CHECK; drop scheduled_activity_audiences.department_id; shrink the enum (new type + cast + drop old, the standard Prisma shape — safe because step 3 removed every DEPARTMENT row).
  • Hazards (ch12): enum value removal (recreate-and-cast — audited in the generated SQL); DROP COLUMN on scheduled_activity_audiences (hazard #3, old pods during rolling deploy — accepted: no production activity usage, flagged for deploy coordination); data-modifying migration (roster trimming) — deliberate and signed off, documented in the migration header comment. Check for an uncommitted migration to fold before migrate dev (ch12 rule 1a).
  • An activity left with zero audience rows after step 3 keeps working (roster resolves empty; diagnostics ACTIVITY_SCOPE_EMPTY flags it) — no rows are invented.

Indexes and uniqueness

  • None new. Existing @@index([tenantId, timetableId]) serves the duties and activity lookups; ScheduledBreakDuty.@@index([teacherId]) serves loadTeacherDutiesOn.

5. API surface

No new routes. Changed contracts:

Verb Path Decorators Request DTO Response DTO
GET /attendance/teacher-day unchanged (@RequireScopes(ATTENDANCE,'read') + AttendanceTeacherPolicy) TeacherDayQueryDto (unchanged) TeacherDayResponseDto: today[] cards gain kind: 'LESSON' \| 'ACTIVITY'; ACTIVITY cards carry name = activity name, subjectName: null, cohort: {ACTIVITY, activityId}; new duties: TeacherDayDutyDto[]
GET /attendance/rows, /attendance/inconsistencies unchanged cohortType accepts ACTIVITY (cohortId = activity id) unchanged shapes; the ACTIVITY group's grade/curriculum follow the combined-class "no single value" treatment, mode = the activity department's
POST/PATCH /attendance/records[/:id] unchanged cohort echo accepts ACTIVITY write envelope may describe an ACTIVITY group; PATCH echo-less default for an activity cell becomes the activity itself for non-school-wide callers (school-wide callers keep the class)
POST/PATCH /timetables/:id/activities[/:activityId] unchanged create: + required departmentId; update: optional departmentId; ActivityAudienceSelectorDto loses departmentId ScheduledActivityResponseDto gains department: NamedRef; audience rows can no longer be DEPARTMENT-kind

TeacherDayDutyDto (new, attendance-owned — no import from timetables DTOs): breakId, kind: BreakKind (BREAK/LUNCH), startTime/endTime/durationMinutes, roomId/roomName (nullable), dutyTeacherIds/dutyTeacherNames (full duty set, caller included — mirrors card teacherIds), audience: { kind, id, displayName }[] (department/curriculum/grade/homeroom refs resolved to names). Enough for the FE to render the same modal the timetable break slot opens, without knowing the governing timetableId. Time-ordered; empty when no version governs the date.

Swagger considerations

  • teacher-day JSDoc (public copy): document the kind discriminator, that ACTIVITY cards open /rows with cohortType=ACTIVITY, and that duties are informational (no attendance is taken on a break).
  • New error example for SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT on the two activity mutation routes; the day-filter enum widening shows up automatically from the const.
  • NOT_FOUND example for entity: 'scheduled_activity' on cohort routes (new loadCohortRosterIds branch).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none
*_SCOPES runtime constant none

No reseed needed — the whole iteration rides existing grants.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Break audience model (ScheduledBreakAudience: 4 ref kinds, no EVERYONE, keeps DEPARTMENT) Activities lose the DEPARTMENT kind and redefine EVERYONE as department-wide; breaks stay untouched Breaks bear no attendance — no mode to resolve, no reason to constrain The two audience models drift apart; documented in ch18
Register responses are register-shaped (anchor + cohort + counters) duties[] are informational cards with no anchor, no cohort, no counters, inside an attendance response The agenda is the teacher's whole day; duties belong to the day even though no register exists on them A second card vocabulary in one DTO — mitigated by a separate array rather than a nullable-field union
followUpWhere limbs are relational (some/none on assignment tables) The ACTIVITY cohort limb materializes the roster (studentId IN (expandAudienceOn …)) Audience expansion has no single relational shape (6 kinds + department fence + placement universe); duplicating it in Prisma-where form would fork the semantics expandAudienceOn owns An IN-list bounded by roster size (school-scale, fine); semantics stay single-sourced
Cohort limbs in assertTeacherCohortRead ask dated (valid-time) questions The supervisor limb asks undated row existence on ScheduledActivityTeacher Supervisors are version content with no valid-time table — the same fidelity as the activity row the cohort names; inventing intervals here would exceed what the schedule stores A supervisor added after date D can open D's register (same replace+report family as every schedule edit)

8. Pushback log

US says Conflicts with Proposed instead Status
"mostriamo lo stesso dettaglio che mostreremmo cliccando sullo slot intervallo/pranzo della timetable (modale)" The dashboard FE does not know the governing timetableId, and GET /timetables/:id/lessons is a whole-grid read Inline the modal's fields on duties[] (own DTO, no timetable read) Resolved (chat 2026-08-12)
Activities as-is (roster may span departments) One attendanceMode per card/group Single-department activities; duplicate the activity per department when needed; migration trims existing rosters Resolved — product decision (Fabio, 2026-08-12)
"duties" list on the dashboard First instinct (live-resolve from the published timetable) breaks as-of-D faithfulness Resolve from ExpectedSchedule.timetableId — the version effective on the date, identical semantics to lessons Resolved (chat 2026-08-12)
US2 "vedere le attività nel registro" as a separate feature Same deliverable: the agenda card IS the register entry point (cohort/rows → records routes) Resolved (chat 2026-08-12)

9. Deferrals

  • Admin board activity cards (/groups catalogue) — not asked; admins keep class rows and gain direct /rows?cohortType=ACTIVITY — follow-up: revisit if admins ask for a per-activity board card.
  • otherGroups[] analog for activities — activities always meet on their weekdays; nothing unreachable — follow-up: none planned.
  • Envelope followUps gating for teachers — pre-existing deferral from iteration 1, unchanged.
  • Family/student surface for activities — the family day view's treatment of activity cells is the family surface's own concern — follow-up: family attendance surface iterations.
  • Duty conflict with the caller's own lessons — diagnostics' job at publish time, not the agenda's — follow-up: none.

10. Open questions

None — all resolved in chat 2026-08-12 (single-department activities + roster-trimming migration; duties via governing version; admin board deferred; US2 = same API). The §4 backfill tie-breaks (alphabetical-first department; DEPARTMENT-row conversion to EVERYONE) and the §7 undated-supervisor ruling are called out for sign-off review.


11. Verification plan

  • Unit specs:
  • attendance-teacher.service.spec.ts — ACTIVITY cards render with kind, activity name, null subjectName, ACTIVITY cohort; mode comes from the activity's department (PERIOD → needsAttendance true; DAILY → day-opener only); recorded/inconsistencies fold activity rosters; duties[] maps loadTeacherDutiesOn rows and is empty with no governing version; a lesson-only day is byte-identical to today except the added kind/duties fields.
  • attendance-access.service.spec.ts — supervisor admitted on ACTIVITY cohort, non-supervisor refused, no-teacher-profile refused; existing limbs untouched.
  • attendance.queries.spec.ts / attendance-day.queries.spec.tsloadCohortRosterIds ACTIVITY branch (expansion ∩ placement, NOT_FOUND on unknown id), loadActivityCohortGroup (identity, department mode, empty-audience → empty roster), loadTeacherDutiesOn (weekday + teacher filter, audience refs resolved).
  • attendance.service.spec.ts — PATCH echo-less default on an activity cell: teacher → ACTIVITY envelope, school-wide → class fallback unchanged; SPAN_GROUPS on a mismatched ACTIVITY echo.
  • attendance-insights.service.spec.tsfollowUpWhere ACTIVITY limb narrows by materialized roster.
  • expected-audience.spec.ts — department fence: every kind's matches intersect the activity department; EVERYONE = the department's placed students.
  • timetables.service.spec.ts / timetables.queries.spec.ts — create/update validation (departmentId required, refs out of department → 422 SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT, department change revalidates existing audience), resolveActivityAudiences department fence, response department ref.
  • E2E specs:
  • test/attendance-teacher.e2e-spec.ts — the agenda carries an ACTIVITY card + a duty; a past date answers duties from the version that governed it (revision fixture per rules a–k in feedback_e2e_isolation_patterns).
  • test/attendance-activities.e2e-spec.ts — supervisor opens /rows?cohortType=ACTIVITY and writes; a non-supervisor teacher 403s; PATCH without echo answers the activity envelope.
  • test/timetables-activities.e2e-spec.ts — create requires departmentId; out-of-department audience 422s; EVERYONE resolves department-wide in the roster.
  • test/db-constraints.e2e-spec.ts — recreated kind CHECK still rejects a ref-less GRADE row and now rejects any DEPARTMENT kind (enum-level).
  • Manual verification: run the migration against a dev DB holding a multi-department activity and an EVERYONE activity; confirm the picked department, the trimmed audience rows, and the converted EVERYONE rows. Label-coverage drift suite for the new DTO fields (kind, duties, departmentId already labeled).

Gates run by Fabio (never by the agent) per feedback_user_owns_gates_and_git.


12. Sign-off

  • Approved by: Fabio
  • Date: 2026-08-12
  • Chat reference: "approved" in chat 2026-08-12, after review of the four called-out micro-decisions (backfill tie-breaks, undated supervisor limb, materialized follow-up limb, inlined duty detail)

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