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
This commit is contained in:
Andrej Spielmann
2026-03-26 13:24:57 +01:00
commit 3fefc550fe
256 changed files with 38295 additions and 0 deletions
@@ -0,0 +1,293 @@
# Leistungstest Live-Timer — Design Specification
## Overview
**What:** A live-timer mode for the Leistungstest "Zuweisen" tab where a trainer runs through a timed workout with multiple wrestlers simultaneously. All wrestlers start the same exercise at the same time. The trainer clicks "Erledigt" per wrestler when each finishes — the next exercise starts automatically. All wrestlers visible in a table-like layout.
**Why:** Replaces the current per-wrestler, per-exercise form entry with a real-time group training experience. Trainers can see all wrestlers at once and track live progress.
---
## Data Model
### TrainingSession (React state + localStorage)
```typescript
interface TrainingSession {
templateId: number
templateName: string
wrestlers: WrestlerSession[]
globalElapsedSeconds: number // shared across all wrestlers
isRunning: boolean
}
interface WrestlerSession {
wrestler: IWrestler
currentExerciseIndex: number
exercises: ExerciseSession[]
}
interface ExerciseSession {
exerciseId: number
exerciseName: string
targetReps: string // e.g. "3×10" — from template
elapsedSeconds: number // time when Erledigt was clicked
status: "pending" | "active" | "done"
}
```
### Backend API
Uses existing endpoint: `POST /leistungstest/results/`
When a wrestler's exercise is marked "Erledigt":
- `POST /leistungstest/results/` with `template`, `wrestler`, `total_time_seconds` (cumulative), `rating: 3`, `notes: ""`, `items` array
When all exercises of a wrestler are done:
- The full result is already saved incrementally — no final save step needed
No new backend endpoints required.
---
## User Flow
### Step 1: Setup (Form Mode)
- Trainer is in "Zuweisen" tab
- Selects a **Template** (has exercises + target reps pre-defined)
- Selects **Wrestlers** (multiple via Sheet)
- Clicks **"⏱ Training starten"** → switches to timer mode
### Step 2: Training Starts
- All wrestlers begin **Exercise 1** simultaneously
- Global timer starts (00:00 → 00:01 → ...)
- All wrestlers visible in table-like layout, each with their own exercise cards
### Step 3: Tracking
- Trainer watches live elapsed time per wrestler/exercise
- When a wrestler finishes an exercise → clicks **"✓ Erledigt"**
- Elapsed time for that exercise is saved
- Next exercise for that wrestler starts immediately (auto-start)
- Other wrestlers continue their current exercise
### Step 4: Completion
- When all exercises of all wrestlers are done (or trainer clicks "Training beenden"):
- Summary modal shows: X/Y wrestlers completed, total time
- All results already saved to backend via incremental POSTs
- localStorage session cleared
- Trainer returns to form mode
### Step 5: Post-Edit
- Results visible in "Ergebnisse" tab
- Can be edited (notes, rating, individual exercise times) via existing edit form
---
## UI Layout
### Header Banner (always visible during timer)
```
┌──────────────────────────────────────────────────────────────┐
│ GEMEINSAME ZEIT 05:23 [⏸ Pausieren] [■ Ende] │
└──────────────────────────────────────────────────────────────┘
```
- Dark navy background (#1B1A55)
- Large monospace timer (MM:SS)
- "Pausieren" toggles to "▶ Fortsetzen" when paused
- "Training beenden" opens confirmation dialog
### Wrestler Rows (scrollable list)
Each wrestler = one row, stacked vertically. Like a table but each row is independent.
```
┌──────────────────────────────────────────────────────────────┐
│ ● Max Mustermann 3/3 ✓ [grün] │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │Liegestütze │ │Kniebeugen │ │Burpees │ │
│ │Soll: 3×10 │ │Soll: 3×15 │ │Soll: 3×20 │ │
│ │✓ 0:28 │ │✓ 0:44 │ │✓ 0:19 │ │
│ │[grün] │ │[grün] │ │[grün] │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
Three states per exercise card:
| State | Background | Border | Badge | Button |
|-------|-----------|--------|-------|--------|
| **pending** | gray-50 | gray-200 | — | ▶ Start (disabled) |
| **active** | yellow-50 | yellow-400 | ▶ Aktiv | ✓ Erledigt |
| **done** | green-50 | green-400 | ✓ MM:SS | — |
When a wrestler finishes all exercises → row header turns green with "✓ Alle erledigt".
### Exercise Card (within wrestler row)
```
┌──────────────────────────┐
│ ▶ Übungsname │
│ Soll: 3×10 │
│ MM:SS │
│ [✓ Erledigt] │
└──────────────────────────┘
```
- Exercise name with status icon
- Target reps from template (e.g. "Soll: 3×10") — **reps, not time**
- Live elapsed seconds (MM:SS) updating every second when active
- Button: "✓ Erledigt" for active exercises
---
## State Management
### React State
```typescript
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
const [session, setSession] = useState<TrainingSession | null>(null)
const [globalTimerInterval, setGlobalTimerInterval] = useState<NodeJS.Timeout | null>(null)
const [restoreModalOpen, setRestoreModalOpen] = useState(false)
const [endModalOpen, setEndModalOpen] = useState(false)
const [summary, setSummary] = useState<{ completed: number; total: number; totalTime: number } | null>(null)
```
### Timer Tick Effect
```typescript
useEffect(() => {
if (session?.isRunning) {
const interval = setInterval(() => {
setSession(prev => prev ? { ...prev, globalElapsedSeconds: prev.globalElapsedSeconds + 1 } : prev)
}, 1000)
setGlobalTimerInterval(interval)
return () => { clearInterval(interval); setGlobalTimerInterval(null) }
}
}, [session?.isRunning])
```
### localStorage Persistence
Key: `"leistungstest_timer_session"`
Saved on every session change. Restored session shows resume/discard modal on next load.
---
## Session Initialization (Start Training)
When "⏱ Training starten" is clicked:
```typescript
const timerWrestlers: WrestlerSession[] = wrestlers
.filter(w => selectedWrestlerIds.includes(w.id))
.map(w => ({
wrestler: w,
currentExerciseIndex: 0,
exercises: template.exercises.map(e => ({
exerciseId: e.exercise,
exerciseName: e.exercise_name || String(e.exercise),
targetReps: e.target_reps, // e.g. "3×10" from template
elapsedSeconds: 0,
status: "active" as const, // all start active (first exercise)
})),
}))
setSession({
templateId: template.id,
templateName: template.name,
wrestlers: timerWrestlers,
globalElapsedSeconds: 0,
isRunning: true,
})
setInputMode("timer")
```
All wrestlers start with their first exercise in "active" state simultaneously.
---
## Marking Exercise Done
```typescript
const markDone = async (wrestlerIdx: number, exerciseIdx: number) => {
const wrestler = session.wrestlers[wrestlerIdx]
const exercise = wrestler.exercises[exerciseIdx]
const elapsed = exercise.elapsedSeconds
// Save to backend
await apiFetch("/leistungstest/results/", {
method: "POST",
token,
body: JSON.stringify({
template: session.templateId,
wrestler: wrestler.wrestler.id,
total_time_seconds: elapsed,
rating: 3,
notes: "",
items: [{
exercise: exercise.exerciseId,
target_reps: parseReps(exercise.targetReps),
actual_reps: parseReps(exercise.targetReps),
order: exerciseIdx,
}],
}),
})
// Update state: mark done, auto-start next
const updated = [...session.wrestlers]
updated[wrestlerIdx] = {
...wrestler,
exercises: wrestler.exercises.map((e, i) =>
i === exerciseIdx ? { ...e, status: "done" as const, elapsedSeconds: elapsed } : e
),
}
// Auto-start next exercise if exists
const nextIdx = exerciseIdx + 1
if (nextIdx < wrestler.exercises.length) {
updated[wrestlerIdx].exercises[nextIdx].status = "active"
updated[wrestlerIdx].currentExerciseIndex = nextIdx
}
setSession({ ...session, wrestlers: updated })
}
```
---
## Backend Compatibility
### Template stores target_reps as string (e.g. "3×10")
- `parseReps("3×10")` → extracts number for API (e.g. `30` or just stores as-is)
- Backend serializer accepts `target_reps` as string or int — needs verification
### LeistungstestResult stores `total_time_seconds` (int)
- Each "Erledigt" click saves the cumulative elapsed time for that wrestler
- Individual exercise times stored in `items` array (one item per exercise)
### If backend needs `items` on every save or only at end
- Currently: save on every "Erledigt" click with just that exercise
- Alternative: save incrementally, last item completes the result
- **Decision needed:** Does the backend create one result per exercise click, or update an existing result?
---
## Edge Cases
1. **Page reload during training** → localStorage restore, resume/discard prompt
2. **Trainer closes browser** → session persisted, can resume
3. **All exercises done before "Training beenden"** → auto-trigger summary
4. **Only some wrestlers finish** → partial results saved, can resume later
5. **Pause** → global timer stops, all active exercises pause (no per-exercise timer, just global)
6. **Empty template** → disable "Training starten" if template has no exercises
---
## Out of Scope (for this implementation)
- Real-time sync across multiple trainers (future)
- Per-exercise individual timers (only global timer)
- Audio alerts
- Export/print
- Changing exercise order mid-training