first commit
This commit is contained in:
431
app/utils/gradeCalculations.ts
Normal file
431
app/utils/gradeCalculations.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user