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,541 @@
# 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<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:
```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<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:
```typescript
<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:
```tsx
<CardHeader>
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
</CardHeader>
```
With:
```tsx
<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:
```tsx
{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:
```tsx
{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:
```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/`