stammtisch-hersbruck/frontend/src/lib/components/RunningTimesToast.svelte
Daniel Michelberger 76e2eeff0e refactor: Offline-Meldung in die Zeitnahme-Leiste, ganze Zeile klickbar
Die Offline-Meldung stand als eigener Streifen unter der Kopfleiste —
eine zweite Statusflaeche neben der, die es dafuer schon gibt. Sie sitzt
jetzt in der Leiste unten rechts, ganz oben und mit fallender
Dringlichkeit darunter: fehlende Verbindung, nicht uebertragene Zeiten,
laufende Uhren. Das Symbol in der Kopfleiste wird offline weiss auf rot.

In der Zeitentabelle ist die ganze Zeile anklickbar statt nur die Spalte
"Zeit" — und nur, solange die Uhr laeuft. Eine gewertete Zeit hat im
Zeit-Dialog nichts zu holen; was an ihr noch zu aendern waere, aendert
die Korrektur nebenan.

Name und Korrektur halten den Klick auf: Der Name fuehrt zum Fahrer,
nicht in den Dialog. Die Zeit bleibt bei laufender Uhr ein echter Knopf,
denn ein <tr> nimmt keinen Fokus — ohne ihn waere die Zeile mit der
Tastatur nicht erreichbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:23:54 +02:00

243 lines
11 KiB
Svelte

<script lang="ts">
/**
* Die Leiste über allen Dashboard-Seiten: alles, was den Zeitnehmer
* betrifft, an einer Stelle. Von oben nach unten mit fallender
* Dringlichkeit — fehlende Verbindung, nicht übertragene Zeiten, laufende
* Uhren.
*
* 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.
*
* 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: 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'
import { getStageContext } from '$lib/stores/stages.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { timeOutbox } from '$lib/stores/timeOutbox.svelte'
import { connection } from '$lib/stores/connection.svelte'
import TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, CloudOff, RefreshCw, Timer, WifiOff } 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(true)
// 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 — 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. */
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 stageLabel(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
return stage?.description || 'Stage'
}
/** „seit 3 min" — wie lange die Verbindung schon fehlt. */
function since(ms: number): string {
const min = Math.floor(ms / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `seit ${min} min`
return `seit ${Math.floor(min / 60)} h`
}
</script>
{#if running.length > 0 || timeOutbox.pending > 0 || !connection.online}
<!-- 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">
{#if running.length > 0}
<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>
{/if}
<span class="text-sm font-medium">
{#if running.length === 0}
Zeitnahme
{:else if running.length === 1}
Zeit läuft
{:else}
{running.length} Zeiten laufen
{/if}
</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>
<!--
Die fehlende Verbindung steht ueber allem: Sie ist der Grund,
aus dem die Zeiten darunter liegenbleiben. Die Meldung geht
erst, wenn der Server wieder antwortet — an der Strecke waeren
fuenf Sekunden Einblendung dieselbe Katastrophe wie gar keine.
-->
{#if !connection.online}
<div class="flex items-start gap-2 border-b bg-amber-500/15 px-3 py-2">
<WifiOff class="mt-0.5 size-4 shrink-0 text-amber-600" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium">
Keine Verbindung
{#if connection.offlineFor !== null}
<span class="font-normal text-muted-foreground">
({since(connection.offlineFor)})
</span>
{/if}
</p>
<p class="text-xs text-muted-foreground">
Die Zeitnahme läuft weiter. Gestoppt wird auf diesem Gerät,
übertragen, sobald der Server wieder antwortet.
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
tooltip="Verbindung prüfen"
disabled={connection.checking}
onclick={() => connection.check()}
>
<RefreshCw class="size-4 {connection.checking ? 'animate-spin' : ''}" />
</Button>
</div>
{/if}
<!--
Nicht uebertragene Zeiten darunter: Eine Zeit, die nur auf
diesem Geraet existiert, ist das Naechstdringende.
-->
{#if timeOutbox.pending > 0}
<div class="flex items-center gap-2 border-b bg-amber-500/10 px-3 py-2">
<CloudOff class="size-4 shrink-0 text-amber-600" />
<span class="min-w-0 flex-1 text-xs">
{timeOutbox.pending}
{timeOutbox.pending === 1 ? 'Zeit liegt' : 'Zeiten liegen'} nur auf diesem
Gerät.
{#if timeOutbox.error}
<span class="block text-destructive">{timeOutbox.error}</span>
{/if}
</span>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
tooltip="Jetzt übertragen"
disabled={timeOutbox.flushing}
onclick={() => timeOutbox.flush()}
>
<RefreshCw class="size-4 {timeOutbox.flushing ? 'animate-spin' : ''}" />
</Button>
</div>
{/if}
{#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)}
<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.
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 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>
{/if}
</div>
</div>
<TimeDialog
bind:open={timeDialog}
eventId={dialogEvent}
riderId={dialogRider}
stageId={dialogStage}
/>
{/if}