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
714 lines
23 KiB
Markdown
714 lines
23 KiB
Markdown
# 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.
|