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
This commit is contained in:
Andrej Spielmann
2026-03-26 13:24:57 +01:00
commit 3fefc550fe
256 changed files with 38295 additions and 0 deletions
@@ -0,0 +1,713 @@
# 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<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:
```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<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:
```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
<CardHeader>
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
</CardHeader>
```
Replace with:
```tsx
<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:
```tsx
{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:
```tsx
</div>
</div>
```
Insert before the `{resultItems.length > 0 && (` section:
```tsx
{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:
```typescript
<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 `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.