# Leistungstest Live-Timer — 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:** Add a live-timer mode to the Leistungstest "Zuweisen" tab where trainers run through a timed workout session with per-wrestler exercise tracking and automatic result saving. **Architecture:** Single-file implementation within the existing 1458-line `leistungstest/page.tsx`. Add `inputMode` state to toggle between existing form mode and new timer mode. Timer mode uses split-panel layout (wrestler list left, timer + exercises right). Timer runs continuously across wrestlers via `setInterval`. Per-wrestler results saved immediately via existing `POST /leistungstest/results/`. Session persisted to `localStorage` under key `"leistungstest_timer_session"`. **Tech Stack:** React hooks (useState, useEffect, useCallback, useMemo), existing UI components (Card, Button, Input, Badge, Modal, Sheet), existing icons (no new deps), framer-motion for animations, Sonner for toasts. --- ## File to Modify - `frontend/src/app/(dashboard)/leistungstest/page.tsx` — add all timer-related code to this file --- ## Task Breakdown ### Task 1: Add TypeScript interfaces + state declarations **Location:** After line 74 (after `editTemplateExercises` state), before the `useEffect` on line 76. **Goal:** Add timer-specific interfaces and state variables. - [ ] **Step 1: Add interfaces** Add these types after the imports (before line 22): ```typescript interface TimerExercise { exerciseId: number exerciseName: string targetReps: number actualReps: string status: "pending" | "done" startedAt: number | null } interface TimerWrestler { wrestler: IWrestler status: "pending" | "active" | "done" startedAt: number | null exercises: TimerExercise[] resultId: number | null } interface TimerSession { templateId: number templateName: string wrestlers: TimerWrestler[] currentWrestlerIndex: number totalElapsedSeconds: number isRunning: boolean } interface RestoredSession { session: TimerSession savedAt: number } ``` - [ ] **Step 2: Add state declarations** After line 74 (`editTemplateExercises` state), add: ```typescript // Timer mode state const [inputMode, setInputMode] = useState<"form" | "timer">("form") const [timerSession, setTimerSession] = useState(null) const [timerInterval, setTimerInterval] = useState(null) const [restoreModalOpen, setRestoreModalOpen] = useState(false) const [restoredSession, setRestoredSession] = useState(null) const [endTrainingModalOpen, setEndTrainingModalOpen] = useState(false) const [trainingSummary, setTrainingSummary] = useState<{ completed: number total: number totalTime: number } | null>(null) ``` - [ ] **Step 3: Add localStorage constants and helpers** After the interfaces (before line 22 area), add: ```typescript const TIMER_SESSION_KEY = "leistungstest_timer_session" ``` Also add a helper function after the state declarations (around line 76): ```typescript 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")}` } ``` --- ### Task 2: Add localStorage restore check on mount **Location:** Modify the existing `useEffect` on line 76 (`fetchPreferences` call) to also check for saved timer session. **Goal:** On page load in timer mode, prompt user to restore interrupted session. - [ ] **Step 1: Add useEffect to check for saved session** After line 506 (after `fetchPreferences` useEffect), add new useEffect: ```typescript useEffect(() => { if (inputMode === "timer") { const saved = localStorage.getItem(TIMER_SESSION_KEY) if (saved) { try { const parsed = JSON.parse(saved) as RestoredSession if (parsed.session.isRunning === false) { setRestoredSession(parsed) setRestoreModalOpen(true) } } catch { localStorage.removeItem(TIMER_SESSION_KEY) } } } }, [inputMode]) ``` --- ### Task 3: Add TimerMode sub-component **Location:** Inside the `LeistungstestPage` function, after the existing helper functions (around line 472). **Goal:** Create the TimerMode component that renders the split-panel layout. - [ ] **Step 1: Add TimerMode component** Add this as a const inside the component (after line 472, before the `tabs` array): ```typescript const TimerMode = ({ session, onUpdate }: { session: TimerSession; onUpdate: (s: TimerSession) => void }) => { const currentWrestler = session.wrestlers[session.currentWrestlerIndex] const completedCount = session.wrestlers.filter(w => w.status === "done").length const allExercisesDone = currentWrestler?.exercises.every(e => e.status === "done") ?? false const togglePause = useCallback(() => { onUpdate({ ...session, isRunning: !session.isRunning }) }, [session, onUpdate]) const updateWrestler = (index: number, updated: TimerWrestler) => { const newWrestlers = [...session.wrestlers] newWrestlers[index] = updated onUpdate({ ...session, wrestlers: newWrestlers }) } const startExercise = (exerciseIdx: number) => { if (!currentWrestler) return const updated = { ...currentWrestler } updated.exercises = updated.exercises.map((e, i) => i === exerciseIdx ? { ...e, status: "active" as const, startedAt: Date.now() } : e ) updateWrestler(session.currentWrestlerIndex, updated) } const doneExercise = async (exerciseIdx: number) => { if (!currentWrestler || !token) return const updated = { ...currentWrestler } updated.exercises = updated.exercises.map((e, i) => i === exerciseIdx ? { ...e, status: "done" as const } : e ) updateWrestler(session.currentWrestlerIndex, updated) } const updateActualReps = (exerciseIdx: number, reps: string) => { if (!currentWrestler) return const updated = { ...currentWrestler } updated.exercises = updated.exercises.map((e, i) => i === exerciseIdx ? { ...e, actualReps: reps } : e ) updateWrestler(session.currentWrestlerIndex, updated) } const goToNextWrestler = async () => { if (!currentWrestler || !token) return // Mark current wrestler done const doneWrestler: TimerWrestler = { ...currentWrestler, status: "done", } const newWrestlers = [...session.wrestlers] newWrestlers[session.currentWrestlerIndex] = doneWrestler // Save result immediately try { const itemsPayload = doneWrestler.exercises .filter(e => e.actualReps) .map((e, i) => ({ exercise: e.exerciseId, target_reps: e.targetReps, actual_reps: parseInt(e.actualReps) || 0, order: i, })) const elapsedForWrestler = doneWrestler.startedAt ? Math.floor((Date.now() - doneWrestler.startedAt) / 1000) : session.totalElapsedSeconds const result = await apiFetch("/leistungstest/results/", { method: "POST", token, body: JSON.stringify({ template: session.templateId, wrestler: doneWrestler.wrestler.id, total_time_seconds: elapsedForWrestler, rating: 3, notes: "", items: itemsPayload, }), }) newWrestlers[session.currentWrestlerIndex] = { ...doneWrestler, resultId: result.id } } catch { toast.error("Fehler beim Speichern") } const nextIndex = session.currentWrestlerIndex + 1 if (nextIndex < session.wrestlers.length) { const nextWrestler = { ...newWrestlers[nextIndex], status: "active" as const, startedAt: Date.now() } newWrestlers[nextIndex] = nextWrestler onUpdate({ ...session, wrestlers: newWrestlers, currentWrestlerIndex: nextIndex }) } else { onUpdate({ ...session, wrestlers: newWrestlers, isRunning: false }) setTrainingSummary({ completed: newWrestlers.filter(w => w.status === "done").length, total: session.wrestlers.length, totalTime: session.totalElapsedSeconds, }) setEndTrainingModalOpen(true) } } const endTraining = () => { setEndTrainingModalOpen(true) } const confirmEndTraining = async () => { if (!currentWrestler || !token) return // Save current wrestler if started const startedExercises = currentWrestler.exercises.filter(e => e.startedAt !== null) if (startedExercises.length > 0) { try { const itemsPayload = currentWrestler.exercises .filter(e => e.actualReps) .map((e, i) => ({ exercise: e.exerciseId, target_reps: e.targetReps, actual_reps: parseInt(e.actualReps) || 0, order: i, })) await apiFetch("/leistungstest/results/", { method: "POST", token, body: JSON.stringify({ template: session.templateId, wrestler: currentWrestler.wrestler.id, total_time_seconds: session.totalElapsedSeconds, rating: 3, notes: "", items: itemsPayload, }), }) } catch { // best effort } } localStorage.removeItem(TIMER_SESSION_KEY) setTimerSession(null) setEndTrainingModalOpen(false) setTrainingSummary(null) setInputMode("form") fetchResults() toast.success("Training beendet") } if (!currentWrestler) return null return (
{/* Left: Wrestler list */} Ringer ({completedCount}/{session.wrestlers.length})
{session.wrestlers.map((w, i) => (
{w.status === "done" ? "✅" : w.status === "active" ? "●" : "○"}
{w.wrestler.first_name} {w.wrestler.last_name}
{w.exercises.filter(e => e.status === "done").length}/{w.exercises.length} Übungen
))}
{/* Right: Timer + exercises */}
{currentWrestler.wrestler.first_name} {currentWrestler.wrestler.last_name}

{session.templateName}

{/* Timer display */}
{formatTime(session.totalElapsedSeconds)}
{/* Exercise list */}
ÜBUNGEN ({currentWrestler.exercises.filter(e => e.status === "done").length}/{currentWrestler.exercises.length})
{currentWrestler.exercises.map((exercise, i) => (
{exercise.exerciseName}
Soll: {exercise.targetReps}
{exercise.status === "done" ? "ERLEDIGT" : exercise.status === "active" ? "LÄUFT" : "AUSSTEHEND"}
updateActualReps(i, e.target.value)} disabled={exercise.status === "done"} className="text-center" />
{exercise.status === "pending" && ( )} {exercise.status === "active" && ( )}
))}
{/* Actions */}
{/* End training confirmation modal */} } >
) } ``` --- ### Task 4: Add timer tick effect **Location:** After the existing `useEffect` blocks (after line 506). **Goal:** Start/stop the timer interval based on `timerSession.isRunning`. - [ ] **Step 1: Add timer interval useEffect** Add after line 506: ```typescript useEffect(() => { if (timerSession?.isRunning) { const interval = setInterval(() => { setTimerSession(prev => { if (!prev) return prev return { ...prev, totalElapsedSeconds: prev.totalElapsedSeconds + 1 } }) }, 1000) setTimerInterval(interval) return () => { clearInterval(interval) setTimerInterval(null) } } else { if (timerInterval) { clearInterval(timerInterval) setTimerInterval(null) } } }, [timerSession?.isRunning]) ``` --- ### Task 5: Add localStorage persistence effect **Location:** After the timer tick effect. **Goal:** Save timer session to localStorage on every change. - [ ] **Step 1: Add localStorage persistence useEffect** ```typescript useEffect(() => { if (timerSession && inputMode === "timer") { localStorage.setItem(TIMER_SESSION_KEY, JSON.stringify({ session: timerSession, savedAt: Date.now(), })) } }, [timerSession, inputMode]) ``` --- ### Task 6: Add mode toggle UI in Zuweisen tab **Location:** In the Zuweisen tab render (around line 709-721), after the CardHeader opening tag. **Goal:** Add a toggle switch between "Formular" and "Timer" mode. - [ ] **Step 1: Modify CardHeader in zuweisen tab** Find around line 718: ```tsx Neues Ergebnis ``` Replace with: ```tsx
{inputMode === "form" ? "Neues Ergebnis" : "Training starten"}
``` --- ### Task 7: Conditionally render form vs timer mode in Zuweisen tab **Location:** In the Zuweisen tab, replace the form render logic (around line 722) with conditional rendering. **Goal:** When `inputMode === "timer"` and `timerSession !== null`, render `TimerMode`. Otherwise render the existing form. - [ ] **Step 1: Wrap existing form in form mode check** Find line 722: `
` Wrap the entire form content (lines 722-877) with: ```tsx {inputMode === "timer" && timerSession ? ( ) : ( {/* ... existing form content (lines 722-877) ... */} )} ``` Then add the closing `)}` after the form's closing `` (line 876) before `` (line 878). - [ ] **Step 2: Add "Training starten" button below wrestler/template selection in form mode** Find the section around lines 789-790 (after the wrestler selection div closes), after: ```tsx
``` Insert before the `{resultItems.length > 0 && (` section: ```tsx {inputMode === "form" && resultForm.template && resultForm.wrestlers.length > 0 && (
)} ``` --- ### Task 8: Add session restore modal **Location:** Before the closing `` of the main component (around line 1456). **Goal:** Prompt user to restore an interrupted session. - [ ] **Step 1: Add restore session modal** Before line 1456 (``), add: ```typescript } >
{/* Training summary modal */} {}} title="Training abgeschlossen!" description={ trainingSummary ? `${trainingSummary.completed} von ${trainingSummary.total} Ringern absolviert in ${formatTime(trainingSummary.totalTime)}` : "" } size="sm" footer={ } >
``` --- ### Task 9: Build verification - [ ] **Step 1: Run typecheck** `cd frontend && npm run typecheck` - [ ] **Step 2: Run lint** `cd frontend && npm run lint` - [ ] **Step 3: Verify build** `cd frontend && npm run build` --- ## Notes - The `TimerMode` component references `fetchResults` which is defined inside `LeistungstestPage`. Since it's a nested const, it has access via closure — no changes needed. - The `timerSession` is saved to localStorage whenever it changes (Task 5 effect), but `isRunning` is also saved so we can detect interrupted sessions on page load. - When user clicks "Training beenden" mid-session, we do best-effort save of the current wrestler if they started any exercises. - No new backend endpoints needed — uses existing `POST /leistungstest/results/`. - Types `IWrestler`, `ILeistungstestResult`, `ILeistungstestTemplate`, `apiFetch` are all already imported and used in the file.