# 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 } 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("log") const [isLoading, setIsLoading] = useState(true) // Data states const [entries, setEntries] = useState([]) const [stats, setStats] = useState(null) const [wrestlers, setWrestlers] = useState([]) const [exercises, setExercises] = useState([]) const [trainings, setTrainings] = useState([]) // Filter states const [filterWrestler, setFilterWrestler] = useState("") const [filterExercise, setFilterExercise] = useState("") // 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>("/training-log/", { token }), apiFetch>("/wrestlers/?page_size=100", { token }), apiFetch>("/exercises/?page_size=100", { token }), apiFetch>("/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 return (

Training Log

{/* Tabs */}
{tabs.map(tab => ( ))}
{/* Log Tab */} {activeTab === "log" && ( Neuer Eintrag
setFormData({...formData, reps: e.target.value})} />
setFormData({...formData, sets: e.target.value})} />
setFormData({...formData, time_minutes: e.target.value})} />
setFormData({...formData, weight_kg: e.target.value})} />
{[1,2,3,4,5].map(star => ( ))}