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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 15:36:22 +02:00
parent 27d34ad0d3
commit fb2652dcbf
6 changed files with 194 additions and 59 deletions

View file

@ -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<TrailCommentsResponse, 'expand'> & {
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<TrailComment[]>([])
loading = $state(false)
error = $state<string | null>(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<TrailCommentStore>(KEY)
}

View file

@ -1,6 +1,7 @@
import { getContext, setContext } from 'svelte' import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte' import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte' import { getTeamContext } from './teams.svelte'
import { isRecent } from '$lib/time'
import type { TrailMarkersResponse, UsersResponse } from '$lib/types' import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('trailMarker') const KEY = Symbol('trailMarker')
@ -28,6 +29,11 @@ export class TrailMarkerStore {
.sort((a, b) => (a.created < b.created ? 1 : -1)) .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 */ /** Nur die offenen — was auf der Karte auffallen soll */
openByTrail(trailId: string): TrailMarker[] { openByTrail(trailId: string): TrailMarker[] {
return this.byTrail(trailId).filter((r) => !r.resolved) return this.byTrail(trailId).filter((r) => !r.resolved)

View file

@ -70,3 +70,19 @@ export function formatDateRange(starts: string | undefined, ends: string | undef
return `${from} ${to}` 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
}

View file

@ -11,6 +11,7 @@
import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte' import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte' import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { setTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
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'
@ -34,6 +35,7 @@
const trailVersions = setTrailVersionContext() const trailVersions = setTrailVersionContext()
const trailFlags = setTrailFlagContext() const trailFlags = setTrailFlagContext()
const trailMarkers = setTrailMarkerContext() const trailMarkers = setTrailMarkerContext()
const trailComments = setTrailCommentContext()
let isMobileMenuOpen = $state(false) let isMobileMenuOpen = $state(false)
@ -48,6 +50,7 @@
await Promise.all([ await Promise.all([
events.load(), runs.load(), riders.load(), times.load(), events.load(), runs.load(), riders.load(), times.load(),
trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(),
trailComments.load(),
]) ])
events.subscribe() events.subscribe()
runs.subscribe() runs.subscribe()
@ -57,6 +60,7 @@
trailVersions.subscribe() trailVersions.subscribe()
trailFlags.subscribe() trailFlags.subscribe()
trailMarkers.subscribe() trailMarkers.subscribe()
trailComments.subscribe()
}) })
onDestroy(() => { onDestroy(() => {
@ -69,6 +73,7 @@
trailVersions.destroy() trailVersions.destroy()
trailFlags.destroy() trailFlags.destroy()
trailMarkers.destroy() trailMarkers.destroy()
trailComments.destroy()
}) })
function handleLogout() { function handleLogout() {

View file

@ -5,18 +5,23 @@
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte' import TrailMap from '$lib/components/TrailMap.svelte'
import { trailStatus } from '$lib/trailStatus' import { trailStatus } from '$lib/trailStatus'
import { getTrailCommentContext } from '$lib/stores/trailComments.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 { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
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 { 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' import type { TrailVersionsResponse } from '$lib/types'
const trails = getTrailContext() const trails = getTrailContext()
const versions = getTrailVersionContext() const versions = getTrailVersionContext()
const markers = getTrailMarkerContext() const markers = getTrailMarkerContext()
const comments = getTrailCommentContext()
let dialog = $state(false) let dialog = $state(false)
let form = $state({ name: '', description: '' }) let form = $state({ name: '', description: '' })
@ -109,6 +114,10 @@
{@const version = currentVersion(trail.id)} {@const version = currentVersion(trail.id)}
{@const open = markers.openByTrail(trail.id).length} {@const open = markers.openByTrail(trail.id).length}
{@const status = trailStatus(trail.status)} {@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)}
<a href="/dashboard/trails/{trail.id}" class="block"> <a href="/dashboard/trails/{trail.id}" class="block">
<Card class="h-full hover:border-primary transition-colors"> <Card class="h-full hover:border-primary transition-colors">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
@ -139,15 +148,60 @@
{/if} {/if}
<div class="flex items-center gap-4 text-sm text-muted-foreground"> <div class="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatKm(version?.distance_m)}</span> <!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
<span>{version?.ascent_m ? `${version.ascent_m} hm` : ''}</span> <span class="flex items-center gap-1.5">
<Ruler class="size-4" />
{formatKm(version?.distance_m)}
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
{version?.ascent_m ? `${version.ascent_m} hm` : ''}
</span>
{#if open > 0} {#if open > 0}
<span class="flex items-center gap-1 text-amber-600 ml-auto"> <span
class="flex items-center gap-1 text-amber-600 ml-auto"
title="{open} offene Meldung{open === 1 ? '' : 'en'}"
>
<AlertTriangle class="size-4" /> <AlertTriangle class="size-4" />
{open} {open}
</span> </span>
{/if} {/if}
</div> </div>
<!--
Ein Punkt markiert Bewegung der letzten zwei Wochen. Die
Zahl allein sagt nichts darüber, ob sich gerade etwas tut.
-->
<div class="flex items-center gap-4 text-sm border-t pt-3">
<span
class="flex items-center gap-1.5"
class:text-muted-foreground={!freshMarkers}
class:font-medium={freshMarkers}
title={freshMarkers
? `Marker, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Marker'}
>
<MapPin class="size-4" />
{markerCount}
{#if freshMarkers}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
<span
class="flex items-center gap-1.5"
class:text-muted-foreground={!freshComments}
class:font-medium={freshComments}
title={freshComments
? `Kommentare, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Kommentare'}
>
<MessageSquare class="size-4" />
{commentCount}
{#if freshComments}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
</a> </a>

View file

@ -6,6 +6,7 @@
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte' import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte' import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte' import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte' import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte' import ElevationProfile from '$lib/components/ElevationProfile.svelte'
@ -30,7 +31,7 @@
} from 'lucide-svelte' } from 'lucide-svelte'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { browser } from '$app/environment' 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 // Generics der JSON-Felder, wie in trails/+page.svelte — die
// Rohdaten kommen ungetypt aus PocketBase zurück. // Rohdaten kommen ungetypt aus PocketBase zurück.
@ -47,6 +48,7 @@
const versions = getTrailVersionContext() const versions = getTrailVersionContext()
const flags = getTrailFlagContext() const flags = getTrailFlagContext()
const markers = getTrailMarkerContext() const markers = getTrailMarkerContext()
const commentStore = getTrailCommentContext()
const teams = getTeamContext() const teams = getTeamContext()
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined) const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
@ -256,69 +258,17 @@
} }
// --- Kommentare ------------------------------------------------------ // --- Kommentare ------------------------------------------------------
// Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht. const comments = $derived(trail ? commentStore.byTrail(trail.id) : [])
type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }>
let comments = $state<Comment[]>([])
let commentText = $state('') let commentText = $state('')
let commentError = $state<string | null>(null) let commentError = $state<string | null>(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() { async function addComment() {
if (!trail || !commentText.trim()) return if (!trail || !commentText.trim()) return
commentError = null commentError = null
try { try {
await api.collection('trail_comments').create({ await commentStore.create(trail.id, trail.team, commentText)
trail: trail.id,
team: trail.team,
text: commentText.trim(),
created_by: auth.user?.id,
})
commentText = '' commentText = ''
} catch (e: any) { } catch (e: any) {
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.' commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'