Compare commits

..

17 commits

Author SHA1 Message Date
Daniel Michelberger
f773c52b0a feat: mehrere Teilnehmer auf einmal eintragen
Ein Event beginnt mit zwanzig Anmeldungen, nicht mit einer. Bisher hiess
das: zwanzigmal denselben Dialog oeffnen, einen Namen aus einer
Auswahlliste suchen, eine Nummer tippen, speichern.

Jetzt steht der Kader als Liste da — Haken und Startnummer nebeneinander,
darueber eine Suche und "Alle"/"Keinen". Der Knopf zaehlt mit, was
ausgewaehlt ist.

Geprueft wird vor dem Schreiben, nicht dabei: doppelte Nummern innerhalb
der Auswahl und gegen die schon vergebenen. Eine Kollision soll nicht
erst auffallen, wenn die halbe Liste eingetragen ist.

Der alte Dialog bleibt, aber nur noch fuers Aendern einer Startnummer —
sein Anlege-Zweig samt Fahrer-Auswahlliste ist entfallen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:57:45 +02:00
Daniel Michelberger
b80b1198fc feat: Grund zur Zeitkorrektur, "Strafzeit fuer alle" entfaellt
Das Schema traegt es bereits: times.comment gibt es seit dem ersten
Snapshot und wurde von der Anwendung nie benutzt. Der Grund steht jetzt
dort — optional, aber nach dem Event ist eine Strafe ohne Grund schwer
zu verteidigen. Er haengt im Tooltip an der Korrektur und steht als
eigene Spalte im CSV.

"Strafzeit fuer alle" ist raus. Eine Strafe trifft einen Fahrer; was
fuer alle gleich gilt, ist keine Strafe, sondern eine andere Stage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:30:24 +02:00
Daniel Michelberger
5c4d2fec4d style: dunkleres Rot am Offline-Symbol
bg-destructive ist ein helles Signalrot; auf der hellen Kopfleiste
flimmerte das Symbol mehr als es meldete. red-800 steht ruhiger und
traegt das Weiss besser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:25:44 +02:00
Daniel Michelberger
76e2eeff0e refactor: Offline-Meldung in die Zeitnahme-Leiste, ganze Zeile klickbar
Die Offline-Meldung stand als eigener Streifen unter der Kopfleiste —
eine zweite Statusflaeche neben der, die es dafuer schon gibt. Sie sitzt
jetzt in der Leiste unten rechts, ganz oben und mit fallender
Dringlichkeit darunter: fehlende Verbindung, nicht uebertragene Zeiten,
laufende Uhren. Das Symbol in der Kopfleiste wird offline weiss auf rot.

In der Zeitentabelle ist die ganze Zeile anklickbar statt nur die Spalte
"Zeit" — und nur, solange die Uhr laeuft. Eine gewertete Zeit hat im
Zeit-Dialog nichts zu holen; was an ihr noch zu aendern waere, aendert
die Korrektur nebenan.

Name und Korrektur halten den Klick auf: Der Name fuehrt zum Fahrer,
nicht in den Dialog. Die Zeit bleibt bei laufender Uhr ein echter Knopf,
denn ein <tr> nimmt keinen Fokus — ohne ihn waere die Zeile mit der
Tastatur nicht erreichbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:23:54 +02:00
Daniel Michelberger
660e72717a fix: offline gestoppte Zeit laesst sich nicht zweimal stoppen
Beim Test aufgefallen: Wer im Funkloch stoppte, sah die Zeit weiter als
laufend — der Ausgang hatte den Stopp, der Datensatz nicht. Ein zweiter
Druck legte einen zweiten Stopp daneben, und beim Uebertragen gewann der
letzte. Welche Zeit am Ende zaehlte, war nicht mehr zu sagen.

Drei Dinge dagegen. Ein Stopp im Ausgang traegt sich sofort auch am
Datensatz ein, die Zeit ist damit sichtbar beendet. Ein zweiter Stopp
fuer dieselbe Zeit wird abgewiesen, mit Ansage statt still. Und ist der
Ausgang leer, laedt der Store einmal neu und tauscht die vorlaeufigen
Zeiten gegen die echten samt ihren IDs.

Dabei fiel die groessere Luecke auf: Ein offline gestarteter Lauf stand
nirgends und liess sich deshalb gar nicht stoppen — die Oberflaeche
kennt nur, was im Store steht. Er bekommt jetzt einen vorlaeufigen
Datensatz mit einer local:-ID, laeuft sichtbar mit und laesst sich
stoppen, bevor der Server ueberhaupt von ihm weiss.

Dazu der Verbindungswaechter: /api/health im Takt, Symbol in der
Kopfleiste und eine Meldung, die stehen bleibt, bis der Server wieder
antwortet. navigator.onLine allein taugt dafuer nicht — im WLAN ohne
Uplink und hinter einem Portal steht es unbeirrt auf true. Kehrt die
Verbindung zurueck, geht der Ausgang von selbst raus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:15:35 +02:00
Daniel Michelberger
6c2addb020 feat: Klick auf die Zeit oeffnet den Zeit-Dialog
Wer auf eine laufende Uhr sieht, will sie stoppen; wer auf eine
gestoppte sieht, will sie richtigen. Der Weg ueber den Knopf oben und
die Fahrerauswahl fuehrte zur selben Stelle, nur laenger — und verlangte
die Auswahl eines Fahrers, der in der Zeile laengst dranstand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:11:01 +02:00
Daniel Michelberger
d01eae078a feat: Wer gedrueckt hat, und Strafzeiten je Zeit oder je Stage
startedBy und stoppedBy standen laengst im Datensatz und wurden bei
jedem Start und Stopp geschrieben — nur sah sie niemand. Bei einer
strittigen Messung ist das die erste Frage, also steht es jetzt unter
dem Namen an der Zeit.

Und die Korrektur: times.correction gab es, sie wird auf die Dauer
gerechnet und im CSV exportiert, aber setzen konnte sie kein Mensch.
Jetzt zwei Wege dorthin — an der einzelnen Zeit ueber die Spalte, die
schon da war, und ueber alle gewerteten Zeiten der Stage auf einmal.

Der Wert wird gesetzt und nicht addiert. Zweimal denselben Dialog
abzuschicken darf keine doppelte Strafe ergeben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:07:48 +02:00
Daniel Michelberger
bb56945fe3 feat: App wird installierbar und uebersteht ein Funkloch beim Neuladen
Ohne Service Worker war "offline" nur so lange wahr, wie der Tab offen
blieb: Ein Neuladen an der Stage endete in einer weissen Seite — samt
den Zeiten, die noch im Ausgang lagen.

