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

14 KiB
Raw Permalink Blame History

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

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

.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

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

import { CalendarView } from "@/components/ui/calendar-view"
import { Calendar } from "lucide-react"
  • Step 2: Update viewMode state

Change line ~50:

const [viewMode, setViewMode] = useState<"grid" | "list" | "calendar">("grid")
  • Step 3: Update toggle buttons

Find the existing toggle (around line 420-440) and update to:

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

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

{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

git add -A
git commit -m "feat(trainings): add calendar month view with react-big-calendar"