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

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Frontend environment variables
VITE_API_URL=http://localhost:8000/api
# Backend environment variables
# MongoDB connection string
# For local development: mongodb://localhost:27017
# For Docker: mongodb://mongodb:27017
# For remote MongoDB: mongodb://username:password@host:port
MONGODB_URL=mongodb://mongodb:27017
# Database name
DATABASE_NAME=grademaxxing
# JWT Secret Key - CHANGE THIS IN PRODUCTION!
# Generate a secure key with: python -c "import secrets; print(secrets.token_urlsafe(64))"
SECRET_KEY=your-super-secret-key-change-this-in-production
# JWT Algorithm
ALGORITHM=HS256
# Access token expiration in minutes (default: 43200 = 30 days)
ACCESS_TOKEN_EXPIRE_MINUTES=43200
# CORS allowed origins (comma-separated)
CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://127.0.0.1:8000

140
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,140 @@
# Grademaxxing - AI Coding Agent Instructions
## Project Architecture
**Fullstack SPA Architecture:**
- **Frontend:** React Router 7 SPA (client-side only, no SSR) built with Vite + TypeScript + Tailwind CSS v4
- **Backend:** FastAPI (Python) serves both API (`/api/*`) and built React SPA (`build/client/`)
- **Database:** MongoDB with Motor async driver
- **Deployment:** Docker Compose with 3 services (mongodb, frontend-build, backend)
**Critical Architectural Decision:** Backend serves the compiled React SPA from `build/client/` directory. All non-`/api/*` routes fall through to `index.html` for client-side routing. Frontend must be built before backend serves it.
## Essential Workflows
**Development:**
```bash
# Docker (recommended) - starts MongoDB + builds frontend + runs backend
docker-compose up --build
# Local development (3 terminals)
# Terminal 1: cd backend && uvicorn main:app --reload
# Terminal 2: pnpm dev
# Terminal 3: mongod --dbpath ./data
```
**Build & Deploy:**
```bash
pnpm build # Builds React SPA to build/client/
# Backend automatically serves from build/client/ when running
```
**Quick start scripts:** `start.bat` (Windows) or `./start.sh` (Linux/Mac) wrap `docker-compose up --build`
## Data Model & Business Logic
**Flexible Grading System (Not German-specific):**
- Each `Subject` has custom `grading_categories` (e.g., "Written" 60%, "Oral" 40%)
- Categories must sum to 100% weight (validated in [backend/models.py](backend/models.py#L74-L80))
- Each `Grade` has `grade/max_grade` (e.g., 85/100) and `weight_in_category` for multiple tests
- Supports any grading scale (percentage, German 1-6, US letters, etc.)
**Grade Calculation Pipeline ([app/utils/gradeCalculations.ts](app/utils/gradeCalculations.ts)):**
1. Normalize each grade to percentage: `(grade / max_grade) * 100`
2. Calculate category average: weighted sum of grades in category
3. Calculate subject average: weighted sum of category averages
4. Calculate overall GPA: simple average of all subject averages
**Report Periods:** Filter grades by date range for semester/quarter calculations (see [calculateReportCard](app/utils/gradeCalculations.ts#L80-L110))
## Code Conventions
**Backend (FastAPI + MongoDB):**
- All routes require auth via `Depends(get_current_user)` from [backend/routes/auth_routes.py](backend/routes/auth_routes.py)
- MongoDB ObjectId handling: Convert to string for JSON responses, convert to `ObjectId()` for queries
- Model pattern: `{Entity}Base``{Entity}Create/Update``{Entity}InDB``{Entity}Response` (see [backend/models.py](backend/models.py))
- Route structure: Each entity gets its own router in `backend/routes/`, imported in [main.py](backend/main.py#L40-L43)
- Always use `await` with Motor async operations: `await db.collection.find().to_list()`
**Frontend (React Router 7 SPA):**
- Route definitions in [app/routes.ts](app/routes.ts), files in `app/routes/` (use `.tsx` extension)
- All authenticated pages wrap children in `<ProtectedRoute>` component ([app/components/ProtectedRoute.tsx](app/components/ProtectedRoute.tsx))
- API client singleton: `import { api } from "~/api/client"` - handles auth headers automatically
- Auth token stored in localStorage (`access_token`), set on login/register, removed on logout
- Component pattern: Small reusable components in `app/components/`, import as `~/components/{Name}`
- Tailwind CSS only - no CSS modules or styled-components
**TypeScript Type Safety:**
- Backend types defined in [backend/models.py](backend/models.py) (Pydantic)
- Frontend types in [app/types/api.ts](app/types/api.ts) - **manually sync with backend models**
- Use `_id` (alias) for MongoDB ObjectId in responses, map to `id` field in TypeScript when needed
## Authentication & Authorization
**Flow:**
1. Login/register → Backend returns JWT + user data ([backend/routes/auth_routes.py](backend/routes/auth_routes.py))
2. Frontend stores token in localStorage ([app/api/client.ts](app/api/client.ts#L56-L58))
3. All protected API calls include `Authorization: Bearer {token}` header
4. Backend validates token with `get_current_user` dependency
5. Frontend uses `ProtectedRoute` wrapper to redirect unauthenticated users
**User Isolation:** All database queries filter by `user_id` (from token) - users only see their own data
## Common Patterns
**Adding a New Entity:**
1. Define Pydantic models in [backend/models.py](backend/models.py): `{Entity}Base`, `{Entity}Create`, `{Entity}InDB`, `{Entity}Response`
2. Create router in `backend/routes/{entity}_routes.py` with CRUD endpoints
3. Import and include router in [backend/main.py](backend/main.py)
4. Add TypeScript types to [app/types/api.ts](app/types/api.ts)
5. Add API client methods to [app/api/client.ts](app/api/client.ts)
6. Create routes/components in `app/`
**Adding a New Route:**
1. Create `app/routes/{name}.tsx` with default export component
2. Add route to [app/routes.ts](app/routes.ts) config array
3. Wrap in `<ProtectedRoute>` if auth required
**MongoDB Query Pattern:**
```python
db = get_database()
cursor = db.collection.find({"user_id": current_user.id})
items = await cursor.to_list(length=None)
for item in items:
item["_id"] = str(item["_id"]) # Convert ObjectId to string
return [ResponseModel(**item) for item in items]
```
## Critical Files
- [backend/main.py](backend/main.py) - FastAPI app initialization, SPA serving, route inclusion
- [app/api/client.ts](app/api/client.ts) - Centralized API client with auth
- [app/utils/gradeCalculations.ts](app/utils/gradeCalculations.ts) - Core business logic for grade calculations
- [backend/models.py](backend/models.py) - Pydantic models with validation (category weights must sum to 100%)
- [docker-compose.yml](docker-compose.yml) - Multi-stage build: frontend-build → backend serves result
## Known Issues & Gotchas
- Frontend build must complete before backend serves (`frontend-build` container in Docker)
- MongoDB ObjectId serialization: Always convert to string for JSON responses
- CORS: Backend allows origins from `CORS_ORIGINS` env var (comma-separated)
- Category weights validation: Must sum to 100% (enforced in Pydantic model)
- Date handling: Python uses `datetime.utcnow()`, frontend converts ISO strings
- No SSR: React Router in SPA mode only, backend serves static build
## Testing
**Manual testing:** See [DEVELOPMENT.md](DEVELOPMENT.md#L244-L254) checklist (register → create period → add subject → add grades → view dashboard)
**API Testing:** FastAPI auto-docs at `http://localhost:8000/docs` (Swagger UI)
## Environment Setup
**Required Environment Variables:**
- `MONGODB_URL` - MongoDB connection string (default: `mongodb://localhost:27017`)
- `SECRET_KEY` - JWT signing key (generate with `python -c "import secrets; print(secrets.token_urlsafe(64))"`)
- `DATABASE_NAME` - MongoDB database name (default: `grademaxxing`)
- `CORS_ORIGINS` - Comma-separated allowed origins (default: `http://localhost:5173,http://localhost:8000`)
- `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT expiration (default: 10080 = 7 days)
Frontend uses `VITE_API_URL` for API base URL (defaults to `http://localhost:8000/api`)

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/
# Python
__pycache__/
*.pyc
.env
# Docker
/docker-compose.override.yml
*.log
/db
.pnpm-store

237
README.md Normal file
View File

@@ -0,0 +1,237 @@
# Grademaxxing - Grade Management System
A full-stack web application for managing grades with flexible grading systems, supporting multiple users, custom grading categories, and report periods.
## Features
- 🔐 **Multi-user authentication** with JWT tokens
- 📚 **Flexible subject management** with custom grading categories (not limited to German system)
- 📊 **Grade tracking** with weighted categories
- 📅 **Report periods** for semester/quarter grades
- 📈 **Automatic calculations** for category averages and overall GPAs
- 🎨 **Modern UI** with Tailwind CSS
- 🐳 **Fully containerized** with Docker Compose
## Tech Stack
**Frontend:**
- React Router 7 (SPA mode)
- TypeScript
- Tailwind CSS v4
- Vite
**Backend:**
- FastAPI (Python)
- MongoDB (with Motor async driver)
- JWT authentication
- Pydantic validation
## Project Structure
```
grademaxxing/
├── app/ # Frontend React application
│ ├── api/ # API client
│ ├── components/ # Reusable UI components
│ ├── routes/ # Page routes
│ ├── types/ # TypeScript types
│ └── utils/ # Utility functions
├── backend/ # Python FastAPI backend
│ ├── routes/ # API endpoints
│ ├── main.py # FastAPI application
│ ├── models.py # Pydantic models
│ ├── database.py # MongoDB connection
│ ├── auth.py # Authentication utilities
│ ├── config.py # Configuration
│ └── requirements.txt # Python dependencies
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile.backend # Backend container
└── Dockerfile # Frontend container (optional)
```
## Quick Start with Docker
### Prerequisites
- Docker
- Docker Compose
### Setup
1. **Clone the repository**
```bash
git clone <repository-url>
cd grademaxxing
```
2. **Create environment file**
```bash
cp .env.example .env
```
Edit `.env` and configure the following required variables:
```env
# MongoDB connection string (for Docker use: mongodb://mongodb:27017)
MONGODB_URL=mongodb://mongodb:27017
# Database name
DATABASE_NAME=grademaxxing
# JWT Secret Key - GENERATE A SECURE KEY!
# Run: python -c "import secrets; print(secrets.token_urlsafe(64))"
SECRET_KEY=your-super-secret-key-change-this-in-production
# Token expiration (30 days = 43200 minutes)
ACCESS_TOKEN_EXPIRE_MINUTES=43200
# CORS origins
CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://127.0.0.1:8000
```
3. **Start all services**
```bash
docker-compose up --build
```
This will:
- Start MongoDB on port 27017
- Build the frontend
- Start the Python backend on port 8000
- Serve the frontend through the backend
4. **Access the application**
- Open your browser and navigate to: `http://localhost:8000`
- Create an account and start managing your grades!
### Docker Services
- **mongodb**: MongoDB database (port 27017)
- **frontend-build**: Builds the React SPA (runs once)
- **backend**: FastAPI server serving API and static files (port 8000)
### Stopping Services
```bash
# Stop all services
docker-compose down
# Stop and remove volumes (clears database)
docker-compose down -v
```
## Development
For local development without Docker, see [DEVELOPMENT.md](DEVELOPMENT.md).
## Environment Variables
All configuration is managed through the `.env` file in the project root.
**Required variables:**
| Variable | Description | Example |
|----------|-------------|---------|
| `MONGODB_URL` | MongoDB connection string | `mongodb://mongodb:27017` (Docker)<br>`mongodb://localhost:27017` (Local) |
| `DATABASE_NAME` | Database name | `grademaxxing` |
| `SECRET_KEY` | JWT signing key (keep secure!) | Generate with: `python -c "import secrets; print(secrets.token_urlsafe(64))"` |
| `ALGORITHM` | JWT algorithm | `HS256` |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Token expiration time | `43200` (30 days) |
| `CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:5173,http://localhost:8000` |
| `VITE_API_URL` | Frontend API endpoint | `http://localhost:8000/api` |
**Note:** The `.env.example` file contains all available variables with documentation.
## Usage Guide
### 1. Create an Account
- Register with email, username, and password
- Login to access your dashboard
### 2. Create Report Periods
- Go to "Report Periods"
- Define time periods (e.g., "First Semester 2024/2025")
- Set start and end dates
### 3. Add Subjects
- Click "Add Subject"
- Enter subject name and optional teacher
- Define grading categories with weights (must sum to 100%)
- Example: "Written" (60%), "Oral" (40%)
- Or: "Tests" (70%), "Homework" (20%), "Participation" (10%)
- Choose a color for visual identification
### 4. Add Grades
- Click on a subject
- Add grades with:
- Name (optional)
- Category
- Grade value and maximum
- Weight within category
- Date
- Notes (optional)
### 5. View Report Cards
- Dashboard shows overall average for selected period
- Report card table displays category and final grades per subject
- Automatic weighted average calculations
## API Documentation
Once the backend is running, visit:
- API docs: `http://localhost:8000/docs`
- Alternative docs: `http://localhost:8000/redoc`
### Main Endpoints
**Authentication:**
- `POST /api/auth/register` - Create account
- `POST /api/auth/login` - Login
- `GET /api/auth/me` - Get current user
**Subjects:**
- `GET /api/subjects` - List subjects
- `POST /api/subjects` - Create subject
- `PUT /api/subjects/{id}` - Update subject
- `DELETE /api/subjects/{id}` - Delete subject
**Grades:**
- `GET /api/grades?subject_id={id}` - List grades
- `POST /api/grades` - Create grade
- `PUT /api/grades/{id}` - Update grade
- `DELETE /api/grades/{id}` - Delete grade
**Report Periods:**
- `GET /api/periods` - List periods
- `POST /api/periods` - Create period
- `PUT /api/periods/{id}` - Update period
- `DELETE /api/periods/{id}` - Delete period
## Building for Production
### Using Docker Compose
```bash
docker-compose up --build -d
```
### Manual Build
**Frontend:**
```bash
pnpm build
```
Output: `build/client/`
**Backend:**
```bash
cd backend
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000
```
## License
MIT
## Contributing
Contributions welcome! Please open an issue or submit a pull request.

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

38
backend/auth.py Normal file
View File

@@ -0,0 +1,38 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt # type: ignore[import]
from passlib.context import CryptContext
from config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""Decode a JWT access token"""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None

23
backend/config.py Normal file
View File

@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
mongodb_url: str
database_name: str = "grademaxxing"
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 43200 # 30 days
vite_api_url: str = "http://localhost:8000/api"
cors_origins: str = "http://localhost:5173,http://localhost:8000,http://127.0.0.1:8000"
@property
def cors_origins_list(self) -> List[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()

26
backend/database.py Normal file
View File

@@ -0,0 +1,26 @@
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
from config import settings
client: AsyncIOMotorClient = None
database: AsyncIOMotorDatabase = None
async def connect_to_mongo():
"""Connect to MongoDB"""
global client, database
client = AsyncIOMotorClient(settings.mongodb_url)
database = client[settings.database_name]
print(f"Connected to MongoDB at {settings.mongodb_url}")
async def close_mongo_connection():
"""Close MongoDB connection"""
global client
if client:
client.close()
print("Closed MongoDB connection")
def get_database() -> AsyncIOMotorDatabase:
"""Get database instance"""
return database

168
backend/main.py Normal file
View File

@@ -0,0 +1,168 @@
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, StreamingResponse
from contextlib import asynccontextmanager
import os
from pathlib import Path
from io import StringIO
import csv
from typing import Optional
from config import settings
from database import connect_to_mongo, close_mongo_connection, get_database
from routes import auth_routes, subject_routes, grade_routes, period_routes, teacher_grade_routes
from routes.auth_routes import get_current_user
from models import UserInDB
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events"""
# Startup
await connect_to_mongo()
yield
# Shutdown
await close_mongo_connection()
app = FastAPI(
title="Grademaxxing API",
description="Grade management system API",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes
app.include_router(auth_routes.router, prefix="/api")
app.include_router(subject_routes.router, prefix="/api")
app.include_router(grade_routes.router, prefix="/api")
app.include_router(period_routes.router, prefix="/api")
app.include_router(teacher_grade_routes.router, prefix="/api")
@app.get("/api/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "version": "1.0.0"}
@app.get("/api/export/grades")
async def export_grades_csv(
period_id: Optional[str] = None,
current_user: UserInDB = Depends(get_current_user)
):
"""Export all grades as CSV, optionally filtered by period"""
db = get_database()
# Get all subjects for the user
subjects_cursor = db.subjects.find({"user_id": current_user.id})
subjects = await subjects_cursor.to_list(length=None)
subject_dict = {str(subject["_id"]): subject for subject in subjects}
# Build grade query
grade_query = {"user_id": current_user.id}
# If period_id is provided, filter grades by date range
if period_id:
period = await db.periods.find_one({"_id": period_id, "user_id": current_user.id})
if period:
grade_query["date"] = {
"$gte": period["start_date"],
"$lte": period["end_date"]
}
# Get all grades
grades_cursor = db.grades.find(grade_query).sort("date", -1)
grades = await grades_cursor.to_list(length=None)
# Create CSV in memory
output = StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
"Subject",
"Category",
"Grade Name",
"Grade",
"Max Grade",
"Percentage",
"Weight in Category",
"Date",
"Notes"
])
# Write grade rows
for grade in grades:
subject = subject_dict.get(grade["subject_id"])
subject_name = subject["name"] if subject else "Unknown Subject"
# Calculate percentage
percentage = (grade["grade"] / grade["max_grade"] * 100) if grade["max_grade"] > 0 else 0
writer.writerow([
subject_name,
grade.get("category_name", ""),
grade.get("name", ""),
grade["grade"],
grade["max_grade"],
f"{percentage:.2f}%",
grade.get("weight_in_category", 1.0),
grade["date"].strftime("%Y-%m-%d") if "date" in grade else "",
grade.get("notes", "")
])
# Prepare response
output.seek(0)
filename = f"grades_export_{current_user.username}.csv"
if period_id:
period = await db.periods.find_one({"_id": period_id, "user_id": current_user.id})
if period:
filename = f"grades_{period['name'].replace(' ', '_')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
# Serve static files (React SPA)
static_dir = Path(__file__).parent.parent / "build" / "client"
if static_dir.exists():
app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve React SPA for all non-API routes"""
# Check if the file exists
file_path = static_dir / full_path
if file_path.is_file():
return FileResponse(file_path)
# For all other routes, serve index.html (SPA routing)
index_path = static_dir / "index.html"
if index_path.exists():
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Not found")
else:
print(f"Warning: Static directory not found at {static_dir}")
print("Run the frontend build first to serve the UI")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

265
backend/models.py Normal file
View File

@@ -0,0 +1,265 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional, List, Dict
from datetime import datetime
from bson import ObjectId
class PyObjectId(ObjectId):
"""Custom ObjectId type for Pydantic"""
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def validate(cls, v):
if not ObjectId.is_valid(v):
raise ValueError("Invalid ObjectId")
return ObjectId(v)
@classmethod
def __get_pydantic_json_schema__(cls, field_schema):
field_schema.update(type="string")
# User Models
class UserBase(BaseModel):
email: EmailStr
username: str = Field(..., min_length=3, max_length=50)
class UserCreate(UserBase):
password: str = Field(..., min_length=6)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserInDB(UserBase):
id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id")
hashed_password: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
class UserResponse(UserBase):
id: str = Field(..., alias="_id")
created_at: datetime
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserResponse
# Grading Category Model (flexible, not German-specific)
class GradingCategory(BaseModel):
name: str = Field(..., min_length=1, max_length=100) # e.g., "Written", "Oral", "Homework"
weight: float = Field(..., ge=0, le=100) # Percentage weight (0-100)
color: Optional[str] = Field(default="#3b82f6") # Hex color for UI
# Subject Model
class SubjectBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
grading_categories: List[GradingCategory] = Field(..., min_items=1)
color: Optional[str] = Field(default="#3b82f6")
teacher: Optional[str] = None
grade_system: Optional[str] = Field(default="percentage") # "percentage", "german", "us-letter"
target_grade: Optional[float] = None
target_grade_max: Optional[float] = None
@field_validator("grading_categories")
@classmethod
def validate_weights(cls, categories: List[GradingCategory]) -> List[GradingCategory]:
total_weight = sum(cat.weight for cat in categories)
if abs(total_weight - 100.0) > 0.01: # Allow small floating point errors
raise ValueError(f"Grading category weights must sum to 100%, got {total_weight}%")
return categories
class SubjectCreate(SubjectBase):
pass
class SubjectUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
grading_categories: Optional[List[GradingCategory]] = None
color: Optional[str] = None
teacher: Optional[str] = None
grade_system: Optional[str] = None
target_grade: Optional[float] = None
target_grade_max: Optional[float] = None
@field_validator("grading_categories")
@classmethod
def validate_weights(cls, categories: Optional[List[GradingCategory]]) -> Optional[List[GradingCategory]]:
if categories is None:
return None
total_weight = sum(cat.weight for cat in categories)
if abs(total_weight - 100.0) > 0.01:
raise ValueError(f"Grading category weights must sum to 100%, got {total_weight}%")
return categories
class SubjectInDB(SubjectBase):
id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id")
user_id: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
class SubjectResponse(SubjectBase):
id: str = Field(..., alias="_id")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
# Test/Grade Model
class GradeBase(BaseModel):
subject_id: str
category_name: str # Must match one of the subject's grading categories
grade: float = Field(..., ge=0) # Grade value (e.g., 1.0 to 6.0 for German, 0-100 for percentage)
max_grade: float = Field(..., ge=0) # Maximum possible grade (e.g., 6.0 or 100)
weight_in_category: float = Field(default=1.0, ge=0) # Weight within the category (for averaging multiple tests)
name: Optional[str] = Field(None, max_length=200) # Test name/description
date: datetime = Field(default_factory=datetime.utcnow)
notes: Optional[str] = None
class GradeCreate(GradeBase):
pass
class GradeUpdate(BaseModel):
category_name: Optional[str] = None
grade: Optional[float] = Field(None, ge=0)
max_grade: Optional[float] = Field(None, ge=0)
weight_in_category: Optional[float] = Field(None, ge=0)
name: Optional[str] = Field(None, max_length=200)
date: Optional[datetime] = None
notes: Optional[str] = None
class GradeInDB(GradeBase):
id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id")
user_id: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
class GradeResponse(GradeBase):
id: str = Field(..., alias="_id")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
# Report Period Model
class ReportPeriodBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100) # e.g., "First Half-Year 2024/2025"
start_date: datetime
end_date: datetime
@field_validator("end_date")
@classmethod
def validate_dates(cls, end_date: datetime, info) -> datetime:
start_date = info.data.get("start_date")
if start_date and end_date <= start_date:
raise ValueError("end_date must be after start_date")
return end_date
class ReportPeriodCreate(ReportPeriodBase):
pass
class ReportPeriodUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ReportPeriodInDB(ReportPeriodBase):
id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id")
user_id: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
class ReportPeriodResponse(ReportPeriodBase):
id: str = Field(..., alias="_id")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
# Teacher Grade Override Model (manual grades from teachers)
class TeacherGradeBase(BaseModel):
subject_id: str
period_id: str
grade: float = Field(..., ge=0) # The grade value (e.g., 1.0-6.0 for German, 0-100 for percentage)
max_grade: float = Field(..., ge=0) # Maximum possible grade (e.g., 6.0 or 100)
notes: Optional[str] = Field(None, max_length=500)
class TeacherGradeCreate(TeacherGradeBase):
pass
class TeacherGradeUpdate(BaseModel):
grade: Optional[float] = Field(None, ge=0)
max_grade: Optional[float] = Field(None, ge=0)
notes: Optional[str] = Field(None, max_length=500)
class TeacherGradeInDB(TeacherGradeBase):
id: str = Field(default_factory=lambda: str(ObjectId()), alias="_id")
user_id: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}
class TeacherGradeResponse(TeacherGradeBase):
id: str = Field(..., alias="_id")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
json_encoders = {ObjectId: str}

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
motor==3.6.0
pymongo>=4.9,<4.10
pydantic==2.10.5
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
email-validator==2.2.0

View File

@@ -0,0 +1,7 @@
# Backend routes
from . import auth_routes
from . import subject_routes
from . import grade_routes
from . import period_routes
__all__ = ["auth_routes", "subject_routes", "grade_routes", "period_routes"]

View File

@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from datetime import timedelta
from typing import Optional
from models import UserCreate, UserLogin, UserResponse, UserInDB, Token
from auth import get_password_hash, verify_password, create_access_token, decode_access_token
from database import get_database
from config import settings
from bson import ObjectId
router = APIRouter(prefix="/auth", tags=["authentication"])
security = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInDB:
"""Get current authenticated user"""
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
db = get_database()
user_dict = await db.users.find_one({"_id": ObjectId(user_id)})
if user_dict is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
user_dict["_id"] = str(user_dict["_id"])
return UserInDB(**user_dict)
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate):
"""Register a new user"""
db = get_database()
# Check if user already exists
existing_user = await db.users.find_one({"email": user.email})
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
existing_username = await db.users.find_one({"username": user.username})
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
# Create new user
user_in_db = UserInDB(
email=user.email,
username=user.username,
hashed_password=get_password_hash(user.password)
)
user_dict = user_in_db.model_dump(by_alias=True)
user_dict["_id"] = ObjectId(user_dict["_id"])
result = await db.users.insert_one(user_dict)
user_dict["_id"] = str(result.inserted_id)
# Create access token
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": str(result.inserted_id)},
expires_delta=access_token_expires
)
user_response = UserResponse(
_id=str(result.inserted_id),
email=user.email,
username=user.username,
created_at=user_in_db.created_at
)
return Token(access_token=access_token, user=user_response)
@router.post("/login", response_model=Token)
async def login(user_login: UserLogin):
"""Login a user"""
db = get_database()
# Find user by email
user_dict = await db.users.find_one({"email": user_login.email})
if not user_dict:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
user_dict["_id"] = str(user_dict["_id"])
user = UserInDB(**user_dict)
# Verify password
if not verify_password(user_login.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.id},
expires_delta=access_token_expires
)
user_response = UserResponse(
_id=user.id,
email=user.email,
username=user.username,
created_at=user.created_at
)
return Token(access_token=access_token, user=user_response)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: UserInDB = Depends(get_current_user)):
"""Get current user information"""
return UserResponse(
_id=current_user.id,
email=current_user.email,
username=current_user.username,
created_at=current_user.created_at
)

View File

@@ -0,0 +1,202 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List, Optional
from models import GradeCreate, GradeUpdate, GradeResponse, GradeInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/grades", tags=["grades"])
@router.post("", response_model=GradeResponse, status_code=status.HTTP_201_CREATED)
async def create_grade(
grade: GradeCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create a new grade/test entry"""
db = get_database()
# Verify subject exists and belongs to user
if not ObjectId.is_valid(grade.subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
subject = await db.subjects.find_one({
"_id": ObjectId(grade.subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Verify category exists in subject
category_names = [cat["name"] for cat in subject.get("grading_categories", [])]
if grade.category_name not in category_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Category '{grade.category_name}' not found in subject. Available: {', '.join(category_names)}"
)
grade_in_db = GradeInDB(
**grade.model_dump(),
user_id=current_user.id
)
grade_dict = grade_in_db.model_dump(by_alias=True)
grade_dict["_id"] = ObjectId(grade_dict["_id"])
result = await db.grades.insert_one(grade_dict)
grade_dict["_id"] = str(result.inserted_id)
return GradeResponse(**grade_dict)
@router.get("", response_model=List[GradeResponse])
async def get_grades(
subject_id: Optional[str] = Query(None),
current_user: UserInDB = Depends(get_current_user)
):
"""Get all grades for the current user, optionally filtered by subject"""
db = get_database()
query = {"user_id": current_user.id}
if subject_id:
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
query["subject_id"] = subject_id
cursor = db.grades.find(query).sort("date", -1) # Most recent first
grades = await cursor.to_list(length=None)
for grade in grades:
grade["_id"] = str(grade["_id"])
return [GradeResponse(**grade) for grade in grades]
@router.get("/{grade_id}", response_model=GradeResponse)
async def get_grade(
grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific grade"""
db = get_database()
if not ObjectId.is_valid(grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid grade ID"
)
grade = await db.grades.find_one({
"_id": ObjectId(grade_id),
"user_id": current_user.id
})
if not grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Grade not found"
)
grade["_id"] = str(grade["_id"])
return GradeResponse(**grade)
@router.put("/{grade_id}", response_model=GradeResponse)
async def update_grade(
grade_id: str,
grade_update: GradeUpdate,
current_user: UserInDB = Depends(get_current_user)
):
"""Update a grade"""
db = get_database()
if not ObjectId.is_valid(grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid grade ID"
)
# Check if grade exists and belongs to user
existing_grade = await db.grades.find_one({
"_id": ObjectId(grade_id),
"user_id": current_user.id
})
if not existing_grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Grade not found"
)
# If updating category, verify it exists in subject
if grade_update.category_name:
subject = await db.subjects.find_one({
"_id": ObjectId(existing_grade["subject_id"]),
"user_id": current_user.id
})
if subject:
category_names = [cat["name"] for cat in subject.get("grading_categories", [])]
if grade_update.category_name not in category_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Category '{grade_update.category_name}' not found in subject"
)
# Update only provided fields
update_data = grade_update.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
await db.grades.update_one(
{"_id": ObjectId(grade_id)},
{"$set": update_data}
)
# Fetch updated grade
updated_grade = await db.grades.find_one({"_id": ObjectId(grade_id)})
updated_grade["_id"] = str(updated_grade["_id"])
return GradeResponse(**updated_grade)
@router.delete("/{grade_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_grade(
grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a grade"""
db = get_database()
if not ObjectId.is_valid(grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid grade ID"
)
# Check if grade exists and belongs to user
grade = await db.grades.find_one({
"_id": ObjectId(grade_id),
"user_id": current_user.id
})
if not grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Grade not found"
)
# Delete grade
await db.grades.delete_one({"_id": ObjectId(grade_id)})
return None

View File

@@ -0,0 +1,149 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from models import ReportPeriodCreate, ReportPeriodUpdate, ReportPeriodResponse, ReportPeriodInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/periods", tags=["report-periods"])
@router.post("", response_model=ReportPeriodResponse, status_code=status.HTTP_201_CREATED)
async def create_period(
period: ReportPeriodCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create a new report period"""
db = get_database()
period_in_db = ReportPeriodInDB(
**period.model_dump(),
user_id=current_user.id
)
period_dict = period_in_db.model_dump(by_alias=True)
period_dict["_id"] = ObjectId(period_dict["_id"])
result = await db.periods.insert_one(period_dict)
period_dict["_id"] = str(result.inserted_id)
return ReportPeriodResponse(**period_dict)
@router.get("", response_model=List[ReportPeriodResponse])
async def get_periods(current_user: UserInDB = Depends(get_current_user)):
"""Get all report periods for the current user"""
db = get_database()
cursor = db.periods.find({"user_id": current_user.id}).sort("start_date", -1)
periods = await cursor.to_list(length=None)
for period in periods:
period["_id"] = str(period["_id"])
return [ReportPeriodResponse(**period) for period in periods]
@router.get("/{period_id}", response_model=ReportPeriodResponse)
async def get_period(
period_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific report period"""
db = get_database()
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
period = await db.periods.find_one({
"_id": ObjectId(period_id),
"user_id": current_user.id
})
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report period not found"
)
period["_id"] = str(period["_id"])
return ReportPeriodResponse(**period)
@router.put("/{period_id}", response_model=ReportPeriodResponse)
async def update_period(
period_id: str,
period_update: ReportPeriodUpdate,
current_user: UserInDB = Depends(get_current_user)
):
"""Update a report period"""
db = get_database()
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
# Check if period exists and belongs to user
existing_period = await db.periods.find_one({
"_id": ObjectId(period_id),
"user_id": current_user.id
})
if not existing_period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report period not found"
)
# Update only provided fields
update_data = period_update.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
await db.periods.update_one(
{"_id": ObjectId(period_id)},
{"$set": update_data}
)
# Fetch updated period
updated_period = await db.periods.find_one({"_id": ObjectId(period_id)})
updated_period["_id"] = str(updated_period["_id"])
return ReportPeriodResponse(**updated_period)
@router.delete("/{period_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_period(
period_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a report period"""
db = get_database()
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
# Check if period exists and belongs to user
period = await db.periods.find_one({
"_id": ObjectId(period_id),
"user_id": current_user.id
})
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report period not found"
)
# Delete period
await db.periods.delete_one({"_id": ObjectId(period_id)})
return None

View File

@@ -0,0 +1,152 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from models import SubjectCreate, SubjectUpdate, SubjectResponse, SubjectInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/subjects", tags=["subjects"])
@router.post("", response_model=SubjectResponse, status_code=status.HTTP_201_CREATED)
async def create_subject(
subject: SubjectCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create a new subject"""
db = get_database()
subject_in_db = SubjectInDB(
**subject.model_dump(),
user_id=current_user.id
)
subject_dict = subject_in_db.model_dump(by_alias=True)
subject_dict["_id"] = ObjectId(subject_dict["_id"])
result = await db.subjects.insert_one(subject_dict)
subject_dict["_id"] = str(result.inserted_id)
return SubjectResponse(**subject_dict)
@router.get("", response_model=List[SubjectResponse])
async def get_subjects(current_user: UserInDB = Depends(get_current_user)):
"""Get all subjects for the current user"""
db = get_database()
cursor = db.subjects.find({"user_id": current_user.id})
subjects = await cursor.to_list(length=None)
for subject in subjects:
subject["_id"] = str(subject["_id"])
return [SubjectResponse(**subject) for subject in subjects]
@router.get("/{subject_id}", response_model=SubjectResponse)
async def get_subject(
subject_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific subject"""
db = get_database()
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
subject = await db.subjects.find_one({
"_id": ObjectId(subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
subject["_id"] = str(subject["_id"])
return SubjectResponse(**subject)
@router.put("/{subject_id}", response_model=SubjectResponse)
async def update_subject(
subject_id: str,
subject_update: SubjectUpdate,
current_user: UserInDB = Depends(get_current_user)
):
"""Update a subject"""
db = get_database()
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
# Check if subject exists and belongs to user
existing_subject = await db.subjects.find_one({
"_id": ObjectId(subject_id),
"user_id": current_user.id
})
if not existing_subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Update only provided fields
update_data = subject_update.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
await db.subjects.update_one(
{"_id": ObjectId(subject_id)},
{"$set": update_data}
)
# Fetch updated subject
updated_subject = await db.subjects.find_one({"_id": ObjectId(subject_id)})
updated_subject["_id"] = str(updated_subject["_id"])
return SubjectResponse(**updated_subject)
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_subject(
subject_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a subject and all associated grades"""
db = get_database()
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
# Check if subject exists and belongs to user
subject = await db.subjects.find_one({
"_id": ObjectId(subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Delete subject
await db.subjects.delete_one({"_id": ObjectId(subject_id)})
# Delete all grades for this subject
await db.grades.delete_many({"subject_id": subject_id, "user_id": current_user.id})
return None

View File

@@ -0,0 +1,186 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List, Optional
from models import TeacherGradeCreate, TeacherGradeUpdate, TeacherGradeResponse, TeacherGradeInDB, UserInDB
from database import get_database
from routes.auth_routes import get_current_user
from bson import ObjectId
from datetime import datetime
router = APIRouter(prefix="/teacher-grades", tags=["teacher-grades"])
@router.post("", response_model=TeacherGradeResponse, status_code=status.HTTP_201_CREATED)
async def create_teacher_grade(
teacher_grade: TeacherGradeCreate,
current_user: UserInDB = Depends(get_current_user)
):
"""Create or update a teacher-provided grade override for a subject in a period"""
db = get_database()
# Verify subject exists and belongs to user
if not ObjectId.is_valid(teacher_grade.subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
subject = await db.subjects.find_one({
"_id": ObjectId(teacher_grade.subject_id),
"user_id": current_user.id
})
if not subject:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subject not found"
)
# Verify period exists and belongs to user
if not ObjectId.is_valid(teacher_grade.period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
period = await db.periods.find_one({
"_id": ObjectId(teacher_grade.period_id),
"user_id": current_user.id
})
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Period not found"
)
# Check if teacher grade already exists for this subject and period
existing = await db.teacher_grades.find_one({
"subject_id": teacher_grade.subject_id,
"period_id": teacher_grade.period_id,
"user_id": current_user.id
})
if existing:
# Update existing teacher grade
update_data = teacher_grade.model_dump()
update_data["updated_at"] = datetime.utcnow()
await db.teacher_grades.update_one(
{"_id": existing["_id"]},
{"$set": update_data}
)
updated = await db.teacher_grades.find_one({"_id": existing["_id"]})
updated["_id"] = str(updated["_id"])
return TeacherGradeResponse(**updated)
else:
# Create new teacher grade
teacher_grade_in_db = TeacherGradeInDB(
**teacher_grade.model_dump(),
user_id=current_user.id
)
grade_dict = teacher_grade_in_db.model_dump(by_alias=True)
grade_dict["_id"] = ObjectId(grade_dict["_id"])
result = await db.teacher_grades.insert_one(grade_dict)
grade_dict["_id"] = str(result.inserted_id)
return TeacherGradeResponse(**grade_dict)
@router.get("", response_model=List[TeacherGradeResponse])
async def get_teacher_grades(
subject_id: Optional[str] = Query(None),
period_id: Optional[str] = Query(None),
current_user: UserInDB = Depends(get_current_user)
):
"""Get all teacher grades for the current user, optionally filtered by subject or period"""
db = get_database()
query = {"user_id": current_user.id}
if subject_id:
if not ObjectId.is_valid(subject_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid subject ID"
)
query["subject_id"] = subject_id
if period_id:
if not ObjectId.is_valid(period_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid period ID"
)
query["period_id"] = period_id
cursor = db.teacher_grades.find(query)
teacher_grades = await cursor.to_list(length=None)
for grade in teacher_grades:
grade["_id"] = str(grade["_id"])
return [TeacherGradeResponse(**grade) for grade in teacher_grades]
@router.get("/{teacher_grade_id}", response_model=TeacherGradeResponse)
async def get_teacher_grade(
teacher_grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Get a specific teacher grade"""
db = get_database()
if not ObjectId.is_valid(teacher_grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid teacher grade ID"
)
teacher_grade = await db.teacher_grades.find_one({
"_id": ObjectId(teacher_grade_id),
"user_id": current_user.id
})
if not teacher_grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Teacher grade not found"
)
teacher_grade["_id"] = str(teacher_grade["_id"])
return TeacherGradeResponse(**teacher_grade)
@router.delete("/{teacher_grade_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_teacher_grade(
teacher_grade_id: str,
current_user: UserInDB = Depends(get_current_user)
):
"""Delete a teacher grade override"""
db = get_database()
if not ObjectId.is_valid(teacher_grade_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid teacher grade ID"
)
# Check if teacher grade exists and belongs to user
teacher_grade = await db.teacher_grades.find_one({
"_id": ObjectId(teacher_grade_id),
"user_id": current_user.id
})
if not teacher_grade:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Teacher grade not found"
)
# Delete teacher grade
await db.teacher_grades.delete_one({"_id": ObjectId(teacher_grade_id)})
return None

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
# Frontend Build Container (builds once and exits)
frontend-build:
image: node:20-alpine
container_name: grademaxxing-frontend-build
working_dir: /app
command: sh -c "corepack enable && pnpm install --frozen-lockfile && pnpm run build"
volumes:
- ./:/app
env_file:
- ./.env
environment:
- CI=true
networks:
- grademaxxing-network
# Python Backend (serves API and static files)
backend:
image: python:3.9
container_name: grademaxxing-backend
restart: unless-stopped
working_dir: /app
command: sh -c "cd backend && pip install --no-cache-dir -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000"
ports:
- "4442:8000"
volumes:
- .:/app
env_file:
- ./.env
networks:
- grademaxxing-network
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
networks:
grademaxxing-network:
driver: bridge

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "grademaxxing",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"preview": "vite preview --outDir build/client",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/node": "7.12.0",
"@react-router/serve": "7.12.0",
"isbot": "^5.1.31",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router": "7.12.0"
},
"devDependencies": {
"@react-router/dev": "7.12.0",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

2567
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

7
react-router.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
} satisfies Config;

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});