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:
parent
a8049e7406
commit
245f8d7924
10 changed files with 799 additions and 533 deletions
22
frontend/src/lib/stores/ticker.svelte.ts
Normal file
22
frontend/src/lib/stores/ticker.svelte.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
30
frontend/src/lib/time.ts
Normal file
30
frontend/src/lib/time.ts
Normal 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' })
|
||||
}
|
||||
|
|
@ -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' },
|
||||
]
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</a>
|
||||
</Card>
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/timing" class="block p-6">
|
||||
<Timer class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Zeitnahme</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Zeiten starten und stoppen</p>
|
||||
<a href="/dashboard/trails" class="block p-6">
|
||||
<Route class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Trails</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Strecken, Zustand und Meldungen</p>
|
||||
</a>
|
||||
</Card>
|
||||
<Card class="hover:border-primary transition-colors">
|
||||
<a href="/dashboard/results" class="block p-6">
|
||||
<Flag class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Ergebnisse</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Zeiten und Rankings</p>
|
||||
<a href="/dashboard/teams" class="block p-6">
|
||||
<UsersRound class="h-6 w-6 text-muted-foreground mb-3" />
|
||||
<h3 class="font-semibold">Teams</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Mitglieder und Rechte</p>
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
</CardHeader>
|
||||
<CardContent class="flex items-center justify-between">
|
||||
<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>
|
||||
</Card>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts">
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -20,18 +22,28 @@
|
|||
Timer,
|
||||
Trash2,
|
||||
Users,
|
||||
Flag,
|
||||
} from 'lucide-svelte'
|
||||
import type { RidersResponse, RunsResponse } from '$lib/types'
|
||||
|
||||
const events = getEventContext()
|
||||
const runs = getRunContext()
|
||||
const riders = getRiderContext()
|
||||
const times = getTimeContext()
|
||||
|
||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||
const eventRiders = $derived(event ? riders.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
|
||||
let riderDialog = $state(false)
|
||||
let ridingEdit = $state<RidersResponse | null>(null)
|
||||
|
|
@ -139,18 +151,6 @@
|
|||
<p class="mt-2 text-sm text-muted-foreground">{event.description}</p>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
<Separator />
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
Fahrer
|
||||
<span class="text-muted-foreground font-normal text-base">({eventRiders.length})</span>
|
||||
</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>
|
||||
<Button onclick={openRiderCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
|
|
@ -190,23 +190,31 @@
|
|||
<Table.Row>
|
||||
<Table.Head class="w-20">#</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head class="w-40">Zeiten</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#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>
|
||||
<Badge variant="outline" class="font-mono">{rider.number || '—'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">
|
||||
{#if fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->
|
||||
<a href={riderPath(rider.id)} class="hover:underline">
|
||||
{#if fullName(rider)}
|
||||
{fullName(rider)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground italic">— ohne Name —</span>
|
||||
{/if}
|
||||
</a>
|
||||
</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)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -234,7 +242,7 @@
|
|||
Runs
|
||||
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
||||
</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>
|
||||
<Button onclick={openRunCreate}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
|
|
@ -259,14 +267,26 @@
|
|||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Bezeichnung</Table.Head>
|
||||
<Table.Head class="w-48">Zeiten</Table.Head>
|
||||
<Table.Head class="text-right">Aktionen</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each eventRuns as run (run.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{run.description}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{@const runTimes = times.getByRun(run.id)}
|
||||
{@const active = runTimes.filter((t) => t.status === 'active').length}
|
||||
{@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)}>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue