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
|
## 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/`.
|
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
|
Container und müssen mit `docker compose cp` ins Repo geholt werden; siehe
|
||||||
`backend/README.md`.
|
`backend/README.md`.
|
||||||
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
|
- **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,
|
trails, trail_versions, trail_flags, trail_markers, trail_comments,
|
||||||
event_participants, event_series
|
event_participants, event_series
|
||||||
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
|
- **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
|
Das Schema liegt als versionierte Migration in `pb_migrations/` und wird beim
|
||||||
Start automatisch angewendet. Es enthält die sechs fachlichen Collections
|
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
|
`1754400000_init_schema.js` ist ein Snapshot der produktiven Instanz. Er wurde
|
||||||
per `GET /api/collections` abgezogen und ruft `importCollections` mit
|
per `GET /api/collections` abgezogen und ruft `importCollections` mit
|
||||||
|
|
|
||||||
|
|
@ -588,7 +588,7 @@ migrate((app) => {
|
||||||
"createRule": "team.users.id ?= @request.auth.id",
|
"createRule": "team.users.id ?= @request.auth.id",
|
||||||
"updateRule": "team.users.id ?= @request.auth.id",
|
"updateRule": "team.users.id ?= @request.auth.id",
|
||||||
"deleteRule": "team.users.id ?= @request.auth.id",
|
"deleteRule": "team.users.id ?= @request.auth.id",
|
||||||
"name": "runs",
|
"name": "stages",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
|
|
@ -916,7 +916,7 @@ migrate((app) => {
|
||||||
"id": "ltfephwe",
|
"id": "ltfephwe",
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
"minSelect": 0,
|
"minSelect": 0,
|
||||||
"name": "run",
|
"name": "stage",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"required": false,
|
"required": false,
|
||||||
"system": 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)
|
const url = $derived(team?.logo ? getFileURL(team, team.logo) : null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative {className} hover:scale-110 hover:rotate-3 transition-transform" id="logo">
|
<div class="relative {className}" id="logo">
|
||||||
{#if url}
|
{#if url}
|
||||||
<img alt="" class="absolute h-full w-full object-contain" src={url} />
|
<img alt="" class="absolute h-full w-full object-contain" src={url} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,17 @@
|
||||||
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
|
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
|
||||||
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
|
* 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
|
* 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
|
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
|
||||||
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
|
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
|
||||||
*
|
*
|
||||||
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
|
* 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 { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { riderName, getRiderContext } from '$lib/stores/riders.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 { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import TimeDialog from './TimeDialog.svelte'
|
import TimeDialog from './TimeDialog.svelte'
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
|
|
@ -35,15 +35,15 @@
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
let dialogEvent = $state('')
|
let dialogEvent = $state('')
|
||||||
let dialogRider = $state<string | null>(null)
|
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) {
|
function openTimeDialog(riderId: string | undefined, stageId: string | undefined) {
|
||||||
const run = runId ? runs.getById(runId) : undefined
|
const stage = stageId ? stages.getById(stageId) : undefined
|
||||||
if (!run?.event) return
|
if (!stage?.event) return
|
||||||
|
|
||||||
dialogEvent = run.event
|
dialogEvent = stage.event
|
||||||
dialogRider = riderId ?? null
|
dialogRider = riderId ?? null
|
||||||
dialogRun = run.id
|
dialogStage = stage.id
|
||||||
timeDialog = true
|
timeDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,9 +51,9 @@
|
||||||
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
|
||||||
const open = $derived(single || expanded)
|
const open = $derived(single || expanded)
|
||||||
|
|
||||||
/** Die Startnummer gilt je Event, deshalb der Umweg über den Run. */
|
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
|
||||||
function riderLabel(riderId: string | undefined, runId: string | undefined) {
|
function riderLabel(riderId: string | undefined, stageId: string | undefined) {
|
||||||
const eventId = runId ? runs.getById(runId)?.event : undefined
|
const eventId = stageId ? stages.getById(stageId)?.event : undefined
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
const r = riderId ? riders.getById(riderId) : undefined
|
const r = riderId ? riders.getById(riderId) : undefined
|
||||||
return r ? riderName(r) : 'Unbekannter Fahrer'
|
return r ? riderName(r) : 'Unbekannter Fahrer'
|
||||||
|
|
@ -61,15 +61,15 @@
|
||||||
return participants.riderLabel(eventId, riderId)
|
return participants.riderLabel(eventId, riderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPath(runId: string | undefined) {
|
function stagePath(stageId: string | undefined) {
|
||||||
const run = runId ? runs.getById(runId) : undefined
|
const stage = stageId ? stages.getById(stageId) : undefined
|
||||||
if (!run?.event) return null
|
if (!stage?.event) return null
|
||||||
return `/dashboard/events/${run.event}/runs/${run.id}`
|
return `/dashboard/events/${stage.event}/stages/${stage.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLabel(runId: string | undefined) {
|
function stageLabel(stageId: string | undefined) {
|
||||||
const run = runId ? runs.getById(runId) : undefined
|
const stage = stageId ? stages.getById(stageId) : undefined
|
||||||
return run?.description || 'Run'
|
return stage?.description || 'Stage'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -113,15 +113,15 @@
|
||||||
{#if open}
|
{#if open}
|
||||||
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
||||||
{#each running as t (t.id)}
|
{#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">
|
<li class="flex items-center gap-2 px-3 py-2">
|
||||||
<div class="min-w-0 flex-1">
|
<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">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
{#if path}
|
{#if path}
|
||||||
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a>
|
||||||
{:else}
|
{:else}
|
||||||
{runLabel(t.run)}
|
{stageLabel(t.stage)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,7 +135,7 @@
|
||||||
size="icon"
|
size="icon"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
tooltip="Zeit erfassen"
|
tooltip="Zeit erfassen"
|
||||||
onclick={() => openTimeDialog(t.rider, t.run)}
|
onclick={() => openTimeDialog(t.rider, t.stage)}
|
||||||
>
|
>
|
||||||
<Timer class="size-4" />
|
<Timer class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -150,6 +150,6 @@
|
||||||
bind:open={timeDialog}
|
bind:open={timeDialog}
|
||||||
eventId={dialogEvent}
|
eventId={dialogEvent}
|
||||||
riderId={dialogRider}
|
riderId={dialogRider}
|
||||||
runId={dialogRun}
|
stageId={dialogStage}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<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
|
* darin — auch wenn einer davon aus dem Kontext vorbelegt ist —, damit
|
||||||
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
|
* beim Starten und Stoppen nie ein Zweifel bleibt, wen und was man
|
||||||
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
|
* gerade erwischt. Start und Stopp passieren ausschließlich hier: weder
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
import { getTimeContext, type TimeStatus } from '$lib/stores/times.svelte'
|
||||||
import { riderName, getRiderContext } from '$lib/stores/riders.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 { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import { ticker } from '$lib/stores/ticker.svelte'
|
import { ticker } from '$lib/stores/ticker.svelte'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -21,24 +21,24 @@
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
eventId,
|
eventId,
|
||||||
riderId = null,
|
riderId = null,
|
||||||
runId = null,
|
stageId = null,
|
||||||
}: {
|
}: {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
/** Event, aus dem Fahrer und Runs zur Auswahl stehen. */
|
/** Event, aus dem Fahrer und Stages zur Auswahl stehen. */
|
||||||
eventId: string
|
eventId: string
|
||||||
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
|
/** Vorbelegung aus dem Kontext; bleibt im Dialog änderbar. */
|
||||||
riderId?: string | null
|
riderId?: string | null
|
||||||
runId?: string | null
|
stageId?: string | null
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
let selectedRider = $state('')
|
let selectedRider = $state('')
|
||||||
let selectedRun = $state('')
|
let selectedStage = $state('')
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
let confirmDnf = $state(false)
|
let confirmDnf = $state(false)
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
selectedRider = riderId ?? ''
|
selectedRider = riderId ?? ''
|
||||||
selectedRun = runId ?? ''
|
selectedStage = stageId ?? ''
|
||||||
error = null
|
error = null
|
||||||
confirmDnf = false
|
confirmDnf = false
|
||||||
})
|
})
|
||||||
|
|
@ -63,16 +63,16 @@
|
||||||
.filter((r) => !!r)
|
.filter((r) => !!r)
|
||||||
: [],
|
: [],
|
||||||
)
|
)
|
||||||
const eventRuns = $derived(eventId ? runs.getByEvent(eventId) : [])
|
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
|
||||||
|
|
||||||
const riderLabel = $derived(
|
const riderLabel = $derived(
|
||||||
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
|
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
|
||||||
)
|
)
|
||||||
const runLabel = $derived(
|
const stageLabel = $derived(
|
||||||
selectedRun ? runs.getById(selectedRun)?.description ?? 'Run wählen…' : 'Run wählen…',
|
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
|
// Über die laufende Liste gelesen, damit die Uhr im Dialog mitzählt und
|
||||||
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
|
// verschwindet, sobald jemand anders dieselbe Zeit stoppt.
|
||||||
|
|
@ -80,14 +80,14 @@
|
||||||
ready
|
ready
|
||||||
? times
|
? times
|
||||||
.allRunning(clock.now)
|
.allRunning(clock.now)
|
||||||
.find((t) => t.run === selectedRun && t.rider === selectedRider)
|
.find((t) => t.stage === selectedStage && t.rider === selectedRider)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
|
/** Bereits gewertete Zeit derselben Paarung — Hinweis, keine Sperre. */
|
||||||
const existing = $derived(
|
const existing = $derived(
|
||||||
ready && !active
|
ready && !active
|
||||||
? times.getByRun(selectedRun).find((t) => t.rider === selectedRider && t.stop)
|
? times.getByStage(selectedStage).find((t) => t.rider === selectedRider && t.stop)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
busy = true
|
busy = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
await times.start(selectedRun, selectedRider)
|
await times.start(selectedStage, selectedRider)
|
||||||
open = false
|
open = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
|
error = e.message ?? 'Die Zeit konnte nicht gestartet werden.'
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Zeit erfassen</Dialog.Title>
|
<Dialog.Title>Zeit erfassen</Dialog.Title>
|
||||||
<Dialog.Description>
|
<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.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
|
|
@ -151,17 +151,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>Run</Label>
|
<Label>Stage</Label>
|
||||||
<Select.Root type="single" bind:value={selectedRun} disabled={eventRuns.length === 0}>
|
<Select.Root type="single" bind:value={selectedStage} disabled={eventStages.length === 0}>
|
||||||
<Select.Trigger class="w-full">{runLabel}</Select.Trigger>
|
<Select.Trigger class="w-full">{stageLabel}</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each eventRuns as r (r.id)}
|
{#each eventStages as r (r.id)}
|
||||||
<Select.Item value={r.id}>{r.description || 'Run'}</Select.Item>
|
<Select.Item value={r.id}>{r.description || 'Stage'}</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
{#if eventRuns.length === 0}
|
{#if eventStages.length === 0}
|
||||||
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Runs.</p>
|
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Stages.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,19 @@ import { getContext, setContext } from 'svelte'
|
||||||
import { api } from './pocketbase.svelte'
|
import { api } from './pocketbase.svelte'
|
||||||
import { app } from './app.svelte'
|
import { app } from './app.svelte'
|
||||||
import { getTeamContext } from './teams.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 {
|
export class StageStore {
|
||||||
records = $state<RunsResponse[]>([])
|
records = $state<StagesResponse[]>([])
|
||||||
loading = $state(false)
|
loading = $state(false)
|
||||||
error = $state<string | null>(null)
|
error = $state<string | null>(null)
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null
|
private unsubscribe: (() => void) | null = null
|
||||||
private teams = getTeamContext()
|
private teams = getTeamContext()
|
||||||
|
|
||||||
get scoped(): RunsResponse[] {
|
get scoped(): StagesResponse[] {
|
||||||
const teamId = this.teams.activeId
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) return []
|
if (!teamId) return []
|
||||||
return this.records.filter((r) => r.team === teamId)
|
return this.records.filter((r) => r.team === teamId)
|
||||||
|
|
@ -24,12 +24,12 @@ export class RunStore {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
this.records = await api.collection('runs').getFullList({
|
this.records = await api.collection('stages').getFullList({
|
||||||
sort: '+created',
|
sort: '+created',
|
||||||
requestKey: null,
|
requestKey: null,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.error = e.message ?? 'Fehler beim Laden der Runs'
|
this.error = e.message ?? 'Fehler beim Laden der Stages'
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|
@ -38,34 +38,34 @@ export class RunStore {
|
||||||
|
|
||||||
subscribe() {
|
subscribe() {
|
||||||
if (this.unsubscribe) return
|
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)
|
const idx = this.records.findIndex((r) => r.id === e.record.id)
|
||||||
if (e.action === 'create' && idx === -1) this.records = [...this.records, e.record]
|
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 === '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)
|
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
|
||||||
}).then((unsub) => {
|
}).then((unsub) => {
|
||||||
this.unsubscribe = 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
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
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>) {
|
async edit(id: string, data: Partial<StagesResponse>) {
|
||||||
return await api.collection('runs').update(id, data)
|
return await api.collection('stages').update(id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(record: RunsResponse) {
|
remove(record: StagesResponse) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
app.confirm.request({
|
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.`,
|
text: `Möchten Sie „${record.description}" wirklich löschen? Alle zugehörigen Zeiten werden ebenfalls gelöscht.`,
|
||||||
yes: async () => {
|
yes: async () => {
|
||||||
try {
|
try {
|
||||||
await api.collection('runs').delete(record.id)
|
await api.collection('stages').delete(record.id)
|
||||||
app.confirm.close()
|
app.confirm.close()
|
||||||
resolve()
|
resolve()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -97,12 +97,12 @@ export class RunStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRunContext() {
|
export function setStageContext() {
|
||||||
const store = new RunStore()
|
const store = new StageStore()
|
||||||
setContext(KEY, store)
|
setContext(KEY, store)
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRunContext(): RunStore {
|
export function getStageContext(): StageStore {
|
||||||
return getContext<RunStore>(KEY)
|
return getContext<StageStore>(KEY)
|
||||||
}
|
}
|
||||||
|
|
@ -75,11 +75,11 @@ export class TimeStore {
|
||||||
}).catch((e) => console.warn('times subscribe failed:', e))
|
}).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
|
const teamId = this.teams.activeId
|
||||||
if (!teamId) throw new Error('Kein aktives Team')
|
if (!teamId) throw new Error('Kein aktives Team')
|
||||||
return await api.collection('times').create({
|
return await api.collection('times').create({
|
||||||
run: runId,
|
stage: stageId,
|
||||||
rider: riderId,
|
rider: riderId,
|
||||||
team: teamId,
|
team: teamId,
|
||||||
start: new Date().toISOString(),
|
start: new Date().toISOString(),
|
||||||
|
|
@ -128,17 +128,17 @@ export class TimeStore {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
getByStage(stageId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped.filter((r) => r.run === runId).map((r) => enrich(r, now))
|
return this.scoped.filter((r) => r.stage === stageId).map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
getByRider(riderId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
|
return this.scoped.filter((r) => r.rider === riderId).map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Laufende Zeiten eines Runs — der am längsten laufende zuerst. */
|
/** Laufende Zeiten einer Stage — die am längsten laufende zuerst. */
|
||||||
runningByRun(runId: string, now = Date.now()): TimeWithDuration[] {
|
runningByStage(stageId: string, now = Date.now()): TimeWithDuration[] {
|
||||||
return this.getByRun(runId, now)
|
return this.getByStage(stageId, now)
|
||||||
.filter((r) => r.status === 'active')
|
.filter((r) => r.status === 'active')
|
||||||
.sort((a, b) => (a.start < b.start ? -1 : 1))
|
.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,
|
* Zeiten ohne Wertung (DNF/DNS/DSQ). Sie tauchen in keiner Rangliste auf,
|
||||||
* dürfen aber auch nicht unsichtbar werden.
|
* dürfen aber auch nicht unsichtbar werden.
|
||||||
*/
|
*/
|
||||||
unrankedByRun(runId: string): TimeWithDuration[] {
|
unrankedByStage(stageId: string): TimeWithDuration[] {
|
||||||
return this.getByRun(runId).filter(
|
return this.getByStage(stageId).filter(
|
||||||
(r) => r.status !== 'active' && r.status !== 'finished',
|
(r) => r.status !== 'active' && r.status !== 'finished',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
isRiderActive(runId: string, riderId: string) {
|
isRiderActive(stageId: string, riderId: string) {
|
||||||
return this.scoped.some(
|
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(
|
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
|
* Alle laufenden Zeiten des Teams, stage-übergreifend — der am längsten
|
||||||
* laufende zuerst. `now` wie bei `getByRun`, damit die Anzeige mitzählt.
|
* laufende zuerst. `now` wie bei `getByStage`, damit die Anzeige mitzählt.
|
||||||
*/
|
*/
|
||||||
allRunning(now = Date.now()): TimeWithDuration[] {
|
allRunning(now = Date.now()): TimeWithDuration[] {
|
||||||
return this.scoped
|
return this.scoped
|
||||||
|
|
@ -185,15 +185,15 @@ export class TimeStore {
|
||||||
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
|
return this.scoped.filter((r) => r.status === 'finished').map((r) => enrich(r, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
getLeaderboard(runId: string): TimeWithDuration[] {
|
getLeaderboard(stageId: string): TimeWithDuration[] {
|
||||||
return this.getByRun(runId)
|
return this.getByStage(stageId)
|
||||||
.filter((r) => r.status === 'finished' && r.duration !== undefined)
|
.filter((r) => r.status === 'finished' && r.duration !== undefined)
|
||||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platz eines Fahrers im Run, 1-basiert; null ohne gewertete Zeit. */
|
/** Platz eines Fahrers im Stage, 1-basiert; null ohne gewertete Zeit. */
|
||||||
placement(runId: string, riderId: string): number | null {
|
placement(stageId: string, riderId: string): number | null {
|
||||||
const idx = this.getLeaderboard(runId).findIndex((t) => t.rider === riderId)
|
const idx = this.getLeaderboard(stageId).findIndex((t) => t.rider === riderId)
|
||||||
return idx === -1 ? null : idx + 1
|
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",
|
EventSeries = "event_series",
|
||||||
Events = "events",
|
Events = "events",
|
||||||
Riders = "riders",
|
Riders = "riders",
|
||||||
Runs = "runs",
|
Stages = "stages",
|
||||||
TeamInvites = "team_invites",
|
TeamInvites = "team_invites",
|
||||||
Teams = "teams",
|
Teams = "teams",
|
||||||
Times = "times",
|
Times = "times",
|
||||||
|
|
@ -124,7 +124,7 @@ export type RidersRecord = {
|
||||||
user?: RecordIdString
|
user?: RecordIdString
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunsRecord = {
|
export type StagesRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
description?: string
|
description?: string
|
||||||
event?: RecordIdString
|
event?: RecordIdString
|
||||||
|
|
@ -169,7 +169,7 @@ export type TimesRecord = {
|
||||||
created?: IsoDateString
|
created?: IsoDateString
|
||||||
id: string
|
id: string
|
||||||
rider?: RecordIdString
|
rider?: RecordIdString
|
||||||
run?: RecordIdString
|
stage?: RecordIdString
|
||||||
start?: IsoDateString
|
start?: IsoDateString
|
||||||
startedBy?: RecordIdString
|
startedBy?: RecordIdString
|
||||||
status?: TimesStatusOptions
|
status?: TimesStatusOptions
|
||||||
|
|
@ -278,7 +278,7 @@ export type EventParticipantsResponse<Texpand = unknown> = Required<EventPartici
|
||||||
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
export type EventSeriesResponse<Texpand = unknown> = Required<EventSeriesRecord> & BaseSystemFields<Texpand>
|
||||||
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
export type EventsResponse<Texpand = unknown> = Required<EventsRecord> & BaseSystemFields<Texpand>
|
||||||
export type RidersResponse<Texpand = unknown> = Required<RidersRecord> & 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 TeamInvitesResponse<Texpand = unknown> = Required<TeamInvitesRecord> & BaseSystemFields<Texpand>
|
||||||
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
|
export type TeamsResponse<Texpand = unknown> = Required<TeamsRecord> & BaseSystemFields<Texpand>
|
||||||
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
|
export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSystemFields<Texpand>
|
||||||
|
|
@ -296,7 +296,7 @@ export type CollectionRecords = {
|
||||||
event_series: EventSeriesRecord
|
event_series: EventSeriesRecord
|
||||||
events: EventsRecord
|
events: EventsRecord
|
||||||
riders: RidersRecord
|
riders: RidersRecord
|
||||||
runs: RunsRecord
|
stages: StagesRecord
|
||||||
team_invites: TeamInvitesRecord
|
team_invites: TeamInvitesRecord
|
||||||
teams: TeamsRecord
|
teams: TeamsRecord
|
||||||
times: TimesRecord
|
times: TimesRecord
|
||||||
|
|
@ -313,7 +313,7 @@ export type CollectionResponses = {
|
||||||
event_series: EventSeriesResponse
|
event_series: EventSeriesResponse
|
||||||
events: EventsResponse
|
events: EventsResponse
|
||||||
riders: RidersResponse
|
riders: RidersResponse
|
||||||
runs: RunsResponse
|
stages: StagesResponse
|
||||||
team_invites: TeamInvitesResponse
|
team_invites: TeamInvitesResponse
|
||||||
teams: TeamsResponse
|
teams: TeamsResponse
|
||||||
times: TimesResponse
|
times: TimesResponse
|
||||||
|
|
@ -333,7 +333,7 @@ export type TypedPocketBase = PocketBase & {
|
||||||
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
collection(idOrName: 'event_series'): RecordService<EventSeriesResponse>
|
||||||
collection(idOrName: 'events'): RecordService<EventsResponse>
|
collection(idOrName: 'events'): RecordService<EventsResponse>
|
||||||
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
collection(idOrName: 'riders'): RecordService<RidersResponse>
|
||||||
collection(idOrName: 'runs'): RecordService<RunsResponse>
|
collection(idOrName: 'stages'): RecordService<StagesResponse>
|
||||||
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
collection(idOrName: 'team_invites'): RecordService<TeamInvitesResponse>
|
||||||
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
collection(idOrName: 'teams'): RecordService<TeamsResponse>
|
||||||
collection(idOrName: 'times'): RecordService<TimesResponse>
|
collection(idOrName: 'times'): RecordService<TimesResponse>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { setTeamContext } from '$lib/stores/teams.svelte'
|
import { setTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
|
||||||
import { setEventContext } from '$lib/stores/events.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 { setRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { setTimeContext } from '$lib/stores/times.svelte'
|
import { setTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
const teams = setTeamContext()
|
const teams = setTeamContext()
|
||||||
const invites = setTeamInviteContext()
|
const invites = setTeamInviteContext()
|
||||||
const events = setEventContext()
|
const events = setEventContext()
|
||||||
const runs = setRunContext()
|
const stages = setStageContext()
|
||||||
const riders = setRiderContext()
|
const riders = setRiderContext()
|
||||||
const times = setTimeContext()
|
const times = setTimeContext()
|
||||||
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
// Nach events und riders: Der Store greift im Constructor auf beide zu.
|
||||||
|
|
@ -56,14 +56,14 @@
|
||||||
await teams.load()
|
await teams.load()
|
||||||
teams.subscribe()
|
teams.subscribe()
|
||||||
await Promise.all([
|
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(),
|
invites.load(),
|
||||||
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
|
||||||
trailComments.load(),
|
trailComments.load(),
|
||||||
])
|
])
|
||||||
invites.subscribe()
|
invites.subscribe()
|
||||||
events.subscribe()
|
events.subscribe()
|
||||||
runs.subscribe()
|
stages.subscribe()
|
||||||
riders.subscribe()
|
riders.subscribe()
|
||||||
times.subscribe()
|
times.subscribe()
|
||||||
participants.subscribe()
|
participants.subscribe()
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
teams.destroy()
|
teams.destroy()
|
||||||
invites.destroy()
|
invites.destroy()
|
||||||
events.destroy()
|
events.destroy()
|
||||||
runs.destroy()
|
stages.destroy()
|
||||||
riders.destroy()
|
riders.destroy()
|
||||||
times.destroy()
|
times.destroy()
|
||||||
participants.destroy()
|
participants.destroy()
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
let dashHover = $state(false)
|
let dashHover = $state(false)
|
||||||
|
|
||||||
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
|
// 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 = [
|
const nav = [
|
||||||
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
|
||||||
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
|
||||||
|
|
@ -171,8 +171,9 @@
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
class="shrink-0 transition-transform relative duration-200 top-2"
|
class="shrink-0 transition-transform relative duration-75 top-2"
|
||||||
class:scale-105={dashHover}
|
class:scale-120={dashHover}
|
||||||
|
class:rotate-3={dashHover}
|
||||||
onmouseenter={() => (dashHover = true)}
|
onmouseenter={() => (dashHover = true)}
|
||||||
onmouseleave={() => (dashHover = false)}
|
onmouseleave={() => (dashHover = false)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
import { getEventContext } from '$lib/stores/events.svelte'
|
||||||
import { getRiderContext } from '$lib/stores/riders.svelte'
|
import { getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
{#each upNext as event}
|
{#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">
|
<Card class="hover:border-primary transition-colors">
|
||||||
<a href="/dashboard/events/{event.id}" class="block">
|
<a href="/dashboard/events/{event.id}" class="block">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
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 { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
import {
|
import {
|
||||||
getEventSeriesContext,
|
getEventSeriesContext,
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
import type { EventsResponse } from '$lib/types'
|
import type { EventsResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
const series = getEventSeriesContext()
|
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>
|
<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">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each group.items as event}
|
{#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">
|
<Card class="hover:border-primary transition-colors">
|
||||||
<a href="/dashboard/events/{event.id}" class="block">
|
<a href="/dashboard/events/{event.id}" class="block">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -328,7 +328,7 @@
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Timer class="size-3.5" />
|
<Timer class="size-3.5" />
|
||||||
{runCount} Run{runCount === 1 ? '' : 's'}
|
{stageCount} Stage{stageCount === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
|
|
@ -416,7 +416,7 @@
|
||||||
mit Zeitnahme
|
mit Zeitnahme
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-muted-foreground -mt-2">
|
<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.
|
unnötig.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
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 { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
|
|
@ -36,10 +36,10 @@
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-svelte'
|
} from 'lucide-svelte'
|
||||||
import type { EventParticipantsResponse, RunsResponse } from '$lib/types'
|
import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
||||||
const eventParticipants = $derived(event ? participants.byEvent(event.id) : [])
|
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 —
|
* Teilnehmer verwaltet, wer das Event angelegt hat, und die Teamleitung —
|
||||||
|
|
@ -65,14 +65,14 @@
|
||||||
!!event && !!ownRider && !ownEntry && event.participation !== 'geschlossen',
|
!!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.
|
// Die Übersicht zeigt deshalb pro Zeile, was dort schon passiert ist.
|
||||||
function riderPath(riderId: string) {
|
function riderPath(riderId: string) {
|
||||||
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
return `/dashboard/events/${event?.id}/riders/${riderId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPath(runId: string) {
|
function stagePath(stageId: string) {
|
||||||
return `/dashboard/events/${event?.id}/runs/${runId}`
|
return `/dashboard/events/${event?.id}/stages/${stageId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
// Zeit-Dialog: der einzige Weg, eine Zeit zu starten oder zu stoppen.
|
||||||
|
|
@ -162,30 +162,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run-Dialog
|
// Stage-Dialog
|
||||||
let runDialog = $state(false)
|
let stageDialog = $state(false)
|
||||||
let runEdit = $state<RunsResponse | null>(null)
|
let stageEdit = $state<StagesResponse | null>(null)
|
||||||
let runForm = $state({ description: '' })
|
let stageForm = $state({ description: '' })
|
||||||
|
|
||||||
function openRunCreate() {
|
function openStageCreate() {
|
||||||
runEdit = null
|
stageEdit = null
|
||||||
runForm = { description: '' }
|
stageForm = { description: '' }
|
||||||
runDialog = true
|
stageDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRunEdit(r: RunsResponse) {
|
function openStageEdit(r: StagesResponse) {
|
||||||
runEdit = r
|
stageEdit = r
|
||||||
runForm = { description: r.description ?? '' }
|
stageForm = { description: r.description ?? '' }
|
||||||
runDialog = true
|
stageDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRun() {
|
async function saveStage() {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
if (!runForm.description.trim()) return alert('Bezeichnung ist Pflicht')
|
if (!stageForm.description.trim()) return alert('Bezeichnung ist Pflicht')
|
||||||
try {
|
try {
|
||||||
if (runEdit) await runs.edit(runEdit.id, runForm)
|
if (stageEdit) await stages.edit(stageEdit.id, stageForm)
|
||||||
else await runs.create({ ...runForm, event: event.id })
|
else await stages.create({ ...stageForm, event: event.id })
|
||||||
runDialog = false
|
stageDialog = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(`Fehler: ${e.message}`)
|
alert(`Fehler: ${e.message}`)
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +236,7 @@
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (timeDialog = true)}
|
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" />
|
<Timer class="h-4 w-4 mr-2" />
|
||||||
Zeit erfassen
|
Zeit erfassen
|
||||||
|
|
@ -342,7 +342,7 @@
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
{done} von {eventRuns.length} Runs
|
{done} von {eventStages.length} Stages
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/if}
|
{/if}
|
||||||
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
<Table.Cell class="text-right whitespace-nowrap" onclick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -411,34 +411,34 @@
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Runs — nur bei Events mit Zeitnahme -->
|
<!-- Stages — nur bei Events mit Zeitnahme -->
|
||||||
{#if event.timing}
|
{#if event.timing}
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||||
<Timer class="h-5 w-5" />
|
<Timer class="h-5 w-5" />
|
||||||
Runs
|
Stages
|
||||||
<span class="text-muted-foreground font-normal text-base">({eventRuns.length})</span>
|
<span class="text-muted-foreground font-normal text-base">({eventStages.length})</span>
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<Button onclick={openRunCreate}>
|
<Button onclick={openStageCreate}>
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Run anlegen
|
Stage anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
{#if eventRuns.length === 0}
|
{#if eventStages.length === 0}
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
<Timer class="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
<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>
|
<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" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Ersten Run anlegen
|
Erste Stage anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -451,13 +451,13 @@
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each eventRuns as run (run.id)}
|
{#each eventStages as stage (stage.id)}
|
||||||
{@const runTimes = times.getByRun(run.id)}
|
{@const stageTimes = times.getByStage(stage.id)}
|
||||||
{@const active = runTimes.filter((t) => t.status === 'active').length}
|
{@const active = stageTimes.filter((t) => t.status === 'active').length}
|
||||||
{@const ranked = runTimes.filter((t) => t.status === 'finished').length}
|
{@const ranked = stageTimes.filter((t) => t.status === 'finished').length}
|
||||||
<Table.Row class="cursor-pointer" onclick={() => goto(runPath(run.id))}>
|
<Table.Row class="cursor-pointer" onclick={() => goto(stagePath(stage.id))}>
|
||||||
<Table.Cell class="font-medium">
|
<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>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if active > 0}
|
{#if active > 0}
|
||||||
|
|
@ -466,10 +466,10 @@
|
||||||
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
|
<span class="text-muted-foreground text-sm">{ranked} gewertet</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right" onclick={(e) => e.stopPropagation()}>
|
<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" />
|
<Pencil class="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
@ -539,29 +539,29 @@
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Run-Dialog -->
|
<!-- Stage-Dialog -->
|
||||||
<Dialog.Root bind:open={runDialog}>
|
<Dialog.Root bind:open={stageDialog}>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{runEdit ? 'Run bearbeiten' : 'Neuer Run'}</Dialog.Title>
|
<Dialog.Title>{stageEdit ? 'Stage bearbeiten' : 'Neue Stage'}</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Run für „{event?.name}"
|
Stage für „{event?.name}"
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
saveRun()
|
saveStage()
|
||||||
}}
|
}}
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="run-desc">Bezeichnung *</Label>
|
<Label for="stage-desc">Bezeichnung *</Label>
|
||||||
<Input id="run-desc" bind:value={runForm.description} placeholder="Stage 1 – Downhill" required />
|
<Input id="stage-desc" bind:value={stageForm.description} placeholder="Stage 1 – Downhill" required />
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button type="button" variant="ghost" onclick={() => (runDialog = false)}>Abbrechen</Button>
|
<Button type="button" variant="ghost" onclick={() => (stageDialog = false)}>Abbrechen</Button>
|
||||||
<Button type="submit">{runEdit ? 'Speichern' : 'Anlegen'}</Button>
|
<Button type="submit">{stageEdit ? 'Speichern' : 'Anlegen'}</Button>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</form>
|
</form>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Fahrerübersicht: alle Runs des Events aus Sicht eines Fahrers. Gezeigt
|
* Fahrerübersicht: alle Stages des Events aus Sicht eines Fahrers. Gezeigt
|
||||||
* wird je Run seine Zeit, sein Platz und ob gerade gemessen wird — die
|
* wird je Stage seine Zeit, sein Platz und ob gerade gemessen wird — die
|
||||||
* Zeitnahme selbst passiert auf der Run-Seite, dorthin führt jede Zeile.
|
* Zeitnahme selbst passiert auf der Stage-Seite, dorthin führt jede Zeile.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
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 { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
|
|
@ -37,40 +37,40 @@
|
||||||
const valid = $derived(!!event && !!rider && !!entry)
|
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.
|
* bleibt, was dem Fahrer noch fehlt.
|
||||||
*/
|
*/
|
||||||
const rows = $derived.by(() => {
|
const rows = $derived.by(() => {
|
||||||
if (!valid || !event || !rider) return []
|
if (!valid || !event || !rider) return []
|
||||||
|
|
||||||
// clock.now nur für laufende Zeiten nötig, hält aber die ganze Liste
|
// 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)
|
const own = times.getByRider(rider.id, clock.now)
|
||||||
|
|
||||||
return runs.getByEvent(event.id).map((run) => {
|
return stages.getByEvent(event.id).map((stage) => {
|
||||||
const forRun = own.filter((t) => t.run === run.id)
|
const forStage = own.filter((t) => t.stage === stage.id)
|
||||||
const active = forRun.find((t) => t.status === 'active')
|
const active = forStage.find((t) => t.status === 'active')
|
||||||
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
|
// Bei mehreren Versuchen zählt die schnellste gewertete Zeit.
|
||||||
const best = forRun
|
const best = forStage
|
||||||
.filter((t) => t.status === 'finished')
|
.filter((t) => t.status === 'finished')
|
||||||
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
|
.sort((a, b) => (a.duration ?? 0) - (b.duration ?? 0))[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run,
|
stage,
|
||||||
active,
|
active,
|
||||||
best,
|
best,
|
||||||
others: forRun.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
others: forStage.filter((t) => t.status !== 'active' && t.status !== 'finished'),
|
||||||
placement: best ? times.placement(run.id, rider.id) : null,
|
placement: best ? times.placement(stage.id, rider.id) : null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
// Start und Stopp laufen ausschließlich über den Zeit-Dialog.
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
let dialogRun = $state<string | null>(null)
|
let dialogStage = $state<string | null>(null)
|
||||||
|
|
||||||
function openTimeDialog(runId: string | null = null) {
|
function openTimeDialog(stageId: string | null = null) {
|
||||||
dialogRun = runId
|
dialogStage = stageId
|
||||||
timeDialog = true
|
timeDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
|
<div class="text-3xl font-bold">{stats.done}<span class="text-lg text-muted-foreground">/{stats.of}</span></div>
|
||||||
|
|
@ -163,19 +163,19 @@
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<Timer class="h-4 w-4 text-primary" />
|
<Timer class="h-4 w-4 text-primary" />
|
||||||
Runs ({rows.length})
|
Stages ({rows.length})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-0">
|
||||||
{#if rows.length === 0}
|
{#if rows.length === 0}
|
||||||
<p class="px-6 py-8 text-sm text-center text-muted-foreground">
|
<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>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Run</Table.Head>
|
<Table.Head>Stage</Table.Head>
|
||||||
<Table.Head class="w-24">Status</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-28">Zeit</Table.Head>
|
||||||
<Table.Head class="text-right w-20">Platz</Table.Head>
|
<Table.Head class="text-right w-20">Platz</Table.Head>
|
||||||
|
|
@ -183,17 +183,17 @@
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as row (row.run.id)}
|
{#each rows as row (row.stage.id)}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer"
|
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">
|
<Table.Cell class="font-medium">
|
||||||
<a
|
<a
|
||||||
href="/dashboard/events/{event.id}/runs/{row.run.id}"
|
href="/dashboard/events/{event.id}/stages/{row.stage.id}"
|
||||||
class="hover:underline"
|
class="hover:underline"
|
||||||
>
|
>
|
||||||
{row.run.description || 'Run'}
|
{row.stage.description || 'Stage'}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|
@ -244,5 +244,5 @@
|
||||||
bind:open={timeDialog}
|
bind:open={timeDialog}
|
||||||
eventId={event?.id ?? ''}
|
eventId={event?.id ?? ''}
|
||||||
riderId={rider?.id ?? null}
|
riderId={rider?.id ?? null}
|
||||||
runId={dialogRun}
|
stageId={dialogStage}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<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
|
* 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
|
* und gestoppt wird ausschließlich im Zeit-Dialog. Auf dem Handy an der
|
||||||
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
|
* Strecke ist ein versehentlicher Treffer sonst schnell passiert, und
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/state'
|
import { page } from '$app/state'
|
||||||
import { getEventContext } from '$lib/stores/events.svelte'
|
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 { riderName, getRiderContext } from '$lib/stores/riders.svelte'
|
||||||
import { getTimeContext } from '$lib/stores/times.svelte'
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
||||||
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
|
import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
|
||||||
|
|
||||||
const events = getEventContext()
|
const events = getEventContext()
|
||||||
const runs = getRunContext()
|
const stages = getStageContext()
|
||||||
const riders = getRiderContext()
|
const riders = getRiderContext()
|
||||||
const times = getTimeContext()
|
const times = getTimeContext()
|
||||||
const participants = getEventParticipantContext()
|
const participants = getEventParticipantContext()
|
||||||
|
|
@ -30,17 +30,17 @@
|
||||||
const clock = ticker()
|
const clock = ticker()
|
||||||
|
|
||||||
const event = $derived(page.params.id ? events.getById(page.params.id) : undefined)
|
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.
|
// 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.
|
// Am Start steht, wer beim Event zugesagt hat.
|
||||||
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
const starters = $derived(event ? participants.confirmedByEvent(event.id) : [])
|
||||||
const running = $derived(valid && run ? times.runningByRun(run.id, clock.now) : [])
|
const running = $derived(valid && stage ? times.runningByStage(stage.id, clock.now) : [])
|
||||||
const leaderboard = $derived(valid && run ? times.getLeaderboard(run.id) : [])
|
const leaderboard = $derived(valid && stage ? times.getLeaderboard(stage.id) : [])
|
||||||
const unranked = $derived(valid && run ? times.unrankedByRun(run.id) : [])
|
const unranked = $derived(valid && stage ? times.unrankedByStage(stage.id) : [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
|
* Laufende und gewertete Zeiten in einer Liste: Wer gerade unterwegs ist,
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Zeitnahme --------------------------------------------------------
|
// --- 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.
|
// dabei vorbelegt, den Fahrer wählt man dort.
|
||||||
let timeDialog = $state(false)
|
let timeDialog = $state(false)
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const csv = rows.map((r) => r.map((c) => `"${c.replaceAll('"', '""')}"`).join(',')).join('\n')
|
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 blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
|
|
@ -93,13 +93,13 @@
|
||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if !valid}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="py-16 text-center">
|
<CardContent class="py-16 text-center">
|
||||||
<Timer class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<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">
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
Vermutlich gehört er nicht zum aktiven Team oder wurde gelöscht.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{:else if event && run}
|
{:else if event && stage}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
<ArrowLeft class="h-3 w-3" />
|
<ArrowLeft class="h-3 w-3" />
|
||||||
{event.name || 'Event'}
|
{event.name || 'Event'}
|
||||||
</a>
|
</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">
|
<p class="mt-1 text-muted-foreground">
|
||||||
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
|
{running.length} laufend · {leaderboard.length} gewertet · {starters.length} am Start
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -249,4 +249,4 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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.
|
* der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
|
||||||
*/
|
*/
|
||||||
function riderPath(riderId: string) {
|
function riderPath(riderId: string) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue