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,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.
|
||||
Reference in New Issue
Block a user