- 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
18 KiB
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— addelapsed_secondsfield toLeistungstestResultItembackend/leistungstest/serializers.py— addelapsed_secondstoLeistungstestResultItemSerializer- 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:
elapsed_seconds = models.PositiveIntegerField(default=0)
- Step 2: Update serializer
In backend/leistungstest/serializers.py, update LeistungstestResultItemSerializer.Meta.fields (line 27):
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
- Step 3: Create migration
cd backend && python manage.py makemigrations leistungstest --name leistungstestresultitem_elapsed_seconds
- Step 4: Apply migration
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,RestoredSessioninterfaces - Remove:
TIMER_SESSION_KEYconstant - Remove state:
inputMode,timerSession,timerInterval,restoreModalOpen,restoredSession,endTrainingModalOpen,trainingSummary - Remove:
formatTimehelper function
Step 2: Add new interfaces
Add after the imports (before line 22):
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:
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:
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:
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.
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:
<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:
<CardHeader>
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
</CardHeader>
With:
<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:
{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:
{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:
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
targetRepsis stored as string like "3×10" from the template — displayed directly, no parsing needed for display- When saving:
target_repssent as integer (parseInt),actual_reps= same integer (full reps completed) elapsed_secondsper exercise stored in ResultItem- The
LiveTrainingcomponent uses the globaltoken,fetchResults, andtemplatesfrom the parent scope - No new backend endpoints — uses existing
POST /leistungstest/results/