Plan 78 R8 - SekiroKenjii/Quizzivy GitHub Wiki
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, andr.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_labelandroom, the dashboard'stoday,useIdlePollingand the console composites - R5 (
75-r5.md): theorganizationmodule (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/previewleak 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 anx-resourceentry 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.
- T-R8.1 (R7's contract step) and T-R8.2 run first.
- The backend tasks (T-R8.3 β¦ T-R8.9) follow the migrations, at most four agents at a time.
- Each screen (T-R8.10 β¦ T-R8.15) waits for the backend task it names.
- 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_sessionsholds 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 opaquesessionId. 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). AnEXCLUDE USING gistoverdaterange(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}, wherelabelis 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.
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'sexpectedUpdatedAtbecomes required. A request without it answers 400VALIDATION_FAILEDfromhttpx.ValidateRequests, and the handler adds no check of its own. - Every web caller already sends it (T-R7.5), and
pnpm typecheckpasses with the regenerated types. - Nothing else in R7's release checklist is marked for contraction. This file's checklist hands R9 nothing.
-
make genis its own commit.
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_termscreatesapp.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 NULLandends_on date NOT NULL-created_byβ usersRESTRICT-created_atandupdated_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_seriescreatesapp.class_session_series. Down drops it. - Columns: -id-class_idβ classesON 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 NULLandends_on date-time_zone text NOT NULL(β€64; validity is checked in Go) -created_byβ usersRESTRICT-created_atandupdated_at-CHECK (ends_on IS NULL OR ends_on >= starts_on)andCHECK (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 astarts_onmore 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_sessionscreatesapp.class_sessions. Down drops it. - Columns: -id uuid PRIMARY KEY DEFAULT uuidv7()-class_idβ classesON DELETE CASCADE-series_idandseries_date date, withCHECK ((series_id IS NULL) = (series_date IS NULL))-starts_at timestamptz NOT NULL-duration_minutes(15β480) -room text(1β60) andnote text(1β200) -cancelled_at timestamptz-created_byβ usersRESTRICT,created_atandupdated_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, whoseseries_idis 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_markscreatesapp.attendance_marks. Down drops it. - Columns: -session_idβ class_sessionsON DELETE RESTRICT-student_idβ usersRESTRICT-status text NOT NULL CHECK (status IN ('present','late','absent'))-marked_byβ usersRESTRICT-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
RESTRICTFKs join T-R2.13's constraint-name map with a newreferencedByvalue,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, asattendance-identity/repositories/delete.go:attendance_marks_student_id_fkeyandattendance_marks_marked_by_fkeyasattendance, andterms_created_by_fkey,class_session_series_created_by_fkeyandclass_sessions_created_by_fkeyasowned_content- one integration test per repository: deleting a class whose only remaining reference is attendance, and deleting a student with a mark, each answer 409referencedBy: attendance, never a 500 -
constraints_test.goshows 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 statusexcused- deleting a class that has attendance, which fails with 23503 - deleting a class with only sessions, which cascades -
privileges_test.goasserts the grants. - goose up/down/up passes.
-
20-data-model.mdgains 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.
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/termsreturns{items: [{id, name, startsOn, endsOn, state: current|upcoming|past}]}, newest first.stateis computed in the actor's zone. -POST /admin/settings/termstakes{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.updatedorterm.deleted, entityterm. - 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.
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.goholdsOccurrenceManager.Expand(series, rows, from, to). - It walks each series' weekdays in the series' time zone betweenmax(from, starts_on)andmin(to, ends_on). - It builds eachstarts_atfrom the local date, start time and zone withtime.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 bystarts_at. -
domain/session_id.goparses and formats{seriesId}.{yyyymmdd}and plain uuids. Anything else answers 404. -
ClassSessionis{id, classId, className, seriesId|null, date, startsAt, durationMinutes, room|null, note|null, cancelled, changed, hasAttendance}.changedis 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 firstClassSession. - WithrepeatWeekly, it creates a series: -weekdayis the date's -starts_onis the date -ends_onisendsOn, else the end of the term containing the date, else NULL -time_zoneis the actor's profile zone, defaulting toAsia/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, keepingseries_dateas the original date even when the session moves. -cancelledsets or clearscancelled_at. -
DELETE /teacher/sessions/{sessionId}(teaching.classes.write) removes a one-off. - A series occurrence answers 409SESSION_IN_SERIES; the web offers Cancel instead. - A session with attendance answers 409SESSION_HAS_ATTENDANCE. -
PATCH /teacher/session-series/{seriesId}(teaching.classes.write) takes{fromDate, weekday?, startTime?, durationMinutes?, room?|null}and returns{series, droppedChanges}. -fromDatemust 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 atfromDate. - Per-date rows of the old series fromfromDateon without attendance are deleted and counted indroppedChanges. -
POST /teacher/session-series/{seriesId}/end(teaching.classes.write) takes{lastDate}and returns the series, or 204 when it was removed. - It setsends_onand deletes later per-date rows without attendance. - AlastDatebeforestarts_ondeletes 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-resourcein R2's per-parameter form (T-R2.16): path{sessionId}assession, path{seriesId}assession-series, and the create operation's path{classId}as the existingclass.sessionandsession-seriesjoin the kind enum in this PR. - AsessionIdis 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.updatedandsession_series.ended, entityclass_sessionorclass_session_series. - Tests:
- expansion across month and year ends, in
Asia/Ho_Chi_Minhand 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
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 β fromis 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. - AclassIdoutside the caller's class scope answers 404, as an unknown one does. -
x-resource(T-R2.16's form): the queryclassIddeclares{in: query, name: classId, kind: class}, and the operation declaresx-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 withcancelled: true
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 (74drows V-17, V-18). - It lists started, non-cancelled sessions in scope, newest first, 20 a page. -since(inclusive) andbefore(exclusive) boundstartsAt;marked=true|falsekeeps sessions with or without attendance.countsis null whilemarkedis false. -notMarkedcounts the sessions in scope, and ofclassIdwhen it is sent, that have started and have no attendance, whatever the page: the tab's badge. -limit=1gives the page's default session. - AclassIdoutside 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. Anoteof 1β500 characters rides on a status; a note with a null status answers 422 naming the student. An absentnotekeeps the stored one, and an explicit null clears it. - Refusals: - a session starting more than 15 minutes from now: 422SESSION_NOT_STARTED- a cancelled session: 409SESSION_CANCELLED- a student who is neither a member nor already marked: 422, naming the ids sent - A series occurrence gets its row first, withINSERT β¦ ON CONFLICT (series_id, series_date) DO NOTHINGunder the series row's lock. -
x-resource(T-R2.16's form): - the sessions list declares{in: query, name: classId, kind: class}andx-resource-list- the GET and PUT declare path{sessionId}assession- the PUT declares body/marks/0/studentIdasstudent; 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.savedaudit 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.":
unmarkedstays 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,beforeandmarkedbounds, itscounts, andnotMarkedagreeing with a full count - the audit diff - isolation, one parameter at a time: A's class in the list'sclassId, A's session in the path, and A's student inmarks
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:
-
ClassScheduleis{slots: [{weekday, startTime, durationMinutes}] (β€14), label: string|null}. - The slots come from series active today or later, deduplicated and ordered by weekday, then time. -labelisclasses.schedule_label. -roomfollows the decisions section. -
scheduleandroomappear onClass, on R5's admin class rows, and onMyClass. OnMyClassthey are the slots, label and room only: no dates, notes, counts or members. - The
/join/preview200 gainsschedule(ClassSchedule | null) androom(string | null). -additionalProperties: falsestays. - 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 injoin_preview_test.goandclassroom_limits_test.goinserver/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:
TestTheSuccessBodyHasExactlyThreeKeysinjoin_preview_test.gobecomesTestTheSuccessBodyHasExactlyFiveKeys, asserting exactlyclassId,className,teacherName,scheduleandroom. The PR states why the pin changes, citing the leak review above. -
openapi-contract.test.tspins the preview's properties to exactlyclassId,className,teacherName,scheduleandroom. 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
-
MyClasscarries no count
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]}]}. -startis normalised to its ISO week's Monday in the student's zone, and defaults to the current week. - AWeekItemis 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 nox-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
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'stodaygains{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.
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-permissionmatchesGET /teacher/calendar's, andteacher-nav.test.tspasses. 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 withteaching.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.tsformats 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.
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_SERIESopens 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.
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-permissionmatches 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 withnotMarkedfor 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-pressedand counts (DG-17) - a selection bar on--primarywhile 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" byrateScopefrom 640, Status, Note - rows of at least 56px: a checkbox, the avatar and name, the rate (danger ink under 80%), aradiogroup"Attendance" of Present, Late and Absent (each option at least 44 wide; choosing the active one clears it), and a Note button (message-square-textwith 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.
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_OVERLAPSshows 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.
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
/joinand 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.
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.tsturns 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/weekis 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.
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 matchATT_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/previewreturns 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/weekandMyClass. -
pnpm e2eandpnpm e2e:livepass.
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/calendarandfeatures/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.mdis complete for R8, and60-backend-architecture.mdliststimetable. -
40-open-items.mdgains this file's open items.gaps.mdnotes what was built under DG-92 and DG-93.
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-deckon 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.
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.
- Every task is ticked on
work/redesign-r8.developis 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/weekthrough 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 withreferencedBy(T-R8.2) - the/join/previewleak 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 tojoin_preview_test.gois 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 todevelop, and tagv0.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
CurrentTermare ready for the gradebook and reports.
-
O-R8.1 β Excused absences. DG-92 decided present, late and absent, plus unmarked. An
excusedstatus 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.