feat: Ein Dialog fuer die Zeitnahme, keine Aktionen mehr inline

Start und Stopp laufen ausschliesslich ueber TimeDialog. Der fuehrt immer
beide Angaben - Fahrer und Run -, auch wenn eine davon aus dem Kontext
vorbelegt ist: Beim Stoppen an der Strecke soll kein Zweifel bleiben, wen
man gerade erwischt.

DNF fragt nach, bevor es schreibt. Eine Zeit ohne Wertung laesst sich nicht
zurueckholen, ein Fehlgriff waere also endgueltig.

Tabelle und Toast-Leiste zeigen damit nur noch an; ihre Knoepfe oeffnen den
Dialog mit passender Vorbelegung. Der alte Stopp-Dialog und der
Fahrerlisten-Start entfallen - beides waren Ein-Tipp-Aktionen und damit
genau das, was hier nicht mehr sein soll.

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:55:33 +02:00
parent 514b5de27b
commit cc55bd66a5
4 changed files with 264 additions and 231 deletions

View file

@ -7,15 +7,17 @@
*
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
*
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
* Zeit-Dialog mit Fahrer und Run vorbelegt.
*/
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 TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, Square } from 'lucide-svelte'
import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte'
const times = getTimeContext()
const riders = getRiderContext()
@ -26,7 +28,22 @@
const single = $derived(running.length === 1)
let expanded = $state(false)
let stopTimeId = $state<string | null>(null)
// Vorbelegung für den Zeit-Dialog
let timeDialog = $state(false)
let dialogEvent = $state('')
let dialogRider = $state<string | null>(null)
let dialogRun = $state<string | null>(null)
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
if (!run?.event) return
dialogEvent = run.event
dialogRider = riderId ?? null
dialogRun = run.id
timeDialog = true
}
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
@ -111,10 +128,10 @@
variant="ghost"
size="icon"
class="shrink-0"
title="Zeit stoppen"
onclick={() => (stopTimeId = t.id)}
title="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.run)}
>
<Square class="size-4 text-destructive" />
<Timer class="size-4" />
</Button>
</li>
{/each}
@ -123,5 +140,10 @@
</div>
</div>
<StopTimeDialog bind:timeId={stopTimeId} />
<TimeDialog
bind:open={timeDialog}
eventId={dialogEvent}
riderId={dialogRider}
runId={dialogRun}
/>
{/if}

View file

@ -1,84 +0,0 @@
<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

