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
4 changes: 3 additions & 1 deletion src/app/[locale]/(app)/studies/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 0 additions & 2 deletions src/components/learn/module-assessment-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,6 @@ export function ModuleAssessmentCard({
{!passFail && final.percent != null && (
<p className="text-muted-foreground text-xs tabular-nums">
{Math.round(final.percent)} %
{final.bonus && final.bonus.percentPoints > 0 &&
` · ${t("bonusApplied", { points: final.bonus.percentPoints })}`}
</p>
)}
</div>
Expand Down
36 changes: 28 additions & 8 deletions src/lib/grades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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", () => {
Expand Down
16 changes: 9 additions & 7 deletions src/lib/grades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
Loading