3fefc550fe
- Django backend with DRF (clubs, wrestlers, trainers, exercises, templates, trainings, homework, locations, leistungstest) - Next.js 16 frontend with React, Shadcn UI, Tailwind - JWT authentication - Full CRUD for all entities - Calendar view for trainings - Homework management system - Leistungstest tracking
416 lines
13 KiB
Markdown
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)
|