stammtisch-hersbruck/frontend/src/lib/components/RunningTimesToast.svelte
Daniel Michelberger 893509be9d refactor: Aus Runs werden Stages, bis in die Datenbank
"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
2026-09-07 22:16:22 +02:00

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}