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:
parent
3120bbfdbf
commit
893509be9d
17 changed files with 261 additions and 215 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
45
backend/pb_migrations/1754502000_runs_become_stages.js
Normal file
45
backend/pb_migrations/1754502000_runs_become_stages.js
Normal 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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
14
frontend/src/lib/types.d.ts
vendored
14
frontend/src/lib/types.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue