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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-04 09:42:07 +01:00
parent 3a4af698ca
commit 6aac8bd885
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 399 additions and 39 deletions

View file

@ -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 (
<MapContainer
center={[50, 10]}
zoom={6}
className={className ?? "h-36 w-full rounded"}
zoomControl={interactive ?? false}
attributionControl={interactive ?? false}
dragging={interactive ?? false}
scrollWheelZoom={interactive ?? false}
doubleClickZoom={interactive ?? false}
touchZoom={interactive ?? false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution={interactive ? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
/>
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
<FitBounds data={data} />
</MapContainer>
);
}

View file

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eq, desc } from "drizzle-orm"; import { eq, desc, sql } from "drizzle-orm";
import { getDb } from "./db.ts"; import { getDb } from "./db.ts";
import { activities, routes } from "@trails-cool/db/schema/journal"; import { activities, routes } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx"; import { parseGpxAsync } from "@trails-cool/gpx";
@ -59,16 +59,22 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
export async function getActivity(id: string) { export async function getActivity(id: string) {
const db = getDb(); const db = getDb();
const [activity] = await db.select().from(activities).where(eq(activities.id, id)); 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) { export async function listActivities(ownerId: string) {
const db = getDb(); const db = getDb();
return db const rows = await db
.select() .select()
.from(activities) .from(activities)
.where(eq(activities.ownerId, ownerId)) .where(eq(activities.ownerId, ownerId))
.orderBy(desc(activities.createdAt)); .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) { export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
@ -106,3 +112,34 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
return routeId; return routeId;
} }
async function getActivityGeojson(id: string): Promise<string | null> {
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<Map<string, string>> {
const map = new Map<string, string>();
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;
}

View file

@ -60,7 +60,9 @@ export async function createRoute(ownerId: string, input: RouteInput) {
export async function getRoute(id: string) { export async function getRoute(id: string) {
const db = getDb(); const db = getDb();
const [route] = await db.select().from(routes).where(eq(routes.id, id)); 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) { export async function getRouteWithVersions(id: string) {
@ -79,11 +81,16 @@ export async function getRouteWithVersions(id: string) {
export async function listRoutes(ownerId: string) { export async function listRoutes(ownerId: string) {
const db = getDb(); const db = getDb();
return db const rows = await db
.select() .select()
.from(routes) .from(routes)
.where(eq(routes.ownerId, ownerId)) .where(eq(routes.ownerId, ownerId))
.orderBy(desc(routes.updatedAt)); .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( export async function updateRoute(
@ -170,3 +177,35 @@ async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxStr
} }
export { setGeomFromGpx }; export { setGeomFromGpx };
async function getGeojson(table: "routes" | "activities", id: string): Promise<string | null> {
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<Map<string, string>> {
const map = new Map<string, string>();
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;
}

View file

@ -1,3 +1,4 @@
import { Suspense, lazy } from "react";
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import type { Route } from "./+types/activities.$id"; import type { Route } from "./+types/activities.$id";
import { getSessionUser } from "~/lib/auth.server"; import { getSessionUser } from "~/lib/auth.server";
@ -5,6 +6,10 @@ import { getActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib
import { listRoutes } from "~/lib/routes.server"; import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate"; import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const activity = await getActivity(params.id); const activity = await getActivity(params.id);
if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); 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, duration: activity.duration,
routeId: activity.routeId, routeId: activity.routeId,
hasGpx: !!activity.gpx, hasGpx: !!activity.gpx,
geojson: activity.geojson ?? null,
startedAt: activity.startedAt?.toISOString() ?? null, startedAt: activity.startedAt?.toISOString() ?? null,
createdAt: activity.createdAt.toISOString(), createdAt: activity.createdAt.toISOString(),
}, },
@ -99,6 +105,14 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
)} )}
</div> </div>
{activity.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<Suspense fallback={<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">Loading map...</div>}>
<RouteMapThumbnail geojson={activity.geojson} interactive className="h-full w-full" />
</Suspense>
</div>
)}
{activity.routeId && ( {activity.routeId && (
<div className="mt-6"> <div className="mt-6">
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline"> <a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">

View file

@ -1,9 +1,15 @@
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities._index"; import type { Route } from "./+types/activities._index";
import { getSessionUser } from "~/lib/auth.server"; import { getSessionUser } from "~/lib/auth.server";
import { listActivities } from "~/lib/activities.server"; import { listActivities } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate"; import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request); const user = await getSessionUser(request);
if (!user) return redirect("/auth/login"); if (!user) return redirect("/auth/login");
@ -18,6 +24,7 @@ export async function loader({ request }: Route.LoaderArgs) {
duration: a.duration, duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null, startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(), 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) { export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) {
const { activities } = loaderData; const { activities } = loaderData;
const { t } = useTranslation("journal");
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8"> <div className="mx-auto max-w-4xl px-4 py-8">
@ -46,26 +54,41 @@ export default function ActivitiesListPage({ loaderData }: Route.ComponentProps)
No activities yet. Record your first adventure! No activities yet. Record your first adventure!
</p> </p>
) : ( ) : (
<ul className="mt-6 divide-y divide-gray-200"> <ul className="mt-6 space-y-4">
{activities.map((activity) => ( {activities.map((activity) => (
<li key={activity.id}> <li key={activity.id}>
<a <a
href={`/activities/${activity.id}`} href={`/activities/${activity.id}`}
className="block py-4 hover:bg-gray-50" className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
> >
<div className="flex items-center justify-between"> <div className="flex gap-4">
<h2 className="text-lg font-medium text-gray-900">{activity.name}</h2> <div className="w-48 shrink-0">
<span className="text-sm text-gray-500"> {activity.geojson ? (
<ClientDate iso={activity.startedAt ?? activity.createdAt} /> <Suspense fallback={<div className="h-36 w-full animate-pulse rounded bg-gray-100" />}>
</span> <RouteMapThumbnail geojson={activity.geojson} />
</div> </Suspense>
<div className="mt-1 flex gap-4 text-sm text-gray-500"> ) : (
{activity.distance != null && ( <div className="flex h-36 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
<span>{(activity.distance / 1000).toFixed(1)} km</span> {t("routes.noMapPreview")}
)} </div>
{activity.elevationGain != null && ( )}
<span> {activity.elevationGain} m</span> </div>
)} <div className="flex flex-1 flex-col justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">{activity.name}</h2>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{activity.distance != null && (
<span>{(activity.distance / 1000).toFixed(1)} km</span>
)}
{activity.elevationGain != null && (
<span> {activity.elevationGain} m</span>
)}
</div>
</div>
<span className="text-sm text-gray-400">
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
</span>
</div>
</div> </div>
</a> </a>
</li> </li>

View file

@ -1,15 +1,23 @@
import { useState, useCallback } from "react"; import { useState, useCallback, Suspense, lazy } from "react";
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id"; import type { Route } from "./+types/routes.$id";
import { getSessionUser } from "~/lib/auth.server"; import { getSessionUser } from "~/lib/auth.server";
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate"; import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ params, request }: Route.LoaderArgs) { export async function loader({ params, request }: Route.LoaderArgs) {
const route = await getRouteWithVersions(params.id); const [routeWithVersions, routeWithGeojson] = await Promise.all([
if (!route) throw data({ error: "Route not found" }, { status: 404 }); getRouteWithVersions(params.id),
getRoute(params.id),
]);
if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 });
const route = routeWithVersions;
const user = await getSessionUser(request); const user = await getSessionUser(request);
const isOwner = user?.id === route.ownerId; const isOwner = user?.id === route.ownerId;
@ -24,6 +32,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
elevationLoss: route.elevationLoss, elevationLoss: route.elevationLoss,
routingProfile: route.routingProfile, routingProfile: route.routingProfile,
hasGpx: !!route.gpx, hasGpx: !!route.gpx,
geojson: routeWithGeojson?.geojson ?? null,
createdAt: route.createdAt.toISOString(), createdAt: route.createdAt.toISOString(),
updatedAt: route.updatedAt.toISOString(), updatedAt: route.updatedAt.toISOString(),
}, },
@ -150,6 +159,14 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
)} )}
</div> </div>
{route.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<Suspense fallback={<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">Loading map...</div>}>
<RouteMapThumbnail geojson={route.geojson} interactive className="h-full w-full" />
</Suspense>
</div>
)}
{versions.length > 0 && ( {versions.length > 0 && (
<div className="mt-8"> <div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Version History</h2> <h2 className="text-lg font-semibold text-gray-900">Version History</h2>

View file

@ -1,10 +1,15 @@
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { Suspense, lazy } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes._index"; import type { Route } from "./+types/routes._index";
import { getSessionUser } from "~/lib/auth.server"; import { getSessionUser } from "~/lib/auth.server";
import { listRoutes } from "~/lib/routes.server"; import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate"; import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request); const user = await getSessionUser(request);
if (!user) return redirect("/auth/login"); if (!user) return redirect("/auth/login");
@ -17,6 +22,7 @@ export async function loader({ request }: Route.LoaderArgs) {
distance: r.distance, distance: r.distance,
elevationGain: r.elevationGain, elevationGain: r.elevationGain,
updatedAt: r.updatedAt.toISOString(), updatedAt: r.updatedAt.toISOString(),
geojson: r.geojson ?? null,
})), })),
}); });
} }
@ -46,26 +52,41 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
{t("routes.noRoutesYet")} {t("routes.noRoutesYet")}
</p> </p>
) : ( ) : (
<ul className="mt-6 divide-y divide-gray-200"> <ul className="mt-6 space-y-4">
{routes.map((route) => ( {routes.map((route) => (
<li key={route.id}> <li key={route.id}>
<a <a
href={`/routes/${route.id}`} href={`/routes/${route.id}`}
className="block py-4 hover:bg-gray-50" className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
> >
<div className="flex items-center justify-between"> <div className="flex gap-4">
<h2 className="text-lg font-medium text-gray-900">{route.name}</h2> <div className="w-48 shrink-0">
<span className="text-sm text-gray-500"> {route.geojson ? (
<ClientDate iso={route.updatedAt} /> <Suspense fallback={<div className="h-36 w-full animate-pulse rounded bg-gray-100" />}>
</span> <RouteMapThumbnail geojson={route.geojson} />
</div> </Suspense>
<div className="mt-1 flex gap-4 text-sm text-gray-500"> ) : (
{route.distance != null && ( <div className="flex h-36 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
<span>{(route.distance / 1000).toFixed(1)} km</span> {t("routes.noMapPreview")}
)} </div>
{route.elevationGain != null && ( )}
<span> {route.elevationGain} m</span> </div>
)} <div className="flex flex-1 flex-col justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">{route.name}</h2>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{route.distance != null && (
<span>{(route.distance / 1000).toFixed(1)} km</span>
)}
{route.elevationGain != null && (
<span> {route.elevationGain} m</span>
)}
</div>
</div>
<span className="text-sm text-gray-400">
<ClientDate iso={route.updatedAt} />
</span>
</div>
</div> </div>
</a> </a>
</li> </li>

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-03

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -123,6 +123,7 @@ export default {
distance: "Strecke", distance: "Strecke",
elevationGain: "Höhenmeter", elevationGain: "Höhenmeter",
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!", noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
noMapPreview: "Keine Kartenvorschau",
saveChanges: "Änderungen speichern", saveChanges: "Änderungen speichern",
}, },
activities: { activities: {

View file

@ -123,6 +123,7 @@ export default {
distance: "Distance", distance: "Distance",
elevationGain: "Elevation Gain", elevationGain: "Elevation Gain",
noRoutesYet: "No routes yet. Create your first route!", noRoutesYet: "No routes yet. Create your first route!",
noMapPreview: "No map preview",
saveChanges: "Save Changes", saveChanges: "Save Changes",
}, },
activities: { activities: {