commit 3fefc550feb74a67d8021a4515268ae988f86615 Author: Andrej Spielmann Date: Thu Mar 26 13:24:57 2026 +0100 Initial commit: WrestleDesk full project - Django backend with DRF (clubs, wrestlers, trainers, exercises, templates, trainings, homework, locations, leistungstest) - Next.js 16 frontend with React, Shadcn UI, Tailwind - JWT authentication - Full CRUD for all entities - Calendar view for trainings - Homework management system - Leistungstest tracking diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e82d0cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc + +# Environment +.env +.env.local +.env.*.local + +# Build outputs +.next/ +out/ +build/ +dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test +coverage/ + +# Misc +*.snapshot +*.log + +# Local development +*.local + +# Backup files +login_snapshot*.md diff --git a/.skills/frontend-design.md b/.skills/frontend-design.md new file mode 100644 index 0000000..5be498e --- /dev/null +++ b/.skills/frontend-design.md @@ -0,0 +1,42 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/.skills/webapp-testing.md b/.skills/webapp-testing.md new file mode 100644 index 0000000..4726215 --- /dev/null +++ b/.skills/webapp-testing.md @@ -0,0 +1,96 @@ +--- +name: webapp-testing +description: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. +license: Complete terms in LICENSE.txt +--- + +# Web Application Testing + +To test local web applications, write native Python Playwright scripts. + +**Helper Scripts Available**: +- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers) + +**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window. + +## Decision Tree: Choosing Your Approach + +``` +User task → Is it static HTML? + ├─ Yes → Read HTML file directly to identify selectors + │ ├─ Success → Write Playwright script using selectors + │ └─ Fails/Incomplete → Treat as dynamic (below) + │ + └─ No (dynamic webapp) → Is the server already running? + ├─ No → Run: python scripts/with_server.py --help + │ Then use the helper + write simplified Playwright script + │ + └─ Yes → Reconnaissance-then-action: + 1. Navigate and wait for networkidle + 2. Take screenshot or inspect DOM + 3. Identify selectors from rendered state + 4. Execute actions with discovered selectors +``` + +## Example: Using with_server.py + +To start a server, run `--help` first, then use the helper: + +**Single server:** +```bash +python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py +``` + +**Multiple servers (e.g., backend + frontend):** +```bash +python scripts/with_server.py \ + --server "cd backend && python server.py" --port 3000 \ + --server "cd frontend && npm run dev" --port 5173 \ + -- python your_automation.py +``` + +To create an automation script, include only Playwright logic (servers are managed automatically): +```python +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode + page = browser.new_page() + page.goto('http://localhost:5173') # Server already running and ready + page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute + # ... your automation logic + browser.close() +``` + +## Reconnaissance-Then-Action Pattern + +1. **Inspect rendered DOM**: + ```python + page.screenshot(path='/tmp/inspect.png', full_page=True) + content = page.content() + page.locator('button').all() + ``` + +2. **Identify selectors** from inspection results + +3. **Execute actions** using discovered selectors + +## Common Pitfall + +❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps +✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection + +## Best Practices + +- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly. +- Use `sync_playwright()` for synchronous scripts +- Always close the browser when done +- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs +- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()` + +## Reference Files + +- **examples/** - Examples showing common patterns: + - `element_discovery.py` - Discovering buttons, links, and inputs on a page + - `static_html_automation.py` - Using file:// URLs for local HTML + - `console_logging.py` - Capturing console logs during automation \ No newline at end of file diff --git a/.superpowers/brainstorm/12147-1774343554/.server-stopped b/.superpowers/brainstorm/12147-1774343554/.server-stopped new file mode 100644 index 0000000..a26e091 --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/.server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1774346494860} diff --git a/.superpowers/brainstorm/12147-1774343554/.server.pid b/.superpowers/brainstorm/12147-1774343554/.server.pid new file mode 100644 index 0000000..b8145fa --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/.server.pid @@ -0,0 +1 @@ +12159 diff --git a/.superpowers/brainstorm/12147-1774343554/layout-v1.html b/.superpowers/brainstorm/12147-1774343554/layout-v1.html new file mode 100644 index 0000000..785bc15 --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/layout-v1.html @@ -0,0 +1,130 @@ +

Leistungstest Timer — Layout

+

Pro Ringer: links Info/Timer, rechts Übungen. Timer läuft durchgehend.

+ +
+
+
Ringer 1: Max Mustermann
+
+
+
05:23
+
Gesamtzeit
+
+
+ +
+
+
+ ÜBUNGEN +
+
+
+
+
Liegestütze
+
Soll: 30s
+
+
+ ✓ 0:12 + +
+
+
+
+
Kniebeugen
+
Soll: 45s
+
+
+ ⏸ 0:28 + +
+
+
+
+
Burpees
+
Soll: 20s
+
+
+ Ausstehend + +
+
+
+
+
+
+ +
+
Ringer 2: Anna Schmidt
+
+
+
02:41
+
Gesamtzeit
+
+
+ +
+
+
+ ÜBUNGEN +
+
+
+
+
Liegestütze
+
Soll: 30s
+
+
+ ⏸ 0:31 + +
+
+
+
+
Kniebeugen
+
Soll: 45s
+
+
+ Ausstehend + +
+
+
+
+
Burpees
+
Soll: 20s
+
+
+ Ausstehend + +
+
+
+
+
+
+
+ +
+

Zustände pro Übung

+
+
+
AUSSTEHEND
+
Grau — noch nicht gestartet
+
Button: ▶ Start
+
+
+
AKTIV / LÄUFT
+
Gelb — Timer läuft für diese Übung
+
Button: ✓ Erledigt (speichert Zeit)
+
+
+
ERLEDIGT
+
Grün — Zeit gespeichert, keine Aktion mehr
+
Zeit badge: ✓ 0:12
+
+
+
+ +

+ Wichtig: Der globale Timer läuft durchgehend (nicht reset zwischen Übungen). + Jede Übung hat eine eigene "Erledigt"-Zeit. Soll = Zeit (Sekunden), kein Reps. +

diff --git a/.superpowers/brainstorm/12147-1774343554/layout-v2.html b/.superpowers/brainstorm/12147-1774343554/layout-v2.html new file mode 100644 index 0000000..b1f7928 --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/layout-v2.html @@ -0,0 +1,103 @@ +

Leistungstest Timer — Alle Ringer links, Übungen rechts

+

Eine gemeinsame Zeit. Pro Übung "Erledigt" klicken = Zeit wird gespeichert.

+ +
+ + +
+
Ringer
+
+
+ 3 Ringer + 2/3 erledigt +
+
+
+
Max Mustermann
+
✓ 3/3 Übungen
+
+
+
Anna Schmidt
+
2/3 Übungen
+
+
+
Tom Klein
+
0/3 Übungen
+
+
+
+
+ + +
+ +
+
Gemeinsame Zeit
+
05:23
+
+ + +
+
+ + +
+
+ Anna Schmidt + 2/3 erledigt +
+ + +
+
+ ÜBUNGEN + Soll-Zeit in Sekunden +
+ + +
+
+
+
Liegestütze
+
Soll: 30s
+
+
+ ✓ 0:28 + +2s +
+
+ + +
+
+
+
Kniebeugen
+
Soll: 45s · Aktiv!
+
+
+ ⏸ 0:31 + +
+
+ + +
+
3
+
+
Burpees
+
Soll: 20s
+
+
+ + +
+
+
+ + +
+ Anna ist dran: Die aktive Übung ist gelb markiert. Klicke "Start" bei Burpees um die Zeit zu starten, dann "Erledigt" um die Zeit zu speichern. Alle Ringer teilen sich die gleiche Gesamtzeit. +
+
+ +
diff --git a/.superpowers/brainstorm/12147-1774343554/layout-v3.html b/.superpowers/brainstorm/12147-1774343554/layout-v3.html new file mode 100644 index 0000000..2227d87 --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/layout-v3.html @@ -0,0 +1,178 @@ +

Pro Ringer: eigene Übungen, auto-start nach Erledigt

+

Klick "Erledigt" → nächste Übung startet automatisch. Alle Ringer parallel, eigene Geschwindigkeit.

+ +
+ + +
+
Ringer
+
+
+ 3 Ringer + 2/3 ✓ +
+
+ + +
+
Max Mustermann
+
✓ Alle 3 Übungen
+
+
✓ Liegestütze 0:28
+
✓ Kniebeugen 0:44
+
✓ Burpees 0:19
+
+
+ + +
+
Anna Schmidt
+
Übung 2 von 3
+
+
✓ Liegestütze 0:28
+
▶ Kniebeugen 0:31
+
○ Burpees
+
+
+ + +
+
Tom Klein
+
Noch nicht gestartet
+
+
○ Liegestütze
+
○ Kniebeugen
+
○ Burpees
+
+
+ +
+
+
+ + +
+ +
+
Gemeinsame Zeit
+
05:23
+
+ + +
+
+ + +
+
+ Anna Schmidt + Übung 2 von 3 + 1/3 ✓ +
+ + +
+
+ ANNA's ÜBUNGEN + Soll-Zeit +
+ + +
+
+
+
Liegestütze
+
+
+
Soll: 30s
+
✓ 0:28
+
+
+ +2s +
+
+ + +
+
+
+
Kniebeugen
+
Aktiv — Zeit läuft!
+
+
+
Soll: 45s
+
0:31
+
+ +
+ + +
+
3
+
+
Burpees
+
Startet automatisch nach Kniebeugen
+
+
+
Soll: 20s
+
+
+ Ausstehend +
+
+ +
+ + +
+ Auto-Start: Wenn Anna bei "Kniebeugen" auf "✓ Erledigt" klickt → startet "Burpees" automatisch. Der globale Timer läuft weiter. Andere Ringer (Tom) können parallel starten wenn du ihn auswählst. +
+ + +
+
+
+ Tom Klein + 0/3 + (klicken zum Starten) +
+
+
+
1
+
+
Liegestütze
+
+
+
Soll: 30s
+
+ +
+
+
2
+
+
Kniebeugen
+
+
+
Soll: 45s
+
+
+
+
+
3
+
+
Burpees
+
+
+
Soll: 20s
+
+
+
+
+
+ Klicke auf Tom in der Liste um seine Übungen zu starten +
+
+ +
+ +
diff --git a/.superpowers/brainstorm/12147-1774343554/layout-v4.html b/.superpowers/brainstorm/12147-1774343554/layout-v4.html new file mode 100644 index 0000000..98d8ad6 --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/layout-v4.html @@ -0,0 +1,132 @@ +

Drei Ringer parallel — jeder seine eigene Zeile

+

