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:
parent
660e72717a
commit
76e2eeff0e
4 changed files with 105 additions and 90 deletions
|
|
@ -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}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue