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,140 @@
|
||||
# Homework System Integration - Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development or execute tasks manually.
|
||||
|
||||
**Goal:** Integrate all backend homework features into the frontend
|
||||
|
||||
**Architecture:** Update types, enhance homework page with exercise selection and assignments tracking
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/lib/api.ts # Add missing types
|
||||
├── src/app/(dashboard)/homework/page.tsx # Enhanced with exercises
|
||||
└── src/app/(dashboard)/homework/assignments/page.tsx # NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Update Types in api.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/api.ts`
|
||||
|
||||
Add these types:
|
||||
```typescript
|
||||
interface IHomeworkExerciseItem {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
reps: number | null
|
||||
time_minutes: number | null
|
||||
order: number
|
||||
}
|
||||
|
||||
interface IHomework {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
club: number
|
||||
club_name: string
|
||||
due_date: string
|
||||
is_active: boolean
|
||||
exercise_items: IHomeworkExerciseItem[]
|
||||
exercise_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface IHomeworkAssignment {
|
||||
id: number
|
||||
homework: number
|
||||
homework_title: string
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
club: number
|
||||
club_name: string
|
||||
due_date: string
|
||||
notes: string
|
||||
is_completed: boolean
|
||||
completion_date: string | null
|
||||
completed_items: number
|
||||
total_items: number
|
||||
items: IHomeworkAssignmentItem[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface IHomeworkAssignmentItem {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
is_completed: boolean
|
||||
completion_date: string | null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Exercise Selection to Homework Form
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx`
|
||||
|
||||
Enhance the homework form to:
|
||||
1. Fetch exercises from `/exercises/`
|
||||
2. Display exercise selection list with reps/time inputs
|
||||
3. Allow adding/removing exercises from homework
|
||||
4. Save exercise items via `POST /homework/{id}/exercise-items/`
|
||||
|
||||
Changes:
|
||||
- Add `exercises` state to store available exercises
|
||||
- Add `selectedExercises` state for currently selected exercises
|
||||
- Fetch exercises on modal open
|
||||
- Display exercise chips with reps/time
|
||||
- Add exercise via `POST /homework/{id}/exercise-items/`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Homework Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx`
|
||||
|
||||
Update the homework list to show:
|
||||
- Exercise count badge
|
||||
- Visual indicator if has exercises
|
||||
- Better styling for cards
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Assignments Page
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/app/(dashboard)/homework/assignments/page.tsx`
|
||||
|
||||
Features:
|
||||
- List all homework assignments via `GET /homework/assignments/`
|
||||
- Show completion progress (e.g., "3/5 exercises")
|
||||
- Filter by: homework, wrestler, status
|
||||
- Click to expand and see exercises
|
||||
- Mark exercises as complete via `POST /homework/assignments/{id}/complete-item/`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Build and Test
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
Fix any TypeScript errors.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(homework): integrate exercises and assignments"
|
||||
```
|
||||
@@ -0,0 +1,412 @@
|
||||
# Homework System Redesign - 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:** Redesign homework system to assign exercises directly from training page without templates.
|
||||
|
||||
**Architecture:**
|
||||
- New models: `TrainingHomework`, `TrainingHomeworkExercise`, `TrainingHomeworkAssignment`
|
||||
- Training detail page redesigned with 2-column layout: participants | training homework
|
||||
- Homework button per participant opens modal to select exercises
|
||||
- Homework page shows all assignments grouped by training
|
||||
|
||||
**Tech Stack:** Django + DRF backend, Next.js + React frontend
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend (Modified)
|
||||
- `backend/homework/models.py` - Add new models
|
||||
- `backend/homework/serializers.py` - Add new serializers
|
||||
- `backend/homework/views.py` - Add new ViewSet
|
||||
- `backend/homework/urls.py` - Add new routes
|
||||
- `backend/homework/admin.py` - Register new models
|
||||
|
||||
### Frontend (Modified)
|
||||
- `frontend/src/lib/api.ts` - Add new types
|
||||
- `frontend/src/app/(dashboard)/trainings/[id]/page.tsx` - Complete redesign
|
||||
- `frontend/src/app/(dashboard)/homework/page.tsx` - Complete rewrite
|
||||
- `frontend/src/app/(dashboard)/dashboard/page.tsx` - Add homework stats
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Backend - New Models
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/homework/models.py`
|
||||
|
||||
- [ ] **Step 1: Add new models at end of file**
|
||||
|
||||
```python
|
||||
class TrainingHomework(models.Model):
|
||||
training = models.ForeignKey('trainings.Training', on_delete=models.CASCADE, related_name='homework_assignments')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['training']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Homework for Training {self.training_id}"
|
||||
|
||||
|
||||
class TrainingHomeworkExercise(models.Model):
|
||||
training_homework = models.ForeignKey(TrainingHomework, on_delete=models.CASCADE, related_name='exercises')
|
||||
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_homework_items')
|
||||
reps = models.PositiveIntegerField(null=True, blank=True)
|
||||
time_minutes = models.PositiveIntegerField(null=True, blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['training_homework', 'order']
|
||||
unique_together = ['training_homework', 'exercise']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.training_homework} - {self.exercise.name}"
|
||||
|
||||
|
||||
class TrainingHomeworkAssignment(models.Model):
|
||||
training_homework = models.ForeignKey(TrainingHomework, on_delete=models.CASCADE, related_name='assignments')
|
||||
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='training_homework_assignments')
|
||||
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='training_homework_assignments', null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
is_completed = models.BooleanField(default=False)
|
||||
completion_date = models.DateField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['training_homework', 'wrestler']
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['wrestler']),
|
||||
models.Index(fields=['is_completed']),
|
||||
models.Index(fields=['club']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.wrestler} - Training {self.training_homework.training_id}"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run makemigrations**
|
||||
|
||||
Run: `cd backend && python manage.py makemigrations homework`
|
||||
Expected: "Migrations for 'homework': homework/migrations/xxx_create_training_homework.py"
|
||||
|
||||
- [ ] **Step 3: Run migrate**
|
||||
|
||||
Run: `cd backend && python manage.py migrate`
|
||||
Expected: "Applying homework.xxx_create_training_homework... OK"
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend - Serializers
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/homework/serializers.py`
|
||||
|
||||
- [ ] **Step 1: Add new serializers**
|
||||
|
||||
Add at end of file:
|
||||
|
||||
```python
|
||||
class TrainingHomeworkExerciseSerializer(serializers.ModelSerializer):
|
||||
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
|
||||
exercise_category = serializers.CharField(source='exercise.category', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TrainingHomeworkExercise
|
||||
fields = ['id', 'exercise', 'exercise_name', 'exercise_category', 'reps', 'time_minutes', 'order']
|
||||
|
||||
|
||||
class TrainingHomeworkSerializer(serializers.ModelSerializer):
|
||||
exercises = TrainingHomeworkExerciseSerializer(many=True, read_only=True)
|
||||
training_date = serializers.DateField(source='training.date', read_only=True)
|
||||
training_group = serializers.CharField(source='training.group', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TrainingHomework
|
||||
fields = ['id', 'training', 'training_date', 'training_group', 'exercises', 'created_at']
|
||||
|
||||
|
||||
class TrainingHomeworkAssignmentSerializer(serializers.ModelSerializer):
|
||||
training_homework_detail = TrainingHomeworkSerializer(source='training_homework', read_only=True)
|
||||
wrestler_name = serializers.SerializerMethodField()
|
||||
wrestler_group = serializers.CharField(source='wrestler.group', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TrainingHomeworkAssignment
|
||||
fields = ['id', 'training_homework', 'training_homework_detail', 'wrestler', 'wrestler_name', 'wrestler_group', 'notes', 'is_completed', 'completion_date', 'created_at']
|
||||
|
||||
def get_wrestler_name(self, obj):
|
||||
return f"{obj.wrestler.first_name} {obj.wrestler.last_name}"
|
||||
|
||||
|
||||
class TrainingHomeworkAssignmentCreateSerializer(serializers.Serializer):
|
||||
training = serializers.IntegerField()
|
||||
wrestler = serializers.IntegerField()
|
||||
exercises = serializers.ListField(
|
||||
child=serializers.DictField(child=serializers.IntegerField(allow_null=True))
|
||||
)
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
def create(self, validated_data):
|
||||
from clubs.utils import get_user_club
|
||||
from exercises.models import Exercise
|
||||
from trainings.models import Training
|
||||
from wrestlers.models import Wrestler
|
||||
|
||||
user = self.context['request'].user
|
||||
club = get_user_club(user)
|
||||
|
||||
training_id = validated_data['training']
|
||||
wrestler_id = validated_data['wrestler']
|
||||
exercises_data = validated_data['exercises']
|
||||
notes = validated_data.get('notes', '')
|
||||
|
||||
# Create or get TrainingHomework for this training
|
||||
training_obj = Training.objects.get(id=training_id)
|
||||
training_homework, _ = TrainingHomework.objects.get_or_create(training=training_obj)
|
||||
|
||||
# Create assignment
|
||||
assignment = TrainingHomeworkAssignment.objects.create(
|
||||
training_homework=training_homework,
|
||||
wrestler_id=wrestler_id,
|
||||
club=club,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Add exercises
|
||||
for i, ex in enumerate(exercises_data):
|
||||
TrainingHomeworkExercise.objects.create(
|
||||
training_homework=training_homework,
|
||||
exercise_id=ex['exercise'],
|
||||
reps=ex.get('reps'),
|
||||
time_minutes=ex.get('time_minutes'),
|
||||
order=i
|
||||
)
|
||||
|
||||
return assignment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend - Views
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/homework/views.py`
|
||||
|
||||
- [ ] **Step 1: Add new ViewSet**
|
||||
|
||||
Add at end of file:
|
||||
|
||||
```python
|
||||
class TrainingHomeworkAssignmentViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, ClubLevelPermission]
|
||||
filter_backends = [ClubFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
serializer_class = TrainingHomeworkAssignmentSerializer
|
||||
filterset_fields = ['is_completed']
|
||||
search_fields = ['wrestler__first_name', 'wrestler__last_name']
|
||||
ordering_fields = ['created_at', 'is_completed']
|
||||
http_method_names = ['get', 'post', 'patch', 'delete']
|
||||
|
||||
def get_queryset(self):
|
||||
from clubs.utils import get_user_club
|
||||
club = get_user_club(self.request.user)
|
||||
return TrainingHomeworkAssignment.objects.filter(club=club).select_related(
|
||||
'training_homework', 'training_homework__training', 'wrestler'
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return TrainingHomeworkAssignmentCreateSerializer
|
||||
return TrainingHomeworkAssignmentSerializer
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def complete(self, request, pk=None):
|
||||
assignment = self.get_object()
|
||||
assignment.is_completed = True
|
||||
assignment.completion_date = timezone.now().date()
|
||||
assignment.save()
|
||||
serializer = self.get_serializer(assignment)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def uncomplete(self, request, pk=None):
|
||||
assignment = self.get_object()
|
||||
assignment.is_completed = False
|
||||
assignment.completion_date = None
|
||||
assignment.save()
|
||||
serializer = self.get_serializer(assignment)
|
||||
return Response(serializer.data)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Import new serializers at top of file**
|
||||
|
||||
Add to imports:
|
||||
```python
|
||||
from .serializers import (
|
||||
# ... existing imports ...
|
||||
TrainingHomeworkAssignmentSerializer,
|
||||
TrainingHomeworkAssignmentCreateSerializer,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Backend - URLs
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/homework/urls.py`
|
||||
|
||||
- [ ] **Step 1: Add new route**
|
||||
|
||||
Add to urlpatterns:
|
||||
```python
|
||||
router.register(r'training-assignments', views.TrainingHomeworkAssignmentViewSet, basename='training-assignment')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Backend - Admin
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/homework/admin.py`
|
||||
|
||||
- [ ] **Step 1: Register new models**
|
||||
|
||||
Add at end of file:
|
||||
```python
|
||||
@admin.register(TrainingHomework)
|
||||
class TrainingHomeworkAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'training', 'created_at']
|
||||
list_select_related = ['training']
|
||||
|
||||
|
||||
@admin.register(TrainingHomeworkAssignment)
|
||||
class TrainingHomeworkAssignmentAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'wrestler', 'training_homework', 'is_completed', 'created_at']
|
||||
list_filter = ['is_completed', 'created_at']
|
||||
list_select_related = ['wrestler', 'training_homework__training']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend - Types
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/api.ts`
|
||||
|
||||
- [ ] **Step 1: Add new interfaces**
|
||||
|
||||
Add after existing homework interfaces:
|
||||
|
||||
```typescript
|
||||
export interface ITrainingHomeworkExercise {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
exercise_category: string
|
||||
reps: number | null
|
||||
time_minutes: number | null
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface ITrainingHomework {
|
||||
id: number
|
||||
training: number
|
||||
training_date: string
|
||||
training_group: string
|
||||
exercises: ITrainingHomeworkExercise[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ITrainingHomeworkAssignment {
|
||||
id: number
|
||||
training_homework: number
|
||||
training_homework_detail: ITrainingHomework
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
wrestler_group: string
|
||||
notes: string
|
||||
is_completed: boolean
|
||||
completion_date: string | null
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend - Training Page Redesign
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx`
|
||||
|
||||
This is a complete rewrite. Key changes:
|
||||
- 2-column layout: left = participants, right = training homework for this session
|
||||
- Each participant has a homework button (BookOpen icon)
|
||||
- Click opens modal to select exercises and assign
|
||||
|
||||
- [ ] **Step 1: Read current file** (already done above)
|
||||
|
||||
- [ ] **Step 2: Replace the entire file content**
|
||||
|
||||
Write new content with:
|
||||
1. State for homework modal
|
||||
2. State for selected exercises (with reps/time)
|
||||
3. Fetch exercises for the modal
|
||||
4. POST to `/homework/training-assignments/` on submit
|
||||
5. 2-column layout below details section
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Frontend - Homework Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx`
|
||||
|
||||
Complete rewrite:
|
||||
- Fetch from `/homework/training-assignments/`
|
||||
- Group by training date
|
||||
- Filter: All / Open / Completed
|
||||
- Search by wrestler name
|
||||
- Each assignment shows wrestler, exercises, status
|
||||
- Toggle complete/uncomplete via API
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Frontend - Dashboard Stats
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/dashboard/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add homework count to stats**
|
||||
|
||||
Add a new stat card showing count of incomplete assignments from `/homework/training-assignments/?is_completed=false`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Build & Test
|
||||
|
||||
- [ ] **Step 1: Run backend migrate**
|
||||
|
||||
Run: `cd backend && python manage.py migrate`
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
- [ ] **Step 3: Test manually**
|
||||
|
||||
1. Go to training detail page
|
||||
2. Add a participant
|
||||
3. Click homework button on participant
|
||||
4. Select exercises and assign
|
||||
5. Go to homework page and verify assignment appears
|
||||
6. Toggle completion status
|
||||
7. Check dashboard for homework count
|
||||
@@ -0,0 +1,529 @@
|
||||
# Trainings Calendar View - 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 a calendar month view to the Trainings page as an alternative to grid/list views using react-big-calendar.
|
||||
|
||||
**Architecture:** Install react-big-calendar, create a CalendarView component with custom styling to match Shadcn theme, integrate with existing view toggle system, and add day-click popover for training details.
|
||||
|
||||
**Tech Stack:** react-big-calendar, date-fns, existing Shadcn UI components
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/app/(dashboard)/trainings/page.tsx # Modify: add calendar view toggle
|
||||
├── src/components/ui/calendar-view.tsx # Create: CalendarView component
|
||||
├── src/components/ui/calendar.css # Create: Calendar custom styles
|
||||
├── package.json # Modify: add react-big-calendar, date-fns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install Dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
|
||||
- [ ] **Step 1: Add dependencies**
|
||||
|
||||
```bash
|
||||
cd /Volumes/T3/Opencode/WrestleDesk/frontend
|
||||
npm install react-big-calendar date-fns
|
||||
npm install -D @types/react-big-calendar
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify install**
|
||||
|
||||
Run: `npm list react-big-calendar date-fns`
|
||||
Expected: Both packages listed
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Calendar CSS
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ui/calendar.css`
|
||||
|
||||
- [ ] **Step 1: Write calendar styles**
|
||||
|
||||
```css
|
||||
.rbc-calendar {
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rbc-toolbar {
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rbc-toolbar button {
|
||||
color: hsl(var(--foreground));
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rbc-toolbar button:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.rbc-toolbar button.rbc-active {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.rbc-toolbar-label {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.rbc-header {
|
||||
padding: 0.75rem 0;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.rbc-month-view {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-month-row {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.rbc-month-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rbc-day-bg {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.rbc-day-bg:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.rbc-today {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.rbc-off-range-bg {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.rbc-date-cell {
|
||||
padding: 0.5rem;
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rbc-date-cell > a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rbc-event {
|
||||
background: hsl(var(--primary));
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.rbc-event.rbc-selected {
|
||||
background: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
.rbc-show-more {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rbc-overlay {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
padding: 0.5rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.rbc-overlay-header {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create CalendarView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ui/calendar-view.tsx`
|
||||
|
||||
- [ ] **Step 1: Write CalendarView component**
|
||||
|
||||
```tsx
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react"
|
||||
import { Calendar, dateFnsLocalizer, Views, SlotInfo } from "react-big-calendar"
|
||||
import { format, parse, startOfWeek, getDay, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css"
|
||||
import "./calendar.css"
|
||||
|
||||
import { ITraining } from "@/lib/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Clock, MapPin, Users, Eye, Pencil, Trash2, X
|
||||
} from "lucide-react"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
import { apiFetch, PaginatedResponse } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const groupConfig = {
|
||||
kids: { label: "Kinder", class: "bg-primary/10 text-primary border-primary/20" },
|
||||
youth: { label: "Jugend", class: "bg-secondary/10 text-secondary border-secondary/20" },
|
||||
adults: { label: "Erwachsene", class: "bg-accent/10 text-accent border-accent/20" },
|
||||
all: { label: "Alle", class: "bg-muted text-muted-foreground" },
|
||||
}
|
||||
|
||||
const locales = { "de": de }
|
||||
|
||||
const localizer = dateFnsLocalizer({
|
||||
format,
|
||||
parse,
|
||||
startOfWeek: () => startOfWeek(new Date(), { locale: de }),
|
||||
getDay,
|
||||
locales,
|
||||
})
|
||||
|
||||
interface CalendarViewProps {
|
||||
onEdit: (training: ITraining) => void
|
||||
onDelete: (id: number) => void
|
||||
onView: (training: ITraining) => void
|
||||
refreshTrigger: number
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number
|
||||
title: string
|
||||
start: Date
|
||||
end: Date
|
||||
resource: ITraining
|
||||
}
|
||||
|
||||
export function CalendarView({ onEdit, onDelete, onView, refreshTrigger }: CalendarViewProps) {
|
||||
const { token } = useAuth()
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [trainings, setTrainings] = useState<ITraining[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedDayTrainings, setSelectedDayTrainings] = useState<ITraining[]>([])
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
|
||||
const fetchTrainings = useCallback(async (date: Date) => {
|
||||
if (!token) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const start = startOfMonth(subMonths(date, 1))
|
||||
const end = endOfMonth(addMonths(date, 1))
|
||||
const params = new URLSearchParams()
|
||||
params.set("date_from", format(start, "yyyy-MM-dd"))
|
||||
params.set("date_to", format(end, "yyyy-MM-dd"))
|
||||
params.set("page_size", "100")
|
||||
|
||||
const data = await apiFetch<PaginatedResponse<ITraining>>(`/trainings/?${params.toString()}`, { token })
|
||||
setTrainings(data.results || [])
|
||||
} catch {
|
||||
toast.error("Fehler beim Laden der Trainingseinheiten")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrainings(currentDate)
|
||||
}, [fetchTrainings, currentDate, refreshTrigger])
|
||||
|
||||
const events: CalendarEvent[] = useMemo(() => {
|
||||
return trainings
|
||||
.filter(t => t.date)
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
title: `${t.start_time || ""} - ${t.group ? groupConfig[t.group as keyof typeof groupConfig]?.label : ""}`,
|
||||
start: new Date(`${t.date}T${t.start_time || "00:00"}`),
|
||||
end: new Date(`${t.date}T${t.end_time || "23:59"}`),
|
||||
resource: t,
|
||||
}))
|
||||
}, [trainings])
|
||||
|
||||
const handleSelectEvent = useCallback((event: CalendarEvent) => {
|
||||
onView(event.resource)
|
||||
}, [onView])
|
||||
|
||||
const handleSelectSlot = useCallback((slotInfo: SlotInfo) => {
|
||||
const dayTrainings = trainings.filter(t => {
|
||||
const trainingDate = new Date(t.date)
|
||||
return format(trainingDate, "yyyy-MM-dd") === format(slotInfo.start, "yyyy-MM-dd")
|
||||
})
|
||||
if (dayTrainings.length > 0) {
|
||||
setSelectedDay(slotInfo.start)
|
||||
setSelectedDayTrainings(dayTrainings)
|
||||
setPopoverOpen(true)
|
||||
}
|
||||
}, [trainings])
|
||||
|
||||
const eventStyleGetter = useCallback((event: CalendarEvent) => {
|
||||
const group = event.resource.group
|
||||
const config = groupConfig[group as keyof typeof groupConfig] || groupConfig.all
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: `hsl(var(--primary))`,
|
||||
borderRadius: "0.25rem",
|
||||
border: "none",
|
||||
color: `hsl(var(--primary-foreground))`,
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const CustomEvent = ({ event }: { event: CalendarEvent }) => (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="font-medium">{event.resource.start_time}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CustomToolbar = ({ label, onNavigate, onView, view }: any) => (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onNavigate("PREV")}>
|
||||
‹
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onNavigate("TODAY")}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onNavigate("NEXT")}>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={view === "month" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onView("month")}
|
||||
>
|
||||
Monat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<style>{`
|
||||
.rbc-calendar { height: calc(100vh - 280px); min-height: 600px; }
|
||||
`}</style>
|
||||
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
view={Views.MONTH}
|
||||
views={[Views.MONTH]}
|
||||
date={currentDate}
|
||||
onNavigate={setCurrentDate}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onSelectSlot={handleSelectSlot}
|
||||
selectable
|
||||
eventPropGetter={eventStyleGetter}
|
||||
components={{
|
||||
event: CustomEvent,
|
||||
toolbar: CustomToolbar,
|
||||
}}
|
||||
messages={{
|
||||
today: "Heute",
|
||||
month: "Monat",
|
||||
week: "Woche",
|
||||
day: "Tag",
|
||||
}}
|
||||
/>
|
||||
|
||||
{popoverOpen && selectedDay && (
|
||||
<div className="fixed inset-0 z-50" onClick={() => setPopoverOpen(false)}>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-96">
|
||||
<Card className="shadow-xl">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{format(selectedDay, "EEEE, d. MMMM", { locale: de })}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPopoverOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-64 overflow-y-auto space-y-2">
|
||||
{selectedDayTrainings.map(training => (
|
||||
<div
|
||||
key={training.id}
|
||||
className="flex items-center justify-between p-2 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
onView(training)
|
||||
setPopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium">
|
||||
{training.start_time} - {training.end_time}
|
||||
</div>
|
||||
<Badge
|
||||
className={groupConfig[training.group as keyof typeof groupConfig]?.class}
|
||||
variant="secondary"
|
||||
>
|
||||
{groupConfig[training.group as keyof typeof groupConfig]?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
{training.attendance_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Integrate Calendar into Trainings Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/trainings/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
Add to imports section:
|
||||
```tsx
|
||||
import { CalendarView } from "@/components/ui/calendar-view"
|
||||
import { Calendar } from "lucide-react"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update viewMode state**
|
||||
|
||||
Change line ~50:
|
||||
```tsx
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list" | "calendar">("grid")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update toggle buttons**
|
||||
|
||||
Find the existing toggle (around line 420-440) and update to:
|
||||
```tsx
|
||||
<div className="flex border rounded-lg">
|
||||
<button
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`p-2 ${viewMode === "grid" ? "bg-muted" : "hover:bg-muted/50"} rounded-l-lg transition-colors`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`p-2 ${viewMode === "list" ? "bg-muted" : "hover:bg-muted/50"} transition-colors`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("calendar")}
|
||||
className={`p-2 ${viewMode === "calendar" ? "bg-muted" : "hover:bg-muted/50"} rounded-r-lg transition-colors`}
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add CalendarView rendering**
|
||||
|
||||
After the existing grid/list views (around line 525), add:
|
||||
```tsx
|
||||
{viewMode === "calendar" && (
|
||||
<FadeIn delay={0.1}>
|
||||
<CalendarView
|
||||
onEdit={handleEdit}
|
||||
onDelete={(id) => setDeleteId(id)}
|
||||
onView={(training) => router.push(`/trainings/${training.id}`)}
|
||||
refreshTrigger={trainings.length}
|
||||
/>
|
||||
</FadeIn>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Hide pagination in calendar view**
|
||||
|
||||
Find pagination section and update:
|
||||
```tsx
|
||||
{totalPages > 1 && viewMode !== "calendar" && (
|
||||
<Pagination ... />
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Build and Test
|
||||
|
||||
- [ ] **Step 1: Run build**
|
||||
|
||||
Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run build`
|
||||
Expected: SUCCESS
|
||||
|
||||
- [ ] **Step 2: Test in browser**
|
||||
|
||||
Run: `cd /Volumes/T3/Opencode/WrestleDesk/frontend && npm run dev`
|
||||
Navigate to: http://localhost:3000/trainings
|
||||
- Click calendar icon in toggle
|
||||
- Verify calendar renders with month navigation
|
||||
- Click on a day to see trainings popover
|
||||
- Navigate between months
|
||||
- Click "Heute" to return to current month
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(trainings): add calendar month view with react-big-calendar"
|
||||
```
|
||||
@@ -0,0 +1,310 @@
|
||||
# Calendar UI Improvements - 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:** Enhance calendar with better colors, today highlighting, group-based event colors, hover tooltips, and click-to-day-panel
|
||||
|
||||
**Architecture:** Frontend-only CSS and component changes to existing calendar-view.tsx and calendar.css
|
||||
|
||||
**Tech Stack:** React, TypeScript, Tailwind CSS, react-big-calendar, date-fns
|
||||
|
||||
---
|
||||
|
||||
## Files Overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/src/components/ui/calendar-view.tsx` | Main calendar component |
|
||||
| `frontend/src/components/ui/calendar.css` | Calendar styling overrides |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Enhance "Today" Highlighting
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ui/calendar.css:68-70`
|
||||
|
||||
**Changes:**
|
||||
- Replace today's subtle background with light green accent
|
||||
- Add subtle animation pulse effect
|
||||
- Make date number bold
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Update .rbc-today styling**
|
||||
Replace current implementation with light green accent
|
||||
|
||||
```css
|
||||
.rbc-today {
|
||||
background: hsl(142, 76%, 96%);
|
||||
border: 2px solid hsl(142, 76%, 45%);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add today date cell bold styling**
|
||||
Make today's date number more prominent
|
||||
|
||||
```css
|
||||
.rbc-today .rbc-date-cell {
|
||||
font-weight: 700;
|
||||
color: hsl(142, 76%, 30%);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test in browser**
|
||||
Navigate to /trainings and verify today is highlighted
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Group-Based Event Colors
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ui/calendar.css:87-94`
|
||||
- Modify: `frontend/src/components/ui/calendar-view.tsx:114-123`
|
||||
|
||||
**Changes:**
|
||||
- Kids: Blue (#3B82F6) with 15% opacity background
|
||||
- Youth: Purple (#8B5CF6) with 15% opacity background
|
||||
- Adults: Orange (#F97316) with 15% opacity background
|
||||
- Text in dark version of each color for readability
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Define group color constants in calendar-view.tsx**
|
||||
Add color configuration
|
||||
|
||||
```typescript
|
||||
const groupColors = {
|
||||
kids: { bg: "rgba(59, 130, 246, 0.15)", text: "#1E40AF", border: "#3B82F6" },
|
||||
youth: { bg: "rgba(139, 92, 246, 0.15)", text: "#5B21B6", border: "#8B5CF6" },
|
||||
adults: { bg: "rgba(249, 115, 22, 0.15)", text: "#9A3412", border: "#F97316" },
|
||||
all: { bg: "rgba(100, 100, 100, 0.15)", text: "#404040", border: "#666666" },
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update eventStyleGetter to use group colors**
|
||||
Modify function at line 114
|
||||
|
||||
```typescript
|
||||
const eventStyleGetter = useCallback((event: CalendarEvent) => {
|
||||
const group = event.resource.group || "all"
|
||||
const colors = groupColors[group as keyof typeof groupColors]
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: colors.bg,
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
borderRadius: "0.25rem",
|
||||
border: "none",
|
||||
color: colors.text,
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update CSS for events**
|
||||
Replace existing .rbc-event styles
|
||||
|
||||
```css
|
||||
.rbc-event {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 3px solid;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test in browser**
|
||||
Verify each group shows correct color
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Hover Tooltip
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ui/calendar-view.tsx`
|
||||
- Create: `frontend/src/components/ui/calendar-tooltip.tsx` (optional, can be inline)
|
||||
|
||||
**Changes:**
|
||||
- Show tooltip on event hover with training details
|
||||
- Tooltip shows: Time, Group badge, Location, Attendee count
|
||||
- Position tooltip above event
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Add tooltip state and handlers to calendar-view.tsx**
|
||||
Add state for tooltip visibility, position, and content
|
||||
|
||||
```typescript
|
||||
const [tooltipInfo, setTooltipInfo] = useState<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
content: { time: string, group: string, location: string, attendees: number }
|
||||
} | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create custom Event component with hover**
|
||||
Replace CustomEvent at line 125
|
||||
|
||||
```typescript
|
||||
const CustomEvent = ({ event }: { event: CalendarEvent }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={(e) => {
|
||||
setShowTooltip(true)
|
||||
setTooltipInfo({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY - 10,
|
||||
content: {
|
||||
time: `${event.resource.start_time} - ${event.resource.end_time}`,
|
||||
group: groupConfig[event.resource.group as keyof typeof groupConfig]?.label || "",
|
||||
location: event.resource.location_name || "Kein Ort",
|
||||
attendees: event.resource.attendance_count || 0,
|
||||
}
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="font-medium">{event.resource.start_time}</span>
|
||||
</div>
|
||||
{showTooltip && tooltipInfo?.visible && (
|
||||
<div
|
||||
className="fixed z-50 bg-background border rounded-lg shadow-lg p-3 text-sm"
|
||||
style={{ left: tooltipInfo.x, top: tooltipInfo.y, transform: 'translateY(-100%)' }}
|
||||
>
|
||||
<div className="font-medium mb-1">{tooltipInfo.content.time}</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Badge className={groupConfig[event.resource.group as keyof typeof groupConfig]?.class}>
|
||||
{tooltipInfo.content.group}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
📍 {tooltipInfo.content.location}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
👥 {tooltipInfo.content.attendees} Teilnehmer
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test hover in browser**
|
||||
Hover over training events and verify tooltip appears
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Click on Day → Trainings Panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ui/calendar-view.tsx:180-230`
|
||||
|
||||
**Changes:**
|
||||
- Replace bottom popover with improved side panel or modal
|
||||
- Show all trainings for selected day
|
||||
- Better styling with group colors
|
||||
- Click on training opens detail view
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Import Sheet component**
|
||||
Add Sheet import at top of file
|
||||
|
||||
```typescript
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace popover state with sheet state**
|
||||
Change state from popoverOpen to sheetOpen
|
||||
|
||||
```typescript
|
||||
const [selectedDaySheetOpen, setSelectedDaySheetOpen] = useState(false)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace popover JSX with Sheet component**
|
||||
Replace lines 180-230 with:
|
||||
|
||||
```typescript
|
||||
<Sheet open={selectedDaySheetOpen} onOpenChange={setSelectedDaySheetOpen}>
|
||||
<SheetContent side="bottom" className="h-[50vh] sm:max-w-[600px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{format(selectedDay!, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 space-y-3 max-h-[calc(50vh-100px)] overflow-y-auto">
|
||||
{selectedDayTrainings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
Keine Trainings an diesem Tag
|
||||
</p>
|
||||
) : (
|
||||
selectedDayTrainings.map(training => (
|
||||
<div
|
||||
key={training.id}
|
||||
className="flex items-center justify-between p-4 rounded-xl border hover:bg-muted/50 transition-all cursor-pointer"
|
||||
style={{
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: groupColors[training.group as keyof typeof groupColors]?.border || "#666"
|
||||
}}
|
||||
onClick={() => {
|
||||
onView(training)
|
||||
setSelectedDaySheetOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-lg font-semibold">
|
||||
{training.start_time} - {training.end_time}
|
||||
</div>
|
||||
<Badge
|
||||
className={groupConfig[training.group as keyof typeof groupConfig]?.class}
|
||||
>
|
||||
{groupConfig[training.group as keyof typeof groupConfig]?.label}
|
||||
</Badge>
|
||||
{training.location_name && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{training.location_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{training.attendance_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update handleSelectSlot to use new state**
|
||||
Change line 107-111 to set `setSelectedDaySheetOpen(true)` instead of `setPopoverOpen(true)`
|
||||
|
||||
- [ ] **Step 5: Test in browser**
|
||||
Click on a day with trainings and verify Sheet appears
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all tasks:
|
||||
- [ ] Today is highlighted with light green accent and bold date
|
||||
- [ ] Training events show group colors (blue/purple/orange)
|
||||
- [ ] Hover on event shows tooltip with details
|
||||
- [ ] Click on day opens bottom Sheet with training list
|
||||
- [ ] Click on training in Sheet opens detail view
|
||||
- [ ] No console errors
|
||||
- [ ] Responsive on mobile
|
||||
@@ -0,0 +1,278 @@
|
||||
# Dashboard Statistics 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:** Expand Dashboard with comprehensive statistics in Bento Grid layout - attendance by group, homework completion rates, wrestler distribution, and trainer activity.
|
||||
|
||||
**Architecture:** Backend provides single `/api/v1/stats/dashboard/` endpoint aggregating all statistics. Frontend displays in Bento Grid with progress bars and simple bar charts using CSS/divs (no external chart library needed).
|
||||
|
||||
**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend), CSS progress bars (no chart library)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend
|
||||
- **Create:** `backend/stats/__init__.py` - App init
|
||||
- **Create:** `backend/stats/apps.py` - App config
|
||||
- **Create:** `backend/stats/views.py` - DashboardStatsViewSet
|
||||
- **Modify:** `backend/wrestleDesk/urls.py` - Add stats endpoint
|
||||
- **Modify:** `backend/settings.py` - Add 'stats' to INSTALLED_APPS
|
||||
|
||||
### Frontend
|
||||
- **Modify:** `frontend/src/lib/api.ts` - Add `IDashboardStats` interface
|
||||
- **Modify:** `frontend/src/app/(dashboard)/dashboard/page.tsx` - New stat cards and visualizations
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create Stats Backend App
|
||||
|
||||
- [ ] **Step 1: Create stats app directory structure**
|
||||
|
||||
Create `backend/stats/` with:
|
||||
```
|
||||
stats/
|
||||
├── __init__.py
|
||||
├── apps.py
|
||||
└── views.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create apps.py**
|
||||
|
||||
```python
|
||||
from django.apps import AppConfig
|
||||
|
||||
class StatsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'stats'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add 'stats' to INSTALLED_APPS in backend/settings.py**
|
||||
|
||||
Add `'stats'` to the INSTALLED_APPS list.
|
||||
|
||||
- [ ] **Step 4: Create DashboardStatsViewSet in 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, Sum
|
||||
from datetime import datetime, timedelta
|
||||
from wrestlers.models import Wrestler
|
||||
from trainers.models import Trainer
|
||||
from trainings.models import Training, Attendance
|
||||
from homework.models import TrainingHomeworkAssignment
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def dashboard_stats(request):
|
||||
today = datetime.now().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
two_weeks_ago = today - timedelta(days=14)
|
||||
|
||||
# Wrestlers stats
|
||||
total_wrestlers = Wrestler.objects.count()
|
||||
wrestlers_this_week = Wrestler.objects.filter(
|
||||
created_at__date__gte=week_start
|
||||
).count()
|
||||
|
||||
# Trainers stats
|
||||
total_trainers = Trainer.objects.count()
|
||||
active_trainers = Trainer.objects.filter(is_active=True).count()
|
||||
|
||||
# Trainings stats
|
||||
total_trainings = Training.objects.count()
|
||||
trainings_this_week = Training.objects.filter(
|
||||
date__gte=week_start
|
||||
).count()
|
||||
|
||||
# Homework stats
|
||||
open_homework = TrainingHomeworkAssignment.objects.filter(is_completed=False).count()
|
||||
completed_homework = TrainingHomeworkAssignment.objects.filter(is_completed=True).count()
|
||||
|
||||
# Attendance by group this week
|
||||
trainings_this_week_qs = Training.objects.filter(date__gte=week_start)
|
||||
attendance_data = {}
|
||||
for group, label in [('kids', 'Kinder'), ('youth', 'Jugend'), ('adults', 'Erwachsene')]:
|
||||
group_wrestlers = Wrestler.objects.filter(group=group)
|
||||
attended = Attendance.objects.filter(
|
||||
training__in=trainings_this_week_qs,
|
||||
wrestler__in=group_wrestlers
|
||||
).values('wrestler').distinct().count()
|
||||
total = group_wrestlers.count()
|
||||
attendance_data[group] = {
|
||||
'attended': attended,
|
||||
'total': total,
|
||||
'percent': int((attended / total * 100) if total > 0 else 0)
|
||||
}
|
||||
|
||||
# Activity (last 14 days)
|
||||
activity = []
|
||||
for i in range(14):
|
||||
day = today - timedelta(days=13 - i)
|
||||
count = Attendance.objects.filter(training__date=day).count()
|
||||
activity.append({'date': day.isoformat(), 'count': count})
|
||||
|
||||
# Wrestlers by group
|
||||
wrestlers_by_group = {
|
||||
'kids': Wrestler.objects.filter(group='kids', is_active=True).count(),
|
||||
'youth': Wrestler.objects.filter(group='youth', is_active=True).count(),
|
||||
'adults': Wrestler.objects.filter(group='adults', is_active=True).count(),
|
||||
'inactive': Wrestler.objects.filter(is_active=False).count(),
|
||||
}
|
||||
|
||||
# Top trainers (by training count)
|
||||
trainer_stats = Trainer.objects.annotate(
|
||||
training_count=Count('trainings')
|
||||
).order_by('-training_count')[:5]
|
||||
top_trainers = [
|
||||
{'name': t.first_name + ' ' + t.last_name[0] + '.', 'training_count': t.training_count}
|
||||
for t in trainer_stats
|
||||
]
|
||||
|
||||
return Response({
|
||||
'wrestlers': {'total': total_wrestlers, 'this_week': wrestlers_this_week},
|
||||
'trainers': {'total': total_trainers, 'active': active_trainers},
|
||||
'trainings': {'total': total_trainings, 'this_week': trainings_this_week},
|
||||
'homework': {'open': open_homework, 'completed': completed_homework},
|
||||
'attendance': {
|
||||
'this_week': attendance_data,
|
||||
'average': Attendance.objects.filter(training__date__gte=week_start).values('training').distinct().count(),
|
||||
'expected': total_wrestlers
|
||||
},
|
||||
'activity': activity,
|
||||
'wrestlers_by_group': wrestlers_by_group,
|
||||
'top_trainers': top_trainers,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add stats URL to urls.py**
|
||||
|
||||
Add to `backend/wrestleDesk/urls.py`:
|
||||
```python
|
||||
from stats.views import dashboard_stats
|
||||
|
||||
path('api/v1/stats/dashboard/', dashboard_stats, name='dashboard-stats'),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Frontend API Types
|
||||
|
||||
- [ ] **Step 1: Add IDashboardStats interface to frontend/src/lib/api.ts**
|
||||
|
||||
Add after existing interfaces:
|
||||
```typescript
|
||||
export interface IDashboardStats {
|
||||
wrestlers: { total: number; this_week: number }
|
||||
trainers: { total: number; active: number }
|
||||
trainings: { total: number; this_week: number }
|
||||
homework: { open: number; completed: number }
|
||||
attendance: {
|
||||
this_week: {
|
||||
kids: { attended: number; total: number; percent: number }
|
||||
youth: { attended: number; total: number; percent: number }
|
||||
adults: { attended: number; total: number; percent: number }
|
||||
}
|
||||
average: number
|
||||
expected: number
|
||||
}
|
||||
activity: { date: string; count: number }[]
|
||||
wrestlers_by_group: {
|
||||
kids: number
|
||||
youth: number
|
||||
adults: number
|
||||
inactive: number
|
||||
}
|
||||
top_trainers: { name: string; training_count: number }[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update Dashboard Page
|
||||
|
||||
- [ ] **Step 1: Update imports in frontend/src/app/(dashboard)/dashboard/page.tsx**
|
||||
|
||||
Add `Progress` component and `IDashboardStats`:
|
||||
```typescript
|
||||
import { apiFetch, IDashboardStats } from "@/lib/api"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace Stats interface and statCards with new implementation**
|
||||
|
||||
Replace the existing interface and statCards with:
|
||||
```typescript
|
||||
const groupColors = {
|
||||
kids: "bg-blue-500",
|
||||
youth: "bg-purple-500",
|
||||
adults: "bg-orange-500",
|
||||
}
|
||||
|
||||
const groupLabels = {
|
||||
kids: "Kinder",
|
||||
youth: "Jugend",
|
||||
adults: "Erwachsene",
|
||||
inactive: "Inaktiv",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace useEffect to fetch from stats endpoint**
|
||||
|
||||
Replace `fetchStats` with:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
const fetchStats = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await apiFetch<IDashboardStats>('/stats/dashboard/', { token })
|
||||
setStats(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchStats()
|
||||
}, [token])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the dashboard content**
|
||||
|
||||
Replace the entire return section with the Bento Grid layout including:
|
||||
- 4 stat cards (enhanced)
|
||||
- Attendance by group card with progress bars
|
||||
- Training activity card with bar chart
|
||||
- Homework completion card (full width)
|
||||
- Wrestlers by group card
|
||||
- Top trainers card
|
||||
|
||||
Each card uses `FadeIn` with appropriate delay props.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend
|
||||
- Run: `cd backend && python manage.py check`
|
||||
- Test endpoint: `curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/stats/dashboard/`
|
||||
|
||||
### Frontend
|
||||
- Run: `cd frontend && npm run lint`
|
||||
- Run: `npm run typecheck`
|
||||
- Visit: http://localhost:3000/dashboard
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Progress bars use Tailwind `bg-*` classes with calculated widths
|
||||
- Bar chart uses flexbox with varying heights
|
||||
- All data loaded asynchronously with loading state
|
||||
- Error handling: console.error on failure, UI continues to show zeros
|
||||
@@ -0,0 +1,126 @@
|
||||
# WrestleDesk UI Improvements - 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:** Fix 3 issues: (1) Better homework page cards, (2) Wrestlers pagination bug, (3) Training homework icon color + Sheet conversion
|
||||
|
||||
**Architecture:** Frontend-only changes (React/Next.js with TypeScript, Tailwind, Shadcn UI)
|
||||
|
||||
**Tech Stack:** Next.js 16, React, TypeScript, Tailwind CSS, Shadcn UI (Sheet, Card, Badge, Avatar)
|
||||
|
||||
---
|
||||
|
||||
## Files Overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/src/app/(dashboard)/homework/page.tsx` | Homework page - improve card layout |
|
||||
| `frontend/src/app/(dashboard)/wrestlers/page.tsx` | Wrestlers page - fix pagination |
|
||||
| `frontend/src/app/(dashboard)/trainings/[id]/page.tsx` | Training detail - fix icon color + Sheet |
|
||||
| `frontend/src/lib/api.ts` | API types - verify ITrainingHomeworkAssignment |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Improve Homework Page Card Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/homework/page.tsx:169-246`
|
||||
|
||||
**Changes:**
|
||||
- Replace grouped card layout with individual wrestler cards
|
||||
- Each card shows: Avatar, Wrestler Name, Training Date, Group Badge, Exercise List with reps/time, Status Badge, expandable Notes
|
||||
- Grid layout: 2-3 columns on desktop, 1 on mobile
|
||||
- Exercise list shows category color coding
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Add Sheet component to imports (if not already available)**
|
||||
Check if Sheet is imported. If not, run:
|
||||
```bash
|
||||
cd frontend && npx shadcn@latest add sheet
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modify the card rendering loop (lines 169-246)**
|
||||
Replace current grouped cards with individual wrestler cards in a grid
|
||||
|
||||
- [ ] **Step 3: Test the new layout**
|
||||
Navigate to /homework and verify cards display correctly
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Fix Wrestlers Pagination Bug
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/wrestlers/page.tsx:103-126`
|
||||
- Debug: Backend `backend/wrestlers/views.py`
|
||||
|
||||
**Changes:**
|
||||
- Verify API returns correct `count` and `results` in PaginatedResponse
|
||||
- Debug why only 11 wrestlers total are returned instead of actual count
|
||||
- Possibly fix backend query or frontend display logic
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Test API directly**
|
||||
Run: `curl -H "Authorization: Bearer <token>" "http://localhost:8000/api/v1/wrestlers/?page=1&page_size=10"`
|
||||
Check response for `count` field and actual number of results
|
||||
|
||||
- [ ] **Step 2: Check backend pagination class**
|
||||
Read `backend/wrestleDesk/pagination.py` to verify StandardResultsSetPagination
|
||||
|
||||
- [ ] **Step 3: Verify frontend handles response correctly**
|
||||
Check `frontend/src/lib/api.ts` PaginatedResponse interface matches API response
|
||||
|
||||
- [ ] **Step 4: Fix identified issue**
|
||||
- If backend: fix query or pagination
|
||||
- If frontend: fix state update or display logic
|
||||
|
||||
- [ ] **Step 5: Test pagination**
|
||||
Navigate to /wrestlers, verify page 1 shows 10 items, page 2 shows correct items
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Training Homework - Icon Color + Sheet
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx:336-395` (participant icons)
|
||||
- Modify: `frontend/src/app/(dashboard)/trainings/[id]/page.tsx:561-672` (Modal → Sheet)
|
||||
|
||||
**Changes:**
|
||||
- BookOpen icon color: green if wrestler has homework assignment, gray/muted if not
|
||||
- Convert homework Modal to Sheet component
|
||||
- In Sheet, show wrestlers with existing HA marked green
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Add Sheet component to imports (if not already available)**
|
||||
Check if Sheet is imported. If not, run:
|
||||
```bash
|
||||
cd frontend && npx shadcn@latest add sheet
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modify participant icon colors (lines 336-395)**
|
||||
Add conditional styling to BookOpen icon:
|
||||
- Green/primary color if `wrestlerAssignments.length > 0`
|
||||
- Muted/gray color if no assignments
|
||||
|
||||
- [ ] **Step 3: Convert homework Modal to Sheet (lines 561-672)**
|
||||
Replace `<Modal>` with `<Sheet>` component
|
||||
Keep the same form content inside
|
||||
|
||||
- [ ] **Step 4: Add green highlight for wrestlers with HA in Sheet**
|
||||
When opening homework sheet, show wrestlers that already have assignments highlighted
|
||||
|
||||
- [ ] **Step 5: Test the changes**
|
||||
Navigate to /trainings/[id], verify icon colors and Sheet functionality
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all tasks:
|
||||
- [ ] Homework page shows cards with all details (Avatar, Name, Training, Exercises, Status, Notes)
|
||||
- [ ] Wrestlers pagination works: page 1 = 10 items, page 2 = next 10 items
|
||||
- [ ] Training detail: BookOpen icon is green for wrestlers with HA, gray for those without
|
||||
- [ ] Training detail: Homework assignment opens as Sheet, not Modal
|
||||
- [ ] No console errors on any of the affected pages
|
||||
@@ -0,0 +1,415 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,677 @@
|
||||
# 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
|
||||
@@ -0,0 +1,165 @@
|
||||
# View & Filter Persistence - 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:** Set calendar as default view on trainings page and ensure all filter settings persist across pages via backend preferences API
|
||||
|
||||
**Architecture:** Frontend-only changes - modify trainings/page.tsx to load/save viewMode from preferences API
|
||||
|
||||
**Tech Stack:** React, TypeScript, Next.js, API preferences
|
||||
|
||||
---
|
||||
|
||||
## Files Overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/src/app/(dashboard)/trainings/page.tsx` | Trainings page - add viewMode persistence |
|
||||
| `frontend/src/app/(dashboard)/trainers/page.tsx` | Check trainers filter persistence |
|
||||
| `frontend/src/app/(dashboard)/exercises/page.tsx` | Check exercises filter persistence |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Set Calendar as Default + View Persistence
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/app/(dashboard)/trainings/page.tsx`
|
||||
|
||||
**Changes:**
|
||||
1. Load `trainings_view` from preferences API in `fetchPreferences`
|
||||
2. Set initial `viewMode` state from saved preference (default: "calendar")
|
||||
3. Save `trainings_view` when view changes
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Update fetchPreferences to load trainings_view**
|
||||
|
||||
Find `fetchPreferences` function (~line 71-84) and add loading of `trainings_view`:
|
||||
|
||||
```typescript
|
||||
const fetchPreferences = useCallback(async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
const prefs = await apiFetch<{trainings_view?: string; trainings_filters: Record<string, string>}>(`/auth/preferences/`, { token })
|
||||
setFilters({
|
||||
group: prefs.trainings_filters?.group || "all",
|
||||
date_from: prefs.trainings_filters?.date_from || "",
|
||||
date_to: prefs.trainings_filters?.date_to || "",
|
||||
})
|
||||
// NEW: Load view preference
|
||||
if (prefs.trainings_view) {
|
||||
setViewMode(prefs.trainings_view as "grid" | "list" | "calendar")
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch preferences")
|
||||
}
|
||||
}, [token])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create saveViewPreference function**
|
||||
|
||||
Add after `savePreferences` (~line 97):
|
||||
|
||||
```typescript
|
||||
const saveViewPreference = useCallback(async (view: "grid" | "list" | "calendar") => {
|
||||
if (!token) return
|
||||
try {
|
||||
await apiFetch(`/auth/preferences/`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({ trainings_view: view }),
|
||||
})
|
||||
} catch {
|
||||
console.error("Failed to save view preference")
|
||||
}
|
||||
}, [token])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add handleViewChange function**
|
||||
|
||||
Add after `handleResetFilters` (~line 110):
|
||||
|
||||
```typescript
|
||||
const handleViewChange = (view: "grid" | "list" | "calendar") => {
|
||||
setViewMode(view)
|
||||
saveViewPreference(view)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update view buttons to use handleViewChange**
|
||||
|
||||
Find the view toggle buttons (~lines 309-325) and change:
|
||||
```typescript
|
||||
// Instead of: onClick={() => setViewMode("calendar")}
|
||||
onClick={() => handleViewChange("calendar")}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Test in browser**
|
||||
- Navigate to /trainings
|
||||
- Verify calendar view is shown by default
|
||||
- Change to grid view, refresh page
|
||||
- Verify grid view persists
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Verify Trainer Page Filter Persistence
|
||||
|
||||
**Files:**
|
||||
- Check: `frontend/src/app/(dashboard)/trainers/page.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Read trainers page to check filter persistence**
|
||||
|
||||
Find `fetchPreferences` and `savePreferences` functions
|
||||
|
||||
- [ ] **Step 2: If missing, add filter persistence**
|
||||
|
||||
If trainers page doesn't have filter persistence:
|
||||
1. Add `trainers_filters` to preferences save/load
|
||||
2. Add `handleFilterChange` that saves to backend
|
||||
3. Load filters on mount
|
||||
|
||||
- [ ] **Step 3: Test trainer filters**
|
||||
- Go to /trainers
|
||||
- Apply a filter
|
||||
- Refresh page
|
||||
- Verify filter persists
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Verify Exercise Page Filter Persistence
|
||||
|
||||
**Files:**
|
||||
- Check: `frontend/src/app/(dashboard)/exercises/page.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1: Read exercises page to check filter persistence**
|
||||
|
||||
Find `fetchPreferences` and `savePreferences` functions
|
||||
|
||||
- [ ] **Step 2: If missing, add filter persistence**
|
||||
|
||||
If exercises page doesn't have filter persistence:
|
||||
1. Add `exercises_filters` to preferences save/load
|
||||
2. Add `handleFilterChange` that saves to backend
|
||||
3. Load filters on mount
|
||||
|
||||
- [ ] **Step 3: Test exercise filters**
|
||||
- Go to /exercises
|
||||
- Apply a filter
|
||||
- Refresh page
|
||||
- Verify filter persists
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all tasks:
|
||||
- [ ] Trainings page shows calendar view by default
|
||||
- [ ] Trainings view mode persists after refresh
|
||||
- [ ] Trainings filter (group, date) persists after refresh
|
||||
- [ ] Trainer filters persist after refresh
|
||||
- [ ] Exercise filters persist after refresh
|
||||
- [ ] No console errors
|
||||
@@ -0,0 +1,713 @@
|
||||
# Leistungstest Live-Timer — 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 a live-timer mode to the Leistungstest "Zuweisen" tab where trainers run through a timed workout session with per-wrestler exercise tracking and automatic result saving.
|
||||
|
||||
**Architecture:** Single-file implementation within the existing 1458-line `leistungstest/page.tsx`. Add `inputMode` state to toggle between existing form mode and new timer mode. Timer mode uses split-panel layout (wrestler list left, timer + exercises right). Timer runs continuously across wrestlers via `setInterval`. Per-wrestler results saved immediately via existing `POST /leistungstest/results/`. Session persisted to `localStorage` under key `"leistungstest_timer_session"`.
|
||||
|
||||
**Tech Stack:** React hooks (useState, useEffect, useCallback, useMemo), existing UI components (Card, Button, Input, Badge, Modal, Sheet), existing icons (no new deps), framer-motion for animations, Sonner for toasts.
|
||||
|
||||
---
|
||||
|
||||
## File to Modify
|
||||
|
||||
- `frontend/src/app/(dashboard)/leistungstest/page.tsx` — add all timer-related code to this file
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Task 1: Add TypeScript interfaces + state declarations
|
||||
|
||||
**Location:** After line 74 (after `editTemplateExercises` state), before the `useEffect` on line 76.
|
||||
|
||||
**Goal:** Add timer-specific interfaces and state variables.
|
||||
|
||||
- [ ] **Step 1: Add interfaces**
|
||||
|
||||
Add these types after the imports (before line 22):
|
||||
|
||||
```typescript
|
||||
interface TimerExercise {
|
||||
exerciseId: number
|
||||
exerciseName: string
|
||||
targetReps: number
|
||||
actualReps: string
|
||||
status: "pending" | "done"
|
||||
startedAt: number | null
|
||||
}
|
||||
|
||||
interface TimerWrestler {
|
||||
wrestler: IWrestler
|
||||
status: "pending" | "active" | "done"
|
||||
startedAt: number | null
|
||||
exercises: TimerExercise[]
|
||||
resultId: number | null
|
||||
}
|
||||
|
||||
interface TimerSession {
|
||||
templateId: number
|
||||
templateName: string
|
||||
wrestlers: TimerWrestler[]
|
||||
currentWrestlerIndex: number
|
||||
totalElapsedSeconds: number
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
interface RestoredSession {
|
||||
session: TimerSession
|
||||
savedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add state declarations**
|
||||
|
||||
After line 74 (`editTemplateExercises` state), add:
|
||||
|
||||
```typescript
|
||||
// Timer mode state
|
||||
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
|
||||
const [timerSession, setTimerSession] = useState<TimerSession | null>(null)
|
||||
const [timerInterval, setTimerInterval] = useState<NodeJS.Timeout | null>(null)
|
||||
const [restoreModalOpen, setRestoreModalOpen] = useState(false)
|
||||
const [restoredSession, setRestoredSession] = useState<RestoredSession | null>(null)
|
||||
const [endTrainingModalOpen, setEndTrainingModalOpen] = useState(false)
|
||||
const [trainingSummary, setTrainingSummary] = useState<{
|
||||
completed: number
|
||||
total: number
|
||||
totalTime: number
|
||||
} | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add localStorage constants and helpers**
|
||||
|
||||
After the interfaces (before line 22 area), add:
|
||||
|
||||
```typescript
|
||||
const TIMER_SESSION_KEY = "leistungstest_timer_session"
|
||||
```
|
||||
|
||||
Also add a helper function after the state declarations (around line 76):
|
||||
|
||||
```typescript
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add localStorage restore check on mount
|
||||
|
||||
**Location:** Modify the existing `useEffect` on line 76 (`fetchPreferences` call) to also check for saved timer session.
|
||||
|
||||
**Goal:** On page load in timer mode, prompt user to restore interrupted session.
|
||||
|
||||
- [ ] **Step 1: Add useEffect to check for saved session**
|
||||
|
||||
After line 506 (after `fetchPreferences` useEffect), add new useEffect:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (inputMode === "timer") {
|
||||
const saved = localStorage.getItem(TIMER_SESSION_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as RestoredSession
|
||||
if (parsed.session.isRunning === false) {
|
||||
setRestoredSession(parsed)
|
||||
setRestoreModalOpen(true)
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(TIMER_SESSION_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [inputMode])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add TimerMode sub-component
|
||||
|
||||
**Location:** Inside the `LeistungstestPage` function, after the existing helper functions (around line 472).
|
||||
|
||||
**Goal:** Create the TimerMode component that renders the split-panel layout.
|
||||
|
||||
- [ ] **Step 1: Add TimerMode component**
|
||||
|
||||
Add this as a const inside the component (after line 472, before the `tabs` array):
|
||||
|
||||
```typescript
|
||||
const TimerMode = ({ session, onUpdate }: { session: TimerSession; onUpdate: (s: TimerSession) => void }) => {
|
||||
const currentWrestler = session.wrestlers[session.currentWrestlerIndex]
|
||||
const completedCount = session.wrestlers.filter(w => w.status === "done").length
|
||||
const allExercisesDone = currentWrestler?.exercises.every(e => e.status === "done") ?? false
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
onUpdate({ ...session, isRunning: !session.isRunning })
|
||||
}, [session, onUpdate])
|
||||
|
||||
const updateWrestler = (index: number, updated: TimerWrestler) => {
|
||||
const newWrestlers = [...session.wrestlers]
|
||||
newWrestlers[index] = updated
|
||||
onUpdate({ ...session, wrestlers: newWrestlers })
|
||||
}
|
||||
|
||||
const startExercise = (exerciseIdx: number) => {
|
||||
if (!currentWrestler) return
|
||||
const updated = { ...currentWrestler }
|
||||
updated.exercises = updated.exercises.map((e, i) =>
|
||||
i === exerciseIdx ? { ...e, status: "active" as const, startedAt: Date.now() } : e
|
||||
)
|
||||
updateWrestler(session.currentWrestlerIndex, updated)
|
||||
}
|
||||
|
||||
const doneExercise = async (exerciseIdx: number) => {
|
||||
if (!currentWrestler || !token) return
|
||||
const updated = { ...currentWrestler }
|
||||
updated.exercises = updated.exercises.map((e, i) =>
|
||||
i === exerciseIdx ? { ...e, status: "done" as const } : e
|
||||
)
|
||||
updateWrestler(session.currentWrestlerIndex, updated)
|
||||
}
|
||||
|
||||
const updateActualReps = (exerciseIdx: number, reps: string) => {
|
||||
if (!currentWrestler) return
|
||||
const updated = { ...currentWrestler }
|
||||
updated.exercises = updated.exercises.map((e, i) =>
|
||||
i === exerciseIdx ? { ...e, actualReps: reps } : e
|
||||
)
|
||||
updateWrestler(session.currentWrestlerIndex, updated)
|
||||
}
|
||||
|
||||
const goToNextWrestler = async () => {
|
||||
if (!currentWrestler || !token) return
|
||||
// Mark current wrestler done
|
||||
const doneWrestler: TimerWrestler = {
|
||||
...currentWrestler,
|
||||
status: "done",
|
||||
}
|
||||
const newWrestlers = [...session.wrestlers]
|
||||
newWrestlers[session.currentWrestlerIndex] = doneWrestler
|
||||
|
||||
// Save result immediately
|
||||
try {
|
||||
const itemsPayload = doneWrestler.exercises
|
||||
.filter(e => e.actualReps)
|
||||
.map((e, i) => ({
|
||||
exercise: e.exerciseId,
|
||||
target_reps: e.targetReps,
|
||||
actual_reps: parseInt(e.actualReps) || 0,
|
||||
order: i,
|
||||
}))
|
||||
|
||||
const elapsedForWrestler = doneWrestler.startedAt
|
||||
? Math.floor((Date.now() - doneWrestler.startedAt) / 1000)
|
||||
: session.totalElapsedSeconds
|
||||
|
||||
const result = await apiFetch<ILeistungstestResult>("/leistungstest/results/", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
template: session.templateId,
|
||||
wrestler: doneWrestler.wrestler.id,
|
||||
total_time_seconds: elapsedForWrestler,
|
||||
rating: 3,
|
||||
notes: "",
|
||||
items: itemsPayload,
|
||||
}),
|
||||
})
|
||||
|
||||
newWrestlers[session.currentWrestlerIndex] = { ...doneWrestler, resultId: result.id }
|
||||
} catch {
|
||||
toast.error("Fehler beim Speichern")
|
||||
}
|
||||
|
||||
const nextIndex = session.currentWrestlerIndex + 1
|
||||
if (nextIndex < session.wrestlers.length) {
|
||||
const nextWrestler = { ...newWrestlers[nextIndex], status: "active" as const, startedAt: Date.now() }
|
||||
newWrestlers[nextIndex] = nextWrestler
|
||||
onUpdate({ ...session, wrestlers: newWrestlers, currentWrestlerIndex: nextIndex })
|
||||
} else {
|
||||
onUpdate({ ...session, wrestlers: newWrestlers, isRunning: false })
|
||||
setTrainingSummary({
|
||||
completed: newWrestlers.filter(w => w.status === "done").length,
|
||||
total: session.wrestlers.length,
|
||||
totalTime: session.totalElapsedSeconds,
|
||||
})
|
||||
setEndTrainingModalOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const endTraining = () => {
|
||||
setEndTrainingModalOpen(true)
|
||||
}
|
||||
|
||||
const confirmEndTraining = async () => {
|
||||
if (!currentWrestler || !token) return
|
||||
// Save current wrestler if started
|
||||
const startedExercises = currentWrestler.exercises.filter(e => e.startedAt !== null)
|
||||
if (startedExercises.length > 0) {
|
||||
try {
|
||||
const itemsPayload = currentWrestler.exercises
|
||||
.filter(e => e.actualReps)
|
||||
.map((e, i) => ({
|
||||
exercise: e.exerciseId,
|
||||
target_reps: e.targetReps,
|
||||
actual_reps: parseInt(e.actualReps) || 0,
|
||||
order: i,
|
||||
}))
|
||||
|
||||
await apiFetch<ILeistungstestResult>("/leistungstest/results/", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
template: session.templateId,
|
||||
wrestler: currentWrestler.wrestler.id,
|
||||
total_time_seconds: session.totalElapsedSeconds,
|
||||
rating: 3,
|
||||
notes: "",
|
||||
items: itemsPayload,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(TIMER_SESSION_KEY)
|
||||
setTimerSession(null)
|
||||
setEndTrainingModalOpen(false)
|
||||
setTrainingSummary(null)
|
||||
setInputMode("form")
|
||||
fetchResults()
|
||||
toast.success("Training beendet")
|
||||
}
|
||||
|
||||
if (!currentWrestler) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Left: Wrestler list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ringer ({completedCount}/{session.wrestlers.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{session.wrestlers.map((w, i) => (
|
||||
<div
|
||||
key={w.wrestler.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
|
||||
i === session.currentWrestlerIndex ? "bg-primary/10 border border-primary/30" : "bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xl">
|
||||
{w.status === "done" ? "✅" : w.status === "active" ? "●" : "○"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{w.wrestler.first_name} {w.wrestler.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{w.exercises.filter(e => e.status === "done").length}/{w.exercises.length} Übungen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right: Timer + exercises */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{currentWrestler.wrestler.first_name} {currentWrestler.wrestler.last_name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">{session.templateName}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={session.isRunning ? "destructive" : "default"}
|
||||
size="sm"
|
||||
onClick={togglePause}
|
||||
>
|
||||
{session.isRunning ? "Pausieren" : "Fortsetzen"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Timer display */}
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className={`text-6xl font-mono font-bold ${session.isRunning ? "" : "text-orange-500"}`}>
|
||||
{formatTime(session.totalElapsedSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exercise list */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
ÜBUNGEN ({currentWrestler.exercises.filter(e => e.status === "done").length}/{currentWrestler.exercises.length})
|
||||
</div>
|
||||
{currentWrestler.exercises.map((exercise, i) => (
|
||||
<div
|
||||
key={exercise.exerciseId}
|
||||
className={`border rounded-lg p-4 transition-colors ${
|
||||
exercise.status === "done" ? "bg-green-50 border-green-200" :
|
||||
exercise.status === "active" ? "bg-blue-50 border-blue-200" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{exercise.exerciseName}</div>
|
||||
<div className="text-xs text-muted-foreground">Soll: {exercise.targetReps}</div>
|
||||
</div>
|
||||
<Badge variant={exercise.status === "done" ? "default" : "secondary"}>
|
||||
{exercise.status === "done" ? "ERLEDIGT" : exercise.status === "active" ? "LÄUFT" : "AUSSTEHEND"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Ist-Reps"
|
||||
value={exercise.actualReps}
|
||||
onChange={(e) => updateActualReps(i, e.target.value)}
|
||||
disabled={exercise.status === "done"}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
{exercise.status === "pending" && (
|
||||
<Button size="sm" onClick={() => startExercise(i)}>
|
||||
▶ START
|
||||
</Button>
|
||||
)}
|
||||
{exercise.status === "active" && (
|
||||
<Button size="sm" variant="default" onClick={() => doneExercise(i)}>
|
||||
✅ ERLEDIGT
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={goToNextWrestler}
|
||||
disabled={!allExercisesDone && currentWrestler.exercises.some(e => e.status === "active")}
|
||||
>
|
||||
WEITER ZUM NÄCHSTEN RINGER →
|
||||
</Button>
|
||||
<Button variant="outline" onClick={endTraining}>
|
||||
TRAINING BEENDEN
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* End training confirmation modal */}
|
||||
<Modal
|
||||
open={endTrainingModalOpen}
|
||||
onOpenChange={setEndTrainingModalOpen}
|
||||
title="Training beenden?"
|
||||
description="Bist du sicher, dass du das Training beenden möchtest?"
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEndTrainingModalOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmEndTraining}>
|
||||
Training beenden
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add timer tick effect
|
||||
|
||||
**Location:** After the existing `useEffect` blocks (after line 506).
|
||||
|
||||
**Goal:** Start/stop the timer interval based on `timerSession.isRunning`.
|
||||
|
||||
- [ ] **Step 1: Add timer interval useEffect**
|
||||
|
||||
Add after line 506:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (timerSession?.isRunning) {
|
||||
const interval = setInterval(() => {
|
||||
setTimerSession(prev => {
|
||||
if (!prev) return prev
|
||||
return { ...prev, totalElapsedSeconds: prev.totalElapsedSeconds + 1 }
|
||||
})
|
||||
}, 1000)
|
||||
setTimerInterval(interval)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
setTimerInterval(null)
|
||||
}
|
||||
} else {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
setTimerInterval(null)
|
||||
}
|
||||
}
|
||||
}, [timerSession?.isRunning])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add localStorage persistence effect
|
||||
|
||||
**Location:** After the timer tick effect.
|
||||
|
||||
**Goal:** Save timer session to localStorage on every change.
|
||||
|
||||
- [ ] **Step 1: Add localStorage persistence useEffect**
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (timerSession && inputMode === "timer") {
|
||||
localStorage.setItem(TIMER_SESSION_KEY, JSON.stringify({
|
||||
session: timerSession,
|
||||
savedAt: Date.now(),
|
||||
}))
|
||||
}
|
||||
}, [timerSession, inputMode])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add mode toggle UI in Zuweisen tab
|
||||
|
||||
**Location:** In the Zuweisen tab render (around line 709-721), after the CardHeader opening tag.
|
||||
|
||||
**Goal:** Add a toggle switch between "Formular" and "Timer" mode.
|
||||
|
||||
- [ ] **Step 1: Modify CardHeader in zuweisen tab**
|
||||
|
||||
Find around line 718:
|
||||
```tsx
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
|
||||
</CardHeader>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```tsx
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<CardTitle className="text-base">
|
||||
{inputMode === "form" ? "Neues Ergebnis" : "Training starten"}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setInputMode("form")}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
inputMode === "form" ? "bg-background shadow-sm" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Formular
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInputMode("timer")}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
inputMode === "timer" ? "bg-background shadow-sm" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
⏱ Timer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Conditionally render form vs timer mode in Zuweisen tab
|
||||
|
||||
**Location:** In the Zuweisen tab, replace the form render logic (around line 722) with conditional rendering.
|
||||
|
||||
**Goal:** When `inputMode === "timer"` and `timerSession !== null`, render `TimerMode`. Otherwise render the existing form.
|
||||
|
||||
- [ ] **Step 1: Wrap existing form in form mode check**
|
||||
|
||||
Find line 722: `<form onSubmit={handleCreateResult} className="space-y-4">`
|
||||
|
||||
Wrap the entire form content (lines 722-877) with:
|
||||
|
||||
```tsx
|
||||
{inputMode === "timer" && timerSession ? (
|
||||
<TimerMode session={timerSession} onUpdate={setTimerSession} />
|
||||
) : (
|
||||
<form onSubmit={handleCreateResult} className="space-y-4">
|
||||
{/* ... existing form content (lines 722-877) ... */}
|
||||
</form>
|
||||
)}
|
||||
```
|
||||
|
||||
Then add the closing `)}` after the form's closing `</motion.button>` (line 876) before `</CardContent>` (line 878).
|
||||
|
||||
- [ ] **Step 2: Add "Training starten" button below wrestler/template selection in form mode**
|
||||
|
||||
Find the section around lines 789-790 (after the wrestler selection div closes), after:
|
||||
|
||||
```tsx
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Insert before the `{resultItems.length > 0 && (` section:
|
||||
|
||||
```tsx
|
||||
{inputMode === "form" && resultForm.template && resultForm.wrestlers.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const template = templates.find(t => t.id === parseInt(resultForm.template))
|
||||
if (!template) return
|
||||
const timerWrestlers: TimerWrestler[] = wrestlers
|
||||
.filter(w => resultForm.wrestlers.includes(w.id))
|
||||
.map(w => ({
|
||||
wrestler: w,
|
||||
status: "pending" as const,
|
||||
startedAt: null,
|
||||
exercises: template.exercises.map(e => ({
|
||||
exerciseId: e.exercise,
|
||||
exerciseName: e.exercise_name || String(e.exercise),
|
||||
targetReps: e.target_reps,
|
||||
actualReps: "",
|
||||
status: "pending" as const,
|
||||
startedAt: null,
|
||||
})),
|
||||
resultId: null,
|
||||
}))
|
||||
setTimerSession({
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
wrestlers: timerWrestlers,
|
||||
currentWrestlerIndex: 0,
|
||||
totalElapsedSeconds: 0,
|
||||
isRunning: false,
|
||||
})
|
||||
setInputMode("timer")
|
||||
}}
|
||||
className="w-full"
|
||||
variant="default"
|
||||
>
|
||||
⏱ Training starten
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Add session restore modal
|
||||
|
||||
**Location:** Before the closing `</div>` of the main component (around line 1456).
|
||||
|
||||
**Goal:** Prompt user to restore an interrupted session.
|
||||
|
||||
- [ ] **Step 1: Add restore session modal**
|
||||
|
||||
Before line 1456 (`</div>`), add:
|
||||
|
||||
```typescript
|
||||
<Modal
|
||||
open={restoreModalOpen}
|
||||
onOpenChange={setRestoreModalOpen}
|
||||
title="Offenes Training gefunden"
|
||||
description="Möchtest du das Training fortsetzen oder verwerfen?"
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => {
|
||||
localStorage.removeItem(TIMER_SESSION_KEY)
|
||||
setRestoreModalOpen(false)
|
||||
setRestoredSession(null)
|
||||
}}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
if (restoredSession) {
|
||||
setTimerSession(restoredSession.session)
|
||||
setInputMode("timer")
|
||||
}
|
||||
setRestoreModalOpen(false)
|
||||
}}>
|
||||
Fortsetzen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</Modal>
|
||||
|
||||
{/* Training summary modal */}
|
||||
<Modal
|
||||
open={!!trainingSummary}
|
||||
onOpenChange={() => {}}
|
||||
title="Training abgeschlossen!"
|
||||
description={
|
||||
trainingSummary
|
||||
? `${trainingSummary.completed} von ${trainingSummary.total} Ringern absolviert in ${formatTime(trainingSummary.totalTime)}`
|
||||
: ""
|
||||
}
|
||||
size="sm"
|
||||
footer={
|
||||
<Button onClick={() => {
|
||||
localStorage.removeItem(TIMER_SESSION_KEY)
|
||||
setTimerSession(null)
|
||||
setTrainingSummary(null)
|
||||
setInputMode("form")
|
||||
}}>
|
||||
Schließen
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</Modal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Build verification
|
||||
|
||||
- [ ] **Step 1: Run typecheck**
|
||||
`cd frontend && npm run typecheck`
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
`cd frontend && npm run lint`
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
`cd frontend && npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `TimerMode` component references `fetchResults` which is defined inside `LeistungstestPage`. Since it's a nested const, it has access via closure — no changes needed.
|
||||
- The `timerSession` is saved to localStorage whenever it changes (Task 5 effect), but `isRunning` is also saved so we can detect interrupted sessions on page load.
|
||||
- When user clicks "Training beenden" mid-session, we do best-effort save of the current wrestler if they started any exercises.
|
||||
- No new backend endpoints needed — uses existing `POST /leistungstest/results/`.
|
||||
- Types `IWrestler`, `ILeistungstestResult`, `ILeistungstestTemplate`, `apiFetch` are all already imported and used in the file.
|
||||
@@ -0,0 +1,541 @@
|
||||
# Leistungstest Live-Timer v2 — 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:** Replace the previous timer implementation with a new table-style layout where all wrestlers train in parallel, each with their own exercise cards. One shared global timer. Trainer clicks "Erledigt" per wrestler per exercise. All exercises auto-start after the previous one completes. One result per wrestler saved incrementally to backend.
|
||||
|
||||
**Architecture:** Single-file frontend implementation (`leistungstest/page.tsx`) + backend model change for `elapsed_seconds`. Backend uses one Result per wrestler, saved incrementally as exercises complete. `actual_reps = target_reps` (full reps completed), `elapsed_seconds` per exercise in ResultItem.
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/app/(dashboard)/leistungstest/page.tsx` — replace existing timer implementation
|
||||
|
||||
### Backend
|
||||
- `backend/leistungstest/models.py` — add `elapsed_seconds` field to `LeistungstestResultItem`
|
||||
- `backend/leistungstest/serializers.py` — add `elapsed_seconds` to `LeistungstestResultItemSerializer`
|
||||
- New migration `0003_leistungstestresultitem_elapsed_seconds.py`
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Task 1: Backend — Add `elapsed_seconds` field to ResultItem
|
||||
|
||||
**Goal:** Add `elapsed_seconds` field to store time spent on each exercise.
|
||||
|
||||
- [ ] **Step 1: Update model**
|
||||
|
||||
In `backend/leistungstest/models.py`, find `LeistungstestResultItem` class (line 67), add field after `order`:
|
||||
|
||||
```python
|
||||
elapsed_seconds = models.PositiveIntegerField(default=0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update serializer**
|
||||
|
||||
In `backend/leistungstest/serializers.py`, update `LeistungstestResultItemSerializer.Meta.fields` (line 27):
|
||||
|
||||
```python
|
||||
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create migration**
|
||||
```bash
|
||||
cd backend && python manage.py makemigrations leistungstest --name leistungstestresultitem_elapsed_seconds
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Apply migration**
|
||||
```bash
|
||||
cd backend && python manage.py migrate leistungstest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Replace existing timer implementation
|
||||
|
||||
**Goal:** Remove the old per-wrestler, per-exercise timer (TimerMode component, `inputMode` state, old interfaces, etc.) and replace with the new table-style parallel implementation.
|
||||
|
||||
**Steps:**
|
||||
|
||||
#### Step 1: Remove old interfaces and state
|
||||
|
||||
In `page.tsx`, remove from lines 22-75:
|
||||
- Remove: `TimerExercise`, `TimerWrestler`, `TimerSession`, `RestoredSession` interfaces
|
||||
- Remove: `TIMER_SESSION_KEY` constant
|
||||
- Remove state: `inputMode`, `timerSession`, `timerInterval`, `restoreModalOpen`, `restoredSession`, `endTrainingModalOpen`, `trainingSummary`
|
||||
- Remove: `formatTime` helper function
|
||||
|
||||
#### Step 2: Add new interfaces
|
||||
|
||||
Add after the imports (before line 22):
|
||||
|
||||
```typescript
|
||||
interface LiveExercise {
|
||||
exerciseId: number
|
||||
exerciseName: string
|
||||
targetReps: string
|
||||
elapsedSeconds: number
|
||||
status: "pending" | "active" | "done"
|
||||
}
|
||||
|
||||
interface LiveWrestler {
|
||||
wrestler: IWrestler
|
||||
exercises: LiveExercise[]
|
||||
resultId: number | null
|
||||
}
|
||||
|
||||
interface LiveSession {
|
||||
templateId: number
|
||||
templateName: string
|
||||
wrestlers: LiveWrestler[]
|
||||
globalElapsedSeconds: number
|
||||
isRunning: boolean
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Add new state declarations
|
||||
|
||||
After the existing state declarations (around line 74), replace the old timer state with:
|
||||
|
||||
```typescript
|
||||
const [liveMode, setLiveMode] = useState(false)
|
||||
const [liveSession, setLiveSession] = useState<LiveSession | null>(null)
|
||||
const [liveTimerInterval, setLiveTimerInterval] = useState<NodeJS.Timeout | null>(null)
|
||||
const [liveRestoreOpen, setLiveRestoreOpen] = useState(false)
|
||||
const [liveEndOpen, setLiveEndOpen] = useState(false)
|
||||
const [liveSummary, setLiveSummary] = useState<{ completed: number; total: number; totalTime: number } | null>(null)
|
||||
```
|
||||
|
||||
#### Step 4: Add timer tick effect
|
||||
|
||||
After the `useEffect` on line 557 (fetchPreferences), add:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (liveSession?.isRunning) {
|
||||
const interval = setInterval(() => {
|
||||
setLiveSession(prev => {
|
||||
if (!prev) return prev
|
||||
return { ...prev, globalElapsedSeconds: prev.globalElapsedSeconds + 1 }
|
||||
})
|
||||
}, 1000)
|
||||
setLiveTimerInterval(interval)
|
||||
return () => { clearInterval(interval); setLiveTimerInterval(null) }
|
||||
} else {
|
||||
if (liveTimerInterval) { clearInterval(liveTimerInterval); setLiveTimerInterval(null) }
|
||||
}
|
||||
}, [liveSession?.isRunning])
|
||||
```
|
||||
|
||||
#### Step 5: Add localStorage restore effect
|
||||
|
||||
After the timer tick effect:
|
||||
|
||||
```typescript
|
||||
const LIVE_SESSION_KEY = "leistungstest_live_session"
|
||||
|
||||
useEffect(() => {
|
||||
if (liveSession && liveMode) {
|
||||
localStorage.setItem(LIVE_SESSION_KEY, JSON.stringify({ session: liveSession, savedAt: Date.now() }))
|
||||
}
|
||||
}, [liveSession, liveMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveMode) {
|
||||
const saved = localStorage.getItem(LIVE_SESSION_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (parsed.session.isRunning === false) {
|
||||
setLiveRestoreOpen(true)
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(LIVE_SESSION_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [liveMode])
|
||||
```
|
||||
|
||||
#### Step 6: Add LiveTraining component
|
||||
|
||||
Find the `const tabs = [` line. Add the `LiveTraining` component BEFORE `const tabs`.
|
||||
|
||||
```typescript
|
||||
const LiveTraining = ({ session, onUpdate }: { session: LiveSession; onUpdate: (s: LiveSession) => void }) => {
|
||||
const completedCount = session.wrestlers.filter(w => w.exercises.every(e => e.status === "done")).length
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const markDone = async (wrestlerIdx: number, exerciseIdx: number) => {
|
||||
const wrestler = session.wrestlers[wrestlerIdx]
|
||||
const exercise = wrestler.exercises[exerciseIdx]
|
||||
const elapsed = exercise.elapsedSeconds
|
||||
|
||||
const updated = session.wrestlers.map((w, wi) => {
|
||||
if (wi !== wrestlerIdx) return w
|
||||
return {
|
||||
...w,
|
||||
exercises: w.exercises.map((e, ei) =>
|
||||
ei === exerciseIdx ? { ...e, status: "done" as const, elapsedSeconds: elapsed } : e
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const nextIdx = exerciseIdx + 1
|
||||
if (nextIdx < wrestler.exercises.length) {
|
||||
updated[wrestlerIdx] = {
|
||||
...updated[wrestlerIdx],
|
||||
exercises: updated[wrestlerIdx].exercises.map((e, ei) =>
|
||||
ei === nextIdx ? { ...e, status: "active" as const } : e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const allDone = updated[wrestlerIdx].exercises.every(e => e.status === "done")
|
||||
|
||||
if (allDone && token) {
|
||||
try {
|
||||
const itemsPayload = updated[wrestlerIdx].exercises.map((e, i) => ({
|
||||
exercise: e.exerciseId,
|
||||
target_reps: parseInt(e.targetReps) || 0,
|
||||
actual_reps: parseInt(e.targetReps) || 0,
|
||||
elapsed_seconds: e.elapsedSeconds,
|
||||
order: i,
|
||||
}))
|
||||
|
||||
const result = await apiFetch<ILeistungstestResult>("/leistungstest/results/", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
template: session.templateId,
|
||||
wrestler: wrestler.wrestler.id,
|
||||
total_time_seconds: elapsed,
|
||||
rating: 3,
|
||||
notes: "",
|
||||
items: itemsPayload,
|
||||
}),
|
||||
})
|
||||
|
||||
updated[wrestlerIdx] = { ...updated[wrestlerIdx], resultId: result.id }
|
||||
} catch {
|
||||
toast.error("Fehler beim Speichern")
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate({ ...session, wrestlers: updated })
|
||||
}
|
||||
|
||||
const endTraining = () => setLiveEndOpen(true)
|
||||
|
||||
const confirmEnd = async () => {
|
||||
localStorage.removeItem(LIVE_SESSION_KEY)
|
||||
setLiveSession(null)
|
||||
setLiveMode(false)
|
||||
setLiveEndOpen(false)
|
||||
setLiveSummary(null)
|
||||
fetchResults()
|
||||
toast.success("Training beendet")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[#1B1A55] rounded-xl px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-xs font-semibold text-[#9290C3] uppercase tracking-wider">Gemeinsame Zeit</span>
|
||||
<span className="text-4xl font-bold text-white font-mono tracking-widest">
|
||||
{formatTime(session.globalElapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={session.isRunning ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={() => onUpdate({ ...session, isRunning: !session.isRunning })}
|
||||
>
|
||||
{session.isRunning ? "⏸ Pausieren" : "▶ Fortsetzen"}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={endTraining}>
|
||||
■ Training beenden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{session.wrestlers.map((wrestler, wrestlerIdx) => {
|
||||
const doneCount = wrestler.exercises.filter(e => e.status === "done").length
|
||||
const allDone = wrestler.exercises.every(e => e.status === "done")
|
||||
const activeExercise = wrestler.exercises.find(e => e.status === "active")
|
||||
|
||||
return (
|
||||
<div
|
||||
key={wrestler.wrestler.id}
|
||||
className={`rounded-xl border-2 overflow-hidden ${
|
||||
allDone ? "border-green-500 bg-green-50" :
|
||||
activeExercise ? "border-yellow-400 bg-white" :
|
||||
"border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center px-4 py-3 border-b gap-3 ${
|
||||
allDone ? "bg-green-100 border-green-200" :
|
||||
activeExercise ? "bg-yellow-50 border-yellow-200" :
|
||||
"bg-gray-50 border-gray-200"
|
||||
}`}>
|
||||
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
allDone ? "bg-green-500" : activeExercise ? "bg-yellow-400" : "bg-gray-400"
|
||||
}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-bold text-base ${allDone ? "text-green-700" : "text-gray-900"}`}>
|
||||
{wrestler.wrestler.first_name} {wrestler.wrestler.last_name}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: allDone ? "#166534" : activeExercise ? "#a16207" : "#64748b" }}>
|
||||
{allDone ? `✓ Alle ${wrestler.exercises.length} Übungen` :
|
||||
activeExercise ? `Übung läuft: ${activeExercise.exerciseName}` :
|
||||
"Wartet..."}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||||
allDone ? "bg-green-200 text-green-800" :
|
||||
"bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{doneCount}/{wrestler.exercises.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex gap-3 flex-wrap">
|
||||
{wrestler.exercises.map((exercise, exerciseIdx) => {
|
||||
const isActive = exercise.status === "active"
|
||||
const isDone = exercise.status === "done"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={exercise.exerciseId}
|
||||
className={`flex-1 min-w-[140px] rounded-lg p-3 border ${
|
||||
isDone ? "bg-green-100 border-green-300" :
|
||||
isActive ? "bg-yellow-50 border-yellow-400" :
|
||||
"bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className={`text-xs font-semibold mb-1 ${
|
||||
isDone ? "text-green-700" : isActive ? "text-yellow-700" : "text-gray-500"
|
||||
}`}>
|
||||
{isDone ? "✓" : isActive ? "▶" : ""} {exercise.exerciseName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">Soll: {exercise.targetReps}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-lg font-bold font-mono ${
|
||||
isDone ? "text-green-700" : isActive ? "text-yellow-700" : "text-gray-400"
|
||||
}`}>
|
||||
{isDone ? `✓ ${formatTime(exercise.elapsedSeconds)}` :
|
||||
isActive ? formatTime(exercise.elapsedSeconds) : "—"}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Button size="sm" className="bg-green-600 hover:bg-green-700" onClick={() => markDone(wrestlerIdx, exerciseIdx)}>
|
||||
✓ Erledigt
|
||||
</Button>
|
||||
)}
|
||||
{!isDone && !isActive && (
|
||||
<span className="text-xs text-gray-400">Ausstehend</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={liveEndOpen}
|
||||
onOpenChange={setLiveEndOpen}
|
||||
title="Training beenden?"
|
||||
description="Bist du sicher? Laufende Übungen werden nicht gespeichert."
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setLiveEndOpen(false)}>Abbrechen</Button>
|
||||
<Button variant="destructive" onClick={confirmEnd}>Training beenden</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 7: Add session restore modal
|
||||
|
||||
Find the closing `</div>` of the main component (before `</div>` at the end of the file), add before it:
|
||||
|
||||
```typescript
|
||||
<Modal
|
||||
open={liveRestoreOpen}
|
||||
onOpenChange={setLiveRestoreOpen}
|
||||
title="Offenes Training gefunden"
|
||||
description="Möchtest du das Training fortsetzen oder verwerfen?"
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => {
|
||||
localStorage.removeItem(LIVE_SESSION_KEY)
|
||||
setLiveRestoreOpen(false)
|
||||
}}>Verwerfen</Button>
|
||||
<Button onClick={() => {
|
||||
const saved = localStorage.getItem(LIVE_SESSION_KEY)
|
||||
if (saved) setLiveSession(JSON.parse(saved).session)
|
||||
setLiveRestoreOpen(false)
|
||||
}}>Fortsetzen</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</Modal>
|
||||
```
|
||||
|
||||
#### Step 8: Modify Zuweisen tab CardHeader — add Live Mode toggle
|
||||
|
||||
Find the Zuweisen tab CardHeader section (around line 786-788):
|
||||
|
||||
Replace:
|
||||
```tsx
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Neues Ergebnis</CardTitle>
|
||||
</CardHeader>
|
||||
```
|
||||
|
||||
With:
|
||||
```tsx
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<CardTitle className="text-base">
|
||||
{liveMode ? "Training starten" : "Neues Ergebnis"}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLiveMode(false)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
!liveMode ? "bg-background shadow-sm" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Formular
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLiveMode(true)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
liveMode ? "bg-background shadow-sm" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
⏱ Live
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
```
|
||||
|
||||
#### Step 9: Conditionally render form vs live mode
|
||||
|
||||
Find the `<form onSubmit={handleCreateResult}>` inside the Zuweisen tab (around line 790).
|
||||
|
||||
Wrap the entire form with a conditional:
|
||||
|
||||
```tsx
|
||||
{liveMode && liveSession ? (
|
||||
<LiveTraining session={liveSession} onUpdate={setLiveSession} />
|
||||
) : (
|
||||
<form onSubmit={handleCreateResult} className="space-y-4">
|
||||
...
|
||||
</form>
|
||||
)}
|
||||
```
|
||||
|
||||
The closing `)}` goes after the form's closing `</CardContent>` tag.
|
||||
|
||||
#### Step 10: Add "Training starten" button in live mode of the form
|
||||
|
||||
Find the wrestler selection section in the form. After the wrestler badge list (around line 856), before the `{resultItems.length > 0 && (` block, add:
|
||||
|
||||
```tsx
|
||||
{liveMode && resultForm.template && resultForm.wrestlers.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const template = templates.find(t => t.id === parseInt(resultForm.template))
|
||||
if (!template) return
|
||||
setLiveSession({
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
wrestlers: wrestlers
|
||||
.filter(w => resultForm.wrestlers.includes(w.id))
|
||||
.map(w => ({
|
||||
wrestler: w,
|
||||
exercises: template.exercises.map(e => ({
|
||||
exerciseId: e.exercise,
|
||||
exerciseName: e.exercise_name || String(e.exercise),
|
||||
targetReps: e.target_reps,
|
||||
elapsedSeconds: 0,
|
||||
status: "active" as const,
|
||||
})),
|
||||
resultId: null,
|
||||
})),
|
||||
globalElapsedSeconds: 0,
|
||||
isRunning: true,
|
||||
})
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
⏱ Training starten
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Also add to the imports at the top of the file if not already present:
|
||||
```typescript
|
||||
import { toast } from "sonner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verify build
|
||||
|
||||
- [ ] **Step 1: TypeScript check**
|
||||
`cd frontend && npm run typecheck 2>&1 | tail -20`
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
`cd frontend && npm run build 2>&1 | tail -30`
|
||||
|
||||
- [ ] **Step 3: Backend check**
|
||||
`cd backend && python manage.py check`
|
||||
|
||||
---
|
||||
|
||||
## Key Behavior Summary
|
||||
|
||||
| Action | Result |
|
||||
|--------|--------|
|
||||
| Click "⏱ Live" toggle | Switches to live mode UI |
|
||||
| Select template + wrestlers | Form shows "⏱ Training starten" button |
|
||||
| Click "Training starten" | All wrestlers start Exercise 1 simultaneously, timer begins |
|
||||
| Click "✓ Erledigt" (wrestler X) | Exercise time saved, next exercise auto-starts for that wrestler |
|
||||
| All exercises done for wrestler | Result saved to backend, row turns green |
|
||||
| Click "Training beenden" | Confirmation, session cleared, back to form |
|
||||
|
||||
## Notes
|
||||
|
||||
- `targetReps` is stored as string like "3×10" from the template — displayed directly, no parsing needed for display
|
||||
- When saving: `target_reps` sent as integer (parseInt), `actual_reps` = same integer (full reps completed)
|
||||
- `elapsed_seconds` per exercise stored in ResultItem
|
||||
- The `LiveTraining` component uses the global `token`, `fetchResults`, and `templates` from the parent scope
|
||||
- No new backend endpoints — uses existing `POST /leistungstest/results/`
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
# Homework System Integration - Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
The backend has a complete homework system with exercises, assignments, and completion tracking. The frontend currently only supports basic CRUD. This spec covers integrating all backend features into the frontend.
|
||||
|
||||
---
|
||||
|
||||
## Backend Models
|
||||
|
||||
1. **Homework** - Template with title, description, due_date, exercises
|
||||
2. **HomeworkExerciseItem** - Links exercises to homework with reps/time
|
||||
3. **HomeworkAssignment** - Assigns homework to wrestlers
|
||||
4. **HomeworkAssignmentItem** - Tracks completion of each exercise
|
||||
5. **HomeworkStatus** - Overall completion status
|
||||
|
||||
---
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### 1. Update Types (lib/api.ts)
|
||||
|
||||
Add missing types:
|
||||
```typescript
|
||||
interface IHomeworkExerciseItem {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
reps: number | null
|
||||
time_minutes: number | null
|
||||
order: number
|
||||
}
|
||||
|
||||
interface IHomework {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
club: number
|
||||
club_name: string
|
||||
due_date: string
|
||||
is_active: boolean
|
||||
exercise_items: IHomeworkExerciseItem[]
|
||||
exercise_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface IHomeworkAssignment {
|
||||
id: number
|
||||
homework: number
|
||||
homework_title: string
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
club: number
|
||||
club_name: string
|
||||
due_date: string
|
||||
notes: string
|
||||
is_completed: boolean
|
||||
completion_date: string | null
|
||||
items: IHomeworkAssignmentItem[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface IHomeworkAssignmentItem {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
is_completed: boolean
|
||||
completion_date: string | null
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Homework Page - Template Management
|
||||
|
||||
**Exercise Selection:**
|
||||
- When creating/editing homework, allow selecting exercises from the exercise list
|
||||
- Each exercise can have reps (for rep-based) or time_minutes (for time-based)
|
||||
- Drag to reorder exercises
|
||||
|
||||
**API Integration:**
|
||||
- `GET /homework/{id}/exercise-items/` - Get exercises for homework
|
||||
- `POST /homework/{id}/exercise-items/` - Add exercise to homework
|
||||
- `DELETE /homework/{id}/exercise-items/{item_id}/` - Remove exercise
|
||||
|
||||
### 3. Assignments Page - View Assigned Homework
|
||||
|
||||
Create a new tab/section for viewing assignments:
|
||||
- `GET /homework/assignments/` - List all assignments
|
||||
- Filter by: homework, wrestler, completion status
|
||||
- Show completion progress (e.g., "3/5 exercises completed")
|
||||
|
||||
**Assignment Detail:**
|
||||
- View all exercises in the assignment
|
||||
- Mark individual exercises as complete
|
||||
- `POST /homework/assignments/{id}/complete-item/` - Mark exercise complete
|
||||
|
||||
### 4. Update Distribute Flow
|
||||
|
||||
Current: Select wrestlers, assign homework
|
||||
New:
|
||||
1. Select wrestlers (filtered by club + groups)
|
||||
2. Due date is set per assignment
|
||||
3. Creates HomeworkAssignment with all exercise items
|
||||
|
||||
---
|
||||
|
||||
## UI Components
|
||||
|
||||
### Homework Templates Tab
|
||||
- List all homework templates
|
||||
- Create/Edit with exercise selection
|
||||
- Delete template
|
||||
|
||||
### My Homework Tab (for wrestlers)
|
||||
- View assigned homework
|
||||
- Track completion per exercise
|
||||
- Due dates
|
||||
|
||||
### Assignment Management Tab (for trainers)
|
||||
- View all assignments
|
||||
- Filter by status (pending/completed/overdue)
|
||||
- Track which wrestlers completed
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Trainer creates Homework template with exercises
|
||||
2. Trainer assigns homework to wrestlers (filtered by group)
|
||||
3. System creates HomeworkAssignment for each wrestler
|
||||
4. Wrestlers see assignments and mark exercises complete
|
||||
5. Trainers can track completion rates
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
**Frontend Pages:**
|
||||
- `/homework` - Templates list
|
||||
- `/homework/assignments` - Assignments list (new)
|
||||
- `/homework/[id]/edit` - Edit template with exercises
|
||||
|
||||
**Key Changes:**
|
||||
- Update IHomework type with exercise_items
|
||||
- Add exercise selection UI to homework form
|
||||
- Create assignments list view
|
||||
- Add completion tracking
|
||||
|
||||
**API Endpoints Used:**
|
||||
- `GET/POST /homework/` - CRUD templates
|
||||
- `GET/POST /homework/{id}/exercise-items/` - Manage exercises
|
||||
- `POST /homework/{id}/assign/` - Assign to wrestlers
|
||||
- `GET /homework/assignments/` - List assignments
|
||||
- `POST /homework/assignments/{id}/complete-item/` - Mark complete
|
||||
@@ -0,0 +1,303 @@
|
||||
# Homework System Redesign - Spezifikation
|
||||
|
||||
## Überblick
|
||||
|
||||
Komplette Neugestaltung des Homework-Systems ohne Vorlagen. Homework wird direkt von der Training-Seite aus Assignees zugewiesen.
|
||||
|
||||
## Konzept
|
||||
|
||||
**Workflow:**
|
||||
1. Trainer ist auf Training-Detail-Seite
|
||||
2. Bei jedem Teilnehmer gibt es einen Homework-Button (Icon)
|
||||
3. Klick öffnet Modal: Exercises auswählen (mit reps/time)
|
||||
4. Homework wird dem Wrestler für dieses Training zugewiesen
|
||||
5. Auf der Homework-Seite sieht man alle Zuweisungen gruppiert nach Training
|
||||
|
||||
**Keine Templates** - Exercises werden direkt zugewiesen, keine wiederverwendbaren Vorlagen.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Neues Model: `TrainingHomework`
|
||||
|
||||
Verknüpft ein Training mit Exercises.
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|--------------|
|
||||
| `id` | AutoField | Primary Key |
|
||||
| `training` | FK → Training | Das Training |
|
||||
| `created_at` | DateTime | Auto |
|
||||
|
||||
### Neues Model: `TrainingHomeworkAssignment`
|
||||
|
||||
Welcher Wrestler welche Homework eines Trainings bekommen hat.
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|--------------|
|
||||
| `id` | AutoField | Primary Key |
|
||||
| `training_homework` | FK → TrainingHomework | Die Homework |
|
||||
| `wrestler` | FK → Wrestler | Der Wrestler |
|
||||
| `club` | FK → Club | Club (für Filter) |
|
||||
| `notes` | TextField | Optionale Notizen |
|
||||
| `created_at` | DateTime | Auto |
|
||||
|
||||
### Junction Table: `TrainingHomeworkExercise`
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|--------------|
|
||||
| `id` | AutoField | Primary Key |
|
||||
| `training_homework` | FK → TrainingHomework | Die Homework |
|
||||
| `exercise` | FK → Exercise | Die Übung |
|
||||
| `reps` | PositiveInteger | Wiederholungen |
|
||||
| `time_minutes` | PositiveInteger | Zeit in Minuten |
|
||||
| `order` | Integer | Sortierung |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
GET /homework/training-assignments/ - Alle Zuweisungen für Club (gefiltert)
|
||||
POST /homework/training-assignments/ - Neue Zuweisung erstellen
|
||||
GET /homework/training-assignments/{id}/ - Detail
|
||||
PATCH /homework/training-assignments/{id}/ - Aktualisieren (notizen, etc.)
|
||||
DELETE /homework/training-assignments/{id}/ - Löschen
|
||||
|
||||
POST /homework/training-assignments/{id}/complete/ - Als erledigt markieren
|
||||
POST /homework/training-assignments/{id}/uncomplete/ - Als nicht erledigt markieren
|
||||
```
|
||||
|
||||
### Frontend Types
|
||||
|
||||
```typescript
|
||||
interface ITrainingHomework {
|
||||
id: number
|
||||
training: number
|
||||
training_date: string
|
||||
training_group: string
|
||||
exercises: ITrainingHomeworkExercise[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface ITrainingHomeworkExercise {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
exercise_category: string
|
||||
reps: number | null
|
||||
time_minutes: number | null
|
||||
order: number
|
||||
}
|
||||
|
||||
interface ITrainingHomeworkAssignment {
|
||||
id: number
|
||||
training_homework: number
|
||||
training_homework_detail: ITrainingHomework
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
wrestler_group: string
|
||||
is_completed: boolean
|
||||
completion_date: string | null
|
||||
notes: string
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI: Training Detail Seite
|
||||
|
||||
### Layout (oben nach unten)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [← Zurück] Training vom 22.03.2026 [Kinder Badge]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Datum: Montag, 22. März 2026 │
|
||||
│ Zeit: 17:00 - 18:30 │
|
||||
│ Ort: Sporthalle │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ TEILNEHMER (8) │ │ TRAININGSHOMEWORK │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌─────────────────┐ │ │ Hier kommen die │ │
|
||||
│ │ │ 🧒 Max M. 📚 │ │ │ Homework-Übungen │ │
|
||||
│ │ │ Club A │ │ │ für dieses Training │ │
|
||||
│ │ └─────────────────┘ │ │ rein wenn jemand │ │
|
||||
│ │ ┌─────────────────┐ │ │ Homework hat. │ │
|
||||
│ │ │ 🧒 Anna S. 📚 │ │ │ │ │
|
||||
│ │ │ Club B │ │ │ │ │
|
||||
│ │ └─────────────────┘ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ [+ Ringer hinzu] │ │ │ │
|
||||
│ └─────────────────────┘ └─────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Teilnehmer-Karte
|
||||
|
||||
- Liste der Teilnehmer mit Avatar
|
||||
- Bei jedem Teilnehmer rechts:
|
||||
- **Homework-Button** (Buch-Icon) → öffnet Modal
|
||||
- **X-Button** → entfernen
|
||||
- "Ringer hinzufügen" Button unter der Liste
|
||||
|
||||
### Homework-Button Modal
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📚 Hausaufgabe zuweisen - Max Mustermann │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Übungen auswählen: │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ [🔍] Übungen durchsuchen... ││
|
||||
│ ├─────────────────────────────────────────────────────────┤│
|
||||
│ │ ○ Liegestütze (Kraft) [10] Reps ││
|
||||
│ │ ○ Sit-ups (Kraft) [30] Sek ││
|
||||
│ │ ○ Ausfallschritte (Technik) [ ] Reps [60] Sek ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Ausgewählte Übungen: │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ 1. Liegestütze - 10 Wiederholungen [✕] ││
|
||||
│ │ 2. Sit-ups - 30 Sekunden [✕] ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Notizen (optional): │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [Abbrechen] [Zuweisen] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI: Hausaufgaben Seite
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📚 Hausaufgaben │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Filter: [Alle] [Offen] [Erledigt] [🔍 Suche...] │
|
||||
│ │
|
||||
│ Training: 22.03.2026 (Kinder) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ ││
|
||||
│ │ │ 🧒 Max Mustermann [Erledigt ✓] │ ││
|
||||
│ │ │ Liegestütze (10), Sit-ups (30s), ... │ ││
|
||||
│ │ │ Zugewiesen: vor 2 Tagen │ ││
|
||||
│ │ └─────────────────────────────────────────────────────┘ ││
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ ││
|
||||
│ │ │ 🧒 Anna Schmidt [Offen] │ ││
|
||||
│ │ │ Liegestütze (10), Sit-ups (30s), ... │ ││
|
||||
│ │ │ Zugewiesen: vor 2 Tagen │ ││
|
||||
│ │ └─────────────────────────────────────────────────────┘ ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ Training: 20.03.2026 (Jugend) │
|
||||
│ ... │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Assignment Card
|
||||
|
||||
- Wrestler Name + Avatar
|
||||
- Status Badge: "Offen" (orange) oder "Erledigt" (grün)
|
||||
- Liste der Exercises mit reps/time
|
||||
- "Erledigt" Button zum Markieren
|
||||
- Click auf Card → expandiert Details
|
||||
|
||||
---
|
||||
|
||||
## UI: Dashboard
|
||||
|
||||
Zeigt offene Homework-Anzahl:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📊 Dashboard │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 🧒 24 │ │ 👨🏫 8 │ │ 📅 12 │ │ 📚 5 │ │
|
||||
│ │ Ringer │ │ Trainer │ │ Training │ │ Offene │ │
|
||||
│ │ │ │ │ │ diese Wo │ │ Homework│ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Farben & Styling
|
||||
|
||||
- Status "Offen": `bg-warning/10 text-warning`
|
||||
- Status "Erledigt": `bg-success/10 text-success`
|
||||
- Homework Icon: `BookOpen` von Lucide
|
||||
|
||||
---
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Neue Models** in `backend/homework/models.py`:
|
||||
- `TrainingHomework`
|
||||
- `TrainingHomeworkExercise` (Junction)
|
||||
- `TrainingHomeworkAssignment`
|
||||
|
||||
2. **Serializers** in `backend/homework/serializers.py`:
|
||||
- `TrainingHomeworkSerializer`
|
||||
- `TrainingHomeworkExerciseSerializer`
|
||||
- `TrainingHomeworkAssignmentSerializer`
|
||||
|
||||
3. **Views** in `backend/homework/views.py`:
|
||||
- `TrainingHomeworkAssignmentViewSet` mit `/training-assignments/` endpoint
|
||||
- `complete` und `uncomplete` actions
|
||||
|
||||
4. **URLs** in `backend/homework/urls.py`:
|
||||
- `training-assignments/` routes
|
||||
|
||||
5. **Admin** in `backend/homework/admin.py`:
|
||||
- Neue Models registrieren
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Types** in `frontend/src/lib/api.ts`:
|
||||
- `ITrainingHomework`
|
||||
- `ITrainingHomeworkExercise`
|
||||
- `ITrainingHomeworkAssignment`
|
||||
|
||||
2. **Training Detail Page** `frontend/src/app/(dashboard)/trainings/[id]/page.tsx`:
|
||||
- Layout: Details oben, dann 2-Spalten (Teilnehmer | TrainingHomework)
|
||||
- Teilnehmer-Karte mit Homework-Button
|
||||
- Modal für Homework-Zuweisung
|
||||
|
||||
3. **Homework Page** `frontend/src/app/(dashboard)/homework/page.tsx`:
|
||||
- Komplett neu schreiben
|
||||
- Gruppiert nach Training
|
||||
- Status-Filter
|
||||
- Erledigt/Undone togglen
|
||||
|
||||
4. **Dashboard** `frontend/src/app/(dashboard)/dashboard/page.tsx`:
|
||||
- Homework-Count hinzufügen
|
||||
|
||||
---
|
||||
|
||||
## Zu löschende alte Components
|
||||
|
||||
- Alte `Homework`, `HomeworkExerciseItem`, `HomeworkAssignment`, `HomeworkAssignmentItem`, `HomeworkStatus` Models optional behalten (für Migration) aber nicht mehr nutzen
|
||||
- Alte Frontend-Homework-Logik entfernen
|
||||
@@ -0,0 +1,123 @@
|
||||
# Trainings Calendar View - Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Add a calendar month view to the Trainings page as an alternative to the existing grid/list views. Users can toggle between three views: **Grid**, **List**, and **Calendar**.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### View Toggle
|
||||
|
||||
Location: Top-right of the page header (already exists as grid/list toggle)
|
||||
|
||||
```
|
||||
[+ Training hinzufügen] [Grid] [List] [Calendar]
|
||||
```
|
||||
|
||||
Three icon buttons with active state highlighting. Same pattern as existing grid/list toggle.
|
||||
|
||||
---
|
||||
|
||||
### Calendar Month View
|
||||
|
||||
**Library:** `react-big-calendar` with custom styling to match Shadcn design
|
||||
|
||||
**Layout:**
|
||||
- Full-width month grid
|
||||
- Weekdays: Mo Di Mi Do Fr Sa So (German)
|
||||
- Month/Year header with `<` `>` navigation arrows
|
||||
- "Today" button to jump to current month
|
||||
|
||||
**Day Cells:**
|
||||
- Show up to 3 training chips per day (colored by group)
|
||||
- "+N more" indicator if more trainings exist
|
||||
- Today highlighted with accent ring
|
||||
- Past days slightly muted
|
||||
|
||||
**Training Chips:**
|
||||
- Small colored pills showing time + group badge
|
||||
- Colors match existing groupConfig:
|
||||
- kids: primary
|
||||
- youth: secondary
|
||||
- adults: accent
|
||||
|
||||
**Click on Day:**
|
||||
- Opens a popover/dropdown showing all trainings for that day
|
||||
- Each training shows: time, group, location, attendance count
|
||||
- Click training to open detail page or edit modal
|
||||
|
||||
**Click on Training Chip:**
|
||||
- Opens training detail modal directly
|
||||
|
||||
---
|
||||
|
||||
## Data Loading
|
||||
|
||||
- Fetch trainings for the displayed month (with buffer for partial weeks)
|
||||
- `date_from` / `date_to` params sent to API to get relevant trainings
|
||||
- Cache fetched month data to avoid re-fetching on day navigation within same month
|
||||
|
||||
---
|
||||
|
||||
## Component Structure
|
||||
|
||||
```
|
||||
TrainingsPage/
|
||||
├── TrainingsContent (main container)
|
||||
│ ├── Header with toggle buttons
|
||||
│ ├── FilterBar (existing)
|
||||
│ ├── ViewContainer
|
||||
│ │ ├── GridView (existing)
|
||||
│ │ ├── ListView (existing)
|
||||
│ │ └── CalendarView (NEW)
|
||||
│ │ ├── CalendarHeader (month nav)
|
||||
│ │ ├── CalendarGrid
|
||||
│ │ └── DayPopover (trainings for selected day)
|
||||
│ └── Pagination (shown in grid/list, hidden in calendar)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
**Dependencies:**
|
||||
- `react-big-calendar` - Calendar component
|
||||
- `date-fns` - Date manipulation (already available via project or to be added)
|
||||
|
||||
**Styling:**
|
||||
- Custom CSS matching Shadcn theme colors
|
||||
- Override default react-big-calendar styles
|
||||
|
||||
**State:**
|
||||
- `viewMode: "grid" | "list" | "calendar"` (extend existing)
|
||||
- `calendarMonth: Date` - currently displayed month
|
||||
- `selectedDay: Date | null` - for day popover
|
||||
|
||||
**API:**
|
||||
- Keep existing `/trainings/?date_from=X&date_to=Y` to fetch trainings
|
||||
- Calculate date range from calendar month view
|
||||
|
||||
---
|
||||
|
||||
## Interactions
|
||||
|
||||
| Action | Result |
|
||||
|--------|--------|
|
||||
| Click calendar icon in toggle | Switch to calendar view |
|
||||
| Click `<` / `>` arrows | Navigate months |
|
||||
| Click "Today" | Jump to current month |
|
||||
| Click on day cell | Show popover with day's trainings |
|
||||
| Click on training chip | Open training detail modal |
|
||||
| Switch away from calendar | Preserve last viewed month |
|
||||
| Create/edit training | Refresh calendar data |
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
- Smooth transitions when switching views
|
||||
- Loading skeleton for calendar while fetching
|
||||
- Empty state for days with no trainings
|
||||
- Responsive: on mobile, calendar shows in portrait mode with smaller cells
|
||||
@@ -0,0 +1,156 @@
|
||||
# Dashboard Statistics Design
|
||||
|
||||
## Date: 2026-03-23
|
||||
|
||||
## Status: Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Expand the Dashboard with comprehensive statistics displayed in a Bento Grid layout. Replace the current simple count cards with rich stat cards and add new visualization cards for attendance, homework completion, wrestler distribution, and trainer activity.
|
||||
|
||||
## Design
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dashboard Willkommen, [User] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Stat Cards Row - 4 cards] │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ Ringer │ │Trainer │ │Training│ │Hausauf.│ │
|
||||
│ │ 127 │ │ 12 │ │ 45 │ │ 23 │ │
|
||||
│ │+5/woche│ │Aktiv:10│ │diese: 8│ │offen │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ [Middle Row - 2 cards] │
|
||||
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Teilnahme diese Woche │ │ Training Aktivität │ │
|
||||
│ │ ● Kinder ████████░░ 85% │ │ ▁▂▃▅▇██▇▅▃▂▁▂▃▅▇ │ │
|
||||
│ │ ● Jugend ██████░░░░ 62% │ │ Letzte 14 Tage │ │
|
||||
│ │ ● Erwachs.████████ 100% │ └─────────────────────────┘ │
|
||||
│ │ Ø: 24/30 │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ [Full Width Card] │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||
│ │ ✅ Hausaufgaben Erledigung ││
|
||||
│ │ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 38% ││
|
||||
│ └─────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ [Bottom Row - 2 cards] │
|
||||
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Ringer nach Gruppe │ │ Top Trainer │ │
|
||||
│ │ ●Kinder ████████ 45│ │ 1. Max M. 12 │ │
|
||||
│ │ ●Jugend ██████░░ 35│ │ 2. Anna S. 10 │ │
|
||||
│ │ ●Erwachs. █████░░░ 25│ │ 3. Tom K. 9 │ │
|
||||
│ │ ●Inaktiv ███░░░░░ 22│ │ │ │
|
||||
│ └─────────────────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
#### 1. Stat Card (existing, enhanced)
|
||||
- Icon + Label (top-left)
|
||||
- Main number (large, bold)
|
||||
- Sub-info (small, muted)
|
||||
- Hover: subtle lift effect
|
||||
|
||||
#### 2. Attendance Card
|
||||
- Title: "Teilnahme diese Woche"
|
||||
- 3 rows (Kids, Youth, Adults) with progress bars
|
||||
- Progress bar: colored background with filled portion
|
||||
- Percentage and absolute numbers (e.g., "24/30")
|
||||
- Average attendance line at bottom
|
||||
|
||||
#### 3. Training Activity Card
|
||||
- Title: "Training Aktivität"
|
||||
- Simple bar chart visualization using divs
|
||||
- 14 bars representing last 14 days
|
||||
- Bar height proportional to attendance
|
||||
- Label: "Letzte 14 Tage"
|
||||
|
||||
#### 4. Homework Completion Card (full width)
|
||||
- Title: "Hausaufgaben Erledigung"
|
||||
- Single horizontal progress bar
|
||||
- Green fill for completed, gray for open
|
||||
- Percentage and absolute numbers on right
|
||||
- Trend indicator: "+5 diese Woche"
|
||||
|
||||
#### 5. Wrestlers by Group Card
|
||||
- Title: "Ringer nach Gruppe"
|
||||
- 4 rows with progress bars
|
||||
- Groups: Kinder, Jugend, Erwachsene, Inaktiv
|
||||
- Each row: colored dot, label, progress bar, count
|
||||
|
||||
#### 6. Top Trainers Card
|
||||
- Title: "Top Trainer"
|
||||
- List of 3-5 trainers with:
|
||||
- Rank number
|
||||
- Trainer name
|
||||
- Training count
|
||||
- Simple list, no avatars needed
|
||||
|
||||
### Data Requirements
|
||||
|
||||
**Backend API Endpoints needed:**
|
||||
- `GET /api/v1/stats/dashboard/` - Returns all dashboard statistics
|
||||
|
||||
**Response shape:**
|
||||
```json
|
||||
{
|
||||
"wrestlers": { "total": 127, "this_week": 5 },
|
||||
"trainers": { "total": 12, "active": 10 },
|
||||
"trainings": { "total": 45, "this_week": 8 },
|
||||
"homework": { "open": 23, "completed": 38 },
|
||||
"attendance": {
|
||||
"this_week": {
|
||||
"kids": { "attended": 17, "total": 20, "percent": 85 },
|
||||
"youth": { "attended": 12, "total": 20, "percent": 62 },
|
||||
"adults": { "attended": 20, "total": 20, "percent": 100 }
|
||||
},
|
||||
"average": 24,
|
||||
"expected": 30
|
||||
},
|
||||
"activity": [
|
||||
{ "date": "2026-03-09", "count": 18 },
|
||||
{ "date": "2026-03-10", "count": 22 },
|
||||
...
|
||||
],
|
||||
"wrestlers_by_group": {
|
||||
"kids": 45,
|
||||
"youth": 35,
|
||||
"adults": 25,
|
||||
"inactive": 22
|
||||
},
|
||||
"top_trainers": [
|
||||
{ "name": "Max M.", "training_count": 12 },
|
||||
{ "name": "Anna S.", "training_count": 10 },
|
||||
{ "name": "Tom K.", "training_count": 9 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Backend**: Create stats endpoint with all aggregations
|
||||
2. **Frontend**: Update dashboard page with new stat cards
|
||||
3. **Frontend**: Add new visualization cards
|
||||
4. **Styling**: Match existing color scheme (primary, secondary, accent)
|
||||
|
||||
### Color Scheme
|
||||
- Primary: `#1B1A55` (navy) - for main elements
|
||||
- Secondary: `#535C91` (blue) - for secondary elements
|
||||
- Accent: `#9290C3` (lavender) - for highlights
|
||||
- Kids: Blue
|
||||
- Youth: Purple
|
||||
- Adults: Orange
|
||||
- Success/Completed: Green (`#22c55e`)
|
||||
- Inactive: Gray
|
||||
|
||||
## Notes
|
||||
- All statistics should load asynchronously
|
||||
- Show skeleton loaders during loading
|
||||
- Error handling: show "Fehler beim Laden" message if API fails
|
||||
@@ -0,0 +1,208 @@
|
||||
# Leistungstest Design
|
||||
|
||||
## Date: 2026-03-23
|
||||
|
||||
## Status: Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Create a "Leistungstest" (Performance Test) page for creating fitness test templates and assigning them to wrestlers. Each test records exercise results, total time, and rating. Results are tracked over time with progress visualization and leaderboards.
|
||||
|
||||
## Design
|
||||
|
||||
### Layout
|
||||
|
||||
Single page with 4 tabs:
|
||||
- **Vorlagen** (📋) — Create/edit/delete test templates
|
||||
- **Zuweisen** (📝) — Assign template to wrestler, record results
|
||||
- **Ergebnisse** (📊) — View results with progress tracking
|
||||
- **Leaderboard** (🏆) — Rankings by template
|
||||
|
||||
Plus: Sidebar navigation item "Leistungstest"
|
||||
|
||||
### Tab 1: Vorlagen
|
||||
|
||||
**Template List:**
|
||||
- Card for each template showing name, exercise list, usage count
|
||||
- Delete button on each card
|
||||
|
||||
**Create Template Form:**
|
||||
- Name input
|
||||
- Dynamic list of exercises with target reps
|
||||
- Add/remove exercise buttons
|
||||
- Save button
|
||||
|
||||
### Tab 2: Zuweisen
|
||||
|
||||
**Selection:**
|
||||
- Wrestler dropdown (shows names, not IDs)
|
||||
- Template dropdown (shows names, not IDs)
|
||||
|
||||
**Test Form (when both selected):**
|
||||
- List of exercises from template
|
||||
- For each exercise: target reps input + actual result input
|
||||
- Total time input (minutes)
|
||||
- Overall rating (5 stars)
|
||||
- Notes textarea
|
||||
- Submit button
|
||||
|
||||
### Tab 3: Ergebnisse
|
||||
|
||||
**Filters:**
|
||||
- Wrestler dropdown
|
||||
- Template dropdown
|
||||
|
||||
**Results Table:**
|
||||
- Columns: Date, Wrestler, Template, Score (%), Rating, Time
|
||||
- Sorted by date (newest first)
|
||||
|
||||
**Progress Section (when one wrestler + one template selected):**
|
||||
- Shows improvement over time for each exercise
|
||||
- Progress bars with percentage change
|
||||
|
||||
### Tab 4: Leaderboard
|
||||
|
||||
**Selection:**
|
||||
- Template dropdown
|
||||
|
||||
**Rankings Table:**
|
||||
- Columns: Rank, Wrestler, Score %, Rating, Time
|
||||
- Sorted by score (highest first)
|
||||
- Medal icons for top 3 (🥇🥈🥉)
|
||||
|
||||
## Data Models
|
||||
|
||||
### Backend Model: LeistungstestTemplate
|
||||
|
||||
```python
|
||||
class LeistungstestTemplate(models.Model):
|
||||
name = CharField(max_length=200)
|
||||
created_at = DateTimeField(auto_now_add=True)
|
||||
updated_at = DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
```
|
||||
|
||||
### Backend Model: LeistungstestTemplateExercise
|
||||
|
||||
```python
|
||||
class LeistungstestTemplateExercise(models.Model):
|
||||
template = ForeignKey(LeistungstestTemplate, related_name='exercises')
|
||||
exercise = ForeignKey('exercises.Exercise')
|
||||
target_reps = PositiveIntegerField()
|
||||
order = IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['template', 'order']
|
||||
unique_together = ['template', 'exercise']
|
||||
```
|
||||
|
||||
### Backend Model: LeistungstestResult
|
||||
|
||||
```python
|
||||
class LeistungstestResult(models.Model):
|
||||
template = ForeignKey(LeistungstestTemplate)
|
||||
wrestler = ForeignKey('wrestlers.Wrestler')
|
||||
total_time_minutes = PositiveIntegerField(null=True, blank=True)
|
||||
rating = PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
|
||||
notes = TextField(blank=True)
|
||||
completed_at = DateTimeField(default=timezone.now)
|
||||
created_at = DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-completed_at']
|
||||
indexes = [
|
||||
Index(fields=['wrestler']),
|
||||
Index(fields=['template']),
|
||||
Index(fields=['completed_at']),
|
||||
]
|
||||
```
|
||||
|
||||
### Backend Model: LeistungstestResultItem
|
||||
|
||||
```python
|
||||
class LeistungstestResultItem(models.Model):
|
||||
result = ForeignKey(LeistungstestResult, related_name='items')
|
||||
exercise = ForeignKey('exercises.Exercise')
|
||||
target_reps = PositiveIntegerField()
|
||||
actual_reps = PositiveIntegerField()
|
||||
order = IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['result', 'order']
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```
|
||||
# Templates
|
||||
GET /api/v1/leistungstest/templates/ — List templates
|
||||
POST /api/v1/leistungstest/templates/ — Create template
|
||||
GET /api/v1/leistungstest/templates/{id}/ — Get template
|
||||
PATCH /api/v1/leistungstest/templates/{id}/ — Update template
|
||||
DELETE /api/v1/leistungstest/templates/{id}/ — Delete template
|
||||
|
||||
# Template Exercises
|
||||
POST /api/v1/leistungstest/template-exercises/ — Add exercise to template
|
||||
DELETE /api/v1/leistungstest/template-exercises/{id}/ — Remove exercise
|
||||
|
||||
# Results
|
||||
GET /api/v1/leistungstest/results/ — List results (filterable)
|
||||
POST /api/v1/leistungstest/results/ — Create result
|
||||
GET /api/v1/leistungstest/results/{id}/ — Get result
|
||||
DELETE /api/v1/leistungstest/results/{id}/ — Delete result
|
||||
|
||||
# Leaderboard
|
||||
GET /api/v1/leistungstest/leaderboard/ — Get rankings by template
|
||||
```
|
||||
|
||||
## Response Shapes
|
||||
|
||||
### Template Response
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Kraft-Test",
|
||||
"exercises": [
|
||||
{"id": 1, "exercise": 1, "exercise_name": "Klimmzüge", "target_reps": 20, "order": 0},
|
||||
{"id": 2, "exercise": 2, "exercise_name": "Liegestütze", "target_reps": 50, "order": 1}
|
||||
],
|
||||
"usage_count": 12,
|
||||
"created_at": "2026-03-20T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Result Request
|
||||
```json
|
||||
{
|
||||
"template": 1,
|
||||
"wrestler": 1,
|
||||
"total_time_minutes": 12,
|
||||
"rating": 4,
|
||||
"notes": "Gute Leistung",
|
||||
"items": [
|
||||
{"exercise": 1, "target_reps": 20, "actual_reps": 20},
|
||||
{"exercise": 2, "target_reps": 50, "actual_reps": 48}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Leaderboard Response
|
||||
```json
|
||||
{
|
||||
"template": {"id": 1, "name": "Kraft-Test"},
|
||||
"rankings": [
|
||||
{"rank": 1, "wrestler": {"id": 2, "name": "Anna S."}, "score_percent": 100, "rating": 5, "time_minutes": 10},
|
||||
{"rank": 2, "wrestler": {"id": 1, "name": "Max M."}, "score_percent": 96, "rating": 4, "time_minutes": 12}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Wrestler and template dropdowns show names, not IDs (use SelectValue with find)
|
||||
- Score = (sum of actual_reps / sum of target_reps) * 100
|
||||
- Results table shows score as percentage with progress bar
|
||||
- Leaderboard only shows wrestlers who have done the specific template
|
||||
- Progress tracking shows change in score between first and latest result for same wrestler+template
|
||||
@@ -0,0 +1,220 @@
|
||||
# Leistungstest & UI Modernisierung - Design
|
||||
|
||||
## Status
|
||||
- **Draft** - Needs user review
|
||||
- **Date**: 2026-03-23
|
||||
|
||||
---
|
||||
|
||||
## Teil 1: Backend Fixes
|
||||
|
||||
### Problem 1: Template-Erstellung
|
||||
**Aktuell**: Template wird erstellt, aber Übungen werden in separaten POST-Requests gesendet → Inkonsistenz
|
||||
|
||||
**Lösung**: Die `create()` Methode im ViewSet sollte Übungen direkt mit dem Template speichern
|
||||
|
||||
```python
|
||||
def create(self, request, *args, **kwargs):
|
||||
# 1. Template erstellen
|
||||
# 2. Übungen direkt in derselben Transaktion speichern
|
||||
# 3. Response mit allen Daten zurückgeben
|
||||
```
|
||||
|
||||
### Problem 2: IDs statt Namen
|
||||
**Ursache**: Frontend zeigt `SelectItem key={id} value={String(id)}` - der `value` ist die ID, aber `SelectValue` zeigt `children` nicht korrekt
|
||||
|
||||
**Lösung**: Nach dem Speichern der Übungen muss das Template mit `prefetch_related` neu geladen werden, damit `exercise_name` verfügbar ist
|
||||
|
||||
---
|
||||
|
||||
## Teil 2: UI Modernisierung (Ganze App)
|
||||
|
||||
### Design Philosophy: "Elegant & Clean"
|
||||
- Sanfte, subtile Animationen
|
||||
- Professionelles Erscheinungsbild
|
||||
- Fokus auf Lesbarkeit und Benutzerfreundlichkeit
|
||||
- Keine übertriebenen Effekte
|
||||
|
||||
### Aktuelle Probleme
|
||||
1. **Keine Page Transitions** - Seitenwechsel sind abrupt
|
||||
2. **Statische Hover-Effekte** - Keine sanften Übergänge
|
||||
3. **Cards ohne Tiefe** - Keine subtilen Schatten oder Glows
|
||||
4. **Monochrome Icons** - Keine Farbvariation
|
||||
5. **FadeIn nur auf Hauptelementen** - Inkonsistente Animationen
|
||||
|
||||
### Geplante Verbesserungen
|
||||
|
||||
#### 2.1 Global Page Transitions
|
||||
```tsx
|
||||
// app/(dashboard)/layout.tsx
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
// Page-Übergänge mit smooth fade + slide
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
#### 2.2 Subtile Card-Hover-Effekte
|
||||
```tsx
|
||||
// Aktuell
|
||||
<Card>
|
||||
// Neu mit motion
|
||||
<motion.div whileHover={{ y: -2, boxShadow: "0 10px 40px -10px rgba(0,0,0,0.1)" }}>
|
||||
<Card>
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
#### 2.3 Button Micro-Interactions
|
||||
```tsx
|
||||
// Scale + subtle glow on hover
|
||||
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
```
|
||||
|
||||
#### 2.4 Staggered List Animations
|
||||
```tsx
|
||||
// Für Listen und Cards mit verzögertem Erscheinen
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ staggerChildren: 0.05 }}
|
||||
>
|
||||
{items.map(item => (
|
||||
<motion.div key={item.id} variants={itemVariant} />
|
||||
))}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
#### 2.5 Smooth Scrollbar Styling
|
||||
```css
|
||||
/* Modern scrollbar für Webkit-Browser */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 Icon Color Transitions
|
||||
```tsx
|
||||
// Icons mit Farbwechsel bei Hover
|
||||
<motion.div whileHover={{ color: "#1B1A55" }}>
|
||||
<Icon className="transition-colors" />
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teil 3: Component-Spezifische Änderungen
|
||||
|
||||
### 3.1 Sidebar
|
||||
- Hover-State mit sanftem Slide + Farbwechsel
|
||||
- Active-State mit subtle glow/underline
|
||||
- Smooth expand/collapse für Submenüs
|
||||
|
||||
### 3.2 Cards (Stat Cards, Data Cards)
|
||||
- Subtle lift bei Hover
|
||||
- Border-color transition
|
||||
- Sanfte Schatten-Verstärkung
|
||||
|
||||
### 3.3 Tables
|
||||
- Row hover mit background shift
|
||||
- Smooth row appearance (staggered)
|
||||
- Subtle column highlight
|
||||
|
||||
### 3.4 Forms
|
||||
- Input focus mit border-color + subtle glow
|
||||
- Label animation (float label style optional)
|
||||
- Button loading state mit spinner
|
||||
|
||||
### 3.5 Tabs
|
||||
- Underline indicator mit smooth slide
|
||||
- Active tab mit subtle scale
|
||||
|
||||
---
|
||||
|
||||
## Teil 4: Animations-Token
|
||||
|
||||
```typescript
|
||||
// lib/animations.ts (neu oder erweitern)
|
||||
export const transitions = {
|
||||
fast: "150ms",
|
||||
normal: "250ms",
|
||||
slow: "400ms",
|
||||
}
|
||||
|
||||
export const easing = {
|
||||
default: "easeOut",
|
||||
smooth: [0.4, 0, 0.2, 1], // cubic-bezier
|
||||
bounce: [0.68, -0.55, 0.265, 1.55],
|
||||
}
|
||||
|
||||
export const stagger = {
|
||||
fast: 0.05,
|
||||
normal: 0.1,
|
||||
slow: 0.15,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Reihenfolge
|
||||
|
||||
1. **Backend Fix** (10 min)
|
||||
- Template create mit embedded exercises
|
||||
- Verify exercise_name wird korrekt zurückgegeben
|
||||
|
||||
2. **Animation Library Setup** (5 min)
|
||||
- Framer Motion installieren/verifizieren
|
||||
- Animation tokens definieren
|
||||
|
||||
3. **Global Layout** (15 min)
|
||||
- Page transitions in layout.tsx
|
||||
- Smooth scrolling CSS
|
||||
|
||||
4. **Sidebar Enhancement** (15 min)
|
||||
- Hover/active animations
|
||||
- Icon transitions
|
||||
|
||||
5. **Card Components** (20 min)
|
||||
- Alle Cards mit hover effects
|
||||
- Stat cards auf Dashboard
|
||||
|
||||
6. **List/Table Enhancements** (20 min)
|
||||
- Staggered animations
|
||||
- Row hover effects
|
||||
|
||||
7. **Form Enhancements** (15 min)
|
||||
- Input focus states
|
||||
- Button micro-interactions
|
||||
|
||||
8. **Tab Enhancements** (10 min)
|
||||
- Slide underline indicator
|
||||
|
||||
---
|
||||
|
||||
## Erfolgskriterien
|
||||
|
||||
- [ ] Template-Erstellung funktioniert vollständig mit Übungen
|
||||
- [ ] Dropdowns zeigen Namen statt IDs
|
||||
- [ ] Seitenwechsel sind smooth (fade + slide)
|
||||
- [ ] Hover-Effekte auf allen interaktiven Elementen
|
||||
- [ ] Listen appear mit staggered animation
|
||||
- [ ] Keine "jarring" Übergänge
|
||||
- [ ] Performance bleibt gut (nicht zu viele Animationen)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Training Log & Progress Analysis Design
|
||||
|
||||
## Date: 2026-03-23
|
||||
|
||||
## Status: Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Create a "Training Log" page for recording actual exercises performed by wrestlers during training sessions. The system tracks detailed exercise data (reps, sets, time, weight, rating) and provides analysis features including wrestler comparison and progress tracking over time.
|
||||
|
||||
## Design
|
||||
|
||||
### Layout
|
||||
|
||||
Single page with 3 tabs:
|
||||
- **Log** (📋) — Quick entry form for new exercise records
|
||||
- **Historie** (📜) — Filterable list of all entries
|
||||
- **Analyse** (📊) — Summary stats, progress tracking, and wrestler comparison
|
||||
|
||||
### Tab 1: Log
|
||||
|
||||
**Entry Form:**
|
||||
- Ringer dropdown (required)
|
||||
- Training dropdown (optional — links to specific training session)
|
||||
- Übung dropdown (required — from exercises)
|
||||
- reps input (number, required)
|
||||
- sets input (number, default 1)
|
||||
- zeit input (minutes, optional)
|
||||
- Gewicht input (kg, optional)
|
||||
- Bewertung — 5-star rating selector
|
||||
- Notizen textarea (optional)
|
||||
- Speichern button
|
||||
|
||||
### Tab 2: Historie
|
||||
|
||||
**Filter Bar:**
|
||||
- Ringer dropdown (filter by wrestler)
|
||||
- Datum dropdown (date range)
|
||||
- Übung dropdown (filter by exercise)
|
||||
- Suchen button
|
||||
|
||||
**Entry List:**
|
||||
- Table showing: Datum, Ringer, Übung, reps×sets, Zeit, Gewicht, Bewertung
|
||||
- Clickable rows for potential editing
|
||||
- Sorted by date (newest first)
|
||||
|
||||
### Tab 3: Analyse
|
||||
|
||||
**Wrestler Selector:**
|
||||
- Dropdown to select specific wrestler
|
||||
- OR "Alle vergleichen" option
|
||||
|
||||
**Summary Card:**
|
||||
- Gesamt: total entries
|
||||
- Verschiedene Übungen: count of unique exercises
|
||||
- Wiederholungen: total reps
|
||||
- Ø Sätze: average sets per entry
|
||||
- Ø Bewertung: average rating
|
||||
- Diese Woche: entries this week
|
||||
|
||||
**Top Übungen Card:**
|
||||
- List of most performed exercises with counts
|
||||
- Bar visualization
|
||||
|
||||
**Fortschritt Card:**
|
||||
- Shows improvement over time for selected wrestler
|
||||
- Progress bars with percentage change
|
||||
- Only exercises with measurable progress
|
||||
|
||||
**Übungsvergleich Card:**
|
||||
- Side-by-side comparison of two wrestlers
|
||||
- Bar chart showing reps for same exercise types
|
||||
- Only shows exercises both wrestlers have done
|
||||
|
||||
## Data Models
|
||||
|
||||
### Backend Model: TrainingLogEntry
|
||||
|
||||
```python
|
||||
class TrainingLogEntry(models.Model):
|
||||
wrestler = ForeignKey('wrestlers.Wrestler')
|
||||
training = ForeignKey('trainings.Training', null=True, blank=True)
|
||||
exercise = ForeignKey('exercises.Exercise')
|
||||
reps = PositiveIntegerField()
|
||||
sets = PositiveIntegerField(default=1)
|
||||
time_minutes = PositiveIntegerField(null=True, blank=True)
|
||||
weight_kg = DecimalField(null=True, blank=True, max_digits=5, decimal_places=2)
|
||||
rating = PositiveIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)])
|
||||
notes = TextField(blank=True)
|
||||
logged_at = DateTimeField(default=timezone.now)
|
||||
created_at = DateTimeField(auto_now_add=True)
|
||||
updated_at = DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-logged_at']
|
||||
indexes = [
|
||||
Index(fields=['wrestler']),
|
||||
Index(fields=['exercise']),
|
||||
Index(fields=['logged_at']),
|
||||
]
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```
|
||||
GET /api/v1/training-log/ — List entries (filterable)
|
||||
POST /api/v1/training-log/ — Create entry
|
||||
GET /api/v1/training-log/{id}/ — Get single entry
|
||||
PATCH /api/v1/training-log/{id}/ — Update entry
|
||||
DELETE /api/v1/training-log/{id}/ — Delete entry
|
||||
GET /api/v1/training-log/stats/ — Analysis stats
|
||||
GET /api/v1/training-log/compare/ — Comparison data
|
||||
```
|
||||
|
||||
## Response Shapes
|
||||
|
||||
### List Response (GET /training-log/)
|
||||
```json
|
||||
{
|
||||
"count": 156,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"wrestler": 1,
|
||||
"wrestler_name": "Max M.",
|
||||
"training": 5,
|
||||
"training_date": "2026-03-23",
|
||||
"exercise": 1,
|
||||
"exercise_name": "Pushups",
|
||||
"reps": 50,
|
||||
"sets": 3,
|
||||
"time_minutes": 2,
|
||||
"weight_kg": 10.0,
|
||||
"rating": 4,
|
||||
"notes": "",
|
||||
"logged_at": "2026-03-23T15:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Stats Response (GET /training-log/stats/?wrestler=1)
|
||||
```json
|
||||
{
|
||||
"total_entries": 156,
|
||||
"unique_exercises": 12,
|
||||
"total_reps": 4230,
|
||||
"avg_sets": 3.2,
|
||||
"avg_rating": 3.8,
|
||||
"this_week": 23,
|
||||
"top_exercises": [
|
||||
{"name": "Pushups", "count": 45},
|
||||
{"name": "Klimmzüge", "count": 32}
|
||||
],
|
||||
"progress": {
|
||||
"Pushups": {"before": 40, "after": 50, "change_percent": 25},
|
||||
"Klimmzüge": {"before": 8, "after": 10, "change_percent": 40}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Compare Response (GET /training-log/compare/?wrestler1=1&wrestler2=2)
|
||||
```json
|
||||
{
|
||||
"wrestler1": {"id": 1, "name": "Max M."},
|
||||
"wrestler2": {"id": 2, "name": "Anna S."},
|
||||
"exercises": [
|
||||
{
|
||||
"exercise": "Pushups",
|
||||
"wrestler1_avg": 50,
|
||||
"wrestler2_avg": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Backend Model** — Create TrainingLogEntry model in homework app (or new training_log app)
|
||||
2. **Backend ViewSet** — CRUD endpoints + stats endpoint + compare endpoint
|
||||
3. **Frontend API Types** — Add ITrainingLogEntry interface
|
||||
4. **Frontend Page** — Create /training-log page with 3 tabs
|
||||
|
||||
## Notes
|
||||
- Training dropdown should only show trainings from the past (can't log future)
|
||||
- Weight should accept decimals (e.g., 10.5 kg)
|
||||
- Rating is 1-5 stars
|
||||
- All timestamps stored in UTC, displayed in local time
|
||||
@@ -0,0 +1,91 @@
|
||||
# Leistungstest Ergebnisse — Neue Ansicht
|
||||
|
||||
## Overview
|
||||
|
||||
Der "ergebnisse" Tab im Leistungstest zeigt ab sofort Ergebnisse in einer modernen Karten-Ansicht mit Toggle-Option zwischen Karten und Tabelle.
|
||||
|
||||
## Design
|
||||
|
||||
### Karten-Ansicht (Default)
|
||||
- Pro Ergebnis eine übersichtliche Karte
|
||||
- Header: Ringer-Avatar (initialbasiert), Name, Datum, Vorlage
|
||||
- Header-Right: Score (%) in groß (farbcodiert: grün ≥100%, gelb ≥80%, rot <80%), Gesamtzeit
|
||||
- Body: Liste aller Übungen mit Soll/Reps, elapsed_seconds, Done-Check
|
||||
- Footer: Bewertung (Sterne), Bearbeiten/Löschen Buttons
|
||||
|
||||
### Toggle
|
||||
- Toggle-Button-Leiste oben rechts: "⊞ Tabelle" | "📋 Karten"
|
||||
- Aktive Ansicht ist hervorgehoben (weißer Hintergrund, Schatten)
|
||||
- Auswahl wird in User-Preferences gespeichert (optional, falls bereits Preferences-System existiert)
|
||||
|
||||
### Farbcodierung
|
||||
- Score ≥100%: Grün (`#16a34a`)
|
||||
- Score ≥80%: Gelb (`#ca8a04`)
|
||||
- Score <80%: Rot (`#dc2626`)
|
||||
|
||||
### Filter
|
||||
- Vorlage-Dropdown (alle Vorlagen oder spezifische)
|
||||
- Ringer-Dropdown (optional)
|
||||
- Sortierung: Neueste zuerst (default)
|
||||
|
||||
### Detail-Ansicht pro Ergebnis
|
||||
- Alle Übungen mit:
|
||||
- Übungsname
|
||||
- Soll: `targetReps` (z.B. "3×10")
|
||||
- Zeit: `elapsedSeconds` formatiert als `m:ss`
|
||||
- Status: ✓ wenn done
|
||||
|
||||
## Komponenten
|
||||
|
||||
### ResultsCardsView
|
||||
- Rendering aller Ergebnis-Karten
|
||||
- Filter-Logik
|
||||
- Toggle-State
|
||||
|
||||
### ResultCard
|
||||
- Einzelne Ergebniskarte
|
||||
- Avatar mit Initial des Ringers
|
||||
- Farbcodierter Score
|
||||
- Übungsliste mit Zeiten
|
||||
- Aktions-Buttons
|
||||
|
||||
## API
|
||||
|
||||
Ergebnisse werden via `apiFetch("/leistungstest/results/")` geladen (existierendes API).
|
||||
|
||||
Response-Shape:
|
||||
```typescript
|
||||
{
|
||||
id: number
|
||||
template: number
|
||||
template_name: string
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
total_time_seconds: number
|
||||
rating: number
|
||||
score_percent: number
|
||||
completed_at: string
|
||||
items: Array<{
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
target_reps: number
|
||||
actual_reps: number
|
||||
elapsed_seconds: number
|
||||
order: number
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
Keine Änderungen notwendig — bestehendes API wird verwendet.
|
||||
|
||||
## Implementierung
|
||||
|
||||
1. Neuer State `resultViewMode: "cards" | "table"` (default: "cards")
|
||||
2. `ResultsCardsView` Component mit Karten-Logic
|
||||
3. Toggle-Buttons im CardHeader
|
||||
4. Ergebnis-Karten mit allen Details
|
||||
5. Filter funktionieren wie bisher
|
||||
6. Pagination bleibt bestehen
|
||||
@@ -0,0 +1,255 @@
|
||||
# Leistungstest Live-Timer — Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Add a live-timer mode to the Leistungstest "Zuweisen" tab. Instead of manually entering results for each wrestler, trainers can run through a timed workout session: select a template and multiple wrestlers, then start a live timer that tracks per-wrestler time and reps as they complete exercises in sequence. Results are saved immediately per wrestler.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two Modes in the Zuweisen Tab
|
||||
|
||||
The Zuweisen tab has two modes, toggled by a "Modus" switcher above the form:
|
||||
|
||||
1. **Form Mode** (default, existing behavior): Template + Wrestler selection → manual time/reps entry → save
|
||||
2. **Timer Mode** (new): Template + Wrestler selection → live timer interface → results saved per wrestler automatically
|
||||
|
||||
### Mode Toggle
|
||||
|
||||
```tsx
|
||||
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
|
||||
```
|
||||
|
||||
- Toggle switch above the form
|
||||
- Form Mode: existing inputs for template, wrestlers, time (min+sec), rating, notes
|
||||
- Timer Mode: shows "Training starten" button when wrestlers selected
|
||||
|
||||
---
|
||||
|
||||
## Timer Mode Data Flow
|
||||
|
||||
### State
|
||||
|
||||
```typescript
|
||||
interface TimerWrestler {
|
||||
wrestler: IWrestler
|
||||
status: "pending" | "active" | "done"
|
||||
startedAt: number | null // Date.now() when started
|
||||
exercises: TimerExercise[]
|
||||
resultId: number | null // created after first wrestler finishes
|
||||
}
|
||||
|
||||
interface TimerExercise {
|
||||
exerciseId: number
|
||||
exerciseName: string
|
||||
targetReps: number // from template
|
||||
actualReps: string // user input
|
||||
status: "pending" | "done"
|
||||
startedAt: number | null
|
||||
}
|
||||
|
||||
interface TimerSession {
|
||||
templateId: number
|
||||
wrestlers: TimerWrestler[]
|
||||
currentWrestlerIndex: number
|
||||
totalElapsedSeconds: number
|
||||
isRunning: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Session Persistence (localStorage)
|
||||
|
||||
```typescript
|
||||
// Key: "leistungstest_timer_session"
|
||||
// Saved on every state change
|
||||
// Restored on page load if session exists and isRunning === false (interrupted)
|
||||
```
|
||||
|
||||
On page load, if an incomplete session is found in localStorage:
|
||||
- Prompt: "Offenes Training gefunden. Fortsetzen?" → Yes restores session, No clears it
|
||||
|
||||
---
|
||||
|
||||
## UI Layout — Timer Mode
|
||||
|
||||
### Split View
|
||||
|
||||
```
|
||||
┌─────────────────────┬──────────────────────────────────────────┐
|
||||
│ RINGER │ AKTUELLER RINGER: Anna Schmidt │
|
||||
│ │ │
|
||||
│ ○ Max Mustermann │ ┌──────────────┐ [Pausieren] │
|
||||
│ ● Anna Schmidt │ │ 05:23 │ │
|
||||
│ ○ Tom Klein │ └──────────────┘ │
|
||||
│ │ │
|
||||
│ │ ÜBUNGEN (2/5) │
|
||||
│ │ ─────────────────────────────────────── │
|
||||
│ │ Liegestütze Soll: 3x10 │
|
||||
│ │ [___ Ist-Reps] ▶ START │
|
||||
│ │ │
|
||||
│ │ Kniebeugen Soll: 3x15 │
|
||||
│ │ [___ Ist-Reps] ✅ ERLEDIGT │
|
||||
│ │ │
|
||||
│ │ Burpees Soll: 3x8 │
|
||||
│ │ [___ Ist-Reps] ▶ START │
|
||||
│ │ │
|
||||
│ │ ─────────────────────────────────────── │
|
||||
│ │ [WEITER ZUM NÄCHSTEN RINGER →] │
|
||||
│ │ [TRAINING BEENDEN] │
|
||||
└─────────────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
**Left Panel (wrestler list):**
|
||||
- Shows all selected wrestlers
|
||||
- Status indicator: ○ pending, ● active, ✅ done
|
||||
- Click on a done wrestler to review/edit their results
|
||||
- Stays visible throughout
|
||||
|
||||
**Right Panel — Top (current wrestler + timer):**
|
||||
- Wrestler name prominently displayed
|
||||
- Large timer display: MM:SS format, updates every second
|
||||
- Pause/Resume button
|
||||
- Total elapsed time (cumulative across all wrestlers)
|
||||
|
||||
**Right Panel — Middle (exercise list):**
|
||||
- List of exercises from template
|
||||
- Each row shows: exercise name, target reps ("Soll")
|
||||
- Input field for actual reps
|
||||
- Start/Done button per exercise
|
||||
- When exercise is marked done: saves to that wrestler's result (if result exists) or marks pending
|
||||
|
||||
**Right Panel — Bottom (actions):**
|
||||
- "Weiter zum nächsten Ringer" → marks current wrestler done, saves result, advances
|
||||
- "Training beenden" → final save, exits timer mode, shows summary
|
||||
|
||||
---
|
||||
|
||||
## Timer Logic
|
||||
|
||||
### Starting
|
||||
1. User selects template (Sheet) + wrestlers (Sheet)
|
||||
2. Clicks "Training starten"
|
||||
3. Timer mode activates, first wrestler is set to "active", timer starts
|
||||
|
||||
### Per-Exercise Flow
|
||||
1. Trainer sees current exercise (from template)
|
||||
2. Enters actual reps in input field
|
||||
3. Clicks "Start" → exercise timer starts
|
||||
4. Wrestler completes exercise
|
||||
5. Trainer clicks "Done" → exercise marked complete, elapsed time recorded
|
||||
6. Next exercise auto-advances to active state
|
||||
|
||||
### Per-Wrestler Flow
|
||||
1. All exercises done for current wrestler
|
||||
2. Trainer clicks "Weiter zum nächsten Ringer"
|
||||
3. Result is saved immediately via API:
|
||||
- POST `/leistungstest/results/` with all exercise items
|
||||
- `total_time_seconds` = elapsed time for this wrestler
|
||||
4. Next wrestler becomes active, timer continues (does NOT reset)
|
||||
5. Repeat until all wrestlers done
|
||||
|
||||
### Finishing
|
||||
1. Trainer clicks "Training beenden"
|
||||
2. If current wrestler has started but not all exercises done → prompt: "Nicht alle Übungen gemacht. Trotzdem beenden?"
|
||||
3. Confirm → save current wrestler's partial result
|
||||
4. Show summary: wrestlers completed, total time, scores
|
||||
|
||||
### Pause/Resume
|
||||
- "Pausieren" stops the timer
|
||||
- Timer display shows "PAUSIERT" in orange
|
||||
- "Fortsetzen" resumes
|
||||
- Paused time is accumulated in `totalElapsedSeconds`
|
||||
|
||||
---
|
||||
|
||||
## Backend API
|
||||
|
||||
No new endpoints needed. Use existing:
|
||||
|
||||
- `POST /leistungstest/results/` — create result for each wrestler
|
||||
- `GET /leistungstest/results/?template=X` — list results
|
||||
|
||||
### Create Result Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"template": 1,
|
||||
"wrestler": 5,
|
||||
"total_time_seconds": 323,
|
||||
"rating": 3,
|
||||
"notes": "",
|
||||
"items": [
|
||||
{ "exercise": 3, "target_reps": 30, "actual_reps": 28, "order": 0 },
|
||||
{ "exercise": 7, "target_reps": 45, "actual_reps": 45, "order": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Mode (Existing)
|
||||
|
||||
Unchanged behavior. Shows when `inputMode === "form"`:
|
||||
- Template select (Sheet)
|
||||
- Wrestler select (Sheet, multi)
|
||||
- Minutes + seconds inputs
|
||||
- Rating select
|
||||
- Notes textarea
|
||||
- Submit creates single result
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
frontend/src/app/(dashboard)/leistungstest/page.tsx
|
||||
- Add inputMode state
|
||||
- Add TimerMode component (inline or separate)
|
||||
- TimerMode: TimerSession, TimerWrestler, TimerExercise types
|
||||
- localStorage persistence with useEffect
|
||||
|
||||
New sub-components within page.tsx:
|
||||
- TimerMode (full-width layout replacing the form)
|
||||
- WrestlerListPanel (left side)
|
||||
- TimerPanel (right side: timer + exercises)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Persistence
|
||||
|
||||
```typescript
|
||||
const SESSION_KEY = "leistungstest_timer_session"
|
||||
|
||||
useEffect(() => {
|
||||
if (inputMode === "timer" && session) {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
||||
}
|
||||
}, [session, inputMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputMode === "timer") {
|
||||
const saved = localStorage.getItem(SESSION_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (parsed.isRunning === false) {
|
||||
// show restore prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [inputMode])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. Timer state + basic timer display (MM:SS ticking)
|
||||
2. Wrestler list panel with status
|
||||
3. Exercise list with reps input + start/done
|
||||
4. Per-wrestler save on "Weiter"
|
||||
5. localStorage persistence
|
||||
6. Pause/Resume
|
||||
7. Training beenden + summary
|
||||
8. Form mode toggle
|
||||
@@ -0,0 +1,293 @@
|
||||
# Leistungstest Live-Timer — Design Specification
|
||||
|
||||
## Overview
|
||||
|
||||
**What:** A live-timer mode for the Leistungstest "Zuweisen" tab where a trainer runs through a timed workout with multiple wrestlers simultaneously. All wrestlers start the same exercise at the same time. The trainer clicks "Erledigt" per wrestler when each finishes — the next exercise starts automatically. All wrestlers visible in a table-like layout.
|
||||
|
||||
**Why:** Replaces the current per-wrestler, per-exercise form entry with a real-time group training experience. Trainers can see all wrestlers at once and track live progress.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### TrainingSession (React state + localStorage)
|
||||
|
||||
```typescript
|
||||
interface TrainingSession {
|
||||
templateId: number
|
||||
templateName: string
|
||||
wrestlers: WrestlerSession[]
|
||||
globalElapsedSeconds: number // shared across all wrestlers
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
interface WrestlerSession {
|
||||
wrestler: IWrestler
|
||||
currentExerciseIndex: number
|
||||
exercises: ExerciseSession[]
|
||||
}
|
||||
|
||||
interface ExerciseSession {
|
||||
exerciseId: number
|
||||
exerciseName: string
|
||||
targetReps: string // e.g. "3×10" — from template
|
||||
elapsedSeconds: number // time when Erledigt was clicked
|
||||
status: "pending" | "active" | "done"
|
||||
}
|
||||
```
|
||||
|
||||
### Backend API
|
||||
|
||||
Uses existing endpoint: `POST /leistungstest/results/`
|
||||
|
||||
When a wrestler's exercise is marked "Erledigt":
|
||||
- `POST /leistungstest/results/` with `template`, `wrestler`, `total_time_seconds` (cumulative), `rating: 3`, `notes: ""`, `items` array
|
||||
|
||||
When all exercises of a wrestler are done:
|
||||
- The full result is already saved incrementally — no final save step needed
|
||||
|
||||
No new backend endpoints required.
|
||||
|
||||
---
|
||||
|
||||
## User Flow
|
||||
|
||||
### Step 1: Setup (Form Mode)
|
||||
- Trainer is in "Zuweisen" tab
|
||||
- Selects a **Template** (has exercises + target reps pre-defined)
|
||||
- Selects **Wrestlers** (multiple via Sheet)
|
||||
- Clicks **"⏱ Training starten"** → switches to timer mode
|
||||
|
||||
### Step 2: Training Starts
|
||||
- All wrestlers begin **Exercise 1** simultaneously
|
||||
- Global timer starts (00:00 → 00:01 → ...)
|
||||
- All wrestlers visible in table-like layout, each with their own exercise cards
|
||||
|
||||
### Step 3: Tracking
|
||||
- Trainer watches live elapsed time per wrestler/exercise
|
||||
- When a wrestler finishes an exercise → clicks **"✓ Erledigt"**
|
||||
- Elapsed time for that exercise is saved
|
||||
- Next exercise for that wrestler starts immediately (auto-start)
|
||||
- Other wrestlers continue their current exercise
|
||||
|
||||
### Step 4: Completion
|
||||
- When all exercises of all wrestlers are done (or trainer clicks "Training beenden"):
|
||||
- Summary modal shows: X/Y wrestlers completed, total time
|
||||
- All results already saved to backend via incremental POSTs
|
||||
- localStorage session cleared
|
||||
- Trainer returns to form mode
|
||||
|
||||
### Step 5: Post-Edit
|
||||
- Results visible in "Ergebnisse" tab
|
||||
- Can be edited (notes, rating, individual exercise times) via existing edit form
|
||||
|
||||
---
|
||||
|
||||
## UI Layout
|
||||
|
||||
### Header Banner (always visible during timer)
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ GEMEINSAME ZEIT 05:23 [⏸ Pausieren] [■ Ende] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
- Dark navy background (#1B1A55)
|
||||
- Large monospace timer (MM:SS)
|
||||
- "Pausieren" toggles to "▶ Fortsetzen" when paused
|
||||
- "Training beenden" opens confirmation dialog
|
||||
|
||||
### Wrestler Rows (scrollable list)
|
||||
|
||||
Each wrestler = one row, stacked vertically. Like a table but each row is independent.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ● Max Mustermann 3/3 ✓ [grün] │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │Liegestütze │ │Kniebeugen │ │Burpees │ │
|
||||
│ │Soll: 3×10 │ │Soll: 3×15 │ │Soll: 3×20 │ │
|
||||
│ │✓ 0:28 │ │✓ 0:44 │ │✓ 0:19 │ │
|
||||
│ │[grün] │ │[grün] │ │[grün] │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three states per exercise card:
|
||||
|
||||
| State | Background | Border | Badge | Button |
|
||||
|-------|-----------|--------|-------|--------|
|
||||
| **pending** | gray-50 | gray-200 | — | ▶ Start (disabled) |
|
||||
| **active** | yellow-50 | yellow-400 | ▶ Aktiv | ✓ Erledigt |
|
||||
| **done** | green-50 | green-400 | ✓ MM:SS | — |
|
||||
|
||||
When a wrestler finishes all exercises → row header turns green with "✓ Alle erledigt".
|
||||
|
||||
### Exercise Card (within wrestler row)
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ ▶ Übungsname │
|
||||
│ Soll: 3×10 │
|
||||
│ MM:SS │
|
||||
│ [✓ Erledigt] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
- Exercise name with status icon
|
||||
- Target reps from template (e.g. "Soll: 3×10") — **reps, not time**
|
||||
- Live elapsed seconds (MM:SS) updating every second when active
|
||||
- Button: "✓ Erledigt" for active exercises
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### React State
|
||||
|
||||
```typescript
|
||||
const [inputMode, setInputMode] = useState<"form" | "timer">("form")
|
||||
const [session, setSession] = useState<TrainingSession | null>(null)
|
||||
const [globalTimerInterval, setGlobalTimerInterval] = useState<NodeJS.Timeout | null>(null)
|
||||
const [restoreModalOpen, setRestoreModalOpen] = useState(false)
|
||||
const [endModalOpen, setEndModalOpen] = useState(false)
|
||||
const [summary, setSummary] = useState<{ completed: number; total: number; totalTime: number } | null>(null)
|
||||
```
|
||||
|
||||
### Timer Tick Effect
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (session?.isRunning) {
|
||||
const interval = setInterval(() => {
|
||||
setSession(prev => prev ? { ...prev, globalElapsedSeconds: prev.globalElapsedSeconds + 1 } : prev)
|
||||
}, 1000)
|
||||
setGlobalTimerInterval(interval)
|
||||
return () => { clearInterval(interval); setGlobalTimerInterval(null) }
|
||||
}
|
||||
}, [session?.isRunning])
|
||||
```
|
||||
|
||||
### localStorage Persistence
|
||||
|
||||
Key: `"leistungstest_timer_session"`
|
||||
Saved on every session change. Restored session shows resume/discard modal on next load.
|
||||
|
||||
---
|
||||
|
||||
## Session Initialization (Start Training)
|
||||
|
||||
When "⏱ Training starten" is clicked:
|
||||
|
||||
```typescript
|
||||
const timerWrestlers: WrestlerSession[] = wrestlers
|
||||
.filter(w => selectedWrestlerIds.includes(w.id))
|
||||
.map(w => ({
|
||||
wrestler: w,
|
||||
currentExerciseIndex: 0,
|
||||
exercises: template.exercises.map(e => ({
|
||||
exerciseId: e.exercise,
|
||||
exerciseName: e.exercise_name || String(e.exercise),
|
||||
targetReps: e.target_reps, // e.g. "3×10" from template
|
||||
elapsedSeconds: 0,
|
||||
status: "active" as const, // all start active (first exercise)
|
||||
})),
|
||||
}))
|
||||
|
||||
setSession({
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
wrestlers: timerWrestlers,
|
||||
globalElapsedSeconds: 0,
|
||||
isRunning: true,
|
||||
})
|
||||
setInputMode("timer")
|
||||
```
|
||||
|
||||
All wrestlers start with their first exercise in "active" state simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Marking Exercise Done
|
||||
|
||||
```typescript
|
||||
const markDone = async (wrestlerIdx: number, exerciseIdx: number) => {
|
||||
const wrestler = session.wrestlers[wrestlerIdx]
|
||||
const exercise = wrestler.exercises[exerciseIdx]
|
||||
const elapsed = exercise.elapsedSeconds
|
||||
|
||||
// Save to backend
|
||||
await apiFetch("/leistungstest/results/", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
template: session.templateId,
|
||||
wrestler: wrestler.wrestler.id,
|
||||
total_time_seconds: elapsed,
|
||||
rating: 3,
|
||||
notes: "",
|
||||
items: [{
|
||||
exercise: exercise.exerciseId,
|
||||
target_reps: parseReps(exercise.targetReps),
|
||||
actual_reps: parseReps(exercise.targetReps),
|
||||
order: exerciseIdx,
|
||||
}],
|
||||
}),
|
||||
})
|
||||
|
||||
// Update state: mark done, auto-start next
|
||||
const updated = [...session.wrestlers]
|
||||
updated[wrestlerIdx] = {
|
||||
...wrestler,
|
||||
exercises: wrestler.exercises.map((e, i) =>
|
||||
i === exerciseIdx ? { ...e, status: "done" as const, elapsedSeconds: elapsed } : e
|
||||
),
|
||||
}
|
||||
|
||||
// Auto-start next exercise if exists
|
||||
const nextIdx = exerciseIdx + 1
|
||||
if (nextIdx < wrestler.exercises.length) {
|
||||
updated[wrestlerIdx].exercises[nextIdx].status = "active"
|
||||
updated[wrestlerIdx].currentExerciseIndex = nextIdx
|
||||
}
|
||||
|
||||
setSession({ ...session, wrestlers: updated })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Compatibility
|
||||
|
||||
### Template stores target_reps as string (e.g. "3×10")
|
||||
- `parseReps("3×10")` → extracts number for API (e.g. `30` or just stores as-is)
|
||||
- Backend serializer accepts `target_reps` as string or int — needs verification
|
||||
|
||||
### LeistungstestResult stores `total_time_seconds` (int)
|
||||
- Each "Erledigt" click saves the cumulative elapsed time for that wrestler
|
||||
- Individual exercise times stored in `items` array (one item per exercise)
|
||||
|
||||
### If backend needs `items` on every save or only at end
|
||||
- Currently: save on every "Erledigt" click with just that exercise
|
||||
- Alternative: save incrementally, last item completes the result
|
||||
- **Decision needed:** Does the backend create one result per exercise click, or update an existing result?
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Page reload during training** → localStorage restore, resume/discard prompt
|
||||
2. **Trainer closes browser** → session persisted, can resume
|
||||
3. **All exercises done before "Training beenden"** → auto-trigger summary
|
||||
4. **Only some wrestlers finish** → partial results saved, can resume later
|
||||
5. **Pause** → global timer stops, all active exercises pause (no per-exercise timer, just global)
|
||||
6. **Empty template** → disable "Training starten" if template has no exercises
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (for this implementation)
|
||||
|
||||
- Real-time sync across multiple trainers (future)
|
||||
- Per-exercise individual timers (only global timer)
|
||||
- Audio alerts
|
||||
- Export/print
|
||||
- Changing exercise order mid-training
|
||||
@@ -0,0 +1,154 @@
|
||||
# Leistungstest Statistiken — Design
|
||||
|
||||
## Overview
|
||||
|
||||
Erweiterung des Leistungstest-Bereichs um Statistiken und Ranglisten auf zwei Ebenen: **Vorlagen** und **Übungen**. Filterbar nach Zeitraum (Monat / Alle Zeiten).
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Ranglisten pro Vorlage
|
||||
- Rangliste aller Wrestler nach `score_percent` (absteigend)
|
||||
- Optional: Sortierung nach `total_time_seconds` (aufsteigend = schnellste Zeit)
|
||||
- Zeitraum-Filter: "Alle Zeiten" | "Dieser Monat" | "Letzte 3 Monate" | "Dieses Jahr"
|
||||
- Anzeige: Rank, Wrestler-Name, Score %, Gesamtzeit, Datum
|
||||
|
||||
### 2. Ranglisten pro Übung
|
||||
- Für jede Übung: Rangliste nach `elapsed_seconds` (schnellste Zeit)
|
||||
- Berechnung: Beste Zeit (niedrigste elapsed_seconds) pro Wrestler pro Übung
|
||||
- Zeitraum-Filter wie oben
|
||||
- Anzeige: Rank, Wrestler-Name, Übungsname, Bestzeit, Datum
|
||||
|
||||
### 3. Ringer-Statistik (optional)
|
||||
- Pro Wrestler: Durchschnitts-Score, Anzahl Tests, Trend (besser/schlechter)
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Keine neuen Modelle — Berechnung erfolgt on-the-fly aus `LeistungstestResult` und `LeistungstestResultItem`.
|
||||
|
||||
## API Endpoints (Backend)
|
||||
|
||||
### `GET /api/v1/leistungstest/stats/leaderboard/`
|
||||
Query-Parameter:
|
||||
- `type`: `"template"` | `"exercise"`
|
||||
- `template_id`: number (für template-Typ)
|
||||
- `period`: `"all"` | `"month"` | `"3months"` | `"year"`
|
||||
- `limit`: number (default: 10)
|
||||
|
||||
Response für `type=template`:
|
||||
```json
|
||||
{
|
||||
"template_id": 1,
|
||||
"template_name": "Krafttest Februar",
|
||||
"period": "all",
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"wrestler_id": 1,
|
||||
"wrestler_name": "Max Mustermann",
|
||||
"score_percent": 100.0,
|
||||
"total_time_seconds": 342,
|
||||
"completed_at": "2026-03-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response für `type=exercise`:
|
||||
```json
|
||||
{
|
||||
"exercise_id": 1,
|
||||
"exercise_name": "Liegestütze",
|
||||
"period": "all",
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"wrestler_id": 1,
|
||||
"wrestler_name": "Max Mustermann",
|
||||
"best_time_seconds": 75,
|
||||
"completed_at": "2026-03-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/leistungstest/stats/exercises/`
|
||||
Liste aller Übungen die jemals in einem Leistungstest verwendet wurden (für Dropdown).
|
||||
|
||||
## Frontend
|
||||
|
||||
### Neuer Tab: "📊 Statistiken"
|
||||
|
||||
Tabs im Leistungstest:
|
||||
1. Vorlagen (existiert)
|
||||
2. Zuweisen (existiert)
|
||||
3. Ergebnisse (existiert, jetzt Karten-Ansicht)
|
||||
4. **📊 Statistiken** (NEU)
|
||||
|
||||
### Statistiken-Tab Layout:
|
||||
```
|
||||
[Tab: Vorlagen | Zuweisen | Ergebnisse | 📊 Statistiken]
|
||||
|
||||
📊 Statistiken
|
||||
|
||||
[Dropdowns: Vorlage auswählen | Zeitraum: Alle Zeiten ▾]
|
||||
|
||||
-tabs-
|
||||
[⊞ Nach Vorlage] [📋 Nach Übung]
|
||||
|
||||
--- Nach Vorlage ---
|
||||
Rangliste: Krafttest Februar
|
||||
1. 🥇 Max Mustermann — 100% (5:42)
|
||||
2. 🥈 Anna Schmidt — 87% (6:20)
|
||||
3. 🥉 Tom Klein — 65% (8:15)
|
||||
|
||||
--- Nach Übung ---
|
||||
Rangliste: Liegestütze
|
||||
1. 🥇 Max Mustermann — 1:15
|
||||
2. 🥈 Anna Schmidt — 1:30
|
||||
3. 🥉 Tom Klein — 2:00
|
||||
|
||||
Rangliste: Kniebeugen
|
||||
1. 🥇 Max Mustermann — 2:30
|
||||
...
|
||||
```
|
||||
|
||||
## Backend-Implementierung
|
||||
|
||||
### Neue Datei: `leistungstest/stats.py`
|
||||
```python
|
||||
def get_template_leaderboard(template_id, period="all", limit=10):
|
||||
# Filter results by template and period
|
||||
# Order by score_percent DESC, total_time_seconds ASC
|
||||
# Return top N with rank
|
||||
|
||||
def get_exercise_leaderboard(exercise_id, period="all", limit=10):
|
||||
# For each wrestler, find their BEST (lowest) elapsed_seconds
|
||||
# Filter by period
|
||||
# Order by best_time ASC
|
||||
# Return top N
|
||||
```
|
||||
|
||||
### Neue URL: `leistungstest/stats.py` ViewSet
|
||||
- `GET /leaderboard/` — Template oder Exercise Leaderboard
|
||||
- `GET /exercises/` — Liste verwendeter Übungen
|
||||
|
||||
## Zeitraum-Filter Logik
|
||||
```python
|
||||
def get_date_range(period):
|
||||
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 # "all"
|
||||
```
|
||||
|
||||
## Implementierungs-Reihenfolge
|
||||
|
||||
1. Backend: `stats.py` mit ViewSet + URLs
|
||||
2. Frontend: Neuer "Statistiken" Tab
|
||||
3. Toggle zwischen Vorlagen/Übungs-Ansicht
|
||||
4. Zeitraum-Filter
|
||||
5. Ranglisten-Anzeige mit Medaillen-Icons
|
||||
Reference in New Issue
Block a user