Wie eine Tabelle: pro Ringer eine Zeile. Alle 3 parallel. Eigene Zeiten pro Übung.

+ + +
+
+ Gemeinsame Zeit +
05:23
+
+
+ + +
+
+ + +
+ + +
+
+
+
+
Max Mustermann
+
✓ Alle 3 Übungen erledigt
+
+
3/3 ✓
+
+
+
+
Liegestütze
+
+ Soll: 30s + ✓ 0:28 +
+
+
+
Kniebeugen
+
+ Soll: 45s + ✓ 0:44 +
+
+
+
Burpees
+
+ Soll: 20s + ✓ 0:19 +
+
+
+
+ + +
+
+
+
+
Anna Schmidt
+
Aktive Übung: Kniebeugen
+
+
2/3
+
+
+ +
+
Liegestütze
+
+ Soll: 30s + ✓ 0:28 +
+
+ +
+
▶ Kniebeugen
+
+ Soll: 45s + 0:31 +
+ +
+ +
+
Burpees
+
+ Soll: 20s + Ausstehend +
+
+
+
+ + +
+
+
+
+
Tom Klein
+
Noch nicht gestartet
+
+
0/3
+
+
+
+
Liegestütze
+
+ Soll: 30s + +
+
+
+
Kniebeugen
+
+ Soll: 45s + +
+
+
+
Burpees
+
+ Soll: 20s + +
+
+
+
+ +
+ +
+ So funktioniert's: Alle 3 Ringer arbeiten parallel. Anna hat gerade Kniebeugen aktiv (gelb) — nach dem Klick auf "✓ Erledigt" startet Burpees automatisch. Tom kann jederzeit auf "▶ Start" bei Liegestütze klicken um seine erste Übung zu starten. Der globale Timer läuft für alle gemeinsam. +
diff --git a/.superpowers/brainstorm/12147-1774343554/layout-v5.html b/.superpowers/brainstorm/12147-1774343554/layout-v5.html new file mode 100644 index 0000000..57d9203 --- /dev/null +++ b/.superpowers/brainstorm/12147-1774343554/layout-v5.html @@ -0,0 +1,130 @@ +

Timer start → alle beginnen Übung 1 automatisch

+

Soll = Reps (z.B. "3x10"). Trainer klickt "Erledigt" pro Ringer wenn fertig.

+ + +
+
+ Gemeinsame Zeit +
00:00
+
+ +
+ + +
+ + +
+
+
+
+
Max Mustermann
+
Übung 1 läuft...
+
+
0/3
+
+
+ +
+
▶ Liegestütze
+
+ Soll: 3×10 + 0:28 +
+ +
+ +
+
Kniebeugen
+
+ Soll: 3×15 + +
+
+
+
Burpees
+
+ Soll: 3×20 + +
+
+
+
+ + +
+
+
+
+
Anna Schmidt
+
Übung 1 läuft...
+
+
0/3
+
+
+
+
▶ Liegestütze
+
+ Soll: 3×10 + 0:31 +
+ +
+
+
Kniebeugen
+
+ Soll: 3×15 + +
+
+
+
Burpees
+
+ Soll: 3×20 + +
+
+
+
+ + +
+
+
+
+
Tom Klein
+
Übung 1 läuft...
+
+
0/3
+
+
+
+
▶ Liegestütze
+
+ Soll: 3×10 + 0:22 +
+ +
+
+
Kniebeugen
+
+ Soll: 3×15 + +
+
+
+
Burpees
+
+ Soll: 3×20 + +
+
+
+
+ +
+ +
+ Flow: Trainer klickt "▶ Training starten" → ALLE 3 Ringer beginnen gleichzeitig Übung 1 ("Liegestütze 3×10"). Der Timer läuft. Trainer klickt bei Max auf "✓ Erledigt" → Zeit wird gespeichert (0:28), Max's nächste Übung (Kniebeugen) startet automatisch. Anna und Tom sind noch bei Übung 1. +
diff --git a/.superpowers/brainstorm/12147-ergebnisse/option1-karten.html b/.superpowers/brainstorm/12147-ergebnisse/option1-karten.html new file mode 100644 index 0000000..19f921a --- /dev/null +++ b/.superpowers/brainstorm/12147-ergebnisse/option1-karten.html @@ -0,0 +1,237 @@ + + + + + + Option 1: Karten-Ansicht + + + + +
+

Ergebnisse — Option 1: Karten-Ansicht

+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
M
+
+
Max Mustermann
+
Heute, 14:32 · Krafttest Februar
+
+
+
+
100%
+
5:42 min
+
+
+
+
Übungen
+
+
+ Liegestütze +
+ 3×10 + 1:15 + +
+
+
+ Kniebeugen +
+ 3×15 + 2:30 + +
+
+
+ Burpees +
+ 3×20 + 1:57 + +
+
+
+
+ +
+ + +
+
+
+
A
+
+
Anna Schmidt
+
Gestern, 10:15 · Krafttest Februar
+
+
+
+
87%
+
6:20 min
+
+
+
+
Übungen
+
+
+ Liegestütze +
+ 3×10 + 1:30 + +
+
+
+ Kniebeugen +
+ 3×15 + 3:00 + +
+
+
+ Burpees +
+ 3×20 + + +
+
+
+
+ +
+ + +
+
+
+
T
+
+
Tom Klein
+
23.03.26, 16:45 · Krafttest Februar
+
+
+
+
65%
+
8:15 min
+
+
+
+
Übungen
+
+
+ Liegestütze +
+ 3×10 + 2:00 + +
+
+
+ Kniebeugen +
+ 3×15 + + +
+
+
+ Burpees +
+ 3×20 + + +
+
+
+
+ +
+
+ +
+ Vorteile: Pro Ergebnis eine übersichtliche Karte mit allen Details (Ringer-Foto, Score, Zeit, alle Übungen mit Zeiten). Klick auf Karte zeigt Details. Farbcodierung: Grün/Gelb/Rot nach Score. +
+
+ + diff --git a/.superpowers/brainstorm/12147-ergebnisse/option2-tabelle.html b/.superpowers/brainstorm/12147-ergebnisse/option2-tabelle.html new file mode 100644 index 0000000..7f33282 --- /dev/null +++ b/.superpowers/brainstorm/12147-ergebnisse/option2-tabelle.html @@ -0,0 +1,191 @@ + + + + + + Option 2: Erweiterte Tabelle + + + + +
+

Ergebnisse — Option 2: Erweiterte Tabelle

