From 245f8d79242ffb4809599c9bf3411c5e7a9f02ea Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Wed, 2 Sep 2026 12:08:14 +0200 Subject: [PATCH] feat: Zeitnahme und Ergebnisse an Run und Fahrer statt an eigene Seiten Die Routen /dashboard/timing und /dashboard/results entfallen samt ihrer Menueeintraege. Beides passiert jetzt dort, wo es hingehoert: am Run innerhalb seines Events. Die Run-Seite zeigt laufende Zeiten in Echtzeit, jede Zeile ist Klickziel fuer den Stopp-Dialog (Stoppen oder DNF). Gestartet wird ueber einen Dialog mit Fahrersuche. Dazu Rangliste mit CSV-Export und ein Abschnitt fuer Zeiten ohne Wertung. Die Fahrer-Seite zeigt alle Runs des Events aus Sicht eines Fahrers, mit Zeit, Status und Platz - auch Runs ohne Zeit, damit sichtbar bleibt, was fehlt. enrich() im times-Store nimmt now als Parameter: Ohne das waere Date.now() fuer Svelte keine Abhaengigkeit und laufende Zeiten stuenden still. Der neue ticker() liefert diesen Wert, wodurch die {#key tick}-Bloecke der alten Zeitnahmeseite entfallen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o --- frontend/src/lib/stores/ticker.svelte.ts | 22 + frontend/src/lib/stores/times.svelte.ts | 52 ++- frontend/src/lib/time.ts | 30 ++ frontend/src/routes/dashboard/+layout.svelte | 6 +- frontend/src/routes/dashboard/+page.svelte | 20 +- .../routes/dashboard/events/[id]/+page.svelte | 70 ++- .../events/[id]/riders/[riderId]/+page.svelte | 217 +++++++++ .../events/[id]/runs/[runId]/+page.svelte | 431 ++++++++++++++++++ .../src/routes/dashboard/results/+page.svelte | 203 --------- .../src/routes/dashboard/timing/+page.svelte | 281 ------------ 10 files changed, 799 insertions(+), 533 deletions(-) create mode 100644 frontend/src/lib/stores/ticker.svelte.ts create mode 100644 frontend/src/lib/time.ts create mode 100644 frontend/src/routes/dashboard/events/[id]/riders/[riderId]/+page.svelte create mode 100644 frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte delete mode 100644 frontend/src/routes/dashboard/results/+page.svelte delete mode 100644 frontend/src/routes/dashboard/timing/+page.svelte diff --git a/frontend/src/lib/stores/ticker.svelte.ts b/frontend/src/lib/stores/ticker.svelte.ts new file mode 100644 index 0000000..84e0742 --- /dev/null +++ b/frontend/src/lib/stores/ticker.svelte.ts @@ -0,0 +1,22 @@ +/** + * Laufende Uhr für Live-Anzeigen. Wird im Komponenten-Setup aufgerufen; das + * Intervall hängt am Effekt-Kontext der Komponente und endet mit ihr. + * + * Der Wert wird bewusst als Zahl herausgereicht statt als fertiger Text: + * Ableitungen, die `now` lesen, rechnen dadurch bei jedem Tick neu — ohne + * `{#key}`-Blöcke, die sonst ganze Listen neu aufbauen würden. + */ +export function ticker(intervalMs = 100) { + let now = $state(Date.now()) + + $effect(() => { + const id = setInterval(() => (now = Date.now()), intervalMs) + return () => clearInterval(id) + }) + + return { + get now() { + return now + }, + } +} diff --git a/frontend/src/lib/stores/times.svelte.ts b/frontend/src/lib/stores/times.svelte.ts index f804580..d87b30d 100644 --- a/frontend/src/lib/stores/times.svelte.ts +++ b/frontend/src/lib/stores/times.svelte.ts @@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte' import { api } from './pocketbase.svelte' import { app } from './app.svelte' import { getTeamContext } from './teams.svelte' +import { formatDuration } from '$lib/time' import type { TimesResponse, TimesStatusOptions } from '$lib/types' const KEY = Symbol('time') @@ -13,18 +14,22 @@ export interface TimeWithDuration extends TimesResponse { formattedTime?: string } -function enrich(t: TimesResponse): TimeWithDuration { +/** + * `now` steckt als Parameter drin, damit laufende Zeiten live weiterzählen: + * Eine Ableitung, die einen tickenden Zeitwert hereinreicht, rechnet bei + * jedem Tick neu. Ohne den Parameter wäre `Date.now()` für Svelte keine + * Abhängigkeit und die Anzeige stünde still. + */ +function enrich(t: TimesResponse, now: number): TimeWithDuration { if (!t.start) return t const start = new Date(t.start).getTime() - const end = t.stop ? new Date(t.stop).getTime() : Date.now() + const end = t.stop ? new Date(t.stop).getTime() : now let duration = (end - start) / 1000 if (t.correction) duration += t.correction - const minutes = Math.floor(duration / 60) - const seconds = (duration % 60).toFixed(1) return { ...t, duration, - formattedTime: `${minutes}:${seconds.padStart(4, '0')}`, + formattedTime: formatDuration(duration), } } @@ -123,12 +128,29 @@ export class TimeStore { }) } - getByRun(runId: string): TimeWithDuration[] { - return this.scoped.filter((r) => r.run === runId).map(enrich) + getByRun(runId: string, now = Date.now()): TimeWithDuration[] { + return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now)) } - getByRider(riderId: string): TimeWithDuration[] { - return this.scoped.filter((r) => r.rider === riderId).map(enrich) + getByRider(riderId: string, now = Date.now()): TimeWithDuration[] { + return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now)) + } + + /** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */ + runningByRun(runId: string, now = Date.now()): TimeWithDuration[] { + return this.getByRun(runId, now) + .filter((r) => r.status === 'active') + .sort((a, b) => (a.start < b.start ? -1 : 1)) + } + + /** + * Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf, + * dürfen aber auch nicht unsichtbar werden. + */ + unrankedByRun(runId: string): TimeWithDuration[] { + return this.getByRun(runId).filter( + (r) => r.status !== 'active' && r.status !== 'finished', + ) } isRiderActive(runId: string, riderId: string) { @@ -144,11 +166,13 @@ export class TimeStore { } get running(): TimeWithDuration[] { - return this.scoped.filter((r) => r.status === 'active').map(enrich) + const now = Date.now() + return this.scoped.filter((r) => r.status === 'active').map((r) => enrich(r, now)) } get finished(): TimeWithDuration[] { - return this.scoped.filter((r) => r.status === 'finished').map(enrich) + const now = Date.now() + return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now)) } getLeaderboard(runId: string): TimeWithDuration[] { @@ -157,6 +181,12 @@ export class TimeStore { .sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0)) } + /** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */ + placement(runId: string, riderId: string): number | null { + const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId) + return idx === -1 ? null : idx + 1 + } + destroy() { if (this.unsubscribe) { this.unsubscribe() diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts new file mode 100644 index 0000000..bbf8f05 --- /dev/null +++ b/frontend/src/lib/time.ts @@ -0,0 +1,30 @@ +/** + * Formatierung von Zeiten. Bewusst hier und nicht im times-Store: Auch + * Komponenten ohne Store-Zugriff zeigen Dauern an, und die Darstellung soll + * überall dieselbe sein. + */ + +/** Dauer in Sekunden als `m:ss.s`. */ +export function formatDuration(seconds: number | undefined): string { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '0:00.0' + + const m = Math.floor(seconds / 60) + const s = (seconds % 60).toFixed(1) + + return `${m}:${s.padStart(4, '0')}` +} + +/** Beschriftungen der Zeit-Status aus der Collection. */ +export const TIME_STATUS_LABEL: Record = { + active: 'Läuft', + finished: 'Beendet', + dnf: 'DNF', + dns: 'DNS', + dsq: 'DSQ', +} + +/** Zeitpunkt als `TT.MM.JJJJ, HH:MM`. */ +export function formatDateTime(iso: string | undefined): string { + if (!iso) return '—' + return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 011b5b5..c0ccd0a 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -15,7 +15,7 @@ import { Separator } from '@/components/ui/separator' import Logo from '$lib/components/Logo.svelte' import TeamSwitcher from '$lib/components/TeamSwitcher.svelte' - import { Home, Calendar, Flag, Timer, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte' + import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings } from 'lucide-svelte' import { toggleMode, mode } from 'mode-watcher' import { onDestroy, onMount } from 'svelte' @@ -82,12 +82,12 @@ }) } + // Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides + // passiert dort, wo es hingehört — am Run innerhalb seines Events. const nav = [ { href: '/dashboard', icon: Home, label: 'Dashboard' }, { href: '/dashboard/events', icon: Calendar, label: 'Events' }, { href: '/dashboard/trails', icon: Route, label: 'Trails' }, - { href: '/dashboard/timing', icon: Timer, label: 'Zeitnahme' }, - { href: '/dashboard/results', icon: Flag, label: 'Ergebnisse' }, { href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' }, ] diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 05c7c72..c22eefd 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' - import { Calendar, Users, Flag, Timer, Clock, MapPin } from 'lucide-svelte' + import { Calendar, Users, Clock, MapPin, Route, UsersRound } from 'lucide-svelte' const teams = getTeamContext() const events = getEventContext() @@ -87,17 +87,17 @@ - - -

Zeitnahme

-

Zeiten starten und stoppen

+
+ +

Trails

+

Strecken, Zustand und Meldungen

- - -

Ergebnisse

-

Zeiten und Rankings

+
+ +

Teams

+

Mitglieder und Rechte

@@ -135,7 +135,7 @@

{eventRuns} Run{eventRuns === 1 ? '' : 's'}

- +
{/each} diff --git a/frontend/src/routes/dashboard/events/[id]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/+page.svelte index 2653d1d..6293a1a 100644 --- a/frontend/src/routes/dashboard/events/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/events/[id]/+page.svelte @@ -1,8 +1,10 @@ + +{rider ? fullName(rider) : 'Fahrer'} – {event?.name ?? 'Event'} + +{#if !valid} + + + +

Fahrer nicht gefunden

+

+ Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht. +

+ +
+
+{:else if event && rider} +
+
+ + + {event.name || 'Event'} + +
+ {rider.number || '—'} +

+ {fullName(rider) || 'Ohne Name'} +

+
+
+ +
+ + + Gefahrene Runs + + +
{stats.done}/{stats.of}
+
+
+ + + Bestzeit + + +
+ {stats.best != null ? formatDuration(stats.best) : '—'} +
+
+
+ + + Gesamtzeit + + +
+ {stats.done > 0 ? formatDuration(stats.total) : '—'} +
+
+
+
+ + + + + + Runs ({rows.length}) + + + + {#if rows.length === 0} +

+ Für dieses Event gibt es noch keine Runs. +

+ {:else} + + + + Run + Status + Zeit + Platz + + + + + {#each rows as row (row.run.id)} + goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)} + > + + + {row.run.description || 'Run'} + + + + {#if row.active} + Läuft + {:else if row.best} + Beendet + {:else if row.others.length > 0} + + {TIME_STATUS_LABEL[row.others[0].status] ?? row.others[0].status} + + {:else} + + {/if} + + + {#if row.active} + {row.active.formattedTime} + {:else if row.best} + {row.best.formattedTime} + {:else} + + {/if} + + + {#if row.placement} + + {row.placement} + + {:else} + + {/if} + + + + + + {/each} + + + {/if} +
+
+
+{/if} diff --git a/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte new file mode 100644 index 0000000..2d157a3 --- /dev/null +++ b/frontend/src/routes/dashboard/events/[id]/runs/[runId]/+page.svelte @@ -0,0 +1,431 @@ + + +{run?.description ?? 'Run'} – {event?.name ?? 'Event'} + +{#if !valid} + + + +

Run nicht gefunden

+

+ Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht. +

+ +
+
+{:else if event && run} +
+
+
+ + + {event.name || 'Event'} + +

{run.description || 'Run'}

+

+ {running.length} laufend · {leaderboard.length} gewertet · {eventRiders.length} Fahrer im Event +

+
+ +
+ {#if leaderboard.length > 0} + + {/if} + +
+
+ + {#if eventRiders.length === 0} + + + Für dieses Event gibt es noch keine Fahrer. + Jetzt anlegen + + + {/if} + + + + + + + Laufende Zeiten ({running.length}) + + + + {#if running.length === 0} +

+ Keine laufende Zeit. Über „Zeitnahme starten" geht es los. +

+ {:else} +
    + {#each running as t (t.id)} + {@const r = rider(t.rider)} +
  • + + +
  • + {/each} +
+ {/if} +
+
+ + + + + + + Rangliste ({leaderboard.length}) + + + + {#if leaderboard.length === 0} +

+ Für diesen Run wurden noch keine Zeiten gewertet. +

+ {:else} + + + + Platz + # + Name + Zeit + Korrektur + + + + {#each leaderboard as t, i (t.id)} + {@const r = rider(t.rider)} + + + + {i + 1} + + + + {r?.number ?? '—'} + + + {#if r} + {fullName(r) || '—'} + {:else} + — + {/if} + + {t.formattedTime} + + {t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : '–'} + + + {/each} + + + {/if} +
+
+ + {#if unranked.length > 0} + + + + + Ohne Wertung ({unranked.length}) + + + +
    + {#each unranked as t (t.id)} + {@const r = rider(t.rider)} +
  • +
    + {r?.number ?? '—'} + + {#if r} + {fullName(r) || '—'} + {:else} + — + {/if} + +
    + {TIME_STATUS_LABEL[t.status] ?? t.status} +
  • + {/each} +
+
+
+ {/if} +
+{/if} + + + + + + Zeitnahme starten + + Fahrer für „{run?.description}" wählen — die Zeit läuft ab dem Klick. + + + +
+ + +
+ + {#if startError} +

{startError}

+ {/if} + +
+ {#if startCandidates.length === 0} +

+ {riderQuery ? 'Keine Treffer' : 'Keine Fahrer für dieses Event'} +

+ {:else} +
    + {#each startCandidates as c (c.rider.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + + + +
+
+ + + + + + Zeit stoppen? + + {#if stopTime} + {@const r = rider(stopTime.rider)} + #{r?.number ?? '—'} {r ? fullName(r) : 'Unbekannter Fahrer'} + {:else} + Diese Zeit läuft nicht mehr. + {/if} + + + + {#if stopTime} +

+ {stopTime.formattedTime} +

+ {/if} + + {#if stopError} +

{stopError}

+ {/if} + + + + + + +
+
diff --git a/frontend/src/routes/dashboard/results/+page.svelte b/frontend/src/routes/dashboard/results/+page.svelte deleted file mode 100644 index 9fe7848..0000000 --- a/frontend/src/routes/dashboard/results/+page.svelte +++ /dev/null @@ -1,203 +0,0 @@ - - -Ergebnisse - -
-
-
-

Ergebnisse

-

Zeiten und Rankings

-
- {#if leaderboard.length > 0} - - {/if} -
- - - -
-
- - - {selectedEventName} - - {#each events.scoped as e} - {e.name || e.description} - {/each} - - -
-
- - - {selectedRunName} - - {#each availableRuns as r} - {r.description} - {/each} - - -
-
-
-
- - {#if !selectedRunId} - - - -

Wähle Event und Run

-

Um Ergebnisse anzuzeigen, oben auswählen.

-
-
- {:else if leaderboard.length === 0} - - - -

Noch keine Ergebnisse

-

Für diesen Run wurden noch keine Zeiten erfasst.

-
-
- {:else} - {#if podium.length > 0} -
- {#each podium as t, i} - {@const r = getRider(t.rider)} - {#if r} - - - -

Platz {i + 1}

-

#{r.number} {fullName(r)}

-

{t.formattedTime}

-
-
- {/if} - {/each} -
- {/if} - - - - Vollständige Rangliste - - - - - - Platz - # - Name - Zeit - Korrektur - - - - {#each leaderboard as t, i (t.id)} - {@const r = getRider(t.rider)} - - - - {i + 1} - - - - {r?.number ?? '—'} - - {r ? fullName(r) : '—'} - {t.formattedTime} - - {t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : '–'} - - - {/each} - - - - - {/if} -
diff --git a/frontend/src/routes/dashboard/timing/+page.svelte b/frontend/src/routes/dashboard/timing/+page.svelte deleted file mode 100644 index 488b9b6..0000000 --- a/frontend/src/routes/dashboard/timing/+page.svelte +++ /dev/null @@ -1,281 +0,0 @@ - - -Zeitnahme - -
-
-

Zeitnahme

-

Zeiten für Fahrer starten und stoppen

-
- - - -
-
- - - {selectedEventName} - - {#each events.scoped as e} - {e.name || e.description} - {/each} - - -
-
- - - {selectedRunName} - - {#each availableRuns as r} - {r.description} - {/each} - - - {#if selectedEventId && availableRuns.length === 0} -

- Keine Runs für dieses Event. Über PocketBase Runs anlegen. -

- {/if} -
-
-
-
- - {#if !selectedEventId || !selectedRunId} - - - -

Wähle ein Event und einen Run

-

- Um mit der Zeitnahme zu beginnen, oben Event und Run auswählen. -

-
-
- {:else} -
-
-
- - -
- - - - {#if filteredRiders().length === 0} -
- {searchQuery ? 'Keine Treffer' : 'Keine Fahrer für dieses Event'} -
- {:else} -
    - {#each filteredRiders() as rider (rider.id)} - {@const active = times.isRiderActive(selectedRunId, rider.id)} - {@const activeTime = active ? times.getActiveTime(selectedRunId, rider.id) : undefined} -
  • -
    - - {rider.number || '—'} - -
    -

    {fullName(rider) || '—'}

    -
    -
    -
    - {#if active && activeTime} - {#key tick} -
    - {getElapsed(activeTime.start)} -
    - {/key} - - {:else} - - {/if} -
    -
  • - {/each} -
- {/if} -
-
-
- -
- - - - - Laufende Zeiten ({runningTimes.length}) - - - - {#if runningTimes.length === 0} -

Keine laufenden Zeiten

- {:else} -
    - {#each runningTimes as t} - {@const r = getRider(t.rider)} - {#if r} - {#key tick} -
  • -

    #{r.number} {fullName(r)}

    -

    {getElapsed(t.start)}

    -
  • - {/key} - {/if} - {/each} -
- {/if} -
-
- - - - - - Top 5 - - - - {#if leaderboard.length === 0} -

Noch keine Ergebnisse

- {:else} -
    - {#each leaderboard as t, i} - {@const r = getRider(t.rider)} - {#if r} -
  1. -
    - - {i + 1} - - #{r.number} {fullName(r)} -
    - {t.formattedTime} -
  2. - {/if} - {/each} -
- {/if} -
-
-
-
- {/if} -