222 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|