first commit

This commit is contained in:
Space
2026-01-17 13:37:57 +01:00
commit 3e34d84a29
49 changed files with 8579 additions and 0 deletions

View 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;
}

179
app/utils/gradeSystems.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* German Grading System Utilities
* German grades: 1 (best) to 6 (worst)
*/
export type GradeSystem = "percentage" | "german" | "us-letter";
export interface GradeSystemConfig {
type: GradeSystem;
displayName: string;
description: string;
}
export const GRADE_SYSTEMS: Record<GradeSystem, GradeSystemConfig> = {
percentage: {
type: "percentage",
displayName: "Percentage (0-100%)",
description: "Standard percentage-based grading",
},
german: {
type: "german",
displayName: "German (1-6)",
description: "1 = sehr gut (very good), 6 = ungenügend (insufficient)",
},
"us-letter": {
type: "us-letter",
displayName: "US Letter (A-F)",
description: "A = 90-100%, F = below 60%",
},
};
/**
* Convert percentage (0-100) to German grade (1-6)
* German grading scale:
* 1 (sehr gut / very good): 92-100%
* 2 (gut / good): 81-91%
* 3 (befriedigend / satisfactory): 67-80%
* 4 (ausreichend / sufficient): 50-66%
* 5 (mangelhaft / deficient): 30-49%
* 6 (ungenügend / insufficient): 0-29%
*/
export function percentageToGermanGrade(percentage: number): number {
if (percentage >= 92) return 1.0;
if (percentage >= 81) return 2.0;
if (percentage >= 67) return 3.0;
if (percentage >= 50) return 4.0;
if (percentage >= 30) return 5.0;
return 6.0;
}
/**
* Convert percentage to German grade with decimal precision
* Uses linear interpolation within each grade range
*/
export function percentageToGermanGradeDetailed(percentage: number): number {
if (percentage >= 92) {
// 1.0 - 1.5 range (92-100%)
const position = (100 - percentage) / (100 - 92);
return 1.0 + position * 0.5;
} else if (percentage >= 81) {
// 1.5 - 2.5 range (81-91%)
const position = (92 - percentage) / (92 - 81);
return 1.5 + position * 1.0;
} else if (percentage >= 67) {
// 2.5 - 3.5 range (67-80%)
const position = (81 - percentage) / (81 - 67);
return 2.5 + position * 1.0;
} else if (percentage >= 50) {
// 3.5 - 4.5 range (50-66%)
const position = (67 - percentage) / (67 - 50);
return 3.5 + position * 1.0;
} else if (percentage >= 30) {
// 4.5 - 5.5 range (30-49%)
const position = (50 - percentage) / (50 - 30);
return 4.5 + position * 1.0;
} else {
// 5.5 - 6.0 range (0-29%)
const position = (30 - percentage) / 30;
return 5.5 + position * 0.5;
}
}
/**
* Convert German grade (1-6) to approximate percentage
*/
export function germanGradeToPercentage(grade: number): number {
if (grade <= 1.5) return 92 + (1.5 - grade) * 16; // 92-100%
if (grade <= 2.5) return 81 + (2.5 - grade) * 11; // 81-91%
if (grade <= 3.5) return 67 + (3.5 - grade) * 14; // 67-80%
if (grade <= 4.5) return 50 + (4.5 - grade) * 17; // 50-66%
if (grade <= 5.5) return 30 + (5.5 - grade) * 20; // 30-49%
return Math.max(0, 30 - (grade - 5.5) * 60); // 0-29%
}
/**
* Get German grade description
*/
export function getGermanGradeDescription(grade: number): string {
if (grade <= 1.5) return "sehr gut (very good)";
if (grade <= 2.5) return "gut (good)";
if (grade <= 3.5) return "befriedigend (satisfactory)";
if (grade <= 4.5) return "ausreichend (sufficient)";
if (grade <= 5.5) return "mangelhaft (deficient)";
return "ungenügend (insufficient)";
}
/**
* Get color for German grade
*/
export function getGermanGradeColor(grade: number): string {
if (grade <= 2.0) return "text-green-600";
if (grade <= 3.0) return "text-blue-600";
if (grade <= 4.0) return "text-yellow-600";
if (grade <= 5.0) return "text-orange-600";
return "text-red-600";
}
/**
* Format grade based on system
*/
export function formatGradeBySystem(
percentage: number,
system: GradeSystem,
decimals: number = 2
): string {
switch (system) {
case "german": {
const germanGrade = percentageToGermanGradeDetailed(percentage);
return germanGrade.toFixed(1);
}
case "us-letter": {
if (percentage >= 90) return "A";
if (percentage >= 80) return "B";
if (percentage >= 70) return "C";
if (percentage >= 60) return "D";
return "F";
}
case "percentage":
default:
return `${percentage.toFixed(decimals)}%`;
}
}
/**
* Get color for grade based on system
*/
export function getGradeColorBySystem(
percentage: number,
system: GradeSystem
): string {
switch (system) {
case "german": {
const germanGrade = percentageToGermanGradeDetailed(percentage);
return getGermanGradeColor(germanGrade);
}
case "percentage":
case "us-letter":
default:
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";
}
}
/**
* Get description for grade
*/
export function getGradeDescription(
percentage: number,
system: GradeSystem
): string | null {
if (system === "german") {
const germanGrade = percentageToGermanGradeDetailed(percentage);
return getGermanGradeDescription(germanGrade);
}
return null;
}