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

23 KiB

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

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:

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

const TIMER_SESSION_KEY = "leistungstest_timer_session"

Also add a helper function after the state declarations (around line 76):

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:

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

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:

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

<CardHeader>
  <CardTitle className="text-base">Neues Ergebnis</CardTitle>
</CardHeader>

Replace with:

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

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

                    </div>
                  </div>

Insert before the {resultItems.length > 0 && ( section:

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

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