@ -0,0 +1,219 @@
<script lang="ts">
/**
* Der eine Dialog für die Zeitnahme. Fahrer und Run stehen immer beide
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
* Tabellen noch die Toast-Leiste lösen sie direkt aus.
*/
import { getTimeContext, type TimeStatus } 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 { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select'
import { AlertTriangle, Play, Square } from 'lucide-svelte'
let {
open = $bindable(false),
eventId,
riderId = null,
runId = null,
}: {
open?: boolean
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
eventId: string
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
riderId?: string | null
runId?: string | null
} = $props()
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const clock = ticker()
let selectedRider = $state('')
let selectedRun = $state('')
let error = $state<string | null>(null)
let confirmDnf = $state(false)
let busy = $state(false)
// Beim Öffnen auf die Vorbelegung zurücksetzen — ein Dialog, der noch die
// Auswahl vom letzten Mal zeigt, ist an der Strecke eine Fehlerquelle.
$effect(() => {
if (!open) return
selectedRider = riderId ?? ''
selectedRun = runId ?? ''
error = null
confirmDnf = false
})
const eventRiders = $derived(eventId ? riders.getByEvent(eventId) : [])
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
const riderLabel = $derived(
selectedRider
? (() => {
const r = riders.getById(selectedRider)
return r ? `#${r.number || '—'} ${fullName(r)}`.trim() : 'Fahrer wählen…'
})()
: 'Fahrer wählen…',
)
const runLabel = $derived(
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
)
const ready = $derived(!!selectedRider && !!selectedRun)
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
const active = $derived(
ready
? times
.allRunning(clock.now)
.find((t) => t.run === selectedRun && t.rider === selectedRider)
: undefined,
)
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
const existing = $derived(
ready && !active
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
: undefined,
)
async function start() {
if (!ready || busy) return
busy = true
error = null
try {
await times.start(selectedRun, selectedRider)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
} finally {
busy = false
}
}
async function stop(status: 'finished' | 'dnf') {
if (!active || busy) return
busy = true
error = null
try {
await times.stop(active.id, status as TimeStatus)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestoppt werden.'
} finally {
busy = false
confirmDnf = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeit erfassen</Dialog.Title>
<Dialog.Description>
Fahrer und Run wählen — je nach Stand wird gestartet oder gestoppt.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label>Fahrer</Label>
<Select.Root type="single" bind:value={selectedRider} disabled={eventRiders.length === 0}>
<Select.Trigger class="w-full">{riderLabel}</Select.Trigger>
<Select.Content>
{#each eventRiders as r (r.id)}
<Select.Item value={r.id}>
#{r.number || '—'} {fullName(r)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventRiders.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p>
{/if}
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
<Select.Content>
{#each eventRuns as r (r.id)}
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventRuns.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
{/if}
</div>
{#if active}
<div class="rounded-lg border bg-primary/5 py-4 text-center">
<p class="text-xs text-muted-foreground">läuft seit</p>
<p class="font-mono text-4xl font-semibold tabular-nums">{active.formattedTime}</p>
</div>
{:else if existing}
<p class="text-sm text-muted-foreground">
Für diese Paarung gibt es bereits eine Zeit ({existing.formattedTime}).
Ein neuer Start legt eine zweite an.
</p>
{/if}
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>
{#if confirmDnf}
<!-- DNF beendet die Zeit ohne Wertung und ist nicht zurückzunehmen. -->
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-3">
<p class="text-sm flex items-start gap-2">
<AlertTriangle class="size-4 text-destructive shrink-0 mt-0.5" />
<span>
Wirklich als DNF werten? Die Zeit wird beendet und taucht in keiner
Rangliste auf.
</span>
</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" onclick={() => (confirmDnf = false)}>
Zurück
</Button>
<Button variant="destructive" size="sm" disabled={busy} onclick={() => stop('dnf')}>
DNF bestätigen
</Button>
</div>
</div>
{:else}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
{#if active}
<Button variant="outline" disabled={busy} onclick={() => (confirmDnf = true)}>
DNF
</Button>
<Button variant="destructive" disabled={busy} onclick={() => stop('finished')}>
<Square class="h-4 w-4 mr-2" />
Stoppen
</Button>
{:else}
<Button disabled={!ready || busy} onclick={start}>
<Play class="h-4 w-4 mr-2" />
Starten
</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>

View file

@ -1,8 +1,9 @@
<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
* Run-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
* laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet
* und gestoppt wird ausschließlich im Zeit-Dialog. 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'
@ -12,16 +13,12 @@
import { getTimeContext } from '$lib/stores/times.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time'
import StopTimeDialog from '$lib/components/StopTimeDialog.svelte'
import TimeDialog from '$lib/components/TimeDialog.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 * as Dialog from '@/components/ui/dialog'
import * as Table from '@/components/ui/table'
import {
ArrowLeft, Download, Flag, Play, Search, Square, Timer, Trophy,
} from 'lucide-svelte'
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
@ -60,56 +57,10 @@
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 -----------------------------------------------
// Dialog und Fehlerbehandlung stecken in StopTimeDialog; hier steht nur,
// welche Zeit gerade gestoppt werden soll.
let stopTimeId = $state<string | null>(null)
function openStop(timeId: string) {
stopTimeId = timeId
}
// --- Zeitnahme --------------------------------------------------------
// Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
// dabei vorbelegt, den Fahrer wählt man dort.
let timeDialog = $state(false)
// --- Export ------------------------------------------------------------
function exportCsv() {
@ -179,9 +130,9 @@
CSV
</Button>
{/if}
<Button onclick={openStart} disabled={eventRiders.length === 0}>
<Play class="h-4 w-4 mr-2" />
Zeitnahme starten
<Button onclick={() => (timeDialog = true)} disabled={eventRiders.length === 0}>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
</Button>
</div>
</div>
@ -217,7 +168,6 @@
<Table.Head>Name</Table.Head>
<Table.Head class="text-right">Zeit</Table.Head>
<Table.Head class="text-right w-24">Korrektur</Table.Head>
<Table.Head class="w-12"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@ -225,10 +175,7 @@
{@const t = row.time}
{@const r = rider(t.rider)}
{@const live = row.place === null}
<Table.Row
class={live ? 'cursor-pointer bg-primary/5' : undefined}
onclick={live ? () => openStop(t.id) : undefined}
>
<Table.Row class={live ? 'bg-primary/5' : undefined}>
<Table.Cell>
{#if live}
<Badge variant="outline" class="text-primary border-primary">läuft</Badge>
@ -254,19 +201,6 @@
<Table.Cell class="text-right text-muted-foreground tabular-nums">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
</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>
{/each}
</Table.Body>
@ -308,62 +242,4 @@
</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">{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>
<StopTimeDialog bind:timeId={stopTimeId} />
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} runId={run?.id ?? null} />