2026-05-25 - Custom grade scales
Summary
New feature: school admins can configure how numeric grades are displayed - as letter grades (A-F), GPA points, or pass/fail bands. One scale per school is marked as the default; without a default, grades show as numeric scores (unchanged behavior).
Conversion happens at read time. The grades table still stores the numeric score as the source of truth; the active scale is looked up and applied to map score+max_score to a band on the way out.
Schema
Migration 20260525130000_grade_scales.sql:
public.grade_scale_typeenum (letter|gpa|pass_fail). Numeric display = no active scale.grading.grade_scale- one row per scale a school defines. Columns:school_id,name,scale_type,is_default, audit fields. A partial unique index(school_id) WHERE is_default = trueenforces at most one default per school.grading.grade_scale_band- the bands within a scale. Columns:label,min_percentage,max_percentage(bothnumeric(5,2), CHECK-constrained to0 <= min <= max <= 100), nullablegpa_points(numeric(4,2), only meaningful for GPA scales),is_passboolean,sort_order. Unique on(grade_scale_id, label).- RLS: scales and bands are readable by any user in the same school (teachers need to read them to render converted grades). Writes (
INSERT/UPDATE/DELETE) are admin-only and school-scoped.
Bands may not overlap. Gaps are intentionally allowed - a school can leave 0-39% unscored and the convert-on-read layer returns null for scores in the gap. Overlap validation is enforced in the backend service rather than the DB (clearer error messages).
Backend
New GradeScaleModule mounted at /grade-scales:
GET /grade-scales- list (default first, then alphabetical).GET /grade-scales/default- the active default with bands (cached atgrade-scale:default:<schoolId>for 24h).GET /grade-scales/:id- one scale with bands.POST /grade-scales- create scale + bands in one call. IfisDefault: true, atomically demotes the existing default first.PATCH /grade-scales/:id- update name and/or default flag.PUT /grade-scales/:id/bands- replace the whole band set.POST /grade-scales/:id/set-default- promote a scale to default (demotes any current default).DELETE /grade-scales/:id- cascades to bands.
Writes are gated by AdminGuard. Reads are open to any authenticated user; cross-school isolation is enforced via resolveSchoolId(userId) and a 404 on any out-of-school lookup.
Convert-on-read is wired into GradeService:
findByAssessmentandfindByTermAndSubjecteach fetch the school's default scale once per request viagradeScale.getDefault(userId)(cached).- Each grade in the response now carries
converted: { label, gpaPoints, isPass } | null.nullmeans either no default scale, nomax_score, or the score fell in a gap between bands.
Frontend
- New admin-only page at
/dashboard/grade-scales- list of scales with set-default / edit / delete actions, plus a "New scale" dialog. The bands editor seeds reasonable defaults per scale type (GPA seeds A=4.0..F=0.0, pass/fail seeds Pass>=50, letter seeds A=90..F<60) so admins don't start from a blank slate. - Scale type is locked after creation (UI dims the select with an explanatory note).
- Bands editor enforces overlap validation client-side before submitting.
- Added an "Admin" group to the sidebar (only rendered when
profile?.role === "admin") with the Grade Scales entry. - Grade entry tables (
/dashboard/classes/[classId]/grading) now show a small color-coded badge next to each score (B+, "Pass", etc.). The badge variant isdefault(green) for pass bands,destructive(red) for fail bands; GPA points appear as a tooltip when set. With no default scale or a score in a gap, no badge renders - the numeric score stands alone, unchanged from before.
Notes
- Calculation views (term/year averages on
/dashboard/classes/[classId]) do not currently apply scale conversion. Averages are computed without a single sourcemax_score, so converting them is a separate design decision (convert each grade then average vs. average then convert) - punted until needed. - "Set default" is implemented as a sequential demote-then-promote, not a transaction. If two admins click simultaneously, the partial unique index returns
23505and the loser gets a 409. Acceptable for v1. - The
convertScorehelper onGradeScaleServiceis pure (no I/O); export it from other modules if you ever need scale conversion outside the grade read path.