Plan 78 R8 - SekiroKenjii/Quizzivy GitHub Wiki

R8 β€” Schedule (v0.15.0)

The class timetable the deck draws and the product lacks: weekly class sessions with per-date changes, the teacher's Calendar with its New session dialog, Attendance with a "this term" rate, terms managed by admins, and the student's "This week". A class's schedule line stops being free text once the class has sessions; it is derived from them, and the free text remains the fallback. The public join preview gains the schedule and the room (D5), never a count (DG-02).

Screens are compared with:

  • docs/design/deck/Quizzivy Teacher.dc.html: r.calendar, the New session form, and r.attendance
  • Quizzivy Student.dc.html: schedule (This week), the Classes page and its Join dialog
  • Quizzivy Sign in.dc.html: #join's preview card

Where the deck is silent, docs/design/gaps.md decides. DG-92 settles weekly series and the attendance statuses. DG-93 puts terms in Admin settings. DG-81 names /app/week, and DG-83 the empty states.

R8 needs:

  • R4: the class schedule_label and room, the dashboard's today, useIdlePolling and the console composites
  • R5 (75-r5.md): the organization module (T-R5.5), Admin β€Ί System settings (web/src/features/organization/, T-R5.28a) and the Admin Classes table (T-R5.11, T-R5.25)
  • R7: the weekly summary, and one contract step, T-R8.1

Ordering rule 7 puts R8 before R9, because the gradebook and reports read attendance. Branch work/redesign-r8 is cut from develop, and feature branches are feature/t-r8-<nn>-<slug>.

Deliverable:

  • Weekly session series per class, with one-off sessions and per-date changes: move, change the room, add a note, cancel.
  • The Calendar week view and phone agenda, with assignment opens and closes beside the sessions.
  • Attendance per session (present, late, absent or unmarked), with each student's rate this term.
  • Terms, created and edited by admins.
  • The student's This week, and the timetable line on the student's class cards.
  • A derived schedule and room on every class surface and on the public join preview.

