stammtisch-hersbruck/frontend/src/lib/stores/times.svelte.ts
Daniel Michelberger b80b1198fc feat: Grund zur Zeitkorrektur, "Strafzeit fuer alle" entfaellt
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
2026-09-07 23:30:24 +02:00

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)
}