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:
@@ -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/`
|
||||
Reference in New Issue
Block a user