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"> <script lang="ts">
/** /**
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange * Die Leiste über allen Dashboard-Seiten: alles, was den Zeitnehmer
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen * betrifft, an einer Stelle. Von oben nach unten mit fallender
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr * Dringlichkeit — fehlende Verbindung, nicht übertragene Zeiten, laufende
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-Seite. * 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 * Aufgeklappt ist der Normalfall: Wer an der Strecke steht, will die
* laufenden Zeiten sehen und nicht erst danach fragen. Einklappen geht, * laufenden Zeiten sehen und nicht erst danach fragen. Einklappen geht,
@ -18,9 +22,10 @@
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
import { timeOutbox } from '$lib/stores/timeOutbox.svelte' import { timeOutbox } from '$lib/stores/timeOutbox.svelte'
import { connection } from '$lib/stores/connection.svelte'
import TimeDialog from './TimeDialog.svelte' import TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button' 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 times = getTimeContext()
const riders = getRiderContext() const riders = getRiderContext()
@ -68,9 +73,17 @@
const stage = stageId ? stages.getById(stageId) : undefined const stage = stageId ? stages.getById(stageId) : undefined
return stage?.description || 'Stage' 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> </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. --> <!-- 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="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="rounded-lg border bg-card text-card-foreground shadow-lg overflow-hidden">
@ -116,9 +129,44 @@
</div> </div>
<!-- <!--
Nicht uebertragene Zeiten stehen ganz oben und nicht im Die fehlende Verbindung steht ueber allem: Sie ist der Grund,
Aufgeklappten: Eine Zeit, die nur auf diesem Geraet existiert, aus dem die Zeiten darunter liegenbleiben. Die Meldung geht
ist das Dringendste, was diese Leiste zu sagen hat. 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} {#if timeOutbox.pending > 0}
<div class="flex items-center gap-2 border-b bg-amber-500/10 px-3 py-2"> <div class="flex items-center gap-2 border-b bg-amber-500/10 px-3 py-2">
@ -129,8 +177,6 @@
Gerät. Gerät.
{#if timeOutbox.error} {#if timeOutbox.error}
<span class="block text-destructive">{timeOutbox.error}</span> <span class="block text-destructive">{timeOutbox.error}</span>
{:else if !timeOutbox.isOnline}
<span class="block text-muted-foreground">Kein Netz.</span>
{/if} {/if}
</span> </span>
<Button <Button

View file

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

View file

@ -282,7 +282,20 @@
{@const t = row.time} {@const t = row.time}
{@const r = rider(t.rider)} {@const r = rider(t.rider)}
{@const live = row.place === null} {@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> <Table.Cell>
{#if live} {#if live}
<Badge variant="outline" class="text-primary border-primary">läuft</Badge> <Badge variant="outline" class="text-primary border-primary">läuft</Badge>
@ -299,7 +312,15 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if r} {#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} {:else}
{/if} {/if}
@ -333,16 +354,23 @@
Stelle, nur laenger. Stelle, nur laenger.
--> -->
<Table.Cell class="p-0 text-right"> <Table.Cell class="p-0 text-right">
{#if live}
<Button <Button
variant="ghost" variant="ghost"
class="h-auto w-full justify-end px-4 py-3 tabular-nums {live class="h-auto w-full justify-end px-4 py-3 font-semibold tabular-nums"
? 'font-semibold' tooltip="Zeit stoppen"
: 'font-normal'}" onclick={(e) => {
tooltip={live ? 'Zeit stoppen' : 'Zeit erfassen'} e.stopPropagation()
onclick={() => openTime(t.rider)} openTime(t.rider)
}}
> >
{t.formattedTime} {t.formattedTime}
</Button> </Button>
{:else}
<span class="block px-4 py-3 tabular-nums">
{t.formattedTime}
</span>
{/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right">
<Button <Button
@ -352,7 +380,10 @@
? '' ? ''
: 'text-muted-foreground'}" : 'text-muted-foreground'}"
tooltip="Zeitkorrektur" tooltip="Zeitkorrektur"
onclick={() => openPenalty(t.id, t.correction ?? 0)} onclick={(e) => {
e.stopPropagation()
openPenalty(t.id, t.correction ?? 0)
}}
> >
{t.correction {t.correction
? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)} s` ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)} s`