Compare commits

..

No commits in common. "f773c52b0acfc235da3e4ee0009facb373e33e2b" and "0f4b532773814c1e32b34953d9cf9bb76f108460" have entirely different histories.

33 changed files with 450 additions and 2230 deletions

View file

@ -1,21 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// Die gemeinsame Uhr.
//
// Zeiten entstehen auf den Geraeten an der Strecke, nicht auf dem Server:
// Nur so laesst sich im Funkloch ueberhaupt stoppen. Damit steckt aber der
// Versatz zweier Telefonuhren in jeder Messung, bei der einer startet und ein
// anderer stoppt. Also braucht es eine Referenz, gegen die sich jedes Geraet
// vor dem Event abgleicht — und das ist diese Route.
//
// Warum nicht der `Date`-Header jeder beliebigen Antwort: Der hat nach
// RFC 9110 Sekundenaufloesung. Allein daraus folgte ein Fehler von bis zu
// einer halben Sekunde — auf einer Stage der Unterschied zwischen Platz eins
// und Platz drei. Hier kommen Millisekunden.
//
// Ohne Login: Es ist die Uhrzeit. Sie verraet nichts, was nicht jede Antwort
// des Servers ohnehin im Date-Header mitschickt.
routerAdd('GET', '/api/clock', (e) => {
return e.json(200, { now: Date.now() })
})

View file

@ -18,7 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.42.0", "@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.43.2", "@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.0",
@ -32,7 +32,7 @@
"svelte": "^5.39.5", "svelte": "^5.39.5",
"svelte-check": "^4.3.2", "svelte-check": "^4.3.2",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.3.1", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",
@ -812,9 +812,9 @@
} }
}, },
"node_modules/@lucide/svelte": { "node_modules/@lucide/svelte": {
"version": "1.42.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.42.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz",
"integrity": "sha512-9vOi3EdzJht5aD7F5OraoxrK2GyyL1Y+kVXEtIg4+IAdKmDBrwYK2d/x7akC89uA+cvkfjcw+IuzH0vfd/TuzA==", "integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
@ -5737,13 +5737,13 @@
} }
}, },
"node_modules/tailwind-variants": { "node_modules/tailwind-variants": {
"version": "3.3.1", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz", "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==", "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.x", "node": ">=16.x",
"pnpm": ">=7.x" "pnpm": ">=7.x"
}, },
"peerDependencies": { "peerDependencies": {
@ -5753,9 +5753,6 @@
"peerDependenciesMeta": { "peerDependenciesMeta": {
"tailwind-merge": { "tailwind-merge": {
"optional": true "optional": true
},
"tailwindcss": {
"optional": true
} }
} }
}, },

View file

@ -19,7 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.42.0", "@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.43.2", "@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.0",
@ -33,7 +33,7 @@
"svelte": "^5.39.5", "svelte": "^5.39.5",
"svelte-check": "^4.3.2", "svelte-check": "^4.3.2",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.3.1", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",

View file

@ -145,18 +145,3 @@
cursor: default; cursor: default;
} }
} }
@layer components {
/*
* Startnummern wie am Trikot: schmal, fett, ohne Zierrat. Impact ist auf
* Windows und macOS da, Haettenschweiler und Arial Narrow Bold springen
* ein; am Ende steht eine beliebige serifenlose Schrift, dann sieht die
* Nummer eben normal aus statt falsch.
*/
.font-startnumber {
font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', 'Franklin Gothic Bold', sans-serif;
font-weight: 700;
font-stretch: condensed;
letter-spacing: 0.02em;
}
}

View file

