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
```