From 9f075f0f6c4bba89a88969210bee11743e52ecdb Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Mon, 7 Sep 2026 23:00:40 +0200 Subject: [PATCH] feat: Uhrenabgleich und Ausgang fuer Zeiten ohne Netz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Stage laesst sich nicht wiederholen. Zwei Dinge durften deshalb nicht so bleiben, wie sie waren. Erstens die Uhr. Zeiten entstehen auf den Geraeten an der Strecke — nur so laesst sich im Funkloch ueberhaupt stoppen. Damit steckte aber der Versatz zweier Telefonuhren in jeder Dauer, bei der einer am Start und ein anderer im Ziel drueckt. Jedes Geraet gleicht sich jetzt gegen den Server ab (Cristian: t0 merken, Serverzeit holen, t1 merken, Versatz = T + RTT/2 - t1), sieben Proben, Median ueber die schnellste Haelfte. Der Versatz liegt im localStorage und wird auf jeden Zeitstempel gerechnet. Dafuer eine eigene Route /api/clock in den Hooks: Der Date-Header jeder gewoehnlichen Antwort 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. Zweitens das Netz. Scheitert die Uebertragung eines Starts oder Stopps an einem Funkloch, wandert er in einen Ausgang im localStorage statt in einen Fehler und geht raus, sobald wieder Empfang da ist. Die massgebliche Zeit ist die des Druckens, nicht die der Uebertragung. Ein Stopp kann dabei auf einen Start warten, der selbst noch aussteht. Unterschieden wird streng zwischen Funkloch und Absage: Eine abgelehnte Anfrage — fehlende Rechte, ungueltige Daten — wird gemeldet und nicht aufgefangen, sonst sammelte der Ausgang stumm Eintraege, die auch beim naechsten Versuch scheitern. Die Leiste der laufenden Zeiten zeigt nicht uebertragene Zeiten ganz oben und erscheint dafuer auch dann, wenn gerade nichts laeuft. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr --- backend/pb_hooks/clock.pb.js | 21 +++ .../src/lib/components/ClockSyncDialog.svelte | 120 ++++++++++++ .../lib/components/RunningTimesToast.svelte | 54 +++++- frontend/src/lib/stores/clockSync.svelte.ts | 171 +++++++++++++++++ frontend/src/lib/stores/timeOutbox.svelte.ts | 177 ++++++++++++++++++ frontend/src/lib/stores/times.svelte.ts | 79 ++++++-- .../events/[id]/stages/[stageId]/+page.svelte | 25 ++- 7 files changed, 625 insertions(+), 22 deletions(-) create mode 100644 backend/pb_hooks/clock.pb.js create mode 100644 frontend/src/lib/components/ClockSyncDialog.svelte create mode 100644 frontend/src/lib/stores/clockSync.svelte.ts create mode 100644 frontend/src/lib/stores/timeOutbox.svelte.ts 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}