feat: Zeitnahme fuer den Daumen an der Strecke

Die Leiste der laufenden Zeiten steht jetzt offen: Wer an der Strecke
steht, will sie sehen und nicht erst danach fragen. Und die ganze Zeile
ist der Knopf, nicht ein Icon an ihrem Rand — ein 16-px-Ziel neben einer
fingerbreiten Zeile ist eine Einladung zum Danebentippen. Der Name der
Stage verlinkt dafuer nicht mehr auf ihre Seite: ein Link im Knopf waere
weder gueltiges HTML noch eindeutig zu treffen.

Im Zeit-Dialog steht der Fahrer als Kachel statt in einer Auswahlliste —
die Startnummer gross wie am Trikot (font-startnumber: Impact, mit
Haettenschweiler und Arial Narrow Bold als Ersatz), der Name darunter.
Eine Liste verlangt aufklappen, zielen und treffen; drei Schritte fuer
etwas, das ein Zeitnehmer zwanzigmal am Tag macht.

Starten und Stoppen sind Flaechen von 96 px Hoehe, Symbol ueber Wort,
statt einer Zeile im Fussbereich. DNF steht daneben, gleich hoch, damit
es sich treffen laesst, aber schmaler, damit es nicht versehentlich
getroffen wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:51:46 +02:00
parent 6ee7801d78
commit 09d7303eb2
3 changed files with 137 additions and 63 deletions

View file

@ -145,3 +145,18 @@
cursor: default;
}
}
@layer components {
/*
* Startnummern wie am Trikot: schmal, fett, ohne Zierrat. Impact ist auf
* Windows und macOS da, Haettenschweiler und Arial Narrow Bold springen
* ein; am Ende steht eine beliebige serifenlose Schrift, dann sieht die
* Nummer eben normal aus statt falsch.
*/
.font-startnumber {
font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', 'Franklin Gothic Bold', sans-serif;
font-weight: 700;
font-stretch: condensed;
letter-spacing: 0.02em;
}
}

View file

