feat: Laufende Zeiten in der Rangliste und als sticky Leiste, Monospace raus

Die eigene Karte "Laufende Zeiten" entfaellt: Laufende stehen jetzt oben in
derselben Tabelle wie die gewerteten, mit "laeuft"-Badge statt Platzziffer.
Die Zeile ist Klickziel fuer den Stopp-Dialog, der Knopf daneben macht sie
auch per Tastatur bedienbar.

Neu ist eine dauerhaft sichtbare Leiste ueber allen Dashboard-Seiten,
solange irgendwo eine Zeit laeuft - bei mehreren eingeklappt mit Anzahl und
laengster Zeit, aufklappbar. Damit bleibt die Uhr im Blick, waehrend man
zwischen zwei Startern woanders unterwegs ist.

Der Stopp-Dialog wandert dafuer in eine eigene Komponente, die sich
Run-Seite und Leiste teilen, statt die Logik zweimal zu haben.

Monospace faellt appweit weg; einzige Ausnahme bleibt die grosse mitlaufende
Uhr im Stopp-Dialog. tabular-nums bleibt stehen: gleich breite Ziffern
derselben Schrift, ohne die eine laufende Uhr bei jedem Zehntel springt.

Tabellenkoepfe hatten den Hover-Hintergrund der Body-Zeilen geerbt; die
Regel sitzt jetzt im thead und gilt fuer alle Tabellen der App.

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:13:00 +02:00
parent 245f8d7924
commit 514b5de27b
8 changed files with 289 additions and 126 deletions

View file

