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