diff --git a/backend/pb_hooks/clock.pb.js b/backend/pb_hooks/clock.pb.js
new file mode 100644
index 0000000..96efe33
--- /dev/null
+++ b/backend/pb_hooks/clock.pb.js
@@ -0,0 +1,21 @@
+///
+
+// Die gemeinsame Uhr.
+//
+// Zeiten entstehen auf den Geraeten an der Strecke, nicht auf dem Server:
+// Nur so laesst sich im Funkloch ueberhaupt stoppen. Damit steckt aber der
+// Versatz zweier Telefonuhren in jeder Messung, bei der einer startet und ein
+// anderer stoppt. Also braucht es eine Referenz, gegen die sich jedes Geraet
+// vor dem Event abgleicht — und das ist diese Route.
+//
+// Warum nicht der `Date`-Header jeder beliebigen Antwort: Der hat nach
+// RFC 9110 Sekundenaufloesung. Allein daraus folgte ein Fehler von bis zu
+// einer halben Sekunde — auf einer Stage der Unterschied zwischen Platz eins
+// und Platz drei. Hier kommen Millisekunden.
+//
+// Ohne Login: Es ist die Uhrzeit. Sie verraet nichts, was nicht jede Antwort
+// des Servers ohnehin im Date-Header mitschickt.
+
+routerAdd('GET', '/api/clock', (e) => {
+ return e.json(200, { now: Date.now() })
+})
diff --git a/frontend/src/lib/components/ClockSyncDialog.svelte b/frontend/src/lib/components/ClockSyncDialog.svelte
new file mode 100644
index 0000000..681d4d0
--- /dev/null
+++ b/frontend/src/lib/components/ClockSyncDialog.svelte
@@ -0,0 +1,120 @@
+
+
+
+
+
+ Uhr abgleichen
+
+ Gestartet und gestoppt wird auf den Geräten an der Strecke. Damit die
+ Zeit stimmt, auch wenn Start und Ziel verschiedene Telefone sind,
+ stellt jedes Gerät seine Uhr einmal gegen den Server.
+
+
+
+
+
+ {#if good}
+
+ {:else}
+
+ {/if}
+
+
+ {#if !clockSync.measured}
+
Noch nicht abgeglichen
+
+ Bis dahin gilt die Uhr dieses Geräts, wie sie ist.
+
+ {:else}
+
+ Dieses Gerät geht
+ {#if Math.abs(clockSync.offset) < 50}
+ richtig
+ {:else if clockSync.offset < 0}
+ {ms(-clockSync.offset)} vor
+ {:else}
+ {ms(clockSync.offset)} nach
+ {/if}
+
+
+ Gemessen {age(clockSync.age ?? 0)}, auf ±{ms(clockSync.accuracy ?? 0)} genau.
+ Die Abweichung wird beim Stoppen herausgerechnet.
+
+
+ {#if clockSync.stale}
+
+ Die Messung ist älter als
+ {Math.round(STALE_AFTER_MS / 3600000)} Stunden — vor dem Start
+ noch einmal abgleichen.
+
+ {:else if (clockSync.accuracy ?? 0) > ACCURACY_LIMIT_MS}
+
+ Die Verbindung war zu langsam für eine genaue Messung. Mit
+ besserem Empfang noch einmal versuchen.
+
+ {/if}
+ {/if}
+
+ {#if clockSync.error}
+
{clockSync.error}
+ {/if}
+
+
+
+
+ Der Abgleich braucht Netz und dauert eine Sekunde. Danach zählt die
+ Zeitnahme auch im Funkloch richtig weiter — übertragen wird, sobald
+ wieder Empfang da ist.
+
+
+
+ {timeOutbox.pending}
+ {timeOutbox.pending === 1 ? 'Zeit liegt' : 'Zeiten liegen'} nur auf diesem
+ Gerät.
+ {#if timeOutbox.error}
+ {timeOutbox.error}
+ {:else if !timeOutbox.isOnline}
+ Kein Netz.
+ {/if}
+
+
+
+ {/if}
+
{#if open}
{#each running as t (t.id)}
diff --git a/frontend/src/lib/stores/clockSync.svelte.ts b/frontend/src/lib/stores/clockSync.svelte.ts
new file mode 100644
index 0000000..4c5d9e8
--- /dev/null
+++ b/frontend/src/lib/stores/clockSync.svelte.ts
@@ -0,0 +1,171 @@
+import { api } from './pocketbase.svelte'
+
+const KEY = 'clockOffset'
+
+/**
+ * Ab dieser Abweichung ist eine Messung nicht mehr gut genug für eine Stage.
+ * 250 ms, weil die Uhr auf Zehntel angezeigt wird und ein Fehler, der die
+ * angezeigte Stelle überschreitet, in der Rangliste sichtbar wird.
+ */
+export const ACCURACY_LIMIT_MS = 250
+
+/**
+ * Nach dieser Zeit gilt eine Messung als alt. Telefonuhren driften langsam
+ * (Größenordnung Sekunden pro Woche), aber ein Gerät kann zwischendurch in
+ * den Flugmodus, den Akku leeren oder die Zeitzone wechseln — nach vier
+ * Stunden lohnt der erneute Abgleich, er dauert eine Sekunde.
+ */
+export const STALE_AFTER_MS = 4 * 60 * 60 * 1000
+
+type Stored = { offset: number; measuredAt: number; accuracy: number }
+
+/**
+ * Der Uhrenabgleich zwischen diesem Gerät und dem Server.
+ *
+ * Gemessen wird nach Cristian: Zeitpunkt der Anfrage merken (t0), Serverzeit
+ * holen (T), Zeitpunkt der Antwort merken (t1). Unterwegs war die Antwort
+ * ungefähr die halbe Laufzeit, die Serverzeit im Moment t1 ist also etwa
+ * T + (t1 − t0) / 2, und der Versatz dieses Geräts ist die Differenz dazu.
+ *
+ * Das Verfahren ist nur so gut wie die Symmetrie der Strecke: Dauert der Weg
+ * hin länger als zurück, wandert der Schätzwert. Deshalb mehrere Proben und
+ * der Median über die mit der kürzesten Laufzeit — kurze Laufzeit heißt wenig
+ * Spielraum für Asymmetrie. Die halbe Laufzeit der besten Probe ist zugleich
+ * die Obergrenze des Fehlers und wird als `accuracy` mitgeführt.
+ *
+ * Bewusst kein Store je Team und kein Kontext: Der Versatz gehört dem Gerät,
+ * nicht der Anmeldung, und wird deshalb im localStorage gehalten und beim
+ * Start wieder aufgenommen.
+ */
+export class ClockSync {
+ /** Millisekunden, die zur Gerätezeit addiert werden müssen. */
+ offset = $state(0)
+ /** Wann zuletzt gemessen wurde (Gerätezeit). */
+ measuredAt = $state(null)
+ /** Geschätzte Obergrenze des verbleibenden Fehlers in Millisekunden. */
+ accuracy = $state(null)
+ busy = $state(false)
+ error = $state(null)
+
+ constructor() {
+ if (typeof localStorage === 'undefined') return
+
+ try {
+ const raw = localStorage.getItem(KEY)
+ if (!raw) return
+
+ const stored = JSON.parse(raw) as Stored
+ if (typeof stored.offset !== 'number' || !Number.isFinite(stored.offset)) return
+
+ this.offset = stored.offset
+ this.measuredAt = stored.measuredAt ?? null
+ this.accuracy = stored.accuracy ?? null
+ } catch {
+ // Kaputter Eintrag ist wie kein Eintrag: ohne Abgleich weiter.
+ }
+ }
+
+ /** Die abgeglichene Gegenwart in Millisekunden. */
+ now(): number {
+ return Date.now() + this.offset
+ }
+
+ /** Dieselbe Zeit als ISO-String — die Form, in der PocketBase sie will. */
+ iso(at: number = this.now()): string {
+ return new Date(at).toISOString()
+ }
+
+ /** Alter der Messung in Millisekunden, null wenn nie gemessen. */
+ get age(): number | null {
+ return this.measuredAt === null ? null : Date.now() - this.measuredAt
+ }
+
+ get measured(): boolean {
+ return this.measuredAt !== null
+ }
+
+ get stale(): boolean {
+ const age = this.age
+ return age === null || age > STALE_AFTER_MS
+ }
+
+ /** Ist die letzte Messung gut genug, um damit eine Stage zu nehmen? */
+ get trustworthy(): boolean {
+ return this.measured && !this.stale && (this.accuracy ?? Infinity) <= ACCURACY_LIMIT_MS
+ }
+
+ /**
+ * Gegen den Server abgleichen. `rounds` Proben, die schlechteren fliegen
+ * raus; scheitert jede, bleibt der bisherige Versatz stehen — ein alter
+ * Wert ist besser als gar keiner.
+ */
+ async measure(rounds = 7): Promise {
+ if (this.busy) return
+
+ this.busy = true
+ this.error = null
+
+ const samples: { offset: number; rtt: number }[] = []
+
+ try {
+ for (let i = 0; i < rounds; i++) {
+ const t0 = Date.now()
+
+ const res = await fetch(`${api.baseURL}/api/clock`, { cache: 'no-store' })
+ if (!res.ok) throw new Error(`Server antwortet mit ${res.status}`)
+
+ const t1 = Date.now()
+ const body = (await res.json()) as { now?: number }
+ if (typeof body.now !== 'number') throw new Error('Antwort ohne Zeit')
+
+ const rtt = t1 - t0
+ samples.push({ offset: body.now + rtt / 2 - t1, rtt })
+ }
+
+ if (samples.length === 0) throw new Error('Keine Messung zustande gekommen')
+
+ // Die schnellste Hälfte zählt: Wenig Laufzeit heißt wenig Raum für
+ // eine ungleiche Verteilung zwischen Hin- und Rückweg.
+ samples.sort((a, b) => a.rtt - b.rtt)
+ const best = samples.slice(0, Math.max(1, Math.ceil(samples.length / 2)))
+
+ const offsets = best.map((s) => s.offset).sort((a, b) => a - b)
+ const mid = Math.floor(offsets.length / 2)
+ const median =
+ offsets.length % 2 === 1 ? offsets[mid] : (offsets[mid - 1] + offsets[mid]) / 2
+
+ this.offset = Math.round(median)
+ this.accuracy = Math.round(best[0].rtt / 2)
+ this.measuredAt = Date.now()
+ this.persist()
+ } catch (e: any) {
+ this.error = e?.message ?? 'Der Abgleich ist fehlgeschlagen.'
+ } finally {
+ this.busy = false
+ }
+ }
+
+ private persist() {
+ if (typeof localStorage === 'undefined') return
+ if (this.measuredAt === null || this.accuracy === null) return
+
+ try {
+ const stored: Stored = {
+ offset: this.offset,
+ measuredAt: this.measuredAt,
+ accuracy: this.accuracy,
+ }
+ localStorage.setItem(KEY, JSON.stringify(stored))
+ } catch {
+ // Kein Speicher (privates Fenster, volle Quote): Der Versatz gilt
+ // dann nur für diese Sitzung. Besser als gar kein Abgleich.
+ }
+ }
+}
+
+/**
+ * Eine Instanz für die ganze App. Der Versatz gehört dem Gerät — ein zweiter
+ * Abgleich in einer zweiten Komponente wäre nicht nur überflüssig, er würde
+ * sich mit dem ersten widersprechen.
+ */
+export const clockSync = new ClockSync()
diff --git a/frontend/src/lib/stores/timeOutbox.svelte.ts b/frontend/src/lib/stores/timeOutbox.svelte.ts
new file mode 100644
index 0000000..d1c95a6
--- /dev/null
+++ b/frontend/src/lib/stores/timeOutbox.svelte.ts
@@ -0,0 +1,177 @@
+import { api } from './pocketbase.svelte'
+import type { TimesStatusOptions } from '$lib/types'
+
+const KEY = 'timeOutbox'
+
+/**
+ * Ein Start oder ein Stopp, der noch nicht beim Server ist.
+ *
+ * `at` ist die abgeglichene Zeit im Moment des Drückens — nicht die des
+ * Übertragens. Darin liegt der ganze Sinn: Wann die Nachricht rausgeht, darf
+ * die gemessene Zeit nicht berühren.
+ *
+ * Ein Stopp trägt kein `time`, wenn die Zeit selbst noch im Ausgang liegt
+ * (offline gestartet und offline gestoppt). Er zeigt dann über `localId` auf
+ * den Start und wird erst beim Übertragen aufgelöst.
+ */
+export type OutboxEntry =
+ | {
+ kind: 'start'
+ localId: string
+ at: string
+ stage: string
+ rider: string
+ team: string
+ startedBy?: string
+ }
+ | {
+ kind: 'stop'
+ localId: string
+ at: string
+ status: TimesStatusOptions
+ /** ID der Zeit auf dem Server — fehlt, wenn der Start noch aussteht. */
+ time?: string
+ /** Start aus diesem Ausgang, auf den sich der Stopp bezieht. */
+ startLocalId?: string
+ stoppedBy?: string
+ }
+
+/**
+ * Was an der Strecke gedrückt wurde und noch nicht beim Server ankam.
+ *
+ * Eine Stage lässt sich nicht wiederholen. Ein Funkloch im falschen Moment
+ * darf deshalb nicht bedeuten, dass eine Zeit verloren ist — gedrückt wird
+ * ins localStorage, übertragen wird, sobald es geht. Die Reihenfolge bleibt
+ * erhalten: Ein Stopp kann auf einen Start warten, der selbst noch aussteht.
+ *
+ * Bewusst kein Kontext-Store: Der Ausgang gehört dem Gerät und überlebt
+ * Seitenwechsel, Neustart und Abmeldung.
+ */
+export class TimeOutbox {
+ entries = $state([])
+ /** Läuft gerade eine Übertragung? */
+ flushing = $state(false)
+ /** Was beim letzten Versuch schiefging — bleibt stehen, bis es klappt. */
+ error = $state(null)
+
+ private online = $state(true)
+
+ constructor() {
+ if (typeof localStorage !== 'undefined') {
+ try {
+ const raw = localStorage.getItem(KEY)
+ if (raw) this.entries = JSON.parse(raw) as OutboxEntry[]
+ } catch {
+ // Kaputter Ausgang: lieber leer weiter als beim Start scheitern.
+ }
+ }
+
+ if (typeof window !== 'undefined') {
+ this.online = navigator.onLine
+ window.addEventListener('online', () => {
+ this.online = true
+ void this.flush()
+ })
+ window.addEventListener('offline', () => (this.online = false))
+ }
+ }
+
+ get pending(): number {
+ return this.entries.length
+ }
+
+ get isOnline(): boolean {
+ return this.online
+ }
+
+ private persist() {
+ if (typeof localStorage === 'undefined') return
+ try {
+ localStorage.setItem(KEY, JSON.stringify(this.entries))
+ } catch {
+ // Ohne Speicher bleibt der Ausgang in dieser Sitzung — dann muss
+ // die Übertragung klappen, bevor jemand die Seite schliesst.
+ }
+ }
+
+ private add(entry: OutboxEntry) {
+ this.entries = [...this.entries, entry]
+ this.persist()
+ }
+
+ /** Start vormerken. Gibt die lokale ID zurück, damit ein Stopp sie findet. */
+ queueStart(entry: Omit, 'kind' | 'localId'>): string {
+ const localId = crypto.randomUUID()
+ this.add({ kind: 'start', localId, ...entry })
+ return localId
+ }
+
+ queueStop(entry: Omit, 'kind' | 'localId'>): string {
+ const localId = crypto.randomUUID()
+ this.add({ kind: 'stop', localId, ...entry })
+ return localId
+ }
+
+ /**
+ * Alles übertragen, was geht — der Reihe nach, denn ein Stopp kann auf
+ * seinen Start warten. Der erste Fehler bricht ab: Ist das Netz weg, ist
+ * es auch für den nächsten Eintrag weg, und die Reihenfolge muss halten.
+ */
+ async flush(): Promise {
+ if (this.flushing || this.entries.length === 0) return
+
+ this.flushing = true
+ this.error = null
+
+ // Zeit-IDs, die in diesem Durchlauf entstanden sind — ein Stopp aus
+ // dem Ausgang kennt nur die lokale ID seines Starts.
+ const resolved = new Map()
+
+ try {
+ while (this.entries.length > 0) {
+ const entry = this.entries[0]
+
+ if (entry.kind === 'start') {
+ const record = await api.collection('times').create({
+ stage: entry.stage,
+ rider: entry.rider,
+ team: entry.team,
+ start: entry.at,
+ status: 'active',
+ startedBy: entry.startedBy,
+ correction: 0,
+ })
+ resolved.set(entry.localId, record.id)
+ } else {
+ const timeId =
+ entry.time ??
+ (entry.startLocalId ? resolved.get(entry.startLocalId) : undefined)
+
+ if (!timeId) {
+ // Der zugehörige Start ist weder auf dem Server noch in
+ // diesem Durchlauf entstanden. Weiterreichen wäre ein
+ // stiller Verlust, also stehen lassen und melden.
+ throw new Error(
+ 'Zu einem gestoppten Lauf fehlt der Start. Die Zeit bleibt im Ausgang.',
+ )
+ }
+
+ await api.collection('times').update(timeId, {
+ stop: entry.at,
+ status: entry.status,
+ stoppedBy: entry.stoppedBy,
+ })
+ }
+
+ this.entries = this.entries.slice(1)
+ this.persist()
+ }
+ } catch (e: any) {
+ this.error = e?.message ?? 'Die Übertragung ist fehlgeschlagen.'
+ } finally {
+ this.flushing = false
+ }
+ }
+}
+
+export const timeOutbox = new TimeOutbox()
diff --git a/frontend/src/lib/stores/times.svelte.ts b/frontend/src/lib/stores/times.svelte.ts
index e46b5b4..2607425 100644
--- a/frontend/src/lib/stores/times.svelte.ts
+++ b/frontend/src/lib/stores/times.svelte.ts
@@ -2,6 +2,8 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
+import { clockSync } from './clockSync.svelte'
+import { timeOutbox } from './timeOutbox.svelte'
import { formatDuration } from '$lib/time'
import type { TimesResponse, TimesStatusOptions } from '$lib/types'
@@ -33,6 +35,21 @@ function enrich(t: TimesResponse, now: number): TimeWithDuration {
}
}
+/**
+ * War das ein Funkloch oder eine Absage?
+ *
+ * Nur beim Funkloch darf der Ausgang auffangen. Eine abgelehnte Anfrage —
+ * fehlende Rechte, ungültige Daten — gehoert dem Aufrufer gemeldet, sonst
+ * sammelte der Ausgang stumm Eintraege, die auch beim naechsten Versuch
+ * scheitern. PocketBase setzt bei Netzfehlern `status` auf 0.
+ */
+function isOffline(e: unknown): boolean {
+ if (typeof navigator !== 'undefined' && !navigator.onLine) return true
+
+ const status = (e as { status?: number } | null)?.status
+ return status === 0 || status === undefined
+}
+
export class TimeStore {
records = $state([])
loading = $state(false)
@@ -75,26 +92,60 @@ export class TimeStore {
}).catch((e) => console.warn('times subscribe failed:', e))
}
+ /**
+ * Zeit starten.
+ *
+ * Der Zeitstempel kommt aus dem Uhrenabgleich, nicht aus `new Date()`:
+ * Start und Stopp geschehen oft auf verschiedenen Geräten, und deren
+ * Uhrenversatz steckte sonst ungeprüft in jeder gemessenen Dauer.
+ *
+ * Scheitert die Übertragung, wandert der Start in den Ausgang statt in
+ * einen Fehler. Eine Stage lässt sich nicht wiederholen — im Funkloch
+ * gedrückt bleibt gedrückt, übertragen wird später.
+ */
async start(stageId: string, riderId: string) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
- return await api.collection('times').create({
- stage: stageId,
- rider: riderId,
- team: teamId,
- start: new Date().toISOString(),
- status: 'active' as TimesStatusOptions,
- startedBy: api.authStore.record?.id,
- correction: 0,
- })
+
+ const at = clockSync.iso()
+ const startedBy = api.authStore.record?.id
+
+ try {
+ return await api.collection('times').create({
+ stage: stageId,
+ rider: riderId,
+ team: teamId,
+ start: at,
+ status: 'active' as TimesStatusOptions,
+ startedBy,
+ correction: 0,
+ })
+ } catch (e) {
+ if (!isOffline(e)) throw e
+ timeOutbox.queueStart({ at, stage: stageId, rider: riderId, team: teamId, startedBy })
+ return null
+ }
}
+ /**
+ * 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.
+ */
async stop(timeId: string, status: TimeStatus = 'finished' as TimesStatusOptions) {
- return await api.collection('times').update(timeId, {
- stop: new Date().toISOString(),
- status,
- stoppedBy: api.authStore.record?.id,
- })
+ const at = clockSync.iso()
+ const stoppedBy = api.authStore.record?.id
+
+ try {
+ return await api.collection('times').update(timeId, {
+ stop: at,
+ status,
+ stoppedBy,
+ })
+ } catch (e) {
+ if (!isOffline(e)) throw e
+ timeOutbox.queueStop({ at, status, time: timeId, stoppedBy })
+ return null
+ }
}
async applyCorrection(timeId: string, seconds: number) {
diff --git a/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte b/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte
index 9508c92..4a51a8e 100644
--- a/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte
+++ b/frontend/src/routes/dashboard/events/[id]/stages/[stageId]/+page.svelte
@@ -14,12 +14,14 @@
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time'
+ import { clockSync } from '$lib/stores/clockSync.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte'
+ import ClockSyncDialog from '$lib/components/ClockSyncDialog.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table'
- import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
+ import { ArrowLeft, Clock, Download, Flag, Timer, Trophy, TriangleAlert } from 'lucide-svelte'
const events = getEventContext()
const stages = getStageContext()
@@ -64,6 +66,7 @@
// Start und Stopp laufen ausschließlich über TimeDialog; die Stage ist
// dabei vorbelegt, den Fahrer wählt man dort.
let timeDialog = $state(false)
+ let clockDialog = $state(false)
// --- Export ------------------------------------------------------------
function exportCsv() {
@@ -127,6 +130,25 @@