Compare commits
No commits in common. "f773c52b0acfc235da3e4ee0009facb373e33e2b" and "0f4b532773814c1e32b34953d9cf9bb76f108460" have entirely different histories.
f773c52b0a
...
0f4b532773
33 changed files with 450 additions and 2230 deletions
|
|
@ -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() })
|
|
||||||
})
|
|
||||||
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue