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
This commit is contained in:
Daniel Michelberger 2026-09-07 23:23:54 +02:00
parent 660e72717a
commit 76e2eeff0e
4 changed files with 105 additions and 90 deletions

View file

@ -1,61 +0,0 @@
<script lang="ts">
/**
* Die Meldung, die stehen bleibt, solange der Server nicht antwortet.
*
* Kein Toast, der nach fünf Sekunden geht: Wer an der Strecke steht und
* nicht weiss, dass seine Zeiten nirgends ankommen, erfährt es sonst erst
* am Abend. Sie verschwindet genau dann, wenn die Verbindung zurück ist.
*
* Die Nachricht ist bewusst zweiteilig — was nicht geht, und was trotzdem
* geht. Die Zeitnahme läuft offline weiter, und das muss dastehen, sonst
* hört jemand aus Vorsicht damit auf.
*/
import { connection } from '$lib/stores/connection.svelte'
import { timeOutbox } from '$lib/stores/timeOutbox.svelte'
import { Button } from '@/components/ui/button'
import { RefreshCw, WifiOff } from 'lucide-svelte'
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 !connection.online}
<div class="sticky top-0 z-30 border-b border-amber-500/40 bg-amber-500/15">
<div class="mx-auto flex max-w-7xl flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2 sm:px-6 lg:px-8">
<WifiOff class="size-4 shrink-0 text-amber-700 dark:text-amber-500" />
<span class="text-sm font-medium">
Keine Verbindung zum Server
{#if connection.offlineFor !== null}
<span class="font-normal text-muted-foreground">
({since(connection.offlineFor)})
</span>
{/if}
</span>
<span class="text-sm text-muted-foreground">
Zeitnahme läuft weiter — gestoppt wird auf diesem Gerät und übertragen,
sobald der Server wieder antwortet.
{#if timeOutbox.pending > 0}
{timeOutbox.pending}
{timeOutbox.pending === 1 ? 'Zeit wartet' : 'Zeiten warten'}.
{/if}
</span>
<Button
variant="outline"
size="sm"
class="ml-auto shrink-0"
disabled={connection.checking}
onclick={() => connection.check()}
>
<RefreshCw class="size-4 mr-2 {connection.checking ? 'animate-spin' : ''}" />
Erneut versuchen
</Button>
</div>
</div>
{/if}

View file

@ -1,9 +1,13 @@
<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.
* 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,
@ -18,9 +22,10 @@
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 } from 'lucide-svelte'
import { ChevronDown, ChevronUp, CloudOff, RefreshCw, Timer, WifiOff } from 'lucide-svelte'
const times = getTimeContext()
const riders = getRiderContext()
@ -68,9 +73,17 @@
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}
{#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">
@ -116,9 +129,44 @@
</div>
<!--
Nicht uebertragene Zeiten stehen ganz oben und nicht im
Aufgeklappten: Eine Zeit, die nur auf diesem Geraet existiert,
ist das Dringendste, was diese Leiste zu sagen hat.
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">
@ -129,8 +177,6 @@
Gerät.
{#if timeOutbox.error}
<span class="block text-destructive">{timeOutbox.error}</span>
{:else if !timeOutbox.isOnline}
<span class="block text-muted-foreground">Kein Netz.</span>
{/if}
</span>
<Button

View file

@ -21,7 +21,6 @@
import Logo from '$lib/components/Logo.svelte'
import TeamMenu from '$lib/components/TeamMenu.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import OfflineBanner from '$lib/components/OfflineBanner.svelte'
import { connection } from '$lib/stores/connection.svelte'
import * as NavigationMenu from '@/components/ui/navigation-menu'
import Avatar from '$lib/components/Avatar.svelte'
@ -259,7 +258,9 @@
<Button
variant="ghost"
size="icon"
class="size-7"
class="size-7 {connection.online
? ''
: 'bg-destructive text-white hover:bg-destructive/90 hover:text-white'}"
onclick={() => connection.check()}
tooltip={connection.online
? 'Verbindung steht'
@ -268,7 +269,7 @@
{#if connection.online}
<Wifi class="h-4 w-4 text-muted-foreground" />
{:else}
<WifiOff class="h-4 w-4 text-amber-600" />
<WifiOff class="h-4 w-4" />
{/if}
</Button>
<Button
@ -396,8 +397,6 @@
{/if}
</header>
<OfflineBanner />
<main class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children?.()}
</main>

View file

@ -282,7 +282,20 @@
{@const t = row.time}
{@const r = rider(t.rider)}
{@const live = row.place === null}
<Table.Row class={live ? 'bg-primary/5' : undefined}>
<!--
Anklickbar ist die ganze Zeile, nicht nur
die Zeit — und nur, solange die Uhr laeuft.
Eine gewertete Zeit hat im Zeit-Dialog
nichts zu holen: Sie ist gestoppt, und was
an ihr noch zu aendern waere, aendert die
Korrektur nebenan.
-->
<Table.Row
class="{live ? 'bg-primary/5' : ''} {live
? 'cursor-pointer hover:bg-primary/10'
: ''}"
onclick={live ? () => openTime(t.rider) : undefined}
>
<Table.Cell>
{#if live}
<Badge variant="outline" class="text-primary border-primary">läuft</Badge>
@ -299,7 +312,15 @@
</Table.Cell>
<Table.Cell class="font-medium">
{#if r}
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
<!-- stopPropagation: Der Name fuehrt zum Fahrer,
nicht in den Zeit-Dialog. -->
<a
href={riderPath(r.id)}
class="hover:underline"
onclick={(e) => e.stopPropagation()}
>
{riderName(r) || '—'}
</a>
{:else}
{/if}
@ -333,16 +354,23 @@
Stelle, nur laenger.
-->
<Table.Cell class="p-0 text-right">
<Button
variant="ghost"
class="h-auto w-full justify-end px-4 py-3 tabular-nums {live
? 'font-semibold'
: 'font-normal'}"
tooltip={live ? 'Zeit stoppen' : 'Zeit erfassen'}
onclick={() => openTime(t.rider)}
>
{t.formattedTime}
</Button>
{#if live}
<Button
variant="ghost"
class="h-auto w-full justify-end px-4 py-3 font-semibold tabular-nums"
tooltip="Zeit stoppen"
onclick={(e) => {
e.stopPropagation()
openTime(t.rider)
}}
>
{t.formattedTime}
</Button>
{:else}
<span class="block px-4 py-3 tabular-nums">
{t.formattedTime}
</span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<Button
@ -352,7 +380,10 @@
? ''
: 'text-muted-foreground'}"
tooltip="Zeitkorrektur"
onclick={() => openPenalty(t.id, t.correction ?? 0)}
onclick={(e) => {
e.stopPropagation()
openPenalty(t.id, t.correction ?? 0)
}}
>
{t.correction
? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)} s`