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 { 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)

View file

@ -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
}

View file

@ -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() {

View file

@ -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)}
<a href="/dashboard/trails/{trail.id}" class="block">
<Card class="h-full hover:border-primary transition-colors">
<CardHeader class="pb-3">
@ -139,15 +148,60 @@
{/if}
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatKm(version?.distance_m)}</span>
<span>{version?.ascent_m ? `${version.ascent_m} hm` : ''}</span>
<!-- Dieselben Icons wie im Kopf der Trail-Seite. -->
<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}
<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" />
{open}
</span>
{/if}
</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>
</Card>
</a>

View file

@ -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<Comment[]>([])
let commentText = $state('')
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() {
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.'