first commit
This commit is contained in:
221
app/components/GradeCalculator.tsx
Normal file
221
app/components/GradeCalculator.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user