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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 12:08:14 +02:00
parent a8049e7406
commit 245f8d7924
10 changed files with 799 additions and 533 deletions

View file

@ -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
},
}
}

View file

@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte' import { api } from './pocketbase.svelte'
import { app } from './app.svelte' import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte' import { getTeamContext } from './teams.svelte'
import { formatDuration } from '$lib/time'
import type { TimesResponse, TimesStatusOptions } from '$lib/types' import type { TimesResponse, TimesStatusOptions } from '$lib/types'
const KEY = Symbol('time') const KEY = Symbol('time')
@ -13,18 +14,22 @@ export interface TimeWithDuration extends TimesResponse {
formattedTime?: string 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 if (!t.start) return t
const start = new Date(t.start).getTime() 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 let duration = (end - start) / 1000
if (t.correction) duration += t.correction if (t.correction) duration += t.correction
const minutes = Math.floor(duration / 60)
const seconds = (duration % 60).toFixed(1)
return { return {
...t, ...t,
duration, duration,
formattedTime: `${minutes}:${seconds.padStart(4, '0')}`, formattedTime: formatDuration(duration),
} }
} }
@ -123,12 +128,29 @@ export class TimeStore {
}) })
} }
getByRun(runId: string): TimeWithDuration[] { getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.run === runId).map(enrich) return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
} }
getByRider(riderId: string): TimeWithDuration[] { getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.rider === riderId).map(enrich) 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) { isRiderActive(runId: string, riderId: string) {
@ -144,11 +166,13 @@ export class TimeStore {
} }
get running(): TimeWithDuration[] { 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[] { 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[] { getLeaderboard(runId: string): TimeWithDuration[] {
@ -157,6 +181,12 @@ export class TimeStore {
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0)) .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() { destroy() {
if (this.unsubscribe) { if (this.unsubscribe) {
this.unsubscribe() this.unsubscribe()

30
frontend/src/lib/time.ts Normal file
View file

@ -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<string, string> = {
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' })
}

View file

@ -15,7 +15,7 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte' import Logo from '$lib/components/Logo.svelte'
import TeamSwitcher from '$lib/components/TeamSwitcher.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 { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte' 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 = [ const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' }, { href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' }, { href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' }, { 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' }, { href: '/dashboard/settings/flags', icon: Settings, label: 'Einstellungen' },
] ]
</script> </script>

View file

@ -8,7 +8,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' 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 teams = getTeamContext()
const events = getEventContext() const events = getEventContext()
@ -87,17 +87,17 @@
</a> </a>
</Card> </Card>
<Card class="hover:border-primary transition-colors"> <Card class="hover:border-primary transition-colors">
<a href="/dashboard/timing" class="block p-6"> <a href="/dashboard/trails" class="block p-6">
<Timer class="h-6 w-6 text-muted-foreground mb-3" /> <Route class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Zeitnahme</h3> <h3 class="font-semibold">Trails</h3>
<p class="text-sm text-muted-foreground mt-1">Zeiten starten und stoppen</p> <p class="text-sm text-muted-foreground mt-1">Strecken, Zustand und Meldungen</p>
</a> </a>
</Card> </Card>
<Card class="hover:border-primary transition-colors"> <Card class="hover:border-primary transition-colors">
<a href="/dashboard/results" class="block p-6"> <a href="/dashboard/teams" class="block p-6">
<Flag class="h-6 w-6 text-muted-foreground mb-3" /> <UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
<h3 class="font-semibold">Ergebnisse</h3> <h3 class="font-semibold">Teams</h3>
<p class="text-sm text-muted-foreground mt-1">Zeiten und Rankings</p> <p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
</a> </a>
</Card> </Card>
</div> </div>
@ -135,7 +135,7 @@
</CardHeader> </CardHeader>
<CardContent class="flex items-center justify-between"> <CardContent class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p> <p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
<Button href="/dashboard/timing?event={event.id}" size="sm">Zeitnahme</Button> <Button href="/dashboard/events/{event.id}" size="sm">Zum Event</Button>
</CardContent> </CardContent>
</Card> </Card>
{/each} {/each}

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte' import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte' import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte' import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -20,18 +22,28 @@
Timer, Timer,
Trash2, Trash2,
Users, Users,
Flag,
} from 'lucide-svelte' } from 'lucide-svelte'
import type { RidersResponse, RunsResponse } from '$lib/types' import type { RidersResponse, RunsResponse } from '$lib/types'
const events = getEventContext() const events = getEventContext()
const runs = getRunContext() const runs = getRunContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined) const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventRiders = $derived(event ? riders.getByEvent(event.id) : []) const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const eventRuns = $derived(event ? runs.getByEvent(event.id) : []) const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}`
}
function runPath(runId: string) {
return `/dashboard/events/${event?.id}/runs/${runId}`
}
// Fahrer-Dialog // Fahrer-Dialog
let riderDialog = $state(false) let riderDialog = $state(false)
let ridingEdit = $state<RidersResponse | null>(null) let ridingEdit = $state<RidersResponse | null>(null)
@ -139,18 +151,6 @@
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p> <p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2">
{#if event.status === 'active'}
<Button href="/dashboard/timing?event={event.id}">
<Timer class="h-4 w-4 mr-2" />
Zeitnahme
</Button>
{/if}
<Button href="/dashboard/results?event={event.id}" variant="outline">
<Flag class="h-4 w-4 mr-2" />
Ergebnisse
</Button>
</div>
</div> </div>
<Separator /> <Separator />
@ -164,7 +164,7 @@
Fahrer Fahrer
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span> <span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
</h2> </h2>
<p class="text-sm text-muted-foreground">Teilnehmer dieses Events</p> <p class="text-sm text-muted-foreground">Klick auf einen Fahrer zeigt seine Runs</p>
</div> </div>
<Button onclick={openRiderCreate}> <Button onclick={openRiderCreate}>
<Plus class="h-4 w-4 mr-2" /> <Plus class="h-4 w-4 mr-2" />
@ -190,23 +190,31 @@
<Table.Row> <Table.Row>
<Table.Head class="w-20">#</Table.Head> <Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head> <Table.Head>Name</Table.Head>
<Table.Head class="w-40">Zeiten</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head> <Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each eventRiders as rider (rider.id)} {#each eventRiders as rider (rider.id)}
<Table.Row> {@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}>
<Table.Cell> <Table.Cell>
<Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge> <Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if fullName(rider)} <!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
{fullName(rider)} <a href={riderPath(rider.id)} class="hover:underline">
{:else} {#if fullName(rider)}
<span class="text-muted-foreground italic">— ohne Name —</span> {fullName(rider)}
{/if} {:else}
<span class="text-muted-foreground italic">— ohne Name —</span>
{/if}
</a>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}> <Button variant="ghost" size="icon" onclick={() => openRiderEdit(rider)}>
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>
@ -234,7 +242,7 @@
Runs Runs
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span> <span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
</h2> </h2>
<p class="text-sm text-muted-foreground">Einzelne Läufe/Stages des Events</p> <p class="text-sm text-muted-foreground">Klick auf einen Run öffnet die Zeitnahme</p>
</div> </div>
<Button onclick={openRunCreate}> <Button onclick={openRunCreate}>
<Plus class="h-4 w-4 mr-2" /> <Plus class="h-4 w-4 mr-2" />
@ -259,14 +267,26 @@
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Bezeichnung</Table.Head> <Table.Head>Bezeichnung</Table.Head>
<Table.Head class="w-48">Zeiten</Table.Head>
<Table.Head class="text-right">Aktionen</Table.Head> <Table.Head class="text-right">Aktionen</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each eventRuns as run (run.id)} {#each eventRuns as run (run.id)}
<Table.Row> {@const runTimes = times.getByRun(run.id)}
<Table.Cell class="font-medium">{run.description}</Table.Cell> {@const active = runTimes.filter((t) => t.status === 'active').length}
<Table.Cell class="text-right"> {@const ranked = runTimes.filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
<Table.Cell class="font-medium">
<a href={runPath(run.id)} class="hover:underline">{run.description}</a>
</Table.Cell>
<Table.Cell>
{#if active > 0}
<Badge class="mr-2">{active} laufend</Badge>
{/if}
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}> <Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
<Pencil class="h-4 w-4" /> <Pencil class="h-4 w-4" />
</Button> </Button>

View file

@ -0,0 +1,217 @@
<script lang="ts">
/**
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
*/
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL, formatDuration } from '$lib/time'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table'
import { ArrowLeft, ChevronRight, Timer, Users } from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const clock = ticker()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const rider = $derived(page.params.riderId ? riders.getById(page.params.riderId) : undefined)
const valid = $derived(!!event && !!rider && rider.event === event.id)
/**
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
* bleibt, was dem Fahrer noch fehlt.
*/
const rows = $derived.by(() => {
if (!valid || !event || !rider) return []
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
// aktuell; bei einer Handvoll Runs ist das billig.
const own = times.getByRider(rider.id, clock.now)
return runs.getByEvent(event.id).map((run) => {
const forRun = own.filter((t) => t.run === run.id)
const active = forRun.find((t) => t.status === 'active')
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
const best = forRun
.filter((t) => t.status === 'finished')
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
return {
run,
active,
best,
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
placement: best ? times.placement(run.id, rider.id) : null,
}
})
})
const stats = $derived.by(() => {
const finished = rows.filter((r) => r.best)
const total = finished.reduce((sum, r) => sum + (r.best?.duration ?? 0), 0)
const best = finished
.map((r) => r.best?.duration ?? 0)
.sort((a, b) => a - b)[0]
return { done: finished.length, of: rows.length, total, best }
})
</script>
<svelte:head><title>{rider ? fullName(rider) : 'Fahrer'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid}
<Card>
<CardContent class="py-16 text-center">
<Users class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Fahrer nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1">
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">
<ArrowLeft class="h-4 w-4 mr-2" />
Zur Eventliste
</Button>
</CardContent>
</Card>
{:else if event && rider}
<div class="space-y-6">
<div>
<a
href="/dashboard/events/{event.id}"
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
>
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<div class="flex items-center gap-3">
<Badge variant="outline" class="font-mono text-lg px-3 py-1">{rider.number || '—'}</Badge>
<h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'}
</h1>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Bestzeit</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold font-mono tabular-nums">
{stats.best != null ? formatDuration(stats.best) : '—'}
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Gesamtzeit</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold font-mono tabular-nums">
{stats.done > 0 ? formatDuration(stats.total) : '—'}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Timer class="h-4 w-4 text-primary" />
Runs ({rows.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
Für dieses Event gibt es noch keine Runs.
</p>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Run</Table.Head>
<Table.Head class="w-24">Status</Table.Head>
<Table.Head class="text-right w-28">Zeit</Table.Head>
<Table.Head class="text-right w-20">Platz</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.run.id)}
<Table.Row
class="cursor-pointer"
onclick={() => goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)}
>
<Table.Cell class="font-medium">
<a
href="/dashboard/events/{event.id}/runs/{row.run.id}"
class="hover:underline"
>
{row.run.description || 'Run'}
</a>
</Table.Cell>
<Table.Cell>
{#if row.active}
<Badge>Läuft</Badge>
{:else if row.best}
<Badge variant="secondary">Beendet</Badge>
{:else if row.others.length > 0}
<Badge variant="outline">
{TIME_STATUS_LABEL[row.others[0].status] ?? row.others[0].status}
</Badge>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-right font-mono tabular-nums">
{#if row.active}
<span class="font-semibold">{row.active.formattedTime}</span>
{:else if row.best}
{row.best.formattedTime}
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if row.placement}
<Badge variant={row.placement <= 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center">
{row.placement}
</Badge>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
</Table.Cell>
<Table.Cell>
<ChevronRight class="h-4 w-4 text-muted-foreground" />
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
</div>
{/if}

View file

@ -0,0 +1,431 @@
<script lang="ts">
/**
* Run-Übersicht: laufende Zeiten in Echtzeit, Rangliste und die Zeitnahme
* selbst. Start und Stopp laufen bewusst über Dialoge — auf dem Handy an
* der Strecke ist ein versehentlicher Treffer sonst schnell passiert und
* eine gestoppte Zeit lässt sich nicht wieder aufnehmen.
*/
import { page } from '$app/state'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import * as Dialog from '@/components/ui/dialog'
import * as Table from '@/components/ui/table'
import {
ArrowLeft, Clock, Download, Flag, Play, Search, Square, Timer, Trophy,
} from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
const clock = ticker()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const run = $derived(page.params.runId ? runs.getById(page.params.runId) : undefined)
// Ein Run gehört genau zu einem Event; passt die URL nicht zusammen, ist
// sie von Hand zusammengebaut oder veraltet.
const valid = $derived(!!event && !!run && run.event === event.id)
const eventRiders = $derived(event ? riders.getByEvent(event.id) : [])
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
function rider(id: string | undefined) {
return id ? riders.getById(id) : undefined
}
function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}`
}
// --- Zeitnahme starten -----------------------------------------------
let startDialog = $state(false)
let riderQuery = $state('')
let startError = $state<string | null>(null)
const startCandidates = $derived.by(() => {
const q = riderQuery.toLowerCase().trim()
const list = q
? eventRiders.filter(
(r) =>
(r.number ?? '').toLowerCase().includes(q) ||
fullName(r).toLowerCase().includes(q),
)
: eventRiders
return list.map((r) => ({
rider: r,
active: run ? times.isRiderActive(run.id, r.id) : false,
// Wer den Run schon gefahren ist, darf erneut starten — etwa nach
// einem Neustart wegen Streckenblockade. Der Hinweis genügt.
hasTime: run ? times.getByRun(run.id).some((t) => t.rider === r.id) : false,
}))
})
function openStart() {
riderQuery = ''
startError = null
startDialog = true
}
async function startFor(riderId: string) {
if (!run) return
startError = null
try {
await times.start(run.id, riderId)
startDialog = false
} catch (e: any) {
startError = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
}
}
// --- Zeitnahme stoppen -----------------------------------------------
let stopDialog = $state(false)
let stopTimeId = $state<string | null>(null)
let stopError = $state<string | null>(null)
// Aus der laufenden Liste gelesen, damit die Anzeige im Dialog mitzählt.
const stopTime = $derived(running.find((t) => t.id === stopTimeId))
function openStop(timeId: string) {
stopTimeId = timeId
stopError = null
stopDialog = true
}
async function doStop(status: 'finished' | 'dnf') {
if (!stopTimeId) return
stopError = null
try {
await times.stop(stopTimeId, status as TimeStatus)
stopDialog = false
stopTimeId = null
} catch (e: any) {
stopError = e.message ?? 'Die Zeit konnte nicht gestoppt werden.'
}
}
// --- Export ------------------------------------------------------------
function exportCsv() {
if (leaderboard.length === 0) return
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
leaderboard.forEach((t, i) => {
const r = rider(t.rider)
rows.push([
String(i + 1),
r?.number ?? '',
r ? fullName(r) : '',
t.formattedTime ?? '',
String(t.correction ?? 0),
])
})
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${safeName}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
</script>
<svelte:head><title>{run?.description ?? 'Run'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid}
<Card>
<CardContent class="py-16 text-center">
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Run nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1">
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
</p>
<Button href="/dashboard/events" variant="outline" class="mt-4">
<ArrowLeft class="h-4 w-4 mr-2" />
Zur Eventliste
</Button>
</CardContent>
</Card>
{:else if event && run}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a
href="/dashboard/events/{event.id}"
class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"
>
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
<p class="mt-1 text-muted-foreground">
{running.length} laufend · {leaderboard.length} gewertet · {eventRiders.length} Fahrer im Event
</p>
</div>
<div class="flex items-center gap-2">
{#if leaderboard.length > 0}
<Button variant="outline" onclick={exportCsv}>
<Download class="h-4 w-4 mr-2" />
CSV
</Button>
{/if}
<Button onclick={openStart} disabled={eventRiders.length === 0}>
<Play class="h-4 w-4 mr-2" />
Zeitnahme starten
</Button>
</div>
</div>
{#if eventRiders.length === 0}
<Card>
<CardContent class="py-6 text-sm text-muted-foreground">
Für dieses Event gibt es noch keine Fahrer.
<a href="/dashboard/events/{event.id}" class="underline">Jetzt anlegen</a>
</CardContent>
</Card>
{/if}
<!-- Laufende Zeiten -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Clock class="h-4 w-4 text-primary" />
Laufende Zeiten ({running.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if running.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
Keine laufende Zeit. Über „Zeitnahme starten" geht es los.
</p>
{:else}
<ul class="divide-y">
{#each running as t (t.id)}
{@const r = rider(t.rider)}
<li>
<!-- Ganze Zeile als Ziel: an der Strecke wird mit Handschuhen getippt. -->
<button
type="button"
class="w-full px-6 py-4 flex items-center justify-between gap-4 text-left hover:bg-accent/40"
onclick={() => openStop(t.id)}
>
<div class="flex items-center gap-4 min-w-0">
<Badge variant="outline" class="font-mono text-base px-3 py-1">
{r?.number || '—'}
</Badge>
<span class="font-medium truncate">{r ? fullName(r) : 'Unbekannter Fahrer'}</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="font-mono text-2xl font-semibold tabular-nums">
{t.formattedTime}
</span>
<Square class="h-4 w-4 text-destructive" />
</div>
</button>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
<!-- Rangliste -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" />
Rangliste ({leaderboard.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if leaderboard.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
Für diesen Run wurden noch keine Zeiten gewertet.
</p>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-16">Platz</Table.Head>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head class="text-right">Zeit</Table.Head>
<Table.Head class="text-right w-24">Korrektur</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboard as t, i (t.id)}
{@const r = rider(t.rider)}
<Table.Row>
<Table.Cell>
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center">
{i + 1}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
{:else}
{/if}
</Table.Cell>
<Table.Cell class="text-right font-mono tabular-nums">{t.formattedTime}</Table.Cell>
<Table.Cell class="text-right font-mono text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</CardContent>
</Card>
{#if unranked.length > 0}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Flag class="h-4 w-4 text-muted-foreground" />
Ohne Wertung ({unranked.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
<ul class="divide-y">
{#each unranked as t (t.id)}
{@const r = rider(t.rider)}
<li class="px-6 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge>
<span class="truncate">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
{:else}
{/if}
</span>
</div>
<Badge variant="secondary">{TIME_STATUS_LABEL[t.status] ?? t.status}</Badge>
</li>
{/each}
</ul>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Start-Dialog: welcher Fahrer? -->
<Dialog.Root bind:open={startDialog}>
<Dialog.Content class="max-h-[85vh] flex flex-col">
<Dialog.Header>
<Dialog.Title>Zeitnahme starten</Dialog.Title>
<Dialog.Description>
Fahrer für „{run?.description}" wählen — die Zeit läuft ab dem Klick.
</Dialog.Description>
</Dialog.Header>
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input bind:value={riderQuery} placeholder="Nummer oder Name…" class="pl-9" />
</div>
{#if startError}
<p class="text-sm text-destructive">{startError}</p>
{/if}
<div class="-mx-6 flex-1 overflow-y-auto border-y">
{#if startCandidates.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
{riderQuery ? 'Keine Treffer' : 'Keine Fahrer für dieses Event'}
</p>
{:else}
<ul class="divide-y">
{#each startCandidates as c (c.rider.id)}
<li>
<button
type="button"
class="w-full px-6 py-3 flex items-center justify-between gap-4 text-left hover:bg-accent/40 disabled:opacity-50 disabled:hover:bg-transparent"
disabled={c.active}
onclick={() => startFor(c.rider.id)}
>
<div class="flex items-center gap-3 min-w-0">
<Badge variant="outline" class="font-mono">{c.rider.number || '—'}</Badge>
<span class="truncate">{fullName(c.rider) || '—'}</span>
</div>
{#if c.active}
<Badge>läuft bereits</Badge>
{:else if c.hasTime}
<span class="text-xs text-muted-foreground shrink-0">Zeit vorhanden</span>
{:else}
<Play class="h-4 w-4 text-primary shrink-0" />
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (startDialog = false)}>Abbrechen</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Stopp-Dialog -->
<Dialog.Root bind:open={stopDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeit stoppen?</Dialog.Title>
<Dialog.Description>
{#if stopTime}
{@const r = rider(stopTime.rider)}
#{r?.number ?? '—'} {r ? fullName(r) : 'Unbekannter Fahrer'}
{:else}
Diese Zeit läuft nicht mehr.
{/if}
</Dialog.Description>
</Dialog.Header>
{#if stopTime}
<p class="font-mono text-4xl font-semibold tabular-nums text-center py-4">
{stopTime.formattedTime}
</p>
{/if}
{#if stopError}
<p class="text-sm text-destructive">{stopError}</p>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (stopDialog = false)}>Abbrechen</Button>
<Button variant="outline" onclick={() => doStop('dnf')} disabled={!stopTime}>
DNF
</Button>
<Button variant="destructive" onclick={() => doStop('finished')} disabled={!stopTime}>
<Square class="h-4 w-4 mr-2" />
Stoppen
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,203 +0,0 @@
<script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import * as Select from '@/components/ui/select'
import * as Table from '@/components/ui/table'
import { Trophy, Download, Flag, Medal } from 'lucide-svelte'
import { onMount } from 'svelte'
import { page } from '$app/state'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
let selectedEventId = $state<string>('')
let selectedRunId = $state<string>('')
onMount(() => {
const fromUrl = page.url.searchParams.get('event')
if (fromUrl) selectedEventId = fromUrl
else if (events.scoped.length > 0) selectedEventId = events.scoped[0].id
})
const availableRuns = $derived(
selectedEventId ? runs.getByEvent(selectedEventId) : [],
)
$effect(() => {
if (selectedEventId && availableRuns.length > 0) {
if (!availableRuns.some((r) => r.id === selectedRunId)) {
selectedRunId = availableRuns[0].id
}
} else {
selectedRunId = ''
}
})
const leaderboard = $derived(selectedRunId ? times.getLeaderboard(selectedRunId) : [])
const podium = $derived(leaderboard.slice(0, 3))
function getRider(id: string | undefined) {
return id ? riders.getById(id) : undefined
}
function exportCsv() {
if (leaderboard.length === 0) return
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
leaderboard.forEach((t, i) => {
const r = getRider(t.rider)
rows.push([
String(i + 1),
r?.number ?? '',
r ? fullName(r) : '',
t.formattedTime ?? '',
String(t.correction ?? 0),
])
})
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
const run = runs.getById(selectedRunId)
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${safeName}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
const selectedEventName = $derived(
selectedEventId ? events.getById(selectedEventId)?.name ?? '—' : 'Event wählen…',
)
const selectedRunName = $derived(
selectedRunId ? runs.getById(selectedRunId)?.description ?? '—' : 'Run wählen…',
)
</script>
<svelte:head><title>Ergebnisse</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Ergebnisse</h1>
<p class="mt-1 text-muted-foreground">Zeiten und Rankings</p>
</div>
{#if leaderboard.length > 0}
<Button variant="outline" onclick={exportCsv}>
<Download class="h-4 w-4 mr-2" />
CSV exportieren
</Button>
{/if}
</div>
<Card>
<CardContent class="py-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={selectedEventId}>
<Select.Trigger>{selectedEventName}</Select.Trigger>
<Select.Content>
{#each events.scoped as e}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRunId} disabled={availableRuns.length === 0}>
<Select.Trigger>{selectedRunName}</Select.Trigger>
<Select.Content>
{#each availableRuns as r}
<Select.Item value={r.id}>{r.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
</CardContent>
</Card>
{#if !selectedRunId}
<Card>
<CardContent class="py-16 text-center">
<Trophy class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Wähle Event und Run</p>
<p class="text-sm text-muted-foreground mt-1">Um Ergebnisse anzuzeigen, oben auswählen.</p>
</CardContent>
</Card>
{:else if leaderboard.length === 0}
<Card>
<CardContent class="py-16 text-center">
<Flag class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Noch keine Ergebnisse</p>
<p class="text-sm text-muted-foreground mt-1">Für diesen Run wurden noch keine Zeiten erfasst.</p>
</CardContent>
</Card>
{:else}
{#if podium.length > 0}
<div class="grid gap-4 md:grid-cols-3">
{#each podium as t, i}
{@const r = getRider(t.rider)}
{#if r}
<Card class={i === 0 ? 'border-primary md:order-2' : i === 1 ? 'md:order-1' : 'md:order-3'}>
<CardContent class="py-6 text-center">
<Medal class={`h-8 w-8 mx-auto mb-2 ${i === 0 ? 'text-primary' : 'text-muted-foreground'}`} />
<p class="text-sm text-muted-foreground">Platz {i + 1}</p>
<p class="text-lg font-semibold mt-1">#{r.number} {fullName(r)}</p>
<p class="font-mono text-2xl font-bold mt-2 tabular-nums">{t.formattedTime}</p>
</CardContent>
</Card>
{/if}
{/each}
</div>
{/if}
<Card>
<CardHeader>
<CardTitle>Vollständige Rangliste</CardTitle>
</CardHeader>
<CardContent class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-16">Platz</Table.Head>
<Table.Head class="w-20">#</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head class="text-right">Zeit</Table.Head>
<Table.Head class="text-right w-24">Korrektur</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboard as t, i (t.id)}
{@const r = getRider(t.rider)}
<Table.Row>
<Table.Cell>
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center">
{i + 1}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">{r ? fullName(r) : '—'}</Table.Cell>
<Table.Cell class="text-right font-mono tabular-nums">{t.formattedTime}</Table.Cell>
<Table.Cell class="text-right font-mono text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</CardContent>
</Card>
{/if}
</div>

View file

@ -1,281 +0,0 @@
<script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import * as Select from '@/components/ui/select'
import { Play, Square, Clock, Trophy, Search, Timer } from 'lucide-svelte'
import { onMount, onDestroy } from 'svelte'
import { page } from '$app/state'
const events = getEventContext()
const runs = getRunContext()
const riders = getRiderContext()
const times = getTimeContext()
let selectedEventId = $state<string>('')
let selectedRunId = $state<string>('')
let searchQuery = $state('')
let tick = $state(0)
onMount(() => {
const fromUrl = page.url.searchParams.get('event')
if (fromUrl) {
selectedEventId = fromUrl
} else if (events.active.length > 0) {
selectedEventId = events.active[0].id
} else if (events.scoped.length > 0) {
selectedEventId = events.scoped[0].id
}
const interval = setInterval(() => (tick++), 100)
return () => clearInterval(interval)
})
const availableRuns = $derived(
selectedEventId ? runs.getByEvent(selectedEventId) : [],
)
$effect(() => {
// Wenn Event wechselt und der bisherige Run nicht dazugehört: ersten Run wählen
if (selectedEventId && availableRuns.length > 0) {
if (!availableRuns.some((r) => r.id === selectedRunId)) {
selectedRunId = availableRuns[0].id
}
} else {
selectedRunId = ''
}
})
const eventRiders = $derived(
selectedEventId ? riders.getByEvent(selectedEventId) : [],
)
const filteredRiders = $derived(() => {
const q = searchQuery.toLowerCase().trim()
if (!q) return eventRiders
return eventRiders.filter((r) => {
return (
(r.number ?? '').toLowerCase().includes(q) ||
fullName(r).toLowerCase().includes(q)
)
})
})
const runTimes = $derived(selectedRunId ? times.getByRun(selectedRunId) : [])
const runningTimes = $derived(runTimes.filter((t) => t.status === 'active'))
const leaderboard = $derived(selectedRunId ? times.getLeaderboard(selectedRunId).slice(0, 5) : [])
function getElapsed(startISO: string | undefined): string {
if (!startISO) return '0:00.0'
const start = new Date(startISO).getTime()
const elapsed = (Date.now() - start) / 1000
const m = Math.floor(elapsed / 60)
const s = (elapsed % 60).toFixed(1)
return `${m}:${s.padStart(4, '0')}`
}
function getRider(id: string | undefined) {
return id ? riders.getById(id) : undefined
}
async function start(riderId: string) {
if (!selectedRunId) return alert('Bitte Run auswählen')
if (times.isRiderActive(selectedRunId, riderId)) return alert('Dieser Fahrer läuft bereits!')
try {
await times.start(selectedRunId, riderId)
} catch (e: any) {
alert(`Start fehlgeschlagen: ${e.message}`)
}
}
async function stop(timeId: string) {
try {
await times.stop(timeId)
} catch (e: any) {
alert(`Stopp fehlgeschlagen: ${e.message}`)
}
}
const selectedEventName = $derived(
selectedEventId ? events.getById(selectedEventId)?.name ?? 'Event wählen…' : 'Event wählen…',
)
const selectedRunName = $derived(
selectedRunId ? runs.getById(selectedRunId)?.description ?? 'Run wählen…' : 'Run wählen…',
)
</script>
<svelte:head><title>Zeitnahme</title></svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold tracking-tight">Zeitnahme</h1>
<p class="mt-1 text-muted-foreground">Zeiten für Fahrer starten und stoppen</p>
</div>
<Card>
<CardContent class="py-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label>Event</Label>
<Select.Root type="single" bind:value={selectedEventId}>
<Select.Trigger>{selectedEventName}</Select.Trigger>
<Select.Content>
{#each events.scoped as e}
<Select.Item value={e.id}>{e.name || e.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRunId} disabled={availableRuns.length === 0}>
<Select.Trigger>{selectedRunName}</Select.Trigger>
<Select.Content>
{#each availableRuns as r}
<Select.Item value={r.id}>{r.description}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if selectedEventId && availableRuns.length === 0}
<p class="text-xs text-muted-foreground">
Keine Runs für dieses Event. Über PocketBase Runs anlegen.
</p>
{/if}
</div>
</div>
</CardContent>
</Card>
{#if !selectedEventId || !selectedRunId}
<Card>
<CardContent class="py-16 text-center">
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Wähle ein Event und einen Run</p>
<p class="text-sm text-muted-foreground mt-1">
Um mit der Zeitnahme zu beginnen, oben Event und Run auswählen.
</p>
</CardContent>
</Card>
{:else}
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2 space-y-4">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input bind:value={searchQuery} placeholder="Fahrer suchen (Nummer, Name)…" class="pl-9" />
</div>
<Card>
<CardContent class="p-0">
{#if filteredRiders().length === 0}
<div class="py-12 text-center text-muted-foreground">
{searchQuery ? 'Keine Treffer' : 'Keine Fahrer für dieses Event'}
</div>
{:else}
<ul class="divide-y">
{#each filteredRiders() as rider (rider.id)}
{@const active = times.isRiderActive(selectedRunId, rider.id)}
{@const activeTime = active ? times.getActiveTime(selectedRunId, rider.id) : undefined}
<li class="p-4 flex items-center justify-between hover:bg-accent/40">
<div class="flex items-center gap-4">
<Badge variant="outline" class="font-mono text-base px-3 py-1">
{rider.number || '—'}
</Badge>
<div>
<p class="font-medium">{fullName(rider) || '—'}</p>
</div>
</div>
<div class="flex items-center gap-3">
{#if active && activeTime}
{#key tick}
<div class="font-mono text-2xl font-semibold tabular-nums">
{getElapsed(activeTime.start)}
</div>
{/key}
<Button variant="destructive" onclick={() => stop(activeTime.id)}>
<Square class="h-4 w-4 mr-2" />
Stopp
</Button>
{:else}
<Button onclick={() => start(rider.id)}>
<Play class="h-4 w-4 mr-2" />
Start
</Button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
</div>
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Clock class="h-4 w-4 text-primary" />
Laufende Zeiten ({runningTimes.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if runningTimes.length === 0}
<p class="px-4 py-6 text-sm text-center text-muted-foreground">Keine laufenden Zeiten</p>
{:else}
<ul class="divide-y">
{#each runningTimes as t}
{@const r = getRider(t.rider)}
{#if r}
{#key tick}
<li class="px-4 py-3">
<p class="text-sm text-muted-foreground">#{r.number} {fullName(r)}</p>
<p class="font-mono text-xl font-semibold tabular-nums">{getElapsed(t.start)}</p>
</li>
{/key}
{/if}
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" />
Top 5
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if leaderboard.length === 0}
<p class="px-4 py-6 text-sm text-center text-muted-foreground">Noch keine Ergebnisse</p>
{:else}
<ol class="divide-y">
{#each leaderboard as t, i}
{@const r = getRider(t.rider)}
{#if r}
<li class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-6 justify-center">
{i + 1}
</Badge>
<span class="text-sm">#{r.number} {fullName(r)}</span>
</div>
<span class="font-mono text-sm font-semibold tabular-nums">{t.formattedTime}</span>
</li>
{/if}
{/each}
</ol>
{/if}
</CardContent>
</Card>
</div>
</div>
{/if}
</div>