Drei Sorten Anfragen, drei Strategien. Was zum Build gehoert, liegt
unter seiner Versions-URL und aendert sich nie: Cache zuerst.
Seitenaufrufe und lesende Anfragen an PocketBase: Netz zuerst, Antwort
merken, ohne Netz die zuletzt gesehene Fassung — nur so steht nach einem
Neuladen im Funkloch ueberhaupt ein Kader da, gegen den sich stoppen
laesst.

Schreibende Anfragen werden nie aus dem Cache beantwortet: Ein Start,
der aus dem Cache "gelingt", waere eine Zeit, die es nicht gibt. Dafuer
ist der Ausgang zustaendig. Und /api/clock ist ausgenommen — eine
gemerkte Uhrzeit ist das Gegenteil von dem, wozu die Route da ist.

Beim Abmelden raeumt der Worker seinen API-Vorrat weg. Sonst stuenden
auf einem geteilten Geraet die Daten des vorigen Nutzers offline weiter
da.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:03:24 +02:00
Daniel Michelberger
9f075f0f6c feat: Uhrenabgleich und Ausgang fuer Zeiten ohne Netz
Eine Stage laesst sich nicht wiederholen. Zwei Dinge durften deshalb
nicht so bleiben, wie sie waren.

Erstens die Uhr. Zeiten entstehen auf den Geraeten an der Strecke — nur
so laesst sich im Funkloch ueberhaupt stoppen. Damit steckte aber der
Versatz zweier Telefonuhren in jeder Dauer, bei der einer am Start und
ein anderer im Ziel drueckt. Jedes Geraet gleicht sich jetzt gegen den
Server ab (Cristian: t0 merken, Serverzeit holen, t1 merken, Versatz =
T + RTT/2 - t1), sieben Proben, Median ueber die schnellste Haelfte. Der
Versatz liegt im localStorage und wird auf jeden Zeitstempel gerechnet.

Dafuer eine eigene Route /api/clock in den Hooks: Der Date-Header jeder
gewoehnlichen Antwort 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.

Zweitens das Netz. Scheitert die Uebertragung eines Starts oder Stopps
an einem Funkloch, wandert er in einen Ausgang im localStorage statt in
einen Fehler und geht raus, sobald wieder Empfang da ist. Die
massgebliche Zeit ist die des Druckens, nicht die der Uebertragung. Ein
Stopp kann dabei auf einen Start warten, der selbst noch aussteht.

Unterschieden wird streng zwischen Funkloch und Absage: Eine abgelehnte
Anfrage — fehlende Rechte, ungueltige Daten — wird gemeldet und nicht
aufgefangen, sonst sammelte der Ausgang stumm Eintraege, die auch beim
naechsten Versuch scheitern.

Die Leiste der laufenden Zeiten zeigt nicht uebertragene Zeiten ganz
oben und erscheint dafuer auch dann, wenn gerade nichts laeuft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 23:00:40 +02:00
Daniel Michelberger
09d7303eb2 feat: Zeitnahme fuer den Daumen an der Strecke
Die Leiste der laufenden Zeiten steht jetzt offen: Wer an der Strecke
steht, will sie sehen und nicht erst danach fragen. Und die ganze Zeile
ist der Knopf, nicht ein Icon an ihrem Rand — ein 16-px-Ziel neben einer
fingerbreiten Zeile ist eine Einladung zum Danebentippen. Der Name der
Stage verlinkt dafuer nicht mehr auf ihre Seite: ein Link im Knopf waere
weder gueltiges HTML noch eindeutig zu treffen.

Im Zeit-Dialog steht der Fahrer als Kachel statt in einer Auswahlliste —
die Startnummer gross wie am Trikot (font-startnumber: Impact, mit
Haettenschweiler und Arial Narrow Bold als Ersatz), der Name darunter.
Eine Liste verlangt aufklappen, zielen und treffen; drei Schritte fuer
etwas, das ein Zeitnehmer zwanzigmal am Tag macht.

Starten und Stoppen sind Flaechen von 96 px Hoehe, Symbol ueber Wort,
statt einer Zeile im Fussbereich. DNF steht daneben, gleich hoch, damit
es sich treffen laesst, aber schmaler, damit es nicht versehentlich
getroffen wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:51:46 +02:00
Daniel Michelberger
6ee7801d78 refactor: Icon-Auswahl ohne Freitext, Hoehenprofil weiss, Profil speichert selbst
Icon-Auswahl: Das Raster ist jetzt die Auswahl. Daneben stand ein
Freitextfeld fuer jeden Namen von lucide.dev samt Anleitung. Das
verlangte, einen Katalog von tausend Icons zu kennen und den Namen
fehlerfrei abzutippen, und belohnte den Tippfehler mit einem leeren
Kaestchen. Fehlt ein Icon, gehoert es in FLAG_ICON_SUGGESTIONS.

Hoehenprofil in Weiss zu 90 % statt in Blau.

Profil: kein Speichern-Knopf mehr. Es sind zwei Felder, beide gehoeren
nur dem Kontoinhaber — der Name wird uebernommen, sobald das Feld den
Fokus verliert oder Enter kommt, das Bild, sobald eines gewaehlt ist.
Der Satz zur E-Mail-Adresse faellt weg: Das Feld ist abgeschaltet, das
sagt schon alles, was er erklaeren wollte.

Der Passwortwechsel steckt hinter einem Knopf in einem Dialog. Er ist
die Ausnahme; drei Felder dafuer standen dauerhaft offen auf der Seite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:46:49 +02:00
Daniel Michelberger
ed3d261a2d refactor: Wortwahl im Einladungsdialog und am Logo-Knopf
"Höchstens einlösbar" war kein Satz und kein Begriff, sondern beides
halb. Aus den Feldern wird "Gültigkeit in Tagen" und "Anzahl der
Einlösungen", aus den Hinweisen darunter ganze Saetze statt
Gleichungen ("0 = beliebig oft").

Auch der Hinweis an jedem Link liest sich jetzt wie Sprache: "unbegrenzt
gueltig" statt "ohne Ablauf", "noch nicht eingeloest" statt "0x
genutzt".

Und der Knopf "Logo" sagt jetzt, was er tut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:41:40 +02:00
Daniel Michelberger
ea4e0bd80a fix: Einladungslinks brechen um statt zu scrollen
Der Link lag in einem einzeiligen Feld. So etwas bricht nicht um, es
scrollt — im Dialog sah man ein Stueck aus der Mitte und musste raten,
ob der richtige Link kopiert wird.

