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' 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 interface TimeWithDuration extends TimesResponse { duration?: number formattedTime?: string } /** * `now` steckt als Parameter drin, damit laufende Zeiten live weiterzählen: * Eine Ableitung, die einen tickenden Zeitwert hereinreicht, rechnet bei * jedem Tick neu. Ohne den Parameter wäre `Date.now()` für Svelte keine * Abhängigkeit und die Anzeige stünde still. */ function enrich(t: TimesResponse, now: number): TimeWithDuration { if (!t.start) return t const start = new Date(t.start).getTime() const end = t.stop ? new Date(t.stop).getTime() : now let duration = (end - start) / 1000 if (t.correction) duration += t.correction return { ...t, duration, formattedTime: formatDuration(duration), } } /** * 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) error = $state(null) private unsubscribe: (() => void) | null = null 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[] { const teamId = this.teams.activeId if (!teamId) return [] return this.records.filter((r) => r.team === teamId) } async load() { this.loading = true this.error = null try { this.records = await api.collection('times').getFullList({ sort: '-created', requestKey: null, }) } catch (e: any) { this.error = e.message ?? 'Fehler beim Laden der Zeiten' console.error(e) } finally { this.loading = false } } subscribe() { if (this.unsubscribe) return api.collection('times').subscribe('*', (e) => { const idx = this.records.findIndex((r) => r.id === e.record.id) if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) }).then((unsub) => { this.unsubscribe = unsub }).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') 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 // 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 } } /** 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, * und wie beim Start faengt der Ausgang auf, was das Netz nicht nimmt. */ async stop(timeId: string, status: TimeStatus = 'finished' as TimesStatusOptions) { const at = clockSync.iso() 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 { 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 }) // 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 } } /** * 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 } /** * Zeitkorrektur setzen, mit optionalem Grund. * * Der Grund steht im vorhandenen Feld `comment`. Eine Strafe ohne Grund * ist nach dem Event nicht mehr zu verteidigen — Pflicht ist er trotzdem * nicht: An der Strecke zaehlt, dass die Zahl stimmt. */ async applyCorrection(timeId: string, seconds: number, comment?: string) { return await api.collection('times').update(timeId, { correction: seconds, comment: comment?.trim() ?? '', }) } async updateStatus(timeId: string, status: TimeStatus) { return await api.collection('times').update(timeId, { status }) } remove(record: TimesResponse) { return new Promise((resolve, reject) => { app.confirm.request({ title: 'Zeit löschen?', text: 'Möchten Sie diese Zeit wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.', yes: async () => { try { await api.collection('times').delete(record.id) app.confirm.close() resolve() } catch (e) { app.confirm.close() reject(e) } }, no: () => { app.confirm.close() resolve() }, }) }) } getByStage(stageId: string, now = Date.now()): TimeWithDuration[] { return this.scoped.filter((r) => r.stage === stageId).map((r) => enrich(r, now)) } getByRider(riderId: string, now = Date.now()): TimeWithDuration[] { return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now)) } /** Laufende Zeiten einer Stage — die am längsten laufende zuerst. */ runningByStage(stageId: string, now = Date.now()): TimeWithDuration[] { return this.getByStage(stageId, now) .filter((r) => r.status === 'active') .sort((a, b) => (a.start < b.start ? -1 : 1)) } /** * Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf, * dürfen aber auch nicht unsichtbar werden. */ unrankedByStage(stageId: string): TimeWithDuration[] { return this.getByStage(stageId).filter( (r) => r.status !== 'active' && r.status !== 'finished', ) } isRiderActive(stageId: string, riderId: string) { return this.scoped.some( (r) => r.stage === stageId && r.rider === riderId && r.status === 'active', ) } getActiveTime(stageId: string, riderId: string) { return this.scoped.find( (r) => r.stage === stageId && r.rider === riderId && r.status === 'active', ) } get running(): TimeWithDuration[] { return this.allRunning() } /** * Alle laufenden Zeiten des Teams, stage-übergreifend — der am längsten * laufende zuerst. `now` wie bei `getByStage`, damit die Anzeige mitzählt. */ allRunning(now = Date.now()): TimeWithDuration[] { return this.scoped .filter((r) => r.status === 'active') .map((r) => enrich(r, now)) .sort((a, b) => (a.start < b.start ? -1 : 1)) } get finished(): TimeWithDuration[] { const now = Date.now() return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now)) } getLeaderboard(stageId: string): TimeWithDuration[] { return this.getByStage(stageId) .filter((r) => r.status === 'finished' && r.duration !== undefined) .sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0)) } /** Platz eines Fahrers im Stage, 1-basiert; null ohne gewertete Zeit. */ placement(stageId: string, riderId: string): number | null { const idx = this.getLeaderboard(stageId).findIndex((t) => t.rider === riderId) return idx === -1 ? null : idx + 1 } destroy() { if (this.unsubscribe) { this.unsubscribe() this.unsubscribe = null } } } export function setTimeContext() { const store = new TimeStore() setContext(KEY, store) return store } export function getTimeContext(): TimeStore { return getContext(KEY) }