From e0cd6a0182011cf43036676d29a022eb836b9c58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 20:16:50 +0000 Subject: [PATCH] fix(grades): make the target-grade check rounding-consistent The "what do I need?" simulator compared the required remaining average to the raw target, while every grade in the app is displayed rounded to one decimal. As a result a target whose best achievable final rounds to it (e.g. a best case of 1.504, shown everywhere as "1.5") was reported as unreachable, even though the dashboard would display the achieved grade as exactly the target. Extract the logic into a tested pure function `requiredGradeForGoal` and round the required grade to one decimal before classifying it, so the simulator stays consistent with the rest of the app (the borderline case now reads "you need a 1.0" instead of "unreachable"). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LWLUNyHYuCpouMEUo3P456 --- src/components/learn/grade-goal.tsx | 13 +++--------- src/lib/grades.test.ts | 26 +++++++++++++++++++++++ src/lib/grades.ts | 33 +++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/components/learn/grade-goal.tsx b/src/components/learn/grade-goal.tsx index 8ca0aff..d5a1c40 100644 --- a/src/components/learn/grade-goal.tsx +++ b/src/components/learn/grade-goal.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Target } from "lucide-react" import { useTranslations } from "next-intl" import { updateGradeGoal } from "@/app/[locale]/(app)/studies/actions" +import { requiredGradeForGoal } from "@/lib/grades" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -50,15 +51,7 @@ export function GradeGoal({ const parsed = Number(target.replace(",", ".")) const valid = Number.isFinite(parsed) && parsed >= 1 && parsed <= 4 - let result: { kind: "needed"; value: number } | { kind: "safe" } | { kind: "unreachable" } | null = - null - if (valid && remainingEcts > 0) { - const required = - ((parsed * targetEcts - (average ?? 0) * gradedEcts) / remainingEcts) || 0 - if (required < 1) result = { kind: "unreachable" } - else if (required >= 4) result = { kind: "safe" } - else result = { kind: "needed", value: required } - } + const result = valid ? requiredGradeForGoal(parsed, average, gradedEcts, targetEcts) : null if (remainingEcts <= 0) return null @@ -80,7 +73,7 @@ export function GradeGoal({ ? t("invalid") : result?.kind === "needed" ? t("needed", { - grade: result.value.toLocaleString("de-DE", { + grade: result.grade.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1, }), diff --git a/src/lib/grades.test.ts b/src/lib/grades.test.ts index 08bae36..b579430 100644 --- a/src/lib/grades.test.ts +++ b/src/lib/grades.test.ts @@ -8,6 +8,7 @@ import { percentToGrade, programAverage, programAverageFromFinals, + requiredGradeForGoal, type BonusAssignment, } from "./grades" @@ -262,6 +263,31 @@ describe("programAverageFromFinals", () => { }) }) +describe("requiredGradeForGoal", () => { + // 60 ECTS graded at a 2.008 average, 60 ECTS remaining, 120 ECTS total. + const avg = 2.0083333 + it("rounds the required grade to one decimal (consistent with the display)", () => { + // Reaching 1.5 needs 0.99 on the remainder → rounds to 1.0 (best achievable + // final is 1.504, shown as 1,5), so it is 'needed 1.0', not 'unreachable'. + expect(requiredGradeForGoal(1.5, avg, 60, 120)).toEqual({ kind: "needed", grade: 1.0 }) + // Reaching 1.6 needs 1.19 → rounds to 1.2. + expect(requiredGradeForGoal(1.6, avg, 60, 120)).toEqual({ kind: "needed", grade: 1.2 }) + }) + + it("flags a target as unreachable when it needs better than 1.0", () => { + // target 1.3 with a 2.5 average → needs 0.1 on the remainder. + expect(requiredGradeForGoal(1.3, 2.5, 60, 120)).toEqual({ kind: "unreachable" }) + }) + + it("flags a target as safe when even a 4.0 keeps it", () => { + expect(requiredGradeForGoal(3.0, 2.0, 60, 120)).toEqual({ kind: "safe" }) + }) + + it("returns null when no ECTS remain", () => { + expect(requiredGradeForGoal(2.0, 2.0, 120, 120)).toBeNull() + }) +}) + describe("DEFAULT_GERMAN_SCALE", () => { it("is sorted descending and spans 50-95", () => { expect(DEFAULT_GERMAN_SCALE[0]).toEqual({ minPercent: 95, grade: 1.0 }) diff --git a/src/lib/grades.ts b/src/lib/grades.ts index fd7d53e..02d0393 100644 --- a/src/lib/grades.ts +++ b/src/lib/grades.ts @@ -255,3 +255,36 @@ export function programAverageFromFinals( } return totalEcts === 0 ? null : weightedSum / totalEcts } + +export type GradeGoalResult = + | { kind: "needed"; grade: number } + | { kind: "safe" } + | { kind: "unreachable" } + | null + +/** + * "What do I need?" simulator: the ECTS-weighted average grade required on the + * remaining (ungraded) modules to reach `target` as the final program grade. + * + * The required grade is rounded to one decimal to match how grades are shown + * everywhere else — otherwise a target whose best achievable final rounds to it + * (e.g. a best case of 1.504, displayed as "1.5") is wrongly flagged as + * unreachable. Returns null when there are no remaining ECTS or inputs are + * unusable. `safe` = even the worst pass (4.0) keeps the target; `unreachable` + * = would need better than the best grade (1.0). + */ +export function requiredGradeForGoal( + target: number, + average: number | null, + gradedEcts: number, + targetEcts: number +): GradeGoalResult { + const remainingEcts = targetEcts - gradedEcts + if (!(remainingEcts > 0)) return null + const raw = (target * targetEcts - (average ?? 0) * gradedEcts) / remainingEcts + if (!Number.isFinite(raw)) return null + const required = Math.round(raw * 10) / 10 + if (required < BEST_GRADE) return { kind: "unreachable" } + if (required >= PASS_THRESHOLD) return { kind: "safe" } + return { kind: "needed", grade: required } +}