From fb2652dcbff57201c2f1134d55b8f98c117207ec Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Wed, 2 Sep 2026 15:36:22 +0200 Subject: [PATCH] feat: Trail-Karten zeigen Marker und Kommentare, Icons fuer Laenge und Hoehe Laenge und Hoehenmeter tragen dieselben Icons wie der Kopf der Trail-Seite. Dazu kommen Anzahl der Marker und Kommentare - und ein Punkt daneben, wenn davon etwas aus den letzten 14 Tagen stammt. Die blosse Zahl sagt nichts darueber, ob sich gerade etwas tut. Kommentare bekommen dafuer einen eigenen Store. Sie lagen bisher direkt in der Trail-Seite, was richtig war, solange es sie nur dort gab; fuer die Liste waere ein zweiter Ladeweg auf dieselben Daten einer zu viel. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o --- .../src/lib/stores/trailComments.svelte.ts | 104 ++++++++++++++++++ .../src/lib/stores/trailMarkers.svelte.ts | 6 + frontend/src/lib/time.ts | 16 +++ frontend/src/routes/dashboard/+layout.svelte | 5 + .../src/routes/dashboard/trails/+page.svelte | 62 ++++++++++- .../routes/dashboard/trails/[id]/+page.svelte | 60 +--------- 6 files changed, 194 insertions(+), 59 deletions(-) create mode 100644 frontend/src/lib/stores/trailComments.svelte.ts diff --git a/frontend/src/lib/stores/trailComments.svelte.ts b/frontend/src/lib/stores/trailComments.svelte.ts new file mode 100644 index 0000000..1907154 --- /dev/null +++ b/frontend/src/lib/stores/trailComments.svelte.ts @@ -0,0 +1,104 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import { isRecent } from '$lib/time' +import type { TrailCommentsResponse, UsersResponse } from '$lib/types' + +const KEY = Symbol('trailComment') + +/** Kommentar samt Verfasser, sofern dessen Datensatz sichtbar ist. */ +export type TrailComment = Omit & { + expand?: { created_by?: UsersResponse } +} + +/** + * Kommentare lagen bisher direkt in der Trail-Seite — als es sie nur dort + * gab, war das die einfachere Lösung. Die Trail-Liste zeigt jetzt ebenfalls + * ihre Anzahl, und zwei Ladewege auf dieselben Daten wären einer zu viel. + */ +export class TrailCommentStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + + get scoped(): TrailComment[] { + const teamId = this.teams.activeId + if (!teamId) return [] + return this.records.filter((r) => r.team === teamId) + } + + /** Kommentare eines Trails, jüngste zuerst */ + byTrail(trailId: string): TrailComment[] { + return this.scoped + .filter((r) => r.trail === trailId) + .sort((a, b) => (a.created < b.created ? 1 : -1)) + } + + /** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */ + hasRecent(trailId: string, days?: number): boolean { + return this.byTrail(trailId).some((r) => isRecent(r.created, days)) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = (await api.collection('trail_comments').getFullList({ + sort: '-created', + expand: 'created_by', + requestKey: null, + })) as TrailComment[] + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Kommentare' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('trail_comments').subscribe('*', (e) => { + const record = e.record as TrailComment + const idx = this.records.findIndex((r) => r.id === record.id) + if (e.action === 'create' && idx === -1) this.records = [record, ...this.records] + else if (e.action === 'update' && idx !== -1) this.records[idx] = record + else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== record.id) + }, { expand: 'created_by' }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('trail_comments subscribe failed:', e)) + } + + async create(trailId: string, teamId: string, text: string) { + return await api.collection('trail_comments').create({ + trail: trailId, + team: teamId, + text: text.trim(), + created_by: auth.user?.id, + }) + } + + async remove(id: string) { + await api.collection('trail_comments').delete(id) + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setTrailCommentContext() { + const store = new TrailCommentStore() + setContext(KEY, store) + return store +} + +export function getTrailCommentContext(): TrailCommentStore { + return getContext(KEY) +} diff --git a/frontend/src/lib/stores/trailMarkers.svelte.ts b/frontend/src/lib/stores/trailMarkers.svelte.ts index 7b9a112..c546297 100644 --- a/frontend/src/lib/stores/trailMarkers.svelte.ts +++ b/frontend/src/lib/stores/trailMarkers.svelte.ts @@ -1,6 +1,7 @@ import { getContext, setContext } from 'svelte' import { api, auth } from './pocketbase.svelte' import { getTeamContext } from './teams.svelte' +import { isRecent } from '$lib/time' import type { TrailMarkersResponse, UsersResponse } from '$lib/types' const KEY = Symbol('trailMarker') @@ -28,6 +29,11 @@ export class TrailMarkerStore { .sort((a, b) => (a.created < b.created ? 1 : -1)) } + /** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */ + hasRecent(trailId: string, days?: number): boolean { + return this.byTrail(trailId).some((r) => isRecent(r.created, days)) + } + /** Nur die offenen — was auf der Karte auffallen soll */ openByTrail(trailId: string): TrailMarker[] { return this.byTrail(trailId).filter((r) => !r.resolved) diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index eacd705..df035cc 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -70,3 +70,19 @@ export function formatDateRange(starts: string | undefined, ends: string | undef return `${from} – ${to}` } + +/** + * Standardfenster für „neu" in Übersichten. Zwei Wochen, weil ein Trail + * selten öfter befahren wird — kürzer wäre die Hervorhebung meist leer. + */ +export const RECENT_DAYS = 14 + +/** Liegt der Zeitpunkt innerhalb der letzten `days` Tage? */ +export function isRecent(value: string | undefined, days = RECENT_DAYS): boolean { + if (!value) return false + + const then = new Date(value).getTime() + if (!Number.isFinite(then)) return false + + return Date.now() - then <= days * 24 * 60 * 60 * 1000 +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 848c16e..0e3adb4 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -11,6 +11,7 @@ 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' @@ -34,6 +35,7 @@ const trailVersions = setTrailVersionContext() const trailFlags = setTrailFlagContext() const trailMarkers = setTrailMarkerContext() + const trailComments = setTrailCommentContext() let isMobileMenuOpen = $state(false) @@ -48,6 +50,7 @@ await Promise.all([ events.load(), runs.load(), riders.load(), times.load(), trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), + trailComments.load(), ]) events.subscribe() runs.subscribe() @@ -57,6 +60,7 @@ trailVersions.subscribe() trailFlags.subscribe() trailMarkers.subscribe() + trailComments.subscribe() }) onDestroy(() => { @@ -69,6 +73,7 @@ trailVersions.destroy() trailFlags.destroy() trailMarkers.destroy() + trailComments.destroy() }) function handleLogout() { diff --git a/frontend/src/routes/dashboard/trails/+page.svelte b/frontend/src/routes/dashboard/trails/+page.svelte index 665cbe6..33ed96d 100644 --- a/frontend/src/routes/dashboard/trails/+page.svelte +++ b/frontend/src/routes/dashboard/trails/+page.svelte @@ -5,18 +5,23 @@ import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import TrailMap from '$lib/components/TrailMap.svelte' import { trailStatus } from '$lib/trailStatus' + import { getTrailCommentContext } from '$lib/stores/trailComments.svelte' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import * as Dialog from '@/components/ui/dialog' - import { Plus, Route, AlertTriangle } from 'lucide-svelte' + import { + Plus, Route, AlertTriangle, Ruler, TrendingUp, MapPin, MessageSquare, + } from 'lucide-svelte' + import { RECENT_DAYS } from '$lib/time' import type { TrailVersionsResponse } from '$lib/types' const trails = getTrailContext() const versions = getTrailVersionContext() const markers = getTrailMarkerContext() + const comments = getTrailCommentContext() let dialog = $state(false) let form = $state({ name: '', description: '' }) @@ -109,6 +114,10 @@ {@const version = currentVersion(trail.id)} {@const open = markers.openByTrail(trail.id).length} {@const status = trailStatus(trail.status)} + {@const markerCount = markers.byTrail(trail.id).length} + {@const commentCount = comments.byTrail(trail.id).length} + {@const freshMarkers = markers.hasRecent(trail.id)} + {@const freshComments = comments.hasRecent(trail.id)} @@ -139,15 +148,60 @@ {/if}
- {formatKm(version?.distance_m)} - {version?.ascent_m ? `${version.ascent_m} hm` : '–'} + + + + {formatKm(version?.distance_m)} + + + + {version?.ascent_m ? `${version.ascent_m} hm` : '–'} + {#if open > 0} - + {open} {/if}
+ + +
+ + + {markerCount} + {#if freshMarkers} + + {/if} + + + + {commentCount} + {#if freshComments} + + {/if} + +
diff --git a/frontend/src/routes/dashboard/trails/[id]/+page.svelte b/frontend/src/routes/dashboard/trails/[id]/+page.svelte index 4846abb..817840c 100644 --- a/frontend/src/routes/dashboard/trails/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/trails/[id]/+page.svelte @@ -6,6 +6,7 @@ import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte' import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte' import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' + import { getTrailCommentContext } from '$lib/stores/trailComments.svelte' import { getTeamContext } from '$lib/stores/teams.svelte' import TrailMap from '$lib/components/TrailMap.svelte' import ElevationProfile from '$lib/components/ElevationProfile.svelte' @@ -30,7 +31,7 @@ } from 'lucide-svelte' import DOMPurify from 'dompurify' import { browser } from '$app/environment' - import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types' + import type { TrailVersionsResponse } from '$lib/types' // Generics der JSON-Felder, wie in trails/+page.svelte — die // Rohdaten kommen ungetypt aus PocketBase zurück. @@ -47,6 +48,7 @@ const versions = getTrailVersionContext() const flags = getTrailFlagContext() const markers = getTrailMarkerContext() + const commentStore = getTrailCommentContext() const teams = getTeamContext() const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined) @@ -256,69 +258,17 @@ } // --- Kommentare ------------------------------------------------------ - // Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht. - type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }> + const comments = $derived(trail ? commentStore.byTrail(trail.id) : []) - let comments = $state([]) let commentText = $state('') let commentError = $state(null) - let unsubComments: (() => void) | null = null - - $effect(() => { - const id = trail?.id - if (!id) return - - let cancelled = false - - api.collection('trail_comments') - .getFullList({ - filter: `trail="${id}"`, - sort: '-created', - expand: 'created_by', - requestKey: null, - }) - .then((r) => { - if (!cancelled) comments = r as Comment[] - }) - .catch((e) => console.error('Kommentare laden fehlgeschlagen:', e)) - - api.collection('trail_comments') - .subscribe( - '*', - (e) => { - if (e.record.trail !== id) return - const record = e.record as Comment - const idx = comments.findIndex((c) => c.id === record.id) - if (e.action === 'create' && idx === -1) comments = [record, ...comments] - else if (e.action === 'update' && idx !== -1) comments[idx] = record - else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== record.id) - }, - { expand: 'created_by' }, - ) - .then((u) => { - if (cancelled) u() - else unsubComments = u - }) - .catch((e) => console.warn('trail_comments subscribe failed:', e)) - - return () => { - cancelled = true - unsubComments?.() - unsubComments = null - } - }) async function addComment() { if (!trail || !commentText.trim()) return commentError = null try { - await api.collection('trail_comments').create({ - trail: trail.id, - team: trail.team, - text: commentText.trim(), - created_by: auth.user?.id, - }) + await commentStore.create(trail.id, trail.team, commentText) commentText = '' } catch (e: any) { commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'