Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions src/components/learn/grade-goal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand All @@ -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,
}),
Expand Down
26 changes: 26 additions & 0 deletions src/lib/grades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
percentToGrade,
programAverage,
programAverageFromFinals,
requiredGradeForGoal,
type BonusAssignment,
} from "./grades"

Expand Down Expand Up @@ -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 })
Expand Down
33 changes: 33 additions & 0 deletions src/lib/grades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Loading