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:
@@ -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
|
||||
Reference in New Issue
Block a user