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,140 @@
# Homework System Integration - Implementation Plan
> **For agentic workers:** Use subagent-driven-development or execute tasks manually.
**Goal:** Integrate all backend homework features into the frontend
**Architecture:** Update types, enhance homework page with exercise selection and assignments tracking
---
## File Structure
```
frontend/
├── src/lib/api.ts # Add missing types
├── src/app/(dashboard)/homework/page.tsx # Enhanced with exercises
└── src/app/(dashboard)/homework/assignments/page.tsx # NEW
```
---
## Task 1: Update Types in api.ts
**Files:**
- Modify: `frontend/src/lib/api.ts`
Add these types:
```typescript
interface IHomeworkExerciseItem {
id: number
exercise: number
exercise_name: string
reps: number | null
time_minutes: number | null
order: number
}
interface IHomework {
id: number
title: string
description: string
club: number
club_name: string
due_date: string
is_active: boolean
exercise_items: IHomeworkExerciseItem[]
exercise_count: number
created_at: string
updated_at: string
}
interface IHomeworkAssignment {
id: number
homework: number
homework_title: string
wrestler: number
wrestler_name: string
club: number
club_name: string
due_date: string
notes: string
is_completed: boolean
completion_date: string | null
completed_items: number
total_items: number
items: IHomeworkAssignmentItem[]
created_at: string
}
interface IHomeworkAssignmentItem {
id: number
exercise: number
exercise_name: string
is_completed: boolean
completion_date: string | null
}
```
---
## Task 2: Add Exercise Selection to Homework Form
**Files:**
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx`
Enhance the homework form to:
1. Fetch exercises from `/exercises/`
2. Display exercise selection list with reps/time inputs
3. Allow adding/removing exercises from homework
4. Save exercise items via `POST /homework/{id}/exercise-items/`
Changes:
- Add `exercises` state to store available exercises
- Add `selectedExercises` state for currently selected exercises
- Fetch exercises on modal open
- Display exercise chips with reps/time
- Add exercise via `POST /homework/{id}/exercise-items/`
---
## Task 3: Update Homework Display
**Files:**
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx`
Update the homework list to show:
- Exercise count badge
- Visual indicator if has exercises
- Better styling for cards
---
## Task 4: Create Assignments Page
**Files:**
- Create: `frontend/src/app/(dashboard)/homework/assignments/page.tsx`
Features:
- List all homework assignments via `GET /homework/assignments/`
- Show completion progress (e.g., "3/5 exercises")
- Filter by: homework, wrestler, status
- Click to expand and see exercises
- Mark exercises as complete via `POST /homework/assignments/{id}/complete-item/`
---
## Task 5: Build and Test
Run: `cd frontend && npm run build`
Fix any TypeScript errors.
---
## Task 6: Commit
```bash
git add -A
git commit -m "feat(homework): integrate exercises and assignments"
```
@@ -0,0 +1,412 @@
# Homework System Redesign - 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:** Redesign homework system to assign exercises directly from training page without templates.
**Architecture:**
- New models: `TrainingHomework`, `TrainingHomeworkExercise`, `TrainingHomeworkAssignment`
- Training detail page redesigned with 2-column layout: participants | training homework
- Homework button per participant opens modal to select exercises
- Homework page shows all assignments grouped by training
**Tech Stack:** Django + DRF backend, Next.js + React frontend
---
## File Structure
### Backend (Modified)
- `backend/homework/models.py` - Add new models
- `backend/homework/serializers.py` - Add new serializers
- `backend/homework/views.py` - Add new ViewSet
- `backend/homework/urls.py` - Add new routes
- `backend/homework/admin.py` - Register new models
### Frontend (Modified)
- `frontend/src/lib/api.ts` - Add new types
- `frontend/src/app/(dashboard)/trainings/[id]/page.tsx` - Complete redesign
- `frontend/src/app/(dashboard)/homework/page.tsx` - Complete rewrite
- `frontend/src/app/(dashboard)/dashboard/page.tsx` - Add homework stats
---
## Tasks
### Task 1: Backend - New Models
**Files:**
- Modify: `backend/homework/models.py`
- [ ] **Step 1: Add new models at end of file**
```python
class TrainingHomework(models.Model):
training = models.ForeignKey('trainings.Training', on_delete=models.CASCADE, related_name='homework_assignments')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['training']),
]
def __str__(self):
return f"Homework for Training {self.training_id}"
class TrainingHomeworkExercise(models.Model):
training_homework = models.ForeignKey(TrainingHomework, on_delete=models.CASCADE, related_name='exercises')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_homework_items')
reps = models.PositiveIntegerField(null=True, blank=True)
time_minutes = models.PositiveIntegerField(null=True, blank=True)
order = models.IntegerField(default=0)
class Meta:
ordering = ['training_homework', 'order']
unique_together = ['training_homework', 'exercise']
def __str__(self):
return f"{self.training_homework} - {self.exercise.name}"
class TrainingHomeworkAssignment(models.Model):
training_homework = models.ForeignKey(TrainingHomework, on_delete=models.CASCADE, related_name='assignments')
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='training_homework_assignments')
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='training_homework_assignments', null=True, blank=True)
notes = models.TextField(blank=True)
is_completed = models.BooleanField(default=False)
completion_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['training_homework', 'wrestler']
ordering = ['-created_at']
indexes = [
models.Index(fields=['wrestler']),
models.Index(fields=['is_completed']),
models.Index(fields=['club']),
]
def __str__(self):
return f"{self.wrestler} - Training {self.training_homework.training_id}"
```
- [ ] **Step 2: Run makemigrations**
Run: `cd backend && python manage.py makemigrations homework`
Expected: "Migrations for 'homework': homework/migrations/xxx_create_training_homework.py"
- [ ] **Step 3: Run migrate**
Run: `cd backend && python manage.py migrate`
Expected: "Applying homework.xxx_create_training_homework... OK"
---
### Task 2: Backend - Serializers
**Files:**
- Modify: `backend/homework/serializers.py`
- [ ] **Step 1: Add new serializers**
Add at end of file:
```python
class TrainingHomeworkExerciseSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
exercise_category = serializers.CharField(source='exercise.category', read_only=True)
class Meta:
model = TrainingHomeworkExercise
fields = ['id', 'exercise', 'exercise_name', 'exercise_category', 'reps', 'time_minutes', 'order']
class TrainingHomeworkSerializer(serializers.ModelSerializer):
exercises = TrainingHomeworkExerciseSerializer(many=True, read_only=True)
training_date = serializers.DateField(source='training.date', read_only=True)
training_group = serializers.CharField(source='training.group', read_only=True)
class Meta:
model = TrainingHomework
fields = ['id', 'training', 'training_date', 'training_group', 'exercises', 'created_at']
class TrainingHomeworkAssignmentSerializer(serializers.ModelSerializer):
training_homework_detail = TrainingHomeworkSerializer(source='training_homework', read_only=True)
wrestler_name = serializers.SerializerMethodField()
wrestler_group = serializers.CharField(source='wrestler.group', read_only=True)
class Meta:
model = TrainingHomeworkAssignment
fields = ['id', 'training_homework', 'training_homework_detail', 'wrestler', 'wrestler_name', 'wrestler_group', 'notes', 'is_completed', 'completion_date', 'created_at']
def get_wrestler_name(self, obj):
return f"{obj.wrestler.first_name} {obj.wrestler.last_name}"
class TrainingHomeworkAssignmentCreateSerializer(serializers.Serializer):
training = serializers.IntegerField()
wrestler = serializers.IntegerField()
exercises = serializers.ListField(
child=serializers.DictField(child=serializers.IntegerField(allow_null=True))
)
notes = serializers.CharField(required=False, allow_blank=True, default='')
def create(self, validated_data):
from clubs.utils import get_user_club
from exercises.models import Exercise
from trainings.models import Training
from wrestlers.models import Wrestler
user = self.context['request'].user
club = get_user_club(user)
training_id = validated_data['training']
wrestler_id = validated_data['wrestler']
exercises_data = validated_data['exercises']
notes = validated_data.get('notes', '')
# Create or get TrainingHomework for this training
training_obj = Training.objects.get(id=training_id)
training_homework, _ = TrainingHomework.objects.get_or_create(training=training_obj)
# Create assignment
assignment = TrainingHomeworkAssignment.objects.create(
training_homework=training_homework,
wrestler_id=wrestler_id,
club=club,
notes=notes
)
# Add exercises
for i, ex in enumerate(exercises_data):
TrainingHomeworkExercise.objects.create(
training_homework=training_homework,
exercise_id=ex['exercise'],
reps=ex.get('reps'),
time_minutes=ex.get('time_minutes'),
order=i
)
return assignment
```
---
### Task 3: Backend - Views
**Files:**
- Modify: `backend/homework/views.py`
- [ ] **Step 1: Add new ViewSet**
Add at end of file:
```python
class TrainingHomeworkAssignmentViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, ClubLevelPermission]
filter_backends = [ClubFilterBackend, filters.SearchFilter, filters.OrderingFilter]
serializer_class = TrainingHomeworkAssignmentSerializer
filterset_fields = ['is_completed']
search_fields = ['wrestler__first_name', 'wrestler__last_name']
ordering_fields = ['created_at', 'is_completed']
http_method_names = ['get', 'post', 'patch', 'delete']
def get_queryset(self):
from clubs.utils import get_user_club
club = get_user_club(self.request.user)
return TrainingHomeworkAssignment.objects.filter(club=club).select_related(
'training_homework', 'training_homework__training', 'wrestler'
)
def get_serializer_class(self):
if self.action == 'create':
return TrainingHomeworkAssignmentCreateSerializer
return TrainingHomeworkAssignmentSerializer
@action(detail=True, methods=['post'])
def complete(self, request, pk=None):
assignment = self.get_object()
assignment.is_completed = True
assignment.completion_date = timezone.now().date()
assignment.save()
serializer = self.get_serializer(assignment)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def uncomplete(self, request, pk=None):
assignment = self.get_object()
assignment.is_completed = False
assignment.completion_date = None
assignment.save()
serializer = self.get_serializer(assignment)
return Response(serializer.data)
```
- [ ] **Step 2: Import new serializers at top of file**
Add to imports:
```python
from .serializers import (
# ... existing imports ...
TrainingHomeworkAssignmentSerializer,
TrainingHomeworkAssignmentCreateSerializer,
)
```
---
### Task 4: Backend - URLs
**Files:**
- Modify: `backend/homework/urls.py`
- [ ] **Step 1: Add new route**
Add to urlpatterns:
```python
router.register(r'training-assignments', views.TrainingHomeworkAssignmentViewSet, basename='training-assignment')
```
---
### Task 5: Backend - Admin
**Files:**
- Modify: `backend/homework/admin.py`
- [ ] **Step 1: Register new models**
Add at end of file:
```python
@admin.register(TrainingHomework)
class TrainingHomeworkAdmin(admin.ModelAdmin):
list_display = ['id', 'training', 'created_at']
list_select_related = ['training']
@admin.register(TrainingHomeworkAssignment)
class TrainingHomeworkAssignmentAdmin(admin.ModelAdmin):
list_display = ['id', 'wrestler', 'training_homework', 'is_completed', 'created_at']
list_filter = ['is_completed', 'created_at']
list_select_related = ['wrestler', 'training_homework__training']
```
---
### Task 6: Frontend - Types
**Files:**
- Modify: `frontend/src/lib/api.ts`
- [ ] **Step 1: Add new interfaces**
Add after existing homework interfaces:
```typescript
export interface ITrainingHomeworkExercise {
id: number
exercise: number
exercise_name: string
exercise_category: string
reps: number | null
time_minutes: number | null
order: number
}
export interface ITrainingHomework {
id: number
training: number
training_date: string
training_group: string
exercises: ITrainingHomeworkExercise[]
created_at: string
}
export interface ITrainingHomeworkAssignment {
id: number
training_homework: number
training_homework_detail: ITrainingHomework
wrestler: number
wrestler_name: string
wrestler_group: string
notes: string
is_completed: boolean
completion_date: string | null
created_at: string
}
```
---
### Task 7: Frontend - Training Page Redesign
**Files:**
- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx`
This is a complete rewrite. Key changes:
- 2-column layout: left = participants, right = training homework for this session
- Each participant has a homework button (BookOpen icon)
- Click opens modal to select exercises and assign
- [ ] **Step 1: Read current file** (already done above)
- [ ] **Step 2: Replace the entire file content**
Write new content with:
1. State for homework modal
2. State for selected exercises (with reps/time)
3. Fetch exercises for the modal
4. POST to `/homework/training-assignments/` on submit
5. 2-column layout below details section
---
### Task 8: Frontend - Homework Page
**Files:**
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx`
Complete rewrite:
- Fetch from `/homework/training-assignments/`
- Group by training date
- Filter: All / Open / Completed
- Search by wrestler name
- Each assignment shows wrestler, exercises, status
- Toggle complete/uncomplete via API
---
### Task 9: Frontend - Dashboard Stats
**Files:**
- Modify: `frontend/src/app/(dashboard)/dashboard/page.tsx`
- [ ] **Step 1: Add homework count to stats**
Add a new stat card showing count of incomplete assignments from `/homework/training-assignments/?is_completed=false`
---
### Task 10: Build & Test
- [ ] **Step 1: Run backend migrate**
Run: `cd backend && python manage.py migrate`
- [ ] **Step 2: Run frontend build**
Run: `cd frontend && npm run build`
- [ ] **Step 3: Test manually**
1. Go to training detail page
2. Add a participant
3. Click homework button on participant
4. Select exercises and assign
5. Go to homework page and verify assignment appears
6. Toggle completion status
7. Check dashboard for homework count
@@ -0,0 +1,529 @@
# Trainings Calendar View - 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 calendar month view to the Trainings page as an alternative to grid/list views using react-big-calendar.
**Architecture:** Install react-big-calendar, create a CalendarView component with custom styling to match Shadcn theme, integrate with existing view toggle system, and add day-click popover for training details.
**Tech Stack:** react-big-calendar, date-fns, existing Shadcn UI components
---
## File Structure
```
frontend/
├── src/app/(dashboard)/trainings/page.tsx # Modify: add calendar view toggle
├── src/components/ui/calendar-view.tsx # Create: CalendarView component
├── src/components/ui/calendar.css # Create: Calendar custom styles
├── package.json # Modify: add react-big-calendar, date-fns
```
---
## Task 1: Install Dependencies
**Files:**
- Modify: `frontend/package.json`
- [ ] **Step 1: Add dependencies**
```bash
cd /Volumes/T3/Opencode/WrestleDesk/frontend
npm install react-big-calendar date-fns
npm install -D @types/react-big-calendar
```
- [ ] **Step 2: Verify install**
Run: `npm list react-big-calendar date-fns`
Expected: Both packages listed
---
## Task 2: Create Calendar CSS
**Files:**
- Create: `frontend/src/components/ui/calendar.css`
- [ ] **Step 1: Write calendar styles**
```css
.rbc-calendar {
font-family: inherit;
background: transparent;
}
.rbc-toolbar {
padding: 1rem 0;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.rbc-toolbar button {
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
background: transparent;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
transition: all 0.2s;
}
.rbc-toolbar button:hover {
background: hsl(var(--muted));
}
.rbc-toolbar button.rbc-active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.rbc-toolbar-label {
font-weight: 600;
font-size: 1.125rem;
}
.rbc-header {
padding: 0.75rem 0;
font-weight: 500;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
.rbc-month-view {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
}
.rbc-month-row {
border-bottom: 1px solid hsl(var(--border));
}
.rbc-month-row:last-child {
border-bottom: none;
}
.rbc-day-bg {
transition: background-color 0.2s;
}
.rbc-day-bg:hover {
background: hsl(var(--muted));
}
.rbc-today {
background: hsl(var(--primary) / 0.1);
}
.rbc-off-range-bg {
background: hsl(var(--muted) / 0.3);
}
.rbc-date-cell {
padding: 0.5rem;
text-align: right;
font-size: 0.875rem;
}
.rbc-date-cell > a {
color: inherit;
text-decoration: none;
}
.rbc-event {
background: hsl(var(--primary));
border: none;
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
color: hsl(var(--primary-foreground));
}
.rbc-event.rbc-selected {
background: hsl(var(--primary) / 0.8);
}
.rbc-show-more {
font-size: 0.75rem;
color: hsl(var(--primary));
font-weight: 500;
}
.rbc-overlay {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
padding: 0.5rem;
min-width: 200px;
}
.rbc-overlay-header {
border-bottom: 1px solid hsl(var(--border));
padding: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
}
```
---
## Task 3: Create CalendarView Component
**Files:**
- Create: `frontend/src/components/ui/calendar-view.tsx`
- [ ] **Step 1: Write CalendarView component**
```tsx
"use client"
import { useEffect, useState, useMemo, useCallback } from "react"
import { Calendar, dateFnsLocalizer, Views, SlotInfo } from "react-big-calendar"
import { format, parse, startOfWeek, getDay, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns"
import { de } from "date-fns/locale"
import "react-big-calendar/lib/css/react-big-calendar.css"
import "./calendar.css"
import { ITraining } from "@/lib/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Clock, MapPin, Users, Eye, Pencil, Trash2, X
} from "lucide-react"
import { useAuth } from "@/lib/auth"
import { apiFetch, PaginatedResponse } from "@/lib/api"
import { toast } from "sonner"
const groupConfig = {
kids: { label: "Kinder", class: "bg-primary/10 text-primary border-primary/20" },
youth: { label: "Jugend", class: "bg-secondary/10 text-secondary border-secondary/20" },
adults: { label: "Erwachsene", class: "bg-accent/10 text-accent border-accent/20" },
all: { label: "Alle", class: "bg-muted text-muted-foreground" },
}
const locales = { "de": de }
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { locale: de }),
getDay,
locales,
})
interface CalendarViewProps {
onEdit: (training: ITraining) => void
onDelete: (id: number) => void
onView: (training: ITraining) => void
refreshTrigger: number
}
interface CalendarEvent {
id: number
title: string
start: Date
end: Date
resource: ITraining
}
export function CalendarView({ onEdit, onDelete, onView, refreshTrigger }: CalendarViewProps) {
const { token } = useAuth()
const [currentDate, setCurrentDate] = useState(new Date())
const [trainings, setTrainings] = useState<ITraining[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedDayTrainings, setSelectedDayTrainings] = useState<ITraining[]>([])
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
const [popoverOpen, setPopoverOpen] = useState(false)
const fetchTrainings = useCallback(async (date: Date) => {
if (!token) return
setIsLoading(true)
try {
const start = startOfMonth(subMonths(date, 1))
const end = endOfMonth(addMonths(date, 1))
const params = new URLSearchParams()
params.set("date_from", format(start, "yyyy-MM-dd"))
params.set("date_to", format(end, "yyyy-MM-dd"))
params.set("page_size", "100")
const data = await apiFetch<PaginatedResponse<ITraining>>(`/trainings/?${params.toString()}`, { token })
setTrainings(data.results || [])
} catch {
toast.error("Fehler beim Laden der Trainingseinheiten")
} finally {
setIsLoading(false)
}
}, [token])
useEffect(() => {
fetchTrainings(currentDate)
}, [fetchTrainings, currentDate, refreshTrigger])
const events: CalendarEvent[] = useMemo(() => {
return trainings
.filter(t => t.date)
.map(t => ({
id: t.id,
title: `${t.start_time || ""} - ${t.group ? groupConfig[t.group as keyof typeof groupConfig]?.label : ""}`,
start: new Date(`${t.date}T${t.start_time || "00:00"}`),
end: new Date(`${t.date}T${t.end_time || "23:59"}`),
resource: t,
}))
}, [trainings])
const handleSelectEvent = useCallback((event: CalendarEvent) => {
onView(event.resource)
}, [onView])
const handleSelectSlot = useCallback((slotInfo: SlotInfo) => {
const dayTrainings = trainings.filter(t => {
const trainingDate = new Date(t.date)
return format(trainingDate, "yyyy-MM-dd") === format(slotInfo.start, "yyyy-MM-dd")
})
if (dayTrainings.length > 0) {
setSelectedDay(slotInfo.start)
setSelectedDayTrainings(dayTrainings)
setPopoverOpen(true)
}
}, [trainings])
const eventStyleGetter = useCallback((event: CalendarEvent) => {
const group = event.resource.group
const config = groupConfig[group as keyof typeof groupConfig] || groupConfig.all
return {
style: {
backgroundColor: `hsl(var(--primary))`,
borderRadius: "0.25rem",
border: "none",
color: `hsl(var(--primary-foreground))`,
}
}
}, [])
const CustomEvent = ({ event }: { event: CalendarEvent }) => (
<div className="flex items-center gap-1 text-xs">
<span className="font-medium">{event.resource.start_time}</span>
</div>
)
const CustomToolbar = ({ label, onNavigate, onView, view }: any) => (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => onNavigate("PREV")}>
</Button>
<Button variant="outline" size="sm" onClick={() => onNavigate("TODAY")}>
Heute
</Button>
<Button variant="outline" size="sm" onClick={() => onNavigate("NEXT")}>
</Button>
</div>
<span className="text-lg font-semibold">{label}</span>
<div className="flex items-center gap-2">
<Button
variant={view === "month" ? "default" : "outline"}
size="sm"
onClick={() => onView("month")}
>
Monat
</Button>
</div>
</div>
)
return (
<div className="space-y-4">
<style>{`
.rbc-calendar { height: calc(100vh - 280px); min-height: 600px; }
`}</style>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
view={Views.MONTH}
views={[Views.MONTH]}
date={currentDate}
onNavigate={setCurrentDate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
selectable
eventPropGetter={eventStyleGetter}
components={{
event: CustomEvent,
toolbar: CustomToolbar,
}}
messages={{
today: "Heute",
month: "Monat",
week: "Woche",
day: "Tag",
}}
/>
{popoverOpen && selectedDay && (
<div className="fixed inset-0 z-50" onClick={() => setPopoverOpen(false)}>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-96">
<Card className="shadow-xl">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{format(selectedDay, "EEEE, d. MMMM", { locale: de })}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPopoverOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="max-h-64 overflow-y-auto space-y-2">
{selectedDayTrainings.map(training => (
<div
key={training.id}
className="flex items-center justify-between p-2 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => {
onView(training)
setPopoverOpen(false)
}}
>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">
{training.start_time} - {training.end_time}
</div>
<Badge
className={groupConfig[training.group as keyof typeof groupConfig]?.class}
variant="secondary"
>
{groupConfig[training.group as keyof typeof groupConfig]?.label}
</Badge>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="h-3 w-3" />
{training.attendance_count || 0}
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
)}
</div>
)
}
```
---
## Task 4: Integrate Calendar into Trainings Page
**Files:**
- Modify: `frontend/src/app/(dashboard)/trainings/page.tsx`
- [ ] **Step 1: Add imports**
Add to imports section:
```tsx
import { CalendarView } from "@/components/ui/calendar-view"
import { Calendar } from "lucide-react"
```
- [ ] **Step 2: Update viewMode state**
Change line ~50:
```tsx
const [viewMode, setViewMode] = useState<"grid" | "list" | "calendar">("grid")
```
- [ ] **Step 3: Update toggle buttons**
Find the existing toggle (around line 420-440) and update to:
```tsx
<div className="flex border rounded-lg">
<button
onClick={() => setViewMode("grid")}
className={`p-2 ${viewMode === "grid" ? "bg-muted" : "hover:bg-muted/50"} rounded-l-lg transition-colors`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("list")}
className={`p-2 ${viewMode === "list" ? "bg-muted" : "hover:bg-muted/50"} transition-colors`}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("calendar")}
className={`p-2 ${viewMode === "calendar" ? "bg-muted" : "hover:bg-muted/50"} rounded-r-lg transition-colors`}
>
<Calendar className="w-4 h-4" />
</button>
</div>
```
- [ ] **Step 4: Add CalendarView rendering**
After the existing grid/list views (around line 525), add:
```tsx
{viewMode === "calendar" && (
<FadeIn delay={0.1}>
<CalendarView
onEdit={handleEdit}
onDelete={(id) => setDeleteId(id)}
onView={(training) => router.push(`/trainings/${training.id}`)}
refreshTrigger={trainings.length}
/>
</FadeIn>
)}
```
- [ ] **Step 5: Hide pagination in calendar view**
Find pagination section and update:
```tsx
{totalPages > 1 && viewMode !== "calendar" && (
<Pagination ... />
)}
```
---
## Task 5: Build and Test
- [ ] **Step 1: Run build**
Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run build`
Expected: SUCCESS
- [ ] **Step 2: Test in browser**
Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run dev`
Navigate to: http://localhost:3000/trainings
- Click calendar icon in toggle
- Verify calendar renders with month navigation
- Click on a day to see trainings popover
- Navigate between months
- Click "Heute" to return to current month
---
## Task 6: Commit
```bash
git add -A
git commit -m "feat(trainings): add calendar month view with react-big-calendar"
```
@@ -0,0 +1,310 @@
# Calendar UI Improvements - 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:** Enhance calendar with better colors, today highlighting, group-based event colors, hover tooltips, and click-to-day-panel
**Architecture:** Frontend-only CSS and component changes to existing calendar-view.tsx and calendar.css
**Tech Stack:** React, TypeScript, Tailwind CSS, react-big-calendar, date-fns
---
## Files Overview
| File | Purpose |
|------|---------|
| `frontend/src/components/ui/calendar-view.tsx` | Main calendar component |
| `frontend/src/components/ui/calendar.css` | Calendar styling overrides |
---
## Task 1: Enhance "Today" Highlighting
**Files:**
- Modify: `frontend/src/components/ui/calendar.css:68-70`
**Changes:**
- Replace today's subtle background with light green accent
- Add subtle animation pulse effect
- Make date number bold
**Steps:**
- [ ] **Step 1: Update .rbc-today styling**
Replace current implementation with light green accent
```css
.rbc-today {
background: hsl(142, 76%, 96%);
border: 2px solid hsl(142, 76%, 45%);
border-radius: 0.5rem;
}
```
- [ ] **Step 2: Add today date cell bold styling**
Make today's date number more prominent
```css
.rbc-today .rbc-date-cell {
font-weight: 700;
color: hsl(142, 76%, 30%);
}
```
- [ ] **Step 3: Test in browser**
Navigate to /trainings and verify today is highlighted
---
## Task 2: Group-Based Event Colors
**Files:**
- Modify: `frontend/src/components/ui/calendar.css:87-94`
- Modify: `frontend/src/components/ui/calendar-view.tsx:114-123`
**Changes:**
- Kids: Blue (#3B82F6) with 15% opacity background
- Youth: Purple (#8B5CF6) with 15% opacity background
- Adults: Orange (#F97316) with 15% opacity background
- Text in dark version of each color for readability
**Steps:**
- [ ] **Step 1: Define group color constants in calendar-view.tsx**
Add color configuration
```typescript
const groupColors = {
kids: { bg: "rgba(59, 130, 246, 0.15)", text: "#1E40AF", border: "#3B82F6" },
youth: { bg: "rgba(139, 92, 246, 0.15)", text: "#5B21B6", border: "#8B5CF6" },
adults: { bg: "rgba(249, 115, 22, 0.15)", text: "#9A3412", border: "#F97316" },
all: { bg: "rgba(100, 100, 100, 0.15)", text: "#404040", border: "#666666" },
}
```
- [ ] **Step 2: Update eventStyleGetter to use group colors**
Modify function at line 114
```typescript
const eventStyleGetter = useCallback((event: CalendarEvent) => {
const group = event.resource.group || "all"
const colors = groupColors[group as keyof typeof groupColors]
return {
style: {
backgroundColor: colors.bg,
borderLeft: `3px solid ${colors.border}`,
borderRadius: "0.25rem",
border: "none",
color: colors.text,
}
}
}, [])
```
- [ ] **Step 3: Update CSS for events**
Replace existing .rbc-event styles
```css
.rbc-event {
background: transparent;
border: none;
border-left: 3px solid;
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
```
- [ ] **Step 4: Test in browser**
Verify each group shows correct color
---
## Task 3: Hover Tooltip
**Files:**
- Modify: `frontend/src/components/ui/calendar-view.tsx`
- Create: `frontend/src/components/ui/calendar-tooltip.tsx` (optional, can be inline)
**Changes:**
- Show tooltip on event hover with training details
- Tooltip shows: Time, Group badge, Location, Attendee count
- Position tooltip above event
**Steps:**
- [ ] **Step 1: Add tooltip state and handlers to calendar-view.tsx**
Add state for tooltip visibility, position, and content
```typescript
const [tooltipInfo, setTooltipInfo] = useState<{
visible: boolean
x: number
y: number
content: { time: string, group: string, location: string, attendees: number }
} | null>(null)
```
- [ ] **Step 2: Create custom Event component with hover**
Replace CustomEvent at line 125
```typescript
const CustomEvent = ({ event }: { event: CalendarEvent }) => {
const [showTooltip, setShowTooltip] = useState(false)
return (
<div
className="relative"
onMouseEnter={(e) => {
setShowTooltip(true)
setTooltipInfo({
visible: true,
x: e.clientX,
y: e.clientY - 10,
content: {
time: `${event.resource.start_time} - ${event.resource.end_time}`,
group: groupConfig[event.resource.group as keyof typeof groupConfig]?.label || "",
location: event.resource.location_name || "Kein Ort",
attendees: event.resource.attendance_count || 0,
}
})
}}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="flex items-center gap-1 text-xs">
<span className="font-medium">{event.resource.start_time}</span>
</div>
{showTooltip && tooltipInfo?.visible && (
<div
className="fixed z-50 bg-background border rounded-lg shadow-lg p-3 text-sm"
style={{ left: tooltipInfo.x, top: tooltipInfo.y, transform: 'translateY(-100%)' }}
>
<div className="font-medium mb-1">{tooltipInfo.content.time}</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Badge className={groupConfig[event.resource.group as keyof typeof groupConfig]?.class}>
{tooltipInfo.content.group}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-1">
📍 {tooltipInfo.content.location}
</div>
<div className="text-xs text-muted-foreground">
👥 {tooltipInfo.content.attendees} Teilnehmer
</div>
</div>
)}
</div>
)
}
```
- [ ] **Step 3: Test hover in browser**
Hover over training events and verify tooltip appears
---
## Task 4: Click on Day → Trainings Panel
**Files:**
- Modify: `frontend/src/components/ui/calendar-view.tsx:180-230`
**Changes:**
- Replace bottom popover with improved side panel or modal
- Show all trainings for selected day
- Better styling with group colors
- Click on training opens detail view
**Steps:**
- [ ] **Step 1: Import Sheet component**
Add Sheet import at top of file
```typescript
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
```
- [ ] **Step 2: Replace popover state with sheet state**
Change state from popoverOpen to sheetOpen
```typescript
const [selectedDaySheetOpen, setSelectedDaySheetOpen] = useState(false)
```
- [ ] **Step 3: Replace popover JSX with Sheet component**
Replace lines 180-230 with:
```typescript
<Sheet open={selectedDaySheetOpen} onOpenChange={setSelectedDaySheetOpen}>
<SheetContent side="bottom" className="h-[50vh] sm:max-w-[600px]">
<SheetHeader>
<SheetTitle>
{format(selectedDay!, "EEEE, d. MMMM yyyy", { locale: de })}
</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-3 max-h-[calc(50vh-100px)] overflow-y-auto">
{selectedDayTrainings.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
Keine Trainings an diesem Tag
</p>
) : (
selectedDayTrainings.map(training => (
<div
key={training.id}
className="flex items-center justify-between p-4 rounded-xl border hover:bg-muted/50 transition-all cursor-pointer"
style={{
borderLeftWidth: 4,
borderLeftColor: groupColors[training.group as keyof typeof groupColors]?.border || "#666"
}}
onClick={() => {
onView(training)
setSelectedDaySheetOpen(false)
}}
>
<div className="flex items-center gap-4">
<div className="text-lg font-semibold">
{training.start_time} - {training.end_time}
</div>
<Badge
className={groupConfig[training.group as keyof typeof groupConfig]?.class}
>
{groupConfig[training.group as keyof typeof groupConfig]?.label}
</Badge>
{training.location_name && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
{training.location_name}
</div>
)}
</div>
<div className="flex items-center gap-1 text-sm">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{training.attendance_count || 0}</span>
</div>
</div>
))
)}
</div>
</SheetContent>
</Sheet>
```
- [ ] **Step 4: Update handleSelectSlot to use new state**
Change line 107-111 to set `setSelectedDaySheetOpen(true)` instead of `setPopoverOpen(true)`
- [ ] **Step 5: Test in browser**
Click on a day with trainings and verify Sheet appears
---
## Verification Checklist
After all tasks:
- [ ] Today is highlighted with light green accent and bold date
- [ ] Training events show group colors (blue/purple/orange)
- [ ] Hover on event shows tooltip with details
- [ ] Click on day opens bottom Sheet with training list
- [ ] Click on training in Sheet opens detail view
- [ ] No console errors
- [ ] Responsive on mobile
@@ -0,0 +1,278 @@
# Dashboard Statistics 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:** Expand Dashboard with comprehensive statistics in Bento Grid layout - attendance by group, homework completion rates, wrestler distribution, and trainer activity.
**Architecture:** Backend provides single `/api/v1/stats/dashboard/` endpoint aggregating all statistics. Frontend displays in Bento Grid with progress bars and simple bar charts using CSS/divs (no external chart library needed).
**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend), CSS progress bars (no chart library)
---
## File Structure
### Backend
- **Create:** `backend/stats/__init__.py` - App init
- **Create:** `backend/stats/apps.py` - App config
- **Create:** `backend/stats/views.py` - DashboardStatsViewSet
- **Modify:** `backend/wrestleDesk/urls.py` - Add stats endpoint
- **Modify:** `backend/settings.py` - Add 'stats' to INSTALLED_APPS
### Frontend
- **Modify:** `frontend/src/lib/api.ts` - Add `IDashboardStats` interface
- **Modify:** `frontend/src/app/(dashboard)/dashboard/page.tsx` - New stat cards and visualizations
---
## Tasks
### Task 1: Create Stats Backend App
- [ ] **Step 1: Create stats app directory structure**
Create `backend/stats/` with:
```
stats/
├── __init__.py
├── apps.py
└── views.py
```
- [ ] **Step 2: Create apps.py**
```python
from django.apps import AppConfig
class StatsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'stats'
```
- [ ] **Step 3: Add 'stats' to INSTALLED_APPS in backend/settings.py**
Add `'stats'` to the INSTALLED_APPS list.
- [ ] **Step 4: Create DashboardStatsViewSet in views.py**
```python
from rest_framework import viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Count, Sum
from datetime import datetime, timedelta
from wrestlers.models import Wrestler
from trainers.models import Trainer
from trainings.models import Training, Attendance
from homework.models import TrainingHomeworkAssignment
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def dashboard_stats(request):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
two_weeks_ago = today - timedelta(days=14)
# Wrestlers stats
total_wrestlers = Wrestler.objects.count()
wrestlers_this_week = Wrestler.objects.filter(
created_at__date__gte=week_start
).count()
# Trainers stats
total_trainers = Trainer.objects.count()
active_trainers = Trainer.objects.filter(is_active=True).count()
# Trainings stats
total_trainings = Training.objects.count()
trainings_this_week = Training.objects.filter(
date__gte=week_start
).count()
# Homework stats
open_homework = TrainingHomeworkAssignment.objects.filter(is_completed=False).count()
completed_homework = TrainingHomeworkAssignment.objects.filter(is_completed=True).count()
# Attendance by group this week
trainings_this_week_qs = Training.objects.filter(date__gte=week_start)
attendance_data = {}
for group, label in [('kids', 'Kinder'), ('youth', 'Jugend'), ('adults', 'Erwachsene')]:
group_wrestlers = Wrestler.objects.filter(group=group)
attended = Attendance.objects.filter(
training__in=trainings_this_week_qs,
wrestler__in=group_wrestlers
).values('wrestler').distinct().count()
total = group_wrestlers.count()
attendance_data[group] = {
'attended': attended,
'total': total,
'percent': int((attended / total * 100) if total > 0 else 0)
}
# Activity (last 14 days)
activity = []
for i in range(14):
day = today - timedelta(days=13 - i)
count = Attendance.objects.filter(training__date=day).count()
activity.append({'date': day.isoformat(), 'count': count})
# Wrestlers by group
wrestlers_by_group = {
'kids': Wrestler.objects.filter(group='kids', is_active=True).count(),
'youth': Wrestler.objects.filter(group='youth', is_active=True).count(),
'adults': Wrestler.objects.filter(group='adults', is_active=True).count(),
'inactive': Wrestler.objects.filter(is_active=False).count(),
}
# Top trainers (by training count)
trainer_stats = Trainer.objects.annotate(
training_count=Count('trainings')
).order_by('-training_count')[:5]
top_trainers = [
{'name': t.first_name + ' ' + t.last_name[0] + '.', 'training_count': t.training_count}
for t in trainer_stats
]
return Response({
'wrestlers': {'total': total_wrestlers, 'this_week': wrestlers_this_week},
'trainers': {'total': total_trainers, 'active': active_trainers},
'trainings': {'total': total_trainings, 'this_week': trainings_this_week},
'homework': {'open': open_homework, 'completed': completed_homework},
'attendance': {
'this_week': attendance_data,
'average': Attendance.objects.filter(training__date__gte=week_start).values('training').distinct().count(),
'expected': total_wrestlers
},
'activity': activity,
'wrestlers_by_group': wrestlers_by_group,
'top_trainers': top_trainers,
})
```
- [ ] **Step 5: Add stats URL to urls.py**
Add to `backend/wrestleDesk/urls.py`:
```python
from stats.views import dashboard_stats
path('api/v1/stats/dashboard/', dashboard_stats, name='dashboard-stats'),
```
---
### Task 2: Update Frontend API Types
- [ ] **Step 1: Add IDashboardStats interface to frontend/src/lib/api.ts**
Add after existing interfaces:
```typescript
export interface IDashboardStats {
wrestlers: { total: number; this_week: number }
trainers: { total: number; active: number }
trainings: { total: number; this_week: number }
homework: { open: number; completed: number }
attendance: {
this_week: {
kids: { attended: number; total: number; percent: number }
youth: { attended: number; total: number; percent: number }
adults: { attended: number; total: number; percent: number }
}
average: number
expected: number
}
activity: { date: string; count: number }[]
wrestlers_by_group: {
kids: number
youth: number
adults: number
inactive: number
}
top_trainers: { name: string; training_count: number }[]
}
```
---
### Task 3: Update Dashboard Page
- [ ] **Step 1: Update imports in frontend/src/app/(dashboard)/dashboard/page.tsx**
Add `Progress` component and `IDashboardStats`:
```typescript
import { apiFetch, IDashboardStats } from "@/lib/api"
import { Progress } from "@/components/ui/progress"
```
- [ ] **Step 2: Replace Stats interface and statCards with new implementation**
Replace the existing interface and statCards with:
```typescript
const groupColors = {
kids: "bg-blue-500",
youth: "bg-purple-500",
adults: "bg-orange-500",
}
const groupLabels = {
kids: "Kinder",
youth: "Jugend",
adults: "Erwachsene",
inactive: "Inaktiv",
}
```
- [ ] **Step 3: Replace useEffect to fetch from stats endpoint**
Replace `fetchStats` with:
```typescript
useEffect(() => {
if (!token) return
const fetchStats = async () => {
setIsLoading(true)
try {
const data = await apiFetch<IDashboardStats>('/stats/dashboard/', { token })
setStats(data)
} catch (error) {
console.error("Failed to fetch stats:", error)
} finally {
setIsLoading(false)
}
}
fetchStats()
}, [token])
```
- [ ] **Step 4: Replace the dashboard content**
Replace the entire return section with the Bento Grid layout including:
- 4 stat cards (enhanced)
- Attendance by group card with progress bars
- Training activity card with bar chart
- Homework completion card (full width)
- Wrestlers by group card
- Top trainers card
Each card uses `FadeIn` with appropriate delay props.
---
## Testing
### Backend
- Run: `cd backend && python manage.py check`
- Test endpoint: `curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/stats/dashboard/`
### Frontend
- Run: `cd frontend && npm run lint`
- Run: `npm run typecheck`
- Visit: http://localhost:3000/dashboard
---
## Notes
- Progress bars use Tailwind `bg-*` classes with calculated widths
- Bar chart uses flexbox with varying heights
- All data loaded asynchronously with loading state
- Error handling: console.error on failure, UI continues to show zeros
@@ -0,0 +1,126 @@
# WrestleDesk UI Improvements - 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:** Fix 3 issues: (1) Better homework page cards, (2) Wrestlers pagination bug, (3) Training homework icon color + Sheet conversion
**Architecture:** Frontend-only changes (React/Next.js with TypeScript, Tailwind, Shadcn UI)
**Tech Stack:** Next.js 16, React, TypeScript, Tailwind CSS, Shadcn UI (Sheet, Card, Badge, Avatar)
---
## Files Overview
| File | Purpose |
|------|---------|
| `frontend/src/app/(dashboard)/homework/page.tsx` | Homework page - improve card layout |
| `frontend/src/app/(dashboard)/wrestlers/page.tsx` | Wrestlers page - fix pagination |
| `frontend/src/app/(dashboard)/trainings/[id]/page.tsx` | Training detail - fix icon color + Sheet |
| `frontend/src/lib/api.ts` | API types - verify ITrainingHomeworkAssignment |
---
## Task 1: Improve Homework Page Card Layout
**Files:**
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx:169-246`
**Changes:**
- Replace grouped card layout with individual wrestler cards
- Each card shows: Avatar, Wrestler Name, Training Date, Group Badge, Exercise List with reps/time, Status Badge, expandable Notes
- Grid layout: 2-3 columns on desktop, 1 on mobile
- Exercise list shows category color coding
**Steps:**
- [ ] **Step 1: Add Sheet component to imports (if not already available)**
Check if Sheet is imported. If not, run:
```bash
cd frontend && npx shadcn@latest add sheet
```
- [ ] **Step 2: Modify the card rendering loop (lines 169-246)**
Replace current grouped cards with individual wrestler cards in a grid
- [ ] **Step 3: Test the new layout**
Navigate to /homework and verify cards display correctly
---
## Task 2: Fix Wrestlers Pagination Bug
**Files:**
- Modify: `frontend/src/app/(dashboard)/wrestlers/page.tsx:103-126`
- Debug: Backend `backend/wrestlers/views.py`
**Changes:**
- Verify API returns correct `count` and `results` in PaginatedResponse
- Debug why only 11 wrestlers total are returned instead of actual count
- Possibly fix backend query or frontend display logic
**Steps:**
- [ ] **Step 1: Test API directly**
Run: `curl -H "Authorization: Bearer <token>" "http://localhost:8000/api/v1/wrestlers/?page=1&page_size=10"`
Check response for `count` field and actual number of results
- [ ] **Step 2: Check backend pagination class**
Read `backend/wrestleDesk/pagination.py` to verify StandardResultsSetPagination
- [ ] **Step 3: Verify frontend handles response correctly**
Check `frontend/src/lib/api.ts` PaginatedResponse interface matches API response
- [ ] **Step 4: Fix identified issue**
- If backend: fix query or pagination
- If frontend: fix state update or display logic
- [ ] **Step 5: Test pagination**
Navigate to /wrestlers, verify page 1 shows 10 items, page 2 shows correct items
---
## Task 3: Training Homework - Icon Color + Sheet
**Files:**
- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx:336-395` (participant icons)
- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx:561-672` (Modal → Sheet)
**Changes:**
- BookOpen icon color: green if wrestler has homework assignment, gray/muted if not
- Convert homework Modal to Sheet component
- In Sheet, show wrestlers with existing HA marked green
**Steps:**
- [ ] **Step 1: Add Sheet component to imports (if not already available)**
Check if Sheet is imported. If not, run:
```bash
cd frontend && npx shadcn@latest add sheet
```
- [ ] **Step 2: Modify participant icon colors (lines 336-395)**
Add conditional styling to BookOpen icon:
- Green/primary color if `wrestlerAssignments.length > 0`
- Muted/gray color if no assignments
- [ ] **Step 3: Convert homework Modal to Sheet (lines 561-672)**
Replace `<Modal>` with `<Sheet>` component
Keep the same form content inside
- [ ] **Step 4: Add green highlight for wrestlers with HA in Sheet**
When opening homework sheet, show wrestlers that already have assignments highlighted
- [ ] **Step 5: Test the changes**
Navigate to /trainings/[id], verify icon colors and Sheet functionality
---
## Verification Checklist
After all tasks:
- [ ] Homework page shows cards with all details (Avatar, Name, Training, Exercises, Status, Notes)
- [ ] Wrestlers pagination works: page 1 = 10 items, page 2 = next 10 items
- [ ] Training detail: BookOpen icon is green for wrestlers with HA, gray for those without
- [ ] Training detail: Homework assignment opens as Sheet, not Modal
- [ ] No console errors on any of the affected pages
@@ -0,0 +1,415 @@
# Leistungstest 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:** Create Leistungstest (Performance Test) system with templates, assignment, results tracking, and leaderboard.
**Architecture:** Backend provides CRUD for templates and results + leaderboard endpoint. Frontend has 4 tabs: Vorlagen, Zuweisen, Ergebnisse, Leaderboard.
**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend)
---
## File Structure
### Backend
- **Create:** `backend/leistungstest/__init__.py`
- **Create:** `backend/leistungstest/apps.py`
- **Create:** `backend/leistungstest/models.py`
- **Create:** `backend/leistungstest/serializers.py`
- **Create:** `backend/leistungstest/views.py`
- **Create:** `backend/leistungstest/urls.py`
- **Modify:** `backend/wrestleDesk/settings.py` — add 'leistungstest'
- **Modify:** `backend/wrestleDesk/urls.py` — include leistungstest URLs
### Frontend
- **Create:** `frontend/src/app/(dashboard)/leistungstest/page.tsx`
- **Modify:** `frontend/src/components/layout/sidebar.tsx` — add Leistungstest nav item
- **Modify:** `frontend/src/lib/api.ts` — add interfaces
---
## Tasks
### Task 1: Create Leistungstest Backend App
- [ ] **Step 1: Create directory and files**
Create `backend/leistungstest/` with `__init__.py` and `apps.py`
- [ ] **Step 2: Add to INSTALLED_APPS**
Add `'leistungstest'` to `INSTALLED_APPS` in `backend/wrestleDesk/settings.py`
- [ ] **Step 3: Create models**
```python
from django.db import models
from django.utils import timezone
class LeistungstestTemplate(models.Model):
name = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.name
@property
def usage_count(self):
return self.results.count()
class LeistungstestTemplateExercise(models.Model):
template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='exercises')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
target_reps = models.PositiveIntegerField()
order = models.IntegerField(default=0)
class Meta:
ordering = ['template', 'order']
unique_together = ['template', 'exercise']
def __str__(self):
return f"{self.template.name} - {self.exercise.name}"
class LeistungstestResult(models.Model):
template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='results')
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='leistungstest_results')
total_time_minutes = models.PositiveIntegerField(null=True, blank=True)
rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
notes = models.TextField(blank=True)
completed_at = models.DateTimeField(default=timezone.now)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-completed_at']
indexes = [
models.Index(fields=['wrestler']),
models.Index(fields=['template']),
models.Index(fields=['completed_at']),
]
def __str__(self):
return f"{self.wrestler} - {self.template.name}"
@property
def score_percent(self):
items = self.items.all()
if not items.exists():
return 0
total_target = sum(item.target_reps for item in items)
total_actual = sum(item.actual_reps for item in items)
if total_target == 0:
return 0
return round((total_actual / total_target) * 100, 1)
class LeistungstestResultItem(models.Model):
result = models.ForeignKey(LeistungstestResult, on_delete=models.CASCADE, related_name='items')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
target_reps = models.PositiveIntegerField()
actual_reps = models.PositiveIntegerField()
order = models.IntegerField(default=0)
class Meta:
ordering = ['result', 'order']
def __str__(self):
return f"{self.result} - {self.exercise.name}: {self.actual_reps}/{self.target_reps}"
```
- [ ] **Step 4: Create serializers**
```python
from rest_framework import serializers
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
class LeistungstestTemplateExerciseSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = LeistungstestTemplateExercise
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'order']
class LeistungstestTemplateSerializer(serializers.ModelSerializer):
exercises = LeistungstestTemplateExerciseSerializer(many=True, read_only=True)
usage_count = serializers.IntegerField(read_only=True)
class Meta:
model = LeistungstestTemplate
fields = ['id', 'name', 'exercises', 'usage_count', 'created_at']
class LeistungstestResultItemSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = LeistungstestResultItem
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'order']
class LeistungstestResultSerializer(serializers.ModelSerializer):
items = LeistungstestResultItemSerializer(many=True, read_only=True)
template_name = serializers.CharField(source='template.name', read_only=True)
wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True)
score_percent = serializers.FloatField(read_only=True)
class Meta:
model = LeistungstestResult
fields = ['id', 'template', 'template_name', 'wrestler', 'wrestler_name',
'total_time_minutes', 'rating', 'notes', 'completed_at',
'score_percent', 'items', 'created_at']
class LeaderboardEntrySerializer(serializers.Serializer):
rank = serializers.IntegerField()
wrestler = serializers.DictField()
score_percent = serializers.FloatField()
rating = serializers.IntegerField()
time_minutes = serializers.IntegerField(allow_null=True)
```
- [ ] **Step 5: Create views**
```python
from rest_framework import viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Max
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
from .serializers import (
LeistungstestTemplateSerializer, LeistungstestResultSerializer
)
class LeistungstestTemplateViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
queryset = LeistungstestTemplate.objects.all()
serializer_class = LeistungstestTemplateSerializer
def perform_create(self, serializer):
template = serializer.save()
exercises_data = self.request.data.get('exercises', [])
for i, ex in enumerate(exercises_data):
LeistungstestTemplateExercise.objects.create(
template=template,
exercise_id=ex['exercise'],
target_reps=ex['target_reps'],
order=i
)
class LeistungstestTemplateExerciseViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
queryset = LeistungstestTemplateExercise.objects.all()
serializer_class = None
class LeistungstestResultViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = LeistungstestResultSerializer
def get_queryset(self):
queryset = LeistungstestResult.objects.all()
wrestler = self.request.query_params.get('wrestler')
template = self.request.query_params.get('template')
if wrestler:
queryset = queryset.filter(wrestler=wrestler)
if template:
queryset = queryset.filter(template=template)
return queryset.prefetch_related('items')
def perform_create(self, serializer):
result = serializer.save()
items_data = self.request.data.get('items', [])
for i, item in enumerate(items_data):
LeistungstestResultItem.objects.create(
result=result,
exercise_id=item['exercise'],
target_reps=item['target_reps'],
actual_reps=item['actual_reps'],
order=i
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def leaderboard(request):
template_id = request.query_params.get('template')
if not template_id:
return Response({'error': 'template is required'}, status=400)
template = LeistungstestTemplate.objects.get(id=template_id)
latest_results = LeistungstestResult.objects.filter(
template=template
).values('wrestler').annotate(
latest_date=Max('completed_at')
)
rankings = []
for entry in latest_results:
result = LeistungstestResult.objects.get(
template=template,
wrestler_id=entry['wrestler'],
completed_at=entry['latest_date']
)
wrestler = result.wrestler
rankings.append({
'rank': 0,
'wrestler': {'id': wrestler.id, 'name': str(wrestler)},
'score_percent': result.score_percent,
'rating': result.rating,
'time_minutes': result.total_time_minutes
})
rankings.sort(key=lambda x: (-x['score_percent'], x['time_minutes'] or 999))
for i, r in enumerate(rankings):
r['rank'] = i + 1
return Response({
'template': {'id': template.id, 'name': template.name},
'rankings': rankings
})
```
- [ ] **Step 6: Create urls.py**
```python
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import LeistungstestTemplateViewSet, LeistungstestResultViewSet, leaderboard
router = DefaultRouter()
router.register(r'templates', LeistungstestTemplateViewSet, basename='leistungstest-template')
router.register(r'results', LeistungstestResultViewSet, basename='leistungstest-result')
urlpatterns = [
path('leaderboard/', leaderboard, name='leistungstest-leaderboard'),
path('', include(router.urls)),
]
```
- [ ] **Step 7: Add URL to main urls.py**
```python
path('api/v1/leistungstest/', include('leistungstest.urls')),
```
- [ ] **Step 8: Create migration**
Run: `cd backend && python manage.py makemigrations leistungstest`
---
### Task 2: Update Frontend API Types
- [ ] **Step 1: Add interfaces to frontend/src/lib/api.ts**
```typescript
export interface ILeistungstestTemplateExercise {
id: number
exercise: number
exercise_name: string
target_reps: number
order: number
}
export interface ILeistungstestTemplate {
id: number
name: string
exercises: ILeistungstestTemplateExercise[]
usage_count: number
created_at: string
}
export interface ILeistungstestResultItem {
id: number
exercise: number
exercise_name: string
target_reps: number
actual_reps: number
order: number
}
export interface ILeistungstestResult {
id: number
template: number
template_name: string
wrestler: number
wrestler_name: string
total_time_minutes: number | null
rating: number
notes: string
completed_at: string
score_percent: number
items: ILeistungstestResultItem[]
created_at: string
}
export interface ILeaderboardEntry {
rank: number
wrestler: { id: number; name: string }
score_percent: number
rating: number
time_minutes: number | null
}
export interface ILeaderboard {
template: { id: number; name: string }
rankings: ILeaderboardEntry[]
}
```
---
### Task 3: Create Leistungstest Page
- [ ] **Step 1: Create page with 4 tabs**
Create `frontend/src/app/(dashboard)/leistungstest/page.tsx` with:
- Tab state (vorlagen | zuweisen | ergebnisse | leaderboard)
- Vorlagen tab: Template list + create form
- Zuweisen tab: Select wrestler/template, record results
- Ergebnisse tab: Results table with filters + progress
- Leaderboard tab: Rankings by template
- [ ] **Step 2: Add sidebar navigation**
Add to `sidebar.tsx`:
```typescript
{ name: "Leistungstest", href: "/leistungstest", icon: Trophy },
```
Import Trophy icon from lucide-react.
---
## Testing
### Backend
- Run: `cd backend && python manage.py check`
- Run: `python manage.py migrate`
- Test endpoint: `curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/leistungstest/templates/`
### Frontend
- Run: `cd frontend && npm run lint`
- Visit: http://localhost:3000/leistungstest
---
## Notes
- Wrestler/Template dropdowns use SelectValue with find() to show names not IDs
- Score = (sum actual_reps / sum target_reps) * 100
- Leaderboard shows latest result per wrestler for selected template
- Results table sorted by date (newest first)
- Star rating component with clickable stars (1-5)
@@ -0,0 +1,677 @@
# Training Log 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:** Create Training Log page with 3 tabs (Log/Historie/Analyse) for recording and analyzing wrestler exercise performance.
**Architecture:** Backend provides CRUD + stats endpoints for TrainingLogEntry model. Frontend uses tabbed interface with async data fetching.
**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend), Lucide icons
---
## File Structure
### Backend
- **Create:** `backend/training_log/__init__.py`
- **Create:** `backend/training_log/apps.py`
- **Create:** `backend/training_log/models.py` — TrainingLogEntry model
- **Create:** `backend/training_log/serializers.py`
- **Create:** `backend/training_log/views.py`
- **Create:** `backend/training_log/urls.py`
- **Modify:** `backend/wrestleDesk/settings.py` — add 'training_log' to INSTALLED_APPS
- **Modify:** `backend/wrestleDesk/urls.py` — include training_log URLs
### Frontend
- **Create:** `frontend/src/app/(dashboard)/training-log/page.tsx` — Main page with tabs
- **Modify:** `frontend/src/lib/api.ts` — Add ITrainingLogEntry, ITrainingLogStats interfaces
- **Modify:** `frontend/src/components/layout/sidebar.tsx` — Add Training Log nav link
---
## Tasks
### Task 1: Create Training Log Backend App
- [ ] **Step 1: Create training_log directory and files**
Create `backend/training_log/` with `__init__.py` and `apps.py`
- [ ] **Step 2: Add 'training_log' to INSTALLED_APPS in settings.py**
Add `'training_log'` to the INSTALLED_APPS list.
- [ ] **Step 3: Create TrainingLogEntry model**
```python
from django.db import models
from django.utils import timezone
class TrainingLogEntry(models.Model):
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='training_logs')
training = models.ForeignKey('trainings.Training', on_delete=models.SET_NULL, null=True, blank=True, related_name='training_logs')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_logs')
reps = models.PositiveIntegerField()
sets = models.PositiveIntegerField(default=1)
time_minutes = models.PositiveIntegerField(null=True, blank=True)
weight_kg = models.DecimalField(null=True, blank=True, max_digits=5, decimal_places=2)
rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
notes = models.TextField(blank=True)
logged_at = models.DateTimeField(default=timezone.now)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-logged_at']
indexes = [
models.Index(fields=['wrestler']),
models.Index(fields=['exercise']),
models.Index(fields=['logged_at']),
models.Index(fields=['training']),
]
def __str__(self):
return f"{self.wrestler} - {self.exercise.name} ({self.reps}x{self.sets})"
```
- [ ] **Step 4: Create serializers.py**
```python
from rest_framework import serializers
from .models import TrainingLogEntry
class TrainingLogEntrySerializer(serializers.ModelSerializer):
wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True)
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
training_date = serializers.DateField(source='training.date', read_only=True)
class Meta:
model = TrainingLogEntry
fields = [
'id', 'wrestler', 'wrestler_name', 'training', 'training_date',
'exercise', 'exercise_name', 'reps', 'sets', 'time_minutes',
'weight_kg', 'rating', 'notes', 'logged_at', 'created_at'
]
class TrainingLogStatsSerializer(serializers.Serializer):
total_entries = serializers.IntegerField()
unique_exercises = serializers.IntegerField()
total_reps = serializers.IntegerField()
avg_sets = serializers.FloatField()
avg_rating = serializers.FloatField()
this_week = serializers.IntegerField()
top_exercises = serializers.ListField(child=serializers.DictField())
progress = serializers.DictField()
```
- [ ] **Step 5: Create views.py**
```python
from rest_framework import viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Count, Avg, Sum, F
from django.db.models.functions import Coalesce
from datetime import datetime, timedelta
from .models import TrainingLogEntry
from .serializers import TrainingLogEntrySerializer
from wrestlers.models import Wrestler
class TrainingLogEntryViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TrainingLogEntrySerializer
def get_queryset(self):
queryset = TrainingLogEntry.objects.all()
wrestler = self.request.query_params.get('wrestler')
exercise = self.request.query_params.get('exercise')
date_from = self.request.query_params.get('date_from')
date_to = self.request.query_params.get('date_to')
if wrestler:
queryset = queryset.filter(wrestler=wrestler)
if exercise:
queryset = queryset.filter(exercise=exercise)
if date_from:
queryset = queryset.filter(logged_at__date__gte=date_from)
if date_to:
queryset = queryset.filter(logged_at__date__lte=date_to)
return queryset.select_related('wrestler', 'exercise', 'training')
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def training_log_stats(request):
wrestler_id = request.query_params.get('wrestler')
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
queryset = TrainingLogEntry.objects.all()
if wrestler_id:
queryset = queryset.filter(wrestler=wrestler_id)
total_entries = queryset.count()
unique_exercises = queryset.values('exercise').distinct().count()
total_reps = queryset.aggregate(total=Coalesce(Sum(F('reps') * F('sets')), 0))['total'] or 0
avg_sets = queryset.aggregate(avg=Avg('sets'))['avg'] or 0
avg_rating = queryset.aggregate(avg=Avg('rating'))['avg'] or 0
this_week = queryset.filter(logged_at__date__gte=week_start).count()
top_exercises = queryset.values('exercise__name').annotate(
count=Count('id')
).order_by('-count')[:5]
progress = {}
exercises = queryset.values('exercise', 'exercise__name').distinct()
for ex in exercises:
ex_id = ex['exercise']
entries = queryset.filter(exercise=ex_id).order_by('logged_at')
if entries.count() >= 2:
first_reps = entries.first().reps * entries.first().sets
last_reps = entries.last().reps * entries.last().sets
if first_reps > 0:
change = ((last_reps - first_reps) / first_reps) * 100
progress[ex['exercise__name']] = {
'before': first_reps,
'after': last_reps,
'change_percent': round(change, 1)
}
return Response({
'total_entries': total_entries,
'unique_exercises': unique_exercises,
'total_reps': total_reps,
'avg_sets': round(avg_sets, 1),
'avg_rating': round(avg_rating, 1),
'this_week': this_week,
'top_exercises': [{'name': e['exercise__name'], 'count': e['count']} for e in top_exercises],
'progress': progress,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def training_log_compare(request):
wrestler1_id = request.query_params.get('wrestler1')
wrestler2_id = request.query_params.get('wrestler2')
if not wrestler1_id or not wrestler2_id:
return Response({'error': 'Both wrestler1 and wrestler2 required'}, status=400)
wrestler1 = Wrestler.objects.get(id=wrestler1_id)
wrestler2 = Wrestler.objects.get(id=wrestler2_id)
entries1 = TrainingLogEntry.objects.filter(wrestler=wrestler1)
entries2 = TrainingLogEntry.objects.filter(wrestler=wrestler2)
exercises1 = entries1.values('exercise', 'exercise__name').distinct()
exercises2 = entries2.values('exercise', 'exercise__name').distinct()
common_exercises = set(e['exercise'] for e in exercises1) & set(e['exercise'] for e in exercises2)
comparison = []
for ex_id in common_exercises:
ex_name = entries1.filter(exercise=ex_id).first().exercise.name
avg1 = entries1.filter(exercise=ex_id).aggregate(avg=Avg(F('reps') * F('sets')))['avg'] or 0
avg2 = entries2.filter(exercise=ex_id).aggregate(avg=Avg(F('reps') * F('sets')))['avg'] or 0
comparison.append({
'exercise': ex_name,
'wrestler1_avg': round(avg1, 1),
'wrestler2_avg': round(avg2, 1)
})
return Response({
'wrestler1': {'id': wrestler1.id, 'name': str(wrestler1)},
'wrestler2': {'id': wrestler2.id, 'name': str(wrestler2)},
'exercises': comparison
})
```
- [ ] **Step 6: Create urls.py**
```python
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TrainingLogEntryViewSet, training_log_stats, training_log_compare
router = DefaultRouter()
router.register(r'', TrainingLogEntryViewSet, basename='training-log')
urlpatterns = [
path('stats/', training_log_stats, name='training-log-stats'),
path('compare/', training_log_compare, name='training-log-compare'),
path('', include(router.urls)),
]
```
- [ ] **Step 7: Add URL to main urls.py**
Add to `backend/wrestleDesk/urls.py`:
```python
path('api/v1/training-log/', include('training_log.urls')),
```
- [ ] **Step 8: Create migration**
Run: `cd backend && python manage.py makemigrations training_log`
---
### Task 2: Update Frontend API Types
- [ ] **Step 1: Add interfaces to frontend/src/lib/api.ts**
```typescript
export interface ITrainingLogEntry {
id: number
wrestler: number
wrestler_name: string
training: number | null
training_date: string | null
exercise: number
exercise_name: string
reps: number
sets: number
time_minutes: number | null
weight_kg: number | null
rating: number
notes: string
logged_at: string
created_at: string
}
export interface ITrainingLogStats {
total_entries: number
unique_exercises: number
total_reps: number
avg_sets: number
avg_rating: number
this_week: number
top_exercises: { name: string; count: number }[]
progress: Record<string, { before: number; after: number; change_percent: number }>
}
export interface ITrainingLogCompare {
wrestler1: { id: number; name: string }
wrestler2: { id: number; name: string }
exercises: { exercise: string; wrestler1_avg: number; wrestler2_avg: number }[]
}
```
---
### Task 3: Create Training Log Page
- [ ] **Step 1: Create frontend/src/app/(dashboard)/training-log/page.tsx**
Full page component with:
- Tab state (log | histrie | analyse)
- Log tab: Form with wrestler, training, exercise, reps, sets, time, weight, rating, notes
- Historie tab: Filter bar + table with entries
- Analyse tab: Stats summary, progress bars, wrestler comparison
```typescript
"use client"
import { useState, useEffect } from "react"
import { useAuth } from "@/lib/auth"
import { apiFetch, ITrainingLogEntry, ITrainingLogStats, ITrainingLogCompare, IWrestler, IExercise, ITraining, PaginatedResponse } from "@/lib/api"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { PageSkeleton } from "@/components/ui/skeletons"
import { FadeIn } from "@/components/ui/animations"
import { ClipboardList, History, BarChart3, Plus, Star, Loader2 } from "lucide-react"
type TabType = "log" | "historie" | "analyse"
export default function TrainingLogPage() {
const { token } = useAuth()
const [activeTab, setActiveTab] = useState<TabType>("log")
const [isLoading, setIsLoading] = useState(true)
// Data states
const [entries, setEntries] = useState<ITrainingLogEntry[]>([])
const [stats, setStats] = useState<ITrainingLogStats | null>(null)
const [wrestlers, setWrestlers] = useState<IWrestler[]>([])
const [exercises, setExercises] = useState<IExercise[]>([])
const [trainings, setTrainings] = useState<ITraining[]>([])
// Filter states
const [filterWrestler, setFilterWrestler] = useState<string>("")
const [filterExercise, setFilterExercise] = useState<string>("")
// Form state
const [formData, setFormData] = useState({
wrestler: "",
training: "",
exercise: "",
reps: "",
sets: "1",
time_minutes: "",
weight_kg: "",
rating: "3",
notes: ""
})
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!token) return
fetchData()
}, [token])
const fetchData = async () => {
setIsLoading(true)
try {
const [entriesRes, wrestlersRes, exercisesRes, trainingsRes] = await Promise.all([
apiFetch<PaginatedResponse<ITrainingLogEntry>>("/training-log/", { token }),
apiFetch<PaginatedResponse<IWrestler>>("/wrestlers/?page_size=100", { token }),
apiFetch<PaginatedResponse<IExercise>>("/exercises/?page_size=100", { token }),
apiFetch<PaginatedResponse<ITraining>>("/trainings/?page_size=100", { token }),
])
setEntries(entriesRes.results || [])
setWrestlers(wrestlersRes.results || [])
setExercises(exercisesRes.results || [])
setTrainings(trainingsRes.results || [])
} catch (err) {
console.error("Failed to fetch data:", err)
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.wrestler || !formData.exercise || !formData.reps) return
setIsSaving(true)
try {
await apiFetch("/training-log/", {
method: "POST",
token: token!,
body: JSON.stringify({
wrestler: parseInt(formData.wrestler),
training: formData.training ? parseInt(formData.training) : null,
exercise: parseInt(formData.exercise),
reps: parseInt(formData.reps),
sets: parseInt(formData.sets) || 1,
time_minutes: formData.time_minutes ? parseInt(formData.time_minutes) : null,
weight_kg: formData.weight_kg ? parseFloat(formData.weight_kg) : null,
rating: parseInt(formData.rating),
notes: formData.notes,
}),
})
toast.success("Eintrag gespeichert")
setFormData({ wrestler: "", training: "", exercise: "", reps: "", sets: "1", time_minutes: "", weight_kg: "", rating: "3", notes: "" })
fetchData()
} catch {
toast.error("Fehler beim Speichern")
} finally {
setIsSaving(false)
}
}
const filteredEntries = entries.filter(e => {
if (filterWrestler && e.wrestler !== parseInt(filterWrestler)) return false
if (filterExercise && e.exercise !== parseInt(filterExercise)) return false
return true
})
const tabs = [
{ id: "log" as TabType, label: "Log", icon: ClipboardList },
{ id: "historie" as TabType, label: "Historie", icon: History },
{ id: "analyse" as TabType, label: "Analyse", icon: BarChart3 },
]
if (isLoading) return <PageSkeleton />
return (
<div className="space-y-6">
<FadeIn>
<h1 className="text-2xl font-bold">Training Log</h1>
</FadeIn>
{/* Tabs */}
<FadeIn delay={0.05}>
<div className="flex gap-2 border-b pb-2">
{tabs.map(tab => (
<Button
key={tab.id}
variant={activeTab === tab.id ? "default" : "ghost"}
onClick={() => setActiveTab(tab.id)}
className="gap-2"
>
<tab.icon className="w-4 h-4" />
{tab.label}
</Button>
))}
</div>
</FadeIn>
{/* Log Tab */}
{activeTab === "log" && (
<FadeIn delay={0.1}>
<Card>
<CardHeader>
<CardTitle className="text-base">Neuer Eintrag</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium">Ringer *</label>
<Select value={formData.wrestler} onValueChange={v => setFormData({...formData, wrestler: v})}>
<SelectTrigger><SelectValue placeholder="Ringer wählen" /></SelectTrigger>
<SelectContent>
{wrestlers.map(w => (
<SelectItem key={w.id} value={String(w.id)}>{w.first_name} {w.last_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium">Training</label>
<Select value={formData.training} onValueChange={v => setFormData({...formData, training: v})}>
<SelectTrigger><SelectValue placeholder="Training (optional)" /></SelectTrigger>
<SelectContent>
{trainings.map(t => (
<SelectItem key={t.id} value={String(t.id)}>
{new Date(t.date).toLocaleDateString("de-DE")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium">Übung *</label>
<Select value={formData.exercise} onValueChange={v => setFormData({...formData, exercise: v})}>
<SelectTrigger><SelectValue placeholder="Übung wählen" /></SelectTrigger>
<SelectContent>
{exercises.map(e => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-sm font-medium">Reps *</label>
<Input type="number" value={formData.reps} onChange={e => setFormData({...formData, reps: e.target.value})} />
</div>
<div>
<label className="text-sm font-medium">Sets</label>
<Input type="number" value={formData.sets} onChange={e => setFormData({...formData, sets: e.target.value})} />
</div>
<div>
<label className="text-sm font-medium">Zeit (min)</label>
<Input type="number" value={formData.time_minutes} onChange={e => setFormData({...formData, time_minutes: e.target.value})} />
</div>
</div>
<div>
<label className="text-sm font-medium">Gewicht (kg)</label>
<Input type="number" step="0.5" value={formData.weight_kg} onChange={e => setFormData({...formData, weight_kg: e.target.value})} />
</div>
<div>
<label className="text-sm font-medium">Bewertung</label>
<div className="flex gap-1">
{[1,2,3,4,5].map(star => (
<button key={star} type="button" onClick={() => setFormData({...formData, rating: String(star)})}>
<Star className={`w-5 h-5 ${star <= parseInt(formData.rating) ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`} />
</button>
))}
</div>
</div>
<div className="md:col-span-2">
<label className="text-sm font-medium">Notizen</label>
<Textarea value={formData.notes} onChange={e => setFormData({...formData, notes: e.target.value})} />
</div>
<div className="md:col-span-2">
<Button type="submit" disabled={isSaving || !formData.wrestler || !formData.exercise || !formData.reps}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Speichern
</Button>
</div>
</form>
</CardContent>
</Card>
</FadeIn>
)}
{/* Historie Tab */}
{activeTab === "historie" && (
<FadeIn delay={0.1}>
<Card>
<CardHeader>
<CardTitle className="text-base">Eintragsverlauf</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4 flex-wrap">
<Select value={filterWrestler} onValueChange={setFilterWrestler}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Ringer" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Alle</SelectItem>
{wrestlers.map(w => (
<SelectItem key={w.id} value={String(w.id)}>{w.first_name} {w.last_name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterExercise} onValueChange={setFilterExercise}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Übung" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Alle</SelectItem>
{exercises.map(e => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="p-2 text-left">Datum</th>
<th className="p-2 text-left">Ringer</th>
<th className="p-2 text-left">Übung</th>
<th className="p-2 text-left">Reps×Sets</th>
<th className="p-2 text-left">Zeit</th>
<th className="p-2 text-left">Gewicht</th>
<th className="p-2 text-left">Bewertung</th>
</tr>
</thead>
<tbody>
{filteredEntries.map(entry => (
<tr key={entry.id} className="border-t">
<td className="p-2">{new Date(entry.logged_at).toLocaleDateString("de-DE")}</td>
<td className="p-2">{entry.wrestler_name}</td>
<td className="p-2">{entry.exercise_name}</td>
<td className="p-2">{entry.reps}×{entry.sets}</td>
<td className="p-2">{entry.time_minutes ? `${entry.time_minutes}min` : "-"}</td>
<td className="p-2">{entry.weight_kg ? `${entry.weight_kg}kg` : "-"}</td>
<td className="p-2">
<div className="flex">
{[1,2,3,4,5].map(s => (
<Star key={s} className={`w-3 h-3 ${s <= entry.rating ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`} />
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</FadeIn>
)}
{/* Analyse Tab */}
{activeTab === "analyse" && stats && (
<FadeIn delay={0.1}>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader><CardTitle className="text-base">Zusammenfassung</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between"><span>Gesamt:</span><span className="font-medium">{stats.total_entries}</span></div>
<div className="flex justify-between"><span>Übungen:</span><span className="font-medium">{stats.unique_exercises}</span></div>
<div className="flex justify-between"><span>Wiederholungen:</span><span className="font-medium">{stats.total_reps}</span></div>
<div className="flex justify-between"><span>Ø Sätze:</span><span className="font-medium">{stats.avg_sets}</span></div>
<div className="flex justify-between"><span>Ø Bewertung:</span><span className="font-medium">{stats.avg_rating}/5</span></div>
<div className="flex justify-between"><span>Diese Woche:</span><span className="font-medium">{stats.this_week}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Top Übungen</CardTitle></CardHeader>
<CardContent className="space-y-2">
{stats.top_exercises.map((ex, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-sm w-4">{i+1}.</span>
<span className="flex-1 text-sm">{ex.name}</span>
<Badge variant="secondary">{ex.count}x</Badge>
</div>
))}
</CardContent>
</Card>
</div>
</FadeIn>
)}
</div>
)
}
```
- [ ] **Step 2: Add navigation link to sidebar**
Add to `frontend/src/components/layout/sidebar.tsx`:
```typescript
{/* Training Log */}
<SidebarItem href="/training-log" icon={ClipboardList} label="Training Log" />
```
---
## Testing
### Backend
- Run: `cd backend && python manage.py check`
- Run: `python manage.py migrate`
- Test endpoint: `curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/training-log/`
### Frontend
- Run: `cd frontend && npm run lint`
- Visit: http://localhost:3000/training-log
---
## Notes
- Weight input uses `step="0.5"` for decimal support
- Training dropdown shows all trainings (could filter to past only)
- Rating displayed as filled/unfilled stars
- Entry form resets after successful submission
- Toast notifications require importing "sonner" toast
@@ -0,0 +1,165 @@
# View & Filter Persistence - 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:** Set calendar as default view on trainings page and ensure all filter settings persist across pages via backend preferences API
**Architecture:** Frontend-only changes - modify trainings/page.tsx to load/save viewMode from preferences API
**Tech Stack:** React, TypeScript, Next.js, API preferences
---
## Files Overview
| File | Purpose |
|------|---------|
| `frontend/src/app/(dashboard)/trainings/page.tsx` | Trainings page - add viewMode persistence |
| `frontend/src/app/(dashboard)/trainers/page.tsx` | Check trainers filter persistence |
| `frontend/src/app/(dashboard)/exercises/page.tsx` | Check exercises filter persistence |
---
## Task 1: Set Calendar as Default + View Persistence
**Files:**
- Modify: `frontend/src/app/(dashboard)/trainings/page.tsx`
**Changes:**
1. Load `trainings_view` from preferences API in `fetchPreferences`
2. Set initial `viewMode` state from saved preference (default: "calendar")
3. Save `trainings_view` when view changes
**Steps:**
- [ ] **Step 1: Update fetchPreferences to load trainings_view**
Find `fetchPreferences` function (~line 71-84) and add loading of `trainings_view`:
```typescript
const fetchPreferences = useCallback(async () => {
if (!token) return
try {
const prefs = await apiFetch<{trainings_view?: string; trainings_filters: Record<string, string>}>(`/auth/preferences/`, { token })
setFilters({
group: prefs.trainings_filters?.group || "all",
date_from: prefs.trainings_filters?.date_from || "",
date_to: prefs.trainings_filters?.date_to || "",
})
// NEW: Load view preference
if (prefs.trainings_view) {
setViewMode(prefs.trainings_view as "grid" | "list" | "calendar")
}
} catch {
console.error("Failed to fetch preferences")
}
}, [token])
```
- [ ] **Step 2: Create saveViewPreference function**
Add after `savePreferences` (~line 97):
```typescript
const saveViewPreference = useCallback(async (view: "grid" | "list" | "calendar") => {
if (!token) return
try {
await apiFetch(`/auth/preferences/`, {
method: "PATCH",
token,
body: JSON.stringify({ trainings_view: view }),
})
} catch {
console.error("Failed to save view preference")
}
}, [token])
```
- [ ] **Step 3: Add handleViewChange function**
Add after `handleResetFilters` (~line 110):
```typescript
const handleViewChange = (view: "grid" | "list" | "calendar") => {
setViewMode(view)
saveViewPreference(view)
}
```
- [ ] **Step 4: Update view buttons to use handleViewChange**
Find the view toggle buttons (~lines 309-325) and change:
```typescript
// Instead of: onClick={() => setViewMode("calendar")}
onClick={() => handleViewChange("calendar")}
```
- [ ] **Step 5: Test in browser**
- Navigate to /trainings
- Verify calendar view is shown by default
- Change to grid view, refresh page
- Verify grid view persists
---
## Task 2: Verify Trainer Page Filter Persistence
**Files:**
- Check: `frontend/src/app/(dashboard)/trainers/page.tsx`
**Steps:**
- [ ] **Step 1: Read trainers page to check filter persistence**
Find `fetchPreferences` and `savePreferences` functions
- [ ] **Step 2: If missing, add filter persistence**
If trainers page doesn't have filter persistence:
1. Add `trainers_filters` to preferences save/load
2. Add `handleFilterChange` that saves to backend
3. Load filters on mount
- [ ] **Step 3: Test trainer filters**
- Go to /trainers
- Apply a filter
- Refresh page
- Verify filter persists
---
## Task 3: Verify Exercise Page Filter Persistence
**Files:**
- Check: `frontend/src/app/(dashboard)/exercises/page.tsx`
**Steps:**
- [ ] **Step 1: Read exercises page to check filter persistence**
Find `fetchPreferences` and `savePreferences` functions
- [ ] **Step 2: If missing, add filter persistence**
If exercises page doesn't have filter persistence:
1. Add `exercises_filters` to preferences save/load
2. Add `handleFilterChange` that saves to backend
3. Load filters on mount
- [ ] **Step 3: Test exercise filters**
- Go to /exercises
- Apply a filter
- Refresh page
- Verify filter persists
---
## Verification Checklist
After all tasks:
- [ ] Trainings page shows calendar view by default
- [ ] Trainings view mode persists after refresh
- [ ] Trainings filter (group, date) persists after refresh
- [ ] Trainer filters persist after refresh
- [ ] Exercise filters persist after refresh
- [ ] No console errors
@@ -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.
@@ -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/`
@@ -0,0 +1,469 @@
# Leistungstest Statistiken 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 statistics and leaderboards to Leistungstest with rankings by template and by exercise, filterable by time period.
**Architecture:**
- Backend: New ViewSet with leaderboard endpoints in `leistungstest/stats.py` and `leistungstest/views.py`
- Frontend: New "📊 Statistiken" tab in Leistungstest page with template/exercise toggle and period filter
**Tech Stack:** Django REST Framework, React/Next.js, Tailwind CSS
---
## File Structure
### Backend
- Create: `backend/leistungstest/stats.py` — Leaderboard calculation logic
- Modify: `backend/leistungstest/views.py` — Add stats ViewSet
- Modify: `backend/leistungstest/urls.py` — Add stats URLs
- Modify: `frontend/src/lib/api.ts` — Add TypeScript interfaces
### Frontend
- Modify: `frontend/src/app/(dashboard)/leistungstest/page.tsx` — Add Statistiken tab
---
## Backend Tasks
### Task 1: Create stats calculation module
**Files:**
- Create: `backend/leistungstest/stats.py`
```python
from datetime import date, timedelta
from django.db.models import Min, Avg, Count
def get_date_range(period):
"""Return start date for period filter, or None for 'all'."""
today = date.today()
if period == "month":
return today.replace(day=1)
elif period == "3months":
return today - timedelta(days=90)
elif period == "year":
return today.replace(month=1, day=1)
return None
def get_template_leaderboard(template_id, period="all", limit=10):
"""Return top wrestlers by score_percent for a template."""
from .models import LeistungstestResult
qs = LeistungstestResult.objects.filter(template_id=template_id)
start_date = get_date_range(period)
if start_date:
qs = qs.filter(completed_at__date__gte=start_date)
qs = qs.select_related('wrestler').order_by('-score_percent', 'total_time_seconds')
results = []
for rank, result in enumerate(qs[:limit], 1):
results.append({
'rank': rank,
'wrestler_id': result.wrestler_id,
'wrestler_name': str(result.wrestler),
'score_percent': result.score_percent,
'total_time_seconds': result.total_time_seconds,
'completed_at': result.completed_at.date().isoformat() if result.completed_at else None,
})
return results
def get_exercise_leaderboard(exercise_id, period="all", limit=10):
"""Return top wrestlers by best time for an exercise."""
from .models import LeistungstestResultItem, LeistungstestResult
from django.db.models import Min
start_date = get_date_range(period)
qs = LeistungstestResultItem.objects.filter(exercise_id=exercise_id)
if start_date:
qs = qs.filter(result__completed_at__date__gte=start_date)
# Get best time per wrestler
best_times = qs.values('result__wrestler__id', 'result__wrestler__first_name', 'result__wrestler__last_name', 'result__completed_at__date')\
.annotate(best_time=Min('elapsed_seconds'))\
.order_by('best_time')
results = []
for rank, item in enumerate(best_times[:limit], 1):
wrestler_name = f"{item['result__wrestler__first_name']} {item['result__wrestler__last_name']}"
results.append({
'rank': rank,
'wrestler_id': item['result__wrestler__id'],
'wrestler_name': wrestler_name.strip(),
'best_time_seconds': item['best_time'],
'completed_at': item['result__completed_at__date'].isoformat() if item['result__completed_at__date'] else None,
})
return results
def get_used_exercises():
"""Return all exercises that have been used in any Leistungstest result."""
from .models import LeistungstestResultItem
from exercises.models import Exercise
exercise_ids = LeistungstestResultItem.objects.values_list('exercise_id', flat=True).distinct()
return Exercise.objects.filter(id__in=exercise_ids).order_by('name')
```
- [ ] **Step 1: Create stats.py with functions**
- [ ] **Step 2: Test stats functions in Django shell**
- [ ] **Step 3: Commit**
---
### Task 2: Add stats ViewSet and URLs
**Files:**
- Modify: `backend/leistungstest/views.py` — Add `LeistungstestStatsViewSet`
- Modify: `backend/leistungstest/urls.py` — Add stats routes
```python
# In views.py, add:
class LeistungstestStatsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def leaderboard(self, request):
lb_type = request.query_params.get('type', 'template')
template_id = request.query_params.get('template_id')
exercise_id = request.query_params.get('exercise_id')
period = request.query_params.get('period', 'all')
limit = int(request.query_params.get('limit', 10))
if lb_type == 'template' and template_id:
results = get_template_leaderboard(int(template_id), period, limit)
template = LeistungstestTemplate.objects.get(pk=template_id)
return Response({
'template_id': template_id,
'template_name': template.name,
'period': period,
'results': results,
})
elif lb_type == 'exercise' and exercise_id:
results = get_exercise_leaderboard(int(exercise_id), period, limit)
exercise = Exercise.objects.get(pk=exercise_id)
return Response({
'exercise_id': exercise_id,
'exercise_name': exercise.name,
'period': period,
'results': results,
})
return Response({'error': 'Invalid parameters'}, status=400)
def exercises(self, request):
exercises = get_used_exercises()
return Response([{'id': e.id, 'name': e.name} for e in exercises])
```
```python
# In urls.py, add:
from .views import LeistungstestStatsViewSet
router.register(r'stats', LeistungstestStatsViewSet, basename='leistungstest-stats')
```
- [ ] **Step 1: Add ViewSet to views.py**
- [ ] **Step 2: Add URLs to urls.py**
- [ ] **Step 3: Test endpoint with curl:**
```bash
curl -H "Authorization: Bearer $TOKEN" "http://localhost:8000/api/v1/leistungstest/stats/leaderboard/?type=template&template_id=1"
```
- [ ] **Step 4: Commit**
---
## Frontend Tasks
### Task 3: Add TypeScript interfaces
**Files:**
- Modify: `frontend/src/lib/api.ts` — Add interfaces
```typescript
export interface ILeaderboardEntry {
rank: number
wrestler_id: number
wrestler_name: string
score_percent?: number
total_time_seconds?: number
best_time_seconds?: number
completed_at: string | null
}
export interface ITemplateLeaderboard {
template_id: number
template_name: string
period: string
results: ILeaderboardEntry[]
}
export interface IExerciseLeaderboard {
exercise_id: number
exercise_name: string
period: string
results: ILeaderboardEntry[]
}
export interface IExerciseOption {
id: number
name: string
}
```
- [ ] **Step 1: Add interfaces to api.ts**
- [ ] **Step 2: Commit**
---
### Task 4: Add Statistiken tab to Leistungstest page
**Files:**
- Modify: `frontend/src/app/(dashboard)/leistungstest/page.tsx`
Add new tab type:
```typescript
type TabType = "vorlagen" | "zuweisen" | "ergebnisse" | "statistiken"
```
Add state for stats:
```typescript
const [statsViewMode, setStatsViewMode] = useState<"template" | "exercise">("template")
const [statsPeriod, setStatsPeriod] = useState<"all" | "month" | "3months" | "year">("all")
const [statsTemplateId, setStatsTemplateId] = useState<string>("")
const [statsExerciseId, setStatsExerciseId] = useState<string>("")
const [templateLeaderboard, setTemplateLeaderboard] = useState<ITemplateLeaderboard | null>(null)
const [exerciseLeaderboard, setExerciseLeaderboard] = useState<IExerciseLeaderboard | null>(null)
const [exerciseOptions, setExerciseOptions] = useState<IExerciseOption[]>([])
```
Add fetch function:
```typescript
const fetchTemplateLeaderboard = async () => {
if (!statsTemplateId) return
try {
const data = await apiFetch<ITemplateLeaderboard>(
`/leistungstest/stats/leaderboard/?type=template&template_id=${statsTemplateId}&period=${statsPeriod}`,
{ token: token! }
)
setTemplateLeaderboard(data)
} catch (err) {
console.error("Failed to fetch leaderboard:", err)
}
}
const fetchExerciseLeaderboard = async () => {
if (!statsExerciseId) return
try {
const data = await apiFetch<IExerciseLeaderboard>(
`/leistungstest/stats/leaderboard/?type=exercise&exercise_id=${statsExerciseId}&period=${statsPeriod}`,
{ token: token! }
)
setExerciseLeaderboard(data)
} catch (err) {
console.error("Failed to fetch leaderboard:", err)
}
}
const fetchExerciseOptions = async () => {
try {
const data = await apiFetch<IExerciseOption[]>(`/leistungstest/stats/exercises/`, { token: token! })
setExerciseOptions(data)
} catch (err) {
console.error("Failed to fetch exercises:", err)
}
}
```
Add useEffect:
```typescript
useEffect(() => {
if (activeTab === "statistiken") {
fetchExerciseOptions()
}
}, [activeTab])
useEffect(() => {
if (activeTab === "statistiken" && statsViewMode === "template" && statsTemplateId) {
fetchTemplateLeaderboard()
}
}, [activeTab, statsViewMode, statsTemplateId, statsPeriod])
useEffect(() => {
if (activeTab === "statistiken" && statsViewMode === "exercise" && statsExerciseId) {
fetchExerciseLeaderboard()
}
}, [activeTab, statsViewMode, statsExerciseId, statsPeriod])
```
Add tab button in header:
```tsx
<button
onClick={() => setActiveTab("statistiken")}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === "statistiken" ? "text-primary border-b-2 border-primary" : "text-muted-foreground"
}`}
>
📊 Statistiken
</button>
```
Add Statistiken tab content (new card after ergebnisse tab):
```tsx
{activeTab === "statistiken" && (
<motion.div
key="statistiken"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<Card>
<CardHeader>
<div className="flex justify-between items-center gap-4 flex-wrap">
<CardTitle className="text-base">📊 Statistiken</CardTitle>
<div className="flex items-center gap-4">
<Select value={statsViewMode} onValueChange={v => setStatsViewMode(v as "template" | "exercise")}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="template">Nach Vorlage</SelectItem>
<SelectItem value="exercise">Nach Übung</SelectItem>
</SelectContent>
</Select>
{statsViewMode === "template" ? (
<Select value={statsTemplateId} onValueChange={setStatsTemplateId}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Vorlage wählen" />
</SelectTrigger>
<SelectContent>
{templates.map(t => (
<SelectItem key={t.id} value={String(t.id)}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select value={statsExerciseId} onValueChange={setStatsExerciseId}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Übung wählen" />
</SelectTrigger>
<SelectContent>
{exerciseOptions.map(e => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={statsPeriod} onValueChange={v => setStatsPeriod(v as any)}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Zeiten</SelectItem>
<SelectItem value="month">Dieser Monat</SelectItem>
<SelectItem value="3months">Letzte 3 Monate</SelectItem>
<SelectItem value="year">Dieses Jahr</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{statsViewMode === "template" ? (
!statsTemplateId ? (
<p className="text-sm text-muted-foreground">Wähle eine Vorlage aus</p>
) : templateLeaderboard?.results.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Ergebnisse für diese Vorlage</p>
) : (
<div className="space-y-3">
<h3 className="font-semibold">{templateLeaderboard?.template_name}</h3>
{templateLeaderboard?.results.map(entry => (
<div key={entry.wrestler_id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${
entry.rank === 1 ? "bg-yellow-100 text-yellow-700" :
entry.rank === 2 ? "bg-gray-100 text-gray-700" :
entry.rank === 3 ? "bg-orange-100 text-orange-700" :
"bg-slate-100 text-slate-700"
}`}>
{entry.rank <= 3 ? ["🥇","🥈","🥉"][entry.rank-1] : entry.rank}
</div>
<div className="flex-1">
<div className="font-medium">{entry.wrestler_name}</div>
<div className="text-xs text-muted-foreground">{entry.completed_at}</div>
</div>
<div className="text-right">
<div className="font-bold text-lg">{entry.score_percent}%</div>
<div className="text-xs text-muted-foreground">
{entry.total_time_seconds != null ? `${Math.floor(entry.total_time_seconds / 60)}:${String(entry.total_time_seconds % 60).padStart(2, "0")}` : "-"}
</div>
</div>
</div>
))}
</div>
)
) : (
!statsExerciseId ? (
<p className="text-sm text-muted-foreground">Wähle eine Übung aus</p>
) : exerciseLeaderboard?.results.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Ergebnisse für diese Übung</p>
) : (
<div className="space-y-3">
<h3 className="font-semibold">{exerciseLeaderboard?.exercise_name}</h3>
{exerciseLeaderboard?.results.map(entry => (
<div key={entry.wrestler_id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${
entry.rank === 1 ? "bg-yellow-100 text-yellow-700" :
entry.rank === 2 ? "bg-gray-100 text-gray-700" :
entry.rank === 3 ? "bg-orange-100 text-orange-700" :
"bg-slate-100 text-slate-700"
}`}>
{entry.rank <= 3 ? ["🥇","🥈","🥉"][entry.rank-1] : entry.rank}
</div>
<div className="flex-1">
<div className="font-medium">{entry.wrestler_name}</div>
<div className="text-xs text-muted-foreground">{entry.completed_at}</div>
</div>
<div className="text-right">
<div className="font-bold text-lg">
{entry.best_time_seconds != null ? formatTime(entry.best_time_seconds) : "-"}
</div>
<div className="text-xs text-muted-foreground">beste Zeit</div>
</div>
</div>
))}
</div>
)
)}
</CardContent>
</Card>
</motion.div>
)}
```
- [ ] **Step 1: Add tab type and state**
- [ ] **Step 2: Add fetch functions**
- [ ] **Step 3: Add tab button**
- [ ] **Step 4: Add Statistiken tab content**
- [ ] **Step 5: Test in browser**
- [ ] **Step 6: Build to verify**
- [ ] **Step 7: Commit**
---
## Verification
1. Backend test:
```bash
cd backend && python3 manage.py runserver
curl -H "Authorization: Bearer $TOKEN" "http://localhost:8000/api/v1/leistungstest/stats/exercises/"
curl -H "Authorization: Bearer $TOKEN" "http://localhost:8000/api/v1/leistungstest/stats/leaderboard/?type=template&template_id=1&period=all"
```
2. Frontend test:
```bash
cd frontend && npm run build
```
@@ -0,0 +1,154 @@
# Homework System Integration - Design Spec
## Overview
The backend has a complete homework system with exercises, assignments, and completion tracking. The frontend currently only supports basic CRUD. This spec covers integrating all backend features into the frontend.
---
## Backend Models
1. **Homework** - Template with title, description, due_date, exercises
2. **HomeworkExerciseItem** - Links exercises to homework with reps/time
3. **HomeworkAssignment** - Assigns homework to wrestlers
4. **HomeworkAssignmentItem** - Tracks completion of each exercise
5. **HomeworkStatus** - Overall completion status
---
## Frontend Integration
### 1. Update Types (lib/api.ts)
Add missing types:
```typescript
interface IHomeworkExerciseItem {
id: number
exercise: number
exercise_name: string
reps: number | null
time_minutes: number | null
order: number
}
interface IHomework {
id: number
title: string
description: string
club: number
club_name: string
due_date: string
is_active: boolean
exercise_items: IHomeworkExerciseItem[]
exercise_count: number
created_at: string
updated_at: string
}
interface IHomeworkAssignment {
id: number
homework: number
homework_title: string
wrestler: number
wrestler_name: string
club: number
club_name: string
due_date: string
notes: string
is_completed: boolean
completion_date: string | null
items: IHomeworkAssignmentItem[]
created_at: string
}
interface IHomeworkAssignmentItem {
id: number
exercise: number
exercise_name: string
is_completed: boolean
completion_date: string | null
}
```
### 2. Homework Page - Template Management
**Exercise Selection:**
- When creating/editing homework, allow selecting exercises from the exercise list
- Each exercise can have reps (for rep-based) or time_minutes (for time-based)
- Drag to reorder exercises
**API Integration:**
- `GET /homework/{id}/exercise-items/` - Get exercises for homework
- `POST /homework/{id}/exercise-items/` - Add exercise to homework
- `DELETE /homework/{id}/exercise-items/{item_id}/` - Remove exercise
### 3. Assignments Page - View Assigned Homework
Create a new tab/section for viewing assignments:
- `GET /homework/assignments/` - List all assignments
- Filter by: homework, wrestler, completion status
- Show completion progress (e.g., "3/5 exercises completed")
**Assignment Detail:**
- View all exercises in the assignment
- Mark individual exercises as complete
- `POST /homework/assignments/{id}/complete-item/` - Mark exercise complete
### 4. Update Distribute Flow
Current: Select wrestlers, assign homework
New:
1. Select wrestlers (filtered by club + groups)
2. Due date is set per assignment
3. Creates HomeworkAssignment with all exercise items
---
## UI Components
### Homework Templates Tab
- List all homework templates
- Create/Edit with exercise selection
- Delete template
### My Homework Tab (for wrestlers)
- View assigned homework
- Track completion per exercise
- Due dates
### Assignment Management Tab (for trainers)
- View all assignments
- Filter by status (pending/completed/overdue)
- Track which wrestlers completed
---
## Data Flow
1. Trainer creates Homework template with exercises
2. Trainer assigns homework to wrestlers (filtered by group)
3. System creates HomeworkAssignment for each wrestler
4. Wrestlers see assignments and mark exercises complete
5. Trainers can track completion rates
---
## Technical Approach
**Frontend Pages:**
- `/homework` - Templates list
- `/homework/assignments` - Assignments list (new)
- `/homework/[id]/edit` - Edit template with exercises
**Key Changes:**
- Update IHomework type with exercise_items
- Add exercise selection UI to homework form
- Create assignments list view
- Add completion tracking
**API Endpoints Used:**
- `GET/POST /homework/` - CRUD templates
- `GET/POST /homework/{id}/exercise-items/` - Manage exercises
- `POST /homework/{id}/assign/` - Assign to wrestlers
- `GET /homework/assignments/` - List assignments
- `POST /homework/assignments/{id}/complete-item/` - Mark complete
@@ -0,0 +1,303 @@
# Homework System Redesign - Spezifikation
## Überblick
Komplette Neugestaltung des Homework-Systems ohne Vorlagen. Homework wird direkt von der Training-Seite aus Assignees zugewiesen.
## Konzept
**Workflow:**
1. Trainer ist auf Training-Detail-Seite
2. Bei jedem Teilnehmer gibt es einen Homework-Button (Icon)
3. Klick öffnet Modal: Exercises auswählen (mit reps/time)
4. Homework wird dem Wrestler für dieses Training zugewiesen
5. Auf der Homework-Seite sieht man alle Zuweisungen gruppiert nach Training
**Keine Templates** - Exercises werden direkt zugewiesen, keine wiederverwendbaren Vorlagen.
---
## Datenmodell
### Neues Model: `TrainingHomework`
Verknüpft ein Training mit Exercises.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | AutoField | Primary Key |
| `training` | FK → Training | Das Training |
| `created_at` | DateTime | Auto |
### Neues Model: `TrainingHomeworkAssignment`
Welcher Wrestler welche Homework eines Trainings bekommen hat.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | AutoField | Primary Key |
| `training_homework` | FK → TrainingHomework | Die Homework |
| `wrestler` | FK → Wrestler | Der Wrestler |
| `club` | FK → Club | Club (für Filter) |
| `notes` | TextField | Optionale Notizen |
| `created_at` | DateTime | Auto |
### Junction Table: `TrainingHomeworkExercise`
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | AutoField | Primary Key |
| `training_homework` | FK → TrainingHomework | Die Homework |
| `exercise` | FK → Exercise | Die Übung |
| `reps` | PositiveInteger | Wiederholungen |
| `time_minutes` | PositiveInteger | Zeit in Minuten |
| `order` | Integer | Sortierung |
---
## API Endpoints
### Backend
```
GET /homework/training-assignments/ - Alle Zuweisungen für Club (gefiltert)
POST /homework/training-assignments/ - Neue Zuweisung erstellen
GET /homework/training-assignments/{id}/ - Detail
PATCH /homework/training-assignments/{id}/ - Aktualisieren (notizen, etc.)
DELETE /homework/training-assignments/{id}/ - Löschen
POST /homework/training-assignments/{id}/complete/ - Als erledigt markieren
POST /homework/training-assignments/{id}/uncomplete/ - Als nicht erledigt markieren
```
### Frontend Types
```typescript
interface ITrainingHomework {
id: number
training: number
training_date: string
training_group: string
exercises: ITrainingHomeworkExercise[]
created_at: string
}
interface ITrainingHomeworkExercise {
id: number
exercise: number
exercise_name: string
exercise_category: string
reps: number | null
time_minutes: number | null
order: number
}
interface ITrainingHomeworkAssignment {
id: number
training_homework: number
training_homework_detail: ITrainingHomework
wrestler: number
wrestler_name: string
wrestler_group: string
is_completed: boolean
completion_date: string | null
notes: string
created_at: string
}
```
---
## UI: Training Detail Seite
### Layout (oben nach unten)
```
┌─────────────────────────────────────────────────────────────┐
│ [← Zurück] Training vom 22.03.2026 [Kinder Badge]│
├─────────────────────────────────────────────────────────────┤
│ Datum: Montag, 22. März 2026 │
│ Zeit: 17:00 - 18:30 │
│ Ort: Sporthalle │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ TEILNEHMER (8) │ │ TRAININGSHOMEWORK │ │
│ │ │ │ │ │
│ │ ┌─────────────────┐ │ │ Hier kommen die │ │
│ │ │ 🧒 Max M. 📚 │ │ │ Homework-Übungen │ │
│ │ │ Club A │ │ │ für dieses Training │ │
│ │ └─────────────────┘ │ │ rein wenn jemand │ │
│ │ ┌─────────────────┐ │ │ Homework hat. │ │
│ │ │ 🧒 Anna S. 📚 │ │ │ │ │
│ │ │ Club B │ │ │ │ │
│ │ └─────────────────┘ │ │ │ │
│ │ │ │ │ │
│ │ [+ Ringer hinzu] │ │ │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Teilnehmer-Karte
- Liste der Teilnehmer mit Avatar
- Bei jedem Teilnehmer rechts:
- **Homework-Button** (Buch-Icon) → öffnet Modal
- **X-Button** → entfernen
- "Ringer hinzufügen" Button unter der Liste
### Homework-Button Modal
```
┌─────────────────────────────────────────────────────────────┐
│ 📚 Hausaufgabe zuweisen - Max Mustermann │
├─────────────────────────────────────────────────────────────┤
│ │
│ Übungen auswählen: │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ [🔍] Übungen durchsuchen... ││
│ ├─────────────────────────────────────────────────────────┤│
│ │ ○ Liegestütze (Kraft) [10] Reps ││
│ │ ○ Sit-ups (Kraft) [30] Sek ││
│ │ ○ Ausfallschritte (Technik) [ ] Reps [60] Sek ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Ausgewählte Übungen: │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 1. Liegestütze - 10 Wiederholungen [✕] ││
│ │ 2. Sit-ups - 30 Sekunden [✕] ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Notizen (optional): │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ││
│ └─────────────────────────────────────────────────────────┘│
│ │
├─────────────────────────────────────────────────────────────┤
│ [Abbrechen] [Zuweisen] │
└─────────────────────────────────────────────────────────────┘
```
---
## UI: Hausaufgaben Seite
### Layout
```
┌─────────────────────────────────────────────────────────────┐
│ 📚 Hausaufgaben │
├─────────────────────────────────────────────────────────────┤
│ │
│ Filter: [Alle] [Offen] [Erledigt] [🔍 Suche...] │
│ │
│ Training: 22.03.2026 (Kinder) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ┌─────────────────────────────────────────────────────┐ ││
│ │ │ 🧒 Max Mustermann [Erledigt ✓] │ ││
│ │ │ Liegestütze (10), Sit-ups (30s), ... │ ││
│ │ │ Zugewiesen: vor 2 Tagen │ ││
│ │ └─────────────────────────────────────────────────────┘ ││
│ │ ┌─────────────────────────────────────────────────────┐ ││
│ │ │ 🧒 Anna Schmidt [Offen] │ ││
│ │ │ Liegestütze (10), Sit-ups (30s), ... │ ││
│ │ │ Zugewiesen: vor 2 Tagen │ ││
│ │ └─────────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Training: 20.03.2026 (Jugend) │
│ ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Assignment Card
- Wrestler Name + Avatar
- Status Badge: "Offen" (orange) oder "Erledigt" (grün)
- Liste der Exercises mit reps/time
- "Erledigt" Button zum Markieren
- Click auf Card → expandiert Details
---
## UI: Dashboard
Zeigt offene Homework-Anzahl:
```
┌─────────────────────────────────────────────────────────────┐
│ 📊 Dashboard │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 🧒 24 │ │ 👨‍🏫 8 │ │ 📅 12 │ │ 📚 5 │ │
│ │ Ringer │ │ Trainer │ │ Training │ │ Offene │ │
│ │ │ │ │ │ diese Wo │ │ Homework│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Farben & Styling
- Status "Offen": `bg-warning/10 text-warning`
- Status "Erledigt": `bg-success/10 text-success`
- Homework Icon: `BookOpen` von Lucide
---
## Technische Umsetzung
### Backend
1. **Neue Models** in `backend/homework/models.py`:
- `TrainingHomework`
- `TrainingHomeworkExercise` (Junction)
- `TrainingHomeworkAssignment`
2. **Serializers** in `backend/homework/serializers.py`:
- `TrainingHomeworkSerializer`
- `TrainingHomeworkExerciseSerializer`
- `TrainingHomeworkAssignmentSerializer`
3. **Views** in `backend/homework/views.py`:
- `TrainingHomeworkAssignmentViewSet` mit `/training-assignments/` endpoint
- `complete` und `uncomplete` actions
4. **URLs** in `backend/homework/urls.py`:
- `training-assignments/` routes
5. **Admin** in `backend/homework/admin.py`:
- Neue Models registrieren
### Frontend
1. **Types** in `frontend/src/lib/api.ts`:
- `ITrainingHomework`
- `ITrainingHomeworkExercise`
- `ITrainingHomeworkAssignment`
2. **Training Detail Page** `frontend/src/app/(dashboard)/trainings/[id]/page.tsx`:
- Layout: Details oben, dann 2-Spalten (Teilnehmer | TrainingHomework)
- Teilnehmer-Karte mit Homework-Button
- Modal für Homework-Zuweisung
3. **Homework Page** `frontend/src/app/(dashboard)/homework/page.tsx`:
- Komplett neu schreiben
- Gruppiert nach Training
- Status-Filter
- Erledigt/Undone togglen
4. **Dashboard** `frontend/src/app/(dashboard)/dashboard/page.tsx`:
- Homework-Count hinzufügen
---
## Zu löschende alte Components
- Alte `Homework`, `HomeworkExerciseItem`, `HomeworkAssignment`, `HomeworkAssignmentItem`, `HomeworkStatus` Models optional behalten (für Migration) aber nicht mehr nutzen
- Alte Frontend-Homework-Logik entfernen
@@ -0,0 +1,123 @@
# Trainings Calendar View - Design Spec
## Overview
Add a calendar month view to the Trainings page as an alternative to the existing grid/list views. Users can toggle between three views: **Grid**, **List**, and **Calendar**.
---
## Design
### View Toggle
Location: Top-right of the page header (already exists as grid/list toggle)
```
[+ Training hinzufügen] [Grid] [List] [Calendar]
```
Three icon buttons with active state highlighting. Same pattern as existing grid/list toggle.
---
### Calendar Month View
**Library:** `react-big-calendar` with custom styling to match Shadcn design
**Layout:**
- Full-width month grid
- Weekdays: Mo Di Mi Do Fr Sa So (German)
- Month/Year header with `<` `>` navigation arrows
- "Today" button to jump to current month
**Day Cells:**
- Show up to 3 training chips per day (colored by group)
- "+N more" indicator if more trainings exist
- Today highlighted with accent ring
- Past days slightly muted
**Training Chips:**
- Small colored pills showing time + group badge
- Colors match existing groupConfig:
- kids: primary
- youth: secondary
- adults: accent
**Click on Day:**
- Opens a popover/dropdown showing all trainings for that day
- Each training shows: time, group, location, attendance count
- Click training to open detail page or edit modal
**Click on Training Chip:**
- Opens training detail modal directly
---
## Data Loading
- Fetch trainings for the displayed month (with buffer for partial weeks)
- `date_from` / `date_to` params sent to API to get relevant trainings
- Cache fetched month data to avoid re-fetching on day navigation within same month
---
## Component Structure
```
TrainingsPage/
├── TrainingsContent (main container)
│ ├── Header with toggle buttons
│ ├── FilterBar (existing)
│ ├── ViewContainer
│ │ ├── GridView (existing)
│ │ ├── ListView (existing)
│ │ └── CalendarView (NEW)
│ │ ├── CalendarHeader (month nav)
│ │ ├── CalendarGrid
│ │ └── DayPopover (trainings for selected day)
│ └── Pagination (shown in grid/list, hidden in calendar)
```
---
## Technical Approach
**Dependencies:**
- `react-big-calendar` - Calendar component
- `date-fns` - Date manipulation (already available via project or to be added)
**Styling:**
- Custom CSS matching Shadcn theme colors
- Override default react-big-calendar styles
**State:**
- `viewMode: "grid" | "list" | "calendar"` (extend existing)
- `calendarMonth: Date` - currently displayed month
- `selectedDay: Date | null` - for day popover
**API:**
- Keep existing `/trainings/?date_from=X&date_to=Y` to fetch trainings
- Calculate date range from calendar month view
---
## Interactions
| Action | Result |
|--------|--------|
| Click calendar icon in toggle | Switch to calendar view |
| Click `<` / `>` arrows | Navigate months |
| Click "Today" | Jump to current month |
| Click on day cell | Show popover with day's trainings |
| Click on training chip | Open training detail modal |
| Switch away from calendar | Preserve last viewed month |
| Create/edit training | Refresh calendar data |
---
## Polish
- Smooth transitions when switching views
- Loading skeleton for calendar while fetching
- Empty state for days with no trainings
- Responsive: on mobile, calendar shows in portrait mode with smaller cells
@@ -0,0 +1,156 @@
# Dashboard Statistics Design
## Date: 2026-03-23
## Status: Approved
## Overview
Expand the Dashboard with comprehensive statistics displayed in a Bento Grid layout. Replace the current simple count cards with rich stat cards and add new visualization cards for attendance, homework completion, wrestler distribution, and trainer activity.
## Design
### Layout Structure
```
┌─────────────────────────────────────────────────────────────────┐
│ Dashboard Willkommen, [User] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [Stat Cards Row - 4 cards] │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Ringer │ │Trainer │ │Training│ │Hausauf.│ │
│ │ 127 │ │ 12 │ │ 45 │ │ 23 │ │
│ │+5/woche│ │Aktiv:10│ │diese: 8│ │offen │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ [Middle Row - 2 cards] │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Teilnahme diese Woche │ │ Training Aktivität │ │
│ │ ● Kinder ████████░░ 85% │ │ ▁▂▃▅▇██▇▅▃▂▁▂▃▅▇ │ │
│ │ ● Jugend ██████░░░░ 62% │ │ Letzte 14 Tage │ │
│ │ ● Erwachs.████████ 100% │ └─────────────────────────┘ │
│ │ Ø: 24/30 │ │
│ └─────────────────────────┘ │
│ │
│ [Full Width Card] │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ ✅ Hausaufgaben Erledigung ││
│ │ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 38% ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ [Bottom Row - 2 cards] │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Ringer nach Gruppe │ │ Top Trainer │ │
│ │ ●Kinder ████████ 45│ │ 1. Max M. 12 │ │
│ │ ●Jugend ██████░░ 35│ │ 2. Anna S. 10 │ │
│ │ ●Erwachs. █████░░░ 25│ │ 3. Tom K. 9 │ │
│ │ ●Inaktiv ███░░░░░ 22│ │ │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Components
#### 1. Stat Card (existing, enhanced)
- Icon + Label (top-left)
- Main number (large, bold)
- Sub-info (small, muted)
- Hover: subtle lift effect
#### 2. Attendance Card
- Title: "Teilnahme diese Woche"
- 3 rows (Kids, Youth, Adults) with progress bars
- Progress bar: colored background with filled portion
- Percentage and absolute numbers (e.g., "24/30")
- Average attendance line at bottom
#### 3. Training Activity Card
- Title: "Training Aktivität"
- Simple bar chart visualization using divs
- 14 bars representing last 14 days
- Bar height proportional to attendance
- Label: "Letzte 14 Tage"
#### 4. Homework Completion Card (full width)
- Title: "Hausaufgaben Erledigung"
- Single horizontal progress bar
- Green fill for completed, gray for open
- Percentage and absolute numbers on right
- Trend indicator: "+5 diese Woche"
#### 5. Wrestlers by Group Card
- Title: "Ringer nach Gruppe"
- 4 rows with progress bars
- Groups: Kinder, Jugend, Erwachsene, Inaktiv
- Each row: colored dot, label, progress bar, count
#### 6. Top Trainers Card
- Title: "Top Trainer"
- List of 3-5 trainers with:
- Rank number
- Trainer name
- Training count
- Simple list, no avatars needed
### Data Requirements
**Backend API Endpoints needed:**
- `GET /api/v1/stats/dashboard/` - Returns all dashboard statistics
**Response shape:**
```json
{
"wrestlers": { "total": 127, "this_week": 5 },
"trainers": { "total": 12, "active": 10 },
"trainings": { "total": 45, "this_week": 8 },
"homework": { "open": 23, "completed": 38 },
"attendance": {
"this_week": {
"kids": { "attended": 17, "total": 20, "percent": 85 },
"youth": { "attended": 12, "total": 20, "percent": 62 },
"adults": { "attended": 20, "total": 20, "percent": 100 }
},
"average": 24,
"expected": 30
},
"activity": [
{ "date": "2026-03-09", "count": 18 },
{ "date": "2026-03-10", "count": 22 },
...
],
"wrestlers_by_group": {
"kids": 45,
"youth": 35,
"adults": 25,
"inactive": 22
},
"top_trainers": [
{ "name": "Max M.", "training_count": 12 },
{ "name": "Anna S.", "training_count": 10 },
{ "name": "Tom K.", "training_count": 9 }
]
}
```
### Implementation Steps
1. **Backend**: Create stats endpoint with all aggregations
2. **Frontend**: Update dashboard page with new stat cards
3. **Frontend**: Add new visualization cards
4. **Styling**: Match existing color scheme (primary, secondary, accent)
### Color Scheme
- Primary: `#1B1A55` (navy) - for main elements
- Secondary: `#535C91` (blue) - for secondary elements
- Accent: `#9290C3` (lavender) - for highlights
- Kids: Blue
- Youth: Purple
- Adults: Orange
- Success/Completed: Green (`#22c55e`)
- Inactive: Gray
## Notes
- All statistics should load asynchronously
- Show skeleton loaders during loading
- Error handling: show "Fehler beim Laden" message if API fails
@@ -0,0 +1,208 @@
# Leistungstest Design
## Date: 2026-03-23
## Status: Approved
## Overview
Create a "Leistungstest" (Performance Test) page for creating fitness test templates and assigning them to wrestlers. Each test records exercise results, total time, and rating. Results are tracked over time with progress visualization and leaderboards.
## Design
### Layout
Single page with 4 tabs:
- **Vorlagen** (📋) — Create/edit/delete test templates
- **Zuweisen** (📝) — Assign template to wrestler, record results
- **Ergebnisse** (📊) — View results with progress tracking
- **Leaderboard** (🏆) — Rankings by template
Plus: Sidebar navigation item "Leistungstest"
### Tab 1: Vorlagen
**Template List:**
- Card for each template showing name, exercise list, usage count
- Delete button on each card
**Create Template Form:**
- Name input
- Dynamic list of exercises with target reps
- Add/remove exercise buttons
- Save button
### Tab 2: Zuweisen
**Selection:**
- Wrestler dropdown (shows names, not IDs)
- Template dropdown (shows names, not IDs)
**Test Form (when both selected):**
- List of exercises from template
- For each exercise: target reps input + actual result input
- Total time input (minutes)
- Overall rating (5 stars)
- Notes textarea
- Submit button
### Tab 3: Ergebnisse
**Filters:**
- Wrestler dropdown
- Template dropdown
**Results Table:**
- Columns: Date, Wrestler, Template, Score (%), Rating, Time
- Sorted by date (newest first)
**Progress Section (when one wrestler + one template selected):**
- Shows improvement over time for each exercise
- Progress bars with percentage change
### Tab 4: Leaderboard
**Selection:**
- Template dropdown
**Rankings Table:**
- Columns: Rank, Wrestler, Score %, Rating, Time
- Sorted by score (highest first)
- Medal icons for top 3 (🥇🥈🥉)
## Data Models
### Backend Model: LeistungstestTemplate
```python
class LeistungstestTemplate(models.Model):
name = CharField(max_length=200)
created_at = DateTimeField(auto_now_add=True)
updated_at = DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
```
### Backend Model: LeistungstestTemplateExercise
```python
class LeistungstestTemplateExercise(models.Model):
template = ForeignKey(LeistungstestTemplate, related_name='exercises')
exercise = ForeignKey('exercises.Exercise')
target_reps = PositiveIntegerField()
order = IntegerField(default=0)
class Meta:
ordering = ['template', 'order']
unique_together = ['template', 'exercise']
```
### Backend Model: LeistungstestResult
```python
class LeistungstestResult(models.Model):
template = ForeignKey(LeistungstestTemplate)
wrestler = ForeignKey('wrestlers.Wrestler')
total_time_minutes = PositiveIntegerField(null=True, blank=True)
rating = PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
notes = TextField(blank=True)
completed_at = DateTimeField(default=timezone.now)
created_at = DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-completed_at']
indexes = [
Index(fields=['wrestler']),
Index(fields=['template']),
Index(fields=['completed_at']),
]
```
### Backend Model: LeistungstestResultItem
```python
class LeistungstestResultItem(models.Model):
result = ForeignKey(LeistungstestResult, related_name='items')
exercise = ForeignKey('exercises.Exercise')
target_reps = PositiveIntegerField()
actual_reps = PositiveIntegerField()
order = IntegerField(default=0)
class Meta:
ordering = ['result', 'order']
```
## API Endpoints
```
# Templates
GET /api/v1/leistungstest/templates/ — List templates
POST /api/v1/leistungstest/templates/ — Create template
GET /api/v1/leistungstest/templates/{id}/ — Get template
PATCH /api/v1/leistungstest/templates/{id}/ — Update template
DELETE /api/v1/leistungstest/templates/{id}/ — Delete template
# Template Exercises
POST /api/v1/leistungstest/template-exercises/ — Add exercise to template
DELETE /api/v1/leistungstest/template-exercises/{id}/ — Remove exercise
# Results
GET /api/v1/leistungstest/results/ — List results (filterable)
POST /api/v1/leistungstest/results/ — Create result
GET /api/v1/leistungstest/results/{id}/ — Get result
DELETE /api/v1/leistungstest/results/{id}/ — Delete result
# Leaderboard
GET /api/v1/leistungstest/leaderboard/ — Get rankings by template
```
## Response Shapes
### Template Response
```json
{
"id": 1,
"name": "Kraft-Test",
"exercises": [
{"id": 1, "exercise": 1, "exercise_name": "Klimmzüge", "target_reps": 20, "order": 0},
{"id": 2, "exercise": 2, "exercise_name": "Liegestütze", "target_reps": 50, "order": 1}
],
"usage_count": 12,
"created_at": "2026-03-20T10:00:00Z"
}
```
### Create Result Request
```json
{
"template": 1,
"wrestler": 1,
"total_time_minutes": 12,
"rating": 4,
"notes": "Gute Leistung",
"items": [
{"exercise": 1, "target_reps": 20, "actual_reps": 20},
{"exercise": 2, "target_reps": 50, "actual_reps": 48}
]
}
```
### Leaderboard Response
```json
{
"template": {"id": 1, "name": "Kraft-Test"},
"rankings": [
{"rank": 1, "wrestler": {"id": 2, "name": "Anna S."}, "score_percent": 100, "rating": 5, "time_minutes": 10},
{"rank": 2, "wrestler": {"id": 1, "name": "Max M."}, "score_percent": 96, "rating": 4, "time_minutes": 12}
]
}
```
## Implementation Notes
- Wrestler and template dropdowns show names, not IDs (use SelectValue with find)
- Score = (sum of actual_reps / sum of target_reps) * 100
- Results table shows score as percentage with progress bar
- Leaderboard only shows wrestlers who have done the specific template
- Progress tracking shows change in score between first and latest result for same wrestler+template
@@ -0,0 +1,220 @@
# Leistungstest & UI Modernisierung - Design
## Status
- **Draft** - Needs user review
- **Date**: 2026-03-23
---
## Teil 1: Backend Fixes
### Problem 1: Template-Erstellung
**Aktuell**: Template wird erstellt, aber Übungen werden in separaten POST-Requests gesendet → Inkonsistenz
**Lösung**: Die `create()` Methode im ViewSet sollte Übungen direkt mit dem Template speichern
```python
def create(self, request, *args, **kwargs):
# 1. Template erstellen
# 2. Übungen direkt in derselben Transaktion speichern
# 3. Response mit allen Daten zurückgeben
```
### Problem 2: IDs statt Namen
**Ursache**: Frontend zeigt `SelectItem key={id} value={String(id)}` - der `value` ist die ID, aber `SelectValue` zeigt `children` nicht korrekt
**Lösung**: Nach dem Speichern der Übungen muss das Template mit `prefetch_related` neu geladen werden, damit `exercise_name` verfügbar ist
---
## Teil 2: UI Modernisierung (Ganze App)
### Design Philosophy: "Elegant & Clean"
- Sanfte, subtile Animationen
- Professionelles Erscheinungsbild
- Fokus auf Lesbarkeit und Benutzerfreundlichkeit
- Keine übertriebenen Effekte
### Aktuelle Probleme
1. **Keine Page Transitions** - Seitenwechsel sind abrupt
2. **Statische Hover-Effekte** - Keine sanften Übergänge
3. **Cards ohne Tiefe** - Keine subtilen Schatten oder Glows
4. **Monochrome Icons** - Keine Farbvariation
5. **FadeIn nur auf Hauptelementen** - Inkonsistente Animationen
### Geplante Verbesserungen
#### 2.1 Global Page Transitions
```tsx
// app/(dashboard)/layout.tsx
import { motion, AnimatePresence } from "framer-motion"
// Page-Übergänge mit smooth fade + slide
<AnimatePresence mode="wait">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{children}
</motion.div>
</AnimatePresence>
```
#### 2.2 Subtile Card-Hover-Effekte
```tsx
// Aktuell
<Card>
// Neu mit motion
<motion.div whileHover={{ y: -2, boxShadow: "0 10px 40px -10px rgba(0,0,0,0.1)" }}>
<Card>
</motion.div>
```
#### 2.3 Button Micro-Interactions
```tsx
// Scale + subtle glow on hover
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
```
#### 2.4 Staggered List Animations
```tsx
// Für Listen und Cards mit verzögertem Erscheinen
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ staggerChildren: 0.05 }}
>
{items.map(item => (
<motion.div key={item.id} variants={itemVariant} />
))}
</motion.div>
```
#### 2.5 Smooth Scrollbar Styling
```css
/* Modern scrollbar für Webkit-Browser */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.5);
}
```
#### 2.6 Icon Color Transitions
```tsx
// Icons mit Farbwechsel bei Hover
<motion.div whileHover={{ color: "#1B1A55" }}>
<Icon className="transition-colors" />
</motion.div>
```
---
## Teil 3: Component-Spezifische Änderungen
### 3.1 Sidebar
- Hover-State mit sanftem Slide + Farbwechsel
- Active-State mit subtle glow/underline
- Smooth expand/collapse für Submenüs
### 3.2 Cards (Stat Cards, Data Cards)
- Subtle lift bei Hover
- Border-color transition
- Sanfte Schatten-Verstärkung
### 3.3 Tables
- Row hover mit background shift
- Smooth row appearance (staggered)
- Subtle column highlight
### 3.4 Forms
- Input focus mit border-color + subtle glow
- Label animation (float label style optional)
- Button loading state mit spinner
### 3.5 Tabs
- Underline indicator mit smooth slide
- Active tab mit subtle scale
---
## Teil 4: Animations-Token
```typescript
// lib/animations.ts (neu oder erweitern)
export const transitions = {
fast: "150ms",
normal: "250ms",
slow: "400ms",
}
export const easing = {
default: "easeOut",
smooth: [0.4, 0, 0.2, 1], // cubic-bezier
bounce: [0.68, -0.55, 0.265, 1.55],
}
export const stagger = {
fast: 0.05,
normal: 0.1,
slow: 0.15,
}
```
---
## Implementation Reihenfolge
1. **Backend Fix** (10 min)
- Template create mit embedded exercises
- Verify exercise_name wird korrekt zurückgegeben
2. **Animation Library Setup** (5 min)
- Framer Motion installieren/verifizieren
- Animation tokens definieren
3. **Global Layout** (15 min)
- Page transitions in layout.tsx
- Smooth scrolling CSS
4. **Sidebar Enhancement** (15 min)
- Hover/active animations
- Icon transitions
5. **Card Components** (20 min)
- Alle Cards mit hover effects
- Stat cards auf Dashboard
6. **List/Table Enhancements** (20 min)
- Staggered animations
- Row hover effects
7. **Form Enhancements** (15 min)
- Input focus states
- Button micro-interactions
8. **Tab Enhancements** (10 min)
- Slide underline indicator
---
## Erfolgskriterien
- [ ] Template-Erstellung funktioniert vollständig mit Übungen
- [ ] Dropdowns zeigen Namen statt IDs
- [ ] Seitenwechsel sind smooth (fade + slide)
- [ ] Hover-Effekte auf allen interaktiven Elementen
- [ ] Listen appear mit staggered animation
- [ ] Keine "jarring" Übergänge
- [ ] Performance bleibt gut (nicht zu viele Animationen)
@@ -0,0 +1,188 @@
# Training Log & Progress Analysis Design
## Date: 2026-03-23
## Status: Approved
## Overview
Create a "Training Log" page for recording actual exercises performed by wrestlers during training sessions. The system tracks detailed exercise data (reps, sets, time, weight, rating) and provides analysis features including wrestler comparison and progress tracking over time.
## Design
### Layout
Single page with 3 tabs:
- **Log** (📋) — Quick entry form for new exercise records
- **Historie** (📜) — Filterable list of all entries
- **Analyse** (📊) — Summary stats, progress tracking, and wrestler comparison
### Tab 1: Log
**Entry Form:**
- Ringer dropdown (required)
- Training dropdown (optional — links to specific training session)
- Übung dropdown (required — from exercises)
- reps input (number, required)
- sets input (number, default 1)
- zeit input (minutes, optional)
- Gewicht input (kg, optional)
- Bewertung — 5-star rating selector
- Notizen textarea (optional)
- Speichern button
### Tab 2: Historie
**Filter Bar:**
- Ringer dropdown (filter by wrestler)
- Datum dropdown (date range)
- Übung dropdown (filter by exercise)
- Suchen button
**Entry List:**
- Table showing: Datum, Ringer, Übung, reps×sets, Zeit, Gewicht, Bewertung
- Clickable rows for potential editing
- Sorted by date (newest first)
### Tab 3: Analyse
**Wrestler Selector:**
- Dropdown to select specific wrestler
- OR "Alle vergleichen" option
**Summary Card:**
- Gesamt: total entries
- Verschiedene Übungen: count of unique exercises
- Wiederholungen: total reps
- Ø Sätze: average sets per entry
- Ø Bewertung: average rating
- Diese Woche: entries this week
**Top Übungen Card:**
- List of most performed exercises with counts
- Bar visualization
**Fortschritt Card:**
- Shows improvement over time for selected wrestler
- Progress bars with percentage change
- Only exercises with measurable progress
**Übungsvergleich Card:**
- Side-by-side comparison of two wrestlers
- Bar chart showing reps for same exercise types
- Only shows exercises both wrestlers have done
## Data Models
### Backend Model: TrainingLogEntry
```python
class TrainingLogEntry(models.Model):
wrestler = ForeignKey('wrestlers.Wrestler')
training = ForeignKey('trainings.Training', null=True, blank=True)
exercise = ForeignKey('exercises.Exercise')
reps = PositiveIntegerField()
sets = PositiveIntegerField(default=1)
time_minutes = PositiveIntegerField(null=True, blank=True)
weight_kg = DecimalField(null=True, blank=True, max_digits=5, decimal_places=2)
rating = PositiveIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)])
notes = TextField(blank=True)
logged_at = DateTimeField(default=timezone.now)
created_at = DateTimeField(auto_now_add=True)
updated_at = DateTimeField(auto_now=True)
class Meta:
ordering = ['-logged_at']
indexes = [
Index(fields=['wrestler']),
Index(fields=['exercise']),
Index(fields=['logged_at']),
]
```
## API Endpoints
```
GET /api/v1/training-log/ — List entries (filterable)
POST /api/v1/training-log/ — Create entry
GET /api/v1/training-log/{id}/ — Get single entry
PATCH /api/v1/training-log/{id}/ — Update entry
DELETE /api/v1/training-log/{id}/ — Delete entry
GET /api/v1/training-log/stats/ — Analysis stats
GET /api/v1/training-log/compare/ — Comparison data
```
## Response Shapes
### List Response (GET /training-log/)
```json
{
"count": 156,
"results": [
{
"id": 1,
"wrestler": 1,
"wrestler_name": "Max M.",
"training": 5,
"training_date": "2026-03-23",
"exercise": 1,
"exercise_name": "Pushups",
"reps": 50,
"sets": 3,
"time_minutes": 2,
"weight_kg": 10.0,
"rating": 4,
"notes": "",
"logged_at": "2026-03-23T15:30:00Z"
}
]
}
```
### Stats Response (GET /training-log/stats/?wrestler=1)
```json
{
"total_entries": 156,
"unique_exercises": 12,
"total_reps": 4230,
"avg_sets": 3.2,
"avg_rating": 3.8,
"this_week": 23,
"top_exercises": [
{"name": "Pushups", "count": 45},
{"name": "Klimmzüge", "count": 32}
],
"progress": {
"Pushups": {"before": 40, "after": 50, "change_percent": 25},
"Klimmzüge": {"before": 8, "after": 10, "change_percent": 40}
}
}
```
### Compare Response (GET /training-log/compare/?wrestler1=1&wrestler2=2)
```json
{
"wrestler1": {"id": 1, "name": "Max M."},
"wrestler2": {"id": 2, "name": "Anna S."},
"exercises": [
{
"exercise": "Pushups",
"wrestler1_avg": 50,
"wrestler2_avg": 30
}
]
}
```
## Implementation Steps
1. **Backend Model** — Create TrainingLogEntry model in homework app (or new training_log app)
2. **Backend ViewSet** — CRUD endpoints + stats endpoint + compare endpoint
3. **Frontend API Types** — Add ITrainingLogEntry interface
4. **Frontend Page** — Create /training-log page with 3 tabs
## Notes
- Training dropdown should only show trainings from the past (can't log future)
- Weight should accept decimals (e.g., 10.5 kg)
- Rating is 1-5 stars
- All timestamps stored in UTC, displayed in local time
@@ -0,0 +1,91 @@
# Leistungstest Ergebnisse — Neue Ansicht
## Overview
Der "ergebnisse" Tab im Leistungstest zeigt ab sofort Ergebnisse in einer modernen Karten-Ansicht mit Toggle-Option zwischen Karten und Tabelle.
## Design
### Karten-Ansicht (Default)
- Pro Ergebnis eine übersichtliche Karte
- Header: Ringer-Avatar (initialbasiert), Name, Datum, Vorlage
- Header-Right: Score (%) in groß (farbcodiert: grün ≥100%, gelb ≥80%, rot <80%), Gesamtzeit
- Body: Liste aller Übungen mit Soll/Reps, elapsed_seconds, Done-Check
- Footer: Bewertung (Sterne), Bearbeiten/Löschen Buttons
### Toggle
- Toggle-Button-Leiste oben rechts: "⊞ Tabelle" | "📋 Karten"
- Aktive Ansicht ist hervorgehoben (weißer Hintergrund, Schatten)
- Auswahl wird in User-Preferences gespeichert (optional, falls bereits Preferences-System existiert)
### Farbcodierung
- Score ≥100%: Grün (`#16a34a`)
- Score ≥80%: Gelb (`#ca8a04`)
- Score <80%: Rot (`#dc2626`)
### Filter
- Vorlage-Dropdown (alle Vorlagen oder spezifische)
- Ringer-Dropdown (optional)
- Sortierung: Neueste zuerst (default)
### Detail-Ansicht pro Ergebnis
- Alle Übungen mit:
- Übungsname
- Soll: `targetReps` (z.B. "3×10")
- Zeit: `elapsedSeconds` formatiert als `m:ss`
- Status: ✓ wenn done
## Komponenten
### ResultsCardsView
- Rendering aller Ergebnis-Karten
- Filter-Logik
- Toggle-State
### ResultCard
- Einzelne Ergebniskarte
- Avatar mit Initial des Ringers
- Farbcodierter Score
- Übungsliste mit Zeiten
- Aktions-Buttons
## API
Ergebnisse werden via `apiFetch("/leistungstest/results/")` geladen (existierendes API).
Response-Shape:
```typescript
{
id: number
template: number
template_name: string
wrestler: number
wrestler_name: string
total_time_seconds: number
rating: number
score_percent: number
completed_at: string
items: Array<{
id: number
exercise: number
exercise_name: string
target_reps: number
actual_reps: number
elapsed_seconds: number
order: number
}>
}
```
## Backend
Keine Änderungen notwendig — bestehendes API wird verwendet.
## Implementierung
1. Neuer State `resultViewMode: "cards" | "table"` (default: "cards")
2. `ResultsCardsView` Component mit Karten-Logic
3. Toggle-Buttons im CardHeader
4. Ergebnis-Karten mit allen Details
5. Filter funktionieren wie bisher
6. Pagination bleibt bestehen
@@ -0,0 +1,255 @@
# Leistungstest Live-Timer — Specification
## Overview
Add a live-timer mode to the Leistungstest "Zuweisen" tab. Instead of manually entering results for each wrestler, trainers can run through a timed workout session: select a template and multiple wrestlers, then start a live timer that tracks per-wrestler time and reps as they complete exercises in sequence. Results are saved immediately per wrestler.
## Architecture
### Two Modes in the Zuweisen Tab
The Zuweisen tab has two modes, toggled by a "Modus" switcher above the form:
1. **Form Mode** (default, existing behavior): Template + Wrestler selection → manual time/reps entry → save
2. **Timer Mode** (new): Template + Wrestler selection → live timer interface → results saved per wrestler automatically
### Mode Toggle
```tsx
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
```
- Toggle switch above the form
- Form Mode: existing inputs for template, wrestlers, time (min+sec), rating, notes
- Timer Mode: shows "Training starten" button when wrestlers selected
---
## Timer Mode Data Flow
### State
```typescript
interface TimerWrestler {
wrestler: IWrestler
status: "pending" | "active" | "done"
startedAt: number | null // Date.now() when started
exercises: TimerExercise[]
resultId: number | null // created after first wrestler finishes
}
interface TimerExercise {
exerciseId: number
exerciseName: string
targetReps: number // from template
actualReps: string // user input
status: "pending" | "done"
startedAt: number | null
}
interface TimerSession {
templateId: number
wrestlers: TimerWrestler[]
currentWrestlerIndex: number
totalElapsedSeconds: number
isRunning: boolean
}
```
### Session Persistence (localStorage)
```typescript
// Key: "leistungstest_timer_session"
// Saved on every state change
// Restored on page load if session exists and isRunning === false (interrupted)
```
On page load, if an incomplete session is found in localStorage:
- Prompt: "Offenes Training gefunden. Fortsetzen?" → Yes restores session, No clears it
---
## UI Layout — Timer Mode
### Split View
```
┌─────────────────────┬──────────────────────────────────────────┐
│ RINGER │ AKTUELLER RINGER: Anna Schmidt │
│ │ │
│ ○ Max Mustermann │ ┌──────────────┐ [Pausieren] │
│ ● Anna Schmidt │ │ 05:23 │ │
│ ○ Tom Klein │ └──────────────┘ │
│ │ │
│ │ ÜBUNGEN (2/5) │
│ │ ─────────────────────────────────────── │
│ │ Liegestütze Soll: 3x10 │
│ │ [___ Ist-Reps] ▶ START │
│ │ │
│ │ Kniebeugen Soll: 3x15 │
│ │ [___ Ist-Reps] ✅ ERLEDIGT │
│ │ │
│ │ Burpees Soll: 3x8 │
│ │ [___ Ist-Reps] ▶ START │
│ │ │
│ │ ─────────────────────────────────────── │
│ │ [WEITER ZUM NÄCHSTEN RINGER →] │
│ │ [TRAINING BEENDEN] │
└─────────────────────┴──────────────────────────────────────────┘
```
### Components
**Left Panel (wrestler list):**
- Shows all selected wrestlers
- Status indicator: ○ pending, ● active, ✅ done
- Click on a done wrestler to review/edit their results
- Stays visible throughout
**Right Panel — Top (current wrestler + timer):**
- Wrestler name prominently displayed
- Large timer display: MM:SS format, updates every second
- Pause/Resume button
- Total elapsed time (cumulative across all wrestlers)
**Right Panel — Middle (exercise list):**
- List of exercises from template
- Each row shows: exercise name, target reps ("Soll")
- Input field for actual reps
- Start/Done button per exercise
- When exercise is marked done: saves to that wrestler's result (if result exists) or marks pending
**Right Panel — Bottom (actions):**
- "Weiter zum nächsten Ringer" → marks current wrestler done, saves result, advances
- "Training beenden" → final save, exits timer mode, shows summary
---
## Timer Logic
### Starting
1. User selects template (Sheet) + wrestlers (Sheet)
2. Clicks "Training starten"
3. Timer mode activates, first wrestler is set to "active", timer starts
### Per-Exercise Flow
1. Trainer sees current exercise (from template)
2. Enters actual reps in input field
3. Clicks "Start" → exercise timer starts
4. Wrestler completes exercise
5. Trainer clicks "Done" → exercise marked complete, elapsed time recorded
6. Next exercise auto-advances to active state
### Per-Wrestler Flow
1. All exercises done for current wrestler
2. Trainer clicks "Weiter zum nächsten Ringer"
3. Result is saved immediately via API:
- POST `/leistungstest/results/` with all exercise items
- `total_time_seconds` = elapsed time for this wrestler
4. Next wrestler becomes active, timer continues (does NOT reset)
5. Repeat until all wrestlers done
### Finishing
1. Trainer clicks "Training beenden"
2. If current wrestler has started but not all exercises done → prompt: "Nicht alle Übungen gemacht. Trotzdem beenden?"
3. Confirm → save current wrestler's partial result
4. Show summary: wrestlers completed, total time, scores
### Pause/Resume
- "Pausieren" stops the timer
- Timer display shows "PAUSIERT" in orange
- "Fortsetzen" resumes
- Paused time is accumulated in `totalElapsedSeconds`
---
## Backend API
No new endpoints needed. Use existing:
- `POST /leistungstest/results/` — create result for each wrestler
- `GET /leistungstest/results/?template=X` — list results
### Create Result Payload
```json
{
"template": 1,
"wrestler": 5,
"total_time_seconds": 323,
"rating": 3,
"notes": "",
"items": [
{ "exercise": 3, "target_reps": 30, "actual_reps": 28, "order": 0 },
{ "exercise": 7, "target_reps": 45, "actual_reps": 45, "order": 1 }
]
}
```
---
## Form Mode (Existing)
Unchanged behavior. Shows when `inputMode === "form"`:
- Template select (Sheet)
- Wrestler select (Sheet, multi)
- Minutes + seconds inputs
- Rating select
- Notes textarea
- Submit creates single result
---
## File Structure
```
frontend/src/app/(dashboard)/leistungstest/page.tsx
- Add inputMode state
- Add TimerMode component (inline or separate)
- TimerMode: TimerSession, TimerWrestler, TimerExercise types
- localStorage persistence with useEffect
New sub-components within page.tsx:
- TimerMode (full-width layout replacing the form)
- WrestlerListPanel (left side)
- TimerPanel (right side: timer + exercises)
```
---
## Session Persistence
```typescript
const SESSION_KEY = "leistungstest_timer_session"
useEffect(() => {
if (inputMode === "timer" && session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
}, [session, inputMode])
useEffect(() => {
if (inputMode === "timer") {
const saved = localStorage.getItem(SESSION_KEY)
if (saved) {
const parsed = JSON.parse(saved)
if (parsed.isRunning === false) {
// show restore prompt
}
}
}
}, [inputMode])
```
---
## Implementation Priority
1. Timer state + basic timer display (MM:SS ticking)
2. Wrestler list panel with status
3. Exercise list with reps input + start/done
4. Per-wrestler save on "Weiter"
5. localStorage persistence
6. Pause/Resume
7. Training beenden + summary
8. Form mode toggle
@@ -0,0 +1,293 @@
# Leistungstest Live-Timer — Design Specification
## Overview
**What:** A live-timer mode for the Leistungstest "Zuweisen" tab where a trainer runs through a timed workout with multiple wrestlers simultaneously. All wrestlers start the same exercise at the same time. The trainer clicks "Erledigt" per wrestler when each finishes — the next exercise starts automatically. All wrestlers visible in a table-like layout.
**Why:** Replaces the current per-wrestler, per-exercise form entry with a real-time group training experience. Trainers can see all wrestlers at once and track live progress.
---
## Data Model
### TrainingSession (React state + localStorage)
```typescript
interface TrainingSession {
templateId: number
templateName: string
wrestlers: WrestlerSession[]
globalElapsedSeconds: number // shared across all wrestlers
isRunning: boolean
}
interface WrestlerSession {
wrestler: IWrestler
currentExerciseIndex: number
exercises: ExerciseSession[]
}
interface ExerciseSession {
exerciseId: number
exerciseName: string
targetReps: string // e.g. "3×10" — from template
elapsedSeconds: number // time when Erledigt was clicked
status: "pending" | "active" | "done"
}
```
### Backend API
Uses existing endpoint: `POST /leistungstest/results/`
When a wrestler's exercise is marked "Erledigt":
- `POST /leistungstest/results/` with `template`, `wrestler`, `total_time_seconds` (cumulative), `rating: 3`, `notes: ""`, `items` array
When all exercises of a wrestler are done:
- The full result is already saved incrementally — no final save step needed
No new backend endpoints required.
---
## User Flow
### Step 1: Setup (Form Mode)
- Trainer is in "Zuweisen" tab
- Selects a **Template** (has exercises + target reps pre-defined)
- Selects **Wrestlers** (multiple via Sheet)
- Clicks **"⏱ Training starten"** → switches to timer mode
### Step 2: Training Starts
- All wrestlers begin **Exercise 1** simultaneously
- Global timer starts (00:00 → 00:01 → ...)
- All wrestlers visible in table-like layout, each with their own exercise cards
### Step 3: Tracking
- Trainer watches live elapsed time per wrestler/exercise
- When a wrestler finishes an exercise → clicks **"✓ Erledigt"**
- Elapsed time for that exercise is saved
- Next exercise for that wrestler starts immediately (auto-start)
- Other wrestlers continue their current exercise
### Step 4: Completion
- When all exercises of all wrestlers are done (or trainer clicks "Training beenden"):
- Summary modal shows: X/Y wrestlers completed, total time
- All results already saved to backend via incremental POSTs
- localStorage session cleared
- Trainer returns to form mode
### Step 5: Post-Edit
- Results visible in "Ergebnisse" tab
- Can be edited (notes, rating, individual exercise times) via existing edit form
---
## UI Layout
### Header Banner (always visible during timer)
```
┌──────────────────────────────────────────────────────────────┐
│ GEMEINSAME ZEIT 05:23 [⏸ Pausieren] [■ Ende] │
└──────────────────────────────────────────────────────────────┘
```
- Dark navy background (#1B1A55)
- Large monospace timer (MM:SS)
- "Pausieren" toggles to "▶ Fortsetzen" when paused
- "Training beenden" opens confirmation dialog
### Wrestler Rows (scrollable list)
Each wrestler = one row, stacked vertically. Like a table but each row is independent.
```
┌──────────────────────────────────────────────────────────────┐
│ ● Max Mustermann 3/3 ✓ [grün] │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │Liegestütze │ │Kniebeugen │ │Burpees │ │
│ │Soll: 3×10 │ │Soll: 3×15 │ │Soll: 3×20 │ │
│ │✓ 0:28 │ │✓ 0:44 │ │✓ 0:19 │ │
│ │[grün] │ │[grün] │ │[grün] │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
Three states per exercise card:
| State | Background | Border | Badge | Button |
|-------|-----------|--------|-------|--------|
| **pending** | gray-50 | gray-200 | — | ▶ Start (disabled) |
| **active** | yellow-50 | yellow-400 | ▶ Aktiv | ✓ Erledigt |
| **done** | green-50 | green-400 | ✓ MM:SS | — |
When a wrestler finishes all exercises → row header turns green with "✓ Alle erledigt".
### Exercise Card (within wrestler row)
```
┌──────────────────────────┐
│ ▶ Übungsname │
│ Soll: 3×10 │
│ MM:SS │
│ [✓ Erledigt] │
└──────────────────────────┘
```
- Exercise name with status icon
- Target reps from template (e.g. "Soll: 3×10") — **reps, not time**
- Live elapsed seconds (MM:SS) updating every second when active
- Button: "✓ Erledigt" for active exercises
---
## State Management
### React State
```typescript
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
const [session, setSession] = useState<TrainingSession | null>(null)
const [globalTimerInterval, setGlobalTimerInterval] = useState<NodeJS.Timeout | null>(null)
const [restoreModalOpen, setRestoreModalOpen] = useState(false)
const [endModalOpen, setEndModalOpen] = useState(false)
const [summary, setSummary] = useState<{ completed: number; total: number; totalTime: number } | null>(null)
```
### Timer Tick Effect
```typescript
useEffect(() => {
if (session?.isRunning) {
const interval = setInterval(() => {
setSession(prev => prev ? { ...prev, globalElapsedSeconds: prev.globalElapsedSeconds + 1 } : prev)
}, 1000)
setGlobalTimerInterval(interval)
return () => { clearInterval(interval); setGlobalTimerInterval(null) }
}
}, [session?.isRunning])
```
### localStorage Persistence
Key: `"leistungstest_timer_session"`
Saved on every session change. Restored session shows resume/discard modal on next load.
---
## Session Initialization (Start Training)
When "⏱ Training starten" is clicked:
```typescript
const timerWrestlers: WrestlerSession[] = wrestlers
.filter(w => selectedWrestlerIds.includes(w.id))
.map(w => ({
wrestler: w,
currentExerciseIndex: 0,
exercises: template.exercises.map(e => ({
exerciseId: e.exercise,
exerciseName: e.exercise_name || String(e.exercise),
targetReps: e.target_reps, // e.g. "3×10" from template
elapsedSeconds: 0,
status: "active" as const, // all start active (first exercise)
})),
}))
setSession({
templateId: template.id,
templateName: template.name,
wrestlers: timerWrestlers,
globalElapsedSeconds: 0,
isRunning: true,
})
setInputMode("timer")
```
All wrestlers start with their first exercise in "active" state simultaneously.
---
## Marking Exercise Done
```typescript
const markDone = async (wrestlerIdx: number, exerciseIdx: number) => {
const wrestler = session.wrestlers[wrestlerIdx]
const exercise = wrestler.exercises[exerciseIdx]
const elapsed = exercise.elapsedSeconds
// Save to backend
await apiFetch("/leistungstest/results/", {
method: "POST",
token,
body: JSON.stringify({
template: session.templateId,
wrestler: wrestler.wrestler.id,
total_time_seconds: elapsed,
rating: 3,
notes: "",
items: [{
exercise: exercise.exerciseId,
target_reps: parseReps(exercise.targetReps),
actual_reps: parseReps(exercise.targetReps),
order: exerciseIdx,
}],
}),
})
// Update state: mark done, auto-start next
const updated = [...session.wrestlers]
updated[wrestlerIdx] = {
...wrestler,
exercises: wrestler.exercises.map((e, i) =>
i === exerciseIdx ? { ...e, status: "done" as const, elapsedSeconds: elapsed } : e
),
}
// Auto-start next exercise if exists
const nextIdx = exerciseIdx + 1
if (nextIdx < wrestler.exercises.length) {
updated[wrestlerIdx].exercises[nextIdx].status = "active"
updated[wrestlerIdx].currentExerciseIndex = nextIdx
}
setSession({ ...session, wrestlers: updated })
}
```
---
## Backend Compatibility
### Template stores target_reps as string (e.g. "3×10")
- `parseReps("3×10")` → extracts number for API (e.g. `30` or just stores as-is)
- Backend serializer accepts `target_reps` as string or int — needs verification
### LeistungstestResult stores `total_time_seconds` (int)
- Each "Erledigt" click saves the cumulative elapsed time for that wrestler
- Individual exercise times stored in `items` array (one item per exercise)
### If backend needs `items` on every save or only at end
- Currently: save on every "Erledigt" click with just that exercise
- Alternative: save incrementally, last item completes the result
- **Decision needed:** Does the backend create one result per exercise click, or update an existing result?
---
## Edge Cases
1. **Page reload during training** → localStorage restore, resume/discard prompt
2. **Trainer closes browser** → session persisted, can resume
3. **All exercises done before "Training beenden"** → auto-trigger summary
4. **Only some wrestlers finish** → partial results saved, can resume later
5. **Pause** → global timer stops, all active exercises pause (no per-exercise timer, just global)
6. **Empty template** → disable "Training starten" if template has no exercises
---
## Out of Scope (for this implementation)
- Real-time sync across multiple trainers (future)
- Per-exercise individual timers (only global timer)
- Audio alerts
- Export/print
- Changing exercise order mid-training
@@ -0,0 +1,154 @@
# Leistungstest Statistiken — Design
## Overview
Erweiterung des Leistungstest-Bereichs um Statistiken und Ranglisten auf zwei Ebenen: **Vorlagen** und **Übungen**. Filterbar nach Zeitraum (Monat / Alle Zeiten).
## Features
### 1. Ranglisten pro Vorlage
- Rangliste aller Wrestler nach `score_percent` (absteigend)
- Optional: Sortierung nach `total_time_seconds` (aufsteigend = schnellste Zeit)
- Zeitraum-Filter: "Alle Zeiten" | "Dieser Monat" | "Letzte 3 Monate" | "Dieses Jahr"
- Anzeige: Rank, Wrestler-Name, Score %, Gesamtzeit, Datum
### 2. Ranglisten pro Übung
- Für jede Übung: Rangliste nach `elapsed_seconds` (schnellste Zeit)
- Berechnung: Beste Zeit (niedrigste elapsed_seconds) pro Wrestler pro Übung
- Zeitraum-Filter wie oben
- Anzeige: Rank, Wrestler-Name, Übungsname, Bestzeit, Datum
### 3. Ringer-Statistik (optional)
- Pro Wrestler: Durchschnitts-Score, Anzahl Tests, Trend (besser/schlechter)
## Datenmodell
Keine neuen Modelle — Berechnung erfolgt on-the-fly aus `LeistungstestResult` und `LeistungstestResultItem`.
## API Endpoints (Backend)
### `GET /api/v1/leistungstest/stats/leaderboard/`
Query-Parameter:
- `type`: `"template"` | `"exercise"`
- `template_id`: number (für template-Typ)
- `period`: `"all"` | `"month"` | `"3months"` | `"year"`
- `limit`: number (default: 10)
Response für `type=template`:
```json
{
"template_id": 1,
"template_name": "Krafttest Februar",
"period": "all",
"results": [
{
"rank": 1,
"wrestler_id": 1,
"wrestler_name": "Max Mustermann",
"score_percent": 100.0,
"total_time_seconds": 342,
"completed_at": "2026-03-24"
}
]
}
```
Response für `type=exercise`:
```json
{
"exercise_id": 1,
"exercise_name": "Liegestütze",
"period": "all",
"results": [
{
"rank": 1,
"wrestler_id": 1,
"wrestler_name": "Max Mustermann",
"best_time_seconds": 75,
"completed_at": "2026-03-24"
}
]
}
```
### `GET /api/v1/leistungstest/stats/exercises/`
Liste aller Übungen die jemals in einem Leistungstest verwendet wurden (für Dropdown).
## Frontend
### Neuer Tab: "📊 Statistiken"
Tabs im Leistungstest:
1. Vorlagen (existiert)
2. Zuweisen (existiert)
3. Ergebnisse (existiert, jetzt Karten-Ansicht)
4. **📊 Statistiken** (NEU)
### Statistiken-Tab Layout:
```
[Tab: Vorlagen | Zuweisen | Ergebnisse | 📊 Statistiken]
📊 Statistiken
[Dropdowns: Vorlage auswählen | Zeitraum: Alle Zeiten ▾]
-tabs-
[⊞ Nach Vorlage] [📋 Nach Übung]
--- Nach Vorlage ---
Rangliste: Krafttest Februar
1. 🥇 Max Mustermann — 100% (5:42)
2. 🥈 Anna Schmidt — 87% (6:20)
3. 🥉 Tom Klein — 65% (8:15)
--- Nach Übung ---
Rangliste: Liegestütze
1. 🥇 Max Mustermann — 1:15
2. 🥈 Anna Schmidt — 1:30
3. 🥉 Tom Klein — 2:00
Rangliste: Kniebeugen
1. 🥇 Max Mustermann — 2:30
...
```
## Backend-Implementierung
### Neue Datei: `leistungstest/stats.py`
```python
def get_template_leaderboard(template_id, period="all", limit=10):
# Filter results by template and period
# Order by score_percent DESC, total_time_seconds ASC
# Return top N with rank
def get_exercise_leaderboard(exercise_id, period="all", limit=10):
# For each wrestler, find their BEST (lowest) elapsed_seconds
# Filter by period
# Order by best_time ASC
# Return top N
```
### Neue URL: `leistungstest/stats.py` ViewSet
- `GET /leaderboard/` — Template oder Exercise Leaderboard
- `GET /exercises/` — Liste verwendeter Übungen
## Zeitraum-Filter Logik
```python
def get_date_range(period):
today = date.today()
if period == "month":
return today.replace(day=1)
elif period == "3months":
return today - timedelta(days=90)
elif period == "year":
return today.replace(month=1, day=1)
return None # "all"
```
## Implementierungs-Reihenfolge
1. Backend: `stats.py` mit ViewSet + URLs
2. Frontend: Neuer "Statistiken" Tab
3. Toggle zwischen Vorlagen/Übungs-Ansicht
4. Zeitraum-Filter
5. Ranglisten-Anzeige mit Medaillen-Icons