===== EasyGrader.tsx =====
import { useState, useMemo } from "react";
function getGradeColor(pct: number): string {
if (pct >= 90) return "bg-[#1a1a1a]";
if (pct >= 80) return "bg-[#8B0000]";
if (pct >= 70) return "bg-[#A00000]";
if (pct >= 60) return "bg-[#B52020]";
if (pct >= 50) return "bg-[#C43030]";
if (pct >= 40) return "bg-[#CF3f3f]";
if (pct >= 30) return "bg-[#D95050]";
if (pct >= 20) return "bg-[#E06060]";
if (pct >= 10) return "bg-[#E87070]";
return "bg-[#F08080]";
}
export function EasyGrader() {
const [numQuestions, setNumQuestions] = useState("10");
const [numWrong, setNumWrong] = useState("0");
const [showChart, setShowChart] = useState(true);
const [showDecimals, setShowDecimals] = useState(false);
const result = useMemo(() => {
const total = parseInt(numQuestions) || 0;
const wrong = parseInt(numWrong) || 0;
if (total <= 0) return null;
const correct = Math.max(0, total - wrong);
const pct = (correct / total) * 100;
return { correct, total, pct };
}, [numQuestions, numWrong]);
const chartRows = useMemo(() => {
const total = parseInt(numQuestions) || 0;
if (total <= 0) return [];
const rows = [];
for (let w = 1; w <= total; w++) {
const correct = total - w;
const pct = (correct / total) * 100;
rows.push({ wrong: w, pct });
}
return rows;
}, [numQuestions]);
const formatPct = (pct: number) => {
if (showDecimals) return pct.toFixed(2) + "%";
return Math.round(pct) + "%";
};
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Inputs */}
<div className="bg-[#f0f0f0] rounded-md px-6 py-5 flex flex-wrap gap-6 items-center">
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">
# of questions:
</label>
<input
type="number"
min="1"
value={numQuestions}
onChange={(e) => setNumQuestions(e.target.value)}
className="w-24 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 bg-white"
/>
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">
# wrong:
</label>
<input
type="number"
min="0"
value={numWrong}
onChange={(e) => setNumWrong(e.target.value)}
className="w-24 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 bg-white"
/>
</div>
</div>
{/* Result */}
<div className="bg-[#f8f8f8] rounded-md px-6 py-5 space-y-4">
<div className="text-sm font-medium text-gray-600 text-center">Result</div>
<div className="border border-gray-300 rounded bg-white px-6 py-3 text-center text-2xl font-semibold text-gray-800">
{result
? `${result.correct}/${result.total} = ${formatPct(result.pct)}`
: "—"}
</div>
{/* Options */}
<div className="flex flex-wrap gap-6 justify-center text-sm text-gray-700">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={showChart}
onChange={(e) => setShowChart(e.target.checked)}
className="w-4 h-4 accent-gray-700"
/>
Show Grading Chart
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={showDecimals}
onChange={(e) => setShowDecimals(e.target.checked)}
className="w-4 h-4 accent-gray-700"
/>
Show Decimals
</label>
</div>
{/* Grading Chart */}
{showChart && chartRows.length > 0 && (
<div className="mt-2">
<div className="text-sm font-medium text-gray-700 mb-2 text-center">Grading Chart:</div>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-700">
<th className="py-1.5 font-semibold text-center w-1/2"># Wrong</th>
<th className="py-1.5 font-semibold text-center w-1/2">Grade</th>
</tr>
</thead>
<tbody>
{chartRows.map((row) => (
<tr
key={row.wrong}
className={`${getGradeColor(row.pct)} text-white`}
>
<td className="py-1.5 text-center font-medium">{row.wrong}</td>
<td className="py-1.5 text-center font-medium">{formatPct(row.pct)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
===== GradeCalculator.tsx (Average Grade Calculator) =====
import { useState, useMemo } from "react";
type Mode = "percentage" | "letters" | "points";
const LETTER_TO_PCT: Record<string, number> = {
"A+": 98, "A": 95, "A-": 92,
"B+": 88, "B": 85, "B-": 82,
"C+": 78, "C": 75, "C-": 72,
"D+": 68, "D": 65, "D-": 62,
"F": 50,
};
const LETTER_OPTIONS = ["A+","A","A-","B+","B","B-","C+","C","C-","D+","D","D-","F"];
function getLetterGrade(pct: number): string {
if (pct >= 97) return "A+";
if (pct >= 93) return "A";
if (pct >= 90) return "A-";
if (pct >= 87) return "B+";
if (pct >= 83) return "B";
if (pct >= 80) return "B-";
if (pct >= 77) return "C+";
if (pct >= 73) return "C";
if (pct >= 70) return "C-";
if (pct >= 67) return "D+";
if (pct >= 65) return "D";
if (pct >= 60) return "D-";
return "F";
}
interface Row {
id: string;
grade: string;
weight: string;
}
const DEFAULT_ROWS: Row[] = [
{ id: "1", grade: "1", weight: "1" },
{ id: "2", grade: "2", weight: "2" },
{ id: "3", grade: "2", weight: "2" },
{ id: "4", grade: "9", weight: "8" },
{ id: "5", grade: "4", weight: "3" },
{ id: "6", grade: "8", weight: "5" },
{ id: "7", grade: "b", weight: "4" },
{ id: "8", grade: "10", weight: "5" },
{ id: "9", grade: "", weight: "" },
];
export function GradeCalculator() {
const [mode, setMode] = useState<Mode>("percentage");
const [rows, setRows] = useState<Row[]>(DEFAULT_ROWS);
const [targetGrade, setTargetGrade] = useState("");
const updateRow = (id: string, field: "grade" | "weight", value: string) => {
setRows(rows.map((r) => (r.id === id ? { ...r, [field]: value } : r)));
};
const addRow = () => {
setRows([...rows, { id: crypto.randomUUID(), grade: "", weight: "" }]);
};
const reset = () => {
setRows(DEFAULT_ROWS.map(r => ({ ...r, grade: "", weight: "" })));
setTargetGrade("");
};
const calculation = useMemo(() => {
const validRows = rows.filter((r) => r.grade !== "" && r.grade !== undefined);
if (validRows.length === 0) return { avg: null, letter: null, additional: null };
let totalWeightedGrade = 0;
let totalWeight = 0;
let hasWeights = false;
validRows.forEach((r) => {
let gradeVal = 0;
if (mode === "letters") {
gradeVal = LETTER_TO_PCT[r.grade.toUpperCase()] ?? 0;
} else if (mode === "points") {
gradeVal = parseFloat(r.grade) || 0;
} else {
gradeVal = parseFloat(r.grade) || 0;
}
const w = parseFloat(r.weight);
if (!isNaN(w) && w > 0) {
hasWeights = true;
totalWeightedGrade += gradeVal * w;
totalWeight += w;
} else {
totalWeightedGrade += gradeVal;
totalWeight += 1;
}
});
const avg = totalWeight > 0 ? totalWeightedGrade / totalWeight : 0;
const letter = mode !== "letters" ? getLetterGrade(avg) : getLetterGrade(avg);
let additional: number | null = null;
const target = parseFloat(targetGrade);
if (!isNaN(target) && target > 0) {
additional = target * (validRows.length + 1) - totalWeightedGrade / (hasWeights ? 1 : 1);
const currentTotal = mode === "letters"
? validRows.reduce((sum, r) => sum + (LETTER_TO_PCT[r.grade.toUpperCase()] ?? 0), 0)
: validRows.reduce((sum, r) => sum + (parseFloat(r.grade) || 0), 0);
additional = target * (validRows.length + 1) - currentTotal;
}
return { avg, letter, additional };
}, [rows, mode, targetGrade]);
const subTabClass = (m: Mode) =>
`flex-1 py-1.5 text-xs font-semibold text-center cursor-pointer transition-colors ${
mode === m ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"
}`;
return (
<div className="max-w-2xl mx-auto space-y-4">
{/* Sub-tabs */}
<div className="bg-[#f0f0f0] rounded-md p-1 flex border border-gray-200">
<button className={subTabClass("percentage")} onClick={() => setMode("percentage")}>
Percentage
</button>
<button className={subTabClass("letters")} onClick={() => setMode("letters")}>
Letters
</button>
<button className={subTabClass("points")} onClick={() => setMode("points")}>
Points
</button>
</div>
{/* Empty description area (matches reference) */}
<div className="bg-white border border-gray-200 rounded-md px-4 py-3 min-h-[48px] text-xs text-gray-400">
{mode === "percentage" && "Enter your grade percentage and optional weight for each row."}
{mode === "letters" && "Enter your letter grade (A, B+, C-, etc.) and optional weight for each row."}
{mode === "points" && "Enter points earned and optional weight for each row."}
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#f8f8f8] border-b border-gray-200">
<th className="py-2 px-3 text-left text-xs font-semibold text-gray-600 w-10">#</th>
<th className="py-2 px-3 text-left text-xs font-semibold text-gray-600">
Grade {mode === "percentage" ? "(%)" : mode === "letters" ? "(Letter)" : "(Points)"}
</th>
<th className="py-2 px-3 text-left text-xs font-semibold text-gray-600">Weight</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.id} className="border-b border-gray-100 last:border-b-0">
<td className="py-1.5 px-3 text-xs text-gray-500">{i + 1}</td>
<td className="py-1.5 px-2">
{mode === "letters" ? (
<select
value={row.grade}
onChange={(e) => updateRow(row.id, "grade", e.target.value)}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
>
<option value=""></option>
{LETTER_OPTIONS.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
) : (
<input
type="number"
value={row.grade}
onChange={(e) => updateRow(row.id, "grade", e.target.value)}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
/>
)}
</td>
<td className="py-1.5 px-2">
<input
type="number"
value={row.weight}
onChange={(e) => updateRow(row.id, "weight", e.target.value)}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
/>
</td>
</tr>
))}
</tbody>
</table>
{/* Target grade input */}
<div className="px-4 py-3 border-t border-gray-100 bg-[#fafafa] flex flex-wrap items-center gap-2 text-sm text-gray-700">
<span>Find additional grade needed to get average grade of</span>
<input
type="number"
value={targetGrade}
onChange={(e) => setTargetGrade(e.target.value)}
className="w-20 border border-gray-300 rounded px-2 py-1 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
/>
<span>%</span>
</div>
{/* Buttons */}
<div className="px-4 py-3 border-t border-gray-100 flex gap-2">
<button
onClick={reset}
className="px-4 py-2 rounded text-sm font-semibold bg-[#1a1a2e] text-white hover:bg-[#2d2d60] hover:scale-105 hover:shadow-md transition-all duration-150"
>
Reset
</button>
<button
onClick={addRow}
className="px-4 py-2 rounded text-sm font-semibold bg-[#1a1a2e] text-white hover:bg-[#2d2d60] hover:scale-105 hover:shadow-md transition-all duration-150"
>
Add Row
</button>
</div>
</div>
{/* Results */}
<div className="bg-white border border-gray-200 rounded-md px-4 py-4 space-y-3">
<div className="text-sm font-semibold text-gray-700 text-center">Average Grade</div>
<div className="flex gap-3">
<div className="flex-1">
<input
readOnly
value={calculation.avg !== null ? calculation.avg.toFixed(2) : ""}
placeholder="0.00"
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm bg-[#f8f8f8] text-gray-800 focus:outline-none"
/>
</div>
<div className="w-20">
<input
readOnly
value={calculation.letter ?? ""}
placeholder="—"
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm bg-[#f8f8f8] text-gray-800 text-center focus:outline-none"
/>
</div>
</div>
<div className="text-sm text-gray-600">Additional grade needed:</div>
<input
readOnly
value={calculation.additional !== null && targetGrade !== "" ? calculation.additional.toFixed(2) : ""}
placeholder="—"
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm bg-[#f8f8f8] text-gray-800 focus:outline-none"
/>
</div>
</div>
);
}
===== FinalGradeCalculator.tsx =====
import { useState, useMemo } from "react";
type Mode = "percentage" | "letters";
const LETTER_TO_PCT: Record<string, number> = {
"A+": 98, "A": 95, "A-": 92,
"B+": 88, "B": 85, "B-": 82,
"C+": 78, "C": 75, "C-": 72,
"D+": 68, "D": 65, "D-": 62,
"F": 50,
};
const LETTER_OPTIONS = ["A+","A","A-","B+","B","B-","C+","C","C-","D+","D","D-","F"];
function getLetterGrade(pct: number): string {
if (pct >= 97) return "A+";
if (pct >= 93) return "A";
if (pct >= 90) return "A-";
if (pct >= 87) return "B+";
if (pct >= 83) return "B";
if (pct >= 80) return "B-";
if (pct >= 77) return "C+";
if (pct >= 73) return "C";
if (pct >= 70) return "C-";
if (pct >= 67) return "D+";
if (pct >= 65) return "D";
if (pct >= 60) return "D-";
return "F";
}
export function FinalGradeCalculator() {
const [mode, setMode] = useState<Mode>("percentage");
const [currentGrade, setCurrentGrade] = useState("");
const [desiredGrade, setDesiredGrade] = useState("");
const [finalWeight, setFinalWeight] = useState("");
const reset = () => {
setCurrentGrade("");
setDesiredGrade("");
setFinalWeight("");
};
const calculation = useMemo(() => {
const toNum = (val: string) =>
mode === "letters" ? (LETTER_TO_PCT[val] ?? NaN) : parseFloat(val);
const current = toNum(currentGrade);
const desired = toNum(desiredGrade);
const weight = parseFloat(finalWeight);
if (isNaN(current) || isNaN(desired) || isNaN(weight) || weight <= 0 || weight >= 100) {
return { required: null, letter: null };
}
const w = weight / 100;
const required = (desired - current * (1 - w)) / w;
const letter = isNaN(required) ? null : getLetterGrade(required);
return { required, letter };
}, [currentGrade, desiredGrade, finalWeight, mode]);
const subTabBase =
"flex-1 py-2 text-sm font-semibold text-center cursor-pointer transition-colors rounded-sm";
const subTabActive = "bg-white text-gray-900 shadow-sm";
const subTabInactive = "text-gray-500 hover:text-gray-700";
const inputClass =
"w-full border border-gray-300 rounded px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-400";
const selectClass =
"w-full border border-gray-300 rounded px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-emerald-400";
return (
<div className="max-w-2xl mx-auto space-y-4">
{/* Scale selector */}
<div className="text-sm text-gray-700 text-center font-medium">Select the grade scale:</div>
<div className="bg-[#f0f0f0] rounded-md p-1 flex border border-gray-200">
<button
className={`${subTabBase} ${mode === "percentage" ? subTabActive : subTabInactive}`}
onClick={() => setMode("percentage")}
>
Percentage
</button>
<button
className={`${subTabBase} ${mode === "letters" ? subTabActive : subTabInactive}`}
onClick={() => setMode("letters")}
>
Letters
</button>
</div>
{/* Inputs */}
<div className="bg-[#f0f0f0] rounded-md px-6 py-5 space-y-4">
{/* Current Grade */}
<div>
<label className="block text-sm font-medium text-gray-700 text-center mb-1">
Current Grade
</label>
{mode === "percentage" ? (
<div className="flex items-center gap-2">
<input
type="number"
value={currentGrade}
onChange={(e) => setCurrentGrade(e.target.value)}
placeholder=""
className={inputClass}
/>
<span className="text-sm text-gray-600 font-medium">%</span>
</div>
) : (
<select
value={currentGrade}
onChange={(e) => setCurrentGrade(e.target.value)}
className={selectClass}
>
<option value=""></option>
{LETTER_OPTIONS.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
)}
</div>
{/* Desired Grade */}
<div>
<label className="block text-sm font-medium text-gray-700 text-center mb-1">
Desired Grade
</label>
{mode === "percentage" ? (
<div className="flex items-center gap-2">
<input
type="number"
value={desiredGrade}
onChange={(e) => setDesiredGrade(e.target.value)}
placeholder=""
className={inputClass}
/>
<span className="text-sm text-gray-600 font-medium">%</span>
</div>
) : (
<select
value={desiredGrade}
onChange={(e) => setDesiredGrade(e.target.value)}
className={selectClass}
>
<option value=""></option>
{LETTER_OPTIONS.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
)}
</div>
{/* Final Exam Weight */}
<div>
<label className="block text-sm font-medium text-gray-700 text-center mb-1">
Final Exam Weight
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={finalWeight}
onChange={(e) => setFinalWeight(e.target.value)}
placeholder=""
className={inputClass}
/>
<span className="text-sm text-gray-600 font-medium">%</span>
</div>
</div>
{/* Reset */}
<div className="pt-1">
<button
onClick={reset}
className="px-6 py-2 rounded text-sm font-bold bg-gradient-to-br from-emerald-500 to-teal-600 text-white hover:from-emerald-600 hover:to-teal-700 hover:scale-105 hover:shadow-md transition-all duration-150 shadow-sm"
>
Reset
</button>
</div>
</div>
{/* Result */}
<div className="bg-[#f0f0f0] rounded-md px-6 py-4 space-y-3">
<div className="text-sm font-medium text-gray-700 text-center">
Final Exam Grade Needed:
</div>
<div className="flex gap-3">
<input
readOnly
value={
calculation.required !== null
? calculation.required < 0
? "0.00%"
: `${calculation.required.toFixed(2)}%`
: ""
}
placeholder=""
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-800 focus:outline-none"
/>
<input
readOnly
value={calculation.letter ?? ""}
placeholder=""
className="w-24 border border-gray-300 rounded px-3 py-2 text-sm bg-white text-gray-800 text-center focus:outline-none"
/>
</div>
{calculation.required !== null && (
<p className={`text-xs text-center font-medium ${
calculation.required > 100
? "text-red-500"
: calculation.required <= 0
? "text-emerald-600"
: "text-gray-600"
}`}>
{calculation.required > 100
? "This score exceeds 100% — consider extra credit options."
: calculation.required <= 0
? "You've already achieved your desired grade!"
: "You've got this — study hard!"}
</p>
)}
</div>
</div>
);
}
===== Home.tsx =====
import { useState } from "react";
import { cn } from "@/lib/utils";
import { EasyGrader } from "@/components/calculators/EasyGrader";
import { GradeCalculator } from "@/components/calculators/GradeCalculator";
import { FinalGradeCalculator } from "@/components/calculators/FinalGradeCalculator";
type TabId = "easy" | "average" | "final";
interface TabConfig {
id: TabId;
label: string;
title: string;
subtitle: string;
gradient: string;
activeGradient: string;
ring: string;
}
const TABS: TabConfig[] = [
{
id: "easy",
label: "Easy Grader",
title: "Grade Calculator",
subtitle: "Use this simple EZ Grading calculator to find quiz, test and assignment scores:",
gradient: "from-orange-400 to-rose-500 hover:from-orange-500 hover:to-rose-600",
activeGradient: "from-orange-500 to-rose-600",
ring: "ring-orange-300/50",
},
{
id: "average",
label: "Average Grade Calculator",
title: "Average Grade Calculator",
subtitle: "Enter grades and weights:",
gradient: "from-violet-500 to-indigo-600 hover:from-violet-600 hover:to-indigo-700",
activeGradient: "from-violet-600 to-indigo-700",
ring: "ring-violet-300/50",
},
{
id: "final",
label: "Final Grade Calculator",
title: "Final Grade Calculator",
subtitle: "Find out what grade you need on your final exam:",
gradient: "from-emerald-400 to-teal-600 hover:from-emerald-500 hover:to-teal-700",
activeGradient: "from-emerald-500 to-teal-700",
ring: "ring-emerald-300/50",
},
];
export default function Home() {
const [activeTab, setActiveTab] = useState<TabId>("easy");
const activeConfig = TABS.find((t) => t.id === activeTab)!;
return (
<div className="min-h-screen bg-white">
<main className="max-w-3xl mx-auto px-4 py-10">
{/* Header — changes per tab */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{activeConfig.title}</h1>
<p className="text-gray-600 text-sm leading-relaxed">{activeConfig.subtitle}</p>
</div>
{/* Tab Buttons */}
<div className="flex flex-wrap justify-center gap-3 mb-7">
{TABS.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"px-5 py-2.5 rounded-lg text-sm font-bold text-white transition-all duration-200",
"bg-gradient-to-br shadow-md",
isActive
? `${tab.activeGradient} scale-105 shadow-lg ring-2 ${tab.ring}`
: `${tab.gradient} hover:scale-105 hover:shadow-lg`
)}
>
{tab.label}
</button>
);
})}
</div>
{/* Content */}
<div>
{activeTab === "easy" && <EasyGrader />}
{activeTab === "average" && <GradeCalculator />}
{activeTab === "final" && <FinalGradeCalculator />}
</div>
</main>
</div>
);
}