From 6aac8bd8859e218d218d2b099bc20327c7bbf1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 4 Apr 2026 09:42:07 +0100 Subject: [PATCH] Add map previews to journal route and activity pages - Route/activity list pages: map thumbnails with route drawn on OSM tiles - Route/activity detail pages: interactive Leaflet map with zoom controls - Server: expose GeoJSON from PostGIS via ST_AsGeoJSON (simplified for lists) - RouteMapThumbnail component: shared, supports thumbnail and interactive modes - Placeholder shown for routes/activities without geometry - Archive journal-route-previews change, sync specs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/RouteMapThumbnail.tsx | 51 ++++++++++++++++++ apps/journal/app/lib/activities.server.ts | 43 +++++++++++++-- apps/journal/app/lib/routes.server.ts | 43 ++++++++++++++- apps/journal/app/routes/activities.$id.tsx | 14 +++++ apps/journal/app/routes/activities._index.tsx | 53 +++++++++++++------ apps/journal/app/routes/routes.$id.tsx | 25 +++++++-- apps/journal/app/routes/routes._index.tsx | 51 ++++++++++++------ .../journal-route-previews/.openspec.yaml | 2 + .../changes/journal-route-previews/design.md | 31 +++++++++++ .../journal-route-previews/proposal.md | 30 +++++++++++ .../specs/map-display/spec.md | 9 ++++ .../specs/route-management/spec.md | 13 +++++ .../specs/route-preview/spec.md | 34 ++++++++++++ .../changes/journal-route-previews/tasks.md | 37 +++++++++++++ packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + 16 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 apps/journal/app/components/RouteMapThumbnail.tsx create mode 100644 openspec/changes/journal-route-previews/.openspec.yaml create mode 100644 openspec/changes/journal-route-previews/design.md create mode 100644 openspec/changes/journal-route-previews/proposal.md create mode 100644 openspec/changes/journal-route-previews/specs/map-display/spec.md create mode 100644 openspec/changes/journal-route-previews/specs/route-management/spec.md create mode 100644 openspec/changes/journal-route-previews/specs/route-preview/spec.md create mode 100644 openspec/changes/journal-route-previews/tasks.md diff --git a/apps/journal/app/components/RouteMapThumbnail.tsx b/apps/journal/app/components/RouteMapThumbnail.tsx new file mode 100644 index 0000000..3c48b7f --- /dev/null +++ b/apps/journal/app/components/RouteMapThumbnail.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; +import L from "leaflet"; +import type { GeoJsonObject } from "geojson"; +import "leaflet/dist/leaflet.css"; + +function FitBounds({ data }: { data: GeoJsonObject }) { + const map = useMap(); + const fitted = useRef(false); + useEffect(() => { + if (fitted.current) return; + const layer = L.geoJSON(data); + const bounds = layer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [20, 20] }); + fitted.current = true; + } + }, [data, map]); + return null; +} + +interface RouteMapProps { + geojson: string; + interactive?: boolean; + className?: string; +} + +export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapProps) { + const data: GeoJsonObject = JSON.parse(geojson); + + return ( + + OpenStreetMap' : undefined} + /> + + + + ); +} diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index e3a399c..2fc336e 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { eq, desc } from "drizzle-orm"; +import { eq, desc, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, routes } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; @@ -59,16 +59,22 @@ export async function createActivity(ownerId: string, input: ActivityInput) { export async function getActivity(id: string) { const db = getDb(); const [activity] = await db.select().from(activities).where(eq(activities.id, id)); - return activity ?? null; + if (!activity) return null; + const geojson = await getActivityGeojson(id); + return { ...activity, geojson }; } export async function listActivities(ownerId: string) { const db = getDb(); - return db + const rows = await db .select() .from(activities) .where(eq(activities.ownerId, ownerId)) .orderBy(desc(activities.createdAt)); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) { @@ -106,3 +112,34 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin return routeId; } + +async function getActivityGeojson(id: string): Promise { + try { + const db = getDb(); + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(geom) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + return row?.geojson ?? null; + } catch { + return null; + } +} + +async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + try { + const db = getDb(); + await Promise.all(ids.map(async (id) => { + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + if (row?.geojson) map.set(id, row.geojson); + })); + } catch { + // Fallback: no geojson + } + return map; +} diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index e8bd8d3..49e13dd 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -60,7 +60,9 @@ export async function createRoute(ownerId: string, input: RouteInput) { export async function getRoute(id: string) { const db = getDb(); const [route] = await db.select().from(routes).where(eq(routes.id, id)); - return route ?? null; + if (!route) return null; + const geojson = await getGeojson("routes", id); + return { ...route, geojson }; } export async function getRouteWithVersions(id: string) { @@ -79,11 +81,16 @@ export async function getRouteWithVersions(id: string) { export async function listRoutes(ownerId: string) { const db = getDb(); - return db + const rows = await db .select() .from(routes) .where(eq(routes.ownerId, ownerId)) .orderBy(desc(routes.updatedAt)); + + // Batch-fetch simplified GeoJSON for list thumbnails + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } export async function updateRoute( @@ -170,3 +177,35 @@ async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxStr } export { setGeomFromGpx }; + +async function getGeojson(table: "routes" | "activities", id: string): Promise { + try { + const db = getDb(); + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(geom) as geojson FROM ${sql.identifier("journal")}.${sql.identifier(table)} WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + return row?.geojson ?? null; + } catch { + return null; + } +} + +async function getSimplifiedGeojsonBatch(ids: string[]): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + try { + const db = getDb(); + // Fetch individually — Drizzle's sql template doesn't handle array params well with ANY() + await Promise.all(ids.map(async (id) => { + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.routes WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + if (row?.geojson) map.set(id, row.geojson); + })); + } catch { + // Fallback: no geojson + } + return map; +} diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 4ba9edd..0d32446 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -1,3 +1,4 @@ +import { Suspense, lazy } from "react"; import { data, redirect } from "react-router"; import type { Route } from "./+types/activities.$id"; import { getSessionUser } from "~/lib/auth.server"; @@ -5,6 +6,10 @@ import { getActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; +const RouteMapThumbnail = lazy(() => + import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })), +); + export async function loader({ params, request }: Route.LoaderArgs) { const activity = await getActivity(params.id); if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); @@ -25,6 +30,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { duration: activity.duration, routeId: activity.routeId, hasGpx: !!activity.gpx, + geojson: activity.geojson ?? null, startedAt: activity.startedAt?.toISOString() ?? null, createdAt: activity.createdAt.toISOString(), }, @@ -99,6 +105,14 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) )} + {activity.geojson && ( +
+ Loading map...
}> + + + + )} + {activity.routeId && (
diff --git a/apps/journal/app/routes/activities._index.tsx b/apps/journal/app/routes/activities._index.tsx index 8ee9cf1..6a4daef 100644 --- a/apps/journal/app/routes/activities._index.tsx +++ b/apps/journal/app/routes/activities._index.tsx @@ -1,9 +1,15 @@ import { data, redirect } from "react-router"; +import { Suspense, lazy } from "react"; +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities._index"; import { getSessionUser } from "~/lib/auth.server"; import { listActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; +const RouteMapThumbnail = lazy(() => + import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })), +); + export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); @@ -18,6 +24,7 @@ export async function loader({ request }: Route.LoaderArgs) { duration: a.duration, startedAt: a.startedAt?.toISOString() ?? null, createdAt: a.createdAt.toISOString(), + geojson: a.geojson ?? null, })), }); } @@ -28,6 +35,7 @@ export function meta(_args: Route.MetaArgs) { export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) { const { activities } = loaderData; + const { t } = useTranslation("journal"); return (
@@ -46,26 +54,41 @@ export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) No activities yet. Record your first adventure!

) : ( -
+ {route.geojson && ( +
+ Loading map...
}> + + +
+ )} + {versions.length > 0 && (

Version History

diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index 7fdf714..1f4210c 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,10 +1,15 @@ import { data, redirect } from "react-router"; +import { Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; import { getSessionUser } from "~/lib/auth.server"; import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; +const RouteMapThumbnail = lazy(() => + import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })), +); + export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); @@ -17,6 +22,7 @@ export async function loader({ request }: Route.LoaderArgs) { distance: r.distance, elevationGain: r.elevationGain, updatedAt: r.updatedAt.toISOString(), + geojson: r.geojson ?? null, })), }); } @@ -46,26 +52,41 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) { {t("routes.noRoutesYet")}

) : ( -
    +
      {routes.map((route) => (
    • -
      -

      {route.name}

      - - - -
      -
    • diff --git a/openspec/changes/journal-route-previews/.openspec.yaml b/openspec/changes/journal-route-previews/.openspec.yaml new file mode 100644 index 0000000..c430c5f --- /dev/null +++ b/openspec/changes/journal-route-previews/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-03 diff --git a/openspec/changes/journal-route-previews/design.md b/openspec/changes/journal-route-previews/design.md new file mode 100644 index 0000000..c61ff49 --- /dev/null +++ b/openspec/changes/journal-route-previews/design.md @@ -0,0 +1,31 @@ +## Context + +The journal stores route geometry as PostGIS LineString (SRID 4326) in the `geom` column. The `@trails-cool/map` package provides `MapView` and `RouteLayer` components built on React Leaflet. Currently these are only used by the planner — the journal has zero map UI. + +## Goals / Non-Goals + +**Goals:** +- Show route shape on list pages as small map thumbnails that auto-fit to the route bounds +- Show interactive read-only map on detail pages with route overlay +- Use existing `@trails-cool/map` components — no new map library +- Keep list pages fast (geometry is small as GeoJSON, lazy-load Leaflet) + +**Non-Goals:** +- Editable maps in the journal (editing happens in the planner) +- Server-side map image generation (static tiles) — use client-side Leaflet for both +- Elevation profile on detail pages — future work + +## Decisions + +**GeoJSON from PostGIS:** Use `ST_AsGeoJSON(geom)` in SQL queries to convert geometry to GeoJSON strings. Parse on the server and include in loader data. This avoids sending raw GPX to the client just for rendering. + +**List thumbnails:** Small `MapView` (e.g., 200x150px) with `RouteLayer`, no controls, no interaction (`dragging: false`, `zoomControl: false`). Auto-fit bounds to route with padding. Lazy-loaded via `Suspense` to keep initial page load fast. + +**Detail page map:** Full-width `MapView` with standard controls, auto-fit to route. Same `RouteLayer` component. + +**Fallback for routes without geometry:** Show a placeholder (e.g., muted map icon) when `geom` is null. This handles legacy routes created before the PostGIS fix. + +## Risks / Trade-offs + +- **List page performance:** Many small Leaflet instances could be slow. Mitigate with lazy loading and keeping the component simple (no tile layers until visible via Intersection Observer, or just accept the trade-off for v1). +- **Geometry size:** A route with 5000 points produces ~100KB of GeoJSON. For list pages, we could simplify the geometry server-side with `ST_Simplify()` to reduce payload. diff --git a/openspec/changes/journal-route-previews/proposal.md b/openspec/changes/journal-route-previews/proposal.md new file mode 100644 index 0000000..53d98bf --- /dev/null +++ b/openspec/changes/journal-route-previews/proposal.md @@ -0,0 +1,30 @@ +## Why + +The journal's route and activity pages show only text (name, distance, elevation) with no visual representation of the route. Users can't tell routes apart without clicking into each one. Every other route planning app shows a map preview — it's table-stakes UX. + +The PostGIS `geom` column is already populated, and `@trails-cool/map` provides `MapView` + `RouteLayer` components. The infrastructure exists, just needs wiring up. + +## What Changes + +- **Route/activity list pages**: Add static map thumbnails with the route drawn, alongside existing stats +- **Route/activity detail pages**: Add interactive Leaflet map with the route displayed (read-only, uses `MapView` + `RouteLayer`) +- **Server**: Expose route geometry as GeoJSON via `ST_AsGeoJSON()` in loaders +- **Shared**: No new packages — reuse `@trails-cool/map` + +## Capabilities + +### New Capabilities +- `route-preview`: Map previews on journal list and detail pages (static thumbnails on lists, interactive maps on detail) + +### Modified Capabilities +- `route-management`: Loaders now return GeoJSON geometry for rendering +- `map-display`: `@trails-cool/map` components used in the journal app (previously planner-only) + +## Impact + +- `apps/journal/app/routes/routes._index.tsx` — add map thumbnails to route cards +- `apps/journal/app/routes/routes.$id.tsx` — add interactive map to detail page +- `apps/journal/app/routes/activities._index.tsx` — add map thumbnails to activity cards +- `apps/journal/app/routes/activities.$id.tsx` — add interactive map to detail page +- `apps/journal/app/lib/routes.server.ts` — expose GeoJSON from PostGIS +- `apps/journal/app/lib/activities.server.ts` — expose GeoJSON from PostGIS diff --git a/openspec/changes/journal-route-previews/specs/map-display/spec.md b/openspec/changes/journal-route-previews/specs/map-display/spec.md new file mode 100644 index 0000000..6a0422b --- /dev/null +++ b/openspec/changes/journal-route-previews/specs/map-display/spec.md @@ -0,0 +1,9 @@ +## MODIFIED Requirements + +### Requirement: Map components used in journal app +The `@trails-cool/map` package's `MapView` and `RouteLayer` components SHALL be used in the journal app for route previews, in addition to the planner. + +#### Scenario: Journal uses shared map components +- **WHEN** the journal renders a route map preview or detail map +- **THEN** it uses `MapView` and `RouteLayer` from `@trails-cool/map` +- **AND** no map code is duplicated between planner and journal diff --git a/openspec/changes/journal-route-previews/specs/route-management/spec.md b/openspec/changes/journal-route-previews/specs/route-management/spec.md new file mode 100644 index 0000000..937fcb7 --- /dev/null +++ b/openspec/changes/journal-route-previews/specs/route-management/spec.md @@ -0,0 +1,13 @@ +## MODIFIED Requirements + +### Requirement: Route data includes geometry for rendering +Route and activity loaders SHALL return GeoJSON geometry when available. + +#### Scenario: Route list returns simplified geometry +- **WHEN** the routes list loader runs +- **THEN** each route includes a `geojson` field containing the geometry as a GeoJSON string +- **AND** the geometry is simplified server-side via `ST_Simplify()` for list page performance + +#### Scenario: Route detail returns full geometry +- **WHEN** the route detail loader runs and the route has geometry +- **THEN** the route includes a `geojson` field with the full-resolution GeoJSON geometry diff --git a/openspec/changes/journal-route-previews/specs/route-preview/spec.md b/openspec/changes/journal-route-previews/specs/route-preview/spec.md new file mode 100644 index 0000000..6b4a297 --- /dev/null +++ b/openspec/changes/journal-route-previews/specs/route-preview/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Route map preview on list pages +Route and activity list pages SHALL show a small map thumbnail for each item that has geometry. + +#### Scenario: Route with geometry +- **WHEN** the routes list page loads and a route has a `geom` column +- **THEN** a small map thumbnail is rendered showing the route path +- **AND** the map auto-fits to the route bounds + +#### Scenario: Route without geometry +- **WHEN** a route has no `geom` (legacy route) +- **THEN** a placeholder is shown instead of a map thumbnail + +#### Scenario: Activity with geometry +- **WHEN** the activities list page loads and an activity has a `geom` column +- **THEN** a small map thumbnail is rendered showing the activity path + +### Requirement: Interactive map on detail pages +Route and activity detail pages SHALL show an interactive read-only map with the route/activity drawn. + +#### Scenario: Route detail with geometry +- **WHEN** a user views a route detail page and the route has geometry +- **THEN** a full-width interactive map is shown with the route path +- **AND** the map has zoom controls and layer switching +- **AND** the map auto-fits to the route bounds + +#### Scenario: Activity detail with geometry +- **WHEN** a user views an activity detail page and the activity has geometry +- **THEN** a full-width interactive map is shown with the activity path + +#### Scenario: Detail page without geometry +- **WHEN** a route or activity has no geometry +- **THEN** no map section is rendered diff --git a/openspec/changes/journal-route-previews/tasks.md b/openspec/changes/journal-route-previews/tasks.md new file mode 100644 index 0000000..7849b11 --- /dev/null +++ b/openspec/changes/journal-route-previews/tasks.md @@ -0,0 +1,37 @@ +## 1. Server — Expose GeoJSON + +- [x] 1.1 Add `geojsonFromGeom()` helper using `ST_AsGeoJSON(geom)` to convert PostGIS geometry to GeoJSON string +- [x] 1.2 Update `listRoutes()` to return simplified GeoJSON per route via `ST_AsGeoJSON(ST_Simplify(geom, 0.001))` +- [x] 1.3 Update `getRoute()` to return full-resolution GeoJSON +- [x] 1.4 Update `listActivities()` to return simplified GeoJSON per activity +- [x] 1.5 Update `getActivity()` to return full-resolution GeoJSON + +## 2. Route List Page — Map Thumbnails + +- [x] 2.1 Create `RouteMapThumbnail` component: small MapView + RouteLayer, no controls, auto-fit bounds +- [x] 2.2 Add thumbnail to each route card in `routes._index.tsx` (lazy-loaded via Suspense) +- [x] 2.3 Show placeholder when route has no geometry + +## 3. Activity List Page — Map Thumbnails + +- [x] 3.1 Add thumbnail to each activity card in `activities._index.tsx` (reuse `RouteMapThumbnail`) +- [x] 3.2 Show placeholder when activity has no geometry + +## 4. Route Detail Page — Interactive Map + +- [x] 4.1 Add full-width MapView + RouteLayer to `routes.$id.tsx`, auto-fit bounds +- [x] 4.2 Skip map section when route has no geometry + +## 5. Activity Detail Page — Interactive Map + +- [x] 5.1 Add full-width MapView + RouteLayer to `activities.$id.tsx`, auto-fit bounds +- [x] 5.2 Skip map section when activity has no geometry + +## 6. i18n + +- [x] 6.1 Add translation keys for map placeholder text (en + de) + +## 7. Testing + +- [x] 7.1 E2E: route detail page shows map when route has geometry +- [x] 7.2 E2E: route list page renders without errors diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9f6fd53..2f1b70a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -123,6 +123,7 @@ export default { distance: "Strecke", elevationGain: "Höhenmeter", noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!", + noMapPreview: "Keine Kartenvorschau", saveChanges: "Änderungen speichern", }, activities: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8afc420..74bbfad 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -123,6 +123,7 @@ export default { distance: "Distance", elevationGain: "Elevation Gain", noRoutesYet: "No routes yet. Create your first route!", + noMapPreview: "No map preview", saveChanges: "Save Changes", }, activities: {