Files
grademaxxing/app/components/GradeCalculator.tsx
2026-01-17 13:37:57 +01:00

222 lines
7.4 KiB
TypeScript

import { useState } from "react";
import type { Subject, Grade } from "~/types/api";
import { calculateRequiredGrade, type RequiredGradeResult } from "~/utils/gradeCalculations";
import { Button } from "./Button";
import { Input } from "./Input";
interface GradeCalculatorProps {
subject: Subject;
currentGrades: Grade[];
}
export function GradeCalculator({ subject, currentGrades }: GradeCalculatorProps) {
const [targetGrade, setTargetGrade] = useState(
subject.target_grade?.toString() || ""
);
const [maxGradeValue, setMaxGradeValue] = useState(
subject.grade_system === "german" ? "6" : "100"
);
const [remainingAssignments, setRemainingAssignments] = useState<{
[key: string]: string;
}>(() => {
const initial: { [key: string]: string } = {};
subject.grading_categories.forEach((cat) => {
initial[cat.name] = "1";
});
return initial;
});
const [results, setResults] = useState<RequiredGradeResult[] | null>(null);
const [showCalculator, setShowCalculator] = useState(false);
const handleCalculate = () => {
if (!targetGrade || parseFloat(targetGrade) <= 0) {
alert("Please enter a valid target grade");
return;
}
const maxGrade = parseFloat(maxGradeValue);
const target = parseFloat(targetGrade);
// Convert target to percentage
let targetPercentage: number;
if (subject.grade_system === "german" && maxGrade === 6) {
// For German grades, convert using rough approximation
if (target <= 1.5) targetPercentage = 92;
else if (target <= 2.5) targetPercentage = 81;
else if (target <= 3.5) targetPercentage = 67;
else if (target <= 4.0) targetPercentage = 50;
else if (target <= 5.0) targetPercentage = 30;
else targetPercentage = 0;
} else {
targetPercentage = (target / maxGrade) * 100;
}
// Convert remaining assignments to numbers
const remaining: { [key: string]: number } = {};
Object.keys(remainingAssignments).forEach((key) => {
remaining[key] = parseInt(remainingAssignments[key]) || 0;
});
const calculationResults = calculateRequiredGrade(
subject,
currentGrades,
targetPercentage,
remaining,
maxGrade
);
setResults(calculationResults);
};
if (!showCalculator) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-blue-900">
🎯 What-If Grade Calculator
</h3>
<p className="text-sm text-blue-700 mt-1">
Calculate what grades you need on remaining assignments to reach your target
</p>
</div>
<Button onClick={() => setShowCalculator(true)} variant="secondary">
Open Calculator
</Button>
</div>
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
🎯 What-If Grade Calculator
</h3>
<Button onClick={() => setShowCalculator(false)} variant="ghost">
Close
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Target Grade
</label>
<Input
type="number"
value={targetGrade}
onChange={(e) => setTargetGrade(e.target.value)}
placeholder={subject.grade_system === "german" ? "2.0" : "85"}
step="0.01"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Maximum Grade Value
</label>
<Input
type="number"
value={maxGradeValue}
onChange={(e) => setMaxGradeValue(e.target.value)}
placeholder={subject.grade_system === "german" ? "6" : "100"}
step="0.01"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Remaining Assignments per Category
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{subject.grading_categories.map((category) => (
<div key={category.name} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: category.color }}
/>
<span className="text-sm text-gray-700 flex-1">
{category.name} ({category.weight}%)
</span>
<Input
type="number"
value={remainingAssignments[category.name] || "0"}
onChange={(e) =>
setRemainingAssignments({
...remainingAssignments,
[category.name]: e.target.value,
})
}
min="0"
step="1"
className="w-20"
/>
</div>
))}
</div>
</div>
<Button onClick={handleCalculate} className="w-full">
Calculate Required Grades
</Button>
{results && (
<div className="border-t pt-6 space-y-4">
<h4 className="font-semibold text-gray-900">Results:</h4>
{results.map((result) => (
<div
key={result.categoryName}
className={`p-4 rounded-lg border-l-4 ${
!result.isPossible
? "bg-red-50 border-red-500"
: result.requiredPercentage > 90
? "bg-yellow-50 border-yellow-500"
: "bg-green-50 border-green-500"
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<h5 className="font-semibold text-gray-900">
{result.categoryName}
</h5>
<p className="text-sm text-gray-600">
Weight: {result.categoryWeight}% of final grade
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">
{result.requiredGrade.toFixed(2)} / {result.maxGrade}
</div>
<div className="text-sm text-gray-600">
({result.requiredPercentage.toFixed(1)}%)
</div>
</div>
</div>
<p
className={`text-sm ${
!result.isPossible
? "text-red-700 font-semibold"
: "text-gray-700"
}`}
>
{result.message}
</p>
</div>
))}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
<p className="text-sm text-blue-800">
<strong>Note:</strong> These calculations assume each remaining
assignment has equal weight (weight = 1.0) within its category. If
your assignments have different weights, adjust accordingly.
</p>
</div>
</div>
)}
</div>
);
}