Files
WrestleDesk/docs/superpowers/plans/2026-03-23-leistungstest-implementation.md
T
Andrej Spielmann 3fefc550fe 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
2026-03-26 13:24:57 +01:00

416 lines
13 KiB
Markdown

# 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)