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:
Daniel Michelberger 2026-09-07 23:15:35 +02:00
parent 6c2addb020
commit 660e72717a
5 changed files with 341 additions and 2 deletions

View 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}

View 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()

View file

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

View file

@ -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 })
} }

View file

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