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

286
app/api/client.ts Normal file
View File

@@ -0,0 +1,286 @@
import type {
AuthResponse,
UserLogin,
UserRegister,
User,
Subject,
SubjectCreate,
SubjectUpdate,
Grade,
GradeCreate,
GradeUpdate,
ReportPeriod,
ReportPeriodCreate,
ReportPeriodUpdate,
TeacherGrade,
TeacherGradeCreate,
TeacherGradeUpdate,
} from "~/types/api";
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api";
class ApiClient {
private getHeaders(includeAuth: boolean = false): HeadersInit {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (includeAuth) {
const token = localStorage.getItem("access_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
return headers;
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "An error occurred" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
// Auth endpoints
async register(data: UserRegister): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify(data),
});
const result = await this.handleResponse<AuthResponse>(response);
localStorage.setItem("access_token", result.access_token);
return result;
}
async login(data: UserLogin): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify(data),
});
const result = await this.handleResponse<AuthResponse>(response);
localStorage.setItem("access_token", result.access_token);
return result;
}
async getMe(): Promise<User> {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
headers: this.getHeaders(true),
});
return this.handleResponse<User>(response);
}
logout(): void {
localStorage.removeItem("access_token");
}
isAuthenticated(): boolean {
return !!localStorage.getItem("access_token");
}
// Subject endpoints
async getSubjects(): Promise<Subject[]> {
const response = await fetch(`${API_BASE_URL}/subjects`, {
headers: this.getHeaders(true),
});
return this.handleResponse<Subject[]>(response);
}
async getSubject(id: string): Promise<Subject> {
const response = await fetch(`${API_BASE_URL}/subjects/${id}`, {
headers: this.getHeaders(true),
});
return this.handleResponse<Subject>(response);
}
async createSubject(data: SubjectCreate): Promise<Subject> {
const response = await fetch(`${API_BASE_URL}/subjects`, {
method: "POST",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<Subject>(response);
}
async updateSubject(id: string, data: SubjectUpdate): Promise<Subject> {
const response = await fetch(`${API_BASE_URL}/subjects/${id}`, {
method: "PUT",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<Subject>(response);
}
async deleteSubject(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/subjects/${id}`, {
method: "DELETE",
headers: this.getHeaders(true),
});
return this.handleResponse<void>(response);
}
// Grade endpoints
async getGrades(subjectId?: string): Promise<Grade[]> {
const url = subjectId
? `${API_BASE_URL}/grades?subject_id=${subjectId}`
: `${API_BASE_URL}/grades`;
const response = await fetch(url, {
headers: this.getHeaders(true),
});
return this.handleResponse<Grade[]>(response);
}
async getGrade(id: string): Promise<Grade> {
const response = await fetch(`${API_BASE_URL}/grades/${id}`, {
headers: this.getHeaders(true),
});
return this.handleResponse<Grade>(response);
}
async createGrade(data: GradeCreate): Promise<Grade> {
const response = await fetch(`${API_BASE_URL}/grades`, {
method: "POST",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<Grade>(response);
}
async updateGrade(id: string, data: GradeUpdate): Promise<Grade> {
const response = await fetch(`${API_BASE_URL}/grades/${id}`, {
method: "PUT",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<Grade>(response);
}
async deleteGrade(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/grades/${id}`, {
method: "DELETE",
headers: this.getHeaders(true),
});
return this.handleResponse<void>(response);
}
// Report Period endpoints
async getPeriods(): Promise<ReportPeriod[]> {
const response = await fetch(`${API_BASE_URL}/periods`, {
headers: this.getHeaders(true),
});
return this.handleResponse<ReportPeriod[]>(response);
}
async getPeriod(id: string): Promise<ReportPeriod> {
const response = await fetch(`${API_BASE_URL}/periods/${id}`, {
headers: this.getHeaders(true),
});
return this.handleResponse<ReportPeriod>(response);
}
async createPeriod(data: ReportPeriodCreate): Promise<ReportPeriod> {
const response = await fetch(`${API_BASE_URL}/periods`, {
method: "POST",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<ReportPeriod>(response);
}
async updatePeriod(id: string, data: ReportPeriodUpdate): Promise<ReportPeriod> {
const response = await fetch(`${API_BASE_URL}/periods/${id}`, {
method: "PUT",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<ReportPeriod>(response);
}
async deletePeriod(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/periods/${id}`, {
method: "DELETE",
headers: this.getHeaders(true),
});
return this.handleResponse<void>(response);
}
// Teacher Grade endpoints
async getTeacherGrades(subjectId?: string, periodId?: string): Promise<TeacherGrade[]> {
const params = new URLSearchParams();
if (subjectId) params.append("subject_id", subjectId);
if (periodId) params.append("period_id", periodId);
const url = `${API_BASE_URL}/teacher-grades${params.toString() ? `?${params.toString()}` : ""}`;
const response = await fetch(url, {
headers: this.getHeaders(true),
});
return this.handleResponse<TeacherGrade[]>(response);
}
async getTeacherGrade(id: string): Promise<TeacherGrade> {
const response = await fetch(`${API_BASE_URL}/teacher-grades/${id}`, {
headers: this.getHeaders(true),
});
return this.handleResponse<TeacherGrade>(response);
}
async createTeacherGrade(data: TeacherGradeCreate): Promise<TeacherGrade> {
const response = await fetch(`${API_BASE_URL}/teacher-grades`, {
method: "POST",
headers: this.getHeaders(true),
body: JSON.stringify(data),
});
return this.handleResponse<TeacherGrade>(response);
}
async deleteTeacherGrade(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/teacher-grades/${id}`, {
method: "DELETE",
headers: this.getHeaders(true),
});
return this.handleResponse<void>(response);
}
// Export endpoints
async exportGradesCSV(periodId?: string): Promise<void> {
const url = periodId
? `${API_BASE_URL}/export/grades?period_id=${periodId}`
: `${API_BASE_URL}/export/grades`;
const response = await fetch(url, {
headers: this.getHeaders(true),
});
if (!response.ok) {
throw new Error("Failed to export grades");
}
// Create a blob from the response and trigger download
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
// Extract filename from Content-Disposition header or use default
const contentDisposition = response.headers.get("Content-Disposition");
const filename = contentDisposition
? contentDisposition.split("filename=")[1]?.replace(/"/g, "")
: "grades_export.csv";
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
}
}
export const api = new ApiClient();

1
app/app.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

40
app/components/Button.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
children: ReactNode;
}
export function Button({
variant = "primary",
size = "md",
className = "",
children,
...props
}: ButtonProps) {
const baseStyles =
"font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 active:bg-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800",
ghost: "text-gray-700 hover:bg-gray-100 active:bg-gray-200",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2",
lg: "px-6 py-3 text-lg",
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
}

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

View File

@@ -0,0 +1,149 @@
import type { Grade, Subject } from "~/types/api";
import { formatGrade } from "~/utils/gradeCalculations";
interface GradeTableProps {
grades: Grade[];
subject?: Subject;
onEdit?: (grade: Grade) => void;
onDelete?: (gradeId: string) => void;
deletingGradeId?: string | null;
}
export function GradeTable({ grades, subject, onEdit, onDelete, deletingGradeId }: GradeTableProps) {
if (grades.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No grades recorded yet. Add your first grade!
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50 border-b">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Date</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Name</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Category</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Grade</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Percentage</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Weight</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{grades.map((grade) => {
const percentage = (grade.grade / grade.max_grade) * 100;
const category = subject?.grading_categories.find(
(c) => c.name === grade.category_name
);
return (
<tr key={grade._id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 text-sm text-gray-600">
{new Date(grade.date).toLocaleDateString()}
</td>
<td className="py-3 px-4">
<div className="font-medium">{grade.name || "Unnamed"}</div>
{grade.notes && (
<div className="text-sm text-gray-500">{grade.notes}</div>
)}
</td>
<td className="py-3 px-4">
<span
className="px-2 py-1 text-xs font-medium rounded-full"
style={{
backgroundColor: category?.color || "#e5e7eb",
color: "#1f2937",
}}
>
{grade.category_name}
</span>
</td>
<td className="py-3 px-4 text-right font-medium">
{grade.grade} / {grade.max_grade}
</td>
<td className="py-3 px-4 text-right font-semibold">
{formatGrade(percentage)}%
</td>
<td className="py-3 px-4 text-right text-sm text-gray-600">
{grade.weight_in_category}x
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
{onEdit && (
<button
onClick={() => onEdit(grade)}
className="text-blue-500 hover:text-blue-700"
aria-label="Edit grade"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(grade._id)}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
aria-label="Delete grade"
disabled={deletingGradeId === grade._id}
>
{deletingGradeId === grade._id ? (
<svg
className="w-4 h-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
)}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useState } from "react";
import type { GradingCategory } from "~/types/api";
import { Button } from "./Button";
import { Input } from "./Input";
interface GradingCategorySelectorProps {
categories: GradingCategory[];
onChange: (categories: GradingCategory[]) => void;
}
const DEFAULT_COLORS = [
"#3b82f6", // blue
"#10b981", // green
"#f59e0b", // amber
"#ef4444", // red
"#8b5cf6", // purple
"#ec4899", // pink
];
export function GradingCategorySelector({
categories,
onChange,
}: GradingCategorySelectorProps) {
const [error, setError] = useState<string>("");
const addCategory = () => {
const newCategory: GradingCategory = {
name: "",
weight: 0,
color: DEFAULT_COLORS[categories.length % DEFAULT_COLORS.length],
};
onChange([...categories, newCategory]);
};
const updateCategory = (index: number, field: keyof GradingCategory, value: any) => {
const updated = [...categories];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
validateWeights(updated);
};
const removeCategory = (index: number) => {
const updated = categories.filter((_, i) => i !== index);
onChange(updated);
validateWeights(updated);
};
const validateWeights = (cats: GradingCategory[]) => {
const total = cats.reduce((sum, cat) => sum + (Number(cat.weight) || 0), 0);
if (Math.abs(total - 100) > 0.01 && total > 0) {
setError(`Total weight: ${total.toFixed(1)}% (must equal 100%)`);
} else {
setError("");
}
};
const totalWeight = categories.reduce((sum, cat) => sum + (Number(cat.weight) || 0), 0);
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="block text-sm font-medium text-gray-700">
Grading Categories
</label>
<Button type="button" size="sm" onClick={addCategory}>
+ Add Category
</Button>
</div>
<div className="space-y-3">
{categories.map((category, index) => (
<div key={index} className="flex gap-3 items-start p-3 border rounded-lg">
<Input
placeholder="Category name (e.g., Written, Oral)"
value={category.name}
onChange={(e) => updateCategory(index, "name", e.target.value)}
className="flex-1"
/>
<Input
type="number"
placeholder="Weight %"
value={category.weight || ""}
onChange={(e) =>
updateCategory(index, "weight", parseFloat(e.target.value) || 0)
}
className="w-24"
min="0"
max="100"
step="0.1"
/>
<input
type="color"
value={category.color || "#3b82f6"}
onChange={(e) => updateCategory(index, "color", e.target.value)}
className="w-12 h-10 rounded cursor-pointer"
title="Category color"
/>
<button
type="button"
onClick={() => removeCategory(index)}
className="text-red-500 hover:text-red-700 p-2"
aria-label="Remove category"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
{categories.length === 0 && (
<p className="text-sm text-gray-500 text-center py-4">
Add at least one grading category
</p>
)}
<div className="flex justify-between items-center text-sm">
<span className="font-medium text-gray-700">Total Weight:</span>
<span
className={`font-bold ${
Math.abs(totalWeight - 100) < 0.01 ? "text-green-600" : "text-red-600"
}`}
>
{totalWeight.toFixed(1)}%
</span>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}

25
app/components/Input.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = "", ...props }: InputProps) {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<input
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
error ? "border-red-500" : "border-gray-300"
} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,19 @@
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
}
export function LoadingSpinner({ size = "md" }: LoadingSpinnerProps) {
const sizes = {
sm: "w-4 h-4",
md: "w-8 h-8",
lg: "w-12 h-12",
};
return (
<div className="flex justify-center items-center">
<div
className={`${sizes[size]} border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin`}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "react-router";
import { api } from "~/api/client";
interface ProtectedRouteProps {
children: ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const navigate = useNavigate();
useEffect(() => {
if (!api.isAuthenticated()) {
navigate("/login");
}
}, [navigate]);
if (!api.isAuthenticated()) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,30 @@
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
color?: string;
icon?: React.ReactNode;
}
export function StatCard({ title, value, subtitle, color = "#3b82f6", icon }: StatCardProps) {
return (
<div className="p-6 bg-white rounded-lg border-2 border-gray-200 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600 mb-1">{title}</p>
<p className="text-3xl font-bold" style={{ color }}>
{value}
</p>
{subtitle && (
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
)}
</div>
{icon && (
<div className="p-3 rounded-lg" style={{ backgroundColor: `${color}20` }}>
{icon}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import type { Subject } from "~/types/api";
import { Link } from "react-router";
import { formatGradeBySystem, type GradeSystem } from "~/utils/gradeSystems";
import { calculateTargetProgress, getTargetStatusBgColor } from "~/utils/gradeCalculations";
interface SubjectCardProps {
subject: Subject;
averageGrade?: number;
calculatedAverage?: number;
gradeSystem?: string;
onDelete?: () => void;
}
export function SubjectCard({ subject, averageGrade, calculatedAverage, gradeSystem, onDelete }: SubjectCardProps) {
// Use provided gradeSystem, fallback to subject's own system
const displaySystem = (gradeSystem || subject.grade_system || "percentage") as GradeSystem;
// Calculate target progress if grade is available
const targetProgress = averageGrade !== undefined ? calculateTargetProgress(subject.target_grade ?? averageGrade, subject) : null;
const hasTaget = targetProgress && targetProgress.status !== "no-target";
const isOverridden = calculatedAverage !== undefined && averageGrade !== undefined && Math.abs(calculatedAverage - averageGrade) > 0.01;
return (
<div
className={`p-6 rounded-lg border-2 hover:shadow-lg transition-shadow ${hasTaget ? getTargetStatusBgColor(targetProgress.status) : ""}`}
style={{ borderColor: subject.color || "#3b82f6" }}
>
<div className="flex justify-between items-start mb-3">
<Link to={`/subjects/${subject._id}`} className="flex-1">
<h3 className="text-xl font-semibold text-gray-900 hover:text-blue-600">
{subject.name}
</h3>
{subject.teacher && (
<p className="text-sm text-gray-600 mt-1">{subject.teacher}</p>
)}
</Link>
{onDelete && (
<button
onClick={onDelete}
className="text-red-500 hover:text-red-700 ml-2"
aria-label="Delete subject"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
{averageGrade !== undefined && (
<div className="mb-3">
<div className="flex items-center justify-between">
<div>
<div className="text-3xl font-bold" style={{ color: subject.color || "#3b82f6" }}>
{formatGradeBySystem(averageGrade, displaySystem, 1)}
{isOverridden && (
<span className="text-gray-500 text-lg font-normal ml-2">
({formatGradeBySystem(calculatedAverage!, displaySystem, 1)})
</span>
)}
</div>
<p className="text-sm text-gray-600">Current Average</p>
</div>
{hasTaget && (
<div className="text-right">
<div className="text-sm text-gray-600">Target: {formatGradeBySystem(targetProgress.targetPercentage, displaySystem, 1)}</div>
<div className="text-xs font-semibold mt-1">
{targetProgress.status === "above" && (
<span className="text-green-700"> Above Target</span>
)}
{targetProgress.status === "near" && (
<span className="text-yellow-700"> Near Target</span>
)}
{targetProgress.status === "below" && (
<span className="text-red-700"> Below Target</span>
)}
</div>
</div>
)}
</div>
</div>
)}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Grading Categories:</p>
<div className="flex flex-wrap gap-2">
{subject.grading_categories.map((category, index) => (
<span
key={index}
className="px-2 py-1 text-xs font-medium rounded-full"
style={{
backgroundColor: category.color || "#e5e7eb",
color: "#1f2937",
}}
>
{category.name} ({category.weight}%)
</span>
))}
</div>
</div>
</div>
);
}

75
app/root.tsx Normal file
View File

@@ -0,0 +1,75 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

13
app/routes.ts Normal file
View File

@@ -0,0 +1,13 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"),
route("dashboard", "routes/dashboard.tsx"),
route("subjects", "routes/subjects.tsx"),
route("subjects/new", "routes/subjects.new.tsx"),
route("subjects/:subjectId", "routes/subjects.$subjectId.tsx"),
route("subjects/:subjectId/edit", "routes/subjects.$subjectId.edit.tsx"),
route("periods", "routes/periods.tsx"),
] satisfies RouteConfig;

629
app/routes/dashboard.tsx Normal file
View File

@@ -0,0 +1,629 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import { ProtectedRoute } from "~/components/ProtectedRoute";
import { StatCard } from "~/components/StatCard";
import { SubjectCard } from "~/components/SubjectCard";
import { LoadingSpinner } from "~/components/LoadingSpinner";
import { Button } from "~/components/Button";
import { api } from "~/api/client";
import type { Subject, Grade, ReportPeriod, User, TeacherGrade } from "~/types/api";
import { calculateReportCard, calculateOverallGPA, calculateTargetProgress, getTargetStatusColor } from "~/utils/gradeCalculations";
import { formatGradeBySystem, germanGradeToPercentage, type GradeSystem } from "~/utils/gradeSystems";
export default function Dashboard() {
const navigate = useNavigate();
const [user, setUser] = useState<User | null>(null);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [grades, setGrades] = useState<Grade[]>([]);
const [periods, setPeriods] = useState<ReportPeriod[]>([]);
const [selectedPeriod, setSelectedPeriod] = useState<ReportPeriod | null>(null);
const [teacherGrades, setTeacherGrades] = useState<TeacherGrade[]>([]);
const [editingTeacherGrade, setEditingTeacherGrade] = useState<{ subjectId: string; value: string; maxValue: string; notes: string } | null>(null);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [deletingTeacherGradeId, setDeletingTeacherGradeId] = useState<string | null>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [userData, subjectsData, gradesData, periodsData, teacherGradesData] = await Promise.all([
api.getMe(),
api.getSubjects(),
api.getGrades(),
api.getPeriods(),
api.getTeacherGrades(),
]);
setUser(userData);
setSubjects(subjectsData);
setGrades(gradesData);
setPeriods(periodsData);
setTeacherGrades(teacherGradesData);
if (periodsData.length > 0) {
// Find the period that contains today's date
const today = new Date();
const currentPeriod = periodsData.find(period => {
const start = new Date(period.start_date);
const end = new Date(period.end_date);
return today >= start && today <= end;
});
// Use current period if found, otherwise use the most recent period (first in list)
setSelectedPeriod(currentPeriod || periodsData[0]);
}
} catch (error) {
console.error("Failed to load data:", error);
} finally {
setLoading(false);
}
};
const handleLogout = () => {
api.logout();
navigate("/login");
};
const handleTeacherGradeClick = (subjectId: string) => {
const subject = subjects.find(s => s._id === subjectId);
if (!subject || !selectedPeriod) return;
const existing = teacherGrades.find(
tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id
);
const config = getGradeInputConfig(subject.grade_system as GradeSystem);
setEditingTeacherGrade({
subjectId,
value: existing ? existing.grade.toString() : "",
maxValue: existing ? existing.max_grade.toString() : config.maxGradeValue,
notes: existing?.notes || "",
});
};
const handleSaveTeacherGrade = async () => {
if (!editingTeacherGrade || !selectedPeriod) return;
try {
const subject = subjects.find(s => s._id === editingTeacherGrade.subjectId);
if (!subject) return;
const config = getGradeInputConfig(subject.grade_system as GradeSystem);
const finalMaxGrade = config.showMaxGradeInput
? parseFloat(editingTeacherGrade.maxValue)
: parseFloat(config.maxGradeValue);
const data = {
subject_id: editingTeacherGrade.subjectId,
period_id: selectedPeriod._id,
grade: parseFloat(editingTeacherGrade.value),
max_grade: finalMaxGrade,
notes: editingTeacherGrade.notes || undefined,
};
const result = await api.createTeacherGrade(data);
setTeacherGrades([...teacherGrades.filter(
tg => !(tg.subject_id === result.subject_id && tg.period_id === result.period_id)
), result]);
setEditingTeacherGrade(null);
} catch (error) {
alert("Failed to save teacher grade");
}
};
const handleDeleteTeacherGrade = async (subjectId: string) => {
if (!selectedPeriod) return;
const existing = teacherGrades.find(
tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id
);
if (existing && confirm("Remove teacher's grade?")) {
try {
setDeletingTeacherGradeId(subjectId);
await api.deleteTeacherGrade(existing._id);
setTeacherGrades(teacherGrades.filter(tg => tg._id !== existing._id));
} catch (error) {
alert("Failed to delete teacher grade");
} finally {
setDeletingTeacherGradeId(null);
}
}
};
const getGradeInputConfig = (gradeSystem: GradeSystem) => {
switch (gradeSystem) {
case "german":
return {
label: "German Grade",
placeholder: "1.0",
min: "1",
max: "6",
step: "0.1",
maxGradeValue: "6",
showMaxGradeInput: false,
};
case "us-letter":
return {
label: "Percentage",
placeholder: "85",
min: "0",
max: "100",
step: "0.01",
maxGradeValue: "100",
showMaxGradeInput: false,
};
default:
return {
label: "Grade",
placeholder: "85",
min: "0",
max: undefined,
step: "0.01",
maxGradeValue: "100",
showMaxGradeInput: true,
};
}
};
const handleExportCSV = async () => {
try {
setExporting(true);
await api.exportGradesCSV(selectedPeriod?._id);
} catch (error) {
console.error("Failed to export grades:", error);
alert("Failed to export grades. Please try again.");
} finally {
setExporting(false);
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
</ProtectedRoute>
);
}
const reportCardData = selectedPeriod
? calculateReportCard(
subjects,
grades,
new Date(selectedPeriod.start_date),
new Date(selectedPeriod.end_date)
)
: [];
const calculatedGPA = calculateOverallGPA(reportCardData);
// Calculate GPA with teacher grade overrides
const getSubjectFinalGrade = (subjectId: string, calculatedAverage: number) => {
if (!selectedPeriod) return calculatedAverage;
const teacherGrade = teacherGrades.find(
tg => tg.subject_id === subjectId && tg.period_id === selectedPeriod._id
);
if (!teacherGrade) return calculatedAverage;
const subject = subjects.find(s => s._id === subjectId);
if (!subject) return calculatedAverage;
// Convert teacher grade to percentage
const gradeSystem = subject.grade_system as GradeSystem || "percentage";
if (gradeSystem === "german" && teacherGrade.max_grade === 6) {
return germanGradeToPercentage(teacherGrade.grade);
}
return (teacherGrade.grade / teacherGrade.max_grade) * 100;
};
const overallGPA = selectedPeriod && reportCardData.length > 0
? reportCardData.reduce((sum, data) => {
const finalGrade = getSubjectFinalGrade(data.subject._id, data.overallAverage);
return sum + finalGrade;
}, 0) / reportCardData.length
: calculatedGPA;
// Use the first subject's grading system as the primary system
const primaryGradeSystem = (subjects[0]?.grade_system as GradeSystem) || "percentage";
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Grademaxxing</h1>
<p className="text-sm text-gray-600">Welcome back, {user?.username}!</p>
</div>
<div className="flex gap-3">
<Link to="/periods">
<Button variant="ghost">Report Periods</Button>
</Link>
<Link to="/subjects">
<Button variant="secondary">Manage Subjects</Button>
</Link>
<Button variant="ghost" onClick={handleLogout}>
Logout
</Button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Period Selector */}
{periods.length > 0 && (
<div className="mb-8 flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Report Period
</label>
<select
value={selectedPeriod?._id || ""}
onChange={(e) => {
const period = periods.find((p) => p._id === e.target.value);
setSelectedPeriod(period || null);
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{periods.map((period) => (
<option key={period._id} value={period._id}>
{period.name} ({new Date(period.start_date).toLocaleDateString()} -{" "}
{new Date(period.end_date).toLocaleDateString()})
</option>
))}
</select>
</div>
<Button
onClick={handleExportCSV}
disabled={exporting}
variant="secondary"
>
{exporting ? "Exporting..." : "📥 Export CSV"}
</Button>
</div>
)}
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
title="Overall Average"
value={
overallGPA > 0
? selectedPeriod && teacherGrades.some(tg => tg.period_id === selectedPeriod._id) && calculatedGPA !== overallGPA
? `${formatGradeBySystem(overallGPA, primaryGradeSystem, 1)} (${formatGradeBySystem(calculatedGPA, primaryGradeSystem, 1)})`
: formatGradeBySystem(overallGPA, primaryGradeSystem, 1)
: "-"
}
subtitle={selectedPeriod?.name || "All time"}
color="#3b82f6"
/>
<StatCard
title="Total Subjects"
value={subjects.length}
subtitle={`${reportCardData.filter((d) => d.grades.length > 0).length} with grades`}
color="#10b981"
/>
<StatCard
title="Total Grades"
value={grades.length}
subtitle={`${
selectedPeriod
? reportCardData.reduce((sum, d) => sum + d.grades.length, 0)
: grades.length
} in period`}
color="#f59e0b"
/>
</div>
{/* Improvement Tips */}
{selectedPeriod && reportCardData.length > 0 && (
(() => {
const tipsForSubjects = reportCardData
.filter(data => data.categoryAverages.length > 1 && data.categoryAverages.every(c => c.average > 0))
.map(data => {
const sortedCategories = [...data.categoryAverages].sort((a, b) => a.average - b.average);
const weakest = sortedCategories[0];
const strongest = sortedCategories[sortedCategories.length - 1];
const difference = strongest.average - weakest.average;
if (difference > 5) {
return {
subject: data.subject,
weakest,
strongest,
difference
};
}
return null;
})
.filter(tip => tip !== null);
if (tipsForSubjects.length > 0) {
return (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">💡 Improvement Tips</h3>
<div className="mt-2 text-sm text-blue-700 space-y-2">
{tipsForSubjects.map((tip, idx) => (
<p key={idx}>
<strong>{tip!.subject.name}:</strong> Your {tip!.weakest.categoryName} ({formatGradeBySystem(
tip!.weakest.average,
primaryGradeSystem,
1
)}) needs work compared to {tip!.strongest.categoryName} ({formatGradeBySystem(
tip!.strongest.average,
primaryGradeSystem,
1
)}). Focus on {tip!.weakest.categoryName.toLowerCase()} to improve!
</p>
))}
</div>
</div>
</div>
</div>
);
}
return null;
})()
)}
{/* Report Card */}
{selectedPeriod && reportCardData.length > 0 ? (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Report Card - {selectedPeriod.name}
</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Subject
</th>
{reportCardData[0]?.categoryAverages.map((cat) => (
<th
key={cat.categoryName}
className="text-right py-3 px-4 font-semibold text-gray-700"
>
{cat.categoryName} ({cat.weight}%)
</th>
))}
<th className="text-right py-3 px-4 font-semibold text-gray-700">
Final Grade
</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">
Target
</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">
Diff
</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700">
Teacher's Grade
</th>
</tr>
</thead>
<tbody>
{reportCardData.map((data) => {
const teacherGrade = selectedPeriod ? teacherGrades.find(
tg => tg.subject_id === data.subject._id && tg.period_id === selectedPeriod._id
) : null;
const isEditing = editingTeacherGrade?.subjectId === data.subject._id;
// Calculate target progress for this subject
const targetProgress = calculateTargetProgress(data.overallAverage, data.subject);
return (
<tr key={data.subject._id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-medium">{data.subject.name}</td>
{data.categoryAverages.map((cat) => (
<td key={cat.categoryName} className="text-right py-3 px-4">
{cat.average > 0
? formatGradeBySystem(
cat.average,
primaryGradeSystem,
1
)
: "-"}
</td>
))}
<td className="text-right py-3 px-4 font-bold">
{teacherGrade ? (
<span className="text-green-700">
{formatGradeBySystem(
primaryGradeSystem === "german" && teacherGrade.max_grade === 6
? germanGradeToPercentage(teacherGrade.grade)
: (teacherGrade.grade / teacherGrade.max_grade) * 100,
primaryGradeSystem,
1
)}
{data.overallAverage > 0 && (
<span className="text-gray-500 text-sm font-normal">
{" "}({formatGradeBySystem(
data.overallAverage,
primaryGradeSystem,
1
)})
</span>
)}
</span>
) : (
<span className="text-blue-600">
{data.overallAverage > 0
? formatGradeBySystem(
data.overallAverage,
primaryGradeSystem,
1
)
: "-"}
</span>
)}
</td>
<td className="text-right py-3 px-4 text-gray-600">
{targetProgress.status !== "no-target"
? primaryGradeSystem === "german" && data.subject.target_grade_max === 6
? data.subject.target_grade?.toFixed(1)
: formatGradeBySystem(
targetProgress.targetPercentage,
primaryGradeSystem,
1
)
: "-"}
</td>
<td className={`text-right py-3 px-4 font-semibold ${getTargetStatusColor(targetProgress.status)}`}>
{targetProgress.status !== "no-target" && data.overallAverage > 0
? (targetProgress.difference > 0 ? "+" : "") + targetProgress.difference.toFixed(1) + "%"
: "-"}
</td>
<td className="text-right py-3 px-4">
{isEditing ? (
<div className="flex items-center gap-2 justify-end">
<input
type="number"
value={editingTeacherGrade.value}
onChange={(e) => setEditingTeacherGrade({
...editingTeacherGrade,
value: e.target.value
})}
className="w-20 px-2 py-1 border rounded text-sm"
placeholder={getGradeInputConfig(primaryGradeSystem).placeholder}
min={getGradeInputConfig(primaryGradeSystem).min}
max={getGradeInputConfig(primaryGradeSystem).max}
step={getGradeInputConfig(primaryGradeSystem).step}
/>
<button
onClick={handleSaveTeacherGrade}
className="text-green-600 hover:text-green-800"
title="Save"
>
</button>
<button
onClick={() => setEditingTeacherGrade(null)}
className="text-red-600 hover:text-red-800"
title="Cancel"
>
</button>
</div>
) : teacherGrade ? (
<div className="flex items-center gap-2 justify-end">
<span className="font-bold text-green-700">
{formatGradeBySystem(
primaryGradeSystem === "german" && teacherGrade.max_grade === 6
? germanGradeToPercentage(teacherGrade.grade)
: (teacherGrade.grade / teacherGrade.max_grade) * 100,
primaryGradeSystem,
1
)}
</span>
<button
onClick={() => handleTeacherGradeClick(data.subject._id)}
className="text-blue-600 hover:text-blue-800"
title="Edit"
>
</button>
<button
onClick={() => handleDeleteTeacherGrade(data.subject._id)}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Remove"
disabled={deletingTeacherGradeId === data.subject._id}
>
{deletingTeacherGradeId === data.subject._id ? "..." : "✗"}
</button>
</div>
) : (
<button
onClick={() => handleTeacherGradeClick(data.subject._id)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
+ Add
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-8">
<p className="text-yellow-800">
{periods.length === 0 ? (
<>
No report periods defined.{" "}
<Link to="/periods" className="underline font-medium">
Create your first period
</Link>
</>
) : (
"No grades recorded for this period yet."
)}
</p>
</div>
)}
{/* Subjects Grid */}
<div className="mb-6 flex justify-between items-center">
<h2 className="text-xl font-bold text-gray-900">Your Subjects</h2>
<Link to="/subjects/new">
<Button>+ Add Subject</Button>
</Link>
</div>
{subjects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{subjects.map((subject) => {
const subjectGrades = grades.filter((g) => g.subject_id === subject._id);
const avgData = reportCardData.find((d) => d.subject._id === subject._id);
const teacherGrade = selectedPeriod ? teacherGrades.find(
tg => tg.subject_id === subject._id && tg.period_id === selectedPeriod._id
) : null;
const calculatedAverage = avgData?.overallAverage;
const finalAverage = calculatedAverage !== undefined
? getSubjectFinalGrade(subject._id, calculatedAverage)
: undefined;
return (
<SubjectCard
key={subject._id}
subject={subject}
averageGrade={finalAverage}
calculatedAverage={calculatedAverage}
gradeSystem={primaryGradeSystem}
/>
);
})}
</div>
) : (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-600 mb-4">No subjects yet. Create your first subject!</p>
<Link to="/subjects/new">
<Button>+ Add Subject</Button>
</Link>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}

18
app/routes/home.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { api } from "~/api/client";
export default function Home() {
const navigate = useNavigate();
useEffect(() => {
// Redirect to dashboard if authenticated, otherwise to login
if (api.isAuthenticated()) {
navigate("/dashboard");
} else {
navigate("/login");
}
}, [navigate]);
return null;
}

78
app/routes/login.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { useState, type FormEvent } from "react";
import { useNavigate, Link } from "react-router";
import { api } from "~/api/client";
import { Button } from "~/components/Button";
import { Input } from "~/components/Input";
export default function Login() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await api.login({ email, password });
navigate("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Grademaxxing</h1>
<p className="text-gray-600 mt-2">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Input
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
autoComplete="email"
/>
<Input
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
Don't have an account?{" "}
<Link to="/register" className="text-blue-600 hover:text-blue-700 font-medium">
Sign up
</Link>
</p>
</div>
</div>
);
}

256
app/routes/periods.tsx Normal file
View File

@@ -0,0 +1,256 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link } from "react-router";
import { ProtectedRoute } from "~/components/ProtectedRoute";
import { LoadingSpinner } from "~/components/LoadingSpinner";
import { Button } from "~/components/Button";
import { Input } from "~/components/Input";
import { api } from "~/api/client";
import type { ReportPeriod, ReportPeriodCreate, ReportPeriodUpdate } from "~/types/api";
export default function ReportPeriods() {
const [periods, setPeriods] = useState<ReportPeriod[]>([]);
const [loading, setLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [editingPeriod, setEditingPeriod] = useState<ReportPeriod | null>(null);
const [name, setName] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [error, setError] = useState("");
useEffect(() => {
loadPeriods();
}, []);
const loadPeriods = async () => {
try {
const data = await api.getPeriods();
setPeriods(data);
} catch (error) {
console.error("Failed to load periods:", error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (new Date(endDate) <= new Date(startDate)) {
setError("End date must be after start date");
return;
}
try {
if (editingPeriod) {
// Update existing period
const updateData: ReportPeriodUpdate = {
name: name.trim(),
start_date: new Date(startDate).toISOString(),
end_date: new Date(endDate).toISOString(),
};
const updated = await api.updatePeriod(editingPeriod._id, updateData);
setPeriods(periods.map((p) => (p._id === editingPeriod._id ? updated : p)));
} else {
// Create new period
const periodData: ReportPeriodCreate = {
name: name.trim(),
start_date: new Date(startDate).toISOString(),
end_date: new Date(endDate).toISOString(),
};
const newPeriod = await api.createPeriod(periodData);
setPeriods([newPeriod, ...periods]);
}
setName("");
setStartDate("");
setEndDate("");
setShowAddForm(false);
setEditingPeriod(null);
} catch (err) {
setError(err instanceof Error ? err.message : editingPeriod ? "Failed to update period" : "Failed to create period");
}
};
const handleEdit = (period: ReportPeriod) => {
setEditingPeriod(period);
setName(period.name);
setStartDate(new Date(period.start_date).toISOString().split("T")[0]);
setEndDate(new Date(period.end_date).toISOString().split("T")[0]);
setShowAddForm(true);
};
const handleCancelEdit = () => {
setEditingPeriod(null);
setName("");
setStartDate("");
setEndDate("");
setShowAddForm(false);
};
const handleDelete = async (periodId: string) => {
if (!confirm("Delete this report period?")) return;
try {
await api.deletePeriod(periodId);
setPeriods(periods.filter((p) => p._id !== periodId));
} catch (error) {
alert("Failed to delete period");
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
</ProtectedRoute>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link to="/dashboard" className="text-blue-600 hover:text-blue-700 text-sm">
Back to Dashboard
</Link>
<h1 className="text-2xl font-bold text-gray-900 mt-1">Report Periods</h1>
<p className="text-sm text-gray-600 mt-1">
Define time periods for your report cards (e.g., semesters, quarters)
</p>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Add Period Button/Form */}
<div className="mb-6">
{!showAddForm ? (
<Button onClick={() => setShowAddForm(true)}>+ Add Period</Button>
) : (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">{editingPeriod ? "Edit Report Period" : "Create Report Period"}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Period Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., First Semester 2024/2025"
required
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
<Input
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit">{editingPeriod ? "Update Period" : "Create Period"}</Button>
<Button
type="button"
variant="secondary"
onClick={handleCancelEdit}
>
Cancel
</Button>
</div>
</form>
</div>
)}
</div>
{/* Periods List */}
{periods.length > 0 ? (
<div className="space-y-4">
{periods.map((period) => (
<div key={period._id} className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{period.name}</h3>
<p className="text-sm text-gray-600 mt-1">
{new Date(period.start_date).toLocaleDateString()} -{" "}
{new Date(period.end_date).toLocaleDateString()}
</p>
<p className="text-xs text-gray-500 mt-2">
Duration:{" "}
{Math.ceil(
(new Date(period.end_date).getTime() -
new Date(period.start_date).getTime()) /
(1000 * 60 * 60 * 24)
)}{" "}
days
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleEdit(period)}
className="text-blue-500 hover:text-blue-700"
aria-label="Edit period"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => handleDelete(period._id)}
className="text-red-500 hover:text-red-700"
aria-label="Delete period"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-600 mb-4">
No report periods yet. Create your first period!
</p>
<Button onClick={() => setShowAddForm(true)}>+ Add Period</Button>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}

113
app/routes/register.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useState, type FormEvent } from "react";
import { useNavigate, Link } from "react-router";
import { api } from "~/api/client";
import { Button } from "~/components/Button";
import { Input } from "~/components/Input";
export default function Register() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
try {
await api.register({ email, username, password });
navigate("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Grademaxxing</h1>
<p className="text-gray-600 mt-2">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Input
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
autoComplete="email"
/>
<Input
type="text"
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="johndoe"
required
autoComplete="username"
minLength={3}
/>
<Input
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
minLength={6}
/>
<Input
type="password"
label="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="new-password"
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account..." : "Sign Up"}
</Button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
Already have an account?{" "}
<Link to="/login" className="text-blue-600 hover:text-blue-700 font-medium">
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,305 @@
import { useState, useEffect, type FormEvent } from "react";
import { useNavigate, useParams, Link } from "react-router";
import { ProtectedRoute } from "~/components/ProtectedRoute";
import { Button } from "~/components/Button";
import { Input } from "~/components/Input";
import { GradingCategorySelector } from "~/components/GradingCategorySelector";
import { LoadingSpinner } from "~/components/LoadingSpinner";
import { api } from "~/api/client";
import type { Subject, GradingCategory } from "~/types/api";
import { GRADE_SYSTEMS, type GradeSystem } from "~/utils/gradeSystems";
const getMaxGradeForSystem = (system: GradeSystem): string => {
switch (system) {
case "german":
return "6";
case "us-letter":
case "percentage":
default:
return "100";
}
};
export default function EditSubject() {
const navigate = useNavigate();
const params = useParams();
const subjectId = params.subjectId;
const [subject, setSubject] = useState<Subject | null>(null);
const [name, setName] = useState("");
const [teacher, setTeacher] = useState("");
const [color, setColor] = useState("#3b82f6");
const [gradeSystem, setGradeSystem] = useState<GradeSystem>("percentage");
const [targetGrade, setTargetGrade] = useState("");
const [targetMaxGrade, setTargetMaxGrade] = useState("");
const [categories, setCategories] = useState<GradingCategory[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchSubject = async () => {
if (!subjectId) {
setError("Invalid subject ID");
setLoading(false);
return;
}
try {
const subjects = await api.getSubjects();
const foundSubject = subjects.find((s) => s._id === subjectId);
if (!foundSubject) {
setError("Subject not found");
setLoading(false);
return;
}
setSubject(foundSubject);
setName(foundSubject.name);
setTeacher(foundSubject.teacher || "");
setColor(foundSubject.color ?? "#3b82f6");
setGradeSystem((foundSubject.grade_system as GradeSystem) || "percentage");
setTargetGrade(foundSubject.target_grade?.toString() || "");
setTargetMaxGrade(foundSubject.target_grade_max?.toString() || "");
setCategories(foundSubject.grading_categories);
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch subject");
setLoading(false);
}
};
fetchSubject();
}, [subjectId]);
// Auto-update max grade when grade system changes
useEffect(() => {
setTargetMaxGrade(getMaxGradeForSystem(gradeSystem));
}, [gradeSystem]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!name.trim()) {
setError("Subject name is required");
return;
}
if (categories.length === 0) {
setError("Add at least one grading category");
return;
}
const totalWeight = categories.reduce((sum, cat) => sum + cat.weight, 0);
if (Math.abs(totalWeight - 100) > 0.01) {
setError("Category weights must sum to 100%");
return;
}
if (categories.some((cat) => !cat.name.trim())) {
setError("All categories must have a name");
return;
}
if (!subjectId) {
setError("Invalid subject ID");
return;
}
setSaving(true);
try {
await api.updateSubject(subjectId, {
name: name.trim(),
teacher: teacher.trim() || undefined,
color,
grade_system: gradeSystem,
grading_categories: categories,
target_grade: targetGrade ? parseFloat(targetGrade) : undefined,
target_grade_max: targetMaxGrade ? parseFloat(targetMaxGrade) : undefined,
});
navigate(`/subjects/${subjectId}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update subject");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<LoadingSpinner />
</div>
</ProtectedRoute>
);
}
if (error && !subject) {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-red-50 border border-red-200 text-red-700 px-6 py-4 rounded-lg">
{error}
</div>
</div>
</ProtectedRoute>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
to={`/subjects/${subjectId}`}
className="text-blue-600 hover:text-blue-700 text-sm"
>
Back to {subject?.name || "Subject"}
</Link>
<h1 className="text-2xl font-bold text-gray-900 mt-1">Edit Subject</h1>
</div>
</header>
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<Input
label="Subject Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Mathematics, English, Biology"
required
/>
<Input
label="Teacher (Optional)"
value={teacher}
onChange={(e) => setTeacher(e.target.value)}
placeholder="e.g., Mrs. Smith"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Subject Color
</label>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-20 h-10 rounded cursor-pointer border border-gray-300"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Grade Display System
</label>
<select
value={gradeSystem}
onChange={(e) => setGradeSystem(e.target.value as GradeSystem)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{Object.entries(GRADE_SYSTEMS).map(([key, system]) => (
<option key={key} value={key}>
{system.displayName} - {system.description}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Choose how grades will be displayed for this subject
</p>
</div>
<GradingCategorySelector
categories={categories}
onChange={setCategories}
/>
<div className="border-t pt-6">
<h3 className="text-sm font-medium text-gray-700 mb-4">
Target Grade (Optional)
</h3>
<div className="grid grid-cols-2 gap-4">
<Input
label="Target Grade"
type="number"
step="0.01"
value={targetGrade}
onChange={(e) => setTargetGrade(e.target.value)}
placeholder={gradeSystem === "german" ? "e.g., 2.0" : "e.g., 85"}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Grade
</label>
<div className="px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-700">
{targetMaxGrade}
</div>
<p className="text-xs text-gray-500 mt-1">
Auto-set by grade system
</p>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
Set a target grade to track your progress. Leave empty if not needed.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Grade Display System
</label>
<select
value={gradeSystem}
onChange={(e) => setGradeSystem(e.target.value as GradeSystem)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{Object.entries(GRADE_SYSTEMS).map(([key, system]) => (
<option key={key} value={key}>
{system.displayName} - {system.description}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Choose how grades will be displayed for this subject
</p>
</div>
<div>
<GradingCategorySelector
categories={categories}
onChange={setCategories}
/>
<p className="text-xs text-gray-500 mt-2">
Changing categories may affect how existing grades are calculated
</p>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={saving} className="flex-1">
{saving ? "Saving..." : "Save Changes"}
</Button>
<Link to={`/subjects/${subjectId}`} className="flex-1">
<Button type="button" variant="secondary" className="w-full">
Cancel
</Button>
</Link>
</div>
</form>
</div>
</main>
</div>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,486 @@
import { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router";
import { ProtectedRoute } from "~/components/ProtectedRoute";
import { GradeTable } from "~/components/GradeTable";
import { GradeCalculator } from "~/components/GradeCalculator";
import { LoadingSpinner } from "~/components/LoadingSpinner";
import { Button } from "~/components/Button";
import { Input } from "~/components/Input";
import { api } from "~/api/client";
import type { Subject, Grade, GradeCreate, GradeUpdate } from "~/types/api";
import { calculateSubjectAverage, calculateTargetProgress, getTargetStatusColor } from "~/utils/gradeCalculations";
import { formatGradeBySystem, getGermanGradeDescription, germanGradeToPercentage, type GradeSystem } from "~/utils/gradeSystems";
export default function SubjectDetail() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const [subject, setSubject] = useState<Subject | null>(null);
const [grades, setGrades] = useState<Grade[]>([]);
const [loading, setLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [editingGrade, setEditingGrade] = useState<Grade | null>(null);
const [deletingGradeId, setDeletingGradeId] = useState<string | null>(null);
// Form state
const [gradeName, setGradeName] = useState("");
const [gradeValue, setGradeValue] = useState("");
const [maxGrade, setMaxGrade] = useState("100");
const [category, setCategory] = useState("");
const [weight, setWeight] = useState("1");
const [date, setDate] = useState(new Date().toISOString().split("T")[0]);
const [notes, setNotes] = useState("");
const [error, setError] = useState("");
// Get input parameters based on grading system
const getGradeInputConfig = () => {
const system = (subject?.grade_system || "percentage") as GradeSystem;
switch (system) {
case "german":
return {
label: "German Grade",
placeholder: "1.0",
min: "1",
max: "6",
step: "0.1",
maxGradeValue: "6",
showMaxGradeInput: false,
helpText: "1 = sehr gut, 6 = ungenügend"
};
case "us-letter":
return {
label: "Percentage",
placeholder: "85",
min: "0",
max: "100",
step: "0.01",
maxGradeValue: "100",
showMaxGradeInput: false,
helpText: "Enter as percentage (0-100)"
};
default: // percentage
return {
label: "Grade",
placeholder: "85",
min: "0",
max: undefined,
step: "0.01",
maxGradeValue: "100",
showMaxGradeInput: true,
helpText: undefined
};
}
};
useEffect(() => {
loadData();
}, [subjectId]);
const loadData = async () => {
if (!subjectId) return;
try {
const [subjectData, gradesData] = await Promise.all([
api.getSubject(subjectId),
api.getGrades(subjectId),
]);
setSubject(subjectData);
setGrades(gradesData);
if (subjectData.grading_categories.length > 0) {
setCategory(subjectData.grading_categories[0].name);
}
} catch (error) {
console.error("Failed to load subject:", error);
navigate("/subjects");
} finally {
setLoading(false);
}
};
const handleSubmitGrade = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!subject) return;
const config = getGradeInputConfig();
const finalMaxGrade = config.showMaxGradeInput ? parseFloat(maxGrade) : parseFloat(config.maxGradeValue);
try {
if (editingGrade) {
// Update existing grade
const updateData: GradeUpdate = {
category_name: category,
grade: parseFloat(gradeValue),
max_grade: finalMaxGrade,
weight_in_category: parseFloat(weight),
name: gradeName.trim() || undefined,
date: new Date(date).toISOString(),
notes: notes.trim() || undefined,
};
const updated = await api.updateGrade(editingGrade._id, updateData);
setGrades(grades.map((g) => (g._id === editingGrade._id ? updated : g)));
} else {
// Create new grade
const gradeData: GradeCreate = {
subject_id: subject._id,
category_name: category,
grade: parseFloat(gradeValue),
max_grade: finalMaxGrade,
weight_in_category: parseFloat(weight),
name: gradeName.trim() || undefined,
date: new Date(date).toISOString(),
notes: notes.trim() || undefined,
};
const newGrade = await api.createGrade(gradeData);
setGrades([newGrade, ...grades]);
}
// Reset form
setGradeName("");
setGradeValue("");
setMaxGrade("100");
setWeight("1");
setDate(new Date().toISOString().split("T")[0]);
setNotes("");
setShowAddForm(false);
setEditingGrade(null);
} catch (err) {
setError(err instanceof Error ? err.message : editingGrade ? "Failed to update grade" : "Failed to add grade");
}
};
const gradeInputConfig = subject ? getGradeInputConfig() : null;
const handleEditGrade = (grade: Grade) => {
setEditingGrade(grade);
setGradeName(grade.name || "");
setGradeValue(grade.grade.toString());
setMaxGrade(grade.max_grade.toString());
setCategory(grade.category_name);
setWeight(grade.weight_in_category.toString());
setDate(new Date(grade.date).toISOString().split("T")[0]);
setNotes(grade.notes || "");
setShowAddForm(true);
};
const handleCancelEdit = () => {
setEditingGrade(null);
setGradeName("");
setGradeValue("");
setMaxGrade("100");
setWeight("1");
setDate(new Date().toISOString().split("T")[0]);
setNotes("");
setShowAddForm(false);
};
const handleDeleteGrade = async (gradeId: string) => {
if (!confirm("Delete this grade?")) return;
try {
setDeletingGradeId(gradeId);
await api.deleteGrade(gradeId);
setGrades(grades.filter((g) => g._id !== gradeId));
} catch (error) {
alert("Failed to delete grade");
} finally {
setDeletingGradeId(null);
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
</ProtectedRoute>
);
}
if (!subject) {
return null;
}
const avgData = calculateSubjectAverage(subject, grades);
const targetProgress = calculateTargetProgress(avgData.overallAverage, subject);
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link to="/subjects" className="text-blue-600 hover:text-blue-700 text-sm">
Back to Subjects
</Link>
<div className="flex justify-between items-center mt-2">
<div>
<h1 className="text-2xl font-bold text-gray-900">{subject.name}</h1>
{subject.teacher && (
<p className="text-sm text-gray-600">{subject.teacher}</p>
)}
</div>
<div className="text-right">
<div className="text-3xl font-bold" style={{ color: subject.color }}>
{avgData.overallAverage > 0 ? formatGradeBySystem(
avgData.overallAverage,
(subject.grade_system as GradeSystem) || "percentage",
1
) : "-"}
</div>
<p className="text-sm text-gray-600">Overall Average</p>
{targetProgress.status !== "no-target" && avgData.overallAverage > 0 && (
<div className="mt-2 pt-2 border-t">
<div className="flex items-center gap-2 justify-end text-sm">
<span className="text-gray-600">Target:</span>
<span className="font-semibold">
{formatGradeBySystem(
targetProgress.targetPercentage,
(subject.grade_system as GradeSystem) || "percentage",
1
)}
</span>
</div>
<div className={`text-sm font-semibold ${getTargetStatusColor(targetProgress.status)}`}>
{targetProgress.difference > 0 ? "+" : ""}{targetProgress.difference.toFixed(1)}%
{targetProgress.status === "above" ? "above target" : targetProgress.status === "near" ? "near target" : "below target"}
</div>
</div>
)}
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Improvement Tip */}
{avgData.categoryAverages.length > 1 && avgData.categoryAverages.every(c => c.average > 0) && (
(() => {
const sortedCategories = [...avgData.categoryAverages].sort((a, b) => a.average - b.average);
const weakest = sortedCategories[0];
const strongest = sortedCategories[sortedCategories.length - 1];
const difference = strongest.average - weakest.average;
if (difference > 5) { // Only show if there's a significant difference
return (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800"><EFBFBD> Improvement Tip</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
Your <strong>{weakest.categoryName}</strong> grades ({formatGradeBySystem(
weakest.average,
(subject.grade_system as GradeSystem) || "percentage",
1
)}) are lower than your <strong>{strongest.categoryName}</strong> ({formatGradeBySystem(
strongest.average,
(subject.grade_system as GradeSystem) || "percentage",
1
)}).
Focus more on improving your {weakest.categoryName.toLowerCase()} performance to boost your overall grade!
</p>
</div>
</div>
</div>
</div>
);
}
return null;
})()
)}
{/* Grade Calculator */}
<div className="mb-6">
<GradeCalculator subject={subject} currentGrades={grades} />
</div>
{/* Category Averages */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{avgData.categoryAverages.map((cat) => (
<div
key={cat.categoryName}
className="bg-white rounded-lg border-2 p-4"
style={{ borderColor: cat.color || "#e5e7eb" }}
>
<p className="text-sm font-medium text-gray-600">{cat.categoryName}</p>
<p className="text-2xl font-bold mt-1" style={{ color: cat.color }}>
{cat.average > 0 ? formatGradeBySystem(
cat.average,
(subject.grade_system as GradeSystem) || "percentage",
1
) : "-"}
</p>
<p className="text-sm text-gray-500">Weight: {cat.weight}%</p>
</div>
))}
</div>
{/* Add Grade Button/Form */}
<div className="mb-6">
{!showAddForm ? (
<div className="flex gap-3">
<Button onClick={() => setShowAddForm(true)}>+ Add Grade</Button>
<Link to={`/subjects/${subjectId}/edit`}>
<Button variant="secondary">Edit Subject</Button>
</Link>
</div>
) : (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">{editingGrade ? "Edit Grade" : "Add New Grade"}</h3>
<form onSubmit={handleSubmitGrade} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Grade Name (Optional)"
value={gradeName}
onChange={(e) => setGradeName(e.target.value)}
placeholder="e.g., Midterm Exam"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
required
>
{subject.grading_categories.map((cat) => (
<option key={cat.name} value={cat.name}>
{cat.name} ({cat.weight}%)
</option>
))}
</select>
</div>
<div>
<Input
label={gradeInputConfig?.label || "Grade"}
type="number"
value={gradeValue}
onChange={(e) => setGradeValue(e.target.value)}
placeholder={gradeInputConfig?.placeholder || "85"}
required
min={gradeInputConfig?.min || "0"}
max={gradeInputConfig?.max}
step={gradeInputConfig?.step || "0.01"}
/>
{gradeInputConfig?.helpText && (
<p className="text-xs text-gray-500 mt-1">{gradeInputConfig.helpText}</p>
)}
</div>
{gradeInputConfig?.showMaxGradeInput && (
<Input
label="Max Grade"
type="number"
value={maxGrade}
onChange={(e) => setMaxGrade(e.target.value)}
placeholder="100"
required
min="0"
step="0.01"
/>
)}
</div>
{/* Grade Preview */}
{gradeValue && gradeInputConfig && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-medium text-blue-900 mb-1">Grade Preview</div>
{subject?.grade_system === "german" ? (
<>
<div className="text-2xl font-bold text-blue-700">
{parseFloat(gradeValue).toFixed(1)}
</div>
<div className="text-sm text-blue-600 mt-1">
{getGermanGradeDescription(parseFloat(gradeValue))}
</div>
<div className="text-xs text-blue-600 mt-2">
Percentage equivalent: {germanGradeToPercentage(parseFloat(gradeValue)).toFixed(1)}%
</div>
</>
) : (
<>
<div className="text-2xl font-bold text-blue-700">
{formatGradeBySystem(
gradeInputConfig.showMaxGradeInput && maxGrade && parseFloat(maxGrade) > 0
? (parseFloat(gradeValue) / parseFloat(maxGrade)) * 100
: parseFloat(gradeValue),
(subject?.grade_system as GradeSystem) || "percentage"
)}
</div>
{gradeInputConfig.showMaxGradeInput && maxGrade && parseFloat(maxGrade) > 0 && (
<div className="text-xs text-blue-600 mt-2">
Percentage: {((parseFloat(gradeValue) / parseFloat(maxGrade)) * 100).toFixed(1)}%
</div>
)}
</>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Weight in Category"
type="number"
value={weight}
onChange={(e) => setWeight(e.target.value)}
placeholder="1"
required
min="0"
step="0.1"
/>
<Input
label="Date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<Input
label="Notes (Optional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Additional notes..."
/>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit">{editingGrade ? "Update Grade" : "Add Grade"}</Button>
<Button
type="button"
variant="secondary"
onClick={handleCancelEdit}
>
Cancel
</Button>
</div>
</form>
</div>
)}
</div>
{/* Grades Table */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">All Grades</h3>
<GradeTable
grades={grades}
subject={subject}
onEdit={handleEditGrade}
onDelete={handleDeleteGrade}
deletingGradeId={deletingGradeId}
/>
</div>
</main>
</div>
</ProtectedRoute>
);
}

206
app/routes/subjects.new.tsx Normal file
View File

@@ -0,0 +1,206 @@
import { useState, useEffect, type FormEvent } from "react";
import { useNavigate, Link } from "react-router";
import { ProtectedRoute } from "~/components/ProtectedRoute";
import { Button } from "~/components/Button";
import { Input } from "~/components/Input";
import { GradingCategorySelector } from "~/components/GradingCategorySelector";
import { api } from "~/api/client";
import type { GradingCategory } from "~/types/api";
import { GRADE_SYSTEMS, type GradeSystem } from "~/utils/gradeSystems";
const getMaxGradeForSystem = (system: GradeSystem): string => {
switch (system) {
case "german":
return "6";
case "us-letter":
case "percentage":
default:
return "100";
}
};
export default function NewSubject() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [teacher, setTeacher] = useState("");
const [color, setColor] = useState("#3b82f6");
const [gradeSystem, setGradeSystem] = useState<GradeSystem>("percentage");
const [targetGrade, setTargetGrade] = useState("");
const [targetMaxGrade, setTargetMaxGrade] = useState("100");
// Auto-update max grade when grade system changes
useEffect(() => {
setTargetMaxGrade(getMaxGradeForSystem(gradeSystem));
}, [gradeSystem]);
const [categories, setCategories] = useState<GradingCategory[]>([
{ name: "Written", weight: 50, color: "#3b82f6" },
{ name: "Oral", weight: 50, color: "#10b981" },
]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!name.trim()) {
setError("Subject name is required");
return;
}
if (categories.length === 0) {
setError("Add at least one grading category");
return;
}
const totalWeight = categories.reduce((sum, cat) => sum + cat.weight, 0);
if (Math.abs(totalWeight - 100) > 0.01) {
setError("Category weights must sum to 100%");
return;
}
if (categories.some((cat) => !cat.name.trim())) {
setError("All categories must have a name");
return;
}
setLoading(true);
try {
await api.createSubject({
name: name.trim(),
teacher: teacher.trim() || undefined,
color,
grade_system: gradeSystem,
grading_categories: categories,
target_grade: targetGrade ? parseFloat(targetGrade) : undefined,
target_grade_max: targetMaxGrade ? parseFloat(targetMaxGrade) : undefined,
});
navigate("/subjects");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create subject");
} finally {
setLoading(false);
}
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link to="/subjects" className="text-blue-600 hover:text-blue-700 text-sm">
Back to Subjects
</Link>
<h1 className="text-2xl font-bold text-gray-900 mt-1">Create New Subject</h1>
</div>
</header>
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<Input
label="Subject Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Mathematics, English, Biology"
required
/>
<Input
label="Teacher (Optional)"
value={teacher}
onChange={(e) => setTeacher(e.target.value)}
placeholder="e.g., Mrs. Smith"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Subject Color
</label>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-20 h-10 rounded cursor-pointer border border-gray-300"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Grade Display System
</label>
<select
value={gradeSystem}
onChange={(e) => setGradeSystem(e.target.value as GradeSystem)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
{Object.entries(GRADE_SYSTEMS).map(([key, system]) => (
<option key={key} value={key}>
{system.displayName} - {system.description}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Choose how grades will be displayed for this subject
</p>
</div>
<GradingCategorySelector
categories={categories}
onChange={setCategories}
/>
<div className="border-t pt-6">
<h3 className="text-sm font-medium text-gray-700 mb-4">
Target Grade (Optional)
</h3>
<div className="grid grid-cols-2 gap-4">
<Input
label="Target Grade"
type="number"
step="0.01"
value={targetGrade}
onChange={(e) => setTargetGrade(e.target.value)}
placeholder={gradeSystem === "german" ? "e.g., 2.0" : "e.g., 85"}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Grade
</label>
<div className="px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-700">
{targetMaxGrade}
</div>
<p className="text-xs text-gray-500 mt-1">
Auto-set by grade system
</p>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
Set a target grade to track your progress. Leave empty if not needed.
</p>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Creating..." : "Create Subject"}
</Button>
<Link to="/subjects" className="flex-1">
<Button type="button" variant="secondary" className="w-full">
Cancel
</Button>
</Link>
</div>
</form>
</div>
</main>
</div>
</ProtectedRoute>
);
}

110
app/routes/subjects.tsx Normal file
View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { ProtectedRoute } from "~/components/ProtectedRoute";
import { SubjectCard } from "~/components/SubjectCard";
import { LoadingSpinner } from "~/components/LoadingSpinner";
import { Button } from "~/components/Button";
import { api } from "~/api/client";
import type { Subject, Grade } from "~/types/api";
import { calculateSubjectAverage } from "~/utils/gradeCalculations";
export default function SubjectsList() {
const [subjects, setSubjects] = useState<Subject[]>([]);
const [grades, setGrades] = useState<Grade[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [subjectsData, gradesData] = await Promise.all([
api.getSubjects(),
api.getGrades(),
]);
setSubjects(subjectsData);
setGrades(gradesData);
} catch (error) {
console.error("Failed to load subjects:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (subjectId: string) => {
if (!confirm("Are you sure? This will delete all grades for this subject.")) {
return;
}
try {
await api.deleteSubject(subjectId);
setSubjects(subjects.filter((s) => s._id !== subjectId));
setGrades(grades.filter((g) => g.subject_id !== subjectId));
} catch (error) {
alert("Failed to delete subject");
}
};
if (loading) {
return (
<ProtectedRoute>
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
</ProtectedRoute>
);
}
// Use the first subject's grading system as the primary system
const primaryGradeSystem = subjects[0]?.grade_system || "percentage";
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex justify-between items-center">
<div>
<Link to="/dashboard" className="text-blue-600 hover:text-blue-700 text-sm">
Back to Dashboard
</Link>
<h1 className="text-2xl font-bold text-gray-900 mt-1">Manage Subjects</h1>
</div>
<Link to="/subjects/new">
<Button>+ Add Subject</Button>
</Link>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{subjects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{subjects.map((subject) => {
const subjectGrades = grades.filter((g) => g.subject_id === subject._id);
const avgData = calculateSubjectAverage(subject, subjectGrades);
return (
<SubjectCard
key={subject._id}
subject={subject}
averageGrade={avgData.overallAverage}
gradeSystem={primaryGradeSystem}
onDelete={() => handleDelete(subject._id)}
/>
);
})}
</div>
) : (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-600 mb-4">No subjects yet. Create your first subject!</p>
<Link to="/subjects/new">
<Button>+ Add Subject</Button>
</Link>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}

149
app/types/api.ts Normal file
View File

@@ -0,0 +1,149 @@
// API Types matching backend models
export interface GradingCategory {
name: string;
weight: number;
color?: string;
}
export interface Subject {
_id: string;
name: string;
grading_categories: GradingCategory[];
color?: string;
teacher?: string;
grade_system?: string;
target_grade?: number;
target_grade_max?: number;
created_at: string;
updated_at: string;
}
export interface SubjectCreate {
name: string;
grading_categories: GradingCategory[];
color?: string;
teacher?: string;
grade_system?: string;
target_grade?: number;
target_grade_max?: number;
}
export interface SubjectUpdate {
name?: string;
grading_categories?: GradingCategory[];
color?: string;
teacher?: string;
grade_system?: string;
target_grade?: number;
target_grade_max?: number;
}
export interface Grade {
_id: string;
subject_id: string;
category_name: string;
grade: number;
max_grade: number;
weight_in_category: number;
name?: string;
date: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface GradeCreate {
subject_id: string;
category_name: string;
grade: number;
max_grade: number;
weight_in_category?: number;
name?: string;
date?: string;
notes?: string;
}
export interface GradeUpdate {
category_name?: string;
grade?: number;
max_grade?: number;
weight_in_category?: number;
name?: string;
date?: string;
notes?: string;
}
export interface ReportPeriod {
_id: string;
name: string;
start_date: string;
end_date: string;
created_at: string;
updated_at: string;
}
export interface ReportPeriodCreate {
name: string;
start_date: string;
end_date: string;
}
export interface ReportPeriodUpdate {
name?: string;
start_date?: string;
end_date?: string;
}
export interface TeacherGrade {
_id: string;
subject_id: string;
period_id: string;
grade: number;
max_grade: number;
notes?: string;
created_at: string;
updated_at: string;
}
export interface TeacherGradeCreate {
subject_id: string;
period_id: string;
grade: number;
max_grade: number;
notes?: string;
}
export interface TeacherGradeUpdate {
grade?: number;
max_grade?: number;
notes?: string;
}
export interface User {
_id: string;
email: string;
username: string;
created_at: string;
}
export interface UserLogin {
email: string;
password: string;
}
export interface UserRegister {
email: string;
username: string;
password: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
user: User;
}
export interface ApiError {
detail: string;
}

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