Files
WrestleDesk/docs/superpowers/plans/2026-03-23-training-log-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

678 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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