stammtisch-hersbruck/frontend/src/routes/dashboard/+layout.svelte
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

410 lines
20 KiB
Svelte

<script lang="ts">
import { auth } from '$lib/stores/pocketbase.svelte'
import { goto } from '$app/navigation'
import { page } from '$app/state'
import { tooltip } from '$lib/stores/app.svelte'
import { setTeamContext } from '$lib/stores/teams.svelte'
import { setTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { setEventContext } from '$lib/stores/events.svelte'
import { setStageContext } from '$lib/stores/stages.svelte'
import { setRiderContext } from '$lib/stores/riders.svelte'
import { setTimeContext } from '$lib/stores/times.svelte'
import { setEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { setEventSeriesContext } from '$lib/stores/eventSeries.svelte'
import { setTrailContext } from '$lib/stores/trails.svelte'
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { setTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Logo from '$lib/components/Logo.svelte'
import TeamMenu from '$lib/components/TeamMenu.svelte'
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
import OfflineBanner from '$lib/components/OfflineBanner.svelte'
import { connection } from '$lib/stores/connection.svelte'
import * as NavigationMenu from '@/components/ui/navigation-menu'
import Avatar from '$lib/components/Avatar.svelte'
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users, Wifi, WifiOff } from 'lucide-svelte'
import { toggleMode, mode } from 'mode-watcher'
import { onDestroy, onMount } from 'svelte'
let { children } = $props()
// Teams MUSS vor den anderen Stores stehen, weil die anderen Stores
// getTeamContext() im Constructor aufrufen
const teams = setTeamContext()
const invites = setTeamInviteContext()
const events = setEventContext()
const stages = setStageContext()
const riders = setRiderContext()
const times = setTimeContext()
// Nach events und riders: Der Store greift im Constructor auf beide zu.
const participants = setEventParticipantContext()
const series = setEventSeriesContext()
const trails = setTrailContext()
const trailVersions = setTrailVersionContext()
const trailFlags = setTrailFlagContext()
const trailMarkers = setTrailMarkerContext()
const trailComments = setTrailCommentContext()
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 () => {
if (!auth.user) {
goto('/login', { replaceState: true })
return
}
// Teams zuerst laden, weil die anderen Stores nach team filtern
await teams.load()
teams.subscribe()
await Promise.all([
events.load(), stages.load(), riders.load(), times.load(), participants.load(), series.load(),
invites.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.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()
events.subscribe()
stages.subscribe()
riders.subscribe()
times.subscribe()
participants.subscribe()
series.subscribe()
trails.subscribe()
trailVersions.subscribe()
trailFlags.subscribe()
trailMarkers.subscribe()
trailComments.subscribe()
})
onDestroy(() => {
connection.stop()
teams.destroy()
invites.destroy()
events.destroy()
stages.destroy()
riders.destroy()
times.destroy()
participants.destroy()
series.destroy()
trails.destroy()
trailVersions.destroy()
trailFlags.destroy()
trailMarkers.destroy()
trailComments.destroy()
})
// Ohne Rückfrage: Abmelden löscht nichts und ist mit einem Login wieder
// rückgängig gemacht — eine Bestätigung wäre nur ein zusätzlicher Klick.
async function handleLogout() {
await auth.logout()
goto('/login')
}
// Ein Menuepunkt sieht aus wie der andere, ob Link oder aufklappbar.
const navItemClass =
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors'
const navIdleClass = 'text-muted-foreground hover:text-foreground hover:bg-accent'
const navHoverClass = 'text-foreground bg-accent'
const navActiveClass = 'bg-primary text-primary-foreground hover:bg-primary/90'
/**
* Wo man gerade steht. „/dashboard" nur genau dort — sonst waere das
* Dashboard auf jeder Unterseite mit-aktiv, weil alle Pfade damit
* anfangen.
*/
function isActive(href: string) {
const path = page.url.pathname
return href === '/dashboard' ? path === '/dashboard' : path.startsWith(href)
}
/**
* Die vollstaendige Klassenliste eines Menuepunkts. Bewusst eine
* Funktion und kein zusaetzliches `class:`: `text-foreground` und
* `text-muted-foreground` sind dieselbe Utility-Gruppe — stuenden beide
* am Element, entschiede die Reihenfolge im Stylesheet und nicht die im
* Attribut.
*/
function navClass(href: string, hovered = false) {
if (isActive(href)) return `${navItemClass} ${navActiveClass}`
return `${navItemClass} ${hovered ? navHoverClass : navIdleClass}`
}
// Logo und Menuepunkt „Dashboard" fuehren an dieselbe Stelle, also
// reagieren sie gemeinsam — egal, welches von beiden man ueberfaehrt.
// Ueber CSS ginge das nicht: Der Menuepunkt steckt in der <nav>, das Logo
// daneben, und peer-hover reicht nur an Geschwister, nicht in sie hinein.
let dashHover = $state(false)
// Zeitnahme und Ergebnisse haben keine eigenen Seiten mehr: Beides
// passiert dort, wo es hingehört — an der Stage innerhalb ihres Events.
const nav = [
{ href: '/dashboard', icon: Home, label: 'Dashboard' },
{ href: '/dashboard/events', icon: Calendar, label: 'Events' },
{ href: '/dashboard/team', icon: Users, label: 'Team', submenu: true },
{ href: '/dashboard/trails', icon: Route, label: 'Trails' },
{ href: '/dashboard/settings', icon: Settings, label: 'Einstellungen' },
]
</script>
{#if auth.user}
<div class="relative min-h-screen bg-background">
<!--
Das Logo als Wasserzeichen: fest in der Ecke, gedreht und sehr
blass. aria-hidden und pointer-events-none, damit es weder
vorgelesen wird noch Klicks abfängt. Im Dunkelmodus etwas
kräftiger, sonst verschwindet es im dunklen Grund.
-->
<div
aria-hidden="true"
class="pointer-events-none fixed -bottom-24 -right-24 z-0 select-none opacity-[0.04] dark:opacity-[0.07]"
>
<Logo team={teams.active} class="size-[32rem] max-w-[70vw] rotate-12" />
</div>
<!--
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="flex min-h-16 items-center justify-between gap-4 py-2">
<!-- Links: Logo und Menü. Das negative -my-3 frisst das
py-2 der Leiste auf und noch etwas mehr: Das Logo
steht dadurch 72 px hoch in einer 64 px hohen Leiste,
ragt oben und unten leicht darueber hinaus und wirkt
praesenter, ohne den Header hoeher zu machen. -->
<div class="flex items-center gap-3 min-w-0">
<a
href="/dashboard"
class="shrink-0 transition-transform relative duration-75 top-2"
class:scale-120={dashHover}
class:rotate-3={dashHover}
onmouseenter={() => (dashHover = true)}
onmouseleave={() => (dashHover = false)}
>
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
</a>
<!--
Ohne Viewport: Der Inhalt haengt dann am
Menuepunkt, der als NavigationMenu.Item ohnehin
`relative` ist. Mit Viewport waere er eine eigene
Flaeche unter der ganzen Leiste und stuende an
deren linkem Rand — unter „Dashboard" statt unter
„Team". -->
<NavigationMenu.Root viewport={false} class="hidden md:flex">
<NavigationMenu.List class="items-center gap-2">
{#each nav as item}
{@const Icon = item.icon}
{#if item.submenu}
<TeamMenu class={navClass(item.href)} />
{:else}
<NavigationMenu.Item>
<NavigationMenu.Link
href={item.href}
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>
<!-- Rechts: wer man ist, und was man mit sich tun kann.
Das Team steht jetzt im Menue links. -->
<div class="flex items-center gap-2">
<div class="hidden md:flex min-w-0 items-center gap-3">
<a
href="/dashboard/settings"
class="flex min-w-0 items-center gap-2"
use:tooltip={{ content: 'Profil' }}
>
<Avatar user={auth.user} size={24} />
<span class="truncate text-sm font-medium leading-none">
{auth.user.name || auth.user.email}
</span>
</a>
<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"
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 text-amber-600" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={toggleMode}
tooltip="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={handleLogout}
tooltip="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
</span>
</div>
<Button
variant="ghost"
size="icon"
class="md:hidden"
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
tooltip="Menü"
>
{#if isMobileMenuOpen}
<X class="h-4 w-4" />
{:else}
<Menu class="h-4 w-4" />
{/if}
</Button>
</div>
</div>
</div>
{#if isMobileMenuOpen}
<div class="md:hidden border-t">
<nav class="px-2 py-2 space-y-1">
{#each nav as item}
{@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}
<div class="ml-6 space-y-1 border-l pl-3">
{#each teams.records as t (t.id)}
<button
type="button"
onclick={() => {
teams.setActive(t.id)
isMobileMenuOpen = false
}}
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent {t.id ===
teams.activeId
? 'font-medium text-foreground'
: 'text-muted-foreground'}"
>
<span class="flex-1 truncate">{t.name}</span>
{#if t.id === teams.activeId}
<span class="text-xs text-muted-foreground">aktiv</span>
{/if}
</button>
{/each}
<button
type="button"
onclick={() => {
isMobileMenuOpen = false
createTeamDialog = true
}}
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent"
>
<Plus class="h-4 w-4" />
Team erstellen
</button>
</div>
{/if}
{/each}
<Separator class="my-2" />
<div class="flex items-center justify-between gap-2 px-3 py-2">
<div class="flex flex-col items-start gap-1 min-w-0">
<span class="flex items-center gap-2 min-w-0">
<Avatar user={auth.user} size={24} />
<span class="text-sm font-medium leading-none truncate">
{auth.user.name || auth.user.email}
</span>
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button variant="ghost" size="icon" onclick={handleLogout} tooltip="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
</div>
</div>
</nav>
</div>
{/if}
</header>
<OfflineBanner />
<main class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children?.()}
</main>
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
<RunningTimesToast />
<CreateTeamDialog bind:open={createTeamDialog} />
</div>
{/if}