@ -3,10 +3,6 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Installierbar, damit die Zeitnahme an der Strecke ein Funkloch
auch dann uebersteht, wenn jemand die App neu startet. -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eab308" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -1,120 +0,0 @@
<script lang="ts">
/**
* Uhrenabgleich vor dem Event — der Dialog, den jeder aufruft, der an
* diesem Tag Zeiten nimmt.
*
* Warum das nötig ist: Zeiten entstehen auf den Geräten an der Strecke,
* damit im Funkloch überhaupt gestoppt werden kann. Startet aber einer am
* Start und stoppt ein anderer im Ziel, steckt der Versatz beider
* Telefonuhren in der gemessenen Dauer. Der Abgleich holt beide auf
* dieselbe Skala — die des Servers.
*
* Der Versatz gehört dem Gerät und liegt im localStorage; er überlebt
* Seitenwechsel und Abmeldung, aber nicht das Löschen der Websitedaten.
*/
import { clockSync, ACCURACY_LIMIT_MS, STALE_AFTER_MS } from '$lib/stores/clockSync.svelte'
import { Button } from '@/components/ui/button'
import * as Dialog from '@/components/ui/dialog'
import { CircleCheck, RefreshCw, TriangleAlert } from 'lucide-svelte'
let { open = $bindable(false) }: { open?: boolean } = $props()
/** Millisekunden lesbar: unter einer Sekunde in ms, darüber in Sekunden. */
function ms(value: number): string {
const abs = Math.abs(value)
if (abs < 1000) return `${Math.round(value)} ms`
return `${(value / 1000).toFixed(value < 10000 ? 2 : 1)} s`
}
function age(value: number): string {
const min = Math.round(value / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `vor ${min} min`
const h = Math.round(min / 60)
return `vor ${h} h`
}
const good = $derived(clockSync.trustworthy)
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Uhr abgleichen</Dialog.Title>
<Dialog.Description>
Gestartet und gestoppt wird auf den Geräten an der Strecke. Damit die
Zeit stimmt, auch wenn Start und Ziel verschiedene Telefone sind,
stellt jedes Gerät seine Uhr einmal gegen den Server.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div
class="flex items-start gap-3 rounded-lg border p-3"
class:border-primary={good}
class:bg-primary={false}
>
{#if good}
<CircleCheck class="mt-0.5 size-5 shrink-0 text-primary" />
{:else}
<TriangleAlert class="mt-0.5 size-5 shrink-0 text-amber-600" />
{/if}
<div class="min-w-0 flex-1 space-y-1">
{#if !clockSync.measured}
<p class="text-sm font-medium">Noch nicht abgeglichen</p>
<p class="text-sm text-muted-foreground">
Bis dahin gilt die Uhr dieses Geräts, wie sie ist.
</p>
{:else}
<p class="text-sm font-medium">
Dieses Gerät geht
{#if Math.abs(clockSync.offset) < 50}
richtig
{:else if clockSync.offset < 0}
{ms(-clockSync.offset)} vor
{:else}
{ms(clockSync.offset)} nach
{/if}
</p>
<p class="text-sm text-muted-foreground">
Gemessen {age(clockSync.age ?? 0)}, auf ±{ms(clockSync.accuracy ?? 0)} genau.
Die Abweichung wird beim Stoppen herausgerechnet.
</p>
{#if clockSync.stale}
<p class="text-sm text-amber-600">
Die Messung ist älter als
{Math.round(STALE_AFTER_MS / 3600000)} Stunden — vor dem Start
noch einmal abgleichen.
</p>
{:else if (clockSync.accuracy ?? 0) > ACCURACY_LIMIT_MS}
<p class="text-sm text-amber-600">
Die Verbindung war zu langsam für eine genaue Messung. Mit
besserem Empfang noch einmal versuchen.
</p>
{/if}
{/if}
{#if clockSync.error}
<p class="text-sm text-destructive">{clockSync.error}</p>
{/if}
</div>
</div>
<p class="text-xs text-muted-foreground">
Der Abgleich braucht Netz und dauert eine Sekunde. Danach zählt die
Zeitnahme auch im Funkloch richtig weiter — übertragen wird, sobald
wieder Empfang da ist.
</p>
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Schließen</Button>
<Button onclick={() => clockSync.measure()} disabled={clockSync.busy}>
<RefreshCw class="size-4 mr-2 {clockSync.busy ? 'animate-spin' : ''}" />
{clockSync.busy ? 'Wird gemessen …' : 'Jetzt abgleichen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -166,21 +166,14 @@
role="img" role="img"
aria-hidden="true" aria-hidden="true"
> >
<!-- Weiss zu 90 %, die Flaeche darunter blass. --> <path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
<path
d={path}
fill="rgb(255 255 255 / 0.2)"
stroke="rgb(255 255 255 / 0.9)"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
{#if hover} {#if hover}
<line <line
x1={(hover.left / 100) * W} x1={(hover.left / 100) * W}
y1="0" y1="0"
x2={(hover.left / 100) * W} x2={(hover.left / 100) * W}
y2={H} y2={H}
stroke="rgb(255 255 255 / 0.9)" stroke="rgb(14 165 233)"
stroke-width="1" stroke-width="1"
vector-effect="non-scaling-stroke" vector-effect="non-scaling-stroke"
/> />
@ -195,7 +188,7 @@
zur Ellipse und Text mitverzerrt. zur Ellipse und Text mitverzerrt.
--> -->
<span <span
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white ring-2 ring-background" class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500 ring-2 ring-background"
style:left="{hover.left}%" style:left="{hover.left}%"
style:top="{hover.top}%" style:top="{hover.top}%"
></span> ></span>

View file

@ -1,18 +1,16 @@
<script lang="ts"> <script lang="ts">
/** /**
* Icon-Auswahl: das Raster ist die Auswahl, nicht ein Vorschlag daneben. * Icon-Auswahl: Vorschau, Freitextfeld und ein Raster mit Vorschlägen.
* * Erlaubt ist jeder Name von lucide.dev — die Vorschläge sind nur die
* Frueher stand hier zusaetzlich ein Freitextfeld fuer jeden beliebigen * Abkürzung fürs häufig Gebrauchte.
* Namen von lucide.dev. Das verlangte, einen Katalog von tausend Icons zu
* kennen und den Namen fehlerfrei abzutippen, und belohnte den Tippfehler
* mit einem leeren Kaestchen. Was zur Wahl steht, steht jetzt da.
* *
* Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit * Gemeinsam genutzt von der Flag-Verwaltung und dem Marker-Dialog, damit
* beide dieselbe Auswahl zeigen. * beide dieselbe Auswahl zeigen.
*/ */
import FlagIcon from './FlagIcon.svelte' import FlagIcon from './FlagIcon.svelte'
import { tooltip } from '$lib/stores/app.svelte' import { tooltip } from '$lib/stores/app.svelte'
import { FLAG_ICON_SUGGESTIONS } from '$lib/flagIcons' import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
let { let {
@ -22,7 +20,7 @@
label = 'Icon', label = 'Icon',
}: { }: {
value?: string value?: string
/** Faerbt die Icons im Raster, damit man das Ergebnis sieht. */ /** Färbt Vorschau und Vorschläge, damit man das Ergebnis sieht. */
color?: string color?: string
id?: string id?: string
label?: string label?: string
@ -30,11 +28,24 @@
</script> </script>
<div class="space-y-2"> <div class="space-y-2">
<Label id="{id}-label">{label}</Label> <Label for={id}>{label}</Label>
<!-- Eine Gruppe von Knoepfen, kein Eingabefeld: benannt ueber <div class="flex items-center gap-2">
aria-labelledby statt ueber ein <label for>. --> <span class="grid size-9 shrink-0 place-items-center rounded-md border">
<div role="group" aria-labelledby="{id}-label" class="grid grid-cols-10 gap-1 rounded-lg border p-2"> <FlagIcon name={value} {color} class="size-5" />
</span>
<Input {id} bind:value placeholder="z. B. TreePine" class="flex-1" />
</div>
<p class="text-xs text-muted-foreground">
Jeder Name von <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer" class="underline">lucide.dev</a>
ist möglich — geschrieben wie „TreePine" oder „tree-pine".
{#if value.trim() && !iconExists(value)}
<span class="text-destructive">Diesen Namen gibt es dort nicht.</span>
{/if}
</p>
<div class="grid grid-cols-10 gap-1 rounded-lg border p-2">
{#each FLAG_ICON_SUGGESTIONS as name (name)} {#each FLAG_ICON_SUGGESTIONS as name (name)}
<button <button
type="button" type="button"

View file

@ -1,31 +1,24 @@
<script lang="ts"> <script lang="ts">
/** /**
* Die Leiste über allen Dashboard-Seiten: alles, was den Zeitnehmer * Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange
* betrifft, an einer Stelle. Von oben nach unten mit fallender * irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen
* Dringlichkeit — fehlende Verbindung, nicht übertragene Zeiten, laufende * zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr
* Uhren. * bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-Seite.
* *
* Gedacht für die Strecke: Man legt zwischen zwei Startern die Trails * Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die
* oder die Fahrerliste an, und die laufende Uhr bleibt trotzdem im Blick — * längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen.
* samt Stopp ohne Umweg über die Stage-Seite.
* *
* Aufgeklappt ist der Normalfall: Wer an der Strecke steht, will die * Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den
* laufenden Zeiten sehen und nicht erst danach fragen. Einklappen geht, * Zeit-Dialog mit Fahrer und Stage vorbelegt.
* sobald es mehr als eine ist. Bei genau einer entfällt das Klappen.
*
* Gestoppt wird auch von hier aus nicht direkt: Ein Klick auf die Zeile
* öffnet den 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 { getStageContext } from '$lib/stores/stages.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 { timeOutbox } from '$lib/stores/timeOutbox.svelte'
import { connection } from '$lib/stores/connection.svelte'
import TimeDialog from './TimeDialog.svelte' import TimeDialog from './TimeDialog.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, CloudOff, RefreshCw, Timer, WifiOff } from 'lucide-svelte' import { ChevronDown, ChevronUp, Timer } from 'lucide-svelte'
const times = getTimeContext() const times = getTimeContext()
const riders = getRiderContext() const riders = getRiderContext()
@ -36,7 +29,7 @@
const running = $derived(times.allRunning(clock.now)) const running = $derived(times.allRunning(clock.now))
const single = $derived(running.length === 1) const single = $derived(running.length === 1)
let expanded = $state(true) let expanded = $state(false)
// Vorbelegung für den Zeit-Dialog // Vorbelegung für den Zeit-Dialog
let timeDialog = $state(false) let timeDialog = $state(false)
@ -55,8 +48,7 @@
} }
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der // Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer — voreingestellt ist offen. Fällt die Anzahl wieder auf eine, // Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal.
// ist der Zustand egal.
const open = $derived(single || expanded) const open = $derived(single || expanded)
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */ /** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
@ -69,40 +61,30 @@
return participants.riderLabel(eventId, riderId) return participants.riderLabel(eventId, riderId)
} }
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 stageLabel(stageId: string | undefined) { function stageLabel(stageId: string | undefined) {
const stage = stageId ? stages.getById(stageId) : undefined const stage = stageId ? stages.getById(stageId) : undefined
return stage?.description || 'Stage' return stage?.description || 'Stage'
} }
/** „seit 3 min" — wie lange die Verbindung schon fehlt. */
function since(ms: number): string {
const min = Math.floor(ms / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `seit ${min} min`
return `seit ${Math.floor(min / 60)} h`
}
</script> </script>
{#if running.length > 0 || timeOutbox.pending > 0 || !connection.online} {#if running.length > 0}
<!-- fixed statt sticky: Die Leiste soll auch beim Scrollen stehen bleiben. --> <!-- 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="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="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"> <div class="flex items-center gap-2 px-3 py-2 border-b bg-primary/5">
{#if running.length > 0}
<span class="relative flex size-2 shrink-0"> <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="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 class="relative inline-flex size-2 rounded-full bg-primary"></span>
</span> </span>
{/if}
<span class="text-sm font-medium"> <span class="text-sm font-medium">
{#if running.length === 0} {running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`}
Zeitnahme
{:else if running.length === 1}
Zeit läuft
{:else}
{running.length} Zeiten laufen
{/if}
</span> </span>
{#if !open} {#if !open}
@ -128,105 +110,35 @@
{/if} {/if}
</div> </div>
<!--
Die fehlende Verbindung steht ueber allem: Sie ist der Grund,
aus dem die Zeiten darunter liegenbleiben. Die Meldung geht
erst, wenn der Server wieder antwortet — an der Strecke waeren
fuenf Sekunden Einblendung dieselbe Katastrophe wie gar keine.
-->
{#if !connection.online}
<div class="flex items-start gap-2 border-b bg-amber-500/15 px-3 py-2">
<WifiOff class="mt-0.5 size-4 shrink-0 text-amber-600" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium">
Keine Verbindung
{#if connection.offlineFor !== null}
<span class="font-normal text-muted-foreground">
({since(connection.offlineFor)})
</span>
{/if}
</p>
<p class="text-xs text-muted-foreground">
Die Zeitnahme läuft weiter. Gestoppt wird auf diesem Gerät,
übertragen, sobald der Server wieder antwortet.
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
tooltip="Verbindung prüfen"
disabled={connection.checking}
onclick={() => connection.check()}
>
<RefreshCw class="size-4 {connection.checking ? 'animate-spin' : ''}" />
</Button>
</div>
{/if}
<!--
Nicht uebertragene Zeiten darunter: Eine Zeit, die nur auf
diesem Geraet existiert, ist das Naechstdringende.
-->
{#if timeOutbox.pending > 0}
<div class="flex items-center gap-2 border-b bg-amber-500/10 px-3 py-2">
<CloudOff class="size-4 shrink-0 text-amber-600" />
<span class="min-w-0 flex-1 text-xs">
{timeOutbox.pending}
{timeOutbox.pending === 1 ? 'Zeit liegt' : 'Zeiten liegen'} nur auf diesem
Gerät.
{#if timeOutbox.error}
<span class="block text-destructive">{timeOutbox.error}</span>
{/if}
</span>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
tooltip="Jetzt übertragen"
disabled={timeOutbox.flushing}
onclick={() => timeOutbox.flush()}
>
<RefreshCw class="size-4 {timeOutbox.flushing ? 'animate-spin' : ''}" />
</Button>
</div>
{/if}
{#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)}
<li> {@const path = stagePath(t.stage)}
<!-- <li class="flex items-center gap-2 px-3 py-2">
Die ganze Zeile ist der Knopf, nicht ein Icon <div class="min-w-0 flex-1">
an ihrem Rand: An der Strecke wird mit dem <p class="text-sm font-medium truncate">{riderLabel(t.rider, t.stage)}</p>
Daumen bedient, und ein 16-px-Ziel neben einer <p class="text-xs text-muted-foreground truncate">
fingerbreiten Zeile ist eine Einladung zum {#if path}
Danebentippen. <a href={path} class="hover:underline">{stageLabel(t.stage)}</a>
{:else}
Der Name der Stage verlinkt deshalb nicht mehr
auf ihre Seite — ein Link im Knopf waere weder
gueltiges HTML noch eindeutig zu treffen.
-->
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-accent"
onclick={() => openTimeDialog(t.rider, t.stage)}
>
<Timer class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">
{riderLabel(t.rider, t.stage)}
</span>
<span class="block truncate text-xs text-muted-foreground">
{stageLabel(t.stage)} {stageLabel(t.stage)}
</span> {/if}
</span> </p>
</div>
<span class="shrink-0 text-lg font-semibold tabular-nums"> <span class="text-lg font-semibold tabular-nums shrink-0">
{t.formattedTime} {t.formattedTime}
</span> </span>
</button>
<Button
variant="ghost"
size="icon"
class="shrink-0"
tooltip="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.stage)}
>
<Timer class="size-4" />
</Button>
</li> </li>
{/each} {/each}
</ul> </ul>

View file

@ -4,28 +4,18 @@
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams * Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
* und der Weg zu einem neuen. * und der Weg zu einem neuen.
* *
* Traegt bits-uis NavigationMenu und nicht das DropdownMenu. Ein Dropdown * Der Punkt selbst ist ein Link auf die Teamseite. Ein eigener Eintrag
* ist ein Menue, das auf Ansage aufgeht; eine Navigationsleiste ist etwas * „Uebersicht" waere die zweite Tuer zu derselben Seite, und eine
* anderes, und die Unterschiede sind genau die, die hier gebraucht werden: * Ueberschrift ueber drei Teamnamen sagt nichts, was die Namen nicht
* Aufklappen beim Ueberfahren samt Verzoegerung, ein Klick, der nach dem * schon sagen.
* Hover-Aufklappen nicht wieder zuklappt, kein Fokusfang, keine
* Scrollsperre, kein Fokusring nach dem Zeigerbesuch. Das alles stand hier
* vorher als Handarbeit — Zeitgeber, ueberschriebene Zeigerhandler,
* unterdrueckter Fokus. Die Komponente kann es von sich aus.
*
* Der Punkt selbst bleibt ein Link auf die Teamseite: Der Trigger wird
* ueber das child-Snippet als <a> gezeichnet. bits-ui haelt seinen eigenen
* onclick zurueck, wenn per Zeiger aufgeklappt wurde („if opened via
* pointer move, we prevent the click event"), der Klick landet also beim
* Link.
* *
* Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf * Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf
* die Teamseite, und gewechselt wird in deren Liste. * die Teamseite, und gewechselt wird in deren Liste „Meine Teams".
*/ */
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte' import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import * as NavigationMenu from '@/components/ui/navigation-menu' import * as DropdownMenu from '@/components/ui/dropdown-menu'
import { ChevronDown, Plus, Users } from 'lucide-svelte' import { ChevronDown, Plus, Users } from 'lucide-svelte'
let { let {
@ -36,6 +26,21 @@
const teams = getTeamContext() const teams = getTeamContext()
let createDialog = $state(false) let createDialog = $state(false)
let menuOpen = $state(false)
// Zwischen Knopf und aufgeklapptem Menue liegt ein Spalt. Ohne die kurze
// Frist fiele das Menue zu, sobald der Zeiger ihn ueberquert.
let closeTimer: ReturnType<typeof setTimeout> | null = null
function enter() {
if (closeTimer) clearTimeout(closeTimer)
menuOpen = true
}
function leave() {
if (closeTimer) clearTimeout(closeTimer)
closeTimer = setTimeout(() => (menuOpen = false), 150)
}
/** /**
* Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert * Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert
@ -47,55 +52,86 @@
goto('/dashboard/team') goto('/dashboard/team')
onnavigate?.() onnavigate?.()
} }
const itemClass =
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left transition-colors hover:bg-accent'
</script> </script>
<NavigationMenu.Item value="team"> <DropdownMenu.Root bind:open={menuOpen}>
<NavigationMenu.Trigger> <DropdownMenu.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<a {...props} href="/dashboard/team" class={className} onclick={() => onnavigate?.()}> <!--
Die Zeiger- und Tastenhandler von bits-ui werden bewusst
ueberschrieben: Deren onclick ruft preventDefault und macht
den Link damit tot, und ihr pointerdown klappt das Menue zu,
das der Hover gerade aufgeklappt hat — zwei Herren ueber
dasselbe `open`, das Ergebnis war Flackern statt Navigation.
Hier klappt allein der Hover auf, und der Klick folgt dem
Link wie bei jedem anderen Menuepunkt.
-->
<a
{...props}
href="/dashboard/team"
class={className}
onmouseenter={enter}
onmouseleave={leave}
onpointerdown={undefined}
onpointerup={undefined}
onkeydown={undefined}
onclick={() => {
menuOpen = false
onnavigate?.()
}}
>
<Users class="h-4 w-4" /> <Users class="h-4 w-4" />
<span>Team</span> <span>Team</span>
<ChevronDown class="h-3 w-3 opacity-50" /> <ChevronDown class="h-3 w-3 opacity-50" />
</a> </a>
{/snippet} {/snippet}
</NavigationMenu.Trigger> </DropdownMenu.Trigger>
<!-- <!--
mt-0 statt der 1.5 aus dem Standard: Der Abstand war ein toter Streifen Aufgeklappt wird beim Ueberfahren, nicht auf Ansage: Das Menue darf
zwischen Menuepunkt und Panel. Wer den Zeiger hinunterbewegte, verliess deshalb weder den Fokus an sich ziehen noch das Scrollen sperren —
beides zugleich, und nach delayDuration klappte das Menue zu, bevor ein beides gehoert zu einem Dialog, nicht zu einem Menuepunkt, ueber den
Eintrag zu treffen war. Ein Hover-Menue darf keine Luecke haben. der Zeiger nur streift.
Auch der Fokus beim Schliessen bleibt aus. bits-ui gibt ihn sonst an
den Trigger zurueck, und weil hier der Zeiger schliesst und nicht die
Tastatur, stuende danach ein Fokusring auf dem Menuepunkt — fuer
jemanden, der nur darueber gefahren ist.
--> -->
<NavigationMenu.Content class="min-w-56 group-data-[viewport=false]/navigation-menu:mt-0"> <DropdownMenu.Content
{#each teams.records as t (t.id)} align="start"
<button class="min-w-56"
type="button" trapFocus={false}
onclick={() => switchTo(t.id)} preventScroll={false}
class="{itemClass} {t.id === teams.activeId ? 'bg-accent' : ''}" onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onmouseenter={enter}
onmouseleave={leave}
> >
<span class="flex-1 truncate">{t.name}</span> {#each teams.records as t (t.id)}
<DropdownMenu.Item
onclick={() => switchTo(t.id)}
class={t.id === teams.activeId ? 'bg-accent' : ''}
>
<span class="flex-1">{t.name}</span>
{#if teams.isOwner(t)} {#if teams.isOwner(t)}
<span class="text-xs text-muted-foreground">Owner</span> <span class="text-xs text-muted-foreground">Owner</span>
{:else if teams.isAdmin(t)} {:else if teams.isAdmin(t)}
<span class="text-xs text-muted-foreground">Admin</span> <span class="text-xs text-muted-foreground">Admin</span>
{/if} {/if}
</button> </DropdownMenu.Item>
{:else} {:else}
<p class="px-2 py-1.5 text-sm text-muted-foreground">Noch in keinem Team</p> <DropdownMenu.Item disabled>Noch in keinem Team</DropdownMenu.Item>
{/each} {/each}
<div class="my-1 h-px bg-border"></div> <DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (createDialog = true)}>
<button type="button" onclick={() => (createDialog = true)} class={itemClass}> <Plus class="h-4 w-4 mr-2" />
<Plus class="h-4 w-4" />
Team erstellen Team erstellen
</button> </DropdownMenu.Item>
</NavigationMenu.Content> </DropdownMenu.Content>
</NavigationMenu.Item> </DropdownMenu.Root>
<!-- Ausserhalb des Menues: mit dem Klick klappt es zu, und ein darin <!-- Ausserhalb des Menues: mit dem Klick schliesst sich das Dropdown, und ein
gerendeter Dialog verschwaende mit ihm. --> darin gerendeter Dialog verschwaende mit ihm. -->
<CreateTeamDialog bind:open={createDialog} /> <CreateTeamDialog bind:open={createDialog} />

View file

@ -5,12 +5,6 @@
* 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
* Tabellen noch die Toast-Leiste lösen sie direkt aus. * Tabellen noch die Toast-Leiste lösen sie direkt aus.
*
* Bedient wird das an der Strecke, im Stehen, mit einer Hand am Lenker
* und einem Daumen am Telefon. Danach ist alles hier bemessen: Der Fahrer
* wird aus Kacheln gewählt statt aus einer Auswahlliste — die Startnummer
* gross wie am Trikot, der Name darunter —, und Starten und Stoppen sind
* flächige Knöpfe und keine Zeile im Fussbereich.
*/ */
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'
@ -21,7 +15,7 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import * as Dialog from '@/components/ui/dialog' import * as Dialog from '@/components/ui/dialog'
import * as Select from '@/components/ui/select' import * as Select from '@/components/ui/select'
import { AlertTriangle, Flag, Play, Square } from 'lucide-svelte' import { AlertTriangle, Play, Square } from 'lucide-svelte'
let { let {
open = $bindable(false), open = $bindable(false),
@ -71,6 +65,9 @@
) )
const eventStages = $derived(eventId ? stages.getByEvent(eventId) : []) const eventStages = $derived(eventId ? stages.getByEvent(eventId) : [])
const riderLabel = $derived(
selectedRider ? participants.riderLabel(eventId, selectedRider) : 'Fahrer wählen…',
)
const stageLabel = $derived( const stageLabel = $derived(
selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…', selectedStage ? stages.getById(selectedStage)?.description ?? 'Stage wählen…' : 'Stage wählen…',
) )
@ -137,45 +134,19 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label id="rider-label">Fahrer</Label> <Label>Fahrer</Label>
<Select.Root type="single" bind:value={selectedRider} disabled={eventRiders.length === 0}>
<Select.Trigger class="w-full">{riderLabel}</Select.Trigger>
<Select.Content>
{#each eventRiders as r (r.id)}
<Select.Item value={r.id}>
{participants.riderLabel(eventId, r.id)}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if eventRiders.length === 0} {#if eventRiders.length === 0}
<p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p> <p class="text-xs text-muted-foreground">Für dieses Event gibt es noch keine Fahrer.</p>
{:else}
<!--
Kacheln statt Auswahlliste: Eine Liste verlangt
aufklappen, zielen und treffen — drei Schritte für
etwas, das jeder Zeitnehmer zwanzigmal am Tag macht.
Hier steht jeder Fahrer als eigene Fläche da, gross
genug für einen Daumen.
-->
<div
role="radiogroup"
aria-labelledby="rider-label"
class="grid max-h-56 grid-cols-3 gap-2 overflow-y-auto rounded-lg border p-2 sm:grid-cols-4"
>
{#each eventRiders as r (r.id)}
{@const number = participants.numberOf(eventId, r.id)}
{@const chosen = selectedRider === r.id}
<button
type="button"
role="radio"
aria-checked={chosen}
onclick={() => (selectedRider = r.id)}
class="flex min-h-20 flex-col items-center justify-center gap-0.5 rounded-lg border p-2 transition-colors hover:bg-accent"
class:border-primary={chosen}
class:bg-primary={chosen}
class:text-primary-foreground={chosen}
>
<span class="font-startnumber text-2xl leading-none">
{number || ''}
</span>
<span class="line-clamp-2 text-center text-xs leading-tight">
{riderName(r)}
</span>
</button>
{/each}
</div>
{/if} {/if}
</div> </div>
@ -231,50 +202,22 @@
</div> </div>
</div> </div>
{:else} {:else}
<!-- <Dialog.Footer>
Die Handlung steht als Fläche da, nicht als Zeile im <Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
Fussbereich: 96 px hoch, über die volle Breite, Symbol über
Wort. Wer im Stehen auf die Sekunde drückt, zielt nicht auf
einen 36 px hohen Streifen am unteren Rand.
DNF steht daneben und ist schmaler — dieselbe Höhe, damit es
sich treffen lässt, aber nicht dieselbe Breite, damit es nicht
versehentlich getroffen wird.
-->
{#if active} {#if active}
<div class="grid grid-cols-3 gap-2"> <Button variant="outline" disabled={busy} onclick={() => (confirmDnf = true)}>
<Button
variant="destructive"
disabled={busy}
class="col-span-2 h-24 flex-col gap-1 text-lg font-semibold"
onclick={() => stop('finished')}
>
<Square class="size-7" />
Stoppen
</Button>
<Button
variant="outline"
disabled={busy}
class="h-24 flex-col gap-1 text-base font-semibold"
onclick={() => (confirmDnf = true)}
>
<Flag class="size-6" />
DNF DNF
</Button> </Button>
</div> <Button variant="destructive" disabled={busy} onclick={() => stop('finished')}>
<Square class="h-4 w-4 mr-2" />
Stoppen
</Button>
{:else} {:else}
<Button <Button disabled={!ready || busy} onclick={start}>
disabled={!ready || busy} <Play class="h-4 w-4 mr-2" />
class="h-24 w-full flex-col gap-1 text-lg font-semibold"
onclick={start}
>
<Play class="size-7" />
Starten Starten
</Button> </Button>
{/if} {/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
</Dialog.Footer> </Dialog.Footer>
{/if} {/if}
</Dialog.Content> </Dialog.Content>

View file

@ -1,28 +0,0 @@
import Content from "./navigation-menu-content.svelte";
import Indicator from "./navigation-menu-indicator.svelte";
import Item from "./navigation-menu-item.svelte";
import Link from "./navigation-menu-link.svelte";
import List from "./navigation-menu-list.svelte";
import Trigger from "./navigation-menu-trigger.svelte";
import Viewport from "./navigation-menu-viewport.svelte";
import Root from "./navigation-menu.svelte";
export {
Root,
Content,
Indicator,
Item,
Link,
List,
Trigger,
Viewport,
//
Root as NavigationMenuRoot,
Content as NavigationMenuContent,
Indicator as NavigationMenuIndicator,
Item as NavigationMenuItem,
Link as NavigationMenuLink,
List as NavigationMenuList,
Trigger as NavigationMenuTrigger,
Viewport as NavigationMenuViewport,
};

View file

@ -1,20 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ContentProps = $props();
</script>
<NavigationMenuPrimitive.Content
bind:ref
data-slot="navigation-menu-content"
class={cn(
"p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 top-0 left-0 w-full group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto",
className
)}
{...restProps}
/>

View file

@ -1,22 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.IndicatorProps = $props();
</script>
<NavigationMenuPrimitive.Indicator
bind:ref
data-slot="navigation-menu-indicator"
class={cn(
"data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...restProps}
>
<div class="rounded-tl-sm bg-border shadow-md relative top-[60%] h-2 w-2 rotate-45"></div>
</NavigationMenuPrimitive.Indicator>

View file

@ -1,17 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ItemProps = $props();
</script>
<NavigationMenuPrimitive.Item
bind:ref
data-slot="navigation-menu-item"
class={cn("cn-navigation-menu-item relative", className)}
{...restProps}
/>

View file

@ -1,17 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.LinkProps = $props();
</script>
<NavigationMenuPrimitive.Link
bind:ref
data-slot="navigation-menu-link"
class={cn("flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4", className)}
{...restProps}
/>

View file

@ -1,20 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ListProps = $props();
</script>
<NavigationMenuPrimitive.List
bind:ref
data-slot="navigation-menu-list"
class={cn(
"gap-0 group flex flex-1 list-none items-center justify-center",
className
)}
{...restProps}
/>

View file

@ -1,29 +0,0 @@
<script lang="ts" module>
import { tv } from "tailwind-variants";
import { cn } from "$lib/utils.js";
export const navigationMenuTriggerStyle = tv({
base: "rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center outline-none disabled:pointer-events-none",
});
</script>
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: NavigationMenuPrimitive.TriggerProps = $props();
</script>
<NavigationMenuPrimitive.Trigger
bind:ref
data-slot="navigation-menu-trigger"
class={cn(navigationMenuTriggerStyle(), "group", className)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>

View file

@ -1,22 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: NavigationMenuPrimitive.ViewportProps = $props();
</script>
<div class={cn("absolute start-0 top-full isolate z-50 flex justify-center")}>
<NavigationMenuPrimitive.Viewport
bind:ref
data-slot="navigation-menu-viewport"
class={cn(
"rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90 origin-top-center relative mt-1.5 h-[calc(var(--bits-navigation-menu-viewport-height)+1rem)] w-full overflow-hidden md:w-[calc(var(--bits-navigation-menu-viewport-width)+1rem)]",
className
)}
{...restProps}
/>
</div>

View file

@ -1,31 +0,0 @@
<script lang="ts">
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
let {
ref = $bindable(null),
class: className,
viewport = true,
children,
...restProps
}: NavigationMenuPrimitive.RootProps & {
viewport?: boolean;
} = $props();
</script>
<NavigationMenuPrimitive.Root
bind:ref
data-slot="navigation-menu"
data-viewport={viewport}
class={cn(
"max-w-max group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...restProps}
>
{@render children?.()}
{#if viewport}
<NavigationMenuViewport />
{/if}
</NavigationMenuPrimitive.Root>

View file

@ -31,13 +31,14 @@ export function iconLoader(name: string | undefined): (() => Promise<IconModule>
return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null return modules[`/node_modules/lucide-svelte/dist/icons/${toKebab(name)}.svelte`] ?? null
} }
/** Ob ein eingegebener Name existiert — für die Rückmeldung im Dialog. */
export function iconExists(name: string | undefined): boolean {
return iconLoader(name) !== null
}
/** /**
* Die Auswahl, die im Dialog zur Wahl steht nach Themen sortiert. * Vorschläge für die Auswahl im Dialog. Bewusst nach Themen sortiert und
* * nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein.
* Abschliessend: Es gab einmal ein Freitextfeld daneben, in das sich jeder
* Name von lucide.dev eintippen liess. Das verlangte, einen Katalog von
* tausend Icons zu kennen, und belohnte den Tippfehler mit einem leeren
* Kaestchen. Fehlt hier etwas, gehoert es in diese Liste.
*/ */
export const FLAG_ICON_SUGGESTIONS = [ export const FLAG_ICON_SUGGESTIONS = [
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info', 'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',

View file

@ -1,171 +0,0 @@
import { api } from './pocketbase.svelte'
const KEY = 'clockOffset'
/**
* Ab dieser Abweichung ist eine Messung nicht mehr gut genug für eine Stage.
* 250 ms, weil die Uhr auf Zehntel angezeigt wird und ein Fehler, der die
* angezeigte Stelle überschreitet, in der Rangliste sichtbar wird.
*/
export const ACCURACY_LIMIT_MS = 250
/**
* Nach dieser Zeit gilt eine Messung als alt. Telefonuhren driften langsam
* (Größenordnung Sekunden pro Woche), aber ein Gerät kann zwischendurch in
* den Flugmodus, den Akku leeren oder die Zeitzone wechseln nach vier
* Stunden lohnt der erneute Abgleich, er dauert eine Sekunde.
*/
export const STALE_AFTER_MS = 4 * 60 * 60 * 1000
type Stored = { offset: number; measuredAt: number; accuracy: number }
/**
* Der Uhrenabgleich zwischen diesem Gerät und dem Server.
*
* Gemessen wird nach Cristian: Zeitpunkt der Anfrage merken (t0), Serverzeit
* holen (T), Zeitpunkt der Antwort merken (t1). Unterwegs war die Antwort
* ungefähr die halbe Laufzeit, die Serverzeit im Moment t1 ist also etwa
* T + (t1 t0) / 2, und der Versatz dieses Geräts ist die Differenz dazu.
*
* Das Verfahren ist nur so gut wie die Symmetrie der Strecke: Dauert der Weg
* hin länger als zurück, wandert der Schätzwert. Deshalb mehrere Proben und
* der Median über die mit der kürzesten Laufzeit kurze Laufzeit heißt wenig
* Spielraum für Asymmetrie. Die halbe Laufzeit der besten Probe ist zugleich
* die Obergrenze des Fehlers und wird als `accuracy` mitgeführt.
*
* Bewusst kein Store je Team und kein Kontext: Der Versatz gehört dem Gerät,
* nicht der Anmeldung, und wird deshalb im localStorage gehalten und beim
* Start wieder aufgenommen.
*/
export class ClockSync {
/** Millisekunden, die zur Gerätezeit addiert werden müssen. */
offset = $state(0)
/** Wann zuletzt gemessen wurde (Gerätezeit). */
measuredAt = $state<number | null>(null)
/** Geschätzte Obergrenze des verbleibenden Fehlers in Millisekunden. */
accuracy = $state<number | null>(null)
busy = $state(false)
error = $state<string | null>(null)
constructor() {
if (typeof localStorage === 'undefined') return
try {
const raw = localStorage.getItem(KEY)
if (!raw) return
const stored = JSON.parse(raw) as Stored
if (typeof stored.offset !== 'number' || !Number.isFinite(stored.offset)) return
this.offset = stored.offset
this.measuredAt = stored.measuredAt ?? null
this.accuracy = stored.accuracy ?? null
} catch {
// Kaputter Eintrag ist wie kein Eintrag: ohne Abgleich weiter.
}
}
/** Die abgeglichene Gegenwart in Millisekunden. */
now(): number {
return Date.now() + this.offset
}
/** Dieselbe Zeit als ISO-String — die Form, in der PocketBase sie will. */
iso(at: number = this.now()): string {
return new Date(at).toISOString()
}
/** Alter der Messung in Millisekunden, null wenn nie gemessen. */
get age(): number | null {
return this.measuredAt === null ? null : Date.now() - this.measuredAt
}
get measured(): boolean {
return this.measuredAt !== null
}
get stale(): boolean {
const age = this.age
return age === null || age > STALE_AFTER_MS
}
/** Ist die letzte Messung gut genug, um damit eine Stage zu nehmen? */
get trustworthy(): boolean {
return this.measured && !this.stale && (this.accuracy ?? Infinity) <= ACCURACY_LIMIT_MS
}
/**
* Gegen den Server abgleichen. `rounds` Proben, die schlechteren fliegen
* raus; scheitert jede, bleibt der bisherige Versatz stehen ein alter
* Wert ist besser als gar keiner.
*/
async measure(rounds = 7): Promise<void> {
if (this.busy) return
this.busy = true
this.error = null
const samples: { offset: number; rtt: number }[] = []
try {
for (let i = 0; i < rounds; i++) {
const t0 = Date.now()
const res = await fetch(`${api.baseURL}/api/clock`, { cache: 'no-store' })
if (!res.ok) throw new Error(`Server antwortet mit ${res.status}`)
const t1 = Date.now()
const body = (await res.json()) as { now?: number }
if (typeof body.now !== 'number') throw new Error('Antwort ohne Zeit')
const rtt = t1 - t0
samples.push({ offset: body.now + rtt / 2 - t1, rtt })
}
if (samples.length === 0) throw new Error('Keine Messung zustande gekommen')
// Die schnellste Hälfte zählt: Wenig Laufzeit heißt wenig Raum für
// eine ungleiche Verteilung zwischen Hin- und Rückweg.
samples.sort((a, b) => a.rtt - b.rtt)
const best = samples.slice(0, Math.max(1, Math.ceil(samples.length / 2)))
const offsets = best.map((s) => s.offset).sort((a, b) => a - b)
const mid = Math.floor(offsets.length / 2)
const median =
offsets.length % 2 === 1 ? offsets[mid] : (offsets[mid - 1] + offsets[mid]) / 2
this.offset = Math.round(median)
this.accuracy = Math.round(best[0].rtt / 2)
this.measuredAt = Date.now()
this.persist()
} catch (e: any) {
this.error = e?.message ?? 'Der Abgleich ist fehlgeschlagen.'
} finally {
this.busy = false
}
}
private persist() {
if (typeof localStorage === 'undefined') return
if (this.measuredAt === null || this.accuracy === null) return
try {
const stored: Stored = {
offset: this.offset,
measuredAt: this.measuredAt,
accuracy: this.accuracy,
}
localStorage.setItem(KEY, JSON.stringify(stored))
} catch {
// Kein Speicher (privates Fenster, volle Quote): Der Versatz gilt
// dann nur für diese Sitzung. Besser als gar kein Abgleich.
}
}
}
/**
* Eine Instanz für die ganze App. Der Versatz gehört dem Gerät ein zweiter
* Abgleich in einer zweiten Komponente wäre nicht nur überflüssig, er würde
* sich mit dem ersten widersprechen.
*/
export const clockSync = new ClockSync()

View file

@ -1,141 +0,0 @@
import { api } from './pocketbase.svelte'
import { timeOutbox } from './timeOutbox.svelte'
/** Abstand der Prüfungen, solange die Verbindung steht. */
const INTERVAL_ONLINE = 30_000
/**
* Abstand, solange sie fehlt. Enger, weil die Rückkehr die Nachricht ist:
* Erst dann gehen die Zeiten aus dem Ausgang raus.
*/
const INTERVAL_OFFLINE = 5_000
/** Nach dieser Zeit gilt eine Prüfung als gescheitert. */
const TIMEOUT = 6_000
/**
* Steht die Verbindung zum Server?
*
* `navigator.onLine` allein taugt dafür nicht: Es sagt nur, dass das Gerät
* irgendein Netz hat im WLAN einer Hütte ohne Uplink, im Funkloch mit einem
* Balken, hinter einem Portal, das jede Anfrage abfängt, steht es unbeirrt auf
* `true`. Für die Zeitnahme zählt aber nur eine Frage: Kommt eine Anfrage beim
* Server an? Also wird genau das gefragt, in regelmäßigen Abständen.
*
* Umgekehrt ist `navigator.onLine === false` verlässlich kein Netz heißt
* kein Server. Das kürzt eine Prüfung ab, die ohnehin scheitern würde.
*/
export class Connection {
/** Letzter Stand: Server erreichbar? Bis zur ersten Prüfung optimistisch. */
online = $state(true)
/** Läuft gerade eine Prüfung? */
checking = $state(false)
/** Wann zuletzt eine Anfrage ankam. */
lastOk = $state<number | null>(null)
/** Seit wann die Verbindung fehlt — für „seit 3 Minuten offline". */
offlineSince = $state<number | null>(null)
private timer: ReturnType<typeof setTimeout> | null = null
private started = false
/**
* Prüfung starten. Läuft, bis `stop()` kommt die Verbindung ist nichts,
* was eine einzelne Seite beträfe.
*/
start() {
if (this.started || typeof window === 'undefined') return
this.started = true
window.addEventListener('online', this.onBrowserOnline)
window.addEventListener('offline', this.onBrowserOffline)
// Zurück aus dem Hintergrund: Zwischen Sperrbildschirm und Wiedersehen
// kann alles passiert sein, und die Timer standen womöglich still.
document.addEventListener('visibilitychange', this.onVisible)
void this.check()
}
stop() {
if (!this.started) return
this.started = false
window.removeEventListener('online', this.onBrowserOnline)
window.removeEventListener('offline', this.onBrowserOffline)
document.removeEventListener('visibilitychange', this.onVisible)
if (this.timer) clearTimeout(this.timer)
this.timer = null
}
private onBrowserOnline = () => void this.check()
private onBrowserOffline = () => this.setState(false)
private onVisible = () => {
if (document.visibilityState === 'visible') void this.check()
}
/** Wie lange die Verbindung schon fehlt, in Millisekunden. */
get offlineFor(): number | null {
return this.offlineSince === null ? null : Date.now() - this.offlineSince
}
/**
* Einmal nachsehen. Gibt zurück, ob der Server geantwortet hat, damit ein
* Aufrufer (jetzt erneut versuchen") das Ergebnis auswerten kann.
*/
async check(): Promise<boolean> {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.setState(false)
this.schedule()
return false
}
this.checking = true
const controller = new AbortController()
const abort = setTimeout(() => controller.abort(), TIMEOUT)
try {
const res = await fetch(`${api.baseURL}/api/health`, {
cache: 'no-store',
signal: controller.signal,
})
this.setState(res.ok)
return res.ok
} catch {
this.setState(false)
return false
} finally {
clearTimeout(abort)
this.checking = false
this.schedule()
}
}
private setState(online: boolean) {
const was = this.online
this.online = online
if (online) {
this.lastOk = Date.now()
this.offlineSince = null
// Die Rückkehr ist der Moment, in dem der Ausgang leer werden
// kann. Das Browser-Ereignis `online` allein feuert dafür zu früh:
// Es meldet die Netzwerkschnittstelle, nicht den Server.
if (!was && timeOutbox.pending > 0) void timeOutbox.flush()
} else if (was || this.offlineSince === null) {
this.offlineSince = Date.now()
}
}
private schedule() {
if (!this.started) return
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(
() => void this.check(),
this.online ? INTERVAL_ONLINE : INTERVAL_OFFLINE,
)
}
}
/** Eine Verbindung, eine Instanz — sie gehört dem Gerät, nicht einer Seite. */
export const connection = new Connection()

View file

@ -69,12 +69,6 @@ class AuthStore {
logout = async () => { logout = async () => {
api.authStore.clear() api.authStore.clear()
document.cookie = 'pb_auth="";-1; path=/' document.cookie = 'pb_auth="";-1; path=/'
// Der Service Worker haelt gelesene API-Antworten vor, damit die App
// im Funkloch etwas anzuzeigen hat. Nach dem Abmelden hat sie das
// nicht mehr: Auf einem geteilten Geraet stuenden sonst die Daten des
// vorigen Nutzers offline weiter da.
navigator.serviceWorker?.controller?.postMessage('clear-api-cache')
} }

View file

@ -1,204 +0,0 @@
import { api } from './pocketbase.svelte'
import type { TimesStatusOptions } from '$lib/types'
const KEY = 'timeOutbox'
/**
* Ein Start oder ein Stopp, der noch nicht beim Server ist.
*
* `at` ist die abgeglichene Zeit im Moment des Drückens nicht die des
* Übertragens. Darin liegt der ganze Sinn: Wann die Nachricht rausgeht, darf
* die gemessene Zeit nicht berühren.
*
* Ein Stopp trägt kein `time`, wenn die Zeit selbst noch im Ausgang liegt
* (offline gestartet und offline gestoppt). Er zeigt dann über `localId` auf
* den Start und wird erst beim Übertragen aufgelöst.
*/
export type OutboxEntry =
| {
kind: 'start'
localId: string
at: string
stage: string
rider: string
team: string
startedBy?: string
}
| {
kind: 'stop'
localId: string
at: string
status: TimesStatusOptions
/** ID der Zeit auf dem Server — fehlt, wenn der Start noch aussteht. */
time?: string
/** Start aus diesem Ausgang, auf den sich der Stopp bezieht. */
startLocalId?: string
stoppedBy?: string
}
/**
* Was an der Strecke gedrückt wurde und noch nicht beim Server ankam.
*
* Eine Stage lässt sich nicht wiederholen. Ein Funkloch im falschen Moment
* darf deshalb nicht bedeuten, dass eine Zeit verloren ist gedrückt wird
* ins localStorage, übertragen wird, sobald es geht. Die Reihenfolge bleibt
* erhalten: Ein Stopp kann auf einen Start warten, der selbst noch aussteht.
*
* Bewusst kein Kontext-Store: Der Ausgang gehört dem Gerät und überlebt
* Seitenwechsel, Neustart und Abmeldung.
*/
export class TimeOutbox {
entries = $state<OutboxEntry[]>([])
/** Läuft gerade eine Übertragung? */
flushing = $state(false)
/** Was beim letzten Versuch schiefging — bleibt stehen, bis es klappt. */
error = $state<string | null>(null)
private online = $state(true)
constructor() {
if (typeof localStorage !== 'undefined') {
try {
const raw = localStorage.getItem(KEY)
if (raw) this.entries = JSON.parse(raw) as OutboxEntry[]
} catch {
// Kaputter Ausgang: lieber leer weiter als beim Start scheitern.
}
}
if (typeof window !== 'undefined') {
this.online = navigator.onLine
window.addEventListener('online', () => {
this.online = true
void this.flush()
})
window.addEventListener('offline', () => (this.online = false))
}
}
get pending(): number {
return this.entries.length
}
/**
* Liegt für diese Zeit schon ein Stopp im Ausgang?
*
* Ohne diese Frage liesse sich dieselbe Zeit zweimal stoppen, solange das
* Netz weg ist und beim Übertragen gewänne der zweite Eintrag, also die
* falsche Zeit. Ein Stopp ist endgültig, auch wenn er noch hier liegt.
*/
hasPendingStop(timeId: string): boolean {
return this.entries.some(
(e) =>
e.kind === 'stop' &&
(e.time === timeId || (e.startLocalId && `local:${e.startLocalId}` === timeId)),
)
}
/**
* Was tun, wenn der Ausgang leer ist. Der Zeit-Store haengt sich hier ein,
* um seine vorläufigen Datensätze gegen die echten vom Server zu tauschen.
*/
onDrained(fn: () => void) {
this.drained = fn
}
private drained: (() => void) | null = null
get isOnline(): boolean {
return this.online
}
private persist() {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(KEY, JSON.stringify(this.entries))
} catch {
// Ohne Speicher bleibt der Ausgang in dieser Sitzung — dann muss
// die Übertragung klappen, bevor jemand die Seite schliesst.
}
}
private add(entry: OutboxEntry) {
this.entries = [...this.entries, entry]
this.persist()
}
/** Start vormerken. Gibt die lokale ID zurück, damit ein Stopp sie findet. */
queueStart(entry: Omit<Extract<OutboxEntry, { kind: 'start' }>, 'kind' | 'localId'>): string {
const localId = crypto.randomUUID()
this.add({ kind: 'start', localId, ...entry })
return localId
}
queueStop(entry: Omit<Extract<OutboxEntry, { kind: 'stop' }>, 'kind' | 'localId'>): string {
const localId = crypto.randomUUID()
this.add({ kind: 'stop', localId, ...entry })
return localId
}
/**
* Alles übertragen, was geht der Reihe nach, denn ein Stopp kann auf
* seinen Start warten. Der erste Fehler bricht ab: Ist das Netz weg, ist
* es auch für den nächsten Eintrag weg, und die Reihenfolge muss halten.
*/
async flush(): Promise<void> {
if (this.flushing || this.entries.length === 0) return
this.flushing = true
this.error = null
// Zeit-IDs, die in diesem Durchlauf entstanden sind — ein Stopp aus
// dem Ausgang kennt nur die lokale ID seines Starts.
const resolved = new Map<string, string>()
try {
while (this.entries.length > 0) {
const entry = this.entries[0]
if (entry.kind === 'start') {
const record = await api.collection('times').create({
stage: entry.stage,
rider: entry.rider,
team: entry.team,
start: entry.at,
status: 'active',
startedBy: entry.startedBy,
correction: 0,
})
resolved.set(entry.localId, record.id)
} else {
const timeId =
entry.time ??
(entry.startLocalId ? resolved.get(entry.startLocalId) : undefined)
if (!timeId) {
// Der zugehörige Start ist weder auf dem Server noch in
// diesem Durchlauf entstanden. Weiterreichen wäre ein
// stiller Verlust, also stehen lassen und melden.
throw new Error(
'Zu einem gestoppten Lauf fehlt der Start. Die Zeit bleibt im Ausgang.',
)
}
await api.collection('times').update(timeId, {
stop: entry.at,
status: entry.status,
stoppedBy: entry.stoppedBy,
})
}
this.entries = this.entries.slice(1)
this.persist()
}
this.drained?.()
} catch (e: any) {
this.error = e?.message ?? 'Die Übertragung ist fehlgeschlagen.'
} finally {
this.flushing = false
}
}
}
export const timeOutbox = new TimeOutbox()

View file

@ -2,20 +2,11 @@ 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 { clockSync } from './clockSync.svelte'
import { timeOutbox } from './timeOutbox.svelte'
import { formatDuration } from '$lib/time' import { formatDuration } from '$lib/time'
import type { TimesResponse, TimesStatusOptions } from '$lib/types' import type { TimesResponse, TimesStatusOptions } from '$lib/types'
const KEY = Symbol('time') const KEY = Symbol('time')
/**
* Vorläufige Datensätze, die nur dieses Gerät kennt. Das Präfix macht sie
* überall erkennbar eine PocketBase-ID hat 15 Zeichen aus [a-z0-9] und
* kann keinen Doppelpunkt enthalten.
*/
const LOCAL_PREFIX = 'local:'
export type TimeStatus = TimesStatusOptions export type TimeStatus = TimesStatusOptions
export interface TimeWithDuration extends TimesResponse { export interface TimeWithDuration extends TimesResponse {
@ -42,21 +33,6 @@ function enrich(t: TimesResponse, now: number): TimeWithDuration {
} }
} }
/**
* War das ein Funkloch oder eine Absage?
*
* Nur beim Funkloch darf der Ausgang auffangen. Eine abgelehnte Anfrage
* fehlende Rechte, ungültige Daten gehoert dem Aufrufer gemeldet, sonst
* sammelte der Ausgang stumm Eintraege, die auch beim naechsten Versuch
* scheitern. PocketBase setzt bei Netzfehlern `status` auf 0.
*/
function isOffline(e: unknown): boolean {
if (typeof navigator !== 'undefined' && !navigator.onLine) return true
const status = (e as { status?: number } | null)?.status
return status === 0 || status === undefined
}
export class TimeStore { export class TimeStore {
records = $state<TimesResponse[]>([]) records = $state<TimesResponse[]>([])
loading = $state(false) loading = $state(false)
@ -65,13 +41,6 @@ export class TimeStore {
private unsubscribe: (() => void) | null = null private unsubscribe: (() => void) | null = null
private teams = getTeamContext() private teams = getTeamContext()
constructor() {
// Ist der Ausgang leer, sind alle vorläufigen Zeiten beim Server
// angekommen. Einmal neu laden tauscht sie gegen die echten — mit
// deren IDs, ohne die sich später nichts mehr korrigieren liesse.
timeOutbox.onDrained(() => void this.load())
}
get scoped(): TimesResponse[] { get scoped(): TimesResponse[] {
const teamId = this.teams.activeId const teamId = this.teams.activeId
if (!teamId) return [] if (!teamId) return []
@ -106,140 +75,30 @@ export class TimeStore {
}).catch((e) => console.warn('times subscribe failed:', e)) }).catch((e) => console.warn('times subscribe failed:', e))
} }
/**
* Zeit starten.
*
* Der Zeitstempel kommt aus dem Uhrenabgleich, nicht aus `new Date()`:
* Start und Stopp geschehen oft auf verschiedenen Geräten, und deren
* Uhrenversatz steckte sonst ungeprüft in jeder gemessenen Dauer.
*
* Scheitert die Übertragung, wandert der Start in den Ausgang statt in
* einen Fehler. Eine Stage lässt sich nicht wiederholen im Funkloch
* gedrückt bleibt gedrückt, übertragen wird später.
*/
async start(stageId: string, riderId: string) { 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')
const at = clockSync.iso()
const startedBy = api.authStore.record?.id
try {
return await api.collection('times').create({ return await api.collection('times').create({
stage: stageId, stage: stageId,
rider: riderId, rider: riderId,
team: teamId, team: teamId,
start: at, start: new Date().toISOString(),
status: 'active' as TimesStatusOptions, status: 'active' as TimesStatusOptions,
startedBy, startedBy: api.authStore.record?.id,
correction: 0, correction: 0,
}) })
} catch (e) {
if (!isOffline(e)) throw e
// Der Start wandert in den Ausgang — und zugleich als vorläufiger
// Datensatz in die Liste. Ohne ihn stünde die laufende Zeit
// nirgends, und niemand könnte sie stoppen: Die Oberfläche kennt
// nur, was hier steht.
const localId = timeOutbox.queueStart({
at,
stage: stageId,
rider: riderId,
team: teamId,
startedBy,
})
const draft = {
id: `${LOCAL_PREFIX}${localId}`,
stage: stageId,
rider: riderId,
team: teamId,
start: at,
status: 'active',
startedBy,
correction: 0,
} as unknown as TimesResponse
this.records = [...this.records, draft]
return null
}
} }
/** Gehört diese ID zu einem Datensatz, den nur dieses Gerät kennt? */
isLocal(timeId: string): boolean {
return timeId.startsWith(LOCAL_PREFIX)
}
/**
* Zeit stoppen. Wie beim Start gilt die abgeglichene Zeit des Drückens,
* und wie beim Start faengt der Ausgang auf, was das Netz nicht nimmt.
*/
async stop(timeId: string, status: TimeStatus = 'finished' as TimesStatusOptions) { async stop(timeId: string, status: TimeStatus = 'finished' as TimesStatusOptions) {
const at = clockSync.iso()
const stoppedBy = api.authStore.record?.id
// Ein Stopp ist endgültig, auch wenn er noch im Ausgang liegt. Ohne
// diese Sperre liesse sich dieselbe Zeit im Funkloch zweimal stoppen,
// und beim Übertragen gewänne der zweite Druck — die falsche Zeit.
if (timeOutbox.hasPendingStop(timeId)) {
throw new Error('Diese Zeit ist bereits gestoppt und wartet auf die Übertragung.')
}
// Ein Lauf, den nur dieses Gerät kennt, kann gar nicht am Server
// gestoppt werden — sein Start ist dort noch nicht angekommen.
if (this.isLocal(timeId)) {
timeOutbox.queueStop({
at,
status,
startLocalId: timeId.slice(LOCAL_PREFIX.length),
stoppedBy,
})
this.markStopped(timeId, at, status, stoppedBy)
return null
}
try {
return await api.collection('times').update(timeId, { return await api.collection('times').update(timeId, {
stop: at, stop: new Date().toISOString(),
status, status,
stoppedBy, stoppedBy: api.authStore.record?.id,
}) })
} catch (e) {
if (!isOffline(e)) throw e
timeOutbox.queueStop({ at, status, time: timeId, stoppedBy })
// Sofort auch hier eintragen, sonst stünde die Zeit weiter als
// laufend da und liesse sich ein zweites Mal stoppen.
this.markStopped(timeId, at, status, stoppedBy)
return null
}
} }
/** async applyCorrection(timeId: string, seconds: number) {
* Eine Zeit vorläufig als gestoppt führen, solange der Server nichts davon return await api.collection('times').update(timeId, { correction: seconds })
* weiss. Beim Übertragen kommt der echte Datensatz nach und ersetzt sie.
*/
private markStopped(timeId: string, at: string, status: TimeStatus, stoppedBy?: string) {
const idx = this.records.findIndex((t) => t.id === timeId)
if (idx === -1) return
const next = [...this.records]
next[idx] = { ...next[idx], stop: at, status, stoppedBy } as TimesResponse
this.records = next
}
/**
* Zeitkorrektur setzen, mit optionalem Grund.
*
* Der Grund steht im vorhandenen Feld `comment`. Eine Strafe ohne Grund
* ist nach dem Event nicht mehr zu verteidigen Pflicht ist er trotzdem
* nicht: An der Strecke zaehlt, dass die Zahl stimmt.
*/
async applyCorrection(timeId: string, seconds: number, comment?: string) {
return await api.collection('times').update(timeId, {
correction: seconds,
comment: comment?.trim() ?? '',
})
} }
async updateStatus(timeId: string, status: TimeStatus) { async updateStatus(timeId: string, status: TimeStatus) {

View file

@ -20,12 +20,9 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte' import Logo from '$lib/components/Logo.svelte'
import TeamMenu from '$lib/components/TeamMenu.svelte' import TeamMenu from '$lib/components/TeamMenu.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import { connection } from '$lib/stores/connection.svelte'
import * as NavigationMenu from '@/components/ui/navigation-menu'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte' import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users, Wifi, WifiOff } from 'lucide-svelte' import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Users } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher' import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
@ -50,10 +47,6 @@
let isMobileMenuOpen = $state(false) let isMobileMenuOpen = $state(false)
// Der Dialog gehoert nicht ins mobile Menue: Das schliesst sich beim
// Klick, und der Dialog verschwaende mit ihm.
let createTeamDialog = $state(false)
onMount(async () => { onMount(async () => {
if (!auth.user) { if (!auth.user) {
goto('/login', { replaceState: true }) goto('/login', { replaceState: true })
@ -68,10 +61,6 @@
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
trailComments.load(), trailComments.load(),
]) ])
// Die Verbindungsprüfung laeuft, solange das Dashboard steht: Sie
// ist die Grundlage dafuer, dass niemand ins Leere stoppt.
connection.start()
invites.subscribe() invites.subscribe()
events.subscribe() events.subscribe()
stages.subscribe() stages.subscribe()
@ -87,7 +76,6 @@
}) })
onDestroy(() => { onDestroy(() => {
connection.stop()
teams.destroy() teams.destroy()
invites.destroy() invites.destroy()
events.destroy() events.destroy()
@ -172,17 +160,7 @@
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" /> <Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
</div> </div>
<!-- <header class="relative z-10 border-b bg-card">
z-20 und nicht z-10: <main> weiter unten ist ebenfalls
`relative z-10` und steht spaeter im DOM, liegt also oben. Das
aufgeklappte Team-Panel haengt unter die Kopfleiste hinein in
diese Flaeche und lag dadurch dahinter — der Zeiger traf beim
Hinunterfahren `main` statt das Panel, und das Menue klappte zu,
bevor jemand einen Eintrag treffen konnte. Ein z-50 am Panel half
nicht: Die Kopfleiste ist mit ihrem z-index ein eigener
Stapelkontext, aus dem kein Kind herausragt.
-->
<header class="relative z-20 border-b bg-card">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex min-h-16 items-center justify-between gap-4 py-2"> <div class="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das negative -my-3 frisst das <!-- Links: Logo und Menü. Das negative -my-3 frisst das
@ -202,22 +180,13 @@
<Logo team={teams.active} class="size-[4.5rem] -my-3" /> <Logo team={teams.active} class="size-[4.5rem] -my-3" />
</a> </a>
<!-- <nav class="hidden md:flex items-center gap-2">
Ohne Viewport: Der Inhalt haengt dann am
Menuepunkt, der als NavigationMenu.Item ohnehin
`relative` ist. Mit Viewport waere er eine eigene
Flaeche unter der ganzen Leiste und stuende an
deren linkem Rand — unter „Dashboard" statt unter
„Team". -->
<NavigationMenu.Root viewport={false} class="hidden md:flex">
<NavigationMenu.List class="items-center gap-2">
{#each nav as item} {#each nav as item}
{@const Icon = item.icon} {@const Icon = item.icon}
{#if item.submenu} {#if item.submenu}
<TeamMenu class={navClass(item.href)} /> <TeamMenu class={navClass(item.href)} />
{:else} {:else}
<NavigationMenu.Item> <a
<NavigationMenu.Link
href={item.href} href={item.href}
class={navClass(item.href, dashHover && item.href === '/dashboard')} class={navClass(item.href, dashHover && item.href === '/dashboard')}
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)} onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
@ -225,12 +194,10 @@
> >
<Icon class="h-4 w-4" /> <Icon class="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
</NavigationMenu.Link> </a>
</NavigationMenu.Item>
{/if} {/if}
{/each} {/each}
</NavigationMenu.List> </nav>
</NavigationMenu.Root>
</div> </div>
<!-- Rechts: wer man ist, und was man mit sich tun kann. <!-- Rechts: wer man ist, und was man mit sich tun kann.
@ -249,29 +216,6 @@
</a> </a>
<span class="flex shrink-0 items-center gap-1"> <span class="flex shrink-0 items-center gap-1">
<!--
Der Zustand der Verbindung steht dauerhaft
da und nicht erst, wenn etwas schiefgeht:
Wer gleich eine Stage nimmt, will vorher
wissen, woran er ist.
-->
<Button
variant="ghost"
size="icon"
class="size-7 {connection.online
? ''
: 'bg-red-800 text-white hover:bg-red-900 hover:text-white'}"
onclick={() => connection.check()}
tooltip={connection.online
? 'Verbindung steht'
: 'Keine Verbindung zum Server — hier klicken, um erneut zu versuchen'}
>
{#if connection.online}
<Wifi class="h-4 w-4 text-muted-foreground" />
{:else}
<WifiOff class="h-4 w-4" />
{/if}
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -319,6 +263,12 @@
<nav class="px-2 py-2 space-y-1"> <nav class="px-2 py-2 space-y-1">
{#each nav as item} {#each nav as item}
{@const Icon = item.icon} {@const Icon = item.icon}
{#if item.submenu}
<TeamMenu
class="w-full {navClass(item.href)}"
onnavigate={() => (isMobileMenuOpen = false)}
/>
{:else}
<a <a
href={item.href} href={item.href}
onclick={() => (isMobileMenuOpen = false)} onclick={() => (isMobileMenuOpen = false)}
@ -327,46 +277,6 @@
<Icon class="h-4 w-4" /> <Icon class="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
<!--
Auf dem Touchscreen gibt es kein Ueberfahren,
also auch kein Aufklappen. Die Teams stehen
hier eingerueckt unter ihrem Menuepunkt —
sichtbar statt versteckt, und der einzige Weg,
auf dem sich ohne Zeiger wechseln laesst.
-->
{#if item.submenu}
<div class="ml-6 space-y-1 border-l pl-3">
{#each teams.records as t (t.id)}
<button
type="button"
onclick={() => {
teams.setActive(t.id)
isMobileMenuOpen = false
}}
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent {t.id ===
teams.activeId
? 'font-medium text-foreground'
: 'text-muted-foreground'}"
>
<span class="flex-1 truncate">{t.name}</span>
{#if t.id === teams.activeId}
<span class="text-xs text-muted-foreground">aktiv</span>
{/if}
</button>
{/each}
<button
type="button"
onclick={() => {
isMobileMenuOpen = false
createTeamDialog = true
}}
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent"
>
<Plus class="h-4 w-4" />
Team erstellen
</button>
</div>
{/if} {/if}
{/each} {/each}
<Separator class="my-2" /> <Separator class="my-2" />
@ -403,7 +313,5 @@
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. --> <!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
<RunningTimesToast /> <RunningTimesToast />
<CreateTeamDialog bind:open={createTeamDialog} />
</div> </div>
{/if} {/if}

View file

@ -13,6 +13,7 @@
} from '$lib/stores/eventParticipants.svelte' } from '$lib/stores/eventParticipants.svelte'
import { auth } from '$lib/stores/pocketbase.svelte' import { auth } from '$lib/stores/pocketbase.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte' import TimeDialog from '$lib/components/TimeDialog.svelte'
import * as Select from '@/components/ui/select'
import { formatDateRange } from '$lib/time' import { formatDateRange } from '$lib/time'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -33,7 +34,6 @@
Users, Users,
UserPlus, UserPlus,
Check, Check,
Search,
X, X,
} from 'lucide-svelte' } from 'lucide-svelte'
import type { EventParticipantsResponse, StagesResponse } from '$lib/types' import type { EventParticipantsResponse, StagesResponse } from '$lib/types'
@ -81,110 +81,48 @@
// --- Teilnehmer ------------------------------------------------------- // --- Teilnehmer -------------------------------------------------------
let riderDialog = $state(false) let riderDialog = $state(false)
let editing = $state<EventParticipantsResponse | null>(null) let editing = $state<EventParticipantsResponse | null>(null)
let form = $state({ number: '' }) let form = $state({ rider: '', number: '' })
let formError = $state<string | null>(null) let formError = $state<string | null>(null)
const available = $derived(event ? participants.available(event.id) : []) const available = $derived(event ? participants.available(event.id) : [])
// --- Mehrere auf einmal eintragen -------------------------------------
//
// Ein Event beginnt mit zwanzig Anmeldungen, nicht mit einer. Zwanzigmal
// denselben Dialog zu oeffnen, einen Namen aus einer Liste zu suchen und
// eine Nummer zu tippen, ist dieselbe Arbeit zwanzigmal — hier steht der
// Kader stattdessen als Liste da, mit der Nummer gleich daneben.
let addDialog = $state(false)
let addQuery = $state('')
let addBusy = $state(false)
let addError = $state<string | null>(null)
/** Auswahl und Startnummer je Fahrer, nach Fahrer-ID. */
let picks = $state<Record<string, { chosen: boolean; number: string }>>({})
const addFiltered = $derived.by(() => {
const q = addQuery.toLowerCase().trim()
return q ? available.filter((r) => riderName(r).toLowerCase().includes(q)) : available
})
const picked = $derived(available.filter((r) => picks[r.id]?.chosen))
function openAdd() { function openAdd() {
picks = Object.fromEntries(available.map((r) => [r.id, { chosen: false, number: '' }])) editing = null
addQuery = '' form = { rider: available[0]?.id ?? '', number: '' }
addError = null formError = null
addDialog = true riderDialog = true
}
function toggleAll(on: boolean) {
const next = { ...picks }
for (const r of addFiltered) {
next[r.id] = { ...(next[r.id] ?? { number: '' }), chosen: on }
}
picks = next
}
async function saveParticipants() {
if (!event || addBusy) return
const chosen = picked
if (chosen.length === 0) {
addError = 'Niemand ausgewählt.'
return
}
// Erst pruefen, dann schreiben: Eine doppelte Nummer soll nicht erst
// auffallen, wenn die halbe Liste schon eingetragen ist.
const seen = new Map<string, string>()
for (const r of chosen) {
const number = (picks[r.id]?.number ?? '').trim()
if (!number) continue
if (seen.has(number)) {
addError = `Startnummer ${number} ist doppelt vergeben (${seen.get(number)} und ${riderName(r)}).`
return
}
if (participants.isNumberTaken(event.id, number)) {
addError = `Startnummer ${number} ist in diesem Event bereits vergeben.`
return
}
seen.set(number, riderName(r))
}
addBusy = true
addError = null
try {
for (const r of chosen) {
await participants.add({
event: event.id,
rider: r.id,
number: (picks[r.id]?.number ?? '').trim(),
})
}
addDialog = false
} catch (e: any) {
addError = e.message ?? 'Nicht alle Teilnehmer konnten eingetragen werden.'
} finally {
addBusy = false
}
} }
function openEdit(p: EventParticipantsResponse) { function openEdit(p: EventParticipantsResponse) {
editing = p editing = p
form = { number: p.number ?? '' } form = { rider: p.rider, number: p.number ?? '' }
formError = null formError = null
riderDialog = true riderDialog = true
} }
async function saveParticipant() { async function saveParticipant() {
if (!event || !editing) return if (!event) return
if (participants.isNumberTaken(event.id, form.number, editing.id)) { if (!form.rider) {
formError = 'Bitte einen Fahrer wählen.'
return
}
if (participants.isNumberTaken(event.id, form.number, editing?.id)) {
formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.` formError = `Startnummer ${form.number} ist in diesem Event bereits vergeben.`
return return
} }
formError = null formError = null
try { try {
if (editing) {
await participants.edit(editing.id, { number: form.number.trim() }) await participants.edit(editing.id, { number: form.number.trim() })
} else {
await participants.add({
event: event.id,
rider: form.rider,
number: form.number.trim(),
})
}
riderDialog = false riderDialog = false
} catch (e: any) { } catch (e: any) {
formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.' formError = e.message ?? 'Der Teilnehmer konnte nicht gespeichert werden.'
@ -551,13 +489,36 @@
<Dialog.Root bind:open={riderDialog}> <Dialog.Root bind:open={riderDialog}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Startnummer ändern</Dialog.Title> <Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
{#if editing}
Die Startnummer gilt nur für „{event?.name}". Die Startnummer gilt nur für „{event?.name}".
{:else}
Aus dem Kader des Teams. Neue Fahrer legt die Teamleitung in den
Einstellungen an.
{/if}
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<div class="space-y-4"> <div class="space-y-4">
{#if !editing}
<div class="space-y-2">
<Label>Fahrer</Label>
<Select.Root type="single" bind:value={form.rider}>
<Select.Trigger class="w-full">
{form.rider
? riderName(riders.getById(form.rider) ?? ({} as any)) || 'Fahrer wählen…'
: 'Fahrer wählen…'}
</Select.Trigger>
<Select.Content>
{#each available as r (r.id)}
<Select.Item value={r.id}>{riderName(r) || '— ohne Name —'}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="space-y-2"> <div class="space-y-2">
<Label for="p-number">Startnummer</Label> <Label for="p-number">Startnummer</Label>
<Input id="p-number" bind:value={form.number} placeholder="optional" /> <Input id="p-number" bind:value={form.number} placeholder="optional" />
@ -573,100 +534,7 @@
<Dialog.Footer> <Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button> <Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>Speichern</Button> <Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!--
Teilnehmer eintragen: der Kader als Liste, Haken und Nummer nebeneinander.
Wer zwanzig Leute eintraegt, soll das in einem Durchgang tun koennen und
nicht zwanzigmal denselben Dialog oeffnen.
-->
<Dialog.Root bind:open={addDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Teilnehmer eintragen</Dialog.Title>
<Dialog.Description>
Aus dem Kader des Teams. Die Startnummer gilt je Event und kann leer
bleiben.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-3">
{#if available.length === 0}
<p class="text-sm text-muted-foreground">
Der ganze Kader ist schon eingetragen.
</p>
{:else}
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={addQuery} placeholder="Fahrer suchen" class="pl-9" />
</div>
<Button variant="outline" size="sm" onclick={() => toggleAll(true)}>Alle</Button>
<Button variant="ghost" size="sm" onclick={() => toggleAll(false)}>Keinen</Button>
</div>
<div class="max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2">
{#each addFiltered as r (r.id)}
<div class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-accent">
<input
id="pick-{r.id}"
type="checkbox"
class="size-4 shrink-0 rounded border-input accent-primary"
checked={picks[r.id]?.chosen ?? false}
onchange={(e) =>
(picks = {
...picks,
[r.id]: {
number: picks[r.id]?.number ?? '',
chosen: e.currentTarget.checked,
},
})}
/>
<label for="pick-{r.id}" class="min-w-0 flex-1 cursor-pointer truncate text-sm">
{riderName(r) || '— ohne Name —'}
</label>
<Input
value={picks[r.id]?.number ?? ''}
placeholder="Nr."
aria-label="Startnummer für {riderName(r)}"
class="h-8 w-20 shrink-0 text-center"
oninput={(e) =>
(picks = {
...picks,
[r.id]: {
chosen: picks[r.id]?.chosen ?? false,
number: e.currentTarget.value,
},
})}
/>
</div>
{:else}
<p class="px-2 py-4 text-center text-sm text-muted-foreground">
Kein Treffer.
</p>
{/each}
</div>
{/if}
{#if addError}
<p class="text-sm text-destructive">{addError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (addDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipants} disabled={addBusy || picked.length === 0}>
{#if addBusy}
Wird eingetragen …
{:else if picked.length === 0}
Eintragen
{:else}
{picked.length} eintragen
{/if}
</Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View file

@ -12,35 +12,20 @@
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'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { ticker } from '$lib/stores/ticker.svelte' import { ticker } from '$lib/stores/ticker.svelte'
import { TIME_STATUS_LABEL } from '$lib/time' import { TIME_STATUS_LABEL } from '$lib/time'
import { clockSync } from '$lib/stores/clockSync.svelte'
import TimeDialog from '$lib/components/TimeDialog.svelte' import TimeDialog from '$lib/components/TimeDialog.svelte'
import ClockSyncDialog from '$lib/components/ClockSyncDialog.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import * as Table from '@/components/ui/table' import * as Table from '@/components/ui/table'
import { ArrowLeft, Clock, Download, Flag, Pencil, Timer, Trophy, TriangleAlert } from 'lucide-svelte' import { ArrowLeft, Download, Flag, Timer, Trophy } from 'lucide-svelte'
import * as Dialog from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const events = getEventContext() const events = getEventContext()
const stages = getStageContext() const stages = getStageContext()
const riders = getRiderContext() const riders = getRiderContext()
const times = getTimeContext() const times = getTimeContext()
const participants = getEventParticipantContext() const participants = getEventParticipantContext()
const teams = getTeamContext()
// Die Namen der Teammitglieder holen: Ein Team kennt nur die IDs seiner
// Leute, und ohne diesen Zug stuende an jeder Zeit statt "Start: Anna"
// eine abgeschnittene ID.
$effect(() => {
void teams.active
teams.loadMembers()
})
const clock = ticker() const clock = ticker()
@ -79,65 +64,12 @@
// Start und Stopp laufen ausschließlich über TimeDialog; die Stage 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)
let clockDialog = $state(false)
let dialogRider = $state<string | null>(null)
/**
* Zeit-Dialog oeffnen, den Fahrer gleich mit. Aus der Tabelle heraus ist
* er immer schon bekannt — ihn dort noch einmal auswaehlen zu lassen,
* hiesse dieselbe Frage zweimal zu stellen.
*/
function openTime(riderId: string | null = null) {
dialogRider = riderId
timeDialog = true
}
// --- Strafzeiten ------------------------------------------------------
//
// Die Korrektur steckt als Sekunden an der Zeit (times.correction) und
// wird auf die Dauer gerechnet; der Grund daneben im Feld `comment`.
// Immer an einer einzelnen Zeit — eine Strafe trifft einen Fahrer, und
// was fuer alle gleich gilt, ist keine Strafe, sondern eine andere Stage.
let penaltyDialog = $state(false)
let penaltyTime = $state<string | null>(null)
let penaltySeconds = $state(0)
let penaltyReason = $state('')
let penaltyBusy = $state(false)
let penaltyError = $state<string | null>(null)
function openPenalty(timeId: string, current = 0, reason = '') {
penaltyTime = timeId
penaltySeconds = current
penaltyReason = reason
penaltyError = null
penaltyDialog = true
}
async function savePenalty() {
if (penaltyBusy || !penaltyTime) return
penaltyBusy = true
penaltyError = null
try {
await times.applyCorrection(penaltyTime, penaltySeconds, penaltyReason)
penaltyDialog = false
} catch (e: any) {
penaltyError = e.message ?? 'Die Korrektur konnte nicht gespeichert werden.'
} finally {
penaltyBusy = false
}
}
/** Wer hat gedrückt? Der Kontoname, sonst eine gekürzte ID. */
function userLabel(id: string | undefined) {
return id ? teams.userLabel(id) : null
}
// --- Export ------------------------------------------------------------ // --- Export ------------------------------------------------------------
function exportCsv() { function exportCsv() {
if (leaderboard.length === 0) return if (leaderboard.length === 0) return
const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)', 'Grund']] const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)']]
leaderboard.forEach((t, i) => { leaderboard.forEach((t, i) => {
const r = rider(t.rider) const r = rider(t.rider)
rows.push([ rows.push([
@ -146,7 +78,6 @@
r ? riderName(r) : '', r ? riderName(r) : '',
t.formattedTime ?? '', t.formattedTime ?? '',
String(t.correction ?? 0), String(t.correction ?? 0),
t.comment ?? '',
]) ])
}) })
@ -196,32 +127,13 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!--
Der Uhrenabgleich steht hier, wo gestoppt wird, und nicht
in den Einstellungen: Wer die Stage betreut, soll vor dem
ersten Starter sehen, ob seine Uhr taugt.
-->
<Button
variant="outline"
onclick={() => (clockDialog = true)}
tooltip={clockSync.trustworthy
? 'Uhr abgeglichen'
: 'Uhr noch nicht abgeglichen — Zeiten von verschiedenen Geräten können auseinanderlaufen'}
>
{#if clockSync.trustworthy}
<Clock class="h-4 w-4 mr-2" />
{:else}
<TriangleAlert class="h-4 w-4 mr-2 text-amber-600" />
{/if}
Uhr
</Button>
{#if leaderboard.length > 0} {#if leaderboard.length > 0}
<Button variant="outline" onclick={exportCsv}> <Button variant="outline" onclick={exportCsv}>
<Download class="h-4 w-4 mr-2" /> <Download class="h-4 w-4 mr-2" />
CSV CSV
</Button> </Button>
{/if} {/if}
<Button onclick={() => openTime()} disabled={starters.length === 0}> <Button onclick={() => (timeDialog = true)} disabled={starters.length === 0}>
<Timer class="h-4 w-4 mr-2" /> <Timer class="h-4 w-4 mr-2" />
Zeit erfassen Zeit erfassen
</Button> </Button>
@ -240,12 +152,10 @@
<!-- Zeiten: laufende oben, darunter die Wertung --> <!-- Zeiten: laufende oben, darunter die Wertung -->
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex flex-wrap items-center justify-between gap-2">
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Trophy class="h-4 w-4 text-primary" /> <Trophy class="h-4 w-4 text-primary" />
Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''}) Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
</CardTitle> </CardTitle>
</div>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-0">
{#if rows.length === 0} {#if rows.length === 0}
@ -268,20 +178,7 @@
{@const t = row.time} {@const t = row.time}
{@const r = rider(t.rider)} {@const r = rider(t.rider)}
{@const live = row.place === null} {@const live = row.place === null}
<!-- <Table.Row class={live ? 'bg-primary/5' : undefined}>
Anklickbar ist die ganze Zeile, nicht nur
die Zeit — und nur, solange die Uhr laeuft.
Eine gewertete Zeit hat im Zeit-Dialog
nichts zu holen: Sie ist gestoppt, und was
an ihr noch zu aendern waere, aendert die
Korrektur nebenan.
-->
<Table.Row
class="{live ? 'bg-primary/5' : ''} {live
? 'cursor-pointer hover:bg-primary/10'
: ''}"
onclick={live ? () => openTime(t.rider) : undefined}
>
<Table.Cell> <Table.Cell>
{#if live} {#if live}
<Badge variant="outline" class="text-primary border-primary">läuft</Badge> <Badge variant="outline" class="text-primary border-primary">läuft</Badge>
@ -298,86 +195,16 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if r} {#if r}
<!-- stopPropagation: Der Name fuehrt zum Fahrer, <a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a>
nicht in den Zeit-Dialog. -->
<a
href={riderPath(r.id)}
class="hover:underline"
onclick={(e) => e.stopPropagation()}
>
{riderName(r) || '—'}
</a>
{:else} {:else}
{/if} {/if}
<!--
Wer gedrueckt hat, steht an der
Zeit. Bei einer strittigen Messung
ist das die erste Frage, und die
Daten dafuer lagen laengst im
Datensatz — nur sah sie niemand.
-->
{#if userLabel(t.startedBy) || userLabel(t.stoppedBy)}
<span class="block text-xs font-normal text-muted-foreground">
{#if t.startedBy && t.stoppedBy && t.startedBy === t.stoppedBy}
Start und Stopp: {userLabel(t.startedBy)}
{:else}
{#if t.startedBy}Start: {userLabel(t.startedBy)}{/if}
{#if t.startedBy && t.stoppedBy} · {/if}
{#if t.stoppedBy}Stopp: {userLabel(t.stoppedBy)}{/if}
{/if}
</span>
{/if}
</Table.Cell> </Table.Cell>
<!-- <Table.Cell class="text-right tabular-nums {live ? 'font-semibold' : ''}">
Die Zeit selbst oeffnet den Dialog:
Wer auf eine laufende Uhr sieht, will
sie stoppen, und wer auf eine
gestoppte sieht, will sie richtigen.
Der Weg ueber den Knopf oben und die
Fahrerauswahl fuehrt zur selben
Stelle, nur laenger.
-->
<Table.Cell class="p-0 text-right">
{#if live}
<Button
variant="ghost"
class="h-auto w-full justify-end px-4 py-3 font-semibold tabular-nums"
tooltip="Zeit stoppen"
onclick={(e) => {
e.stopPropagation()
openTime(t.rider)
}}
>
{t.formattedTime} {t.formattedTime}
</Button>
{:else}
<span class="block px-4 py-3 tabular-nums">
{t.formattedTime}
</span>
{/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right"> <Table.Cell class="text-right text-muted-foreground tabular-nums">
<Button {t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''}
variant="ghost"
size="sm"
class="tabular-nums font-normal {t.correction
? ''
: 'text-muted-foreground'}"
tooltip={t.comment
? `Zeitkorrektur: ${t.comment}`
: 'Zeitkorrektur'}
onclick={(e) => {
e.stopPropagation()
openPenalty(t.id, t.correction ?? 0, t.comment ?? '')
}}
>
{t.correction
? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)} s`
: ''}
<Pencil class="size-3 ml-1 opacity-50" />
</Button>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
@ -422,57 +249,4 @@
</div> </div>
{/if} {/if}
<TimeDialog <TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} stageId={stage?.id ?? null} />
bind:open={timeDialog}
eventId={event?.id ?? ''}
riderId={dialogRider}
stageId={stage?.id ?? null}
/>
<ClockSyncDialog bind:open={clockDialog} />
<!-- Zeitkorrektur: eine Zahl in Sekunden, plus wie minus. -->
<Dialog.Root bind:open={penaltyDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeitkorrektur</Dialog.Title>
<Dialog.Description>
Wird auf die gemessene Zeit gerechnet. Eine Strafe ist positiv, eine
Gutschrift negativ.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="penalty">Sekunden</Label>
<Input id="penalty" type="number" step="0.1" bind:value={penaltySeconds} />
<p class="text-xs text-muted-foreground">
0 nimmt eine bestehende Korrektur wieder zurück.
</p>
</div>
<div class="space-y-2">
<Label for="penalty-reason">Grund</Label>
<Input
id="penalty-reason"
bind:value={penaltyReason}
placeholder="z. B. Tor ausgelassen"
/>
<p class="text-xs text-muted-foreground">
Optional, aber nach dem Event ist eine Strafe ohne Grund schwer zu
verteidigen.
</p>
</div>
{#if penaltyError}
<p class="text-sm text-destructive">{penaltyError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (penaltyDialog = false)}>Abbrechen</Button>
<Button onclick={savePenalty} disabled={penaltyBusy}>
{penaltyBusy ? 'Wird gespeichert …' : 'Übernehmen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -14,84 +14,63 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import * as Dialog from '@/components/ui/dialog'
import { Camera, KeyRound, Trash2, User } from 'lucide-svelte' import { Camera, KeyRound, Trash2, User } from 'lucide-svelte'
// --- Name und Bild: sofort speichern ---------------------------------
//
// Kein Speichern-Knopf. Es sind zwei Felder, beide gehoeren nur dem
// Kontoinhaber, und ein Knopf, der fast immer nichts zu tun hat, ist eine
// Frage an jemanden, der laengst geantwortet hat: Der Name wird
// uebernommen, sobald das Feld den Fokus verliert oder Enter kommt, das
// Bild, sobald eines gewaehlt ist.
let name = $state(auth.user?.name ?? '') let name = $state(auth.user?.name ?? '')
let avatarFile = $state<File | null>(null)
/** /**
* Vorschau des gerade hochgeladenen Bildes. Sie ueberbrueckt die Zeit, bis * Vorschau des gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar —
* der Server das Bild hat und auth.refresh() es zurueckliefert — ohne sie * das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen.
* bliebe das alte Bild stehen und der Klick sähe wirkungslos aus. Die * Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst
* Object-URL wird danach wieder freigegeben, sonst haelt der Browser jedes * haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel
* einmal angesehene Bild bis zum Seitenwechsel fest. * fest.
*/ */
let avatarPreview = $state<string | null>(null) let avatarPreview = $state<string | null>(null)
let savingProfile = $state(false) $effect(() => {
let profileMessage = $state<string | null>(null) const file = avatarFile
let profileError = $state<string | null>(null) if (!file) {
let messageTimer: ReturnType<typeof setTimeout> | null = null avatarPreview = null
return
function reportSaved() {
profileMessage = 'Gespeichert.'
if (messageTimer) clearTimeout(messageTimer)
messageTimer = setTimeout(() => (profileMessage = null), 2500)
} }
async function saveName() {
const next = name.trim()
if (!auth.user || savingProfile) return
// Nichts geaendert, nichts zu speichern — sonst schriebe jeder
// Fokuswechsel denselben Namen erneut.
if (next === (auth.user.name ?? '')) return
savingProfile = true
profileError = null
try {
await api.collection('users').update(auth.user.id, { name: next })
await auth.refresh()
reportSaved()
} catch (e: any) {
profileError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
name = auth.user.name ?? ''
} finally {
savingProfile = false
}
}
async function saveAvatar(file: File | null) {
if (!auth.user || !file) return
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file)
avatarPreview = url avatarPreview = url
return () => URL.revokeObjectURL(url)
})
let savingProfile = $state(false)
let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null)
async function saveProfile() {
if (!auth.user) return
savingProfile = true savingProfile = true
profileMessage = null
profileError = null profileError = null
try { try {
// Als FormData, weil eine Datei mitkommt. Ein leeres avatar-Feld // Mit Datei als FormData, sonst als einfaches Objekt — ein leeres
// im FormData wuerde ein vorhandenes Bild loeschen — deshalb wird // avatar-Feld im FormData würde ein vorhandenes Bild löschen.
// der Name hier nicht mitgeschickt, der hat seinen eigenen Weg. let data: FormData | Record<string, unknown>
const data = new FormData() if (avatarFile) {
data.append('avatar', file) const fd = new FormData()
fd.append('name', name.trim())
fd.append('avatar', avatarFile)
data = fd
} else {
data = { name: name.trim() }
}
await api.collection('users').update(auth.user.id, data) await api.collection('users').update(auth.user.id, data)
await auth.refresh() await auth.refresh()
reportSaved()
avatarFile = null
profileMessage = 'Profil gespeichert.'
} catch (e: any) { } catch (e: any) {
profileError = e.message ?? 'Das Bild konnte nicht gespeichert werden.' profileError = e.message ?? 'Das Profil konnte nicht gespeichert werden.'
} finally { } finally {
savingProfile = false savingProfile = false
avatarPreview = null
URL.revokeObjectURL(url)
} }
} }
@ -145,14 +124,11 @@
}) })
} }
// --- Passwort ---------------------------------------------------------
// Hinter einem Knopf und in einem Dialog: Ein Passwortwechsel ist die
// Ausnahme, drei Felder dafuer standen dauerhaft offen auf der Seite.
let passwordDialog = $state(false)
let oldPassword = $state('') let oldPassword = $state('')
let newPassword = $state('') let newPassword = $state('')
let confirmPassword = $state('') let confirmPassword = $state('')
let savingPassword = $state(false) let savingPassword = $state(false)
let passwordMessage = $state<string | null>(null)
let passwordError = $state<string | null>(null) let passwordError = $state<string | null>(null)
async function savePassword() { async function savePassword() {
@ -168,6 +144,7 @@
} }
savingPassword = true savingPassword = true
passwordMessage = null
passwordError = null passwordError = null
try { try {
await api.collection('users').update(auth.user.id, { await api.collection('users').update(auth.user.id, {
@ -184,7 +161,7 @@
} }
oldPassword = newPassword = confirmPassword = '' oldPassword = newPassword = confirmPassword = ''
passwordDialog = false passwordMessage = 'Passwort geändert.'
} catch (e: any) { } catch (e: any) {
passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.' passwordError = e.message ?? 'Das Passwort konnte nicht geändert werden.'
} finally { } finally {
@ -193,9 +170,7 @@
} }
</script> </script>
<!-- Eine Spalte: Es sind zwei Karten, und Profilfelder werden nicht breiter, <div class="grid gap-6 lg:grid-cols-2">
wenn man sie in die Breite zieht. -->
<div class="max-w-2xl space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
@ -211,10 +186,9 @@
<!-- <!--
Das Bild steht neben Name und Adresse und reicht ueber beide Das Bild steht neben Name und Adresse und reicht ueber beide
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
unsichtbar darunter, angeklickt wird das Bild selbst. unsichtbar darunter, angeklickt wird das Bild selbst — das
Label „Bild" ueber einem Dateifeld sagte nichts, was das Bild
Zur E-Mail steht nichts weiter: Das Feld ist abgeschaltet, das nicht schon zeigt.
sagt schon alles, was ein Satz darunter erklaeren wollte.
--> -->
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<label <label
@ -222,7 +196,11 @@
class="group relative shrink-0 cursor-pointer rounded-full ring-offset-2 ring-offset-card focus-within:ring-2 focus-within:ring-ring" class="group relative shrink-0 cursor-pointer rounded-full ring-offset-2 ring-offset-card focus-within:ring-2 focus-within:ring-ring"
> >
{#if avatarPreview} {#if avatarPreview}
<img src={avatarPreview} alt="" class="size-20 rounded-full object-cover" /> <img
src={avatarPreview}
alt=""
class="size-20 rounded-full object-cover"
/>
{:else} {:else}
<Avatar user={auth.user} size={80} /> <Avatar user={auth.user} size={80} />
{/if} {/if}
@ -238,38 +216,66 @@
type="file" type="file"
accept="image/*" accept="image/*"
class="sr-only" class="sr-only"
onchange={(e) => saveAvatar(e.currentTarget.files?.[0] ?? null)} onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
/> />
</label> </label>
<div class="min-w-0 flex-1 space-y-2"> <div class="min-w-0 flex-1 space-y-2">
<Input <Input aria-label="Name" bind:value={name} placeholder="Wie du angezeigt wirst" />
aria-label="Name"
bind:value={name}
placeholder="Wie du angezeigt wirst"
onblur={saveName}
onkeydown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
/>
<Input aria-label="E-Mail" value={auth.user?.email ?? ''} readonly disabled /> <Input aria-label="E-Mail" value={auth.user?.email ?? ''} readonly disabled />
</div> </div>
</div> </div>
<div class="flex min-h-5 items-center justify-between gap-3"> <p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
{#if profileError} {#if profileError}
<p class="text-sm text-destructive">{profileError}</p> <p class="text-sm text-destructive">{profileError}</p>
{:else if savingProfile}
<p class="text-sm text-muted-foreground">Wird gespeichert …</p>
{:else if profileMessage} {:else if profileMessage}
<p class="text-sm text-muted-foreground">{profileMessage}</p> <p class="text-sm text-muted-foreground">{profileMessage}</p>
{:else}
<span></span>
{/if} {/if}
<Button variant="outline" size="sm" onclick={() => (passwordDialog = true)}> <Button onclick={saveProfile} disabled={savingProfile}>
<KeyRound class="size-4 mr-2" /> {savingProfile ? 'Wird gespeichert …' : 'Speichern'}
Passwort ändern
</Button> </Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<KeyRound class="size-4 text-primary" />
Passwort ändern
</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div> </div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{:else if passwordMessage}
<p class="text-sm text-muted-foreground">{passwordMessage}</p>
{/if}
<Button
onclick={savePassword}
disabled={savingPassword || !oldPassword || !newPassword}
>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
</CardContent> </CardContent>
</Card> </Card>
@ -277,7 +283,7 @@
Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist, Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist,
steht nicht zwischen Namensfeld und Passwortwechsel. steht nicht zwischen Namensfeld und Passwortwechsel.
--> -->
<Card class="border-destructive/30"> <Card class="lg:col-span-2 border-destructive/30">
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <CardTitle class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-destructive" /> <Trash2 class="size-4 text-destructive" />
@ -312,43 +318,3 @@
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<!-- Passwort ändern -->
<Dialog.Root bind:open={passwordDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Passwort ändern</Dialog.Title>
<Dialog.Description>
Nach der Änderung wirst du still neu angemeldet — PocketBase entwertet
mit dem Wechsel alle Tokens, auch den dieser Sitzung.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
<p class="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (passwordDialog = false)}>Abbrechen</Button>
<Button onclick={savePassword} disabled={savingPassword || !oldPassword || !newPassword}>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -338,14 +338,10 @@
if (!invites.isValid(invite)) parts.push('abgelaufen') if (!invites.isValid(invite)) parts.push('abgelaufen')
else if (invite.expires) { else if (invite.expires) {
parts.push(`gültig bis ${new Date(invite.expires).toLocaleDateString('de-DE')}`) parts.push(`gültig bis ${new Date(invite.expires).toLocaleDateString('de-DE')}`)
} else parts.push('unbegrenzt gültig') } else parts.push('ohne Ablauf')
const max = invite.max_uses ?? 0 const max = invite.max_uses ?? 0
const used = invite.uses ?? 0 parts.push(max > 0 ? `${invite.uses ?? 0} von ${max} genutzt` : `${invite.uses ?? 0}× genutzt`)
if (max > 0) parts.push(`${used} von ${max} Einlösungen`)
else if (used === 0) parts.push('noch nicht eingelöst')
else if (used === 1) parts.push('einmal eingelöst')
else parts.push(`${used}-mal eingelöst`)
return parts.join(' · ') return parts.join(' · ')
} }
@ -453,7 +449,7 @@
</Button> </Button>
<Button variant="outline" size="sm" onclick={() => openLogo(team)}> <Button variant="outline" size="sm" onclick={() => openLogo(team)}>
<Image class="size-4 mr-1" /> <Image class="size-4 mr-1" />
Logo festlegen Logo
</Button> </Button>
<Button variant="outline" size="sm" onclick={() => (inviteDialog = true)}> <Button variant="outline" size="sm" onclick={() => (inviteDialog = true)}>
<Link2 class="size-4 mr-1" /> <Link2 class="size-4 mr-1" />
@ -819,31 +815,23 @@
{#if invites.scoped.length > 0} {#if invites.scoped.length > 0}
<div class="space-y-2"> <div class="space-y-2">
{#each invites.scoped as invite (invite.id)} {#each invites.scoped as invite (invite.id)}
<!--
Der Link steht in voller Laenge auf einer eigenen
Zeile und bricht um. Vorher lag er in einem
einzeiligen Feld, das nicht umbrechen kann, sondern
scrollt — im Dialog sah man davon ein Stueck Mitte
und musste raten, ob der richtige Link kopiert
wird. break-all statt break-words, weil eine URL
keine Leerzeichen hat, an denen sich trennen
liesse.
-->
<div <div
class="space-y-2 rounded-lg border p-2" class="flex flex-wrap items-center gap-2 rounded-lg border p-2"
class:opacity-60={!invites.isValid(invite)} class:opacity-60={!invites.isValid(invite)}
> >
<p class="font-mono text-xs leading-relaxed break-all"> <input
{invites.url(invite)} readonly
</p> value={invites.url(invite)}
<div class="flex items-center gap-2"> class="min-w-0 flex-1 bg-transparent px-1 font-mono text-xs outline-none"
<span class="flex-1 text-xs text-muted-foreground"> onfocus={(e) => e.currentTarget.select()}
/>
<span class="text-xs text-muted-foreground whitespace-nowrap">
{inviteHint(invite)} {inviteHint(invite)}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="size-8 shrink-0" class="size-8"
tooltip="Link kopieren" tooltip="Link kopieren"
onclick={() => copyLink(invite)} onclick={() => copyLink(invite)}
> >
@ -856,30 +844,27 @@
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="size-8 shrink-0" class="size-8"
tooltip="Link zurückziehen" tooltip="Link zurückziehen"
onclick={() => removeInvite(invite)} onclick={() => removeInvite(invite)}
> >
<Trash2 class="size-4 text-destructive" /> <Trash2 class="size-4 text-destructive" />
</Button> </Button>
</div> </div>
</div>
{/each} {/each}
</div> </div>
<Separator /> <Separator />
{/if} {/if}
<div class="space-y-2"> <div class="space-y-2">
<Label for="i-days">Gültigkeit in Tagen</Label> <Label for="i-days">Gültig für (Tage)</Label>
<Input id="i-days" type="number" min="0" bind:value={inviteForm.days} /> <Input id="i-days" type="number" min="0" bind:value={inviteForm.days} />
<p class="text-xs text-muted-foreground">Bei 0 läuft der Link nicht ab.</p> <p class="text-xs text-muted-foreground">0 = läuft nicht ab.</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="i-max">Anzahl der Einlösungen</Label> <Label for="i-max">Höchstens einlösbar</Label>
<Input id="i-max" type="number" min="0" bind:value={inviteForm.maxUses} /> <Input id="i-max" type="number" min="0" bind:value={inviteForm.maxUses} />
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">0 = beliebig oft.</p>
Bei 0 lässt er sich beliebig oft einlösen.
</p>
</div> </div>
{#if inviteError} {#if inviteError}

View file

@ -1,125 +0,0 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker'
/**
* Service Worker für die Zeitnahme im Funkloch.
*
* Ohne ihn ist offline" nur so lange wahr, wie der Tab offen bleibt: Ein
* Neuladen an der Stage endete in einer weißen Seite, und die Zeiten im
* Ausgang wären mit ihr verschwunden. Eine Stage lässt sich nicht
* wiederholen deshalb dieser Aufwand.
*
* Drei Sorten Anfragen, drei Strategien:
*
* 1. Was zum Build gehört (JS, CSS, Bilder), liegt unter seiner Versions-URL
* und ändert sich nie. Cache zuerst, ohne Rückfrage.
* 2. Seitenaufrufe kommen vom Server (die App läuft mit SSR). Netz zuerst,
* die Antwort wandert in den Cache; ohne Netz die zuletzt gesehene
* Fassung derselben Seite.
* 3. Lesende Anfragen an PocketBase ebenso nur so steht nach einem
* Neuladen im Funkloch überhaupt ein Kader da, gegen den sich stoppen
* lässt. Schreibende Anfragen werden nie aus dem Cache beantwortet: Ein
* Start, der aus dem Cache gelingt", wäre eine Zeit, die es nicht gibt.
* Dafür ist der Ausgang in timeOutbox zuständig.
*/
const sw = self as unknown as ServiceWorkerGlobalScope
const ASSETS = `assets-${version}`
const PAGES = `pages-${version}`
const API = `api-${version}`
const PRECACHE = [...build, ...files]
sw.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(ASSETS)
.then((cache) => cache.addAll(PRECACHE))
.then(() => sw.skipWaiting()),
)
})
sw.addEventListener('activate', (event) => {
// Alte Versionen wegräumen. Ein Gerät an der Strecke soll nicht mit dem
// Vorrat von vor drei Events dastehen.
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== ASSETS && k !== PAGES && k !== API)
.map((k) => caches.delete(k)),
),
)
.then(() => sw.clients.claim()),
)
})
/**
* Beim Abmelden fliegt der Vorrat an API-Antworten raus. Sonst stünden die
* Daten des vorigen Nutzers offline noch da auf einem geteilten Gerät ein
* Einblick, den niemand gegeben hat.
*/
sw.addEventListener('message', (event) => {
if (event.data === 'clear-api-cache') {
event.waitUntil(caches.delete(API))
}
})
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request)
if (cached) return cached
return fetch(request)
}
/** Netz zuerst, Cache als Auffangnetz — und jede geglückte Antwort merken. */
async function networkFirst(request: Request, cacheName: string): Promise<Response> {
const cache = await caches.open(cacheName)
try {
const response = await fetch(request)
// Nur Vollstaendiges merken: Ein 206 oder ein Fehler waere im
// Funkloch schlimmer als gar nichts.
if (response.ok && response.status === 200) {
cache.put(request, response.clone())
}
return response
} catch (err) {
const cached = await cache.match(request)
if (cached) return cached
throw err
}
}
sw.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') return
const url = new URL(request.url)
// Andere Ursprünge (Kartenkacheln, Schriften) gehen den normalen Weg.
// Kacheln zu horten waere ein Vorrat ohne Ende.
const sameOrigin = url.origin === sw.location.origin
const isApi = url.pathname.startsWith('/api/')
if (sameOrigin && PRECACHE.includes(url.pathname)) {
event.respondWith(cacheFirst(request))
return
}
if (isApi) {
// Der Uhrenabgleich darf nie aus dem Cache kommen — eine gemerkte
// Uhrzeit ist das Gegenteil von dem, wozu er da ist.
if (url.pathname === '/api/clock') return
event.respondWith(networkFirst(request, API))
return
}
if (sameOrigin && request.mode === 'navigate') {
event.respondWith(networkFirst(request, PAGES))
}
})

View file

@ -1,20 +0,0 @@
{
"name": "Stammtisch Hersbruck",
"short_name": "Stammtisch",
"description": "Events, Stages, Zeiten und Trails des Stammtisch Hersbruck",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#eab308",
"lang": "de",
"icons": [
{
"src": "/schildl.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}