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
243 lines
11 KiB
Svelte
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}
|