Damit sind alle offenen Punkte aus TODO.md gebaut. Fahrer gehoeren ab jetzt dem Team, nicht mehr einem Event. Wer bei einem Termin dabei ist, steht in event_participants - dort haengt auch die Startnummer, denn die gilt je Event: Derselbe Fahrer kann in zwei Rennen zwei Nummern haben und beim Stammtisch gar keine. Die vorhandenen Daten wandern in der Migration mit. Events tragen zwei Schalter. timing blendet Runs und Zeitnahme ein - der woechentliche Stammtisch braucht sie nicht. participation regelt, wie man dazukommt: offen (jeder traegt sich selbst ein), bestaetigung (Anmeldung, Ersteller oder Team-Admin bestaetigt) oder geschlossen. Durchgesetzt wird das serverseitig, nicht nur im Formular. Serien erzeugen echte Einzeltermine statt einer Wiederholungsregel, aus der Termine nur gerechnet werden. Nur so laesst sich beantworten, wer am 12. kommt, und nur so faellt eine einzelne Woche aus. Gerechnet wird auf Kalendertagen: ueber die Zeitumstellung hinweg kippte ein Termin sonst auf den Vortag - dafuer gibt es einen Test. Ausserdem: Teammitglieder duerfen den Zustand eines Trails melden. Die Regel gibt genau die drei Statusfelder frei und schliesst jedes andere aus; ohne das haette man Mitgliedern das ganze Objekt oeffnen muessen. Und bestehende Konten lassen sich wieder per E-Mail ins Team holen, wofuer users.listRule sich oeffnet. Die neuen Regeln sind ungetestet - weder Docker noch eine lokale PocketBase-Instanz standen zur Verfuegung. TODO.md sagt, was nach dem Deploy durchzuspielen ist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
155 lines
6.3 KiB
Svelte
155 lines
6.3 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
import { getTimeContext } from '$lib/stores/times.svelte'
|
|
import { fullName, getRiderContext } from '$lib/stores/riders.svelte'
|
|
import { getRunContext } from '$lib/stores/runs.svelte'
|
|
import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
|
|
import { ticker } from '$lib/stores/ticker.svelte'
|
|
import TimeDialog from './TimeDialog.svelte'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte'
|
|
|
|
const times = getTimeContext()
|
|
const riders = getRiderContext()
|
|
const runs = getRunContext()
|
|
const participants = getEventParticipantContext()
|
|
const clock = ticker()
|
|
|
|
const running = $derived(times.allRunning(clock.now))
|
|
const single = $derived(running.length === 1)
|
|
|
|
let expanded = $state(false)
|
|
|
|
// Vorbelegung für den Zeit-Dialog
|
|
let timeDialog = $state(false)
|
|
let dialogEvent = $state('')
|
|
let dialogRider = $state<string | null>(null)
|
|
let dialogRun = $state<string | null>(null)
|
|
|
|
function openTimeDialog(riderId: string | undefined, runId: string | undefined) {
|
|
const run = runId ? runs.getById(runId) : undefined
|
|
if (!run?.event) return
|
|
|
|
dialogEvent = run.event
|
|
dialogRider = riderId ?? null
|
|
dialogRun = run.id
|
|
timeDialog = true
|
|
}
|
|
|
|
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
|
|
// 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
|
|
if (!eventId) {
|
|
const r = riderId ? riders.getById(riderId) : undefined
|
|
return r ? fullName(r) : 'Unbekannter Fahrer'
|
|
}
|
|
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 runLabel(runId: string | undefined) {
|
|
const run = runId ? runs.getById(runId) : undefined
|
|
return run?.description || 'Run'
|
|
}
|
|
</script>
|
|
|
|
{#if running.length > 0}
|
|
<!-- fixed statt sticky: Die Leiste soll auch beim Scrollen stehen bleiben. -->
|
|
<div class="fixed bottom-4 right-4 z-50 w-[min(24rem,calc(100vw-2rem))]">
|
|
<div class="rounded-lg border bg-card text-card-foreground shadow-lg overflow-hidden">
|
|
<div class="flex items-center gap-2 px-3 py-2 border-b bg-primary/5">
|
|
<span class="relative flex size-2 shrink-0">
|
|
<span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span>
|
|
<span class="relative inline-flex size-2 rounded-full bg-primary"></span>
|
|
</span>
|
|
|
|
<span class="text-sm font-medium">
|
|
{running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`}
|
|
</span>
|
|
|
|
{#if !open}
|
|
<span class="text-sm tabular-nums font-semibold ml-auto">
|
|
{running[0].formattedTime}
|
|
</span>
|
|
{/if}
|
|
|
|
{#if !single}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="ml-auto size-7 shrink-0"
|
|
tooltip={expanded ? 'Einklappen' : 'Alle anzeigen'}
|
|
onclick={() => (expanded = !expanded)}
|
|
>
|
|
{#if expanded}
|
|
<ChevronDown class="size-4" />
|
|
{:else}
|
|
<ChevronUp class="size-4" />
|
|
{/if}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if open}
|
|
<ul class="divide-y max-h-[50vh] overflow-y-auto">
|
|
{#each running as t (t.id)}
|
|
{@const path = runPath(t.run)}
|
|
<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-xs text-muted-foreground truncate">
|
|
{#if path}
|
|
<a href={path} class="hover:underline">{runLabel(t.run)}</a>
|
|
{:else}
|
|
{runLabel(t.run)}
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<span class="text-lg font-semibold tabular-nums shrink-0">
|
|
{t.formattedTime}
|
|
</span>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="shrink-0"
|
|
tooltip="Zeit erfassen"
|
|
onclick={() => openTimeDialog(t.rider, t.run)}
|
|
>
|
|
<Timer class="size-4" />
|
|
</Button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<TimeDialog
|
|
bind:open={timeDialog}
|
|
eventId={dialogEvent}
|
|
riderId={dialogRider}
|
|
runId={dialogRun}
|
|
/>
|
|
{/if}
|