Plan 79 R9 - SekiroKenjii/Quizzivy GitHub Wiki
The release that turns results into grades. A teacher gets the deck's Gradebook (a class's weighted grades for a term, with CSV and Excel export) and Reports (four KPIs with deltas, a weekly average, skills and a watch list). A student gets Grades. The wizard's Schedule step gains the assignment's gradebook category and weight (DG-93). The query review Phase 5 planned (T-5.5) is re-homed here, because R9 adds the heaviest read queries in the product and they should be measured at volume before v1.0.
R9 runs after R8 (78-r8.md: terms, sessions and attendance), because "this term" and the
Attendance KPI read R8's tables (70 §2 rule 7). It also reads R5's activity tracking
(75-r5.md) for "Active students" and "Has never signed in", R4's frozen question skill
(74-r4.md T-R4.15) for skills, R4's review options (T-R4.11) for what a student may see, and
R7's Messages (77-r7.md) for the watch list's Message button. Screens are compared with
docs/design/deck/Quizzivy Teacher.dc.html (r.gradebook, r.reports) and
Quizzivy Student.dc.html (grades). Branch work/redesign-r9 is cut from develop.
Deliverable: the assignment's gradebook category and weight, set in the wizard and on the
assignment's Settings tab; the Gradebook per class and term, with bands, the legend, a pager and
a formula-safe CSV/Excel export; Reports whose class and range selectors drive the data (DG-70);
the student's Grades page and nav tab; a volume seed, EXPLAIN captures and p95 budgets for
every heavy read.
Exit criteria:
- Gradebook, Reports and Grades match the deck at 360, 768, 1024, 1280 and 1440, light and dark, with data shaped like the deck's fixtures (T-R9.13).
- R2's isolation suite (T-R2.16) covers every new operation, each query
classIdandtermIddeclared per parameter: its two-teacher pair shows another teacher'sclassIdanswering 404 on the gradebook, its export and Reports, and its two-student pair does the same on/app/grades. A student sees only their own results, and never a score the review policy withholds. - Every export cell that starts with
=,+,-,@, tab or CR is escaped, in CSV and XLSX. - The volume seed exists,
EXPLAIN (ANALYZE, BUFFERS)for every query T-R9.11 covers is committed underdocs/perf/, and the p95 budgets in T-R9.11 pass at volume. - The five canaries pass untouched; every CI step of 70 §8 is green locally.
- Released to production as
v0.16.0.
Schema changes (numbers assigned at merge):
| File | Task | Notes |
|---|---|---|
NNNNN_add_assignments_gradebook_fields.sql |
T-R9.1 | Constant defaults: no rewrite, no fill trigger |
NNNNN_index_<name>.sql, only if T-R9.11 finds one |
T-R9.11 |
-- +goose NO TRANSACTION, CREATE INDEX CONCURRENTLY
|
Order of work. T-R9.1 first (the contract change every other task reads). The read models (T-R9.2, T-R9.4, T-R9.5) and the volume seed (T-R9.10) then run in parallel, at most four agents at a time. The export (T-R9.3) follows T-R9.2. Each screen waits for its read model. The query review (T-R9.11) runs once the read models exist, so it measures the real SQL. Docs and the browser check close the release.
Decisions taken in this file, worth a second look.
-
The per-assignment weight is the source of truth; category percentages are derived. The
deck's header reads "tests 60%, homework 40%", and its columns carry 10, 10, 20, 25, 15 and 20,
which sum to exactly those totals. Storing both would let them disagree. An assignment stores
gradebook_category(test or homework) andgradebook_weight(0–100). The header shows each category's share of the term's total weight. A weight of 0 keeps a column visible and out of the Final. -
Existing assignments become "Test, weight 10" through constant column defaults. Equal
weights reproduce a plain average, which is the least surprising grade for work assigned before
weights existed. A constant default is written into the catalogue without a table rewrite, and
the R8 binary's inserts receive it during the rolling deploy, so no fill trigger is needed (70
§3). An update that omits
gradebookkeeps the stored values, so an R8 tab cannot reset a weight. - A cell is the student's best graded attempt. "The best score counts" is the wizard's promise for multi-attempt assignments (R4), and R4's class average uses the same rule. A cell is Pending only when nothing is graded yet and an attempt awaits grading. A graded 70% beside a second attempt still waiting shows 70%, with the waiting attempt in the tooltip, because a known number is more useful to the Final than a hole. Voided attempts never count. An attempt still in progress counts as not submitted.
- Missing and pending cells are left out of the Final, as the deck states. The deck computes the Final as the weighted mean over numeric cells only, renormalised. A student who skipped a closed test is therefore not pulled down by it. That rule is the deck's, and the Reports watch list ("Missed 2 assignments") is where a skipped test surfaces. Whether a missing cell should count as zero once the window has closed is O-R9.1.
-
A column belongs to the term that contains its assignment's
closes_at. Terms come from R8. The gradebook opens on the current term (the one containing today, else the most recent past term). With no terms defined it shows every assignment, and the header says "All terms". Weights are renormalised per term, so a term's Final never depends on another term's work. - Gradebook rows are the class's current, active members; columns are the assignments that target the class. An assignment given to individual students is not a class column, and a draft is not a column at all. A student removed from the class leaves its gradebook, and their results stay on the assignment detail, as R4's "Remove from class" promises. Disabled students leave every denominator (NFR-R07).
-
Reports' definitions (all in the teacher's time zone, over the selected classes):
- Average score: the mean percentage of counted attempts (best graded per student and assignment) submitted in the range. The delta is in points against the previous period of the same length.
- Completion rate: of the (student, assignment) pairs whose effective close (R4's per-student overrides included) falls in the range, the share handed in before that close.
- Active students: active students with an activity day (R5) in the last seven days, over all active students in the classes. It ignores the range selector, because the deck's hint is "Signed in this week" and activity is a current state, not a history.
- Attendance: R8's roster rate (present and late over marked), over the sessions in range, with the session count as the hint. The KPI shows "—" when no attendance was taken.
- This term compares with the previous term, and hides the delta when there is none.
- The watch list shows one reason per student, the most urgent first, capped at ten rows: average dropped by 10 points or more (at least two counted attempts in each period), then two or more flagged attempts in the range (D8), then two or more missed assignments in the range, then "Has never signed in". The deck draws one chip per row; a student who meets several reasons is listed under the first.
- Reports keeps the deck's range options: 4 weeks, 8 weeks, 12 weeks, This term. DG-70's interim "7 days, 30 days, this term" was written against the dashboard, which R4 built as 7/14/30 days. Both selectors now drive their queries, as DG-70 asks.
-
The student's Grades page never receives a score the review policy withholds. A row shows
the score only when the student's result page would (R4's
review_show_scoreandreview_release). The Average and skill tiles use those rows only. An average that included withheld scores would leak them by subtraction. A row whose score is withheld holds the latest handed-in attempt, not the best graded one, because picking the best would reveal which attempt scored higher. -
The gradebook export runs in the API, bounded, not in the worker. 70 moves unbounded jobs
(R5's data export, user import with an argon2 hash per row) to the worker process group. A
gradebook export is one teacher's classes for one term: a few dozen columns by a few hundred
rows, with no hashing. It streams CSV through
encoding/csv, and XLSX through excelize'sStreamWriter, which flushes rows to a temp file instead of holding the sheet in memory. Hard caps (40 classes, 200 columns per class, 20,000 cells per class) answer422 EXPORT_TOO_LARGEwith the per-class export as the way out. - "All classes" as CSV is a ZIP with one CSV per class; as Excel it is one sheet per class. Classes have different columns, so a single CSV would need either a long format no teacher expects or a union of columns that is mostly empty.
-
One
gradebookmodule holds the three read models (gradebook, reports, student grades). All three read the same tables with the same "counted attempt" rule, and a second module would have to duplicate it. Likedashboard, the module reads other modules' tables and writes nothing. Weights are written by the assignments module. - Nothing on these screens polls. Each refetches on focus and after a weight change. A gradebook left open on a teacher's second monitor must not keep Neon awake (PR-7).
Depends on: —
Touches: migrations/NNNNN_add_assignments_gradebook_fields.sql (new), api/openapi.yaml (AssignmentInput, Assignment, new GradebookSettings), server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/assignments/{domain/assignment.go,domain/commands.go,application/command/update.go,repositories/assignments.go,repositories/write.go,application/command/duplicate.go}, server/internal/platform/db/tests/{constraints,migrate}_test.go, docs/plan/20-data-model.md
Size: S
Done when:
-
add_assignments_gradebook_fieldsaddsgradebook_category text NOT NULL DEFAULT 'test'andgradebook_weight smallint NOT NULL DEFAULT 10, thenCHECK (gradebook_category IN ('test','homework'))andCHECK (gradebook_weight BETWEEN 0 AND 100), each addedNOT VALIDand validated in the same file. Down drops both columns. - Contract:
GradebookSettings {category: test|homework, weight: 0..100}, required onAssignmentand optional onAssignmentInput(defaults Test and 10 on create).make genruns, and its output is a separate commit. - An update without
gradebookkeeps the stored values. The fields are editable while the assignment is live, because they are not in R4's "Test & timing" lock (DG-65). - Duplicate (R4 T-R4.13) copies the category and weight. The data-modifying CTE that audits assignment updates includes both fields in the diff.
- R5's data export includes the two columns, in its explicit column list.
- Tests:
-
constraints_test.go: out-of-range weight and unknown category are refused. - An integration case inserts an assignment with the R8 column list and gets Test, 10: the old-binary insert test (70 §3). - Update: absentgradebookkeeps the values; a live assignment accepts the change. - goose up/down/up. -
20-data-model.mdrecords the columns and their reason.
Depends on: T-R9.1
Touches: server/internal/modules/gradebook/ (new: domain/{gradebook,final,repository}.go, application/{app.go,query/gradebook.go}, repositories/gradebook.go, http/gradebook.go), server/internal/core/wiring/gradebook.go (new), server/internal/core/router/{modules.go,server.go}, api/openapi.yaml (tag teacher-gradebook), generated code
Size: L
Done when:
- The package comment names the model: read models over results, never written through.
-
GET /teacher/gradebook?classId=&termId=&page=&limit=(x-permission: teaching.grading) returns: -classes: [{id, name}], the tabs: the caller's classes under R2's class scope, archived classes excluded (an archivedclassIdstill answers, read-only) -class,term: {id, name, startsOn, endsOn} | nullandterms: [{id, name}]-categoryShares: {test, homework}as percentages of the term's total weight -columns: [{assignmentId, title, category, weight, closesAt}], ordered byclosesAt-rows: [{student: {id, fullName}, cells: [...], final: number | null}], where a cell is{assignmentId, state: graded|pending|missing, percent, score, max, attemptId, submittedAt, awaitingGrading}, with the numeric fields null unless graded -page,limit(default 25, maximum 100, per O-20) andtotal - Without
classId, the first class tab is used. WithouttermId, the current term is used, else the latest past term, else all assignments. -
domain/final.goholds the one rule: the weighted mean of graded cells, renormalised over their weights, rounded to one decimal; null when no graded cell has a weight above 0. Bands are a web concern. - The counted attempt is chosen in SQL: the maximum of
score_earned / score_totalover attempts withgraded_atset and status notvoided, per student and assignment. Pending means asubmittedortimed_outattempt withoutgraded_at. The query never orders byfinal_score(AGENTS: a virtual column). - The query count is fixed: one statement for columns, one for the page of rows and cells, and one for the total, whatever the class size (a pgx tracer counts them in the test).
- Another teacher's
classIdanswers 404. The operation joins R2's two-teacher isolation suite withx-resource: [{in: query, name: classId, kind: class}, {in: query, name: termId, kind: none}](T-R2.16's per-parameter form; terms are organisation-wide, not owned). - Tests:
- unit:
final_test.gotable (renormalisation, weight 0, all pending, all missing, one decimal) - integration: best-of-attempts, voided ignored, pending beside graded, term boundaries, no terms, archived class, a removed member absent, a disabled member absent - contract: the response validates against the schema
Depends on: T-R9.2, R5's platform/tabular XLSX support
Touches: server/internal/modules/gradebook/{application/query/export.go,http/export.go} (new), server/internal/platform/tabular/{zip.go (new),xlsx.go}, server/internal/platform/tabular/tests/, server/internal/core/router/ratelimits.go, api/openapi.yaml, generated code
Size: M
Done when:
-
GET /teacher/gradebook/export?format=csv|xlsx&scope=class|all&classId=&termId=&comments=(x-permission: teaching.grading). It streams withContent-Disposition: attachment: -format=csv, class:text/csv; charset=utf-8with a BOM -format=csv, all:application/zipholding one CSV per class -format=xlsx:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, one sheet per class - Columns: Student, Email, then per assessment "{title} ({category} · {weight}%)", then
Final. With
comments=true, each assessment gains a "… · comment" column holding the counted attempt'steacher_note. The export is teacher-only, and the note is the teacher's own. - Cell values follow the screen: a graded percentage, "Pending", or empty. In XLSX, percentages are numeric cells and never strings.
- Every text cell goes through
tabular.EscapeCell(R4): student names, emails, titles, comments and sheet names. Sheet names are also cut to 31 characters, stripped of[]:*?/\, and deduplicated with a suffix. - Caps: at most 40 classes, 200 columns per class and 20,000 cells per class. Otherwise
422 EXPORT_TOO_LARGE, whose message points to the per-class export. The XLSX writer is excelize'sStreamWriter. The filename isgradebook-{class or all}-{term}.{ext}, ASCII-folded. -
RateLimits()gainsGET /teacher/gradebook/exportat 10 a minute per IP. It is a heavy read, not a credential, and the limit bounds a stuck retry loop. -
x-resource: [{in: query, name: classId, kind: class}, {in: query, name: termId, kind: none}](T-R2.16's per-parameter form), so the two-teacher suite passes A'sclassIdwithscope=classon its own. - Tests:
-
tabularunit: formula escape (=HYPERLINK(…),+1,-2+3,@SUM(A1), tab, CR), sheet-name sanitising, ZIP layout - integration: both formats and both scopes,commentson and off, and the caps - the XLSX output opened with excelize in the test: typed numeric cells, one sheet per class - isolation: another teacher's class answers 404
Depends on: T-R9.2 (shared counted-attempt SQL), R5 activity, R8 terms and attendance
Touches: server/internal/modules/gradebook/{domain/reports.go,application/query/reports.go,repositories/reports.go,http/reports.go} (new), api/openapi.yaml (tag teacher-reports), generated code
Size: L
Done when:
-
GET /teacher/reports?classId=&range=4w|8w|12w|term(x-permission: people.students.read). WithoutclassIdit covers all the caller's classes.rangedefaults to8w;termanswers422 NO_CURRENT_TERMwhen R8 has no term containing today. It returns: -range: {from, to}andprevious: {from, to} | null-kpis: {averageScore: {value, delta}, completionRate: {value, delta}, activeStudents: {active, total, delta}, attendance: {rate, sessions, delta} | null}, each with the definitions in this file's decisions -weekly: [{isoWeek, from, average | null}], one entry per ISO week of the range in the teacher's zone (4, 8 or 12, or the term's weeks up to today) -skills: [{skill, percent, answers}]over R4's frozenskillof the counted attempts' answers (earned over possible), skills without answers omitted -watchList: [{student: {id, fullName}, class: {id, name}, reason: average_dropped|flagged_attempts|missed_assignments|never_signed_in, value}], capped at ten, reasons and priority as decided above - Every figure is computed in SQL over the caller's class scope. The whole report is at most six statements (tracer-counted). Nothing is cached, and nothing polls.
-
x-resource: [{in: query, name: classId, kind: class}](T-R2.16's per-parameter form). The operation joins R2's two-teacher isolation suite. - Disabled students leave every numerator and denominator. Students removed from a class
count only while they were members: attempts carry the assignment, and targets are read
from
assignment_classesand current membership. - Tests:
- integration on a fixture with two classes and two periods: each KPI and its delta, the
weekly buckets at a week boundary in
Asia/Ho_Chi_Minh, skills, each watch-list reason and its priority, the cap,termwithout a term - isolation: another teacher's class answers 404, and "all classes" never includes it
Depends on: T-R9.2, R4 T-R4.11 (review options)
Touches: server/internal/modules/gradebook/{domain/grades.go,application/query/student_grades.go,repositories/grades.go,http/grades.go} (new), api/openapi.yaml (tag student-grades), generated code, web/tests/e2e/payload-leak.live.spec.ts
Size: M
Done when:
-
GET /app/grades?classId=&before=&limit=(x-permission: learning.take_tests) returns: -classes: [{id, name}], the student's classes that have results -average: number | nullandskills: [{skill, percent}]-items: [{assignmentId, attemptId, title, class: {id, name} | null, submittedAt, display: score|pending|submitted, score, max, percent, band}], wherebandis R6's essay band when the result shows one -nextBefore, for "Load more" - One item per assignment. When the review policy shows the score now, the item holds the
best graded attempt, else the latest handed in. When
displayis notscore, it holds the latest handed-in attempt, never the best graded, so theattemptIdchosen cannot reveal which attempt scored higher. -
display = scoreonly when the student's result page would show the score now (R4'sreview_show_score, andreview_release = after_closeonly after the effective close). Otherwise it ispendingwhile a manual answer waits, elsesubmitted. Whendisplayis notscore,score,max,percentandbandare absent from the JSON, not null. -
x-resource: [{in: query, name: classId, kind: class}](T-R2.16's per-parameter form). The operation joins R2's two-student pair on/app/*: aclassIdthe student is not a member of answers 404, as a missing class would. -
averageandskillsuse only items whosedisplayisscore. - Separate Go types for this projection (
StudentGradeItem); nothing is shared with the teacher's cell type, so a new teacher field cannot reach a student by accident. - The payload-leak walk (E2E 9) covers
/app/gradeswith its key list unchanged. The PR carries the leak review. - Tests:
- integration: a withheld score is absent, and does not move the average; two graded
attempts with
review_show_scoreoff, the lower one latest, give the latest'sattemptId;after_closebefore and after close; a voided attempt is ignored; another student's results are never present;classIdfiltering - isolation: in the two-student pair, student B's request with A'sclassIdanswers 404 - contract: the schema declares no forbidden key
Depends on: T-R9.1, R4 T-R4.27b, T-R4.26
Touches: web/src/features/assignments/components/{ScheduleStep,SettingsGroups}.tsx, web/src/features/assignments/{input.ts,studentRules.ts}, web/src/features/assignments/pages/teacher/AssignmentWizardPage.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/assignments/
Reuses: the segmented control, NumberStepper, FormDialog
Size: S
Done when:
- The Schedule step (DG-93) gains a "Gradebook" row: - "Counts as": Test | Homework (default Test) - "Weight": 0–100 in steps of 5 (default 10), with the hint "Share of the final grade in its term. 0 keeps it out of the final grade."
- The wizard's aside gains the summary line "Gradebook: Test · 10%". "Save draft" persists both (DG-65).
- The assignment's Settings tab gains a fifth card, "Gradebook", editable while live, through
the same
FormDialogand the same toast. - The zod input carries both fields, with the
Expect<Equal<…>>assertion against the generated request type. - States follow the wizard's. Keys are vi first, en worded like the deck's labels, and listed in the PR.
- Tests: defaults, stepper bounds, a live assignment's card is editable, the summary line. Compared with the deck's Schedule step at the five widths, light and dark (the row is extrapolated from deck parts and listed for DG-93 sign-off).
Depends on: T-R9.2, T-R9.3, T-R9.6
Touches: web/src/features/gradebook/ (new: pages/teacher/GradebookPage.tsx, components/{GradebookTable,GradeCell,GradeLegend,ExportGradebookDialog,TermMenu}.tsx, api.ts, bands.ts), web/src/app/router.tsx, web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, web/src/features/students/pages/teacher/StudentsPage.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/gradebook/ (new), web/tests/units/contract/teacher-nav.test.ts
Reuses: DataTable sticky column, Pager, Tooltip, the segmented control, FormDialog, Avatar (DG-22 initials)
Size: L
Done when:
- Route
/teacher/gradebook?class=&term=&page=, lazy, underTeacherLayout, crumb "Gradebook". The sidebar gains the Insights group with Gradebook (table), carryingteaching.grading.teacher-nav.test.tspasses. The palette gains "Gradebook" (the deck's palette list). - Header: "Gradebook", and the sub-line "{term} · final grade weights tests {t}%, homework {h}%." The term name is a menu button (the Reports header's dropdown pattern) listing the terms and "All terms". It is listed for DG-93 sign-off. "Export" is a secondary button.
- Class tabs as the deck's segmented control. Above five classes, or below 768, they become a select.
- Table (min-width 860, horizontal scroll):
- a sticky Student column (28px avatar, name)
- one column per assessment: the name, and the muted "{Test|Homework} · {w}%"
- a Final column (600 weight, left border)
- cells are 56×28 chips: ≥ 80 in success-soft/ink, < 60 in danger-soft/ink, otherwise plain;
Pending in warning; missing is "—" in a dashed ring
- each cell has a tooltip: "{score} / {max} · submitted {date}" plus "1 attempt waiting for
grading" when true, "Waiting for grading", or "Not submitted"
- a graded or pending cell opens the attempt on the assignment detail (R4's
?tab=students&attempt=) - The legend has the deck's four entries. Band colour is never the only signal: the value and the tooltip carry it.
- Clicking a column header opens the Gradebook
FormDialogof T-R9.6 for that assignment. Saving refetches. - The Export dialog: Format CSV | Excel; Classes "This class" | "All classes"; "Include
teacher comments" (off). It downloads through the API client with the bearer token, never a
URL with a token. A 422 shows its message, and the toast reads "Exported gradebook.csv"
(or
.xlsx). - Students (R4 T-R4.40): the "View results" row item opens
/teacher/gradebook?class={first class}&student={id}, which scrolls to and highlights that row. The item is hidden for a student without a class. - States (DG-83): a skeleton table; no classes ("Create a class to see grades." with New class); no assessments in the term ("Nothing assigned to this class in {term}." with Assign test); an error with retry.
- Unit tests: bands at 59.9, 60, 79.9 and 80; tooltips; Final rendering; the term menu;
navigation from a cell; the tabs becoming a select at
viewport("phone"); the export dialog's request. - Compared with
r.gradebookat the five widths, light and dark. Keys vi first and listed.
Depends on: T-R9.4
Touches: web/src/features/reports/ (new: pages/teacher/ReportsPage.tsx, components/{ReportKpis,WeeklyAverageChart,SkillBars,WatchList,ClassMenu,RangeMenu}.tsx, api.ts), web/src/app/router.tsx, web/src/layouts/teacherNav.ts, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/reports/ (new)
Reuses: KpiTile, BarChart, ProgressBar, Avatar, StatusBadge, R4's dropdown menu
Size: M
Done when:
- Route
/teacher/reports?class=&range=, lazy, crumb "Reports". The Insights group gains Reports (chart-column), carryingpeople.students.read. The palette does not gain it, as in the deck (O-R9.4). - Header: "Reports", with the sub-line "{class or All classes} · last {range}" (or "{class} · {term}"). The class menu lists All classes plus the caller's classes. The range menu offers 4 weeks, 8 weeks, 12 weeks and This term, with This term disabled and explained when there is no current term. Both write the URL, and the URL drives the query (DG-70).
- Four KPIs,
auto-fit minmax(210px,1fr): value, and delta withtrending-upin success-ink ortrending-downin danger-ink, plus the hint. The deltas are "+4 pts", "+2%", "−3" and "+1%" in the deck's formats, vi first. No arrow shows when the delta is null or zero. - "Average score by week": one bar per week with its value above. The bar height is scaled over the data's range rounded out to tens. The current week's bar is lime. An empty week has no bar and "—". On phones, every other label shows.
- "By skill": labelled 8px bars with the percentage, warning below 65, otherwise accent.
- "Students to check in with": avatar, name, class, a reason chip (icon and tone as the deck:
trending-down/danger,flag/danger per D8,circle-alert/warning,user-x/warning), and a "Message" button that opens R7's direct message with that student. The empty state is "No one to check in with right now." - States (DG-83): a skeleton per card; no classes; no results in the range ("No results in this range yet."); an error with retry per card.
- Unit tests: the URL round-trips, This term disabled, delta formatting and colours, the chip for each reason, the Message route.
- Compared with
r.reportsat the five widths, light and dark. Keys vi first and listed.
Depends on: T-R9.5
Touches: web/src/features/gradebook/{pages/student/GradesPage.tsx,components/{SkillTiles,GradeRow}.tsx} (new), web/src/app/router.tsx, web/src/app/modules.ts, web/src/layouts/StudentLayout.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/grades.test.tsx (new), web/tests/units/student/shell.test.tsx
Size: M
Done when:
-
modules.tsturns Grades on. The top nav and the bottom tab bar gain Grades (chart-column) between Learn's slot and Me, and Learn stays absent until R10 (R3's rule). The same flag cuts Home's Recent results to the deck's three (73-r3.md, T-R3.6 As built);home.test.tsxalready covers both sides. - Route
/app/grades?class=(DG-81), a root destination (nohandle.detail), max width 960. - "Grades" / "Every result, by class." Class tabs: All classes plus the student's classes, scrolling horizontally on phones.
- Tiles: Average, then one tile per skill with data, 4-up (2-up below 760).
- Rows: title; "{class} · {date}"; the score ("8 / 10", or R6's "6.0 band") with a mini bar at its percentage. "Being graded" (warning, hourglass) and "Submitted" have no bar. A row opens the result page. "Load more" pages.
- States (DG-83): skeleton; "No results yet. They appear here after you hand in a test."; error with retry.
- Unit tests: withheld rows show no score, tiles hide when empty, class filtering, the tab appears only with the module on.
- Compared with the deck's
gradesat the five widths, light and dark. Keys vi first and listed.
Depends on: T-R9.1
Touches: seed/volume/volume.sql (new), seed/README.md, Makefile (seed-volume, new), docs/plan/15-phase-5.md
Size: M
Done when:
- A volume seed that is not part of
make seed.make seed-volumerefuses unlessMIGRATE_DSN's host islocalhostor a CI service name, then loads the file withpsql -v ON_ERROR_STOP=1 "$(MIGRATE_DSN)", asmake seeddoes. - Shaped like a multi-teacher centre after about three years, with every table R1–R9 added:
- 1 admin, 7 teachers, 400 students and 24 classes
- 3 terms per year (R8)
- 600 bank questions with levels and skills (R4), 60 tests with 120 published versions
- 900 assignments, 70% Test and 30% Homework, with mixed weights
- about 24,000 attempts (one or two per student and assignment, 5% pending, 1% voided,
3% flagged), their answers, and about 1,000,000
attempt_events- 3,000 class sessions with attendance (R8) - a year of daily activity (R5), 50,000 notifications (R4) and 20,000 messages (R7) - Generated with
generate_seriesin one SQL file that runs in under five minutes on the development machine, with only thedbcompose service running. The row counts are asserted at the end, likeseed/99-assert.sql. -
15-phase-5.mdmarks T-5.5 as re-homed to R9.
Depends on: T-R9.2 … T-R9.5, T-R9.10
Touches: server/tests/perf_flow_test.go (new, build tag perf), server/tests/harness_test.go, docs/perf/explain-<date>.md (new), .github/workflows/perf.yml (new), docs/plan/50-nfr-registry.md, a migration only if an index is needed
Size: M
Done when:
-
perf_flow_test.godrives the real router against the volume database. It records each statement through a pgx tracer. With-explainit writesEXPLAIN (ANALYZE, BUFFERS)for every distinct statement todocs/perf/explain-<date>.md. - Covered: gradebook (one class page), the gradebook export (all classes), reports (all classes, 12 weeks), student grades, the teacher dashboard (R4), the monitor, the attempt payload, autosave, the integrity timeline, the grading queue, notifications (R4) and the calendar week (R8).
- p95 budgets over 50 runs each: monitor < 200 ms, attempt payload < 150 ms, timeline < 300 ms, autosave < 100 ms, dashboard < 200 ms (the NFR-E04 five, unchanged), gradebook page < 300 ms, reports < 400 ms, student grades < 200 ms, notifications < 100 ms, calendar week < 200 ms, and the all-classes export < 3 s.
- No N+1: each screen's statement count is asserted, and does not change between a class of 5 and a class of 40.
- Any index added goes in its own
-- +goose NO TRANSACTIONfile withCREATE INDEX CONCURRENTLY(these tables have rows).pg_stat_user_indexeson the volume database lists unused indexes, and each is justified in the capture or dropped in its own migration. -
.github/workflows/perf.ymlruns nightly (and on demand). It copies theci.ymlserver job's setup and environment, in order: a Postgres 18 service, psql 18 installed,./scripts/provision-db.sh, goose throughgo -C server run github.com/pressly/goose/v3/cmd/[email protected] … up(nogooseon the runner's PATH, so nevermake migrate), thenpsql "$TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f seed/volume/volume.sql, thengo test -tags perf ./tests/...inserver/. It never touches Neon. - NFR-E04 is updated with the new budgets and flips to ✅ when the job is green.
Depends on: the tasks whose behaviour each part describes
Touches: docs/quizzivy-spec-v0.3.md (§7, §8, §9, §13, §15; version bumped per §18), AGENTS.md, docs/plan/20-data-model.md, docs/plan/50-nfr-registry.md, docs/plan/40-open-items.md, docs/plan/70-redesign-overview.md, docs/design/gaps.md
Size: S
Done when:
- Spec: §7 the assignment's gradebook settings; §8 Gradebook and Reports with their rules (counted attempt, Final, bands, KPI definitions, watch-list reasons), "Availability: R9" flipped; §9 the student's Grades and the review-policy rule; §13 and §15 the columns and operations.
- AGENTS.md: the repository map gains
modules/gradebookandfeatures/{gradebook,reports}; the perf job andmake seed-volumeare named under Local development. -
20-data-model.mdis complete for R9.40-open-items.mdholds this file's open items as O-R entries. The R9 row in 70 §2 is marked shipped at release. -
gaps.mdgains an entry, if not already there, for each design question this file raises, so the design team receives it: the student Grades' combined "Grammar & vocab" tile (O-R9.3), and the palette's omission of Reports, Attendance and Shared with me (O-R9.4), cross-referenced to DG-36. - Every PR that changed behaviour earlier in the release already changed its own lines; this task checks the set.
Depends on: T-R9.6 … T-R9.9
Touches: seed/NN-dev-insights.sql (new, development only, numbered at merge)
Size: S
Done when:
- The development seed holds the deck's shapes: four classes, the six assessments with the deck's weights, pending and missing cells, a term, two periods of results for deltas, and one student per watch-list reason.
-
/teacher/gradebook,/teacher/reports,/app/grades, the wizard's Schedule step and the Settings card are compared withdesign-deckon 5175 at 360, 768, 1024, 1280 and 1440, light and dark. The dev server is confirmed fresh, and sizes are measured withgetBoundingClientRect. - Deviations are fixed in the owning task's branch or filed as issues. Vite and the API are stopped afterwards.
- Every task is ticked on
work/redesign-r9.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(afterdocker compose up -d db minio), 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: E2E 9 now walks/app/grades-node scripts/check-design-deck.mjs - The perf job is green on the release commit, and its capture is committed.
- The five canaries pass, and their files are untouched against
develop. - Security: the isolation suite covers the four new operations, the three
/teacher/*ones in its two-teacher pair and/app/gradesin its two-student pair, with every queryclassIdandtermIddeclared per parameter; E2E 9 passes with its key list unchanged; no public operation was added. - The migration (and any index file) is rehearsed on a Neon branch of production. The timings go in the release PR.
- Deploy and smoke:
- Thuong's go, then
release/0.16.0→main- deploy the API (the release command migrates), then Pages - smoke/healthz,/livez, and sign-in as each role - in the owner's test class, open the gradebook and export it as CSV and Excel; open it with Excel or LibreOffice and check that no cell evaluates - a student opens Grades - back-merge todevelop, and tagv0.16.0 - Release notes, vi first: - the Gradebook, Reports and Grades - how the Final is computed: best attempt, weighted, and what is left out - existing assignments count as Test with weight 10, so the Final starts as a plain average - where to set a category and weight - students see only scores their teacher has released
- T-R9.12's docs are in the release.
- O-R9.1 — Does a missing cell count as zero once the window has closed? The deck leaves missing and pending cells out of the Final, and R9 builds that. A teacher may expect a skipped test to pull the grade down. Default: the deck's rule; the watch list surfaces missed assignments.
-
O-R9.2 — Course test lessons in Insights. R10 delivers course tests as assignments and keeps
them out of the gradebook and Reports (
80-r10.md). Default: out, until a teacher asks. -
O-R9.3 — "Grammar & vocab" as one tile. The deck's student Grades draws a combined tile,
while Reports shows the skills separately. R9 shows skills separately everywhere. This is
proposed as a new DG entry, which T-R9.12 adds to
docs/design/gaps.md. -
O-R9.4 — The palette omits Reports. The deck's palette lists Gradebook but not Reports,
Attendance or Shared with me. R9 follows the deck; the question goes to the design team with
DG-36, through the
gaps.mdentry T-R9.12 adds. - DG-93. The Schedule step's Gradebook row, the Settings card and the gradebook's term menu are extrapolated from deck parts and need the design team's sign-off.
- DG-70. Reports keeps the deck's 4/8/12 weeks and This term, not the interim's "7 days, 30 days". The design team is asked to confirm.
- DG-83. The empty, loading and error states of Gradebook, Reports and Grades ship from deck primitives and are listed for sign-off.
Generated from docs/plan/79-r9.md. Edit that file in the repository; changes made on this page are overwritten.