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:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user