fix: offline gestoppte Zeit laesst sich nicht zweimal stoppen
Beim Test aufgefallen: Wer im Funkloch stoppte, sah die Zeit weiter als laufend — der Ausgang hatte den Stopp, der Datensatz nicht. Ein zweiter Druck legte einen zweiten Stopp daneben, und beim Uebertragen gewann der letzte. Welche Zeit am Ende zaehlte, war nicht mehr zu sagen. Drei Dinge dagegen. Ein Stopp im Ausgang traegt sich sofort auch am Datensatz ein, die Zeit ist damit sichtbar beendet. Ein zweiter Stopp fuer dieselbe Zeit wird abgewiesen, mit Ansage statt still. Und ist der Ausgang leer, laedt der Store einmal neu und tauscht die vorlaeufigen Zeiten gegen die echten samt ihren IDs. Dabei fiel die groessere Luecke auf: Ein offline gestarteter Lauf stand nirgends und liess sich deshalb gar nicht stoppen — die Oberflaeche kennt nur, was im Store steht. Er bekommt jetzt einen vorlaeufigen Datensatz mit einer local:-ID, laeuft sichtbar mit und laesst sich stoppen, bevor der Server ueberhaupt von ihm weiss. Dazu der Verbindungswaechter: /api/health im Takt, Symbol in der Kopfleiste und eine Meldung, die stehen bleibt, bis der Server wieder antwortet. navigator.onLine allein taugt dafuer nicht — im WLAN ohne Uplink und hinter einem Portal steht es unbeirrt auf true. Kehrt die Verbindung zurueck, geht der Ausgang von selbst raus. 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
6c2addb020
commit
660e72717a
5 changed files with 341 additions and 2 deletions
61
frontend/src/lib/components/OfflineBanner.svelte
Normal file
61
frontend/src/lib/components/OfflineBanner.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<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}
|
||||||
141
frontend/src/lib/stores/connection.svelte.ts
Normal file
141
frontend/src/lib/stores/connection.svelte.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { api } from './pocketbase.svelte'
|
||||||
|
import { timeOutbox } from './timeOutbox.svelte'
|
||||||
|
|
||||||
|
/** Abstand der Prüfungen, solange die Verbindung steht. */
|
||||||
|
const INTERVAL_ONLINE = 30_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstand, solange sie fehlt. Enger, weil die Rückkehr die Nachricht ist:
|
||||||
|
* Erst dann gehen die Zeiten aus dem Ausgang raus.
|
||||||
|
*/
|
||||||
|
const INTERVAL_OFFLINE = 5_000
|
||||||
|
|
||||||
|
/** Nach dieser Zeit gilt eine Prüfung als gescheitert. */
|
||||||
|
const TIMEOUT = 6_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steht die Verbindung zum Server?
|
||||||
|
*
|
||||||
|
* `navigator.onLine` allein taugt dafür nicht: Es sagt nur, dass das Gerät
|
||||||
|
* irgendein Netz hat — im WLAN einer Hütte ohne Uplink, im Funkloch mit einem
|
||||||
|
* Balken, hinter einem Portal, das jede Anfrage abfängt, steht es unbeirrt auf
|
||||||
|
* `true`. Für die Zeitnahme zählt aber nur eine Frage: Kommt eine Anfrage beim
|
||||||
|
* Server an? Also wird genau das gefragt, in regelmäßigen Abständen.
|
||||||
|
*
|
||||||
|
* Umgekehrt ist `navigator.onLine === false` verlässlich — kein Netz heißt
|
||||||
|
* kein Server. Das kürzt eine Prüfung ab, die ohnehin scheitern würde.
|
||||||
|
*/
|
||||||
|
export class Connection {
|
||||||
|
/** Letzter Stand: Server erreichbar? Bis zur ersten Prüfung optimistisch. */
|
||||||
|
online = $state(true)
|
||||||
|
/** Läuft gerade eine Prüfung? */
|
||||||
|
checking = $state(false)
|
||||||
|
/** Wann zuletzt eine Anfrage ankam. */
|
||||||
|
lastOk = $state<number | null>(null)
|
||||||
|
/** Seit wann die Verbindung fehlt — für „seit 3 Minuten offline". */
|
||||||
|
offlineSince = $state<number | null>(null)
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private started = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüfung starten. Läuft, bis `stop()` kommt — die Verbindung ist nichts,
|
||||||
|
* was eine einzelne Seite beträfe.
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
if (this.started || typeof window === 'undefined') return
|
||||||
|
this.started = true
|
||||||
|
|
||||||
|
window.addEventListener('online', this.onBrowserOnline)
|
||||||
|
window.addEventListener('offline', this.onBrowserOffline)
|
||||||
|
// Zurück aus dem Hintergrund: Zwischen Sperrbildschirm und Wiedersehen
|
||||||
|
// kann alles passiert sein, und die Timer standen womöglich still.
|
||||||
|
document.addEventListener('visibilitychange', this.onVisible)
|
||||||
|
|
||||||
|
void this.check()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.started) return
|
||||||
|
this.started = false
|
||||||
|
|
||||||
|
window.removeEventListener('online', this.onBrowserOnline)
|
||||||
|
window.removeEventListener('offline', this.onBrowserOffline)
|
||||||
|
document.removeEventListener('visibilitychange', this.onVisible)
|
||||||
|
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private onBrowserOnline = () => void this.check()
|
||||||
|
private onBrowserOffline = () => this.setState(false)
|
||||||
|
private onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') void this.check()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie lange die Verbindung schon fehlt, in Millisekunden. */
|
||||||
|
get offlineFor(): number | null {
|
||||||
|
return this.offlineSince === null ? null : Date.now() - this.offlineSince
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmal nachsehen. Gibt zurück, ob der Server geantwortet hat, damit ein
|
||||||
|
* Aufrufer („jetzt erneut versuchen") das Ergebnis auswerten kann.
|
||||||
|
*/
|
||||||
|
async check(): Promise<boolean> {
|
||||||
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
|
this.setState(false)
|
||||||
|
this.schedule()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.checking = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
const abort = setTimeout(() => controller.abort(), TIMEOUT)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${api.baseURL}/api/health`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
this.setState(res.ok)
|
||||||
|
return res.ok
|
||||||
|
} catch {
|
||||||
|
this.setState(false)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
clearTimeout(abort)
|
||||||
|
this.checking = false
|
||||||
|
this.schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(online: boolean) {
|
||||||
|
const was = this.online
|
||||||
|
this.online = online
|
||||||
|
|
||||||
|
if (online) {
|
||||||
|
this.lastOk = Date.now()
|
||||||
|
this.offlineSince = null
|
||||||
|
|
||||||
|
// Die Rückkehr ist der Moment, in dem der Ausgang leer werden
|
||||||
|
// kann. Das Browser-Ereignis `online` allein feuert dafür zu früh:
|
||||||
|
// Es meldet die Netzwerkschnittstelle, nicht den Server.
|
||||||
|
if (!was && timeOutbox.pending > 0) void timeOutbox.flush()
|
||||||
|
} else if (was || this.offlineSince === null) {
|
||||||
|
this.offlineSince = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule() {
|
||||||
|
if (!this.started) return
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => void this.check(),
|
||||||
|
this.online ? INTERVAL_ONLINE : INTERVAL_OFFLINE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Verbindung, eine Instanz — sie gehört dem Gerät, nicht einer Seite. */
|
||||||
|
export const connection = new Connection()
|
||||||
|
|
@ -80,6 +80,31 @@ export class TimeOutbox {
|
||||||
return this.entries.length
|
return this.entries.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liegt für diese Zeit schon ein Stopp im Ausgang?
|
||||||
|
*
|
||||||
|
* Ohne diese Frage liesse sich dieselbe Zeit zweimal stoppen, solange das
|
||||||
|
* Netz weg ist — und beim Übertragen gewänne der zweite Eintrag, also die
|
||||||
|
* falsche Zeit. Ein Stopp ist endgültig, auch wenn er noch hier liegt.
|
||||||
|
*/
|
||||||
|
hasPendingStop(timeId: string): boolean {
|
||||||
|
return this.entries.some(
|
||||||
|
(e) =>
|
||||||
|
e.kind === 'stop' &&
|
||||||
|
(e.time === timeId || (e.startLocalId && `local:${e.startLocalId}` === timeId)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was tun, wenn der Ausgang leer ist. Der Zeit-Store haengt sich hier ein,
|
||||||
|
* um seine vorläufigen Datensätze gegen die echten vom Server zu tauschen.
|
||||||
|
*/
|
||||||
|
onDrained(fn: () => void) {
|
||||||
|
this.drained = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
private drained: (() => void) | null = null
|
||||||
|
|
||||||
get isOnline(): boolean {
|
get isOnline(): boolean {
|
||||||
return this.online
|
return this.online
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +191,8 @@ export class TimeOutbox {
|
||||||
this.entries = this.entries.slice(1)
|
this.entries = this.entries.slice(1)
|
||||||
this.persist()
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.drained?.()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.error = e?.message ?? 'Die Übertragung ist fehlgeschlagen.'
|
this.error = e?.message ?? 'Die Übertragung ist fehlgeschlagen.'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ import type { TimesResponse, TimesStatusOptions } from '$lib/types'
|
||||||
|
|
||||||
const KEY = Symbol('time')
|
const KEY = Symbol('time')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorläufige Datensätze, die nur dieses Gerät kennt. Das Präfix macht sie
|
||||||
|
* überall erkennbar — eine PocketBase-ID hat 15 Zeichen aus [a-z0-9] und
|
||||||
|
* kann keinen Doppelpunkt enthalten.
|
||||||
|
*/
|
||||||
|
const LOCAL_PREFIX = 'local:'
|
||||||
|
|
||||||
export type TimeStatus = TimesStatusOptions
|
export type TimeStatus = TimesStatusOptions
|
||||||
|
|
||||||
export interface TimeWithDuration extends TimesResponse {
|
export interface TimeWithDuration extends TimesResponse {
|
||||||
|
|
@ -58,6 +65,13 @@ export class TimeStore {
|
||||||
private unsubscribe: (() => void) | null = null
|
private unsubscribe: (() => void) | null = null
|
||||||
private teams = getTeamContext()
|
private teams = getTeamContext()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Ist der Ausgang leer, sind alle vorläufigen Zeiten beim Server
|
||||||
|
// angekommen. Einmal neu laden tauscht sie gegen die echten — mit
|
||||||
|
// deren IDs, ohne die sich später nichts mehr korrigieren liesse.
|
||||||
|
timeOutbox.onDrained(() => void this.load())
|
||||||
|
}
|
||||||
|
|
||||||
get scoped(): TimesResponse[] {
|
get scoped(): TimesResponse[] {
|
||||||
const teamId = this.teams.activeId
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) return []
|
if (!teamId) return []
|
||||||
|
|
@ -122,11 +136,40 @@ export class TimeStore {
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isOffline(e)) throw e
|
if (!isOffline(e)) throw e
|
||||||
timeOutbox.queueStart({ at, stage: stageId, rider: riderId, team: teamId, startedBy })
|
|
||||||
|
// Der Start wandert in den Ausgang — und zugleich als vorläufiger
|
||||||
|
// Datensatz in die Liste. Ohne ihn stünde die laufende Zeit
|
||||||
|
// nirgends, und niemand könnte sie stoppen: Die Oberfläche kennt
|
||||||
|
// nur, was hier steht.
|
||||||
|
const localId = timeOutbox.queueStart({
|
||||||
|
at,
|
||||||
|
stage: stageId,
|
||||||
|
rider: riderId,
|
||||||
|
team: teamId,
|
||||||
|
startedBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
const draft = {
|
||||||
|
id: `${LOCAL_PREFIX}${localId}`,
|
||||||
|
stage: stageId,
|
||||||
|
rider: riderId,
|
||||||
|
team: teamId,
|
||||||
|
start: at,
|
||||||
|
status: 'active',
|
||||||
|
startedBy,
|
||||||
|
correction: 0,
|
||||||
|
} as unknown as TimesResponse
|
||||||
|
|
||||||
|
this.records = [...this.records, draft]
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gehört diese ID zu einem Datensatz, den nur dieses Gerät kennt? */
|
||||||
|
isLocal(timeId: string): boolean {
|
||||||
|
return timeId.startsWith(LOCAL_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeit stoppen. Wie beim Start gilt die abgeglichene Zeit des Drückens,
|
* Zeit stoppen. Wie beim Start gilt die abgeglichene Zeit des Drückens,
|
||||||
* und wie beim Start faengt der Ausgang auf, was das Netz nicht nimmt.
|
* und wie beim Start faengt der Ausgang auf, was das Netz nicht nimmt.
|
||||||
|
|
@ -135,6 +178,26 @@ export class TimeStore {
|
||||||
const at = clockSync.iso()
|
const at = clockSync.iso()
|
||||||
const stoppedBy = api.authStore.record?.id
|
const stoppedBy = api.authStore.record?.id
|
||||||
|
|
||||||
|
// Ein Stopp ist endgültig, auch wenn er noch im Ausgang liegt. Ohne
|
||||||
|
// diese Sperre liesse sich dieselbe Zeit im Funkloch zweimal stoppen,
|
||||||
|
// und beim Übertragen gewänne der zweite Druck — die falsche Zeit.
|
||||||
|
if (timeOutbox.hasPendingStop(timeId)) {
|
||||||
|
throw new Error('Diese Zeit ist bereits gestoppt und wartet auf die Übertragung.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Lauf, den nur dieses Gerät kennt, kann gar nicht am Server
|
||||||
|
// gestoppt werden — sein Start ist dort noch nicht angekommen.
|
||||||
|
if (this.isLocal(timeId)) {
|
||||||
|
timeOutbox.queueStop({
|
||||||
|
at,
|
||||||
|
status,
|
||||||
|
startLocalId: timeId.slice(LOCAL_PREFIX.length),
|
||||||
|
stoppedBy,
|
||||||
|
})
|
||||||
|
this.markStopped(timeId, at, status, stoppedBy)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await api.collection('times').update(timeId, {
|
return await api.collection('times').update(timeId, {
|
||||||
stop: at,
|
stop: at,
|
||||||
|
|
@ -143,11 +206,28 @@ export class TimeStore {
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isOffline(e)) throw e
|
if (!isOffline(e)) throw e
|
||||||
|
|
||||||
timeOutbox.queueStop({ at, status, time: timeId, stoppedBy })
|
timeOutbox.queueStop({ at, status, time: timeId, stoppedBy })
|
||||||
|
// Sofort auch hier eintragen, sonst stünde die Zeit weiter als
|
||||||
|
// laufend da und liesse sich ein zweites Mal stoppen.
|
||||||
|
this.markStopped(timeId, at, status, stoppedBy)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Zeit vorläufig als gestoppt führen, solange der Server nichts davon
|
||||||
|
* weiss. Beim Übertragen kommt der echte Datensatz nach und ersetzt sie.
|
||||||
|
*/
|
||||||
|
private markStopped(timeId: string, at: string, status: TimeStatus, stoppedBy?: string) {
|
||||||
|
const idx = this.records.findIndex((t) => t.id === timeId)
|
||||||
|
if (idx === -1) return
|
||||||
|
|
||||||
|
const next = [...this.records]
|
||||||
|
next[idx] = { ...next[idx], stop: at, status, stoppedBy } as TimesResponse
|
||||||
|
this.records = next
|
||||||
|
}
|
||||||
|
|
||||||
async applyCorrection(timeId: string, seconds: number) {
|
async applyCorrection(timeId: string, seconds: number) {
|
||||||
return await api.collection('times').update(timeId, { correction: seconds })
|
return await api.collection('times').update(timeId, { correction: seconds })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,12 @@
|
||||||
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 * 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'
|
||||||
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
||||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users } from 'lucide-svelte'
|
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users, Wifi, WifiOff } from 'lucide-svelte'
|
||||||
import { toggleMode, mode } from 'mode-watcher'
|
import { toggleMode, mode } from 'mode-watcher'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
|
||||||
|
|
@ -67,6 +69,10 @@
|
||||||
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
||||||
trailComments.load(),
|
trailComments.load(),
|
||||||
])
|
])
|
||||||
|
// Die Verbindungsprüfung laeuft, solange das Dashboard steht: Sie
|
||||||
|
// ist die Grundlage dafuer, dass niemand ins Leere stoppt.
|
||||||
|
connection.start()
|
||||||
|
|
||||||
invites.subscribe()
|
invites.subscribe()
|
||||||
events.subscribe()
|
events.subscribe()
|
||||||
stages.subscribe()
|
stages.subscribe()
|
||||||
|
|
@ -82,6 +88,7 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
connection.stop()
|
||||||
teams.destroy()
|
teams.destroy()
|
||||||
invites.destroy()
|
invites.destroy()
|
||||||
events.destroy()
|
events.destroy()
|
||||||
|
|
@ -243,6 +250,27 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<span class="flex shrink-0 items-center gap-1">
|
<span class="flex shrink-0 items-center gap-1">
|
||||||
|
<!--
|
||||||
|
Der Zustand der Verbindung steht dauerhaft
|
||||||
|
da und nicht erst, wenn etwas schiefgeht:
|
||||||
|
Wer gleich eine Stage nimmt, will vorher
|
||||||
|
wissen, woran er ist.
|
||||||
|
-->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7"
|
||||||
|
onclick={() => connection.check()}
|
||||||
|
tooltip={connection.online
|
||||||
|
? 'Verbindung steht'
|
||||||
|
: 'Keine Verbindung zum Server — hier klicken, um erneut zu versuchen'}
|
||||||
|
>
|
||||||
|
{#if connection.online}
|
||||||
|
<Wifi class="h-4 w-4 text-muted-foreground" />
|
||||||
|
{:else}
|
||||||
|
<WifiOff class="h-4 w-4 text-amber-600" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -368,6 +396,8 @@
|
||||||
{/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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue