Files
WrestleDesk/docs/superpowers/plans/2026-03-24-leistungstest-live-timer-v2-implementation.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

542 lines
18 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 v2 — 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:** Replace the previous timer implementation with a new table-style layout where all wrestlers train in parallel, each with their own exercise cards. One shared global timer. Trainer clicks "Erledigt" per wrestler per exercise. All exercises auto-start after the previous one completes. One result per wrestler saved incrementally to backend.
**Architecture:** Single-file frontend implementation (`leistungstest/page.tsx`) + backend model change for `elapsed_seconds`. Backend uses one Result per wrestler, saved incrementally as exercises complete. `actual_reps = target_reps` (full reps completed), `elapsed_seconds` per exercise in ResultItem.
---
## Files to Modify
### Frontend
- `frontend/src/app/(dashboard)/leistungstest/page.tsx` — replace existing timer implementation
### Backend
- `backend/leistungstest/models.py` — add `elapsed_seconds` field to `LeistungstestResultItem`
- `backend/leistungstest/serializers.py` — add `elapsed_seconds` to `LeistungstestResultItemSerializer`
- New migration `0003_leistungstestresultitem_elapsed_seconds.py`
---
## Task Breakdown
### Task 1: Backend — Add `elapsed_seconds` field to ResultItem
**Goal:** Add `elapsed_seconds` field to store time spent on each exercise.
- [ ] **Step 1: Update model**
In `backend/leistungstest/models.py`, find `LeistungstestResultItem` class (line 67), add field after `order`:
```python
elapsed_seconds = models.PositiveIntegerField(default=0)
```
- [ ] **Step 2: Update serializer**
In `backend/leistungstest/serializers.py`, update `LeistungstestResultItemSerializer.Meta.fields` (line 27):
```python
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
```
- [ ] **Step 3: Create migration**
```bash
cd backend && python manage.py makemigrations leistungstest --name leistungstestresultitem_elapsed_seconds
```
- [ ] **Step 4: Apply migration**
```bash
cd backend && python manage.py migrate leistungstest
```
---
### Task 2: Frontend — Replace existing timer implementation
**Goal:** Remove the old per-wrestler, per-exercise timer (TimerMode component, `inputMode` state, old interfaces, etc.) and replace with the new table-style parallel implementation.
**Steps:**
#### Step 1: Remove old interfaces and state
In `page.tsx`, remove from lines 22-75:
- Remove: `TimerExercise`, `TimerWrestler`, `TimerSession`, `RestoredSession` interfaces
- Remove: `TIMER_SESSION_KEY` constant
- Remove state: `inputMode`, `timerSession`, `timerInterval`, `restoreModalOpen`, `restoredSession`, `endTrainingModalOpen`, `trainingSummary`
- Remove: `formatTime` helper function
#### Step 2: Add new interfaces
Add after the imports (before line 22):
```typescript
interface LiveExercise {
exerciseId: number
exerciseName: string
targetReps: string
elapsedSeconds: number
status: "pending" | "active" | "done"
}
interface LiveWrestler {
wrestler: IWrestler
exercises: LiveExercise[]
resultId: number | null
}
interface LiveSession {
templateId: number
templateName: string
wrestlers: LiveWrestler[]
globalElapsedSeconds: number
isRunning: boolean
}
```
#### Step 3: Add new state declarations
After the existing state declarations (around line 74), replace the old timer state with:
```typescript
const [liveMode, setLiveMode] = useState(false)
const [liveSession, setLiveSession] = useState<LiveSession | null>(null)
const [liveTimerInterval, setLiveTimerInterval] = useState<NodeJS.Timeout | null>(null)
const [liveRestoreOpen, setLiveRestoreOpen] = useState(false)
const [liveEndOpen, setLiveEndOpen] = useState(false)
const [liveSummary, setLiveSummary] = useState<{ completed: number; total: number; totalTime: number } | null>(null)
```
#### Step 4: Add timer tick effect
After the `useEffect` on line 557 (fetchPreferences), add:
```typescript
useEffect(() => {
if (liveSession?.isRunning) {
const interval = setInterval(() => {
setLiveSession(prev => {
if (!prev) return prev
return { ...prev, globalElapsedSeconds: prev.globalElapsedSeconds + 1 }
})
}, 1000)
setLiveTimerInterval(interval)
return () => { clearInterval(interval); setLiveTimerInterval(null) }
} else {
if (liveTimerInterval) { clearInterval(liveTimerInterval); setLiveTimerInterval(null) }
}
}, [liveSession?.isRunning])
```
#### Step 5: Add localStorage restore effect
After the timer tick effect:
```typescript
const LIVE_SESSION_KEY = "leistungstest_live_session"
useEffect(() => {
if (liveSession && liveMode) {
localStorage.setItem(LIVE_SESSION_KEY, JSON.stringify({ session: liveSession, savedAt: Date.now() }))
}
}, [liveSession, liveMode])
useEffect(() => {
if (liveMode) {
const saved = localStorage.getItem(LIVE_SESSION_KEY)
if (saved) {
try {
const parsed = JSON.parse(saved)
if (parsed.session.isRunning === false) {
setLiveRestoreOpen(true)
}
} catch {
localStorage.removeItem(LIVE_SESSION_KEY)
}
}
}
}, [liveMode])
```
#### Step 6: Add LiveTraining component
Find the `const tabs = [` line. Add the `LiveTraining` component BEFORE `const tabs`.
```typescript
const LiveTraining = ({ session, onUpdate }: { session: LiveSession; onUpdate: (s: LiveSession) => void }) => {
const completedCount = session.wrestlers.filter(w => w.exercises.every(e => e.status === "done")).length
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
}
const markDone = async (wrestlerIdx: number, exerciseIdx: number) => {
const wrestler = session.wrestlers[wrestlerIdx]
const exercise = wrestler.exercises[exerciseIdx]
const elapsed = exercise.elapsedSeconds
const updated = session.wrestlers.map((w, wi) => {
if (wi !== wrestlerIdx) return w
return {
...w,
exercises: w.exercises.map((e, ei) =>
ei === exerciseIdx ? { ...e, status: "done" as const, elapsedSeconds: elapsed } : e
),
}
})
const nextIdx = exerciseIdx + 1
if (nextIdx < wrestler.exercises.length) {
updated[wrestlerIdx] = {
...updated[wrestlerIdx],
exercises: updated[wrestlerIdx].exercises.map((e, ei) =>
ei === nextIdx ? { ...e, status: "active" as const } : e
),
}
}
const allDone = updated[wrestlerIdx].exercises.every(e => e.status === "done")
if (allDone && token) {
try {
const itemsPayload = updated[wrestlerIdx].exercises.map((e, i) => ({
exercise: e.exerciseId,
target_reps: parseInt(e.targetReps) || 0,
actual_reps: parseInt(e.targetReps) || 0,
elapsed_seconds: e.elapsedSeconds,
order: i,
}))
const result = await apiFetch<ILeistungstestResult>("/leistungstest/results/", {
method: "POST",
token,
body: JSON.stringify({
template: session.templateId,
wrestler: wrestler.wrestler.id,
total_time_seconds: elapsed,
rating: 3,
notes: "",
items: itemsPayload,
}),
})
updated[wrestlerIdx] = { ...updated[wrestlerIdx], resultId: result.id }
} catch {
toast.error("Fehler beim Speichern")
}
}
onUpdate({ ...session, wrestlers: updated })
}
const endTraining = () => setLiveEndOpen(true)
const confirmEnd = async () => {
localStorage.removeItem(LIVE_SESSION_KEY)
setLiveSession(null)
setLiveMode(false)
setLiveEndOpen(false)
setLiveSummary(null)
fetchResults()
toast.success("Training beendet")
}
return (
<div className="space-y-4">
<div className="bg-[#1B1A55] rounded-xl px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="text-xs font-semibold text-[#9290C3] uppercase tracking-wider">Gemeinsame Zeit</span>
<span className="text-4xl font-bold text-white font-mono tracking-widest">
{formatTime(session.globalElapsedSeconds)}
</span>
</div>
<div className="flex gap-2">
<Button
variant={session.isRunning ? "secondary" : "default"}
size="sm"
onClick={() => onUpdate({ ...session, isRunning: !session.isRunning })}
>
{session.isRunning ? "⏸ Pausieren" : "▶ Fortsetzen"}
</Button>
<Button variant="destructive" size="sm" onClick={endTraining}>
Training beenden
</Button>
</div>
</div>
<div className="flex flex-col gap-3">
{session.wrestlers.map((wrestler, wrestlerIdx) => {
const doneCount = wrestler.exercises.filter(e => e.status === "done").length
const allDone = wrestler.exercises.every(e => e.status === "done")
const activeExercise = wrestler.exercises.find(e => e.status === "active")
return (
<div
key={wrestler.wrestler.id}
className={`rounded-xl border-2 overflow-hidden ${
allDone ? "border-green-500 bg-green-50" :
activeExercise ? "border-yellow-400 bg-white" :
"border-gray-200 bg-white"
}`}
>
<div className={`flex items-center px-4 py-3 border-b gap-3 ${
allDone ? "bg-green-100 border-green-200" :
activeExercise ? "bg-yellow-50 border-yellow-200" :
"bg-gray-50 border-gray-200"
}`}>
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${
allDone ? "bg-green-500" : activeExercise ? "bg-yellow-400" : "bg-gray-400"
}`} />
<div className="flex-1">
<div className={`font-bold text-base ${allDone ? "text-green-700" : "text-gray-900"}`}>
{wrestler.wrestler.first_name} {wrestler.wrestler.last_name}
</div>
<div className="text-xs mt-0.5" style={{ color: allDone ? "#166534" : activeExercise ? "#a16207" : "#64748b" }}>
{allDone ? `✓ Alle ${wrestler.exercises.length} Übungen` :
activeExercise ? `Übung läuft: ${activeExercise.exerciseName}` :
"Wartet..."}
</div>
</div>
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
allDone ? "bg-green-200 text-green-800" :
"bg-gray-100 text-gray-600"
}`}>
{doneCount}/{wrestler.exercises.length}
</span>
</div>
<div className="px-4 py-3 flex gap-3 flex-wrap">
{wrestler.exercises.map((exercise, exerciseIdx) => {
const isActive = exercise.status === "active"
const isDone = exercise.status === "done"
return (
<div
key={exercise.exerciseId}
className={`flex-1 min-w-[140px] rounded-lg p-3 border ${
isDone ? "bg-green-100 border-green-300" :
isActive ? "bg-yellow-50 border-yellow-400" :
"bg-gray-50 border-gray-200"
}`}
>
<div className={`text-xs font-semibold mb-1 ${
isDone ? "text-green-700" : isActive ? "text-yellow-700" : "text-gray-500"
}`}>
{isDone ? "✓" : isActive ? "▶" : ""} {exercise.exerciseName}
</div>
<div className="text-xs text-gray-500 mb-2">Soll: {exercise.targetReps}</div>
<div className="flex items-center justify-between">
<span className={`text-lg font-bold font-mono ${
isDone ? "text-green-700" : isActive ? "text-yellow-700" : "text-gray-400"
}`}>
{isDone ? `${formatTime(exercise.elapsedSeconds)}` :
isActive ? formatTime(exercise.elapsedSeconds) : "—"}
</span>
{isActive && (
<Button size="sm" className="bg-green-600 hover:bg-green-700" onClick={() => markDone(wrestlerIdx, exerciseIdx)}>
Erledigt
</Button>
)}
{!isDone && !isActive && (
<span className="text-xs text-gray-400">Ausstehend</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
<Modal
open={liveEndOpen}
onOpenChange={setLiveEndOpen}
title="Training beenden?"
description="Bist du sicher? Laufende Übungen werden nicht gespeichert."
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setLiveEndOpen(false)}>Abbrechen</Button>
<Button variant="destructive" onClick={confirmEnd}>Training beenden</Button>
</>
}
>
<div />
</Modal>
</div>
)
}
```
#### Step 7: Add session restore modal
Find the closing `</div>` of the main component (before `</div>` at the end of the file), add before it:
```typescript
<Modal
open={liveRestoreOpen}
onOpenChange={setLiveRestoreOpen}
title="Offenes Training gefunden"
description="Möchtest du das Training fortsetzen oder verwerfen?"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => {
localStorage.removeItem(LIVE_SESSION_KEY)
setLiveRestoreOpen(false)
}}>Verwerfen</Button>
<Button onClick={() => {
const saved = localStorage.getItem(LIVE_SESSION_KEY)
if (saved) setLiveSession(JSON.parse(saved).session)
setLiveRestoreOpen(false)
}}>Fortsetzen</Button>
</>
}
>
<div />
</Modal>
```
#### Step 8: Modify Zuweisen tab CardHeader — add Live Mode toggle
Find the Zuweisen tab CardHeader section (around line 786-788):
Replace:
```tsx
<CardHeader>
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
</CardHeader>
```
With:
```tsx
<CardHeader>
<div className="flex items-center justify-between mb-2">
<CardTitle className="text-base">
{liveMode ? "Training starten" : "Neues Ergebnis"}
</CardTitle>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
<button
onClick={() => setLiveMode(false)}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
!liveMode ? "bg-background shadow-sm" : "text-muted-foreground"
}`}
>
Formular
</button>
<button
onClick={() => setLiveMode(true)}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
liveMode ? "bg-background shadow-sm" : "text-muted-foreground"
}`}
>
Live
</button>
</div>
</div>
</CardHeader>
```
#### Step 9: Conditionally render form vs live mode
Find the `<form onSubmit={handleCreateResult}>` inside the Zuweisen tab (around line 790).
Wrap the entire form with a conditional:
```tsx
{liveMode && liveSession ? (
<LiveTraining session={liveSession} onUpdate={setLiveSession} />
) : (
<form onSubmit={handleCreateResult} className="space-y-4">
...
</form>
)}
```
The closing `)}` goes after the form's closing `</CardContent>` tag.
#### Step 10: Add "Training starten" button in live mode of the form
Find the wrestler selection section in the form. After the wrestler badge list (around line 856), before the `{resultItems.length > 0 && (` block, add:
```tsx
{liveMode && resultForm.template && resultForm.wrestlers.length > 0 && (
<div className="pt-2 border-t">
<Button
type="button"
onClick={() => {
const template = templates.find(t => t.id === parseInt(resultForm.template))
if (!template) return
setLiveSession({
templateId: template.id,
templateName: template.name,
wrestlers: wrestlers
.filter(w => resultForm.wrestlers.includes(w.id))
.map(w => ({
wrestler: w,
exercises: template.exercises.map(e => ({
exerciseId: e.exercise,
exerciseName: e.exercise_name || String(e.exercise),
targetReps: e.target_reps,
elapsedSeconds: 0,
status: "active" as const,
})),
resultId: null,
})),
globalElapsedSeconds: 0,
isRunning: true,
})
}}
className="w-full"
>
Training starten
</Button>
</div>
)}
```
Also add to the imports at the top of the file if not already present:
```typescript
import { toast } from "sonner"
```
---
### Task 3: Verify build
- [ ] **Step 1: TypeScript check**
`cd frontend && npm run typecheck 2>&1 | tail -20`
- [ ] **Step 2: Build**
`cd frontend && npm run build 2>&1 | tail -30`
- [ ] **Step 3: Backend check**
`cd backend && python manage.py check`
---
## Key Behavior Summary
| Action | Result |
|--------|--------|
| Click "⏱ Live" toggle | Switches to live mode UI |
| Select template + wrestlers | Form shows "⏱ Training starten" button |
| Click "Training starten" | All wrestlers start Exercise 1 simultaneously, timer begins |
| Click "✓ Erledigt" (wrestler X) | Exercise time saved, next exercise auto-starts for that wrestler |
| All exercises done for wrestler | Result saved to backend, row turns green |
| Click "Training beenden" | Confirmation, session cleared, back to form |
## Notes
- `targetReps` is stored as string like "3×10" from the template — displayed directly, no parsing needed for display
- When saving: `target_reps` sent as integer (parseInt), `actual_reps` = same integer (full reps completed)
- `elapsed_seconds` per exercise stored in ResultItem
- The `LiveTraining` component uses the global `token`, `fetchResults`, and `templates` from the parent scope
- No new backend endpoints — uses existing `POST /leistungstest/results/`