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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
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<OutboxEntry[]>([])
|
|
/** Läuft gerade eine Übertragung? */
|
|
flushing = $state(false)
|
|
/** Was beim letzten Versuch schiefging — bleibt stehen, bis es klappt. */
|
|
error = $state<string | null>(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<Extract<OutboxEntry, { kind: 'start' }>, 'kind' | 'localId'>): string {
|
|
const localId = crypto.randomUUID()
|
|
this.add({ kind: 'start', localId, ...entry })
|
|
return localId
|
|
}
|
|
|
|
queueStop(entry: Omit<Extract<OutboxEntry, { kind: 'stop' }>, '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<void> {
|
|
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<string, string>()
|
|
|
|
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()
|