Jetzt steht er in voller Laenge auf einer eigenen Zeile, darunter der
Hinweis und die beiden Knoepfe. break-all statt break-words, weil eine
URL keine Leerzeichen hat, an denen sich trennen liesse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:38:07 +02:00
Daniel Michelberger
6ff37ef1f6 fix: Kopfleiste ueber den Inhalt, damit das Team-Panel bedienbar ist
<header> und <main> waren beide `relative z-10`. Bei gleichem z-index
gewinnt, was spaeter im DOM steht — also <main>. Das aufgeklappte
Team-Panel haengt unter die Kopfleiste hinein in diese Flaeche und lag
dadurch dahinter: Beim Hinunterfahren traf der Zeiger <main> statt das
Panel, die Komponente sah ein Verlassen und klappte nach delayDuration
zu, bevor ein Eintrag zu treffen war.

Ein z-50 am Panel half nicht — die Kopfleiste ist mit ihrem eigenen
z-index ein Stapelkontext, aus dem kein Kind herausragt. Also die
Kopfleiste selbst hoeher.

Nachgestellt und A/B geprueft: mit z-10 schliesst das Menue beim
Hinunterfahren, mit z-20 bleibt es offen und der Eintrag laesst sich
anklicken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:34:25 +02:00
Daniel Michelberger
43d6508369 fix: Team-Panel unter seinen Menuepunkt, ohne toten Spalt
Zwei Fehler, einer je Anlauf.

Der Inhalt gehoert ohne Viewport gerendert: Dann haengt er am
NavigationMenu.Item, das ohnehin `relative` ist, und steht damit unter
"Team". Mit Viewport ist er eine eigene Flaeche unter der ganzen Leiste
und beginnt an deren linkem Rand — unter "Dashboard".

Und der Standardabstand mt-1.5 war ein toter Streifen zwischen
Menuepunkt und Panel. Wer den Zeiger hinunterbewegte, verliess beides
zugleich; nach delayDuration klappte das Menue zu, bevor ein Eintrag zu
treffen war. Ein Hover-Menue darf keine Luecke haben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:28:41 +02:00
Daniel Michelberger
52dbde2a25 fix: Team-Panel erschien neben statt unter seinem Menuepunkt
Mit viewport={false} haengt der Inhalt per top-full/left-0 am naechsten
positionierten Vorfahren, und das ist die Leiste selbst und nicht der
Menuepunkt. Das Panel stand dadurch am linken Rand statt unter "Team":
Der Zeiger lief auf dem Weg dorthin ins Leere, und nach delayDuration
klappte das Menue zu, bevor jemand einen Eintrag treffen konnte.

Jetzt mit Viewport, so wie die Komponente gedacht ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:26:16 +02:00
Daniel Michelberger
65bb7f1f3f refactor: Kopfnavigation auf bits-uis NavigationMenu
Das Team-Submenue stand auf einem DropdownMenu, und alles, was ein
Menuepunkt braucht und ein Dropdown nicht kann, stand hier als
Handarbeit daneben: ein Zeitgeber fuers Aufklappen, ueberschriebene
Zeigerhandler gegen das Flackern beim Klick, unterdrueckter Fokus gegen
den Ring nach dem Zeigerbesuch.

NavigationMenu kann das von sich aus. Es hat openOnHover samt
delayDuration, es haelt seinen eigenen onclick zurueck, wenn per Zeiger
aufgeklappt wurde ("if opened via pointer move, we prevent the click
event"), es faengt keinen Fokus und sperrt kein Scrollen. Der Trigger
bleibt ueber das child-Snippet ein Link auf die Teamseite.

viewport={false}: Das Panel haengt unter seinem Menuepunkt, statt als
animierte Flaeche ueber die ganze Leiste zu fahren. Bei einem einzigen
aufklappbaren Punkt waere das grosses Kino ohne Stueck.

Das mobile Menue bekommt die Teams stattdessen eingerueckt und
aufgeklappt untergestellt. Auf dem Touchscreen gibt es kein
Ueberfahren — mit dem Menue allein waere seit dem Wegfall der Liste
"Meine Teams" gar kein Wechsel mehr moeglich gewesen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:23:10 +02:00
33 changed files with 2226 additions and 446 deletions

View file

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

View file

@ -18,7 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0", "@lucide/svelte": "^1.42.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.2.2", "tailwind-variants": "^3.3.1",
"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.16.0", "version": "1.42.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.42.0.tgz",
"integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==", "integrity": "sha512-9vOi3EdzJht5aD7F5OraoxrK2GyyL1Y+kVXEtIg4+IAdKmDBrwYK2d/x7akC89uA+cvkfjcw+IuzH0vfd/TuzA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
@ -5737,13 +5737,13 @@
} }
}, },
"node_modules/tailwind-variants": { "node_modules/tailwind-variants": {
"version": "3.2.2", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", "integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.x", "node": ">=16.9.x",
"pnpm": ">=7.x" "pnpm": ">=7.x"
}, },
"peerDependencies": { "peerDependencies": {
@ -5753,6 +5753,9 @@
"peerDependenciesMeta": { "peerDependenciesMeta": {
"tailwind-merge": { "tailwind-merge": {
"optional": true "optional": true
},
"tailwindcss": {
"optional": true
} }
} }
}, },

View file

@ -19,7 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0", "@lucide/svelte": "^1.42.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.2.2", "tailwind-variants": "^3.3.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",

View file

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

View file

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

View file

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

View file

@ -166,14 +166,21 @@
role="img" role="img"
aria-hidden="true" aria-hidden="true"
> >
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" /> <!-- Weiss zu 90 %, die Flaeche darunter blass. -->
<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(14 165 233)" stroke="rgb(255 255 255 / 0.9)"
stroke-width="1" stroke-width="1"
vector-effect="non-scaling-stroke" vector-effect="non-scaling-stroke"
/> />
@ -188,7 +195,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-sky-500 ring-2 ring-background" class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white ring-2 ring-background"
style:left="{hover.left}%" style:left="{hover.left}%"
style:top="{hover.top}%" style:top="{hover.top}%"
></span> ></span>

View file

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

View file

@ -1,24 +1,31 @@
<script lang="ts"> <script lang="ts">
/** /**
* Dauerhaft sichtbare Leiste über allen Dashboard-Seiten, solange * Die Leiste über allen Dashboard-Seiten: alles, was den Zeitnehmer
* irgendwo eine Zeit läuft. Gedacht für die Strecke: Man legt zwischen * betrifft, an einer Stelle. Von oben nach unten mit fallender
* zwei Startern die Trails oder die Fahrerliste an, und die laufende Uhr * Dringlichkeit — fehlende Verbindung, nicht übertragene Zeiten, laufende
* bleibt trotzdem im Blick — samt Stopp ohne Umweg über die Stage-Seite. * Uhren.
* *
* Bei mehreren Zeiten zeigt die Leiste eingeklappt nur die Zahl und die * Gedacht für die Strecke: Man legt zwischen zwei Startern die Trails
* längste Zeit; ausgeklappt alle. Bei genau einer entfällt das Klappen. * oder die Fahrerliste an, und die laufende Uhr bleibt trotzdem im Blick —
* samt Stopp ohne Umweg über die Stage-Seite.
* *
* Gestoppt wird auch von hier aus nicht direkt: Der Knopf öffnet den * Aufgeklappt ist der Normalfall: Wer an der Strecke steht, will die
* Zeit-Dialog mit Fahrer und Stage vorbelegt. * laufenden Zeiten sehen und nicht erst danach fragen. Einklappen geht,
* 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, Timer } from 'lucide-svelte' import { ChevronDown, ChevronUp, CloudOff, RefreshCw, Timer, WifiOff } from 'lucide-svelte'
const times = getTimeContext() const times = getTimeContext()
const riders = getRiderContext() const riders = getRiderContext()
@ -29,7 +36,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(false) let expanded = $state(true)
// Vorbelegung für den Zeit-Dialog // Vorbelegung für den Zeit-Dialog
let timeDialog = $state(false) let timeDialog = $state(false)
@ -48,7 +55,8 @@
} }
// Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der // Eine einzelne Zeit steht immer offen da; bei mehreren entscheidet der
// Nutzer. Fällt die Anzahl wieder auf eine, ist der Zustand egal. // Nutzer — voreingestellt ist offen. Fällt die Anzahl wieder auf eine,
// ist der Zustand egal.
const open = $derived(single || expanded) const open = $derived(single || expanded)
/** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */ /** Die Startnummer gilt je Event, deshalb der Umweg über die Stage. */
@ -61,30 +69,40 @@
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} {#if running.length > 0 || timeOutbox.pending > 0 || !connection.online}
<!-- 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">
<span class="relative flex size-2 shrink-0"> {#if running.length > 0}
<span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span> <span class="relative flex size-2 shrink-0">
<span class="relative inline-flex size-2 rounded-full bg-primary"></span> <span class="absolute inline-flex h-full w-full rounded-full bg-primary opacity-60 animate-ping"></span>
</span> <span class="relative inline-flex size-2 rounded-full bg-primary"></span>
</span>
{/if}
<span class="text-sm font-medium"> <span class="text-sm font-medium">
{running.length === 1 ? 'Zeit läuft' : `${running.length} Zeiten laufen`} {#if running.length === 0}
Zeitnahme
{:else if running.length === 1}
Zeit läuft
{:else}
{running.length} Zeiten laufen
{/if}
</span> </span>
{#if !open} {#if !open}
@ -110,35 +128,105 @@
{/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)}
{@const path = stagePath(t.stage)} <li>
<li class="flex items-center gap-2 px-3 py-2"> <!--
<div class="min-w-0 flex-1"> Die ganze Zeile ist der Knopf, nicht ein Icon
<p class="text-sm font-medium truncate">{riderLabel(t.rider, t.stage)}</p> an ihrem Rand: An der Strecke wird mit dem
<p class="text-xs text-muted-foreground truncate"> Daumen bedient, und ein 16-px-Ziel neben einer
{#if path} fingerbreiten Zeile ist eine Einladung zum
<a href={path} class="hover:underline">{stageLabel(t.stage)}</a> Danebentippen.
{:else}
{stageLabel(t.stage)}
{/if}
</p>
</div>
<span class="text-lg font-semibold tabular-nums shrink-0"> Der Name der Stage verlinkt deshalb nicht mehr
{t.formattedTime} auf ihre Seite — ein Link im Knopf waere weder
</span> gueltiges HTML noch eindeutig zu treffen.
-->
<Button <button
variant="ghost" type="button"
size="icon" class="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-accent"
class="shrink-0"
tooltip="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.stage)} onclick={() => openTimeDialog(t.rider, t.stage)}
> >
<Timer class="size-4" /> <Timer class="size-4 shrink-0 text-muted-foreground" />
</Button>
<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)}
</span>
</span>
<span class="shrink-0 text-lg font-semibold tabular-nums">
{t.formattedTime}
</span>
</button>
</li> </li>
{/each} {/each}
</ul> </ul>

View file

@ -4,18 +4,28 @@
* 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.
* *
* Der Punkt selbst ist ein Link auf die Teamseite. Ein eigener Eintrag * Traegt bits-uis NavigationMenu und nicht das DropdownMenu. Ein Dropdown
* „Uebersicht" waere die zweite Tuer zu derselben Seite, und eine * ist ein Menue, das auf Ansage aufgeht; eine Navigationsleiste ist etwas
* Ueberschrift ueber drei Teamnamen sagt nichts, was die Namen nicht * anderes, und die Unterschiede sind genau die, die hier gebraucht werden:
* schon sagen. * Aufklappen beim Ueberfahren samt Verzoegerung, ein Klick, der nach dem
* 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 „Meine Teams". * die Teamseite, und gewechselt wird in deren Liste.
*/ */
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 DropdownMenu from '@/components/ui/dropdown-menu' import * as NavigationMenu from '@/components/ui/navigation-menu'
import { ChevronDown, Plus, Users } from 'lucide-svelte' import { ChevronDown, Plus, Users } from 'lucide-svelte'
let { let {
@ -26,21 +36,6 @@
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
@ -52,86 +47,55 @@
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>
<DropdownMenu.Root bind:open={menuOpen}> <NavigationMenu.Item value="team">
<DropdownMenu.Trigger> <NavigationMenu.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}
</DropdownMenu.Trigger> </NavigationMenu.Trigger>
<!-- <!--
Aufgeklappt wird beim Ueberfahren, nicht auf Ansage: Das Menue darf mt-0 statt der 1.5 aus dem Standard: Der Abstand war ein toter Streifen
deshalb weder den Fokus an sich ziehen noch das Scrollen sperren — zwischen Menuepunkt und Panel. Wer den Zeiger hinunterbewegte, verliess
beides gehoert zu einem Dialog, nicht zu einem Menuepunkt, ueber den beides zugleich, und nach delayDuration klappte das Menue zu, bevor ein
der Zeiger nur streift. Eintrag zu treffen war. Ein Hover-Menue darf keine Luecke haben.
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.
--> -->
<DropdownMenu.Content <NavigationMenu.Content class="min-w-56 group-data-[viewport=false]/navigation-menu:mt-0">
align="start"
class="min-w-56"
trapFocus={false}
preventScroll={false}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onmouseenter={enter}
onmouseleave={leave}
>
{#each teams.records as t (t.id)} {#each teams.records as t (t.id)}
<DropdownMenu.Item <button
type="button"
onclick={() => switchTo(t.id)} onclick={() => switchTo(t.id)}
class={t.id === teams.activeId ? 'bg-accent' : ''} class="{itemClass} {t.id === teams.activeId ? 'bg-accent' : ''}"
> >
<span class="flex-1">{t.name}</span> <span class="flex-1 truncate">{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}
</DropdownMenu.Item> </button>
{:else} {:else}
<DropdownMenu.Item disabled>Noch in keinem Team</DropdownMenu.Item> <p class="px-2 py-1.5 text-sm text-muted-foreground">Noch in keinem Team</p>
{/each} {/each}
<DropdownMenu.Separator /> <div class="my-1 h-px bg-border"></div>
<DropdownMenu.Item onclick={() => (createDialog = true)}>
<Plus class="h-4 w-4 mr-2" />
Team erstellen
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Ausserhalb des Menues: mit dem Klick schliesst sich das Dropdown, und ein <button type="button" onclick={() => (createDialog = true)} class={itemClass}>
darin gerendeter Dialog verschwaende mit ihm. --> <Plus class="h-4 w-4" />
Team erstellen
</button>
</NavigationMenu.Content>
</NavigationMenu.Item>
<!-- Ausserhalb des Menues: mit dem Klick klappt es zu, und ein darin
gerendeter Dialog verschwaende mit ihm. -->
<CreateTeamDialog bind:open={createDialog} /> <CreateTeamDialog bind:open={createDialog} />

View file

@ -5,6 +5,12 @@
* 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'
@ -15,7 +21,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, Play, Square } from 'lucide-svelte' import { AlertTriangle, Flag, Play, Square } from 'lucide-svelte'
let { let {
open = $bindable(false), open = $bindable(false),
@ -65,9 +71,6 @@
) )
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…',
) )
@ -134,19 +137,45 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>Fahrer</Label> <Label id="rider-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>
@ -202,22 +231,50 @@
</div> </div>
</div> </div>
{:else} {:else}
<Dialog.Footer> <!--
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button> Die Handlung steht als Fläche da, nicht als Zeile im
{#if active} Fussbereich: 96 px hoch, über die volle Breite, Symbol über
<Button variant="outline" disabled={busy} onclick={() => (confirmDnf = true)}> Wort. Wer im Stehen auf die Sekunde drückt, zielt nicht auf
DNF einen 36 px hohen Streifen am unteren Rand.
</Button>
<Button variant="destructive" disabled={busy} onclick={() => stop('finished')}> DNF steht daneben und ist schmaler — dieselbe Höhe, damit es
<Square class="h-4 w-4 mr-2" /> sich treffen lässt, aber nicht dieselbe Breite, damit es nicht
versehentlich getroffen wird.
-->
{#if active}
<div class="grid grid-cols-3 gap-2">
<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 Stoppen
</Button> </Button>
{:else} <Button
<Button disabled={!ready || busy} onclick={start}> variant="outline"
<Play class="h-4 w-4 mr-2" /> disabled={busy}
Starten class="h-24 flex-col gap-1 text-base font-semibold"
onclick={() => (confirmDnf = true)}
>
<Flag class="size-6" />
DNF
</Button> </Button>
{/if} </div>
{:else}
<Button
disabled={!ready || busy}
class="h-24 w-full flex-col gap-1 text-lg font-semibold"
onclick={start}
>
<Play class="size-7" />
Starten
</Button>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>Abbrechen</Button>
</Dialog.Footer> </Dialog.Footer>
{/if} {/if}
</Dialog.Content> </Dialog.Content>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,14 +31,13 @@ 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
}
/** /**
* Vorschläge für die Auswahl im Dialog. Bewusst nach Themen sortiert und * Die Auswahl, die im Dialog zur Wahl steht nach Themen sortiert.
* nicht abschließend: Wer etwas anderes braucht, tippt den Namen ein. *
* Abschliessend: Es gab einmal ein Freitextfeld daneben, in das sich jeder
* Name von lucide.dev eintippen liess. Das verlangte, einen Katalog von
* tausend Icons zu kennen, und belohnte den Tippfehler mit einem leeren
* Kaestchen. Fehlt hier etwas, gehoert es in diese Liste.
*/ */
export const FLAG_ICON_SUGGESTIONS = [ export const FLAG_ICON_SUGGESTIONS = [
'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info', 'TreePine', 'Blocks', 'Waves', 'Ban', 'Construction', 'Info',

View file

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

View file

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

View file

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

View file

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

View file

@ -2,11 +2,20 @@ 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 {
@ -33,6 +42,21 @@ 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)
@ -41,6 +65,13 @@ 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 []
@ -75,30 +106,140 @@ 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')
return await api.collection('times').create({
stage: stageId, const at = clockSync.iso()
rider: riderId, const startedBy = api.authStore.record?.id
team: teamId,
start: new Date().toISOString(), try {
status: 'active' as TimesStatusOptions, return await api.collection('times').create({
startedBy: api.authStore.record?.id, stage: stageId,
correction: 0, rider: riderId,
}) team: teamId,
start: at,
status: 'active' as TimesStatusOptions,
startedBy,
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) {
return await api.collection('times').update(timeId, { const at = clockSync.iso()
stop: new Date().toISOString(), const stoppedBy = api.authStore.record?.id
status,
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, {
stop: at,
status,
stoppedBy,
})
} 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) { /**
return await api.collection('times').update(timeId, { correction: seconds }) * Eine Zeit vorläufig als gestoppt führen, solange der Server nichts davon
* weiss. Beim Übertragen kommt der echte Datensatz nach und ersetzt sie.
*/
private markStopped(timeId: string, at: string, status: TimeStatus, stoppedBy?: string) {
const idx = this.records.findIndex((t) => t.id === timeId)
if (idx === -1) return
const next = [...this.records]
next[idx] = { ...next[idx], stop: at, status, stoppedBy } as TimesResponse
this.records = next
}
/**
* Zeitkorrektur setzen, mit optionalem Grund.
*
* Der Grund steht im vorhandenen Feld `comment`. Eine Strafe ohne Grund
* ist nach dem Event nicht mehr zu verteidigen Pflicht ist er trotzdem
* nicht: An der Strecke zaehlt, dass die Zahl stimmt.
*/
async applyCorrection(timeId: string, seconds: number, comment?: string) {
return await api.collection('times').update(timeId, {
correction: seconds,
comment: comment?.trim() ?? '',
})
} }
async updateStatus(timeId: string, status: TimeStatus) { async updateStatus(timeId: string, status: TimeStatus) {

View file

@ -20,9 +20,12 @@
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, Route, Settings, Users } from 'lucide-svelte' import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users, Wifi, WifiOff } 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'
@ -47,6 +50,10 @@
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 })
@ -61,6 +68,10 @@
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()
@ -76,6 +87,7 @@
}) })
onDestroy(() => { onDestroy(() => {
connection.stop()
teams.destroy() teams.destroy()
invites.destroy() invites.destroy()
events.destroy() events.destroy()
@ -160,7 +172,17 @@
<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
@ -180,24 +202,35 @@
<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"> <!--
{#each nav as item} Ohne Viewport: Der Inhalt haengt dann am
{@const Icon = item.icon} Menuepunkt, der als NavigationMenu.Item ohnehin
{#if item.submenu} `relative` ist. Mit Viewport waere er eine eigene
<TeamMenu class={navClass(item.href)} /> Flaeche unter der ganzen Leiste und stuende an
{:else} deren linkem Rand — unter „Dashboard" statt unter
<a „Team". -->
href={item.href} <NavigationMenu.Root viewport={false} class="hidden md:flex">
class={navClass(item.href, dashHover && item.href === '/dashboard')} <NavigationMenu.List class="items-center gap-2">
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)} {#each nav as item}
onmouseleave={() => item.href === '/dashboard' && (dashHover = false)} {@const Icon = item.icon}
> {#if item.submenu}
<Icon class="h-4 w-4" /> <TeamMenu class={navClass(item.href)} />
<span>{item.label}</span> {:else}
</a> <NavigationMenu.Item>
{/if} <NavigationMenu.Link
{/each} href={item.href}
</nav> class={navClass(item.href, dashHover && item.href === '/dashboard')}
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
onmouseleave={() => item.href === '/dashboard' && (dashHover = false)}
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</NavigationMenu.Link>
</NavigationMenu.Item>
{/if}
{/each}
</NavigationMenu.List>
</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.
@ -216,6 +249,29 @@
</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"
@ -263,20 +319,54 @@
<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}
<a
href={item.href}
onclick={() => (isMobileMenuOpen = false)}
class="w-full {navClass(item.href)}"
>
<Icon class="h-4 w-4" />
<span>{item.label}</span>
</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} {#if item.submenu}
<TeamMenu <div class="ml-6 space-y-1 border-l pl-3">
class="w-full {navClass(item.href)}" {#each teams.records as t (t.id)}
onnavigate={() => (isMobileMenuOpen = false)} <button
/> type="button"
{:else} onclick={() => {
<a teams.setActive(t.id)
href={item.href} isMobileMenuOpen = false
onclick={() => (isMobileMenuOpen = false)} }}
class="w-full {navClass(item.href)}" 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
<Icon class="h-4 w-4" /> ? 'font-medium text-foreground'
<span>{item.label}</span> : 'text-muted-foreground'}"
</a> >
<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" />
@ -313,5 +403,7 @@
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. --> <!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
<RunningTimesToast /> <RunningTimesToast />
<CreateTeamDialog bind:open={createTeamDialog} />
</div> </div>
{/if} {/if}

View file

@ -13,7 +13,6 @@
} 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'
@ -34,6 +33,7 @@
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,48 +81,110 @@
// --- 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({ rider: '', number: '' }) let form = $state({ 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() {
editing = null picks = Object.fromEntries(available.map((r) => [r.id, { chosen: false, number: '' }]))
form = { rider: available[0]?.id ?? '', number: '' } addQuery = ''
formError = null addError = null
riderDialog = true addDialog = 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 = { rider: p.rider, number: p.number ?? '' } form = { number: p.number ?? '' }
formError = null formError = null
riderDialog = true riderDialog = true
} }
async function saveParticipant() { async function saveParticipant() {
if (!event) return if (!event || !editing) return
if (!form.rider) { if (participants.isNumberTaken(event.id, form.number, editing.id)) {
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.'
@ -489,36 +551,13 @@
<Dialog.Root bind:open={riderDialog}> <Dialog.Root bind:open={riderDialog}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{editing ? 'Startnummer ändern' : 'Teilnehmer hinzufügen'}</Dialog.Title> <Dialog.Title>Startnummer ändern</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" />
@ -534,7 +573,100 @@
<Dialog.Footer> <Dialog.Footer>
<Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button> <Button variant="ghost" onclick={() => (riderDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipant}>{editing ? 'Speichern' : 'Hinzufügen'}</Button> <Button onclick={saveParticipant}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!--
Teilnehmer eintragen: der Kader als Liste, Haken und Nummer nebeneinander.
Wer zwanzig Leute eintraegt, soll das in einem Durchgang tun koennen und
nicht zwanzigmal denselben Dialog oeffnen.
-->
<Dialog.Root bind:open={addDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Teilnehmer eintragen</Dialog.Title>
<Dialog.Description>
Aus dem Kader des Teams. Die Startnummer gilt je Event und kann leer
bleiben.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-3">
{#if available.length === 0}
<p class="text-sm text-muted-foreground">
Der ganze Kader ist schon eingetragen.
</p>
{:else}
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={addQuery} placeholder="Fahrer suchen" class="pl-9" />
</div>
<Button variant="outline" size="sm" onclick={() => toggleAll(true)}>Alle</Button>
<Button variant="ghost" size="sm" onclick={() => toggleAll(false)}>Keinen</Button>
</div>
<div class="max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2">
{#each addFiltered as r (r.id)}
<div class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-accent">
<input
id="pick-{r.id}"
type="checkbox"
class="size-4 shrink-0 rounded border-input accent-primary"
checked={picks[r.id]?.chosen ?? false}
onchange={(e) =>
(picks = {
...picks,
[r.id]: {
number: picks[r.id]?.number ?? '',
chosen: e.currentTarget.checked,
},
})}
/>
<label for="pick-{r.id}" class="min-w-0 flex-1 cursor-pointer truncate text-sm">
{riderName(r) || '— ohne Name —'}
</label>
<Input
value={picks[r.id]?.number ?? ''}
placeholder="Nr."
aria-label="Startnummer für {riderName(r)}"
class="h-8 w-20 shrink-0 text-center"
oninput={(e) =>
(picks = {
...picks,
[r.id]: {
chosen: picks[r.id]?.chosen ?? false,
number: e.currentTarget.value,
},
})}
/>
</div>
{:else}
<p class="px-2 py-4 text-center text-sm text-muted-foreground">
Kein Treffer.
</p>
{/each}
</div>
{/if}
{#if addError}
<p class="text-sm text-destructive">{addError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (addDialog = false)}>Abbrechen</Button>
<Button onclick={saveParticipants} disabled={addBusy || picked.length === 0}>
{#if addBusy}
Wird eingetragen …
{:else if picked.length === 0}
Eintragen
{:else}
{picked.length} eintragen
{/if}
</Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View file

@ -12,20 +12,35 @@
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, Download, Flag, Timer, Trophy } from 'lucide-svelte' import { ArrowLeft, Clock, Download, Flag, Pencil, Timer, Trophy, TriangleAlert } 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()
@ -64,12 +79,65 @@
// 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)']] const rows = [['Platz', 'Nummer', 'Name', 'Zeit', 'Korrektur (s)', 'Grund']]
leaderboard.forEach((t, i) => { leaderboard.forEach((t, i) => {
const r = rider(t.rider) const r = rider(t.rider)
rows.push([ rows.push([
@ -78,6 +146,7 @@
r ? riderName(r) : '', r ? riderName(r) : '',
t.formattedTime ?? '', t.formattedTime ?? '',
String(t.correction ?? 0), String(t.correction ?? 0),
t.comment ?? '',
]) ])
}) })
@ -127,13 +196,32 @@
</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={() => (timeDialog = true)} disabled={starters.length === 0}> <Button onclick={() => openTime()} 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>
@ -152,10 +240,12 @@
<!-- Zeiten: laufende oben, darunter die Wertung --> <!-- Zeiten: laufende oben, darunter die Wertung -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2 text-base"> <div class="flex flex-wrap items-center justify-between gap-2">
<Trophy class="h-4 w-4 text-primary" /> <CardTitle class="flex items-center gap-2 text-base">
Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''}) <Trophy class="h-4 w-4 text-primary" />
</CardTitle> Zeiten ({leaderboard.length} gewertet{running.length ? `, ${running.length} laufend` : ''})
</CardTitle>
</div>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-0">
{#if rows.length === 0} {#if rows.length === 0}
@ -178,7 +268,20 @@
{@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>
@ -195,16 +298,86 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium"> <Table.Cell class="font-medium">
{#if r} {#if r}
<a href={riderPath(r.id)} class="hover:underline">{riderName(r) || '—'}</a> <!-- stopPropagation: Der Name fuehrt zum Fahrer,
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' : ''}"> <!--
{t.formattedTime} 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}
</Button>
{:else}
<span class="block px-4 py-3 tabular-nums">
{t.formattedTime}
</span>
{/if}
</Table.Cell> </Table.Cell>
<Table.Cell class="text-right text-muted-foreground tabular-nums"> <Table.Cell class="text-right">
{t.correction ? `${t.correction > 0 ? '+' : ''}${t.correction.toFixed(1)}s` : ''} <Button
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}
@ -249,4 +422,57 @@
</div> </div>
{/if} {/if}
<TimeDialog bind:open={timeDialog} eventId={event?.id ?? ''} stageId={stage?.id ?? null} /> <TimeDialog
bind:open={timeDialog}
eventId={event?.id ?? ''}
riderId={dialogRider}
stageId={stage?.id ?? null}
/>
<ClockSyncDialog bind:open={clockDialog} />
<!-- Zeitkorrektur: eine Zahl in Sekunden, plus wie minus. -->
<Dialog.Root bind:open={penaltyDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Zeitkorrektur</Dialog.Title>
<Dialog.Description>
Wird auf die gemessene Zeit gerechnet. Eine Strafe ist positiv, eine
Gutschrift negativ.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="penalty">Sekunden</Label>
<Input id="penalty" type="number" step="0.1" bind:value={penaltySeconds} />
<p class="text-xs text-muted-foreground">
0 nimmt eine bestehende Korrektur wieder zurück.
</p>
</div>
<div class="space-y-2">
<Label for="penalty-reason">Grund</Label>
<Input
id="penalty-reason"
bind:value={penaltyReason}
placeholder="z. B. Tor ausgelassen"
/>
<p class="text-xs text-muted-foreground">
Optional, aber nach dem Event ist eine Strafe ohne Grund schwer zu
verteidigen.
</p>
</div>
{#if penaltyError}
<p class="text-sm text-destructive">{penaltyError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (penaltyDialog = false)}>Abbrechen</Button>
<Button onclick={savePenalty} disabled={penaltyBusy}>
{penaltyBusy ? 'Wird gespeichert …' : 'Übernehmen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -14,63 +14,84 @@
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 gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar — * Vorschau des gerade hochgeladenen Bildes. Sie ueberbrueckt die Zeit, bis
* das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen. * der Server das Bild hat und auth.refresh() es zurueckliefert — ohne sie
* Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst * bliebe das alte Bild stehen und der Klick sähe wirkungslos aus. Die
* haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel * Object-URL wird danach wieder freigegeben, sonst haelt der Browser jedes
* fest. * einmal angesehene Bild bis zum Seitenwechsel fest.
*/ */
let avatarPreview = $state<string | null>(null) let avatarPreview = $state<string | null>(null)
$effect(() => {
const file = avatarFile
if (!file) {
avatarPreview = null
return
}
const url = URL.createObjectURL(file)
avatarPreview = url
return () => URL.revokeObjectURL(url)
})
let savingProfile = $state(false) let savingProfile = $state(false)
let profileMessage = $state<string | null>(null) let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null) let profileError = $state<string | null>(null)
let messageTimer: ReturnType<typeof setTimeout> | null = null
async function saveProfile() { function reportSaved() {
if (!auth.user) return 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 savingProfile = true
profileMessage = null
profileError = null profileError = null
try { try {
// Mit Datei als FormData, sonst als einfaches Objekt — ein leeres await api.collection('users').update(auth.user.id, { name: next })
// avatar-Feld im FormData würde ein vorhandenes Bild löschen. await auth.refresh()
let data: FormData | Record<string, unknown> reportSaved()
if (avatarFile) { } catch (e: any) {
const fd = new FormData() profileError = e.message ?? 'Der Name konnte nicht gespeichert werden.'
fd.append('name', name.trim()) name = auth.user.name ?? ''
fd.append('avatar', avatarFile) } finally {
data = fd savingProfile = false
} else { }
data = { name: name.trim() } }
}
async function saveAvatar(file: File | null) {
if (!auth.user || !file) return
const url = URL.createObjectURL(file)
avatarPreview = url
savingProfile = true
profileError = null
try {
// Als FormData, weil eine Datei mitkommt. Ein leeres avatar-Feld
// im FormData wuerde ein vorhandenes Bild loeschen — deshalb wird
// der Name hier nicht mitgeschickt, der hat seinen eigenen Weg.
const data = new FormData()
data.append('avatar', file)
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 Profil konnte nicht gespeichert werden.' profileError = e.message ?? 'Das Bild konnte nicht gespeichert werden.'
} finally { } finally {
savingProfile = false savingProfile = false
avatarPreview = null
URL.revokeObjectURL(url)
} }
} }
@ -124,11 +145,14 @@
}) })
} }
// --- 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() {
@ -144,7 +168,6 @@
} }
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, {
@ -161,7 +184,7 @@
} }
oldPassword = newPassword = confirmPassword = '' oldPassword = newPassword = confirmPassword = ''
passwordMessage = 'Passwort geändert.' passwordDialog = false
} 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 {
@ -170,7 +193,9 @@
} }
</script> </script>
<div class="grid gap-6 lg:grid-cols-2"> <!-- Eine Spalte: Es sind zwei Karten, und Profilfelder werden nicht breiter,
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">
@ -186,9 +211,10 @@
<!-- <!--
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 — das unsichtbar darunter, angeklickt wird das Bild selbst.
Label „Bild" ueber einem Dateifeld sagte nichts, was das Bild
nicht schon zeigt. Zur E-Mail steht nichts weiter: Das Feld ist abgeschaltet, das
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
@ -196,11 +222,7 @@
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 <img src={avatarPreview} alt="" class="size-20 rounded-full object-cover" />
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}
@ -216,66 +238,38 @@
type="file" type="file"
accept="image/*" accept="image/*"
class="sr-only" class="sr-only"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)} onchange={(e) => saveAvatar(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 aria-label="Name" bind:value={name} placeholder="Wie du angezeigt wirst" /> <Input
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>
<p class="text-xs text-muted-foreground"> <div class="flex min-h-5 items-center justify-between gap-3">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die {#if profileError}
Team-Administration über PocketBase. <p class="text-sm text-destructive">{profileError}</p>
</p> {:else if savingProfile}
<p class="text-sm text-muted-foreground">Wird gespeichert …</p>
{:else if profileMessage}
<p class="text-sm text-muted-foreground">{profileMessage}</p>
{:else}
<span></span>
{/if}
{#if profileError} <Button variant="outline" size="sm" onclick={() => (passwordDialog = true)}>
<p class="text-sm text-destructive">{profileError}</p> <KeyRound class="size-4 mr-2" />
{:else if profileMessage} Passwort ändern
<p class="text-sm text-muted-foreground">{profileMessage}</p> </Button>
{/if}
<Button onclick={saveProfile} disabled={savingProfile}>
{savingProfile ? 'Wird gespeichert …' : 'Speichern'}
</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>
@ -283,7 +277,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="lg:col-span-2 border-destructive/30"> <Card class="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" />
@ -318,3 +312,43 @@
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<!-- Passwort ändern -->
<Dialog.Root bind:open={passwordDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Passwort ändern</Dialog.Title>
<Dialog.Description>
Nach der Änderung wirst du still neu angemeldet — PocketBase entwertet
mit dem Wechsel alle Tokens, auch den dieser Sitzung.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="pw-old">Aktuelles Passwort</Label>
<Input id="pw-old" type="password" bind:value={oldPassword} autocomplete="current-password" />
</div>
<div class="space-y-2">
<Label for="pw-new">Neues Passwort</Label>
<Input id="pw-new" type="password" bind:value={newPassword} autocomplete="new-password" />
<p class="text-xs text-muted-foreground">Mindestens 8 Zeichen.</p>
</div>
<div class="space-y-2">
<Label for="pw-confirm">Wiederholen</Label>
<Input id="pw-confirm" type="password" bind:value={confirmPassword} autocomplete="new-password" />
</div>
{#if passwordError}
<p class="text-sm text-destructive">{passwordError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (passwordDialog = false)}>Abbrechen</Button>
<Button onclick={savePassword} disabled={savingPassword || !oldPassword || !newPassword}>
{savingPassword ? 'Wird geändert …' : 'Passwort ändern'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -338,10 +338,14 @@
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('ohne Ablauf') } else parts.push('unbegrenzt gültig')
const max = invite.max_uses ?? 0 const max = invite.max_uses ?? 0
parts.push(max > 0 ? `${invite.uses ?? 0} von ${max} genutzt` : `${invite.uses ?? 0}× genutzt`) const used = invite.uses ?? 0
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(' · ')
} }
@ -449,7 +453,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 Logo festlegen
</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" />
@ -815,41 +819,50 @@
{#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="flex flex-wrap items-center gap-2 rounded-lg border p-2" class="space-y-2 rounded-lg border p-2"
class:opacity-60={!invites.isValid(invite)} class:opacity-60={!invites.isValid(invite)}
> >
<input <p class="font-mono text-xs leading-relaxed break-all">
readonly {invites.url(invite)}
value={invites.url(invite)} </p>
class="min-w-0 flex-1 bg-transparent px-1 font-mono text-xs outline-none" <div class="flex items-center gap-2">
onfocus={(e) => e.currentTarget.select()} <span class="flex-1 text-xs text-muted-foreground">
/> {inviteHint(invite)}
<span class="text-xs text-muted-foreground whitespace-nowrap"> </span>
{inviteHint(invite)} <Button
</span> variant="ghost"
<Button size="icon"
variant="ghost" class="size-8 shrink-0"
size="icon" tooltip="Link kopieren"
class="size-8" onclick={() => copyLink(invite)}
tooltip="Link kopieren" >
onclick={() => copyLink(invite)} {#if copied === invite.id}
> <Check class="size-4 text-primary" />
{#if copied === invite.id} {:else}
<Check class="size-4 text-primary" /> <Copy class="size-4" />
{:else} {/if}
<Copy class="size-4" /> </Button>
{/if} <Button
</Button> variant="ghost"
<Button size="icon"
variant="ghost" class="size-8 shrink-0"
size="icon" tooltip="Link zurückziehen"
class="size-8" onclick={() => removeInvite(invite)}
tooltip="Link zurückziehen" >
onclick={() => removeInvite(invite)} <Trash2 class="size-4 text-destructive" />
> </Button>
<Trash2 class="size-4 text-destructive" /> </div>
</Button>
</div> </div>
{/each} {/each}
</div> </div>
@ -857,14 +870,16 @@
{/if} {/if}
<div class="space-y-2"> <div class="space-y-2">
<Label for="i-days">Gültig für (Tage)</Label> <Label for="i-days">Gültigkeit in Tagen</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">0 = läuft nicht ab.</p> <p class="text-xs text-muted-foreground">Bei 0 läuft der Link nicht ab.</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="i-max">Höchstens einlösbar</Label> <Label for="i-max">Anzahl der Einlösungen</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">0 = beliebig oft.</p> <p class="text-xs text-muted-foreground">
Bei 0 lässt er sich beliebig oft einlösen.
</p>
</div> </div>
{#if inviteError} {#if inviteError}

View file

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

View file

@ -0,0 +1,20 @@
{
"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"
}
]
}