+
+
+
+ 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 @@
+
+