3fefc550fe
- 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
542 lines
18 KiB
Markdown
542 lines
18 KiB
Markdown
# 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/`
|