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(); 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(); 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; }