From 660e72717a9b54373e2f26b7cb5fed61a7e0e61f Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Mon, 7 Sep 2026 23:15:35 +0200 Subject: [PATCH] fix: offline gestoppte Zeit laesst sich nicht zweimal stoppen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr --- .../src/lib/components/OfflineBanner.svelte | 61 ++++++++ frontend/src/lib/stores/connection.svelte.ts | 141 ++++++++++++++++++ frontend/src/lib/stores/timeOutbox.svelte.ts | 27 ++++ frontend/src/lib/stores/times.svelte.ts | 82 +++++++++- frontend/src/routes/dashboard/+layout.svelte | 32 +++- 5 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/OfflineBanner.svelte create mode 100644 frontend/src/lib/stores/connection.svelte.ts 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 @@ + +