Exit criteria:

  • The /join/preview leak review is in its PR, and a live assertion pins the preview's exact key set.
  • The isolation suite covers every new /teacher/* and /app/* operation, with an x-resource entry for every id in its path, query or body, and E2E 9 walks /app/week.
  • The five canaries pass untouched.
  • Calendar, Attendance, the Terms section and This week match the deck, or their DG-listed extrapolation, at 360, 768, 1024, 1280 and 1440, in light and dark.
  • The migrations pass goose up/down/up and are rehearsed on a Neon branch.
  • The release ships to production as v0.15.0.

Schema changes. Numbers are assigned at merge. Every file creates a new, empty table, so none indexes a populated table, none is -- +goose NO TRANSACTION, and the R7 binary is unaffected during the rolling deploy.

File Task
NNNNN_create_terms.sql T-R8.2
NNNNN_create_class_session_series.sql T-R8.2
NNNNN_create_class_sessions.sql T-R8.2
NNNNN_create_attendance_marks.sql T-R8.2

Order of work.

  1. T-R8.1 (R7's contract step) and T-R8.2 run first.
  2. The backend tasks (T-R8.3 … T-R8.9) follow the migrations, at most four agents at a time.
  3. Each screen (T-R8.10 … T-R8.15) waits for the backend task it names.
  4. Close-out (T-R8.16 … T-R8.19) comes last.

Decisions taken in this file, worth a second look.

  • Sessions come from weekly series, and an occurrence has no row until it needs one (DG-92).
    • A series row holds the class, weekday, start time, length, room, first and last date, and time zone.
    • Occurrences are computed for the range a screen asks for.
    • class_sessions holds three kinds of row: one-off sessions, per-date changes to an occurrence, and occurrences that have attendance.
    • Reads never write. Editing a series changes every future occurrence without rewriting stored rows, and past attendance stays attached to its date.
    • Materialising on read was rejected. It writes on GET, and it leaves stale rows behind every series edit.
  • An occurrence's id is stable whether or not a row exists. A series occurrence is {seriesId}.{yyyymmdd}, and a one-off session is its uuid. The contract treats both as an opaque sessionId. A write to an occurrence without a row creates it first, in the same transaction.
  • Editing a repeating session applies from a date on and never rewrites the past. The series is split at that date. If today's occurrence already has attendance, the change starts tomorrow. Per-date changes after the split date that describe the old pattern are dropped unless they carry attendance, and the dialog says how many.
  • "Repeat every week" runs to the end of the term containing the first date. With no term, it is open-ended until the teacher stops it. An open-ended series is bounded at read time by the requested range, and no series may be scheduled more than two years out.
  • The statuses are present, late and absent, plus unmarked, which is the absence of a row (DG-92). Excused is not built. Late counts as attended in the rate. The rate is (present + late) Γ· (present + late + absent) over the class's non-cancelled sessions in the current term, meaning the term containing today in the teacher's zone. Without a term it covers every session and reads "overall". Unmarked sessions are left out, so a teacher who skips a day does not lower anyone's rate.
  • Attendance is taken only for a session that has started, allowing 15 minutes of grace, and never for a cancelled one. Marks for a date that has not happened would put fiction into R9's Attendance KPI.
  • Terms are organisation-wide, never overlap, and belong to Admin settings (DG-93, 70 Β§4.1: system.settings.write). An EXCLUDE USING gist over daterange(starts_on, ends_on, '[]') enforces the overlap rule in the database. It needs no extension, because range types carry their own GiST operator class.
  • The schedule line is formatted by the web from structured slots. The server returns {slots: [{weekday, startTime, durationMinutes}], label}, where label is the class's free text, and the web formats the slots per locale: "T2 Β· T4 Β· T6, 18:00" and "Mon Β· Wed Β· Fri, 18:00".
    • A server-built string would be in one language.
    • The room is the class's room, else the single room all active series share, else none.
  • The join preview adds the weekly pattern and the room, nothing else. No session dates, notes, cancellations, member counts or session counts. The preview is what a teacher prints on a flyer, reachable only with a valid code, and it keeps R2's rate budget.
  • Sessions cascade with their class, and attendance restricts it. A class that has attendance is never hard-deleted, just as one with attempts is not. The delete answers 409 with referencedBy: attendance, a value T-R8.2 adds to T-R2.13's constraint-name map. A class with sessions but no attendance deletes cleanly.

T-R8.1 β€” Contract step owed by R7

Depends on: R7 released Touches: api/openapi.yaml (updateQuestion), server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/questions/http/input.go, server/internal/modules/questions/application/tests/crud_test.go, web/tests/support/{fixtures,handlers}.ts Size: S Done when:

  • updateQuestion's expectedUpdatedAt becomes required. A request without it answers 400 VALIDATION_FAILED from httpx.ValidateRequests, and the handler adds no check of its own.
  • Every web caller already sends it (T-R7.5), and pnpm typecheck passes with the regenerated types.
  • Nothing else in R7's release checklist is marked for contraction. This file's checklist hands R9 nothing.
  • make gen is its own commit.

T-R8.2 β€” Migrations: terms, session series, sessions, attendance

Depends on: β€” Touches: migrations/NNNNN_create_terms.sql, migrations/NNNNN_create_class_session_series.sql, migrations/NNNNN_create_class_sessions.sql, migrations/NNNNN_create_attendance_marks.sql (all new), server/internal/platform/db/tests/{constraints,privileges,migrate}_test.go, server/internal/modules/classes/repositories/delete.go and server/internal/modules/identity/repositories/delete.go (T-R2.13's referencedBy map) with their integration tests, api/openapi.yaml (the referencedBy enum), generated code, docs/plan/20-data-model.md Size: M Done when:

  • create_terms creates app.terms. Down drops it. - Columns: - id uuid PRIMARY KEY DEFAULT uuidv7() - name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 60) - starts_on date NOT NULL and ends_on date NOT NULL - created_by β†’ users RESTRICT - created_at and updated_at, with the trigger - CHECK (ends_on >= starts_on). - CONSTRAINT terms_no_overlap EXCLUDE USING gist (daterange(starts_on, ends_on, '[]') WITH &&). - The app role gets SELECT, INSERT, UPDATE and DELETE.
  • create_class_session_series creates app.class_session_series. Down drops it. - Columns: - id - class_id β†’ classes ON DELETE CASCADE - weekday smallint NOT NULL CHECK (weekday BETWEEN 1 AND 7), ISO numbering - start_time time NOT NULL - duration_minutes smallint NOT NULL CHECK (duration_minutes BETWEEN 15 AND 480) - room text (1–60) - starts_on date NOT NULL and ends_on date - time_zone text NOT NULL (≀64; validity is checked in Go) - created_by β†’ users RESTRICT - created_at and updated_at - CHECK (ends_on IS NULL OR ends_on >= starts_on) and CHECK (ends_on IS NULL OR ends_on <= starts_on + 731), the two-year bound. An open-ended series is bounded at read time by the requested range, and the command refuses a starts_on more than two years ahead. - UNIQUE (id, class_id), for the composite FK below. - An index on (class_id). - The app role gets SELECT, INSERT, UPDATE and DELETE.
  • create_class_sessions creates app.class_sessions. Down drops it. - Columns: - id uuid PRIMARY KEY DEFAULT uuidv7() - class_id β†’ classes ON DELETE CASCADE - series_id and series_date date, with CHECK ((series_id IS NULL) = (series_date IS NULL)) - starts_at timestamptz NOT NULL - duration_minutes (15–480) - room text (1–60) and note text (1–200) - cancelled_at timestamptz - created_by β†’ users RESTRICT, created_at and updated_at - FOREIGN KEY (series_id, class_id) REFERENCES app.class_session_series (id, class_id) ON DELETE CASCADE. An occurrence row can never belong to another class. MATCH SIMPLE leaves one-offs, whose series_id is NULL, unchecked. - UNIQUE (series_id, series_date). - An index on (class_id, starts_at). - The app role gets SELECT, INSERT, UPDATE and DELETE.
  • create_attendance_marks creates app.attendance_marks. Down drops it. - Columns: - session_id β†’ class_sessions ON DELETE RESTRICT - student_id β†’ users RESTRICT - status text NOT NULL CHECK (status IN ('present','late','absent')) - marked_by β†’ users RESTRICT - marked_at timestamptz NOT NULL - note text CHECK (char_length(note) BETWEEN 1 AND 500), nullable: v2's per-mark note (T-R8.6) - PRIMARY KEY (session_id, student_id), and an index on (student_id). - The app role gets SELECT, INSERT, UPDATE and DELETE. Unmarking deletes the row, and every change is audited (T-R8.6).
  • The new RESTRICT FKs join T-R2.13's constraint-name map with a new referencedBy value, attendance, added to the contract enum and both 409 descriptions: - classes/repositories/delete.go: attendance_marks_session_id_fkey, reached through the cascade from the class to its sessions, as attendance - identity/repositories/delete.go: attendance_marks_student_id_fkey and attendance_marks_marked_by_fkey as attendance, and terms_created_by_fkey, class_session_series_created_by_fkey and class_sessions_created_by_fkey as owned_content - one integration test per repository: deleting a class whose only remaining reference is attendance, and deleting a student with a mark, each answer 409 referencedBy: attendance, never a 500
  • constraints_test.go shows each rule firing: - two overlapping terms, including sharing an end day - a weekday of 8 - a series over two years - an occurrence row with a date but no series - an occurrence row pointing at another class's series - the status excused - deleting a class that has attendance, which fails with 23503 - deleting a class with only sessions, which cascades
  • privileges_test.go asserts the grants.
  • goose up/down/up passes.
  • 20-data-model.md gains the R8 section and its Β§12 deviation rows: occurrences not stored, the opaque session id, terms as an exclusion constraint, and cascade versus restrict on class delete.

T-R8.3 β€” Terms: admin CRUD and the current-term query

Depends on: T-R8.2, R5's organization module Touches: server/internal/modules/organization/{domain/term.go,application/command/{create_term,update_term,delete_term}.go,application/query/{list_terms,current_term}.go,repositories/terms.go,http/terms.go} (new files in R5's module), server/internal/core/router/server.go, api/openapi.yaml, generated code, server/internal/modules/organization/application/tests/terms_test.go (new) Size: S Done when:

  • The operations, all x-permission: system.settings.write: - GET /admin/settings/terms returns {items: [{id, name, startsOn, endsOn, state: current|upcoming|past}]}, newest first. state is computed in the actor's zone. - POST /admin/settings/terms takes {name, startsOn, endsOn} and returns 201 with the term. - PATCH /admin/settings/terms/{id} takes any of the three. - DELETE /admin/settings/terms/{id} returns 204.
  • An overlap answers 409 TERM_OVERLAPS {termId, name}, mapped from the exclusion violation (23P01) and not pre-checked. A range with its end before its start answers 400.
  • Each write is audited: term.created, term.updated or term.deleted, entity term.
  • The CurrentTerm(date) query is exposed to other modules through a port. The timetable module uses it for series ends and attendance rates, and R9 for the gradebook.
  • Tests: - unit tests for the state computation at the boundaries - integration tests for create, overlap, edit into an overlap, and delete - permission: a teacher gets 403 from the middleware on every operation. Terms are organisation-wide and have no owner, and /admin/* is outside the scoped trees of R2's isolation suite (T-R2.16), so this test is their scoping check.

T-R8.4 β€” Timetable: series, occurrences and per-date changes

Depends on: T-R8.2, T-R8.3 Touches: server/internal/modules/timetable/ (new: domain/{series,occurrence,session_id,manager}.go, application/{command/{create_session,edit_occurrence,delete_session,edit_series,end_series}.go,query/{occurrences,get_session}.go,ports/ports.go}, repositories/{series,sessions}.go, http/sessions.go, with tests/ under each layer), server/internal/core/wiring/timetable.go (new), server/internal/core/adapters/timetable.go (new: class scope and the current term), server/internal/core/router/{modules.go,server.go}, api/openapi.yaml, generated code, server/tests/isolation_flow_test.go (R2's suite table) Size: L Done when:

  • domain/manager.go holds OccurrenceManager.Expand(series, rows, from, to). - It walks each series' weekdays in the series' time zone between max(from, starts_on) and min(to, ends_on). - It builds each starts_at from the local date, start time and zone with time.Date, so no UTC-offset arithmetic is done by hand. - It overlays stored rows by (series_id, series_date) and adds one-offs. - The result is sorted by starts_at.
  • domain/session_id.go parses and formats {seriesId}.{yyyymmdd} and plain uuids. Anything else answers 404.
  • ClassSession is {id, classId, className, seriesId|null, date, startsAt, durationMinutes, room|null, note|null, cancelled, changed, hasAttendance}. changed is true when a stored per-date row differs from its series.
  • POST /teacher/classes/{classId}/sessions (teaching.classes.write) takes {date, startTime ("HH:MM"), durationMinutes, room?, repeatWeekly, endsOn?} and returns 201 with the first ClassSession. - With repeatWeekly, it creates a series: - weekday is the date's - starts_on is the date - ends_on is endsOn, else the end of the term containing the date, else NULL - time_zone is the actor's profile zone, defaulting to Asia/Ho_Chi_Minh - the room defaults to the class's room - Without it, it creates a one-off row.
  • PATCH /teacher/sessions/{sessionId} (teaching.classes.write) takes {date?, startTime?, durationMinutes?, room?|null, note?|null, cancelled?}. - On a series occurrence it writes the per-date row, keeping series_date as the original date even when the session moves. - cancelled sets or clears cancelled_at.
  • DELETE /teacher/sessions/{sessionId} (teaching.classes.write) removes a one-off. - A series occurrence answers 409 SESSION_IN_SERIES; the web offers Cancel instead. - A session with attendance answers 409 SESSION_HAS_ATTENDANCE.
  • PATCH /teacher/session-series/{seriesId} (teaching.classes.write) takes {fromDate, weekday?, startTime?, durationMinutes?, room?|null} and returns {series, droppedChanges}. - fromDate must be today or later in the series' zone, and moves to tomorrow when today's occurrence has attendance. - The old series ends the day before, and a new series starts at fromDate. - Per-date rows of the old series from fromDate on without attendance are deleted and counted in droppedChanges.
  • POST /teacher/session-series/{seriesId}/end (teaching.classes.write) takes {lastDate} and returns the series, or 204 when it was removed. - It sets ends_on and deletes later per-date rows without attendance. - A lastDate before starts_on deletes the series. That is allowed because nothing in it has happened.
  • Every command checks R2's class scope and runs under the class row's lock.
  • Each operation declares x-resource in R2's per-parameter form (T-R2.16): path {sessionId} as session, path {seriesId} as session-series, and the create operation's path {classId} as the existing class. session and session-series join the kind enum in this PR. - A sessionId is opaque text, not a uuid, so the suite's uuid rule would not catch a missing declaration; it is declared explicitly, and the suite substitutes both of A's id forms, a one-off's uuid and a {seriesId}.{yyyymmdd} occurrence. - Another teacher's ids answer 404.
  • Audit: session.created, session.updated, session.cancelled, session.deleted, session_series.updated and session_series.ended, entity class_session or class_session_series.
  • Tests: - expansion across month and year ends, in Asia/Ho_Chi_Minh and in a UTC+9 zone - a moved occurrence appearing on its new date only - cancel and restore - a split with attendance today - ending before the start - the id round trip - a 42-day range over 50 series in two queries, not one per series - isolation for every operation

T-R8.5 β€” The calendar read model

Depends on: T-R8.4 Touches: server/internal/modules/timetable/{application/query/calendar.go,http/calendar.go} (new), server/internal/modules/timetable/application/ports/ports.go, server/internal/core/adapters/timetable.go, api/openapi.yaml, generated code, server/tests/isolation_flow_test.go (R2's suite table) Size: M Done when:

  • GET /teacher/calendar?from=&to=&classId= (workspace.teacher; 70 Β§4.1: calendar views need the workspace) returns {sessions: [ClassSession], assignmentEvents: [{kind: opens|closes, at, assignmentId, title, classNames}]}. - to βˆ’ from is at most 42 days. A longer range answers 400. - Sessions come from classes in the caller's class scope. - Events come from assignments in the caller's assignment scope, through a port typed as an assignments query, using each assignment's own window. Per-student overrides are not drawn. - A classId outside the caller's class scope answers 404, as an unknown one does.
  • x-resource (T-R2.16's form): the query classId declares {in: query, name: classId, kind: class}, and the operation declares x-resource-list.
  • One query loads the series, one the rows and one the events, whatever the number of classes.
  • Tests: - scope: teacher B sees none of A's sessions or events, and B's ?classId= with A's class answers 404 - the range limit - an assignment that opens before the range and closes inside it - a cancelled occurrence returned with cancelled: true

T-R8.6 β€” Attendance

Depends on: T-R8.4 Touches: server/internal/modules/timetable/{domain/attendance.go,application/command/save_attendance.go,application/query/{attendance,recent_sessions}.go,repositories/attendance.go,http/attendance.go} (new), api/openapi.yaml, generated code, server/tests/isolation_flow_test.go (R2's suite table) Size: M Done when:

  • GET /teacher/attendance/sessions?classId=&before=&since=&marked=&limit= (teaching.attendance) returns {items: [{id, classId, className, room, startsAt, marked, studentCount, counts {present, late, absent}|null}], nextBefore, notMarked}, which v2's Attendance page and tab draw (74d rows V-17, V-18). - It lists started, non-cancelled sessions in scope, newest first, 20 a page. - since (inclusive) and before (exclusive) bound startsAt; marked=true|false keeps sessions with or without attendance. counts is null while marked is false. - notMarked counts the sessions in scope, and of classId when it is sent, that have started and have no attendance, whatever the page: the tab's badge. - limit=1 gives the page's default session. - A classId outside the caller's class scope answers 404, as an unknown one does.
  • GET /teacher/sessions/{sessionId}/attendance (teaching.attendance) returns {session, term: {id, name}|null, rateScope: term|overall, counts {present, late, absent, unmarked}, students: [{studentId, fullName, avatarUrl, status|null, note|null, rate|null}]}. - The roster is the class's active student members, plus anyone already marked on this session. - It is sorted as the class members list is.
  • PUT /teacher/sessions/{sessionId}/attendance (teaching.attendance) takes {marks: [{studentId, status: present|late|absent|null, note?: string|null}] (1–200)} and returns the GET shape. - A null status deletes the mark, and its note with it. A note of 1–500 characters rides on a status; a note with a null status answers 422 naming the student. An absent note keeps the stored one, and an explicit null clears it. - Refusals: - a session starting more than 15 minutes from now: 422 SESSION_NOT_STARTED - a cancelled session: 409 SESSION_CANCELLED - a student who is neither a member nor already marked: 422, naming the ids sent - A series occurrence gets its row first, with INSERT … ON CONFLICT (series_id, series_date) DO NOTHING under the series row's lock.
  • x-resource (T-R2.16's form): - the sessions list declares {in: query, name: classId, kind: class} and x-resource-list - the GET and PUT declare path {sessionId} as session - the PUT declares body /marks/0/studentId as student; the suite's minimal body carries one mark, and B's session with A's student answers the same 422 as an unknown id, naming only the id sent
  • Each PUT writes one attendance.saved audit row, with a diff of the changed statuses, in one statement through the data-modifying CTE pattern. A changed note shows in the diff as "note changed" and never as its text, since the register says only the teachers see it.
  • The rate is computed as the decisions section says, in one grouped query for the whole roster.
  • No student operation exposes attendance in R8. So v2's register sentence "Students see their own attendance." is not taken, and neither is its confirm "They count as absent until you mark them.": unmarked stays a status outside the rate (DG-92, DG-198).
  • Tests: - rate arithmetic: late counted, unmarked left out, cancelled sessions left out - term versus overall - the three refusals - two concurrent PUTs on an unmaterialised occurrence make one row - a note is saved, kept by a PUT that omits it, cleared by a null, refused over 500 characters and refused on a null status; the audit diff never holds a note's text - the list's since, before and marked bounds, its counts, and notMarked agreeing with a full count - the audit diff - isolation, one parameter at a time: A's class in the list's classId, A's session in the path, and A's student in marks

T-R8.7 β€” Class schedule from the series, and the join preview

Depends on: T-R8.4 Touches: server/internal/modules/classes/{domain/class.go,application/query/{get,list,list_mine,preview}.go,repositories/{postgres.go,lookup.go,joincodes.go},http/{classes.go,my_classes.go,joincode.go}}, server/internal/modules/classes/application/ports/ (R4's, gaining a ScheduleSource port), server/internal/core/adapters/timetable.go, R5's server/internal/modules/classes/application/query/list_all.go, api/openapi.yaml, generated code, server/internal/modules/classes/application/tests/joincode_preview_test.go, server/internal/core/router/tests/join_preview_test.go, web/tests/units/contract/openapi-contract.test.ts Size: M Done when:

  • ClassSchedule is {slots: [{weekday, startTime, durationMinutes}] (≀14), label: string|null}. - The slots come from series active today or later, deduplicated and ordered by weekday, then time. - label is classes.schedule_label. - room follows the decisions section.
  • schedule and room appear on Class, on R5's admin class rows, and on MyClass. On MyClass they are the slots, label and room only: no dates, notes, counts or members.
  • The /join/preview 200 gains schedule (ClassSchedule | null) and room (string | null). - additionalProperties: false stays. - The 404 path is unchanged. - The description carries the new leak review: the weekly pattern and the room only, the facts a teacher prints on a flyer, returned only for a valid, usable code; no session dates, notes or cancellations; no member, session or attempt counts (DG-02). - The rate limits are unchanged (R2's T-R2.15 budgets). The rate-limit tests in join_preview_test.go and classroom_limits_test.go in server/internal/core/router/tests/ run again in this PR, unchanged.
  • The Go leak pin is re-pinned in this PR, renamed rather than removed or skipped: TestTheSuccessBodyHasExactlyThreeKeys in join_preview_test.go becomes TestTheSuccessBodyHasExactlyFiveKeys, asserting exactly classId, className, teacherName, schedule and room. The PR states why the pin changes, citing the leak review above.
  • openapi-contract.test.ts pins the preview's properties to exactly classId, className, teacherName, schedule and room. The Go and web key pins list the same five keys.
  • Tests: - derivation with no series (label only) - two series at one time - an ended series left out - the room rules - preview key set exact - the preview of an expired code unchanged - MyClass carries no count

T-R8.8 β€” The student week

Depends on: T-R8.4 Touches: server/internal/modules/timetable/{application/query/student_week.go,http/student.go} (new), server/internal/modules/timetable/application/ports/ports.go (a due-items port typed as the assignments student query), server/internal/core/adapters/timetable.go, api/openapi.yaml, generated code, server/tests/isolation_flow_test.go (R2's suite table) Size: S Done when:

  • GET /app/week?start=YYYY-MM-DD (learning.take_tests) returns {start, end, days: [{date, items: [WeekItem]}]}. - start is normalised to its ISO week's Monday in the student's zone, and defaults to the current week. - A WeekItem is one of two kinds: - a session: {kind: session, classId, className, startsAt, durationMinutes, room|null, note|null, cancelled} - a due assignment: {kind: due, assignmentId, title, closesAt, state: not_started|in_progress, durationMinutes|null} - Sessions come from classes the student is an active member of, excluding archived classes. - Due items are assignments targeting the student whose effective close, R4's overrides included, falls in the week and that are not handed in.
  • The payload carries no counts, members, attendance or other students. E2E 9 and the contract sweep cover it.
  • The operation joins the table of T-R2.16's two-student /app/* pair. It takes no id, so it declares no x-resource; student B's week holds none of student A's class sessions or due items.
  • Tests: - the week boundary in UTC+7 - an override that moves one student's close into the week - a class the student left - an archived class - a cancelled session present with its flag

T-R8.9 β€” Dashboard Today sessions, and attendance in the weekly summary

Depends on: T-R8.4, T-R8.6, R7's T-R7.14 Touches: server/internal/modules/dashboard/{application/query/summary.go,repositories/postgres.go}, server/internal/modules/notifications/repositories/weekly.go, server/internal/platform/email/templates/{vi,en}/weekly_summary.tmpl, api/openapi.yaml, generated code Size: S Done when:

  • getDashboard's today gains {kind: session, at, sessionId, classId, className, room} items for today's occurrences in the actor's zone, excluding cancelled ones (R4 left a place for them).
  • The weekly summary gains a line per class: "Attendance {rate} across {n} sessions", from last week's marked sessions. It is omitted when no session was marked. The vi and en templates stay in parity.
  • Tests: today in the actor's zone, cancelled sessions left out, the summary line present and absent, and the golden renders updated.

T-R8.10 β€” Calendar and the New session dialog

Depends on: T-R8.4, T-R8.5 Touches: web/src/features/calendar/ (new: pages/teacher/CalendarPage.tsx, components/{WeekGrid,Agenda,CalendarEvent,Legend,NewSessionDialog}.tsx, api.ts, week.ts), web/src/lib/i18n/schedule.ts (new), web/src/app/router.tsx (/teacher/calendar), web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, locales, web/tests/units/calendar/ (new), web/tests/units/contract/teacher-nav.test.ts Reuses: FormDialog, the date and time pickers, the segmented control, useContentWidthAtLeast Size: L Done when:

  • The nav gains Calendar in group 1, between Dashboard and Messages (the deck's order). Its x-permission matches GET /teacher/calendar's, and teacher-nav.test.ts passes. The palette's Pages gain Calendar.
  • The header: - the h1 is the week, "22 – 28 thΓ‘ng 9" / "22 – 28 September", from lib/i18n/schedule.ts - a segmented control: previous, Today, next - "+ Session" in primary, shown with teaching.classes.write - the week lives in ?week=2026-W39, and weeks start on Monday
  • The legend: Class session (accent), Test closes (warning), Test opens (info).
  • The week grid, per the deck: - a card at most 600 high that scrolls, a 56px hour gutter and seven day columns - a sticky day header that never clips the 08:00 row (drawing 5): day name at 12px, the date at 18/600, coloured for today - hour rows of 44px from 08:00 to 22:00, growing to whole hours to cover any earlier or later event that week - events positioned by top and height (at least 36): the title at 12/600 and the time at 11.5 - today's column tinted, and the weekend on --sidebar - the red now-line with its dot, static under reduced motion
  • Below 768, an agenda from today, grouped by day ("Today Β· Wed 24 Sep"). Each row has a 3px colour bar, the start, the title and the time range, or "Test window".
  • A class event opens the session popover (T-R8.11). A test event opens the assignment.
  • New session, per the deck's form: - Class: the caller's classes - Date: today by default - Starts: 18:00 by default - Length: 60, 90 or 120 min, 90 by default - Room: placeholder "Room 2", prefilled from the class's room - Repeat every week: on by default, with the hint "Until {term end}" or "Until you stop it" - Add session, which toasts "Session added Β· {class}, {time}"
  • lib/i18n/schedule.ts formats slots, weeks and times for both locales and is the one formatter every schedule surface uses.
  • States: - a skeleton grid - an empty week: "No sessions this week." with New session (DG-83) - an error with retry Nothing polls; the page refetches on focus and after a mutation.
  • Unit tests: - event geometry and the grid's growth for a 07:00 session - the agenda at viewport("phone") - ?week= round trip - the form's defaults and validation - both locales' labels
  • Compared with the deck's Calendar and its agenda at the five widths, light and dark. Keys vi first.

T-R8.11 β€” The session popover: per-date changes and repeating sessions

Depends on: T-R8.10 Touches: web/src/features/calendar/components/{SessionPopover,EditSessionDialog,EditSeriesDialog}.tsx (new), locales, web/tests/units/calendar/ Size: M Done when:

  • The popover is built from deck primitives and listed for DG-92 sign-off. It shows the class, date, time range, room, note, and "Every {weekday}" or "One-off".
  • Its actions: - Take attendance, with teaching.attendance, on sessions that have started - Edit this date: date, start, length, room, note - Cancel this date or Restore, with the confirm "Students see it as cancelled in This week." - Edit repeating session: from a date, by default the next occurrence; weekday, start, length and room; the dialog states "Sessions before {date} stay as they are." and, after saving, "{n} one-off changes were cleared." - Stop repeating: last date - Delete, for a one-off without attendance
  • Keyboard: arrow keys move through the actions, Esc closes and focus returns to the event (DG-36).
  • Error messages: - SESSION_HAS_ATTENDANCE: "This session has attendance, so it can only be cancelled." - SESSION_IN_SERIES opens Cancel instead.
  • Unit tests: each action's request, a split when today has attendance (the from-date note), and the keyboard behaviour. Keys vi first.

T-R8.12 β€” Attendance

Depends on: T-R8.6, R4's Class detail (T-R4.42) with v2's join-code card (T-D4.23), T-D4.20 (StickyAside) Touches: web/src/features/attendance/ (new: pages/teacher/AttendancePage.tsx, components/{SessionRow,SessionPicker,Register,ProgressRow,StatusRadio,NoteDialog,SaveBar,UnmarkedConfirm}.tsx, api.ts), web/src/features/classes/pages/teacher/ClassDetailPage.tsx (the tabs), web/src/app/router.tsx (/teacher/attendance), web/src/layouts/teacherNav.ts, locales, web/tests/units/attendance/ (new) Size: M

Two screens, as v2 draws them (74d rows V-15, V-17, V-18). The Attendance page is a cross-class list of sessions; the register is the Attendance tab of Class detail. The fourth export's one-class page at 960 no longer exists.

Done when:

  • The nav gains Attendance under Teaching, after Students. It stays out of the palette, as in the deck. Its x-permission matches the sessions list.
  • The page is 1320 wide: h1 "Attendance" and the sub-line "Take the register for today’s classes, or catch up on sessions you missed." Three cards from listAttendanceSessions: "Today Β· {date}" ("{n} classes"; the sessions that started today), "Needs a register" ("Past sessions with no attendance taken"; marked=false, before today) and "Recent" ("Last 7 days"; before today, with attendance). Each row shows the time (13.5px, 600) over the day ("Today"), the class name, a meta line ("{room} Β· {n} students", or "{p} present Β· {l} late Β· {a} absent" once saved), a status chip (Not taken yet, warning; Not taken, danger; Saved, success) and "Take attendance" (primary, calendar-check) or "Open". Empties: "No classes have started today." (O-R8.6), "All caught up.", "Nothing yet." A row opens the class's Attendance tab on that session.
  • Class detail gains underline tabs Overview | Attendance under its header (?tab=attendance, ?session= selects the session; without it the tab opens the latest). Attendance carries an accent badge with notMarked for the class, hidden at 0. R4's Overview is unchanged.
  • The register's top row: a session picker (calendar-days, "{day} Β· {time}", a chevron) opening a 260px menu titled "Sessions" (each session with Today, Saved or Not taken, and Load more), a status chip (Not taken yet, Not taken, Saved {time}), a 240px search "Search students", and "Mark all present" (or "Mark the rest present" once some are marked).
  • The register card holds, in order: - a progress row, "{k} of {n} marked" or "All {n} marked", with a three-segment bar (present, late, absent) and legend filters (Present, Late, Absent, Not marked) that are toggle buttons with aria-pressed and counts (DG-17) - a selection bar on --primary while rows are selected: "{n} selected", Mark present, Mark late, Mark absent, Clear selection - a 40px header on --muted: Select all (indeterminate when part), Student, "This term" or "Overall" by rateScope from 640, Status, Note - rows of at least 56px: a checkbox, the avatar and name, the rate (danger ink under 80%), a radiogroup "Attendance" of Present, Late and Absent (each option at least 44 wide; choosing the active one clears it), and a Note button (message-square-text with the note, or "+ Note", disabled until the student has a status) - empties "No student matches β€œ{q}”" and "Nobody here yet." - a footer that reads "Late counts as present in the attendance rate." (the deck's first sentence, "Students see their own attendance.", is not taken, DG-198)
  • The Note dialog, "Note for {name}" / "Only you and co-teachers see it.", with the placeholder "e.g. Left early for a doctor’s appointment" and a 500-character limit, writes the note into the session's unsaved marks like a status.
  • A sticky bar 12px from the bottom while anything changed: "{n} changes not saved Β· {m} not marked yet", Discard and a primary "Save attendance", which toasts "Attendance saved Β· {a} absent, {l} late". With students unmarked, Save first asks "Save with {m} not marked?", saying that students you have not marked stay unmarked and are left out of the rate (not the deck's "They count as absent until you mark them.", DG-198), with "Save anyway".
  • An unsaved-changes guard covers leaving the page, the tab and changing the session.
  • States: - a skeleton - no sessions yet: "No sessions yet. Add one in Calendar." with a link - a session not yet started, or cancelled: read-only, with a line saying which - an error with retry
  • Unit tests: - the progress counts and the legend filters - Mark all present, the bulk marks, and clearing a mark - a note, and its button disabled without a status - the guard, and the confirm for unmarked students - the three rate captions - the three cards' empties - the phone layout at viewport("phone")
  • Compared with the deck's Attendance page and Class detail tab at the five widths, light and dark. Keys vi first.

T-R8.13 β€” Terms in Admin β€Ί System settings

Depends on: T-R8.3, R5's System settings page Touches: web/src/features/organization/sections/Terms.tsx (new), web/src/features/organization/components/TermDialog.tsx (new), R5's web/src/features/organization/{pages/admin/SystemSettingsPage.tsx,api.ts}, locales, web/tests/units/organization/terms.test.tsx (new) Size: S Done when:

  • The sub-nav gains "Terms" (calendar-range) after Organization, at /admin/settings/terms, beside R5's four sections. It is listed for DG-93 sign-off as an extrapolation from deck primitives.
  • The card "Terms" / "Used by attendance and the gradebook." holds a table: Name, Starts, Ends, and a Current / Upcoming / Past pill. The row menu offers Edit and Delete (confirm "Attendance rates in this term then cover all sessions.").
  • Add term is a dialog with Name, Starts and Ends, on R1's date picker. TERM_OVERLAPS shows inline: "Overlaps {name}".
  • Each dialog saves itself. The section is not part of the settings DirtyBar.
  • States: empty "No terms yet. Attendance rates cover all sessions until you add one.", a skeleton, and an error.
  • Unit tests: create, the overlap message, delete confirm. Compared with the Admin deck's settings frame at the five widths, light and dark. Keys vi first.

T-R8.14 β€” The schedule on class surfaces, the dashboard and the join preview card

Depends on: T-R8.7, T-R8.9, T-R8.10 Touches: web/src/features/classes/pages/teacher/{ClassesPage,ClassDetailPage}.tsx, web/src/features/classes/components/NewClassDialog.tsx, web/src/features/dashboard/pages/teacher/TeacherDashboardPage.tsx, R1's web/src/features/join/components/ClassPreviewCard.tsx, R3's web/src/features/join/components/JoinDialog.tsx, R5's web/src/features/classes/components/admin/AdminClassesTable.tsx, locales, web/tests/units/{classes,dashboard,join}/ Size: M Done when:

  • Every surface formats through lib/i18n/schedule.ts: - one time: "T2 Β· T4 Β· T6, 18:00" - several: "T2 Β· T4, 18:00; T7, 09:00" - the room follows: " Β· PhΓ²ng 2" / " Β· Room 2" - the free-text label when there are no slots, else "No schedule yet"
  • The teacher's class cards, class detail and the Admin Classes table show the derived line.
  • Edit class, on the card menu and in class detail: - with slots, the Schedule field shows the derived line read-only, with "Change in Calendar" opening the calendar at the class's next session - without slots, it stays R4's free text
  • The dashboard's Today lists sessions with their room. A started session links to its attendance.
  • The join preview card, on /join and in the student's Join dialog, reads "{teacher} Β· {schedule}" as the deck draws it, with the room appended. No count appears anywhere (DG-02).
  • Unit tests: formatter cases in both locales, the read-only Schedule field, the preview line with and without a schedule.
  • Compared with the deck at the five widths, light and dark. Keys vi first.

T-R8.15 β€” The student's This week and the Classes timetable line

Depends on: T-R8.7, T-R8.8 Touches: web/src/features/calendar/pages/student/ThisWeekPage.tsx (new), web/src/app/router.tsx (/app/week), web/src/app/modules.ts, web/src/features/classes/pages/StudentClassesPage.tsx, locales, web/tests/units/student/this-week.test.tsx (new), web/tests/units/student/classes.test.tsx Size: M Done when:

  • modules.ts turns the schedule module on. - The Classes page gains the secondary "This week" button (calendar-days), which R3 left out. - Each class card gains its timetable line (R3's gap).
  • /app/week is a detail route titled "This week" whose back arrow goes to Classes (DG-81). It is at most 820 wide. - The h1 is "This week", with "Classes and test deadlines, 22 – 28 September." - The deck draws no week navigation, so none is built. ?start= is honoured for links.
  • One row per day: - the day column: the weekday in 11.5 uppercase and the date in a 36px circle, filled primary for today - session items: the users icon, the class name, "Room 2 Β· 90 min" or the date's note ("Room 3 this week"), and the time; a cancelled session reads "Cancelled" and is muted - due items: an amber tint, the test icon, the title, "Closes today Β· in progress" or "Due Β· 30 min test", and the time; the item opens the test intro - "Nothing scheduled" on an empty day
  • States: a skeleton, an error with retry, and a whole empty week: "Nothing scheduled this week."
  • Unit tests: today highlighted, a note replacing the sub-line, a cancelled session, a due item's route, and the phone detail header.
  • Compared with the deck's This week and Classes at the five widths, light and dark. Keys vi first.

T-R8.16 β€” Seed and E2E

Depends on: T-R8.10 … T-R8.15 Touches: seed/NN-dev-schedule.sql (new, numbered at merge, development only), web/tests/e2e/{calendar,attendance}.spec.ts (new), web/tests/e2e/schedule.live.spec.ts (new), web/tests/e2e/payload-leak.live.spec.ts Size: M Done when:

  • The dev seed matches the deck's fixtures: - a current term - series for the four classes, shaped like CAL_EV - one per-date change, "Room 3 this week" - marked past sessions whose rates match ATT_RATE
  • Stubbed E2E: - calendar.spec.ts: the week grid, the agenda at 360, and New session - attendance.spec.ts: mark, save, and change session
  • schedule.live.spec.ts: 1. A teacher creates a repeating session. 2. The student sees it in This week, and a per-date change shows there. 3. The teacher takes attendance. 4. /join/preview returns exactly the five keys, with the slot and the room. It is live because the public preview meets the derivation there.
  • E2E 9 walks /app/week and MyClass.
  • pnpm e2e and pnpm e2e:live pass.

T-R8.17 β€” Docs

Depends on: the tasks whose behaviour each part describes Touches: docs/quizzivy-spec-v0.3.md (Β§6, Β§8, Β§9, Β§13, Β§15; version bumped per Β§18), AGENTS.md, docs/plan/{20-data-model,40-open-items,60-backend-architecture}.md, docs/design/gaps.md Size: S Done when:

  • Spec: - Β§6's preview lists schedule and room, with the leak rule - Β§8 covers Calendar, Attendance and the Admin Terms section - Β§9 covers This week and the timetable line - Β§13 and Β§15 list the tables and operations - the "Availability: R8" markers go, and the version is bumped
  • AGENTS.md: - the repository map gains modules/timetable, features/calendar and features/attendance - a verified fact: a series occurrence has no row until it is changed or marked, its id is {seriesId}.{yyyymmdd}, and nothing materialises occurrences on read
  • 20-data-model.md is complete for R8, and 60-backend-architecture.md lists timetable.
  • 40-open-items.md gains this file's open items. gaps.md notes what was built under DG-92 and DG-93.

T-R8.18 β€” Browser verification against the deck

Depends on: T-R8.10 … T-R8.16 Touches: none beyond the seed Size: M Done when:

  • Every new or changed screen is compared with design-deck on 5175 at 360, 768, 1024, 1280 and 1440, light and dark: - Calendar, with a week whose sessions span 07:00–21:30 - the session popover - Attendance - Admin β€Ί Terms - This week - the class cards and class detail - the join card - the dashboard Today
  • The dev server is confirmed fresh, and sizes are measured with getBoundingClientRect.
  • Deviations are fixed in the owning task's branch or filed as issues. Vite and the API are stopped afterwards.

T-R8.19 β€” Release v0.15.0

Depends on: T-R8.1 … T-R8.18 Touches: docs/plan/, release notes Size: S Done when:

  • The release checklist below is complete, with Thuong's go before main.

Release checklist

  • Every task is ticked on work/redesign-r8. develop is merged into it, and verification is re-run after that merge.
  • Every CI step runs locally, in order, judged by exit code (70 Β§8.1): - make lint, make gen-check - make test-api, and goose up/down/up - pnpm lint, pnpm typecheck, pnpm format:check - pnpm test:unit, pnpm test:integration, pnpm build - pnpm e2e, pnpm e2e:content - pnpm e2e:live, required because join changed - node scripts/check-design-deck.mjs
  • The five canaries pass, and their files are untouched against develop.
  • Security: - the isolation suite covers every new /teacher/* and /app/* operation, /app/week through the two-student pair; the /admin/* terms operations are outside its scoped trees and are pinned by T-R8.3's 403 test - every class delete and user delete that the new FKs can refuse answers 409 with referencedBy (T-R8.2) - the /join/preview leak review is in T-R8.7's PR, and the live spec pins the key set - the join rate-limit tests pass unchanged; the only change to join_preview_test.go is T-R8.7's re-pin to five keys - E2E 9 walks /app/week
  • The four migrations are rehearsed on a Neon branch of production, with timings in the release PR.
  • The deploy window is outside exam windows.
  • Deploy and smoke: - Thuong's go, then release/0.15.0 β†’ main - deploy the API (the release command migrates), then Pages - smoke /healthz, /livez, and sign-in as each role - an admin adds the current term - the owner adds a repeating session to their test class and takes attendance for it - a test student sees it in This week - a join preview for that class shows the schedule and the room, and no count - back-merge to develop, and tag v0.15.0
  • Release notes, vi first: - the Calendar and repeating sessions - changing or cancelling one date - attendance and the "this term" rate - admins: add your terms under Admin β€Ί System settings β€Ί Terms, or rates cover all sessions - This week for students - class cards and the join preview now show the timetable and the room
  • Hand-off to R9: nothing to contract. The attendance rate query and CurrentTerm are ready for the gradebook and reports.

Open items

  • O-R8.1 β€” Excused absences. DG-92 decided present, late and absent, plus unmarked. An excused status would change the rate formula and R9's gradebook. Default: not built.
  • O-R8.2 β€” Holidays and term breaks. Nothing cancels a range of dates at once, and a teacher cancels each date separately. Default: per-date cancel. A holiday calendar needs a design row.
  • O-R8.3 β€” Assistants taking attendance. The Assistant role holds teaching.attendance, but has no class until class staff ships (D14, DG-04). Default: teachers and admins take attendance.
  • O-R8.4 β€” A class's time zone. A series keeps the zone of the teacher who created it, and a class has no zone of its own. That is correct while every centre is in one zone. Default: no per-class zone.
  • O-R8.5 β€” Session reminders. The deck draws no "class starts soon" notification, so none is built.
  • O-R8.6 β€” Today's later sessions on the Attendance page. The sessions list holds started sessions only, because the PUT refuses one that starts more than 15 minutes from now, while v2's "Today" card is drawn with "Not taken yet" rows. Default: the card lists the sessions that have started today, and its empty line reads "No classes have started today." where the deck says "No classes today.". The design team is asked.
  • DG-92 (the session popover and per-date editing) and DG-93 (the Terms section) ship extrapolated from deck primitives and are listed for the design team's sign-off.

Generated from docs/plan/78-r8.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️