Files
WrestleDesk/docs/superpowers/plans/2026-03-24-leistungstest-live-timer-v2-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

18 KiB
Raw Permalink Blame History

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:

elapsed_seconds = models.PositiveIntegerField(default=0)
  • Step 2: Update serializer

In backend/leistungstest/serializers.py, update LeistungstestResultItemSerializer.Meta.fields (line 27):

fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
  • Step 3: Create migration
cd backend && python manage.py makemigrations leistungstest --name leistungstestresultitem_elapsed_seconds
  • Step 4: Apply migration
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):

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:

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:

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:

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.

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:

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

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

With:

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

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

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

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/