Files
WrestleDesk/docs/superpowers/plans/2026-03-22-trainings-calendar-plan.md
Andrej Spielmann 3fefc550fe Initial commit: WrestleDesk full project
- Django backend with DRF (clubs, wrestlers, trainers, exercises, templates, trainings, homework, locations, leistungstest)
- Next.js 16 frontend with React, Shadcn UI, Tailwind
- JWT authentication
- Full CRUD for all entities
- Calendar view for trainings
- Homework management system
- Leistungstest tracking
2026-03-26 13:24:57 +01:00

529 lines
14 KiB
Markdown
Raw Permalink Blame History

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