diff --git a/frontend/src/lib/components/OfflineBanner.svelte b/frontend/src/lib/components/OfflineBanner.svelte new file mode 100644 index 0000000..2595f66 --- /dev/null +++ b/frontend/src/lib/components/OfflineBanner.svelte @@ -0,0 +1,61 @@ + + +{#if !connection.online} +
+
+ + + + Keine Verbindung zum Server + {#if connection.offlineFor !== null} + + ({since(connection.offlineFor)}) + + {/if} + + + + 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} + + + +
+
+{/if} diff --git a/frontend/src/lib/stores/connection.svelte.ts b/frontend/src/lib/stores/connection.svelte.ts new file mode 100644 index 0000000..294aba4 --- /dev/null +++ b/frontend/src/lib/stores/connection.svelte.ts @@ -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(null) + /** Seit wann die Verbindung fehlt — für „seit 3 Minuten offline". */ + offlineSince = $state(null) + + private timer: ReturnType | 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 { + 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() diff --git a/frontend/src/lib/stores/timeOutbox.svelte.ts b/frontend/src/lib/stores/timeOutbox.svelte.ts index d1c95a6..bb9c96a 100644 --- a/frontend/src/lib/stores/timeOutbox.svelte.ts +++ b/frontend/src/lib/stores/timeOutbox.svelte.ts @@ -80,6 +80,31 @@ export class TimeOutbox { 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 { return this.online } @@ -166,6 +191,8 @@ export class TimeOutbox { this.entries = this.entries.slice(1) this.persist() } + + this.drained?.() } catch (e: any) { this.error = e?.message ?? 'Die Übertragung ist fehlgeschlagen.' } finally { diff --git a/frontend/src/lib/stores/times.svelte.ts b/frontend/src/lib/stores/times.svelte.ts index 2607425..dd340ae 100644 --- a/frontend/src/lib/stores/times.svelte.ts +++ b/frontend/src/lib/stores/times.svelte.ts @@ -9,6 +9,13 @@ import type { TimesResponse, TimesStatusOptions } from '$lib/types' 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 interface TimeWithDuration extends TimesResponse { @@ -58,6 +65,13 @@ export class TimeStore { private unsubscribe: (() => void) | null = null 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[] { const teamId = this.teams.activeId if (!teamId) return [] @@ -122,11 +136,40 @@ export class TimeStore { }) } catch (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 } } + /** 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, * 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 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 { return await api.collection('times').update(timeId, { stop: at, @@ -143,11 +206,28 @@ export class TimeStore { }) } catch (e) { if (!isOffline(e)) throw e + 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 } } + /** + * 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) { return await api.collection('times').update(timeId, { correction: seconds }) } diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 505b119..c0fbe96 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -21,10 +21,12 @@ 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' 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 { onDestroy, onMount } from 'svelte' @@ -67,6 +69,10 @@ trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.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() events.subscribe() stages.subscribe() @@ -82,6 +88,7 @@ }) onDestroy(() => { + connection.stop() teams.destroy() invites.destroy() events.destroy() @@ -243,6 +250,27 @@ + +