432 lines
14 KiB
TypeScript
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;
|
|
}
|