Das Schema traegt es bereits: times.comment gibt es seit dem ersten Snapshot und wurde von der Anwendung nie benutzt. Der Grund steht jetzt dort — optional, aber nach dem Event ist eine Strafe ohne Grund schwer zu verteidigen. Er haengt im Tooltip an der Korrektur und steht als eigene Spalte im CSV. "Strafzeit fuer alle" ist raus. Eine Strafe trifft einen Fahrer; was fuer alle gleich gilt, ist keine Strafe, sondern eine andere Stage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
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<TimesResponse[]>([])
|
|
loading = $state(false)
|
|
error = $state<string | null>(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<void>((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<TimeStore>(KEY)
|
|
}
|