From ec80278a500c4a64af8670dd114400bb84b0c083 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:50:30 +0000 Subject: [PATCH] fix(grades): allow large grade scales and decouple bonus from the final grade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise the grade-scale row limit from 30 to 200 so fine-grained percent→grade matrices (e.g. 32 rows) can be saved. The jsonb column has no limit; the cap was purely an over-strict validation that rejected the save with an error. - Compute a module's final grade purely from the achieved percentage via the grade scale. A configured assignment bonus is still computed and shown for information, but no longer shifts the final grade (neither percent points nor grade steps). This keeps the grade the user derives from percentage + matrix stable, and makes the ECTS-weighted dashboard average match a manual calculation. - Drop the now-misleading inline "bonus applied" label next to the percentage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LWLUNyHYuCpouMEUo3P456 --- src/app/[locale]/(app)/studies/actions.ts | 4 ++- .../learn/module-assessment-card.tsx | 2 -- src/lib/grades.test.ts | 36 ++++++++++++++----- src/lib/grades.ts | 16 +++++---- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/app/[locale]/(app)/studies/actions.ts b/src/app/[locale]/(app)/studies/actions.ts index 68c94fb..e510407 100644 --- a/src/app/[locale]/(app)/studies/actions.ts +++ b/src/app/[locale]/(app)/studies/actions.ts @@ -24,7 +24,9 @@ const gradeScaleSchema = z grade: z.number().min(1).max(6), }) ) - .max(30) + // A percent→grade table can be fine-grained (e.g. one row per percentage + // point), so allow a generous number of rows. + .max(200) const programSchema = z.object({ name: z.string().min(1).max(200), diff --git a/src/components/learn/module-assessment-card.tsx b/src/components/learn/module-assessment-card.tsx index 162d08d..7d279c4 100644 --- a/src/components/learn/module-assessment-card.tsx +++ b/src/components/learn/module-assessment-card.tsx @@ -184,8 +184,6 @@ export function ModuleAssessmentCard({ {!passFail && final.percent != null && (

{Math.round(final.percent)} % - {final.bonus && final.bonus.percentPoints > 0 && - ` · ${t("bonusApplied", { points: final.bonus.percentPoints })}`}

)} diff --git a/src/lib/grades.test.ts b/src/lib/grades.test.ts index 73e3ed9..08bae36 100644 --- a/src/lib/grades.test.ts +++ b/src/lib/grades.test.ts @@ -152,7 +152,7 @@ describe("effectiveBonus", () => { }) describe("moduleFinalGrade", () => { - it("applies a percent-point bonus before mapping to a grade", () => { + it("derives the grade from the percentage only — a percent-point bonus does not raise it", () => { const r = moduleFinalGrade({ module: { passFail: false, @@ -165,13 +165,14 @@ describe("moduleFinalGrade", () => { assignments: [{ kind: "graded", status: "graded", percent: 100 }], scale: null, }) - // 78 + 5 = 83 → 2.0 on the default scale (≥80) - expect(r.percent).toBe(83) - expect(r.grade).toBe(2.0) + // 78 % → 2.3 (≥75); the +5 bonus is reported but not applied to the grade. + expect(r.percent).toBe(78) + expect(r.grade).toBe(2.3) expect(r.source).toBe("assessment") + expect(r.bonus?.percentPoints).toBe(5) }) - it("applies grade steps and clamps at 1.0", () => { + it("does not apply grade steps to the final grade", () => { const r = moduleFinalGrade({ module: { passFail: false, @@ -180,12 +181,31 @@ describe("moduleFinalGrade", () => { bonusMinAvgPercent: null, bonusMinCompletedShare: null, }, - attempts: [{ attempt: 1, resultPercent: "96", passed: true }], + attempts: [{ attempt: 1, resultPercent: "82", passed: true }], + assignments: [{ kind: "graded", status: "graded", percent: 100 }], + scale: null, + }) + // 82 % → 2.0; without decoupling the 0.3 step would have yielded 1.7. + expect(r.grade).toBe(2.0) + expect(r.bonus?.gradeSteps).toBe(0.3) + }) + + it("yields the same final grade with or without a configured bonus", () => { + const attempts = [{ attempt: 1, resultPercent: "78", passed: true }] + const withBonus = moduleFinalGrade({ + module: { + passFail: false, + bonusType: "percent_points", + bonusValue: "5", + bonusMinAvgPercent: null, + bonusMinCompletedShare: null, + }, + attempts, assignments: [{ kind: "graded", status: "graded", percent: 100 }], scale: null, }) - // 96 → 1.0, minus 0.3 step clamps back to 1.0 - expect(r.grade).toBe(1.0) + const withoutBonus = moduleFinalGrade({ module: noBonus, attempts, assignments: [], scale: null }) + expect(withBonus.grade).toBe(withoutBonus.grade) }) it("uses the latest attempt", () => { diff --git a/src/lib/grades.ts b/src/lib/grades.ts index 6acb0a1..fd7d53e 100644 --- a/src/lib/grades.ts +++ b/src/lib/grades.ts @@ -174,9 +174,10 @@ export type FinalGrade = { } /** - * Computes a module's final grade from its latest assessment attempt plus the - * assignment bonus, falling back to legacy free-form grades when no attempt - * exists. Pass/fail modules yield only a passed flag. + * Computes a module's final grade from its latest assessment attempt (achieved + * percentage → grade scale), falling back to legacy free-form grades when no + * attempt exists. The assignment bonus is computed and returned for display but + * does not alter the final grade. Pass/fail modules yield only a passed flag. */ export function moduleFinalGrade(input: FinalGradeInput): FinalGrade { const { module, attempts, assignments, scale, legacyGrades } = input @@ -198,12 +199,13 @@ export function moduleFinalGrade(input: FinalGradeInput): FinalGrade { const basePercent = num(latest.resultPercent) if (basePercent != null) { - const boostedPercent = Math.min(100, basePercent + bonus.percentPoints) - let grade = percentToGrade(scale, boostedPercent) - grade = Math.max(BEST_GRADE, grade - bonus.gradeSteps) + // The final grade is derived purely from the achieved percentage via the + // grade scale. A configured assignment bonus is reported for information + // only (see `bonus` below) and never shifts the final grade. + const grade = percentToGrade(scale, basePercent) return { grade, - percent: boostedPercent, + percent: basePercent, passed: grade <= PASS_THRESHOLD, attempt: latest.attempt, source: "assessment",