+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RingerScoreZeitBew.Aktionen
+
+
M
+
+
Max Mustermann
+
24.03.26 · Krafttest Februar
+
+
+
100%5:42★★★★☆ +
+ + +
+
+
+
+
+ Liegestütze + +
+
+ Soll: 3×10 + 1:15 +
+
+
+
+ Kniebeugen + +
+
+ Soll: 3×15 + 2:30 +
+
+
+
+ Burpees + +
+
+ Soll: 3×20 + 1:57 +
+
+
+
+
+
A
+
+
Anna Schmidt
+
23.03.26 · Krafttest Februar
+
+
+
87%6:20★★★☆☆ +
+ + +
+
+
+
T
+
+
Tom Klein
+
23.03.26 · Krafttest Februar
+
+
+
65%8:15★★☆☆☆ +
+ + +
+
+
+ +
+ Vorteile: Kompakte Tabellenansicht — wie gewohnt, aber mit "Details" Toggle pro Zeile um alle Übungen mit Zeiten anzuzeigen. Gut für Vergleiche nebeneinander. +
+
+ + diff --git a/.superpowers/brainstorm/12147-ergebnisse/preview.html b/.superpowers/brainstorm/12147-ergebnisse/preview.html new file mode 100644 index 0000000..6131fbe --- /dev/null +++ b/.superpowers/brainstorm/12147-ergebnisse/preview.html @@ -0,0 +1,228 @@ + + + + + + Ergebnisse — Toggle Ansicht + + + + +
+ + +
+ + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RingerScoreZeitBew.Aktionen
M
Max Mustermann
24.03.26 · Krafttest Februar
100%5:42★★★★☆
+
+
Liegestütze
Soll: 3×101:15
+
Kniebeugen
Soll: 3×152:30
+
Burpees
Soll: 3×201:57
+
+
A
Anna Schmidt
23.03.26 · Krafttest Februar
87%6:20★★★☆☆
T
Tom Klein
23.03.26 · Krafttest Februar
65%8:15★★☆☆☆
+
+
+ + + + + +
+ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..713e3f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1198 @@ +# AGENTS.md - WrestleDesk + +## Project Overview + +**Backend-First Development**: This project uses a backend-first approach. Agents should focus on Django backend development before frontend work. + +### Tech Stack + +- **Backend**: Django + Django REST Framework + PostgreSQL +- **Frontend**: Next.js 16 + React + Shadcn UI + Tailwind CSS +- **Auth**: JWT via djangorestframework-simplejwt + +--- + +## Development Commands + +### Backend (Django) + +```bash +cd backend + +pip install -r requirements.txt + +python manage.py migrate +python manage.py makemigrations +python manage.py runserver +python manage.py test +python manage.py shell +python manage.py createsuperuser +python manage.py seed_data +``` + +### Frontend (Next.js) + +```bash +cd frontend + +npm install + +npm run dev +npm run build +npm run start +npm run lint +npm run typecheck +``` + +--- + +## Frontend Setup + +### Create New Next.js Project + +```bash +npx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" +cd frontend +``` + +### Initialize Shadcn UI + +```bash +npx shadcn@latest init + +# When prompted: +# - Style: default +# - Base color: zinc (or slate) +# - CSS file: src/app/globals.css +# - CSS variables: yes +# - Dark mode: class +# - SSR: yes +``` + +### Add Shadcn Components + +```bash +npx shadcn@latest add button card dialog sheet table badge input select dropdown-menu toast tooltip avatar separator tabs accordion alert skeleton progress switch checkbox label scroll-area +``` + +--- + +## Frontend Project Structure + +``` +frontend/ +├── src/ +│ ├── app/ +│ │ ├── (auth)/ +│ │ │ └── login/ +│ │ │ └── page.tsx +│ │ ├── (dashboard)/ +│ │ │ ├── layout.tsx +│ │ │ ├── page.tsx # Dashboard +│ │ │ ├── wrestlers/ +│ │ │ │ └── page.tsx # Wrestlers CRUD +│ │ │ ├── trainers/ +│ │ │ │ └── page.tsx # Trainers CRUD +│ │ │ ├── exercises/ +│ │ │ │ └── page.tsx # Exercises CRUD +│ │ │ ├── trainings/ +│ │ │ │ ├── page.tsx # Trainings list (Grid/List/Calendar) +│ │ │ │ └── [id]/ +│ │ │ │ └── page.tsx # Training detail + attendance + homework +│ │ │ ├── templates/ +│ │ │ │ └── page.tsx # Templates CRUD +│ │ │ ├── homework/ +│ │ │ │ └── page.tsx # Homework assignments overview +│ │ │ └── clubs/ +│ │ │ └── page.tsx # Clubs CRUD +│ │ ├── api/ +│ │ │ └── [...slug]/ +│ │ │ └── route.ts # API proxy +│ │ ├── layout.tsx +│ │ ├── globals.css +│ │ └── providers.tsx +│ ├── components/ +│ │ ├── ui/ # Shadcn components +│ │ ├── layout/ # Sidebar +│ │ ├── modal.tsx # Reusable modal +│ │ ├── animations.tsx # FadeIn wrapper +│ │ ├── skeletons.tsx # Loading states +│ │ ├── empty-state.tsx # Empty state +│ │ └── pagination.tsx # Pagination +│ ├── lib/ +│ │ ├── api.ts # API client + types +│ │ ├── auth.ts # Zustand auth store +│ │ └── utils.ts # cn() helper +│ └── hooks/ # React hooks +├── next.config.js +├── tailwind.config.ts +└── package.json +``` + +--- + +## Implemented Features Status + +### ✅ Fully Working + +| Feature | Backend | Frontend | Notes | +|---------|---------|----------|-------| +| **Authentication** | ✅ JWT login/register | ✅ Login page with zustand | Rate limited, persistent storage | +| **Dashboard** | ✅ Stats endpoints | ✅ Stats cards + animations | Shows wrestlers, trainers, trainings, open homework | +| **Wrestlers CRUD** | ✅ Full API | ✅ Full UI with photo upload | Filters, pagination, FormData | +| **Trainers CRUD** | ✅ Full API | ✅ Full UI with photo upload | Filters, pagination | +| **Exercises CRUD** | ✅ Full API | ✅ Full UI with categories | Categories: warmup, kraft, technik, ausdauer, spiele, cool_down | +| **Clubs CRUD** | ✅ Full API | ✅ Full UI with logo upload | Wrestler count displayed | +| **Trainings** | ✅ Full API | ✅ Grid/List/Calendar view | Attendance management working | +| **Training Detail** | ✅ Full API | ✅ 2-column layout | Participants + Homework side-by-side | +| **Homework System** | ✅ Complete | ✅ Full implementation | Training-based homework with completion tracking | +| **Templates** | ✅ Full API | ✅ Basic CRUD | Exercise association via through table | + +### ⚠️ Known Issues + +| Issue | Status | Priority | +|-------|--------|----------| +| **Wrestlers FormData** | 🔴 Bug in handleSubmit - references undefined variables | High | +| **Locations Page** | 🟡 Backend exists, frontend missing | Medium | +| **Settings Page** | 🟡 Sidebar links to /settings but no page | Low | + +--- + +## Architecture Decisions + +### ❌ NOT Required: Club-Level Data Isolation + +**Important**: Club-level data isolation is NOT a requirement for this project. All users can see all data across all clubs. + +- The `ClubFilterBackend` and `ClubLevelPermission` exist in `utils/permissions.py` but are NOT enforced +- ViewSets use only `IsAuthenticated` permission +- This is intentional - the system is designed for open data access + +### Multi-Club Training Feature + +Trainers and wrestlers from OTHER clubs CAN be added to training sessions: + +- `GET /api/v1/trainers/available_for_training/` - Returns ALL trainers +- `GET /api/v1/wrestlers/available_for_training/` - Returns ALL wrestlers + +This allows cross-club collaboration while maintaining separate club entities. + +--- + +## React Patterns + +### Component Structure + +```tsx +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface Props { + title: string +} + +export function ComponentName({ title }: Props) { + const [loading, setLoading] = useState(false) + + return ( + + + {title} + + + + + + ) +} +``` + +### Using Shadcn Components + +```tsx +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +``` + +### Toast Notifications (Sonner) + +```tsx +import { toast } from "sonner" + +toast.success("Ringer erstellt") +toast.error("Fehler beim Speichern") +``` + +--- + +## Next.js/React Development Guidelines + +#### Core Principles +- **Server Components First**: Use Server Components by default; add "use client" only when interactivity is needed +- **TypeScript Strict Mode**: Enable strict TypeScript everywhere; avoid `any` type +- **Component Composition**: Prefer small, reusable components over monolithic ones +- **Data Fetching**: Use React Query (TanStack Query) for client-side data fetching; Server Components for initial data +- **Performance**: Lazy load routes and heavy components; use `next/image` for optimized images + +#### Project Structure Best Practices + +**File Organization** +``` +src/ +├── app/ # App Router (file-based routing) +│ ├── (auth)/ # Route groups (no URL prefix) +│ ├── (dashboard)/ # Dashboard routes +│ ├── api/ # API routes (proxy to backend) +│ └── layout.tsx # Root layout +├── components/ +│ ├── ui/ # Shadcn/ui components (ALWAYS manually managed) +│ ├── forms/ # Form components with react-hook-form + zod +│ ├── data-display/ # Tables, lists, cards +│ └── layout/ # Sidebar, header, navigation +├── hooks/ # Custom React hooks +├── lib/ # Utilities, API client, stores +├── types/ # Shared TypeScript types +└── styles/ # Global styles +``` + +**Naming Conventions** +- Components: `PascalCase.tsx` (e.g., `WrestlerCard.tsx`) +- Hooks: `camelCase.ts` with `use` prefix (e.g., `useWrestlers.ts`) +- Utils: `camelCase.ts` (e.g., `formatDate.ts`) +- Types: `PascalCase.ts` or inline interfaces +- Constants: `UPPERCASE_WITH_UNDERSCORE` + +#### Component Development + +**Component Anatomy** +```tsx +// 1. "use client" ONLY if needed for interactivity +"use client" + +import { useState } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" + +// 2. Define schema with zod +const formSchema = z.object({ + firstName: z.string().min(1, "Vorname ist erforderlich"), + lastName: z.string().min(1, "Nachname ist erforderlich"), +}) + +type FormValues = z.infer + +// 3. Define interface BEFORE component +interface WrestlerFormProps { + onSubmit: (values: FormValues) => Promise + defaultValues?: Partial +} + +// 4. Component with explicit prop types +export function WrestlerForm({ onSubmit, defaultValues }: WrestlerFormProps) { + const [isLoading, setIsLoading] = useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: defaultValues ?? { + firstName: "", + lastName: "", + }, + }) + + async function handleSubmit(values: FormValues) { + setIsLoading(true) + try { + await onSubmit(values) + toast.success("Ringer gespeichert") + } catch (error) { + toast.error("Fehler beim Speichern") + } finally { + setIsLoading(false) + } + } + + return ( +
+ + ( + + Vorname + + + + + + )} + /> + + + + ) +} +``` + +**When to Use "use client"** +| Use Client | Server Component | +|------------|-----------------| +| useState, useEffect | Data fetching (Server Components) | +| Event handlers | Static UI rendering | +| Browser APIs | SEO metadata | +| Third-party hooks | Static forms (no validation) | +| Real-time subscriptions | Static pages | + +#### State Management Strategy + +**Local State**: Use `useState` for component-specific UI state +```tsx +const [isOpen, setIsOpen] = useState(false) +const [selectedId, setSelectedId] = useState(null) +``` + +**Server State (TanStack Query)**: Use for all API data +```tsx +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { apiFetch } from "@/lib/api" + +export function useWrestlers(filters?: Record) { + return useQuery({ + queryKey: ["wrestlers", filters], + queryFn: () => apiFetch<{ results: Wrestler[] }>("/wrestlers/", { params: filters }), + }) +} + +export function useCreateWrestler() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: CreateWrestlerInput) => + apiFetch("/wrestlers/", { method: "POST", body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["wrestlers"] }) + toast.success("Ringer erstellt") + }, + }) +} +``` + +**Global UI State (Zustand)**: Use for auth, theme, modals +```tsx +// lib/auth-store.ts +import { create } from "zustand" +import { persist } from "zustand/middleware" + +interface AuthState { + token: string | null + user: User | null + setAuth: (token: string, user: User) => void + logout: () => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + user: null, + setAuth: (token, user) => set({ token, user }), + logout: () => set({ token: null, user: null }), + }), + { name: "auth-storage" } + ) +) +``` + +#### Form Handling + +**Use react-hook-form + zod for all forms** +```tsx +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +const schema = z.object({ + email: z.string().email("Ungültige E-Mail"), + password: z.string().min(8, "Mindestens 8 Zeichen"), +}) + +type FormData = z.infer + +export function LoginForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { email: "", password: "" }, + }) + + // ... form implementation +} +``` + +**FormData for File Uploads** +```tsx +const formData = new FormData() +formData.append("first_name", data.firstName) +formData.append("photo", photoFile) // File object directly + +await apiFetch("/wrestlers/", { + method: "POST", + body: formData, + // Don't set Content-Type header - browser sets it with boundary +}) +``` + +#### Data Fetching Patterns + +**Next.js App Router - Server Components (Preferred)** +```tsx +// app/(dashboard)/wrestlers/page.tsx +import { apiFetch } from "@/lib/api" +import { WrestlerList } from "./wrestler-list" + +export default async function WrestlersPage() { + const data = await apiFetch<{ results: Wrestler[] }>("/wrestlers/") + + return +} +``` + +**Client Components with TanStack Query** +```tsx +// components/wrestler-list.tsx +"use client" + +import { useQuery } from "@tanstack/react-query" +import { apiFetch } from "@/lib/api" + +export function WrestlerList() { + const { data, isLoading, error } = useQuery({ + queryKey: ["wrestlers"], + queryFn: () => apiFetch<{ results: Wrestler[] }>("/wrestlers/"), + }) + + if (isLoading) return + if (error) return
Fehler beim Laden
+ + return ( +
+ {data?.results.map((wrestler) => ( + + ))} +
+ ) +} +``` + +#### Reactive Filter + Table Pattern + +Pages with filters (search, dropdowns) and a data table must use a **two-component split** to prevent full-page flashes when typing: + +``` +PageNamePage → Header + Filter bar (stable, mounted once) +PageNameTable → Table + Pagination (reactive, updates independently) +``` + +**The Problem:** If `isLoading` is shared state at the page level, typing in the search input triggers a fetch which sets `isLoading = true`, and if that renders `` at the top of the return statement, the entire page goes blank on every keystroke. + +**The Fix:** + +1. **Main page component** holds: header, filter bar, modal state, total count +2. **Table sub-component** holds: table data state, `isTableLoading` state, fetch logic +3. Filter changes → only the table component re-renders, not the whole page +4. Table sub-component shows its own skeleton rows during loading, never replaces the page + +**exercises/page.tsx pattern:** +```tsx +function ExerciseTable({ filters, token, onEdit, onDelete }) { + const [exercises, setExercises] = useState([]) + const [isTableLoading, setIsTableLoading] = useState(false) + + useEffect(() => { + setIsTableLoading(true) + fetchExercises(filters).then(data => { + setExercises(data.results) + setIsTableLoading(false) + }) + }, [filters]) + + if (isTableLoading) { + return ( +
+ +
+ ) + } + + return +} + +export default function ExercisesPage() { + const [filters, setFilters] = useState(DEFAULT_FILTERS) + const [totalCount, setTotalCount] = useState(0) + + if (isLoading) return // initial load only + + return ( +
+ Header + Add Button + Filter Bar + + + +
+ ) +} +``` + +**Key rules:** +- `isLoading` (initial page load) → `` returned BEFORE the main layout +- `isTableLoading` (filter/pagination changes) → table shows inline skeleton, page layout stays +- NEVER `return ` inside the main return's JSX tree — it must be an early return before the layout renders +- Table component is a separate function component, not inline JSX +- Parent page's `isLoading` initializes to `true`, becomes `false` after first mount effect + +#### Delete Confirmation Modal Pattern + +For destructive actions (delete) on list pages, always show a confirmation modal before executing the delete. This prevents accidental deletions. + +**State (in the page component):** +```tsx +const [deleteId, setDeleteId] = useState(null) +const [isDeleting, setIsDeleting] = useState(false) +``` + +**Delete handler (sets confirmation state instead of deleting directly):** +```tsx +const handleDelete = (id: number) => { + setDeleteId(id) +} + +const confirmDelete = async () => { + if (!deleteId) return + setIsDeleting(true) + try { + await apiFetch(`/resource/${deleteId}/`, { method: "DELETE", token: token! }) + toast.success("Erfolgreich gelöscht") + setDeleteId(null) + fetchData() + } catch { + toast.error("Fehler beim Löschen") + } finally { + setIsDeleting(false) + } +} +``` + +**Modal (at bottom of page, before closing tag):** +```tsx + !open && setDeleteId(null)} + title="[Resource] löschen" + description="Bist du sicher, dass du diesen Eintrag löschen möchtest?" + size="sm" + footer={ + <> + + + + } +> +
+ +``` + +**Delete button in table (calls handleDelete, not the API directly):** +```tsx + +``` + +**Key rules:** +- Never call the API directly from a delete button — always go through the confirmation flow +- The modal shows `size="sm"` for simple confirmations (just a title + description) +- The modal body is empty (`
`) — the description text is sufficient +- `isDeleting` prevents double-submit while the API call is in flight + +#### Error Handling + +**API Error Handling** +```tsx +async function handleSubmit(values: FormValues) { + try { + await createWrestler(values) + toast.success("Erfolg!") + } catch (error) { + if (error instanceof ApiError) { + // Handle field errors + if (error.status === 422) { + form.setError("firstName", { message: error.errors.first_name?.[0] }) + return + } + } + toast.error("Ein Fehler ist aufgetreten") + } +} +``` + +**Error Boundaries** +```tsx +"use client" + +import { Component, type ReactNode } from "react" + +interface Props { + children: ReactNode + fallback: ReactNode +} + +interface State { + hasError: boolean +} + +export class ErrorBoundary extends Component { + state: State = { hasError: false } + + static getDerivedStateFromError(): State { + return { hasError: true } + } + + render() { + if (this.state.hasError) { + return this.props.fallback + } + return this.props.children + } +} +``` + +#### Performance Optimization + +**Code Splitting** +```tsx +import dynamic from "next/dynamic" + +const DataTable = dynamic(() => import("@/components/data-table"), { + loading: () => , + ssr: false, +}) +``` + +**Image Optimization** +```tsx +import Image from "next/image" + + +``` + +**Memoization** +```tsx +import { useMemo } from "react" + +const sortedWrestlers = useMemo(() => { + return wrestlers + .filter(w => w.isActive) + .sort((a, b) => a.lastName.localeCompare(b.lastName)) +}, [wrestlers]) +``` + +#### Testing Guidelines + +**Component Testing** +```tsx +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi } from "vitest" +import { WrestlerCard } from "./wrestler-card" + +describe("WrestlerCard", () => { + it("renders wrestler name", () => { + render() + expect(screen.getByText("Max Mustermann")).toBeInTheDocument() + }) + + it("calls onEdit when edit button is clicked", async () => { + const onEdit = vi.fn() + render() + + fireEvent.click(screen.getByRole("button", { name: /bearbeiten/i })) + + expect(onEdit).toHaveBeenCalledWith(mockWrestler.id) + }) +}) +``` + +#### Accessibility (a11y) + +- Use semantic HTML elements (` + + +
+ {label} +
+ +
+
+ ) + + return ( +
+ + + + + {popoverOpen && selectedDay && ( +
setPopoverOpen(false)}> +
+ + +
+ + {format(selectedDay, "EEEE, d. MMMM", { locale: de })} + + +
+
+ + {selectedDayTrainings.map(training => ( +
{ + onView(training) + setPopoverOpen(false) + }} + > +
+
+ {training.start_time} - {training.end_time} +
+ + {groupConfig[training.group as keyof typeof groupConfig]?.label} + +
+
+ + {training.attendance_count || 0} +
+
+ ))} +
+
+
+
+ )} +
+ ) +} +``` + +--- + +## Task 4: Integrate Calendar into Trainings Page + +**Files:** +- Modify: `frontend/src/app/(dashboard)/trainings/page.tsx` + +- [ ] **Step 1: Add imports** + +Add to imports section: +```tsx +import { CalendarView } from "@/components/ui/calendar-view" +import { Calendar } from "lucide-react" +``` + +- [ ] **Step 2: Update viewMode state** + +Change line ~50: +```tsx +const [viewMode, setViewMode] = useState<"grid" | "list" | "calendar">("grid") +``` + +- [ ] **Step 3: Update toggle buttons** + +Find the existing toggle (around line 420-440) and update to: +```tsx +
+ + + +
+``` + +- [ ] **Step 4: Add CalendarView rendering** + +After the existing grid/list views (around line 525), add: +```tsx +{viewMode === "calendar" && ( + + setDeleteId(id)} + onView={(training) => router.push(`/trainings/${training.id}`)} + refreshTrigger={trainings.length} + /> + +)} +``` + +- [ ] **Step 5: Hide pagination in calendar view** + +Find pagination section and update: +```tsx +{totalPages > 1 && viewMode !== "calendar" && ( + +)} +``` + +--- + +## Task 5: Build and Test + +- [ ] **Step 1: Run build** + +Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run build` +Expected: SUCCESS + +- [ ] **Step 2: Test in browser** + +Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run dev` +Navigate to: http://localhost:3000/trainings +- Click calendar icon in toggle +- Verify calendar renders with month navigation +- Click on a day to see trainings popover +- Navigate between months +- Click "Heute" to return to current month + +--- + +## Task 6: Commit + +```bash +git add -A +git commit -m "feat(trainings): add calendar month view with react-big-calendar" +``` \ No newline at end of file diff --git a/docs/superpowers/plans/2026-03-23-calendar-improvements.md b/docs/superpowers/plans/2026-03-23-calendar-improvements.md new file mode 100644 index 0000000..7a44aee --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-calendar-improvements.md @@ -0,0 +1,310 @@ +# Calendar UI Improvements - Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enhance calendar with better colors, today highlighting, group-based event colors, hover tooltips, and click-to-day-panel + +**Architecture:** Frontend-only CSS and component changes to existing calendar-view.tsx and calendar.css + +**Tech Stack:** React, TypeScript, Tailwind CSS, react-big-calendar, date-fns + +--- + +## Files Overview + +| File | Purpose | +|------|---------| +| `frontend/src/components/ui/calendar-view.tsx` | Main calendar component | +| `frontend/src/components/ui/calendar.css` | Calendar styling overrides | + +--- + +## Task 1: Enhance "Today" Highlighting + +**Files:** +- Modify: `frontend/src/components/ui/calendar.css:68-70` + +**Changes:** +- Replace today's subtle background with light green accent +- Add subtle animation pulse effect +- Make date number bold + +**Steps:** + +- [ ] **Step 1: Update .rbc-today styling** + Replace current implementation with light green accent + +```css +.rbc-today { + background: hsl(142, 76%, 96%); + border: 2px solid hsl(142, 76%, 45%); + border-radius: 0.5rem; +} +``` + +- [ ] **Step 2: Add today date cell bold styling** + Make today's date number more prominent + +```css +.rbc-today .rbc-date-cell { + font-weight: 700; + color: hsl(142, 76%, 30%); +} +``` + +- [ ] **Step 3: Test in browser** + Navigate to /trainings and verify today is highlighted + +--- + +## Task 2: Group-Based Event Colors + +**Files:** +- Modify: `frontend/src/components/ui/calendar.css:87-94` +- Modify: `frontend/src/components/ui/calendar-view.tsx:114-123` + +**Changes:** +- Kids: Blue (#3B82F6) with 15% opacity background +- Youth: Purple (#8B5CF6) with 15% opacity background +- Adults: Orange (#F97316) with 15% opacity background +- Text in dark version of each color for readability + +**Steps:** + +- [ ] **Step 1: Define group color constants in calendar-view.tsx** + Add color configuration + +```typescript +const groupColors = { + kids: { bg: "rgba(59, 130, 246, 0.15)", text: "#1E40AF", border: "#3B82F6" }, + youth: { bg: "rgba(139, 92, 246, 0.15)", text: "#5B21B6", border: "#8B5CF6" }, + adults: { bg: "rgba(249, 115, 22, 0.15)", text: "#9A3412", border: "#F97316" }, + all: { bg: "rgba(100, 100, 100, 0.15)", text: "#404040", border: "#666666" }, +} +``` + +- [ ] **Step 2: Update eventStyleGetter to use group colors** + Modify function at line 114 + +```typescript +const eventStyleGetter = useCallback((event: CalendarEvent) => { + const group = event.resource.group || "all" + const colors = groupColors[group as keyof typeof groupColors] + return { + style: { + backgroundColor: colors.bg, + borderLeft: `3px solid ${colors.border}`, + borderRadius: "0.25rem", + border: "none", + color: colors.text, + } + } +}, []) +``` + +- [ ] **Step 3: Update CSS for events** + Replace existing .rbc-event styles + +```css +.rbc-event { + background: transparent; + border: none; + border-left: 3px solid; + border-radius: 0.25rem; + padding: 0.125rem 0.375rem; + font-size: 0.75rem; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); +} +``` + +- [ ] **Step 4: Test in browser** + Verify each group shows correct color + +--- + +## Task 3: Hover Tooltip + +**Files:** +- Modify: `frontend/src/components/ui/calendar-view.tsx` +- Create: `frontend/src/components/ui/calendar-tooltip.tsx` (optional, can be inline) + +**Changes:** +- Show tooltip on event hover with training details +- Tooltip shows: Time, Group badge, Location, Attendee count +- Position tooltip above event + +**Steps:** + +- [ ] **Step 1: Add tooltip state and handlers to calendar-view.tsx** + Add state for tooltip visibility, position, and content + +```typescript +const [tooltipInfo, setTooltipInfo] = useState<{ + visible: boolean + x: number + y: number + content: { time: string, group: string, location: string, attendees: number } +} | null>(null) +``` + +- [ ] **Step 2: Create custom Event component with hover** + Replace CustomEvent at line 125 + +```typescript +const CustomEvent = ({ event }: { event: CalendarEvent }) => { + const [showTooltip, setShowTooltip] = useState(false) + + return ( +
{ + setShowTooltip(true) + setTooltipInfo({ + visible: true, + x: e.clientX, + y: e.clientY - 10, + content: { + time: `${event.resource.start_time} - ${event.resource.end_time}`, + group: groupConfig[event.resource.group as keyof typeof groupConfig]?.label || "", + location: event.resource.location_name || "Kein Ort", + attendees: event.resource.attendance_count || 0, + } + }) + }} + onMouseLeave={() => setShowTooltip(false)} + > +
+ {event.resource.start_time} +
+ {showTooltip && tooltipInfo?.visible && ( +
+
{tooltipInfo.content.time}
+
+ + {tooltipInfo.content.group} + +
+
+ 📍 {tooltipInfo.content.location} +
+
+ 👥 {tooltipInfo.content.attendees} Teilnehmer +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 3: Test hover in browser** + Hover over training events and verify tooltip appears + +--- + +## Task 4: Click on Day → Trainings Panel + +**Files:** +- Modify: `frontend/src/components/ui/calendar-view.tsx:180-230` + +**Changes:** +- Replace bottom popover with improved side panel or modal +- Show all trainings for selected day +- Better styling with group colors +- Click on training opens detail view + +**Steps:** + +- [ ] **Step 1: Import Sheet component** + Add Sheet import at top of file + +```typescript +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" +``` + +- [ ] **Step 2: Replace popover state with sheet state** + Change state from popoverOpen to sheetOpen + +```typescript +const [selectedDaySheetOpen, setSelectedDaySheetOpen] = useState(false) +``` + +- [ ] **Step 3: Replace popover JSX with Sheet component** + Replace lines 180-230 with: + +```typescript + + + + + {format(selectedDay!, "EEEE, d. MMMM yyyy", { locale: de })} + + +
+ {selectedDayTrainings.length === 0 ? ( +

+ Keine Trainings an diesem Tag +

+ ) : ( + selectedDayTrainings.map(training => ( +
{ + onView(training) + setSelectedDaySheetOpen(false) + }} + > +
+
+ {training.start_time} - {training.end_time} +
+ + {groupConfig[training.group as keyof typeof groupConfig]?.label} + + {training.location_name && ( +
+ + {training.location_name} +
+ )} +
+
+ + {training.attendance_count || 0} +
+
+ )) + )} +
+
+
+``` + +- [ ] **Step 4: Update handleSelectSlot to use new state** + Change line 107-111 to set `setSelectedDaySheetOpen(true)` instead of `setPopoverOpen(true)` + +- [ ] **Step 5: Test in browser** + Click on a day with trainings and verify Sheet appears + +--- + +## Verification Checklist + +After all tasks: +- [ ] Today is highlighted with light green accent and bold date +- [ ] Training events show group colors (blue/purple/orange) +- [ ] Hover on event shows tooltip with details +- [ ] Click on day opens bottom Sheet with training list +- [ ] Click on training in Sheet opens detail view +- [ ] No console errors +- [ ] Responsive on mobile diff --git a/docs/superpowers/plans/2026-03-23-dashboard-statistics-implementation.md b/docs/superpowers/plans/2026-03-23-dashboard-statistics-implementation.md new file mode 100644 index 0000000..958c03d --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-dashboard-statistics-implementation.md @@ -0,0 +1,278 @@ +# Dashboard Statistics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand Dashboard with comprehensive statistics in Bento Grid layout - attendance by group, homework completion rates, wrestler distribution, and trainer activity. + +**Architecture:** Backend provides single `/api/v1/stats/dashboard/` endpoint aggregating all statistics. Frontend displays in Bento Grid with progress bars and simple bar charts using CSS/divs (no external chart library needed). + +**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend), CSS progress bars (no chart library) + +--- + +## File Structure + +### Backend +- **Create:** `backend/stats/__init__.py` - App init +- **Create:** `backend/stats/apps.py` - App config +- **Create:** `backend/stats/views.py` - DashboardStatsViewSet +- **Modify:** `backend/wrestleDesk/urls.py` - Add stats endpoint +- **Modify:** `backend/settings.py` - Add 'stats' to INSTALLED_APPS + +### Frontend +- **Modify:** `frontend/src/lib/api.ts` - Add `IDashboardStats` interface +- **Modify:** `frontend/src/app/(dashboard)/dashboard/page.tsx` - New stat cards and visualizations + +--- + +## Tasks + +### Task 1: Create Stats Backend App + +- [ ] **Step 1: Create stats app directory structure** + +Create `backend/stats/` with: +``` +stats/ +├── __init__.py +├── apps.py +└── views.py +``` + +- [ ] **Step 2: Create apps.py** + +```python +from django.apps import AppConfig + +class StatsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'stats' +``` + +- [ ] **Step 3: Add 'stats' to INSTALLED_APPS in backend/settings.py** + +Add `'stats'` to the INSTALLED_APPS list. + +- [ ] **Step 4: Create DashboardStatsViewSet in views.py** + +```python +from rest_framework import viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Count, Sum +from datetime import datetime, timedelta +from wrestlers.models import Wrestler +from trainers.models import Trainer +from trainings.models import Training, Attendance +from homework.models import TrainingHomeworkAssignment + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def dashboard_stats(request): + today = datetime.now().date() + week_start = today - timedelta(days=today.weekday()) + two_weeks_ago = today - timedelta(days=14) + + # Wrestlers stats + total_wrestlers = Wrestler.objects.count() + wrestlers_this_week = Wrestler.objects.filter( + created_at__date__gte=week_start + ).count() + + # Trainers stats + total_trainers = Trainer.objects.count() + active_trainers = Trainer.objects.filter(is_active=True).count() + + # Trainings stats + total_trainings = Training.objects.count() + trainings_this_week = Training.objects.filter( + date__gte=week_start + ).count() + + # Homework stats + open_homework = TrainingHomeworkAssignment.objects.filter(is_completed=False).count() + completed_homework = TrainingHomeworkAssignment.objects.filter(is_completed=True).count() + + # Attendance by group this week + trainings_this_week_qs = Training.objects.filter(date__gte=week_start) + attendance_data = {} + for group, label in [('kids', 'Kinder'), ('youth', 'Jugend'), ('adults', 'Erwachsene')]: + group_wrestlers = Wrestler.objects.filter(group=group) + attended = Attendance.objects.filter( + training__in=trainings_this_week_qs, + wrestler__in=group_wrestlers + ).values('wrestler').distinct().count() + total = group_wrestlers.count() + attendance_data[group] = { + 'attended': attended, + 'total': total, + 'percent': int((attended / total * 100) if total > 0 else 0) + } + + # Activity (last 14 days) + activity = [] + for i in range(14): + day = today - timedelta(days=13 - i) + count = Attendance.objects.filter(training__date=day).count() + activity.append({'date': day.isoformat(), 'count': count}) + + # Wrestlers by group + wrestlers_by_group = { + 'kids': Wrestler.objects.filter(group='kids', is_active=True).count(), + 'youth': Wrestler.objects.filter(group='youth', is_active=True).count(), + 'adults': Wrestler.objects.filter(group='adults', is_active=True).count(), + 'inactive': Wrestler.objects.filter(is_active=False).count(), + } + + # Top trainers (by training count) + trainer_stats = Trainer.objects.annotate( + training_count=Count('trainings') + ).order_by('-training_count')[:5] + top_trainers = [ + {'name': t.first_name + ' ' + t.last_name[0] + '.', 'training_count': t.training_count} + for t in trainer_stats + ] + + return Response({ + 'wrestlers': {'total': total_wrestlers, 'this_week': wrestlers_this_week}, + 'trainers': {'total': total_trainers, 'active': active_trainers}, + 'trainings': {'total': total_trainings, 'this_week': trainings_this_week}, + 'homework': {'open': open_homework, 'completed': completed_homework}, + 'attendance': { + 'this_week': attendance_data, + 'average': Attendance.objects.filter(training__date__gte=week_start).values('training').distinct().count(), + 'expected': total_wrestlers + }, + 'activity': activity, + 'wrestlers_by_group': wrestlers_by_group, + 'top_trainers': top_trainers, + }) +``` + +- [ ] **Step 5: Add stats URL to urls.py** + +Add to `backend/wrestleDesk/urls.py`: +```python +from stats.views import dashboard_stats + +path('api/v1/stats/dashboard/', dashboard_stats, name='dashboard-stats'), +``` + +--- + +### Task 2: Update Frontend API Types + +- [ ] **Step 1: Add IDashboardStats interface to frontend/src/lib/api.ts** + +Add after existing interfaces: +```typescript +export interface IDashboardStats { + wrestlers: { total: number; this_week: number } + trainers: { total: number; active: number } + trainings: { total: number; this_week: number } + homework: { open: number; completed: number } + attendance: { + this_week: { + kids: { attended: number; total: number; percent: number } + youth: { attended: number; total: number; percent: number } + adults: { attended: number; total: number; percent: number } + } + average: number + expected: number + } + activity: { date: string; count: number }[] + wrestlers_by_group: { + kids: number + youth: number + adults: number + inactive: number + } + top_trainers: { name: string; training_count: number }[] +} +``` + +--- + +### Task 3: Update Dashboard Page + +- [ ] **Step 1: Update imports in frontend/src/app/(dashboard)/dashboard/page.tsx** + +Add `Progress` component and `IDashboardStats`: +```typescript +import { apiFetch, IDashboardStats } from "@/lib/api" +import { Progress } from "@/components/ui/progress" +``` + +- [ ] **Step 2: Replace Stats interface and statCards with new implementation** + +Replace the existing interface and statCards with: +```typescript +const groupColors = { + kids: "bg-blue-500", + youth: "bg-purple-500", + adults: "bg-orange-500", +} + +const groupLabels = { + kids: "Kinder", + youth: "Jugend", + adults: "Erwachsene", + inactive: "Inaktiv", +} +``` + +- [ ] **Step 3: Replace useEffect to fetch from stats endpoint** + +Replace `fetchStats` with: +```typescript +useEffect(() => { + if (!token) return + const fetchStats = async () => { + setIsLoading(true) + try { + const data = await apiFetch('/stats/dashboard/', { token }) + setStats(data) + } catch (error) { + console.error("Failed to fetch stats:", error) + } finally { + setIsLoading(false) + } + } + fetchStats() +}, [token]) +``` + +- [ ] **Step 4: Replace the dashboard content** + +Replace the entire return section with the Bento Grid layout including: +- 4 stat cards (enhanced) +- Attendance by group card with progress bars +- Training activity card with bar chart +- Homework completion card (full width) +- Wrestlers by group card +- Top trainers card + +Each card uses `FadeIn` with appropriate delay props. + +--- + +## Testing + +### Backend +- Run: `cd backend && python manage.py check` +- Test endpoint: `curl -H "Authorization: Bearer " http://localhost:8000/api/v1/stats/dashboard/` + +### Frontend +- Run: `cd frontend && npm run lint` +- Run: `npm run typecheck` +- Visit: http://localhost:3000/dashboard + +--- + +## Notes +- Progress bars use Tailwind `bg-*` classes with calculated widths +- Bar chart uses flexbox with varying heights +- All data loaded asynchronously with loading state +- Error handling: console.error on failure, UI continues to show zeros diff --git a/docs/superpowers/plans/2026-03-23-homework-training-improvements.md b/docs/superpowers/plans/2026-03-23-homework-training-improvements.md new file mode 100644 index 0000000..403375e --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-homework-training-improvements.md @@ -0,0 +1,126 @@ +# WrestleDesk UI Improvements - Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 3 issues: (1) Better homework page cards, (2) Wrestlers pagination bug, (3) Training homework icon color + Sheet conversion + +**Architecture:** Frontend-only changes (React/Next.js with TypeScript, Tailwind, Shadcn UI) + +**Tech Stack:** Next.js 16, React, TypeScript, Tailwind CSS, Shadcn UI (Sheet, Card, Badge, Avatar) + +--- + +## Files Overview + +| File | Purpose | +|------|---------| +| `frontend/src/app/(dashboard)/homework/page.tsx` | Homework page - improve card layout | +| `frontend/src/app/(dashboard)/wrestlers/page.tsx` | Wrestlers page - fix pagination | +| `frontend/src/app/(dashboard)/trainings/[id]/page.tsx` | Training detail - fix icon color + Sheet | +| `frontend/src/lib/api.ts` | API types - verify ITrainingHomeworkAssignment | + +--- + +## Task 1: Improve Homework Page Card Layout + +**Files:** +- Modify: `frontend/src/app/(dashboard)/homework/page.tsx:169-246` + +**Changes:** +- Replace grouped card layout with individual wrestler cards +- Each card shows: Avatar, Wrestler Name, Training Date, Group Badge, Exercise List with reps/time, Status Badge, expandable Notes +- Grid layout: 2-3 columns on desktop, 1 on mobile +- Exercise list shows category color coding + +**Steps:** + +- [ ] **Step 1: Add Sheet component to imports (if not already available)** + Check if Sheet is imported. If not, run: + ```bash + cd frontend && npx shadcn@latest add sheet + ``` + +- [ ] **Step 2: Modify the card rendering loop (lines 169-246)** + Replace current grouped cards with individual wrestler cards in a grid + +- [ ] **Step 3: Test the new layout** + Navigate to /homework and verify cards display correctly + +--- + +## Task 2: Fix Wrestlers Pagination Bug + +**Files:** +- Modify: `frontend/src/app/(dashboard)/wrestlers/page.tsx:103-126` +- Debug: Backend `backend/wrestlers/views.py` + +**Changes:** +- Verify API returns correct `count` and `results` in PaginatedResponse +- Debug why only 11 wrestlers total are returned instead of actual count +- Possibly fix backend query or frontend display logic + +**Steps:** + +- [ ] **Step 1: Test API directly** + Run: `curl -H "Authorization: Bearer " "http://localhost:8000/api/v1/wrestlers/?page=1&page_size=10"` + Check response for `count` field and actual number of results + +- [ ] **Step 2: Check backend pagination class** + Read `backend/wrestleDesk/pagination.py` to verify StandardResultsSetPagination + +- [ ] **Step 3: Verify frontend handles response correctly** + Check `frontend/src/lib/api.ts` PaginatedResponse interface matches API response + +- [ ] **Step 4: Fix identified issue** + - If backend: fix query or pagination + - If frontend: fix state update or display logic + +- [ ] **Step 5: Test pagination** + Navigate to /wrestlers, verify page 1 shows 10 items, page 2 shows correct items + +--- + +## Task 3: Training Homework - Icon Color + Sheet + +**Files:** +- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx:336-395` (participant icons) +- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx:561-672` (Modal → Sheet) + +**Changes:** +- BookOpen icon color: green if wrestler has homework assignment, gray/muted if not +- Convert homework Modal to Sheet component +- In Sheet, show wrestlers with existing HA marked green + +**Steps:** + +- [ ] **Step 1: Add Sheet component to imports (if not already available)** + Check if Sheet is imported. If not, run: + ```bash + cd frontend && npx shadcn@latest add sheet + ``` + +- [ ] **Step 2: Modify participant icon colors (lines 336-395)** + Add conditional styling to BookOpen icon: + - Green/primary color if `wrestlerAssignments.length > 0` + - Muted/gray color if no assignments + +- [ ] **Step 3: Convert homework Modal to Sheet (lines 561-672)** + Replace `` with `` component + Keep the same form content inside + +- [ ] **Step 4: Add green highlight for wrestlers with HA in Sheet** + When opening homework sheet, show wrestlers that already have assignments highlighted + +- [ ] **Step 5: Test the changes** + Navigate to /trainings/[id], verify icon colors and Sheet functionality + +--- + +## Verification Checklist + +After all tasks: +- [ ] Homework page shows cards with all details (Avatar, Name, Training, Exercises, Status, Notes) +- [ ] Wrestlers pagination works: page 1 = 10 items, page 2 = next 10 items +- [ ] Training detail: BookOpen icon is green for wrestlers with HA, gray for those without +- [ ] Training detail: Homework assignment opens as Sheet, not Modal +- [ ] No console errors on any of the affected pages diff --git a/docs/superpowers/plans/2026-03-23-leistungstest-implementation.md b/docs/superpowers/plans/2026-03-23-leistungstest-implementation.md new file mode 100644 index 0000000..bc548e8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-leistungstest-implementation.md @@ -0,0 +1,415 @@ +# Leistungstest Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create Leistungstest (Performance Test) system with templates, assignment, results tracking, and leaderboard. + +**Architecture:** Backend provides CRUD for templates and results + leaderboard endpoint. Frontend has 4 tabs: Vorlagen, Zuweisen, Ergebnisse, Leaderboard. + +**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend) + +--- + +## File Structure + +### Backend +- **Create:** `backend/leistungstest/__init__.py` +- **Create:** `backend/leistungstest/apps.py` +- **Create:** `backend/leistungstest/models.py` +- **Create:** `backend/leistungstest/serializers.py` +- **Create:** `backend/leistungstest/views.py` +- **Create:** `backend/leistungstest/urls.py` +- **Modify:** `backend/wrestleDesk/settings.py` — add 'leistungstest' +- **Modify:** `backend/wrestleDesk/urls.py` — include leistungstest URLs + +### Frontend +- **Create:** `frontend/src/app/(dashboard)/leistungstest/page.tsx` +- **Modify:** `frontend/src/components/layout/sidebar.tsx` — add Leistungstest nav item +- **Modify:** `frontend/src/lib/api.ts` — add interfaces + +--- + +## Tasks + +### Task 1: Create Leistungstest Backend App + +- [ ] **Step 1: Create directory and files** + +Create `backend/leistungstest/` with `__init__.py` and `apps.py` + +- [ ] **Step 2: Add to INSTALLED_APPS** + +Add `'leistungstest'` to `INSTALLED_APPS` in `backend/wrestleDesk/settings.py` + +- [ ] **Step 3: Create models** + +```python +from django.db import models +from django.utils import timezone + + +class LeistungstestTemplate(models.Model): + name = models.CharField(max_length=200) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return self.name + + @property + def usage_count(self): + return self.results.count() + + +class LeistungstestTemplateExercise(models.Model): + template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='exercises') + exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE) + target_reps = models.PositiveIntegerField() + order = models.IntegerField(default=0) + + class Meta: + ordering = ['template', 'order'] + unique_together = ['template', 'exercise'] + + def __str__(self): + return f"{self.template.name} - {self.exercise.name}" + + +class LeistungstestResult(models.Model): + template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='results') + wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='leistungstest_results') + total_time_minutes = models.PositiveIntegerField(null=True, blank=True) + rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3) + notes = models.TextField(blank=True) + completed_at = models.DateTimeField(default=timezone.now) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-completed_at'] + indexes = [ + models.Index(fields=['wrestler']), + models.Index(fields=['template']), + models.Index(fields=['completed_at']), + ] + + def __str__(self): + return f"{self.wrestler} - {self.template.name}" + + @property + def score_percent(self): + items = self.items.all() + if not items.exists(): + return 0 + total_target = sum(item.target_reps for item in items) + total_actual = sum(item.actual_reps for item in items) + if total_target == 0: + return 0 + return round((total_actual / total_target) * 100, 1) + + +class LeistungstestResultItem(models.Model): + result = models.ForeignKey(LeistungstestResult, on_delete=models.CASCADE, related_name='items') + exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE) + target_reps = models.PositiveIntegerField() + actual_reps = models.PositiveIntegerField() + order = models.IntegerField(default=0) + + class Meta: + ordering = ['result', 'order'] + + def __str__(self): + return f"{self.result} - {self.exercise.name}: {self.actual_reps}/{self.target_reps}" +``` + +- [ ] **Step 4: Create serializers** + +```python +from rest_framework import serializers +from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem + + +class LeistungstestTemplateExerciseSerializer(serializers.ModelSerializer): + exercise_name = serializers.CharField(source='exercise.name', read_only=True) + + class Meta: + model = LeistungstestTemplateExercise + fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'order'] + + +class LeistungstestTemplateSerializer(serializers.ModelSerializer): + exercises = LeistungstestTemplateExerciseSerializer(many=True, read_only=True) + usage_count = serializers.IntegerField(read_only=True) + + class Meta: + model = LeistungstestTemplate + fields = ['id', 'name', 'exercises', 'usage_count', 'created_at'] + + +class LeistungstestResultItemSerializer(serializers.ModelSerializer): + exercise_name = serializers.CharField(source='exercise.name', read_only=True) + + class Meta: + model = LeistungstestResultItem + fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'order'] + + +class LeistungstestResultSerializer(serializers.ModelSerializer): + items = LeistungstestResultItemSerializer(many=True, read_only=True) + template_name = serializers.CharField(source='template.name', read_only=True) + wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True) + score_percent = serializers.FloatField(read_only=True) + + class Meta: + model = LeistungstestResult + fields = ['id', 'template', 'template_name', 'wrestler', 'wrestler_name', + 'total_time_minutes', 'rating', 'notes', 'completed_at', + 'score_percent', 'items', 'created_at'] + + +class LeaderboardEntrySerializer(serializers.Serializer): + rank = serializers.IntegerField() + wrestler = serializers.DictField() + score_percent = serializers.FloatField() + rating = serializers.IntegerField() + time_minutes = serializers.IntegerField(allow_null=True) +``` + +- [ ] **Step 5: Create views** + +```python +from rest_framework import viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Max +from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem +from .serializers import ( + LeistungstestTemplateSerializer, LeistungstestResultSerializer +) + + +class LeistungstestTemplateViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + queryset = LeistungstestTemplate.objects.all() + serializer_class = LeistungstestTemplateSerializer + + def perform_create(self, serializer): + template = serializer.save() + exercises_data = self.request.data.get('exercises', []) + for i, ex in enumerate(exercises_data): + LeistungstestTemplateExercise.objects.create( + template=template, + exercise_id=ex['exercise'], + target_reps=ex['target_reps'], + order=i + ) + + +class LeistungstestTemplateExerciseViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + queryset = LeistungstestTemplateExercise.objects.all() + serializer_class = None + + +class LeistungstestResultViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = LeistungstestResultSerializer + + def get_queryset(self): + queryset = LeistungstestResult.objects.all() + wrestler = self.request.query_params.get('wrestler') + template = self.request.query_params.get('template') + if wrestler: + queryset = queryset.filter(wrestler=wrestler) + if template: + queryset = queryset.filter(template=template) + return queryset.prefetch_related('items') + + def perform_create(self, serializer): + result = serializer.save() + items_data = self.request.data.get('items', []) + for i, item in enumerate(items_data): + LeistungstestResultItem.objects.create( + result=result, + exercise_id=item['exercise'], + target_reps=item['target_reps'], + actual_reps=item['actual_reps'], + order=i + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def leaderboard(request): + template_id = request.query_params.get('template') + if not template_id: + return Response({'error': 'template is required'}, status=400) + + template = LeistungstestTemplate.objects.get(id=template_id) + + latest_results = LeistungstestResult.objects.filter( + template=template + ).values('wrestler').annotate( + latest_date=Max('completed_at') + ) + + rankings = [] + for entry in latest_results: + result = LeistungstestResult.objects.get( + template=template, + wrestler_id=entry['wrestler'], + completed_at=entry['latest_date'] + ) + wrestler = result.wrestler + rankings.append({ + 'rank': 0, + 'wrestler': {'id': wrestler.id, 'name': str(wrestler)}, + 'score_percent': result.score_percent, + 'rating': result.rating, + 'time_minutes': result.total_time_minutes + }) + + rankings.sort(key=lambda x: (-x['score_percent'], x['time_minutes'] or 999)) + for i, r in enumerate(rankings): + r['rank'] = i + 1 + + return Response({ + 'template': {'id': template.id, 'name': template.name}, + 'rankings': rankings + }) +``` + +- [ ] **Step 6: Create urls.py** + +```python +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import LeistungstestTemplateViewSet, LeistungstestResultViewSet, leaderboard + +router = DefaultRouter() +router.register(r'templates', LeistungstestTemplateViewSet, basename='leistungstest-template') +router.register(r'results', LeistungstestResultViewSet, basename='leistungstest-result') + +urlpatterns = [ + path('leaderboard/', leaderboard, name='leistungstest-leaderboard'), + path('', include(router.urls)), +] +``` + +- [ ] **Step 7: Add URL to main urls.py** + +```python +path('api/v1/leistungstest/', include('leistungstest.urls')), +``` + +- [ ] **Step 8: Create migration** + +Run: `cd backend && python manage.py makemigrations leistungstest` + +--- + +### Task 2: Update Frontend API Types + +- [ ] **Step 1: Add interfaces to frontend/src/lib/api.ts** + +```typescript +export interface ILeistungstestTemplateExercise { + id: number + exercise: number + exercise_name: string + target_reps: number + order: number +} + +export interface ILeistungstestTemplate { + id: number + name: string + exercises: ILeistungstestTemplateExercise[] + usage_count: number + created_at: string +} + +export interface ILeistungstestResultItem { + id: number + exercise: number + exercise_name: string + target_reps: number + actual_reps: number + order: number +} + +export interface ILeistungstestResult { + id: number + template: number + template_name: string + wrestler: number + wrestler_name: string + total_time_minutes: number | null + rating: number + notes: string + completed_at: string + score_percent: number + items: ILeistungstestResultItem[] + created_at: string +} + +export interface ILeaderboardEntry { + rank: number + wrestler: { id: number; name: string } + score_percent: number + rating: number + time_minutes: number | null +} + +export interface ILeaderboard { + template: { id: number; name: string } + rankings: ILeaderboardEntry[] +} +``` + +--- + +### Task 3: Create Leistungstest Page + +- [ ] **Step 1: Create page with 4 tabs** + +Create `frontend/src/app/(dashboard)/leistungstest/page.tsx` with: +- Tab state (vorlagen | zuweisen | ergebnisse | leaderboard) +- Vorlagen tab: Template list + create form +- Zuweisen tab: Select wrestler/template, record results +- Ergebnisse tab: Results table with filters + progress +- Leaderboard tab: Rankings by template + +- [ ] **Step 2: Add sidebar navigation** + +Add to `sidebar.tsx`: +```typescript +{ name: "Leistungstest", href: "/leistungstest", icon: Trophy }, +``` + +Import Trophy icon from lucide-react. + +--- + +## Testing + +### Backend +- Run: `cd backend && python manage.py check` +- Run: `python manage.py migrate` +- Test endpoint: `curl -H "Authorization: Bearer " http://localhost:8000/api/v1/leistungstest/templates/` + +### Frontend +- Run: `cd frontend && npm run lint` +- Visit: http://localhost:3000/leistungstest + +--- + +## Notes +- Wrestler/Template dropdowns use SelectValue with find() to show names not IDs +- Score = (sum actual_reps / sum target_reps) * 100 +- Leaderboard shows latest result per wrestler for selected template +- Results table sorted by date (newest first) +- Star rating component with clickable stars (1-5) diff --git a/docs/superpowers/plans/2026-03-23-training-log-implementation.md b/docs/superpowers/plans/2026-03-23-training-log-implementation.md new file mode 100644 index 0000000..55620f4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-training-log-implementation.md @@ -0,0 +1,677 @@ +# Training Log Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create Training Log page with 3 tabs (Log/Historie/Analyse) for recording and analyzing wrestler exercise performance. + +**Architecture:** Backend provides CRUD + stats endpoints for TrainingLogEntry model. Frontend uses tabbed interface with async data fetching. + +**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend), Lucide icons + +--- + +## File Structure + +### Backend +- **Create:** `backend/training_log/__init__.py` +- **Create:** `backend/training_log/apps.py` +- **Create:** `backend/training_log/models.py` — TrainingLogEntry model +- **Create:** `backend/training_log/serializers.py` +- **Create:** `backend/training_log/views.py` +- **Create:** `backend/training_log/urls.py` +- **Modify:** `backend/wrestleDesk/settings.py` — add 'training_log' to INSTALLED_APPS +- **Modify:** `backend/wrestleDesk/urls.py` — include training_log URLs + +### Frontend +- **Create:** `frontend/src/app/(dashboard)/training-log/page.tsx` — Main page with tabs +- **Modify:** `frontend/src/lib/api.ts` — Add ITrainingLogEntry, ITrainingLogStats interfaces +- **Modify:** `frontend/src/components/layout/sidebar.tsx` — Add Training Log nav link + +--- + +## Tasks + +### Task 1: Create Training Log Backend App + +- [ ] **Step 1: Create training_log directory and files** + +Create `backend/training_log/` with `__init__.py` and `apps.py` + +- [ ] **Step 2: Add 'training_log' to INSTALLED_APPS in settings.py** + +Add `'training_log'` to the INSTALLED_APPS list. + +- [ ] **Step 3: Create TrainingLogEntry model** + +```python +from django.db import models +from django.utils import timezone + + +class TrainingLogEntry(models.Model): + wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='training_logs') + training = models.ForeignKey('trainings.Training', on_delete=models.SET_NULL, null=True, blank=True, related_name='training_logs') + exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_logs') + reps = models.PositiveIntegerField() + sets = models.PositiveIntegerField(default=1) + time_minutes = models.PositiveIntegerField(null=True, blank=True) + weight_kg = models.DecimalField(null=True, blank=True, max_digits=5, decimal_places=2) + rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3) + notes = models.TextField(blank=True) + logged_at = models.DateTimeField(default=timezone.now) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-logged_at'] + indexes = [ + models.Index(fields=['wrestler']), + models.Index(fields=['exercise']), + models.Index(fields=['logged_at']), + models.Index(fields=['training']), + ] + + def __str__(self): + return f"{self.wrestler} - {self.exercise.name} ({self.reps}x{self.sets})" +``` + +- [ ] **Step 4: Create serializers.py** + +```python +from rest_framework import serializers +from .models import TrainingLogEntry + + +class TrainingLogEntrySerializer(serializers.ModelSerializer): + wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True) + exercise_name = serializers.CharField(source='exercise.name', read_only=True) + training_date = serializers.DateField(source='training.date', read_only=True) + + class Meta: + model = TrainingLogEntry + fields = [ + 'id', 'wrestler', 'wrestler_name', 'training', 'training_date', + 'exercise', 'exercise_name', 'reps', 'sets', 'time_minutes', + 'weight_kg', 'rating', 'notes', 'logged_at', 'created_at' + ] + + +class TrainingLogStatsSerializer(serializers.Serializer): + total_entries = serializers.IntegerField() + unique_exercises = serializers.IntegerField() + total_reps = serializers.IntegerField() + avg_sets = serializers.FloatField() + avg_rating = serializers.FloatField() + this_week = serializers.IntegerField() + top_exercises = serializers.ListField(child=serializers.DictField()) + progress = serializers.DictField() +``` + +- [ ] **Step 5: Create views.py** + +```python +from rest_framework import viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Count, Avg, Sum, F +from django.db.models.functions import Coalesce +from datetime import datetime, timedelta +from .models import TrainingLogEntry +from .serializers import TrainingLogEntrySerializer +from wrestlers.models import Wrestler + + +class TrainingLogEntryViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = TrainingLogEntrySerializer + + def get_queryset(self): + queryset = TrainingLogEntry.objects.all() + wrestler = self.request.query_params.get('wrestler') + exercise = self.request.query_params.get('exercise') + date_from = self.request.query_params.get('date_from') + date_to = self.request.query_params.get('date_to') + + if wrestler: + queryset = queryset.filter(wrestler=wrestler) + if exercise: + queryset = queryset.filter(exercise=exercise) + if date_from: + queryset = queryset.filter(logged_at__date__gte=date_from) + if date_to: + queryset = queryset.filter(logged_at__date__lte=date_to) + + return queryset.select_related('wrestler', 'exercise', 'training') + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def training_log_stats(request): + wrestler_id = request.query_params.get('wrestler') + today = datetime.now().date() + week_start = today - timedelta(days=today.weekday()) + + queryset = TrainingLogEntry.objects.all() + if wrestler_id: + queryset = queryset.filter(wrestler=wrestler_id) + + total_entries = queryset.count() + unique_exercises = queryset.values('exercise').distinct().count() + total_reps = queryset.aggregate(total=Coalesce(Sum(F('reps') * F('sets')), 0))['total'] or 0 + avg_sets = queryset.aggregate(avg=Avg('sets'))['avg'] or 0 + avg_rating = queryset.aggregate(avg=Avg('rating'))['avg'] or 0 + this_week = queryset.filter(logged_at__date__gte=week_start).count() + + top_exercises = queryset.values('exercise__name').annotate( + count=Count('id') + ).order_by('-count')[:5] + + progress = {} + exercises = queryset.values('exercise', 'exercise__name').distinct() + for ex in exercises: + ex_id = ex['exercise'] + entries = queryset.filter(exercise=ex_id).order_by('logged_at') + if entries.count() >= 2: + first_reps = entries.first().reps * entries.first().sets + last_reps = entries.last().reps * entries.last().sets + if first_reps > 0: + change = ((last_reps - first_reps) / first_reps) * 100 + progress[ex['exercise__name']] = { + 'before': first_reps, + 'after': last_reps, + 'change_percent': round(change, 1) + } + + return Response({ + 'total_entries': total_entries, + 'unique_exercises': unique_exercises, + 'total_reps': total_reps, + 'avg_sets': round(avg_sets, 1), + 'avg_rating': round(avg_rating, 1), + 'this_week': this_week, + 'top_exercises': [{'name': e['exercise__name'], 'count': e['count']} for e in top_exercises], + 'progress': progress, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def training_log_compare(request): + wrestler1_id = request.query_params.get('wrestler1') + wrestler2_id = request.query_params.get('wrestler2') + + if not wrestler1_id or not wrestler2_id: + return Response({'error': 'Both wrestler1 and wrestler2 required'}, status=400) + + wrestler1 = Wrestler.objects.get(id=wrestler1_id) + wrestler2 = Wrestler.objects.get(id=wrestler2_id) + + entries1 = TrainingLogEntry.objects.filter(wrestler=wrestler1) + entries2 = TrainingLogEntry.objects.filter(wrestler=wrestler2) + + exercises1 = entries1.values('exercise', 'exercise__name').distinct() + exercises2 = entries2.values('exercise', 'exercise__name').distinct() + common_exercises = set(e['exercise'] for e in exercises1) & set(e['exercise'] for e in exercises2) + + comparison = [] + for ex_id in common_exercises: + ex_name = entries1.filter(exercise=ex_id).first().exercise.name + avg1 = entries1.filter(exercise=ex_id).aggregate(avg=Avg(F('reps') * F('sets')))['avg'] or 0 + avg2 = entries2.filter(exercise=ex_id).aggregate(avg=Avg(F('reps') * F('sets')))['avg'] or 0 + comparison.append({ + 'exercise': ex_name, + 'wrestler1_avg': round(avg1, 1), + 'wrestler2_avg': round(avg2, 1) + }) + + return Response({ + 'wrestler1': {'id': wrestler1.id, 'name': str(wrestler1)}, + 'wrestler2': {'id': wrestler2.id, 'name': str(wrestler2)}, + 'exercises': comparison + }) +``` + +- [ ] **Step 6: Create urls.py** + +```python +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import TrainingLogEntryViewSet, training_log_stats, training_log_compare + +router = DefaultRouter() +router.register(r'', TrainingLogEntryViewSet, basename='training-log') + +urlpatterns = [ + path('stats/', training_log_stats, name='training-log-stats'), + path('compare/', training_log_compare, name='training-log-compare'), + path('', include(router.urls)), +] +``` + +- [ ] **Step 7: Add URL to main urls.py** + +Add to `backend/wrestleDesk/urls.py`: +```python +path('api/v1/training-log/', include('training_log.urls')), +``` + +- [ ] **Step 8: Create migration** + +Run: `cd backend && python manage.py makemigrations training_log` + +--- + +### Task 2: Update Frontend API Types + +- [ ] **Step 1: Add interfaces to frontend/src/lib/api.ts** + +```typescript +export interface ITrainingLogEntry { + id: number + wrestler: number + wrestler_name: string + training: number | null + training_date: string | null + exercise: number + exercise_name: string + reps: number + sets: number + time_minutes: number | null + weight_kg: number | null + rating: number + notes: string + logged_at: string + created_at: string +} + +export interface ITrainingLogStats { + total_entries: number + unique_exercises: number + total_reps: number + avg_sets: number + avg_rating: number + this_week: number + top_exercises: { name: string; count: number }[] + progress: Record +} + +export interface ITrainingLogCompare { + wrestler1: { id: number; name: string } + wrestler2: { id: number; name: string } + exercises: { exercise: string; wrestler1_avg: number; wrestler2_avg: number }[] +} +``` + +--- + +### Task 3: Create Training Log Page + +- [ ] **Step 1: Create frontend/src/app/(dashboard)/training-log/page.tsx** + +Full page component with: +- Tab state (log | histrie | analyse) +- Log tab: Form with wrestler, training, exercise, reps, sets, time, weight, rating, notes +- Historie tab: Filter bar + table with entries +- Analyse tab: Stats summary, progress bars, wrestler comparison + +```typescript +"use client" + +import { useState, useEffect } from "react" +import { useAuth } from "@/lib/auth" +import { apiFetch, ITrainingLogEntry, ITrainingLogStats, ITrainingLogCompare, IWrestler, IExercise, ITraining, PaginatedResponse } from "@/lib/api" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { PageSkeleton } from "@/components/ui/skeletons" +import { FadeIn } from "@/components/ui/animations" +import { ClipboardList, History, BarChart3, Plus, Star, Loader2 } from "lucide-react" + +type TabType = "log" | "historie" | "analyse" + +export default function TrainingLogPage() { + const { token } = useAuth() + const [activeTab, setActiveTab] = useState("log") + const [isLoading, setIsLoading] = useState(true) + + // Data states + const [entries, setEntries] = useState([]) + const [stats, setStats] = useState(null) + const [wrestlers, setWrestlers] = useState([]) + const [exercises, setExercises] = useState([]) + const [trainings, setTrainings] = useState([]) + + // Filter states + const [filterWrestler, setFilterWrestler] = useState("") + const [filterExercise, setFilterExercise] = useState("") + + // Form state + const [formData, setFormData] = useState({ + wrestler: "", + training: "", + exercise: "", + reps: "", + sets: "1", + time_minutes: "", + weight_kg: "", + rating: "3", + notes: "" + }) + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + if (!token) return + fetchData() + }, [token]) + + const fetchData = async () => { + setIsLoading(true) + try { + const [entriesRes, wrestlersRes, exercisesRes, trainingsRes] = await Promise.all([ + apiFetch>("/training-log/", { token }), + apiFetch>("/wrestlers/?page_size=100", { token }), + apiFetch>("/exercises/?page_size=100", { token }), + apiFetch>("/trainings/?page_size=100", { token }), + ]) + setEntries(entriesRes.results || []) + setWrestlers(wrestlersRes.results || []) + setExercises(exercisesRes.results || []) + setTrainings(trainingsRes.results || []) + } catch (err) { + console.error("Failed to fetch data:", err) + } finally { + setIsLoading(false) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!formData.wrestler || !formData.exercise || !formData.reps) return + + setIsSaving(true) + try { + await apiFetch("/training-log/", { + method: "POST", + token: token!, + body: JSON.stringify({ + wrestler: parseInt(formData.wrestler), + training: formData.training ? parseInt(formData.training) : null, + exercise: parseInt(formData.exercise), + reps: parseInt(formData.reps), + sets: parseInt(formData.sets) || 1, + time_minutes: formData.time_minutes ? parseInt(formData.time_minutes) : null, + weight_kg: formData.weight_kg ? parseFloat(formData.weight_kg) : null, + rating: parseInt(formData.rating), + notes: formData.notes, + }), + }) + toast.success("Eintrag gespeichert") + setFormData({ wrestler: "", training: "", exercise: "", reps: "", sets: "1", time_minutes: "", weight_kg: "", rating: "3", notes: "" }) + fetchData() + } catch { + toast.error("Fehler beim Speichern") + } finally { + setIsSaving(false) + } + } + + const filteredEntries = entries.filter(e => { + if (filterWrestler && e.wrestler !== parseInt(filterWrestler)) return false + if (filterExercise && e.exercise !== parseInt(filterExercise)) return false + return true + }) + + const tabs = [ + { id: "log" as TabType, label: "Log", icon: ClipboardList }, + { id: "historie" as TabType, label: "Historie", icon: History }, + { id: "analyse" as TabType, label: "Analyse", icon: BarChart3 }, + ] + + if (isLoading) return + + return ( +
+ +

Training Log

+
+ + {/* Tabs */} + +
+ {tabs.map(tab => ( + + ))} +
+
+ + {/* Log Tab */} + {activeTab === "log" && ( + + + + Neuer Eintrag + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + setFormData({...formData, reps: e.target.value})} /> +
+
+ + setFormData({...formData, sets: e.target.value})} /> +
+
+ + setFormData({...formData, time_minutes: e.target.value})} /> +
+
+
+ + setFormData({...formData, weight_kg: e.target.value})} /> +
+
+ +
+ {[1,2,3,4,5].map(star => ( + + ))} +
+
+
+ +