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:
parent
6ee7801d78
commit
09d7303eb2
3 changed files with 137 additions and 63 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<!--
|
||||
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}
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{:else}
|
||||
<Button disabled={!ready || busy} onclick={start}>
|
||||
<Play class="h-4 w-4 mr-2" />
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue