Files
WrestleDesk/docs/superpowers/specs/2026-03-24-leistungstest-live-timer-v2-design.md
Andrej Spielmann 3fefc550fe 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
2026-03-26 13:24:57 +01:00

294 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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