"Run" hiess in dieser App immer schon der abgesteckte Abschnitt eines Events, auf dem gefahren und gestoppt wird — also das, was im Rennsport Stage heisst. "Run" ist daneben der einzelne Durchgang eines Fahrers, und genau der steht hier als `times`. Zwei Bedeutungen fuer ein Wort, an einer Stelle, an der beide vorkommen. Die Migration benennt um statt neu anzulegen: Collection und Feld behalten ihre IDs, PocketBase benennt Tabelle und Spalte um, die Daten bleiben stehen. Gesucht wird ueber die Collection-ID und nicht ueber den Namen, damit sie auch auf einer frischen Datenbank durchlaeuft, deren Snapshot die Collection bereits `stages` nennt. Im Frontend wandert `times.run` zu `times.stage`, der Store heisst stages.svelte.ts, und aus /events/[id]/runs/[runId] wird /events/[id]/stages/[stageId]. Nicht umbenannt: `running`, `allRunning` und RunningTimesToast. Das sind laufende Zeiten und keine Stages — dieselben Buchstaben, andere Sache. Und die Artikel: Der Run war maskulin, die Stage ist feminin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
155 lines
6.4 KiB
Svelte
155 lines
6.4 KiB
Svelte
<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 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.
|
|
*
|
|
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
|
|
* Zeit-Dialog mit Fahrer und Stage vorbelegt.
|
|
*/
|
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
|
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
|
import { getStageContext } from '$lib/stores/stages.svelte'
|
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
import { ticker } from '$lib/stores/ticker.svelte'
|
|
import TimeDialog from './TimeDialog.svelte'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte'
|
|
|
|
const times = getTimeContext()
|
|
const riders = getRiderContext()
|
|
const stages = getStageContext()
|
|
const participants = getEventParticipantContext()
|
|
const clock = ticker()
|
|
|
|
const running = $derived(times.allRunning(clock.now))
|
|
const single = $derived(running.length === 1)
|
|
|
|
let expanded = $state(false)
|
|
|
|
// Vorbelegung für den Zeit-Dialog
|
|
let timeDialog = $state(false)
|
|
let dialogEvent = $state('')
|
|
let dialogRider = $state<string | null>(null)
|
|
let dialogStage = $state<string | null>(null)
|
|
|
|
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) {
|
|
const stage = stageId ? stages.getById(stageId) : undefined
|
|
if (!stage?.event) return
|
|
|
|
dialogEvent = stage.event
|
|
dialogRider = riderId ?? null
|
|
dialogStage = stage.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.
|
|
const open = $derived(single || expanded)
|
|
|
|
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
|
|
function riderLabel(riderId: string | undefined, stageId: string | undefined) {
|
|
const eventId = stageId ? stages.getById(stageId)?.event : undefined
|
|
if (!eventId) {
|
|
const r = riderId ? riders.getById(riderId) : undefined
|
|
return r ? riderName(r) : 'Unbekannter Fahrer'
|
|
}
|
|
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'
|
|
}
|
|
</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"
|
|
tooltip={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 = 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>
|
|
|
|
<span class="text-lg font-semibold tabular-nums shrink-0">
|
|
{t.formattedTime}
|
|
</span>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="shrink-0"
|
|
tooltip="Zeit erfassen"
|
|
onclick={() => openTimeDialog(t.rider, t.stage)}
|
|
>
|
|
<Timer class="size-4" />
|
|
</Button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<TimeDialog
|
|
bind:open={timeDialog}
|
|
eventId={dialogEvent}
|
|
riderId={dialogRider}
|
|
stageId={dialogStage}
|
|
/>
|
|
{/if}
|