@ -0,0 +1,127 @@
<script lang="ts">
/**
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Run-Seite.
*
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
*/
import { getTimeContext } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import StopTimeDialog from './StopTimeDialog.svelte'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, Square } from 'lucide-svelte'
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const clock = ticker()
const running = $derived(times.allRunning(clock.now))
const single = $derived(running.length === 1)
let expanded = $state(false)
let stopTimeId = $state<string | null>(null)
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
const open = $derived(single || expanded)
function riderLabel(riderId: string | undefined) {
const r = riderId ? riders.getById(riderId) : undefined
if (!r) return 'Unbekannter Fahrer'
return `#${r.number || '—'} ${fullName(r)}`.trim()
}
function runPath(runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
if (!run?.event) return null
return `/dashboard/events/${run.event}/runs/${run.id}`
}
function runLabel(runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
return run?.description || 'Run'
}
</script>
{#if running.length > 0}
<!-- fixed statt sticky: Die Leiste soll auch beim Scrollen stehen bleiben. -->
<div class="fixed bottom-4 right-4 z-50 w-[min(24rem,calc(100vw-2rem))]">
<div class="rounded-lg border bg-card text-card-foreground shadow-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 border-b bg-primary/5">
<span class="relative flex size-2 shrink-0">
<span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span>
<span class="relative inline-flex size-2 rounded-full bg-primary"></span>
</span>
<span class="text-sm font-medium">
{running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`}
</span>
{#if !open}
<span class="text-sm tabular-nums font-semibold ml-auto">
{running[0].formattedTime}
</span>
{/if}
{#if !single}
<Button
variant="ghost"
size="icon"
class="ml-auto size-7 shrink-0"
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
onclick={() => (expanded = !expanded)}
>
{#if expanded}
<ChevronDown class="size-4" />
{:else}
<ChevronUp class="size-4" />
{/if}
</Button>
{/if}
</div>
{#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)}
{@const path = runPath(t.run)}
<li class="flex items-center gap-2 px-3 py-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{riderLabel(t.rider)}</p>
<p class="text-xs text-muted-foreground truncate">
{#if path}
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
{:else}
{runLabel(t.run)}
{/if}
</p>
</div>
<span class="text-lg font-semibold tabular-nums shrink-0">
{t.formattedTime}
</span>
<Button
variant="ghost"
size="icon"
class="shrink-0"
title="Zeit stoppen"
onclick={() => (stopTimeId = t.id)}
>
<Square class="size-4 text-destructive" />
</Button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<StopTimeDialog bind:timeId={stopTimeId} />
{/if}

View file

@ -0,0 +1,84 @@
<script lang="ts">
/**
* Bestätigung zum Stoppen einer laufenden Zeit. Eigene Komponente, weil
* gestoppt wird, wo man gerade ist: auf der Run-Seite und aus der
* Toast-Leiste heraus. Beide binden dieselbe `timeId`.
*/
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
import { Square } from 'lucide-svelte'
let { timeId = $bindable(null) }: { timeId?: string | null } = $props()
const times = getTimeContext()
const riders = getRiderContext()
const clock = ticker()
// Aus der laufenden Liste gelesen statt kopiert: So zählt die Anzeige im
// Dialog weiter und verschwindet, wenn jemand anders zuerst stoppt.
const time = $derived(
timeId ? times.allRunning(clock.now).find((t) => t.id === timeId) : undefined,
)
const rider = $derived(time?.rider ? riders.getById(time.rider) : undefined)
let error = $state<string | null>(null)
function close() {
timeId = null
error = null
}
async function stop(status: 'finished' | 'dnf') {
if (!timeId) return
error = null
try {
await times.stop(timeId, status as TimeStatus)
close()
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestoppt werden.'
}
}
</script>
<Dialog.Root
open={timeId !== null}
onOpenChange={(open) => {
if (!open) close()
}}
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeit stoppen?</Dialog.Title>
<Dialog.Description>
{#if time}
#{rider?.number ?? '—'} {rider ? fullName(rider) : 'Unbekannter Fahrer'}
{:else}
Diese Zeit läuft nicht mehr.
{/if}
</Dialog.Description>
</Dialog.Header>
{#if time}
<p class="font-mono text-4xl font-semibold tabular-nums text-center py-4">
{time.formattedTime}
</p>
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={close}>Abbrechen</Button>
<Button variant="outline" onclick={() => stop('dnf')} disabled={!time}>DNF</Button>
<Button variant="destructive" onclick={() => stop('finished')} disabled={!time}>
<Square class="h-4 w-4 mr-2" />
Stoppen
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -13,7 +13,7 @@
<thead <thead
bind:this={ref} bind:this={ref}
data-slot="table-header" data-slot="table-header"
class={cn("[&_tr]:border-b", className)} class={cn("[&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View file

@ -166,8 +166,18 @@ export class TimeStore {
} }
get running(): TimeWithDuration[] { get running(): TimeWithDuration[] {
const now = Date.now() return this.allRunning()
return this.scoped.filter((r) => r.status === 'active').map((r) => enrich(r, now)) }
/**
* Alle laufenden Zeiten des Teams, run-übergreifend der am längsten
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
*/
allRunning(now = Date.now()): TimeWithDuration[] {
return this.scoped
.filter((r) => r.status === 'active')
.map((r) => enrich(r, now))
.sort((a, b) => (a.start < b.start ? -1 : 1))
} }
get finished(): TimeWithDuration[] { get finished(): TimeWithDuration[] {

View file

@ -15,6 +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 RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, 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'
@ -182,5 +183,8 @@
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children?.()} {@render children?.()}
</main> </main>
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
<RunningTimesToast />
</div> </div>
{/if} {/if}

View file

@ -199,7 +199,7 @@
{@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length} {@const done = times.getByRider(rider.id).filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(riderPath(rider.id))}> <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">{rider.number || '—'}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
<!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. --> <!-- Eigener Link, damit die Zeile auch per Tastatur erreichbar ist. -->

View file

@ -97,7 +97,7 @@
{event.name || 'Event'} {event.name || 'Event'}
</a> </a>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Badge variant="outline" class="font-mono text-lg px-3 py-1">{rider.number || '—'}</Badge> <Badge variant="outline" class="text-lg px-3 py-1">{rider.number || '—'}</Badge>
<h1 class="text-3xl font-bold tracking-tight"> <h1 class="text-3xl font-bold tracking-tight">
{fullName(rider) || 'Ohne Name'} {fullName(rider) || 'Ohne Name'}
</h1> </h1>
@ -118,7 +118,7 @@
<CardTitle class="text-sm font-medium text-muted-foreground">Bestzeit</CardTitle> <CardTitle class="text-sm font-medium text-muted-foreground">Bestzeit</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="text-3xl font-bold font-mono tabular-nums"> <div class="text-3xl font-bold tabular-nums">
{stats.best != null ? formatDuration(stats.best) : '—'} {stats.best != null ? formatDuration(stats.best) : '—'}
</div> </div>
</CardContent> </CardContent>
@ -128,7 +128,7 @@
<CardTitle class="text-sm font-medium text-muted-foreground">Gesamtzeit</CardTitle> <CardTitle class="text-sm font-medium text-muted-foreground">Gesamtzeit</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="text-3xl font-bold font-mono tabular-nums"> <div class="text-3xl font-bold tabular-nums">
{stats.done > 0 ? formatDuration(stats.total) : '—'} {stats.done > 0 ? formatDuration(stats.total) : '—'}
</div> </div>
</CardContent> </CardContent>
@ -185,7 +185,7 @@
<span class="text-muted-foreground text-sm"></span> <span class="text-muted-foreground text-sm"></span>
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right font-mono tabular-nums"> <Table.Cell class="text-right tabular-nums">
{#if row.active} {#if row.active}
<span class="font-semibold">{row.active.formattedTime}</span> <span class="font-semibold">{row.active.formattedTime}</span>
{:else if row.best} {:else if row.best}
@ -196,7 +196,7 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
{#if row.placement} {#if row.placement}
<Badge variant={row.placement <= 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center"> <Badge variant={row.placement <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
{row.placement} {row.placement}
</Badge> </Badge>
{:else} {:else}

View file

@ -9,9 +9,10 @@
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, type TimeStatus } from '$lib/stores/times.svelte' import { getTimeContext } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time' import { TIME_STATUS_LABEL } from '$lib/time'
import StopTimeDialog from '$lib/components/StopTimeDialog.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'
@ -19,7 +20,7 @@
import * as Dialog from '@/components/ui/dialog' import * as Dialog from '@/components/ui/dialog'
import * as Table from '@/components/ui/table' import * as Table from '@/components/ui/table'
import { import {
ArrowLeft, Clock, Download, Flag, Play, Search, Square, Timer, Trophy, ArrowLeft, Download, Flag, Play, Search, Square, Timer, Trophy,
} from 'lucide-svelte' } from 'lucide-svelte'
const events = getEventContext() const events = getEventContext()
@ -41,6 +42,16 @@
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : []) const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : []) const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
/**
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
* steht oben ohne Platz, darunter die Rangliste. So sieht man beides auf
* einen Blick, statt zwischen zwei Karten zu springen.
*/
const rows = $derived([
...running.map((time) => ({ time, place: null as number | null })),
...leaderboard.map((time, i) => ({ time, place: i + 1 })),
])
function rider(id: string | undefined) { function rider(id: string | undefined) {
return id ? riders.getById(id) : undefined return id ? riders.getById(id) : undefined
} }
@ -92,30 +103,12 @@
} }
// --- Zeitnahme stoppen ----------------------------------------------- // --- Zeitnahme stoppen -----------------------------------------------
let stopDialog = $state(false) // Dialog und Fehlerbehandlung stecken in StopTimeDialog; hier steht nur,
// welche Zeit gerade gestoppt werden soll.
let stopTimeId = $state<string | null>(null) 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) { function openStop(timeId: string) {
stopTimeId = timeId 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 ------------------------------------------------------------ // --- Export ------------------------------------------------------------
@ -202,62 +195,18 @@
</Card> </Card>
{/if} {/if}
<!-- Laufende Zeiten --> <!-- Zeiten: laufende oben, darunter die Wertung -->
<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> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" /> <Trophy class="h-4 w-4 text-primary" />
Rangliste ({leaderboard.length}) Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-0">
{#if leaderboard.length === 0} {#if rows.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground"> <p class="px-6 py-8 text-sm text-center text-muted-foreground">
Für diesen Run wurden noch keine Zeiten gewertet. Noch keine Zeit. Über „Zeitnahme starten" geht es los.
</p> </p>
{:else} {:else}
<Table.Root> <Table.Root>
@ -268,19 +217,29 @@
<Table.Head>Name</Table.Head> <Table.Head>Name</Table.Head>
<Table.Head class="text-right">Zeit</Table.Head> <Table.Head class="text-right">Zeit</Table.Head>
<Table.Head class="text-right w-24">Korrektur</Table.Head> <Table.Head class="text-right w-24">Korrektur</Table.Head>
<Table.Head class="w-12"></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each leaderboard as t, i (t.id)} {#each rows as row (row.time.id)}
{@const t = row.time}
{@const r = rider(t.rider)} {@const r = rider(t.rider)}
<Table.Row> {@const live = row.place === null}
<Table.Row
class={live ? 'cursor-pointer bg-primary/5' : undefined}
onclick={live ? () => openStop(t.id) : undefined}
>
<Table.Cell> <Table.Cell>
<Badge variant={i < 3 ? 'default' : 'secondary'} class="font-mono w-7 justify-center"> {#if live}
{i + 1} <Badge variant="outline" class="text-primary border-primary">läuft</Badge>
{:else}
<Badge variant={row.place! <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
{row.place}
</Badge> </Badge>
{/if}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge> <Badge variant="outline">{r?.number ?? '—'}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if r} {#if r}
@ -289,10 +248,25 @@
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right font-mono tabular-nums">{t.formattedTime}</Table.Cell> <Table.Cell class="text-right tabular-nums {live ? 'font-semibold' : ''}">
<Table.Cell class="text-right font-mono text-muted-foreground tabular-nums"> {t.formattedTime}
</Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''} {t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
{#if live}
<!-- Eigener Knopf, damit die Zeile nicht nur mit der Maus stoppbar ist. -->
<Button
variant="ghost"
size="icon"
title="Zeit stoppen"
onclick={() => openStop(t.id)}
>
<Square class="h-4 w-4 text-destructive" />
</Button>
{/if}
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
@ -315,7 +289,7 @@
{@const r = rider(t.rider)} {@const r = rider(t.rider)}
<li class="px-6 py-3 flex items-center justify-between gap-4"> <li class="px-6 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<Badge variant="outline" class="font-mono">{r?.number ?? '—'}</Badge> <Badge variant="outline">{r?.number ?? '—'}</Badge>
<span class="truncate"> <span class="truncate">
{#if r} {#if r}
<a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a> <a href={riderPath(r.id)} class="hover:underline">{fullName(r) || '—'}</a>
@ -369,7 +343,7 @@
onclick={() => startFor(c.rider.id)} onclick={() => startFor(c.rider.id)}
> >
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<Badge variant="outline" class="font-mono">{c.rider.number || '—'}</Badge> <Badge variant="outline">{c.rider.number || '—'}</Badge>
<span class="truncate">{fullName(c.rider) || '—'}</span> <span class="truncate">{fullName(c.rider) || '—'}</span>
</div> </div>
{#if c.active} {#if c.active}
@ -392,40 +366,4 @@
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<!-- Stopp-Dialog --> <StopTimeDialog bind:timeId={stopTimeId} />
<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>