- 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
23 KiB
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):
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:
// Timer mode state
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
const [timerSession, setTimerSession] = useState<TimerSession | null>(null)
const [timerInterval, setTimerInterval] = useState<NodeJS.Timeout | null>(null)
const [restoreModalOpen, setRestoreModalOpen] = useState(false)
const [restoredSession, setRestoredSession] = useState<RestoredSession | null>(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:
const TIMER_SESSION_KEY = "leistungstest_timer_session"
Also add a helper function after the state declarations (around line 76):
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:
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):
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<ILeistungstestResult>("/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<ILeistungstestResult>("/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 (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Left: Wrestler list */}
<Card>
<CardHeader>
<CardTitle className="text-base">Ringer ({completedCount}/{session.wrestlers.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.wrestlers.map((w, i) => (
<div
key={w.wrestler.id}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
i === session.currentWrestlerIndex ? "bg-primary/10 border border-primary/30" : "bg-muted/50"
}`}
>
<div className="text-xl">
{w.status === "done" ? "✅" : w.status === "active" ? "●" : "○"}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{w.wrestler.first_name} {w.wrestler.last_name}
</div>
<div className="text-xs text-muted-foreground">
{w.exercises.filter(e => e.status === "done").length}/{w.exercises.length} Übungen
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Right: Timer + exercises */}
<Card className="lg:col-span-2">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{currentWrestler.wrestler.first_name} {currentWrestler.wrestler.last_name}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">{session.templateName}</p>
</div>
<Button
variant={session.isRunning ? "destructive" : "default"}
size="sm"
onClick={togglePause}
>
{session.isRunning ? "Pausieren" : "Fortsetzen"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Timer display */}
<div className="flex items-center justify-center py-4">
<div className={`text-6xl font-mono font-bold ${session.isRunning ? "" : "text-orange-500"}`}>
{formatTime(session.totalElapsedSeconds)}
</div>
</div>
{/* Exercise list */}
<div className="space-y-3">
<div className="text-sm font-medium text-muted-foreground">
ÜBUNGEN ({currentWrestler.exercises.filter(e => e.status === "done").length}/{currentWrestler.exercises.length})
</div>
{currentWrestler.exercises.map((exercise, i) => (
<div
key={exercise.exerciseId}
className={`border rounded-lg p-4 transition-colors ${
exercise.status === "done" ? "bg-green-50 border-green-200" :
exercise.status === "active" ? "bg-blue-50 border-blue-200" : ""
}`}
>
<div className="flex items-center justify-between mb-3">
<div>
<div className="font-medium text-sm">{exercise.exerciseName}</div>
<div className="text-xs text-muted-foreground">Soll: {exercise.targetReps}</div>
</div>
<Badge variant={exercise.status === "done" ? "default" : "secondary"}>
{exercise.status === "done" ? "ERLEDIGT" : exercise.status === "active" ? "LÄUFT" : "AUSSTEHEND"}
</Badge>
</div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
type="number"
placeholder="Ist-Reps"
value={exercise.actualReps}
onChange={(e) => updateActualReps(i, e.target.value)}
disabled={exercise.status === "done"}
className="text-center"
/>
</div>
{exercise.status === "pending" && (
<Button size="sm" onClick={() => startExercise(i)}>
▶ START
</Button>
)}
{exercise.status === "active" && (
<Button size="sm" variant="default" onClick={() => doneExercise(i)}>
✅ ERLEDIGT
</Button>
)}
</div>
</div>
))}
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
<Button
className="flex-1"
onClick={goToNextWrestler}
disabled={!allExercisesDone && currentWrestler.exercises.some(e => e.status === "active")}
>
WEITER ZUM NÄCHSTEN RINGER →
</Button>
<Button variant="outline" onClick={endTraining}>
TRAINING BEENDEN
</Button>
</div>
</CardContent>
</Card>
{/* End training confirmation modal */}
<Modal
open={endTrainingModalOpen}
onOpenChange={setEndTrainingModalOpen}
title="Training beenden?"
description="Bist du sicher, dass du das Training beenden möchtest?"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setEndTrainingModalOpen(false)}>
Abbrechen
</Button>
<Button variant="destructive" onClick={confirmEndTraining}>
Training beenden
</Button>
</>
}
>
<div />
</Modal>
</div>
)
}
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:
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
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:
<CardHeader>
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
</CardHeader>
Replace with:
<CardHeader>
<div className="flex items-center justify-between mb-2">
<CardTitle className="text-base">
{inputMode === "form" ? "Neues Ergebnis" : "Training starten"}
</CardTitle>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
<button
onClick={() => setInputMode("form")}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
inputMode === "form" ? "bg-background shadow-sm" : "text-muted-foreground"
}`}
>
Formular
</button>
<button
onClick={() => setInputMode("timer")}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
inputMode === "timer" ? "bg-background shadow-sm" : "text-muted-foreground"
}`}
>
⏱ Timer
</button>
</div>
</div>
</CardHeader>
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: <form onSubmit={handleCreateResult} className="space-y-4">
Wrap the entire form content (lines 722-877) with:
{inputMode === "timer" && timerSession ? (
<TimerMode session={timerSession} onUpdate={setTimerSession} />
) : (
<form onSubmit={handleCreateResult} className="space-y-4">
{/* ... existing form content (lines 722-877) ... */}
</form>
)}
Then add the closing )} after the form's closing </motion.button> (line 876) before </CardContent> (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:
</div>
</div>
Insert before the {resultItems.length > 0 && ( section:
{inputMode === "form" && 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
const timerWrestlers: TimerWrestler[] = wrestlers
.filter(w => resultForm.wrestlers.includes(w.id))
.map(w => ({
wrestler: w,
status: "pending" as const,
startedAt: null,
exercises: template.exercises.map(e => ({
exerciseId: e.exercise,
exerciseName: e.exercise_name || String(e.exercise),
targetReps: e.target_reps,
actualReps: "",
status: "pending" as const,
startedAt: null,
})),
resultId: null,
}))
setTimerSession({
templateId: template.id,
templateName: template.name,
wrestlers: timerWrestlers,
currentWrestlerIndex: 0,
totalElapsedSeconds: 0,
isRunning: false,
})
setInputMode("timer")
}}
className="w-full"
variant="default"
>
⏱ Training starten
</Button>
</div>
)}
Task 8: Add session restore modal
Location: Before the closing </div> of the main component (around line 1456).
Goal: Prompt user to restore an interrupted session.
- Step 1: Add restore session modal
Before line 1456 (</div>), add:
<Modal
open={restoreModalOpen}
onOpenChange={setRestoreModalOpen}
title="Offenes Training gefunden"
description="Möchtest du das Training fortsetzen oder verwerfen?"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => {
localStorage.removeItem(TIMER_SESSION_KEY)
setRestoreModalOpen(false)
setRestoredSession(null)
}}>
Verwerfen
</Button>
<Button onClick={() => {
if (restoredSession) {
setTimerSession(restoredSession.session)
setInputMode("timer")
}
setRestoreModalOpen(false)
}}>
Fortsetzen
</Button>
</>
}
>
<div />
</Modal>
{/* Training summary modal */}
<Modal
open={!!trainingSummary}
onOpenChange={() => {}}
title="Training abgeschlossen!"
description={
trainingSummary
? `${trainingSummary.completed} von ${trainingSummary.total} Ringern absolviert in ${formatTime(trainingSummary.totalTime)}`
: ""
}
size="sm"
footer={
<Button onClick={() => {
localStorage.removeItem(TIMER_SESSION_KEY)
setTimerSession(null)
setTrainingSummary(null)
setInputMode("form")
}}>
Schließen
</Button>
}
>
<div />
</Modal>
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
TimerModecomponent referencesfetchResultswhich is defined insideLeistungstestPage. Since it's a nested const, it has access via closure — no changes needed. - The
timerSessionis saved to localStorage whenever it changes (Task 5 effect), butisRunningis 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,apiFetchare all already imported and used in the file.