refactor: Aus Runs werden Stages, bis in die Datenbank

"Run" hiess in dieser App immer schon der abgesteckte Abschnitt eines
Events, auf dem gefahren und gestoppt wird — also das, was im Rennsport
Stage heisst. "Run" ist daneben der einzelne Durchgang eines Fahrers,
und genau der steht hier als `times`. Zwei Bedeutungen fuer ein Wort, an
einer Stelle, an der beide vorkommen.

Die Migration benennt um statt neu anzulegen: Collection und Feld
behalten ihre IDs, PocketBase benennt Tabelle und Spalte um, die Daten
bleiben stehen. Gesucht wird ueber die Collection-ID und nicht ueber den
Namen, damit sie auch auf einer frischen Datenbank durchlaeuft, deren
Snapshot die Collection bereits `stages` nennt.

Im Frontend wandert `times.run` zu `times.stage`, der Store heisst
stages.svelte.ts, und aus /events/[id]/runs/[runId] wird
/events/[id]/stages/[stageId].

Nicht umbenannt: `running`, `allRunning` und RunningTimesToast. Das sind
laufende Zeiten und keine Stages — dieselben Buchstaben, andere Sache.

Und die Artikel: Der Run war maskulin, die Stage ist feminin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:16:22 +02:00
parent 3120bbfdbf
commit 893509be9d
17 changed files with 261 additions and 215 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, runs, riders, times and teams for a motorsport/cycling event organization.
stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization.
The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`.
@ -60,7 +60,7 @@ docker compose down
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
`backend/README.md`.
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
- **Collections**: users, teams, team_invites, events, runs, riders, times,
- **Collections**: users, teams, team_invites, events, stages, riders, times,
trails, trail_versions, trail_flags, trail_markers, trail_comments,
event_participants, event_series
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit

View file

@ -21,7 +21,7 @@ Damit das Frontend gegen diese Instanz läuft, in `../frontend/.env`
Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim
Start automatisch angewendet. Es enthält die sechs fachlichen Collections
`users`, `teams`, `events`, `runs`, `riders`, `times`.
`users`, `teams`, `events`, `stages`, `riders`, `times`.
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
per `GET /api/collections` abgezogen und ruft `importCollections` mit

View file

@ -588,7 +588,7 @@ migrate((app) => {
"createRule": "team.users.id ?= @request.auth.id",
"updateRule": "team.users.id ?= @request.auth.id",
"deleteRule": "team.users.id ?= @request.auth.id",
"name": "runs",
"name": "stages",
"type": "base",
"fields": [
{
@ -916,7 +916,7 @@ migrate((app) => {
"id": "ltfephwe",
"maxSelect": 1,
"minSelect": 0,
"name": "run",
"name": "stage",
"presentable": false,
"required": false,
"system": false,

View file

@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
// Aus einem Run wird eine Stage.
//
// „Run" hiess in dieser App immer schon der abgesteckte Abschnitt eines
// Events, auf dem gefahren und gestoppt wird — also das, was im Rennsport
// Stage heisst. „Run" ist daneben der einzelne Durchgang eines Fahrers, und
// genau der steht hier als `times`. Zwei Bedeutungen fuer ein Wort, an einer
// Stelle, an der beide vorkommen.
//
// Umbenannt wird, nicht neu angelegt: Collection und Feld behalten ihre IDs,
// PocketBase benennt Tabelle und Spalte um, die Daten bleiben stehen. Ueber
// die ID gesucht statt ueber den Namen, damit die Migration auch auf einer
// frischen Datenbank durchlaeuft, deren Snapshot die Collection bereits
// `stages` nennt.
const STAGES_COLLECTION_ID = 'jo49lycyfs2sz2c'
migrate((app) => {
const stages = app.findCollectionByNameOrId(STAGES_COLLECTION_ID)
if (stages.name !== 'stages') {
stages.name = 'stages'
app.save(stages)
}
const times = app.findCollectionByNameOrId('times')
const field = times.fields.getByName('run')
if (field) {
field.name = 'stage'
app.save(times)
}
}, (app) => {
const times = app.findCollectionByNameOrId('times')
const field = times.fields.getByName('stage')
if (field) {
field.name = 'run'
app.save(times)
}
const stages = app.findCollectionByNameOrId(STAGES_COLLECTION_ID)
if (stages.name !== 'runs') {
stages.name = 'runs'
app.save(stages)
}
})

View file

@ -18,7 +18,7 @@
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
</script>
<div class="relative {className} hover:scale-110 hover:rotate-3 transition-transform" id="logo">
<div class="relative {className}" id="logo">
{#if url}
<img alt="" class="absolute h-full w-full object-contain" src={url} />
{:else}

View file

@ -3,17 +3,17 @@
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Run-Seite.
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-Seite.
*
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
*
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
* Zeit-Dialog mit Fahrer und Run vorbelegt.
* Zeit-Dialog mit Fahrer und Stage vorbelegt.
*/
import { getTimeContext } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import TimeDialog from './TimeDialog.svelte'
@ -22,7 +22,7 @@
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const stages = getStageContext()
const participants = getEventParticipantContext()
const clock = ticker()
@ -35,15 +35,15 @@
let timeDialog = $state(false)
let dialogEvent = $state('')
let dialogRider = $state<string | null>(null)
let dialogRun = $state<string | null>(null)
let dialogStage = $state<string | null>(null)
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
if (!run?.event) return
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
if (!stage?.event) return
dialogEvent = run.event
dialogEvent = stage.event
dialogRider = riderId ?? null
dialogRun = run.id
dialogStage = stage.id
timeDialog = true
}
@ -51,9 +51,9 @@
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
const open = $derived(single || expanded)
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
function riderLabel(riderId: string | undefined, runId: string | undefined) {
const eventId = runId ? runs.getById(runId)?.event : undefined
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
function riderLabel(riderId: string | undefined, stageId: string | undefined) {
const eventId = stageId ? stages.getById(stageId)?.event : undefined
if (!eventId) {
const r = riderId ? riders.getById(riderId) : undefined
return r ? riderName(r) : 'Unbekannter Fahrer'
@ -61,15 +61,15 @@
return participants.riderLabel(eventId, riderId)
}
function runPath(runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
if (!run?.event) return null
return `/dashboard/events/${run.event}/runs/${run.id}`
function stagePath(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
if (!stage?.event) return null
return `/dashboard/events/${stage.event}/stages/${stage.id}`
}
function runLabel(runId: string | undefined) {
const run = runId ? runs.getById(runId) : undefined
return run?.description || 'Run'
function stageLabel(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined
return stage?.description || 'Stage'
}
</script>
@ -113,15 +113,15 @@
{#if open}
<ul class="divide-y max-h-[50vh] overflow-y-auto">
{#each running as t (t.id)}
{@const path = runPath(t.run)}
{@const path = stagePath(t.stage)}
<li class="flex items-center gap-2 px-3 py-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.run)}</p>
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.stage)}</p>
<p class="text-xs text-muted-foreground truncate">
{#if path}
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a>
{:else}
{runLabel(t.run)}
{stageLabel(t.stage)}
{/if}
</p>
</div>
@ -135,7 +135,7 @@
size="icon"
class="shrink-0"
tooltip="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.run)}
onclick={() => openTimeDialog(t.rider, t.stage)}
>
<Timer class="size-4" />
</Button>
@ -150,6 +150,6 @@
bind:open={timeDialog}
eventId={dialogEvent}
riderId={dialogRider}
runId={dialogRun}
stageId={dialogStage}
/>
{/if}

View file

@ -1,6 +1,6 @@
<script lang="ts">
/**
* Der eine Dialog für die Zeitnahme. Fahrer und Run stehen immer beide
* Der eine Dialog für die Zeitnahme. Fahrer und Stage stehen immer beide
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
@ -8,7 +8,7 @@
*/
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { ticker } from '$lib/stores/ticker.svelte'
import { Button } from '@/components/ui/button'
@ -21,24 +21,24 @@
open = $bindable(false),
eventId,
riderId = null,
runId = null,
stageId = null,
}: {
open?: boolean
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
/** Event, aus dem Fahrer und Stages zur Auswahl stehen. */
eventId: string
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
riderId?: string | null
runId?: string | null
stageId?: string | null
} = $props()
const times = getTimeContext()
const riders = getRiderContext()
const runs = getRunContext()
const stages = getStageContext()
const participants = getEventParticipantContext()
const clock = ticker()
let selectedRider = $state('')
let selectedRun = $state('')
let selectedStage = $state('')
let error = $state<string | null>(null)
let confirmDnf = $state(false)
let busy = $state(false)
@ -49,7 +49,7 @@
if (!open) return
selectedRider = riderId ?? ''
selectedRun = runId ?? ''
selectedStage = stageId ?? ''
error = null
confirmDnf = false
})
@ -63,16 +63,16 @@
.filter((r) => !!r)
: [],
)
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
const riderLabel = $derived(
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
)
const runLabel = $derived(
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
const stageLabel = $derived(
selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…',
)
const ready = $derived(!!selectedRider && !!selectedRun)
const ready = $derived(!!selectedRider && !!selectedStage)
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
@ -80,14 +80,14 @@
ready
? times
.allRunning(clock.now)
.find((t) => t.run === selectedRun && t.rider === selectedRider)
.find((t) => t.stage === selectedStage && t.rider === selectedRider)
: undefined,
)
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
const existing = $derived(
ready && !active
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
? times.getByStage(selectedStage).find((t) => t.rider === selectedRider && t.stop)
: undefined,
)
@ -97,7 +97,7 @@
busy = true
error = null
try {
await times.start(selectedRun, selectedRider)
await times.start(selectedStage, selectedRider)
open = false
} catch (e: any) {
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
@ -128,7 +128,7 @@
<Dialog.Header>
<Dialog.Title>Zeit erfassen</Dialog.Title>
<Dialog.Description>
Fahrer und Run wählen — je nach Stand wird gestartet oder gestoppt.
Fahrer und Stage wählen — je nach Stand wird gestartet oder gestoppt.
</Dialog.Description>
</Dialog.Header>
@ -151,17 +151,17 @@
</div>
<div class="space-y-2">
<Label>Run</Label>
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
<Label>Stage</Label>
<Select.Root type="single" bind:value={selectedStage} disabled={eventStages.length === 0}>
<Select.Trigger class="w-full">{stageLabel}</Select.Trigger>
<Select.Content>
{#each eventRuns as r (r.id)}
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
{#each eventStages as r (r.id)}
<Select.Item value={r.id}>{r.description || 'Stage'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventRuns.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
{#if eventStages.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Stages.</p>
{/if}
</div>

View file

@ -2,19 +2,19 @@ import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import type { RunsResponse } from '$lib/types'
import type { StagesResponse } from '$lib/types'
const KEY = Symbol('run')
const KEY = Symbol('stage')
export class RunStore {
records = $state<RunsResponse[]>([])
export class StageStore {
records = $state<StagesResponse[]>([])
loading = $state(false)
error = $state<string | null>(null)
private unsubscribe: (() => void) | null = null
private teams = getTeamContext()
get scoped(): RunsResponse[] {
get scoped(): StagesResponse[] {
const teamId = this.teams.activeId
if (!teamId) return []
return this.records.filter((r) => r.team === teamId)
@ -24,12 +24,12 @@ export class RunStore {
this.loading = true
this.error = null
try {
this.records = await api.collection('runs').getFullList({
this.records = await api.collection('stages').getFullList({
sort: '+created',
requestKey: null,
})
} catch (e: any) {
this.error = e.message ?? 'Fehler beim Laden der Runs'
this.error = e.message ?? 'Fehler beim Laden der Stages'
console.error(e)
} finally {
this.loading = false
@ -38,34 +38,34 @@ export class RunStore {
subscribe() {
if (this.unsubscribe) return
api.collection('runs').subscribe('*', (e) => {
api.collection('stages').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id)
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
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('runs subscribe failed:', e))
}).catch((e) => console.warn('stages subscribe failed:', e))
}
async create(data: Partial<RunsResponse>) {
async create(data: Partial<StagesResponse>) {
const teamId = this.teams.activeId
if (!teamId) throw new Error('Kein aktives Team')
return await api.collection('runs').create({ ...data, team: teamId })
return await api.collection('stages').create({ ...data, team: teamId })
}
async edit(id: string, data: Partial<RunsResponse>) {
return await api.collection('runs').update(id, data)
async edit(id: string, data: Partial<StagesResponse>) {
return await api.collection('stages').update(id, data)
}
remove(record: RunsResponse) {
remove(record: StagesResponse) {
return new Promise<void>((resolve, reject) => {
app.confirm.request({
title: 'Run löschen?',
title: 'Stage löschen?',
text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
yes: async () => {
try {
await api.collection('runs').delete(record.id)
await api.collection('stages').delete(record.id)
app.confirm.close()
resolve()
} catch (e) {
@ -97,12 +97,12 @@ export class RunStore {
}
}
export function setRunContext() {
const store = new RunStore()
export function setStageContext() {
const store = new StageStore()
setContext(KEY, store)
return store
}
export function getRunContext(): RunStore {
return getContext<RunStore>(KEY)
export function getStageContext(): StageStore {
return getContext<StageStore>(KEY)
}

View file

@ -75,11 +75,11 @@ export class TimeStore {
}).catch((e) => console.warn('times subscribe failed:', e))
}
async start(runId: string, riderId: string) {
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({
run: runId,
stage: stageId,
rider: riderId,
team: teamId,
start: new Date().toISOString(),
@ -128,17 +128,17 @@ export class TimeStore {
})
}
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
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 eines Runs — der am längsten laufende zuerst. */
runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
return this.getByRun(runId, 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))
}
@ -147,21 +147,21 @@ export class TimeStore {
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
* dürfen aber auch nicht unsichtbar werden.
*/
unrankedByRun(runId: string): TimeWithDuration[] {
return this.getByRun(runId).filter(
unrankedByStage(stageId: string): TimeWithDuration[] {
return this.getByStage(stageId).filter(
(r) => r.status !== 'active' && r.status !== 'finished',
)
}
isRiderActive(runId: string, riderId: string) {
isRiderActive(stageId: string, riderId: string) {
return this.scoped.some(
(r) => r.run === runId && r.rider === riderId && r.status === 'active',
(r) => r.stage === stageId && r.rider === riderId && r.status === 'active',
)
}
getActiveTime(runId: string, riderId: string) {
getActiveTime(stageId: string, riderId: string) {
return this.scoped.find(
(r) => r.run === runId && r.rider === riderId && r.status === 'active',
(r) => r.stage === stageId && r.rider === riderId && r.status === 'active',
)
}
@ -170,8 +170,8 @@ export class TimeStore {
}
/**
* Alle laufenden Zeiten des Teams, run-übergreifend der am längsten
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
* 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
@ -185,15 +185,15 @@ export class TimeStore {
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
}
getLeaderboard(runId: string): TimeWithDuration[] {
return this.getByRun(runId)
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 Run, 1-basiert; null ohne gewertete Zeit. */
placement(runId: string, riderId: string): number | null {
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
/** 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
}

View file

@ -10,7 +10,7 @@ export enum Collections {
EventSeries = "event_series",
Events = "events",
Riders = "riders",
Runs = "runs",
Stages = "stages",
TeamInvites = "team_invites",
Teams = "teams",
Times = "times",
@ -124,7 +124,7 @@ export type RidersRecord = {
user?: RecordIdString
}
export type RunsRecord = {
export type StagesRecord = {
created?: IsoDateString
description?: string
event?: RecordIdString
@ -169,7 +169,7 @@ export type TimesRecord = {
created?: IsoDateString
id: string
rider?: RecordIdString
run?: RecordIdString
stage?: RecordIdString
start?: IsoDateString
startedBy?: RecordIdString
status?: TimesStatusOptions
@ -278,7 +278,7 @@ export type EventParticipantsResponse<Texpand = unknown> = Required<EventPartici
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & BaseSystemFields<Texpand>
export type RunsResponse<Texpand = unknown> = Required<RunsRecord> & BaseSystemFields<Texpand>
export type StagesResponse<Texpand = unknown> = Required<StagesRecord> & BaseSystemFields<Texpand>
export type TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
@ -296,7 +296,7 @@ export type CollectionRecords = {
event_series: EventSeriesRecord
events: EventsRecord
riders: RidersRecord
runs: RunsRecord
stages: StagesRecord
team_invites: TeamInvitesRecord
teams: TeamsRecord
times: TimesRecord
@ -313,7 +313,7 @@ export type CollectionResponses = {
event_series: EventSeriesResponse
events: EventsResponse
riders: RidersResponse
runs: RunsResponse
stages: StagesResponse
team_invites: TeamInvitesResponse
teams: TeamsResponse
times: TimesResponse
@ -333,7 +333,7 @@ export type TypedPocketBase = PocketBase & {
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
collection(idOrName: 'events'): RecordService<EventsResponse>
collection(idOrName: 'riders'): RecordService<RidersResponse>
collection(idOrName: 'runs'): RecordService<RunsResponse>
collection(idOrName: 'stages'): RecordService<StagesResponse>
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
collection(idOrName: 'teams'): RecordService<TeamsResponse>
collection(idOrName: 'times'): RecordService<TimesResponse>

View file

@ -6,7 +6,7 @@
import { setTeamContext } from '$lib/stores/teams.svelte'
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { setEventContext } from '$lib/stores/events.svelte'
import { setRunContext } from '$lib/stores/runs.svelte'
import { setStageContext } from '$lib/stores/stages.svelte'
import { setRiderContext } from '$lib/stores/riders.svelte'
import { setTimeContext } from '$lib/stores/times.svelte'
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
@ -33,7 +33,7 @@
const teams = setTeamContext()
const invites = setTeamInviteContext()
const events = setEventContext()
const runs = setRunContext()
const stages = setStageContext()
const riders = setRiderContext()
const times = setTimeContext()
// Nach events und riders: Der Store greift im Constructor auf beide zu.
@ -56,14 +56,14 @@
await teams.load()
teams.subscribe()
await Promise.all([
events.load(), runs.load(), riders.load(), times.load(), participants.load(), series.load(),
events.load(), stages.load(), riders.load(), times.load(), participants.load(), series.load(),
invites.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
trailComments.load(),
])
invites.subscribe()
events.subscribe()
runs.subscribe()
stages.subscribe()
riders.subscribe()
times.subscribe()
participants.subscribe()
@ -79,7 +79,7 @@
teams.destroy()
invites.destroy()
events.destroy()
runs.destroy()
stages.destroy()
riders.destroy()
times.destroy()
participants.destroy()
@ -134,7 +134,7 @@
let dashHover = $state(false)
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
// passiert dort, wo es hingehört — am Run innerhalb seines Events.
// passiert dort, wo es hingehört — an der Stage innerhalb ihres Events.
const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
@ -171,8 +171,9 @@
<div class="flex items-center gap-3 min-w-0">
<a
href="/dashboard"
class="shrink-0 transition-transform relative duration-200 top-2"
class:scale-105={dashHover}
class="shrink-0 transition-transform relative duration-75 top-2"
class:scale-120={dashHover}
class:rotate-3={dashHover}
onmouseenter={() => (dashHover = true)}
onmouseleave={() => (dashHover = false)}
>

View file

@ -4,7 +4,7 @@
import { getEventContext } from '$lib/stores/events.svelte'
import { getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@ -13,7 +13,7 @@
const teams = getTeamContext()
const events = getEventContext()
const runs = getRunContext()
const stages = getStageContext()
const riders = getRiderContext()
const times = getTimeContext()
@ -97,7 +97,7 @@
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each upNext as event}
{@const eventRuns = runs.getByEvent(event.id).length}
{@const eventStages = stages.getByEvent(event.id).length}
<Card class="hover:border-primary transition-colors">
<a href="/dashboard/events/{event.id}" class="block">
<CardHeader>
@ -117,7 +117,7 @@
{/if}
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">{eventRuns} Run{eventRuns === 1 ? '' : 's'}</p>
<p class="text-sm text-muted-foreground">{eventStages} Stage{eventStages === 1 ? '' : 's'}</p>
</CardContent>
</a>
</Card>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import {
getEventSeriesContext,
@ -22,7 +22,7 @@
import type { EventsResponse } from '$lib/types'
const events = getEventContext()
const runs = getRunContext()
const stages = getStageContext()
const participants = getEventParticipantContext()
const series = getEventSeriesContext()
@ -301,7 +301,7 @@
<h2 class="text-lg font-semibold">{group.title} <span class="text-muted-foreground font-normal">({group.items.length})</span></h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each group.items as event}
{@const runCount = runs.getByEvent(event.id).length}
{@const stageCount = stages.getByEvent(event.id).length}
<Card class="hover:border-primary transition-colors">
<a href="/dashboard/events/{event.id}" class="block">
<CardHeader>
@ -328,7 +328,7 @@
{#if event.timing}
<span class="flex items-center gap-1">
<Timer class="size-3.5" />
{runCount} Run{runCount === 1 ? '' : 's'}
{stageCount} Stage{stageCount === 1 ? '' : 's'}
</span>
{/if}
<span class="flex items-center gap-1">
@ -416,7 +416,7 @@
mit Zeitnahme
</label>
<p class="text-xs text-muted-foreground -mt-2">
Blendet Runs und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
Blendet Stages und Zeitnahme ein. Für Stammtisch oder Feierabendrunde
unnötig.
</p>

View file

@ -2,7 +2,7 @@
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
@ -36,10 +36,10 @@
Check,
X,
} from 'lucide-svelte'
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
const events = getEventContext()
const runs = getRunContext()
const stages = getStageContext()
const riders = getRiderContext()
const times = getTimeContext()
const teams = getTeamContext()
@ -47,7 +47,7 @@
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
const eventRuns = $derived(event ? runs.getByEvent(event.id) : [])
const eventStages = $derived(event ? stages.getByEvent(event.id) : [])
/**
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
@ -65,14 +65,14 @@
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
)
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Run bzw. Fahrer.
// Zeitnahme und Ergebnisse hängen jetzt am jeweiligen Stage bzw. Fahrer.
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
function riderPath(riderId: string) {
return `/dashboard/events/${event?.id}/riders/${riderId}`
}
function runPath(runId: string) {
return `/dashboard/events/${event?.id}/runs/${runId}`
function stagePath(stageId: string) {
return `/dashboard/events/${event?.id}/stages/${stageId}`
}
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
@ -162,30 +162,30 @@
}
}
// Run-Dialog
let runDialog = $state(false)
let runEdit = $state<RunsResponse | null>(null)
let runForm = $state({ description: '' })
// Stage-Dialog
let stageDialog = $state(false)
let stageEdit = $state<StagesResponse | null>(null)
let stageForm = $state({ description: '' })
function openRunCreate() {
runEdit = null
runForm = { description: '' }
runDialog = true
function openStageCreate() {
stageEdit = null
stageForm = { description: '' }
stageDialog = true
}
function openRunEdit(r: RunsResponse) {
runEdit = r
runForm = { description: r.description ?? '' }
runDialog = true
function openStageEdit(r: StagesResponse) {
stageEdit = r
stageForm = { description: r.description ?? '' }
stageDialog = true
}
async function saveRun() {
async function saveStage() {
if (!event) return
if (!runForm.description.trim()) return alert('Bezeichnung ist Pflicht')
if (!stageForm.description.trim()) return alert('Bezeichnung ist Pflicht')
try {
if (runEdit) await runs.edit(runEdit.id, runForm)
else await runs.create({ ...runForm, event: event.id })
runDialog = false
if (stageEdit) await stages.edit(stageEdit.id, stageForm)
else await stages.create({ ...stageForm, event: event.id })
stageDialog = false
} catch (e: any) {
alert(`Fehler: ${e.message}`)
}
@ -236,7 +236,7 @@
<Button
onclick={() => (timeDialog = true)}
disabled={eventParticipants.length === 0 || eventRuns.length === 0}
disabled={eventParticipants.length === 0 || eventStages.length === 0}
>
<Timer class="h-4 w-4 mr-2" />
Zeit erfassen
@ -342,7 +342,7 @@
</Table.Cell>
{#if event.timing}
<Table.Cell class="text-muted-foreground text-sm">
{done} von {eventRuns.length} Runs
{done} von {eventStages.length} Stages
</Table.Cell>
{/if}
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
@ -411,34 +411,34 @@
</Card>
</section>
<!-- Runs — nur bei Events mit Zeitnahme -->
<!-- Stages — nur bei Events mit Zeitnahme -->
{#if event.timing}
<section class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<Timer class="h-5 w-5" />
Runs
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
Stages
<span class="text-muted-foreground font-normal text-base">({eventStages.length})</span>
</h2>
<p class="text-sm text-muted-foreground">Klick auf einen Run öffnet die Zeitnahme</p>
<p class="text-sm text-muted-foreground">Klick auf eine Stage öffnet die Zeitnahme</p>
</div>
<Button onclick={openRunCreate}>
<Button onclick={openStageCreate}>
<Plus class="h-4 w-4 mr-2" />
Run anlegen
Stage anlegen
</Button>
</div>
<Card>
<CardContent class="p-0">
{#if eventRuns.length === 0}
{#if eventStages.length === 0}
<div class="py-12 text-center">
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p class="font-medium">Noch keine Runs</p>
<p class="font-medium">Noch keine Stages</p>
<p class="text-sm text-muted-foreground mt-1 mb-4">Lege den ersten Lauf an.</p>
<Button onclick={openRunCreate}>
<Button onclick={openStageCreate}>
<Plus class="h-4 w-4 mr-2" />
Ersten Run anlegen
Erste Stage anlegen
</Button>
</div>
{:else}
@ -451,13 +451,13 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each eventRuns as run (run.id)}
{@const runTimes = times.getByRun(run.id)}
{@const active = runTimes.filter((t) => t.status === 'active').length}
{@const ranked = runTimes.filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
{#each eventStages as stage (stage.id)}
{@const stageTimes = times.getByStage(stage.id)}
{@const active = stageTimes.filter((t) => t.status === 'active').length}
{@const ranked = stageTimes.filter((t) => t.status === 'finished').length}
<Table.Row class="cursor-pointer" onclick={() => goto(stagePath(stage.id))}>
<Table.Cell class="font-medium">
<a href={runPath(run.id)} class="hover:underline">{run.description}</a>
<a href={stagePath(stage.id)} class="hover:underline">{stage.description}</a>
</Table.Cell>
<Table.Cell>
{#if active > 0}
@ -466,10 +466,10 @@
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
</Table.Cell>
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onclick={() => openRunEdit(run)}>
<Button variant="ghost" size="icon" onclick={() => openStageEdit(stage)}>
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => runs.remove(run)}>
<Button variant="ghost" size="icon" onclick={() => stages.remove(stage)}>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</Table.Cell>
@ -539,29 +539,29 @@
</Dialog.Content>
</Dialog.Root>
<!-- Run-Dialog -->
<Dialog.Root bind:open={runDialog}>
<!-- Stage-Dialog -->
<Dialog.Root bind:open={stageDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{runEdit ? 'Run bearbeiten' : 'Neuer Run'}</Dialog.Title>
<Dialog.Title>{stageEdit ? 'Stage bearbeiten' : 'Neue Stage'}</Dialog.Title>
<Dialog.Description>
Run für „{event?.name}"
Stage für „{event?.name}"
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault()
saveRun()
saveStage()
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="run-desc">Bezeichnung *</Label>
<Input id="run-desc" bind:value={runForm.description} placeholder="Stage 1 Downhill" required />
<Label for="stage-desc">Bezeichnung *</Label>
<Input id="stage-desc" bind:value={stageForm.description} placeholder="Stage 1 Downhill" required />
</div>
<Dialog.Footer>
<Button type="button" variant="ghost" onclick={() => (runDialog = false)}>Abbrechen</Button>
<Button type="submit">{runEdit ? 'Speichern' : 'Anlegen'}</Button>
<Button type="button" variant="ghost" onclick={() => (stageDialog = false)}>Abbrechen</Button>
<Button type="submit">{stageEdit ? 'Speichern' : 'Anlegen'}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>

View file

@ -1,13 +1,13 @@
<script lang="ts">
/**
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
* Fahrerübersicht: alle Stages des Events aus Sicht eines Fahrers. Gezeigt
* wird je Stage seine Zeit, sein Platz und ob gerade gemessen wird — die
* Zeitnahme selbst passiert auf der Stage-Seite, dorthin führt jede Zeile.
*/
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
@ -22,7 +22,7 @@
const events = getEventContext()
const runs = getRunContext()
const stages = getStageContext()
const riders = getRiderContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
@ -37,40 +37,40 @@
const valid = $derived(!!event && !!rider && !!entry)
/**
* Eine Zeile je Run des Events — auch für Runs ohne Zeit, damit sichtbar
* Eine Zeile je Stage des Events — auch für Stages ohne Zeit, damit sichtbar
* bleibt, was dem Fahrer noch fehlt.
*/
const rows = $derived.by(() => {
if (!valid || !event || !rider) return []
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
// aktuell; bei einer Handvoll Runs ist das billig.
// aktuell; bei einer Handvoll Stages ist das billig.
const own = times.getByRider(rider.id, clock.now)
return runs.getByEvent(event.id).map((run) => {
const forRun = own.filter((t) => t.run === run.id)
const active = forRun.find((t) => t.status === 'active')
return stages.getByEvent(event.id).map((stage) => {
const forStage = own.filter((t) => t.stage === stage.id)
const active = forStage.find((t) => t.status === 'active')
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
const best = forRun
const best = forStage
.filter((t) => t.status === 'finished')
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
return {
run,
stage,
active,
best,
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
placement: best ? times.placement(run.id, rider.id) : null,
others: forStage.filter((t) => t.status !== 'active' && t.status !== 'finished'),
placement: best ? times.placement(stage.id, rider.id) : null,
}
})
})
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
let timeDialog = $state(false)
let dialogRun = $state<string | null>(null)
let dialogStage = $state<string | null>(null)
function openTimeDialog(runId: string | null = null) {
dialogRun = runId
function openTimeDialog(stageId: string | null = null) {
dialogStage = stageId
timeDialog = true
}
@ -131,7 +131,7 @@
<div class="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Runs</CardTitle>
<CardTitle class="text-sm font-medium text-muted-foreground">Gefahrene Stages</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
@ -163,19 +163,19 @@
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Timer class="h-4 w-4 text-primary" />
Runs ({rows.length})
Stages ({rows.length})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
{#if rows.length === 0}
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
Für dieses Event gibt es noch keine Runs.
Für dieses Event gibt es noch keine Stages.
</p>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Run</Table.Head>
<Table.Head>Stage</Table.Head>
<Table.Head class="w-24">Status</Table.Head>
<Table.Head class="text-right w-28">Zeit</Table.Head>
<Table.Head class="text-right w-20">Platz</Table.Head>
@ -183,17 +183,17 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.run.id)}
{#each rows as row (row.stage.id)}
<Table.Row
class="cursor-pointer"
onclick={() => goto(`/dashboard/events/${event.id}/runs/${row.run.id}`)}
onclick={() => goto(`/dashboard/events/${event.id}/stages/${row.stage.id}`)}
>
<Table.Cell class="font-medium">
<a
href="/dashboard/events/{event.id}/runs/{row.run.id}"
href="/dashboard/events/{event.id}/stages/{row.stage.id}"
class="hover:underline"
>
{row.run.description || 'Run'}
{row.stage.description || 'Stage'}
</a>
</Table.Cell>
<Table.Cell>
@ -244,5 +244,5 @@
bind:open={timeDialog}
eventId={event?.id ?? ''}
riderId={rider?.id ?? null}
runId={dialogRun}
stageId={dialogStage}
/>

View file

@ -1,6 +1,6 @@
<script lang="ts">
/**
* Run-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
* Stage-Übersicht: laufende und gewertete Zeiten in einer Tabelle, die
* laufenden zählen in Echtzeit mit. Die Tabelle zeigt nur an — gestartet
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
@ -8,7 +8,7 @@
*/
import { page } from '$app/state'
import { getEventContext } from '$lib/stores/events.svelte'
import { getRunContext } from '$lib/stores/runs.svelte'
import { getStageContext } from '$lib/stores/stages.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getTimeContext } from '$lib/stores/times.svelte'
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
@ -22,7 +22,7 @@
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
const events = getEventContext()
const runs = getRunContext()
const stages = getStageContext()
const riders = getRiderContext()
const times = getTimeContext()
const participants = getEventParticipantContext()
@ -30,17 +30,17 @@
const clock = ticker()
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
const run = $derived(page.params.runId ? runs.getById(page.params.runId) : undefined)
const stage = $derived(page.params.stageId ? stages.getById(page.params.stageId) : undefined)
// Ein Run gehört genau zu einem Event; passt die URL nicht zusammen, ist
// Ein Stage gehört genau zu einem Event; passt die URL nicht zusammen, ist
// sie von Hand zusammengebaut oder veraltet.
const valid = $derived(!!event && !!run && run.event === event.id)
const valid = $derived(!!event && !!stage && stage.event === event.id)
// Am Start steht, wer beim Event zugesagt hat.
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
const running = $derived(valid && stage ? times.runningByStage(stage.id, clock.now) : [])
const leaderboard = $derived(valid && stage ? times.getLeaderboard(stage.id) : [])
const unranked = $derived(valid && stage ? times.unrankedByStage(stage.id) : [])
/**
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
@ -61,7 +61,7 @@
}
// --- Zeitnahme --------------------------------------------------------
// Start und Stopp laufen ausschließlich über TimeDialog; der Run ist
// Start und Stopp laufen ausschließlich über TimeDialog; die Stage ist
// dabei vorbelegt, den Fahrer wählt man dort.
let timeDialog = $state(false)
@ -82,7 +82,7 @@
})
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
const safeName = (run?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const safeName = (stage?.description ?? 'ergebnisse').replace(/[^a-z0-9]+/gi, '-')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@ -93,13 +93,13 @@
}
</script>
<svelte:head><title>{run?.description ?? 'Run'} {event?.name ?? 'Event'}</title></svelte:head>
<svelte:head><title>{stage?.description ?? 'Stage'} {event?.name ?? 'Event'}</title></svelte:head>
{#if !valid}
<Card>
<CardContent class="py-16 text-center">
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-lg font-medium">Run nicht gefunden</p>
<p class="text-lg font-medium">Stage nicht gefunden</p>
<p class="text-sm text-muted-foreground mt-1">
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
</p>
@ -109,7 +109,7 @@
</Button>
</CardContent>
</Card>
{:else if event && run}
{:else if event && stage}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
@ -120,7 +120,7 @@
<ArrowLeft class="h-3 w-3" />
{event.name || 'Event'}
</a>
<h1 class="text-3xl font-bold tracking-tight">{run.description || 'Run'}</h1>
<h1 class="text-3xl font-bold tracking-tight">{stage.description || 'Stage'}</h1>
<p class="mt-1 text-muted-foreground">
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
</p>
@ -249,4 +249,4 @@
</div>
{/if}
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} runId={run?.id ?? null} />
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} stageId={stage?.id ?? null} />

View file

@ -133,7 +133,7 @@
})
/**
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen am Run und
* Zur Fahrerseite geht es über ein Event, denn Zeiten hängen an der Stage und
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
*/
function riderPath(riderId: string) {