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:
parent
245f8d7924
commit
514b5de27b
8 changed files with 289 additions and 126 deletions
127
frontend/src/lib/components/RunningTimesToast.svelte
Normal file
127
frontend/src/lib/components/RunningTimesToast.svelte
Normal 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}
|
||||||
84
frontend/src/lib/components/StopTimeDialog.svelte
Normal file
84
frontend/src/lib/components/StopTimeDialog.svelte
Normal 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>
|
||||||
|
|
@ -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?.()}
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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. -->
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</Badge>
|
{:else}
|
||||||
|
<Badge variant={row.place! <= 3 ? 'default' : 'secondary'} class="w-7 justify-center">
|
||||||
|
{row.place}
|
||||||
|
</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>
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue