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

+
+ + + + + +
+
diff --git a/frontend/src/lib/components/RunningTimesToast.svelte b/frontend/src/lib/components/RunningTimesToast.svelte index 8dfad7e..f9ff685 100644 --- a/frontend/src/lib/components/RunningTimesToast.svelte +++ b/frontend/src/lib/components/RunningTimesToast.svelte @@ -17,9 +17,10 @@ import { getStageContext } from '$lib/stores/stages.svelte' import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte' import { ticker } from '$lib/stores/ticker.svelte' + import { timeOutbox } from '$lib/stores/timeOutbox.svelte' import TimeDialog from './TimeDialog.svelte' import { Button } from '@/components/ui/button' - import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte' + import { ChevronDown, ChevronUp, CloudOff, RefreshCw, Timer } from 'lucide-svelte' const times = getTimeContext() const riders = getRiderContext() @@ -69,18 +70,26 @@ } -{#if running.length > 0} +{#if running.length > 0 || timeOutbox.pending > 0}
- - - - + {#if running.length > 0} + + + + + {/if} - {running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`} + {#if running.length === 0} + Zeitnahme + {:else if running.length === 1} + Zeit läuft + {:else} + {running.length} Zeiten laufen + {/if} {#if !open} @@ -106,6 +115,37 @@ {/if}
+ + {#if timeOutbox.pending > 0} +
+ + + {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 @@
+ + {#if leaderboard.length > 0}