@ -5,11 +5,12 @@
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-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.
* Aufgeklappt ist der Normalfall: Wer an der Strecke steht, will die
* laufenden Zeiten sehen und nicht erst danach fragen. Einklappen geht,
* sobald es mehr als eine ist. Bei genau einer entfällt das Klappen.
*
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
* Zeit-Dialog mit Fahrer und Stage vorbelegt.
* Gestoppt wird auch von hier aus nicht direkt: Ein Klick auf die Zeile
* öffnet den Zeit-Dialog mit Fahrer und Stage vorbelegt.
*/
import { getTimeContext } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
@ -29,7 +30,7 @@
const running = $derived(times.allRunning(clock.now))
const single = $derived(running.length === 1)
let expanded = $state(false)
let expanded = $state(true)
// Vorbelegung für den Zeit-Dialog
let timeDialog = $state(false)
@ -48,7 +49,8 @@
}
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
// Nutzer — voreingestellt ist offen. Fällt die Anzahl wieder auf eine,
// ist der Zustand egal.
const open = $derived(single || expanded)
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
@ -61,12 +63,6 @@
return participants.riderLabel(eventId, riderId)
}
function stagePath(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
if (!stage?.event) return null
return `/dashboard/events/${stage.event}/stages/${stage.id}`
}
function stageLabel(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
return stage?.description || 'Stage'
@ -113,32 +109,38 @@
{#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)}
{@const path = stagePath(t.stage)}
<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, t.stage)}</p>
<p class="text-xs text-muted-foreground truncate">
{#if path}
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a>
{:else}
{stageLabel(t.stage)}
{/if}
</p>
</div>
<li>
<!--
Die ganze Zeile ist der Knopf, nicht ein Icon
an ihrem Rand: An der Strecke wird mit dem
Daumen bedient, und ein 16-px-Ziel neben einer
fingerbreiten Zeile ist eine Einladung zum
Danebentippen.
<span class="text-lg font-semibold tabular-nums shrink-0">
{t.formattedTime}
</span>
<Button
variant="ghost"
size="icon"
class="shrink-0"
tooltip="Zeit erfassen"
Der Name der Stage verlinkt deshalb nicht mehr
auf ihre Seite — ein Link im Knopf waere weder
gueltiges HTML noch eindeutig zu treffen.
-->
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-accent"
onclick={() => openTimeDialog(t.rider, t.stage)}
>
<Timer class="size-4" />
</Button>
<Timer class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">
{riderLabel(t.rider, t.stage)}
</span>
<span class="block truncate text-xs text-muted-foreground">
{stageLabel(t.stage)}
</span>
</span>
<span class="shrink-0 text-lg font-semibold tabular-nums">
{t.formattedTime}
</span>
</button>
</li>
{/each}
</ul>

View file

@ -5,6 +5,12 @@
* 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.
*
* Bedient wird das an der Strecke, im Stehen, mit einer Hand am Lenker
* und einem Daumen am Telefon. Danach ist alles hier bemessen: Der Fahrer
* wird aus Kacheln gewählt statt aus einer Auswahlliste — die Startnummer
* gross wie am Trikot, der Name darunter —, und Starten und Stoppen sind
* flächige Knöpfe und keine Zeile im Fussbereich.
*/
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
@ -15,7 +21,7 @@
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'
import { AlertTriangle, Flag, Play, Square } from 'lucide-svelte'
let {
open = $bindable(false),
@ -65,9 +71,6 @@
)
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
const riderLabel = $derived(
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
)
const stageLabel = $derived(
selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…',
)
@ -134,19 +137,45 @@
<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}>
{participants.riderLabel(eventId, r.id)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label id="rider-label">Fahrer</Label>
{#if eventRiders.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p>
{:else}
<!--
Kacheln statt Auswahlliste: Eine Liste verlangt
aufklappen, zielen und treffen — drei Schritte für
etwas, das jeder Zeitnehmer zwanzigmal am Tag macht.
Hier steht jeder Fahrer als eigene Fläche da, gross
genug für einen Daumen.
-->
<div
role="radiogroup"
aria-labelledby="rider-label"
class="grid max-h-56 grid-cols-3 gap-2 overflow-y-auto rounded-lg border p-2 sm:grid-cols-4"
>
{#each eventRiders as r (r.id)}
{@const number = participants.numberOf(eventId, r.id)}
{@const chosen = selectedRider === r.id}
<button
type="button"
role="radio"
aria-checked={chosen}
onclick={() => (selectedRider = r.id)}
class="flex min-h-20 flex-col items-center justify-center gap-0.5 rounded-lg border p-2 transition-colors hover:bg-accent"
class:border-primary={chosen}
class:bg-primary={chosen}
class:text-primary-foreground={chosen}
>
<span class="font-startnumber text-2xl leading-none">
{number || ''}
</span>
<span class="line-clamp-2 text-center text-xs leading-tight">
{riderName(r)}
</span>
</button>
{/each}
</div>
{/if}
</div>
@ -202,22 +231,50 @@
</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" />
<!--
Die Handlung steht als Fläche da, nicht als Zeile im
Fussbereich: 96 px hoch, über die volle Breite, Symbol über
Wort. Wer im Stehen auf die Sekunde drückt, zielt nicht auf
einen 36 px hohen Streifen am unteren Rand.
DNF steht daneben und ist schmaler — dieselbe Höhe, damit es
sich treffen lässt, aber nicht dieselbe Breite, damit es nicht
versehentlich getroffen wird.
-->
{#if active}
<div class="grid grid-cols-3 gap-2">
<Button
variant="destructive"
disabled={busy}
class="col-span-2 h-24 flex-col gap-1 text-lg font-semibold"
onclick={() => stop('finished')}
>
<Square class="size-7" />
Stoppen
</Button>
{:else}
<Button disabled={!ready || busy} onclick={start}>
<Play class="h-4 w-4 mr-2" />
Starten
<Button
variant="outline"
disabled={busy}
class="h-24 flex-col gap-1 text-base font-semibold"
onclick={() => (confirmDnf = true)}
>
<Flag class="size-6" />
DNF
</Button>
{/if}
</div>
{:else}
<Button
disabled={!ready || busy}
class="h-24 w-full flex-col gap-1 text-lg font-semibold"
onclick={start}
>
<Play class="size-7" />
Starten
</Button>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>