Files
WrestleDesk/docs/superpowers/plans/2026-03-24-leistungstest-statistiken-implementation.md
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

17 KiB

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

type TabType = "vorlagen" | "zuweisen" | "ergebnisse" | "statistiken"

Add state for stats:

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:

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:

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:

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

{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:

    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:

    cd frontend && npm run build