Files
grademaxxing/app/utils/gradeCalculations.ts
2026-01-17 13:37:57 +01:00

432 lines
14 KiB
TypeScript

import type { Subject, Grade, GradingCategory } from "~/types/api";
import { germanGradeToPercentage, percentageToGermanGrade } from "./gradeSystems";
export interface CategoryAverage {
categoryName: string;
average: number; // Always stored as percentage (0-100) for calculations
weight: number;
color?: string;
}
export interface SubjectGradeData {
subject: Subject;
grades: Grade[];
categoryAverages: CategoryAverage[];
overallAverage: number; // Always stored as percentage (0-100) for calculations
}
/**
* Calculate the weighted average for a specific grading category
* @param grades - Array of grades in the category
* @param isGermanSystem - Whether the grades use the German 1-6 system (inverted scale)
*/
export function calculateCategoryAverage(grades: Grade[], isGermanSystem: boolean = false): number {
if (grades.length === 0) return 0;
const totalWeight = grades.reduce((sum, grade) => sum + grade.weight_in_category, 0);
if (totalWeight === 0) return 0;
const weightedSum = grades.reduce((sum, grade) => {
// Normalize grade to percentage
let percentage: number;
if (isGermanSystem && grade.max_grade === 6) {
// For German grades (1-6 scale), convert to percentage using the conversion function
percentage = germanGradeToPercentage(grade.grade);
} else {
// For standard percentage-based grades
percentage = (grade.grade / grade.max_grade) * 100;
}
return sum + percentage * grade.weight_in_category;
}, 0);
return weightedSum / totalWeight;
}
/**
* Calculate the overall average for a subject based on category weights
*/
export function calculateSubjectAverage(
subject: Subject,
grades: Grade[]
): SubjectGradeData {
const categoryAverages: CategoryAverage[] = [];
const isGermanSystem = subject.grade_system === "german";
// Group grades by category
const gradesByCategory = new Map<string, Grade[]>();
grades.forEach((grade) => {
const existing = gradesByCategory.get(grade.category_name) || [];
existing.push(grade);
gradesByCategory.set(grade.category_name, existing);
});
// Calculate average for each category
subject.grading_categories.forEach((category) => {
const categoryGrades = gradesByCategory.get(category.name) || [];
const average = calculateCategoryAverage(categoryGrades, isGermanSystem);
categoryAverages.push({
categoryName: category.name,
average,
weight: category.weight,
color: category.color,
});
});
// Calculate overall weighted average
const overallAverage = categoryAverages.reduce((sum, cat) => {
return sum + (cat.average * cat.weight) / 100;
}, 0);
return {
subject,
grades,
categoryAverages,
overallAverage,
};
}
/**
* Calculate report card averages for all subjects within a date range
*/
export function calculateReportCard(
subjects: Subject[],
allGrades: Grade[],
startDate: Date,
endDate: Date
): SubjectGradeData[] {
return subjects.map((subject) => {
// Filter grades for this subject within the date range
const subjectGrades = allGrades.filter((grade) => {
const gradeDate = new Date(grade.date);
return (
grade.subject_id === subject._id &&
gradeDate >= startDate &&
gradeDate <= endDate
);
});
return calculateSubjectAverage(subject, subjectGrades);
});
}
/**
* Calculate overall GPA across all subjects
*/
export function calculateOverallGPA(reportCardData: SubjectGradeData[]): number {
if (reportCardData.length === 0) return 0;
const sum = reportCardData.reduce(
(total, data) => total + data.overallAverage,
0
);
return sum / reportCardData.length;
}
/**
* Format a percentage grade to a specific decimal place
*/
export function formatGrade(grade: number, decimals: number = 2): string {
return grade.toFixed(decimals);
}
/**
* Format a grade for display based on the grading system
* For German system: converts percentage to 1-6 scale (e.g., "2.3")
* For other systems: shows percentage with % sign (e.g., "85%")
*
* @param percentage - Grade as percentage (0-100)
* @param gradeSystem - The grading system being used
* @param decimals - Number of decimal places
*/
export function formatGradeForDisplay(
percentage: number,
gradeSystem: string = "percentage",
decimals: number = 1
): string {
if (gradeSystem === "german") {
const germanGrade = percentageToGermanGrade(percentage);
return germanGrade.toFixed(decimals);
}
return `${percentage.toFixed(decimals)}%`;
}
/**
* Get the native grade value (not percentage) based on the grading system
* For German system: returns 1-6 scale value
* For other systems: returns percentage
*
* @param percentage - Grade as percentage (0-100)
* @param gradeSystem - The grading system being used
*/
export function getDisplayGrade(
percentage: number,
gradeSystem: string = "percentage"
): number {
if (gradeSystem === "german") {
return percentageToGermanGrade(percentage);
}
return percentage;
}
/**
* Convert percentage to letter grade (US system)
*/
export function percentageToLetterGrade(percentage: number): string {
if (percentage >= 90) return "A";
if (percentage >= 80) return "B";
if (percentage >= 70) return "C";
if (percentage >= 60) return "D";
return "F";
}
/**
* Get color for grade based on percentage
*/
export function getGradeColor(percentage: number): string {
if (percentage >= 90) return "text-green-600";
if (percentage >= 80) return "text-blue-600";
if (percentage >= 70) return "text-yellow-600";
if (percentage >= 60) return "text-orange-600";
return "text-red-600";
}
export interface TargetProgress {
currentGrade: number;
targetGrade: number;
targetMaxGrade: number;
currentPercentage: number;
targetPercentage: number;
difference: number; // Positive = above target, negative = below target
percentageDifference: number; // Difference as percentage
status: "above" | "near" | "below" | "no-target"; // above = exceeds target, near = within 5%, below = more than 5% below
}
/**
* Calculate target progress for a subject
* @param currentAverage - Current subject average (as percentage)
* @param subject - Subject with optional target_grade and target_grade_max
*/
export function calculateTargetProgress(
currentAverage: number,
subject: Subject
): TargetProgress {
// No target set
if (!subject.target_grade || !subject.target_grade_max) {
return {
currentGrade: currentAverage,
targetGrade: 0,
targetMaxGrade: 100,
currentPercentage: currentAverage,
targetPercentage: 0,
difference: 0,
percentageDifference: 0,
status: "no-target",
};
}
// Convert target to percentage
const isGermanSystem = subject.grade_system === "german" && subject.target_grade_max === 6;
let targetPercentage: number;
if (isGermanSystem) {
// For German grades (1-6 scale), convert to percentage using the conversion function
targetPercentage = germanGradeToPercentage(subject.target_grade);
} else {
// For standard percentage-based grades
targetPercentage = (subject.target_grade / subject.target_grade_max) * 100;
}
const difference = currentAverage - targetPercentage;
const percentageDifference = targetPercentage > 0 ? (difference / targetPercentage) * 100 : 0;
// Determine status
let status: "above" | "near" | "below";
if (difference > 0) {
status = "above";
} else if (Math.abs(difference) <= 5) {
status = "near";
} else {
status = "below";
}
return {
currentGrade: currentAverage,
targetGrade: subject.target_grade,
targetMaxGrade: subject.target_grade_max,
currentPercentage: currentAverage,
targetPercentage,
difference,
percentageDifference,
status,
};
}
/**
* Get color class for target progress status
*/
export function getTargetStatusColor(status: TargetProgress["status"]): string {
switch (status) {
case "above":
return "text-green-600";
case "near":
return "text-yellow-600";
case "below":
return "text-red-600";
default:
return "text-gray-600";
}
}
/**
* Get background color class for target progress status
*/
export function getTargetStatusBgColor(status: TargetProgress["status"]): string {
switch (status) {
case "above":
return "bg-green-100 border-green-500";
case "near":
return "bg-yellow-100 border-yellow-500";
case "below":
return "bg-red-100 border-red-500";
default:
return "bg-gray-100 border-gray-300";
}
}
export interface RequiredGradeResult {
requiredGrade: number; // Grade needed on remaining assignments
maxGrade: number; // Maximum grade value in the system
requiredPercentage: number; // Required grade as percentage
isPossible: boolean; // Whether target is achievable
categoryName: string;
categoryWeight: number;
message: string; // Human-readable explanation
}
/**
* Calculate what grade is needed on remaining assignments to reach a target grade
*
* @param subject - The subject with grading categories
* @param currentGrades - Current grades for the subject
* @param targetPercentage - Target overall average (as percentage)
* @param remainingAssignments - Number of remaining assignments per category
* @param maxGrade - Maximum grade value (e.g., 6 for German, 100 for percentage)
* @returns Array of required grades per category
*/
export function calculateRequiredGrade(
subject: Subject,
currentGrades: Grade[],
targetPercentage: number,
remainingAssignments: { [categoryName: string]: number },
maxGrade: number = 100
): RequiredGradeResult[] {
const isGermanSystem = subject.grade_system === "german" && maxGrade === 6;
// Group grades by category
const gradesByCategory = new Map<string, Grade[]>();
currentGrades.forEach((grade) => {
const existing = gradesByCategory.get(grade.category_name) || [];
existing.push(grade);
gradesByCategory.set(grade.category_name, existing);
});
const results: RequiredGradeResult[] = [];
subject.grading_categories.forEach((category) => {
const categoryGrades = gradesByCategory.get(category.name) || [];
const currentCategoryAverage = calculateCategoryAverage(categoryGrades, isGermanSystem);
const remainingCount = remainingAssignments[category.name] || 0;
// Calculate what category average is needed
// targetPercentage = sum of (categoryAvg * categoryWeight / 100)
// We need to solve for the new category average considering remaining assignments
let requiredPercentage: number;
let isPossible = true;
let message = "";
if (remainingCount === 0) {
// No remaining assignments in this category
requiredPercentage = currentCategoryAverage;
isPossible = Math.abs(currentCategoryAverage - targetPercentage) < 0.01;
message = "No remaining assignments in this category";
} else {
// Calculate the contribution of other categories at their current averages
let otherCategoriesContribution = 0;
subject.grading_categories.forEach((cat) => {
if (cat.name !== category.name) {
const catGrades = gradesByCategory.get(cat.name) || [];
const catAvg = calculateCategoryAverage(catGrades, isGermanSystem);
otherCategoriesContribution += (catAvg * cat.weight) / 100;
}
});
// Required contribution from this category
const requiredContribution = targetPercentage - otherCategoriesContribution;
requiredPercentage = (requiredContribution * 100) / category.weight;
// Calculate what grade percentage is needed on new assignments
// New category average = (currentSum + requiredGrade * remainingCount) / (currentCount + remainingCount)
const currentCount = categoryGrades.length;
const currentTotalWeight = categoryGrades.reduce((sum, g) => sum + g.weight_in_category, 0);
// Assuming each new assignment has weight = 1
const newTotalWeight = currentTotalWeight + remainingCount;
// currentCategoryAverage = currentSum / currentTotalWeight
// So currentSum = currentCategoryAverage * currentTotalWeight
const currentSum = currentCategoryAverage * currentTotalWeight;
// requiredPercentage = (currentSum + requiredGradePercentage * remainingCount) / newTotalWeight
// Solve for requiredGradePercentage:
requiredPercentage = (requiredPercentage * newTotalWeight - currentSum) / remainingCount;
// Check if achievable
if (requiredPercentage > 100) {
isPossible = false;
const maxDisplay = formatGradeForDisplay(100, subject.grade_system);
message = `Target unreachable - would need ${formatGradeForDisplay(requiredPercentage, subject.grade_system)} (>${maxDisplay})`;
} else if (requiredPercentage < 0) {
isPossible = true;
const minDisplay = formatGradeForDisplay(0, subject.grade_system);
message = `Target already exceeded! You can score ${minDisplay} and still reach your goal.`;
requiredPercentage = 0;
} else {
isPossible = true;
message = `Need ${formatGradeForDisplay(requiredPercentage, subject.grade_system)} average on ${remainingCount} remaining assignment${remainingCount > 1 ? 's' : ''}`;
}
}
// Convert percentage to grade value
let requiredGrade: number;
if (isGermanSystem) {
// For German system, convert percentage back to 1-6 scale
// This is an approximation - exact conversion depends on the grading curve
if (requiredPercentage >= 92) requiredGrade = 1.0;
else if (requiredPercentage >= 81) requiredGrade = 2.0;
else if (requiredPercentage >= 67) requiredGrade = 3.0;
else if (requiredPercentage >= 50) requiredGrade = 4.0;
else if (requiredPercentage >= 30) requiredGrade = 5.0;
else requiredGrade = 6.0;
} else {
requiredGrade = (requiredPercentage / 100) * maxGrade;
}
results.push({
requiredGrade,
maxGrade,
requiredPercentage,
isPossible,
categoryName: category.name,
categoryWeight: category.weight,
message,
});
});
return results;
}