# 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(null) const [liveTimerInterval, setLiveTimerInterval] = useState(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("/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 (
Gemeinsame Zeit {formatTime(session.globalElapsedSeconds)}
{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 (
{wrestler.wrestler.first_name} {wrestler.wrestler.last_name}
{allDone ? `✓ Alle ${wrestler.exercises.length} Übungen` : activeExercise ? `Übung läuft: ${activeExercise.exerciseName}` : "Wartet..."}
{doneCount}/{wrestler.exercises.length}
{wrestler.exercises.map((exercise, exerciseIdx) => { const isActive = exercise.status === "active" const isDone = exercise.status === "done" return (
{isDone ? "✓" : isActive ? "▶" : ""} {exercise.exerciseName}
Soll: {exercise.targetReps}
{isDone ? `✓ ${formatTime(exercise.elapsedSeconds)}` : isActive ? formatTime(exercise.elapsedSeconds) : "—"} {isActive && ( )} {!isDone && !isActive && ( Ausstehend )}
) })}
) })}
} >
) } ``` #### Step 7: Add session restore modal Find the closing `
` of the main component (before `
` at the end of the file), add before it: ```typescript } >
``` #### Step 8: Modify Zuweisen tab CardHeader — add Live Mode toggle Find the Zuweisen tab CardHeader section (around line 786-788): Replace: ```tsx Neues Ergebnis ``` With: ```tsx
{liveMode ? "Training starten" : "Neues Ergebnis"}
``` #### Step 9: Conditionally render form vs live mode Find the `
` inside the Zuweisen tab (around line 790). Wrap the entire form with a conditional: ```tsx {liveMode && liveSession ? ( ) : ( ... )} ``` The closing `)}` goes after the form's closing `` 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 && (
)} ``` 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/`