Merge pull request #391 from trails-cool/stigi/journal-poi-details

Show POI details on Journal route detail page
This commit is contained in:
Ullrich Schäfer 2026-05-17 23:38:37 +02:00 committed by GitHub
commit bcf551cd27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 462 additions and 9 deletions

View file

@ -60,6 +60,7 @@ export async function action({ request }: { request: Request }) {
? await request.json().catch(() => ({}))
: {};
const routeName = (body as { routeName?: string }).routeName ?? "E2E callback test route";
const visibility = (body as { visibility?: string }).visibility ?? "public";
const routeId = randomUUID();
await db.insert(routes).values({
@ -67,6 +68,7 @@ export async function action({ request }: { request: Request }) {
ownerId: user.id,
name: routeName,
description: "",
visibility: visibility as "public" | "unlisted" | "private",
});
const token = await createRouteToken(routeId);

View file

@ -30,16 +30,26 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw data({ error: "Route not found" }, { status: 404 });
}
// Compute per-day stats if route has day breaks and GPX
// Parse GPX once for day stats and waypoint POI data
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) {
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; osmId?: number; poiTags?: Record<string, string> }> = [];
if (route.gpx) {
try {
const { computeDays } = await import("@trails-cool/gpx");
const { parseGpxAsync } = await import("@trails-cool/gpx");
const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx");
const gpxData = await parseGpxAsync(route.gpx);
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
waypoints = gpxData.waypoints.map((w) => ({
lat: w.lat,
lon: w.lon,
name: w.name,
isDayBreak: w.isDayBreak,
osmId: w.osmId,
poiTags: w.poiTags as Record<string, string> | undefined,
}));
if (route.dayBreaks && route.dayBreaks.length > 0) {
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
}
} catch {
// Fall back to no day stats
// Fall back to empty
}
}
@ -119,6 +129,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
updatedAt: route.updatedAt.toISOString(),
},
dayStats,
waypoints,
versions: route.versions.map((v) => ({
version: v.version,
changeDescription: v.changeDescription,
@ -187,7 +198,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
}
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
const { route, dayStats, versions, isOwner, wahooPush } = loaderData;
const { route, dayStats, waypoints, versions, isOwner, wahooPush } = loaderData;
const { t, i18n } = useTranslation("journal");
const [editLoading, setEditLoading] = useState(false);
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
@ -387,6 +398,56 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
</div>
)}
{waypoints.some((w) => w.osmId || w.poiTags) && (
<div className="mt-6">
<h2 className="text-lg font-semibold text-gray-900">{t("routes.waypoints")}</h2>
<ul className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{waypoints.filter((w) => w.osmId || w.poiTags || w.name).map((w, i) => (
<li key={i} className="px-4 py-3">
<p className="font-medium text-gray-900">
{w.name ?? `${w.lat.toFixed(5)}, ${w.lon.toFixed(5)}`}
</p>
{w.poiTags && (
<dl className="mt-1 space-y-0.5 text-sm text-gray-600">
{w.poiTags.phone && (
<div className="flex gap-2">
<dt className="shrink-0 text-gray-400">{t("routes.poi.phone")}</dt>
<dd><a href={`tel:${w.poiTags.phone}`} className="hover:underline">{w.poiTags.phone}</a></dd>
</div>
)}
{w.poiTags.website && (
<div className="flex gap-2">
<dt className="shrink-0 text-gray-400">{t("routes.poi.website")}</dt>
<dd><a href={w.poiTags.website} target="_blank" rel="noopener noreferrer" className="hover:underline truncate">{w.poiTags.website}</a></dd>
</div>
)}
{w.poiTags.opening_hours && (
<div className="flex gap-2">
<dt className="shrink-0 text-gray-400">{t("routes.poi.openingHours")}</dt>
<dd>{w.poiTags.opening_hours}</dd>
</div>
)}
{(w.poiTags["addr:street"] || w.poiTags["addr:city"]) && (
<div className="flex gap-2">
<dt className="shrink-0 text-gray-400">{t("routes.poi.address")}</dt>
<dd>
{[
w.poiTags["addr:street"] && `${w.poiTags["addr:street"]}${w.poiTags["addr:housenumber"] ? " " + w.poiTags["addr:housenumber"] : ""}`,
w.poiTags["addr:postcode"] && w.poiTags["addr:city"]
? `${w.poiTags["addr:postcode"]} ${w.poiTags["addr:city"]}`
: w.poiTags["addr:city"],
].filter(Boolean).join(", ")}
</dd>
</div>
)}
</dl>
)}
</li>
))}
</ul>
</div>
)}
{route.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />

View file

@ -4,6 +4,7 @@ import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx, computeDays } from "@trails-cool/gpx";
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
import type { WaypointPoiTags } from "@trails-cool/types";
function getTracks(yjs: YjsState): TrackPoint[][] {
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
@ -24,6 +25,8 @@ function getWaypoints(yjs: YjsState) {
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
isDayBreak: yMap.get("overnight") === true ? true : undefined,
osmId: yMap.get("osmId") as number | undefined,
poiTags: yMap.get("poiTags") as WaypointPoiTags | undefined,
}));
}

View file

@ -4,6 +4,7 @@ import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx";
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
import type { WaypointPoiTags } from "@trails-cool/types";
interface SaveToJournalButtonProps {
yjs: YjsState;
@ -46,6 +47,8 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
isDayBreak: yMap.get("overnight") === true ? true : undefined,
osmId: yMap.get("osmId") as number | undefined,
poiTags: yMap.get("poiTags") as WaypointPoiTags | undefined,
}));
const notes = yjs.notes.toString() || undefined;

101
docs/gpx-extensions.md Normal file
View file

@ -0,0 +1,101 @@
# trails.cool GPX Extensions
trails.cool uses a custom XML namespace to store planning metadata in GPX files. This allows round-tripping through export, import, and Journal save without losing information.
## Namespace
```
xmlns:trails="https://trails.cool/gpx/1"
```
Declared on the root `<gpx>` element whenever any extension is present.
---
## `<trails:poi>` — POI metadata on waypoints
Stored inside `<wpt><extensions>`. Carries the OpenStreetMap node ID and key tags for waypoints that were snapped to a POI in the Planner.
### Syntax
```xml
<wpt lat="52.52" lon="13.405">
<name>Bike Shop Berlin</name>
<extensions>
<trails:poi osmId="123456">
<trails:tag k="phone" v="+49 30 12345"/>
<trails:tag k="website" v="https://example.com"/>
<trails:tag k="opening_hours" v="Mo-Fr 09:00-18:00"/>
<trails:tag k="addr:street" v="Unter den Linden"/>
<trails:tag k="addr:housenumber" v="1"/>
<trails:tag k="addr:postcode" v="10117"/>
<trails:tag k="addr:city" v="Berlin"/>
</trails:poi>
</extensions>
</wpt>
```
### Attributes
| Attribute | Required | Description |
|-----------|----------|-------------|
| `osmId` | no | OSM node ID (integer). Present when the waypoint was snapped to a known OSM node. |
### Child elements
Each `<trails:tag>` stores one OSM tag. The following keys are persisted:
| Key | Description |
|-----|-------------|
| `phone` / `contact:phone` | Phone number |
| `website` | Website URL |
| `opening_hours` | OSM opening hours string (e.g. `Mo-Fr 09:00-18:00`) |
| `addr:street` | Street name |
| `addr:housenumber` | House number |
| `addr:postcode` | Postal code |
| `addr:city` | City |
| `amenity` | OSM amenity value (e.g. `bicycle_shop`) |
| `tourism` | OSM tourism value (e.g. `camp_site`) |
| `shop` | OSM shop value |
### Display
The Journal route detail page renders a **Waypoints** section when at least one waypoint has POI metadata. Phone numbers become `tel:` links; websites become `https:` links.
---
## `<trails:planning>` — Planning metadata on routes
Stored inside the top-level `<extensions>` element. Carries no-go areas defined in the Planner.
### Syntax
```xml
<extensions>
<trails:planning>
<trails:nogo>
<trails:point lat="52.51" lon="13.40"/>
<trails:point lat="52.51" lon="13.41"/>
<trails:point lat="52.52" lon="13.41"/>
</trails:nogo>
</trails:planning>
</extensions>
```
### Elements
| Element | Description |
|---------|-------------|
| `<trails:planning>` | Container for all planning metadata. |
| `<trails:nogo>` | A no-go area polygon. Requires at least 3 `<trails:point>` children. Multiple `<trails:nogo>` elements are allowed. |
| `<trails:point lat="…" lon="…"/>` | A vertex of the no-go polygon. |
No-go areas are passed to BRouter when computing routes, which routes around them. They are preserved through export/import and Journal save.
---
## Compatibility
These extensions are **forward-compatible**: parsers that don't know the `trails:` namespace will ignore the `<extensions>` blocks and load the file as a normal GPX. Geometry, waypoint names, and elevation data are always stored in standard GPX elements.
The namespace URI `https://trails.cool/gpx/1` is versioned. If the schema changes incompatibly, the minor version will increment.

View file

@ -120,6 +120,57 @@ test.describe("Integration: Journal ↔ Planner handoff", () => {
});
});
test.describe("Integration: POI metadata roundtrip", () => {
test("GPX with POI extensions round-trips through Journal and renders on detail page", async ({ page, request }) => {
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1" xmlns:trails="https://trails.cool/gpx/1">
<wpt lat="52.52" lon="13.405">
<name>Bike Shop Berlin</name>
<extensions>
<trails:poi osmId="123456">
<trails:tag k="phone" v="+49 30 12345"/>
<trails:tag k="website" v="https://bikeshop.example"/>
<trails:tag k="opening_hours" v="Mo-Fr 09:00-18:00"/>
</trails:poi>
</extensions>
</wpt>
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.51" lon="13.38"><ele>40</ele></trkpt>
<trkpt lat="52.50" lon="13.35"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>`;
const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`);
expect(seedResp.ok()).toBeTruthy();
const { routeId, token } = await seedResp.json() as { routeId: string; token: string };
const callbackResp = await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, {
headers: { Authorization: `Bearer ${token}` },
data: { gpx },
});
expect(callbackResp.status()).toBe(200);
// Navigate to route detail page and check POI details are rendered
await page.goto(`${JOURNAL}/routes/${routeId}`);
await expect(page.getByText("Bike Shop Berlin")).toBeVisible();
await expect(page.getByText("+49 30 12345")).toBeVisible();
await expect(page.getByText("Mo-Fr 09:00-18:00")).toBeVisible();
await expect(page.getByRole("link", { name: "https://bikeshop.example" })).toBeVisible();
});
test("GPX without POI extensions shows no waypoints section", async ({ page, request }) => {
const seedResp = await request.post(`${JOURNAL}/api/e2e/seed`);
const { routeId, token } = await seedResp.json() as { routeId: string; token: string };
await request.post(`${JOURNAL}/api/routes/${routeId}/callback`, {
headers: { Authorization: `Bearer ${token}` },
data: { gpx: VALID_GPX },
});
await page.goto(`${JOURNAL}/routes/${routeId}`);
await expect(page.getByText("Waypoints")).not.toBeVisible();
});
});
test.describe("Integration: BRouter routing", () => {
// Helper: mint a planner session so our /api/route calls satisfy
// the session-bound gate introduced alongside this test file.

View file

@ -0,0 +1,88 @@
# Design: Journal POI Details
## Data Flow
POI metadata already flows from Overpass → POI snap → Yjs Y.Map (`osmId`, `poiTags`). The gap is that neither GPX export nor the Journal save extracts these fields.
```
Yjs Y.Map
{ lat, lon, name, overnight, osmId, poiTags: { phone, website, ... } }
↓ (currently drops osmId + poiTags)
Waypoint type → generateGpx → GPX <wpt>
Journal DB → route.gpx
↓ (currently not parsed)
Journal route detail page
```
## Changes
### 1. `@trails-cool/types` — extend `Waypoint`
Add optional POI fields to the shared `Waypoint` interface:
```ts
export interface Waypoint {
lat: number;
lon: number;
name?: string;
isDayBreak?: boolean;
osmId?: number;
poiTags?: {
phone?: string;
website?: string;
opening_hours?: string;
"addr:street"?: string;
"addr:housenumber"?: string;
"addr:postcode"?: string;
"addr:city"?: string;
amenity?: string;
tourism?: string;
shop?: string;
};
}
```
### 2. Planner — extract `osmId` + `poiTags` from Yjs
In `ExportButton.tsx` and `SaveToJournalButton.tsx`, add the two fields to the waypoint mapping:
```ts
osmId: yMap.get("osmId") as number | undefined,
poiTags: yMap.get("poiTags") as Waypoint["poiTags"] | undefined,
```
### 3. GPX — encode/decode POI fields as `<extensions>`
**`generate.ts`**: When a waypoint has `osmId` or `poiTags`, emit a `<extensions><trails:poi ...>` block inside `<wpt>`. Always declare the `trails:` namespace on the root `<gpx>` element (already done for noGoAreas, just extend the condition).
```xml
<wpt lat="52.5" lon="13.4">
<name>Fahrradhändler Müller</name>
<extensions>
<trails:poi osmId="123456">
<trails:tag k="phone" v="+49 30 12345"/>
<trails:tag k="website" v="https://example.com"/>
<trails:tag k="opening_hours" v="Mo-Fr 09:00-18:00"/>
</trails:poi>
</extensions>
</wpt>
```
**`parse.ts`**: In `parseWaypoints`, query `trails:poi` (and unprefixed `poi`) extensions; read the `osmId` attribute and `trails:tag` children.
### 4. Journal route detail — display POI metadata
In `routes.$id.tsx`:
- Parse waypoints from `route.gpx` in the loader (already done for `dayStats`, can reuse that parse)
- Pass waypoints with POI data to the component
- Render a `WaypointList` section showing: waypoint name + coords, and if `poiTags` present: phone (tel: link), website (https: link), opening hours, address
No new DB columns needed — everything lives in the GPX string already stored in `routes.gpx`.
## Non-Goals
- No new DB columns for POI data
- No Overpass lookups in the Journal
- No display of `osmId` to the user (internal reference only)
- Opening hours parsing/formatting: display raw string as-is

View file

@ -0,0 +1,11 @@
# Tasks: Journal POI Details
- [x] Extend `Waypoint` type in `packages/types/src/index.ts` with `osmId` and `poiTags` fields
- [x] Extract `osmId` and `poiTags` from Yjs Y.Map in `apps/planner/app/components/ExportButton.tsx`
- [x] Extract `osmId` and `poiTags` from Yjs Y.Map in `apps/planner/app/components/SaveToJournalButton.tsx`
- [x] Encode POI fields as `<trails:poi>` extensions in `packages/gpx/src/generate.ts`
- [x] Parse `<trails:poi>` extensions back into waypoints in `packages/gpx/src/parse.ts`
- [x] Add unit tests for GPX POI encode/decode roundtrip in `packages/gpx/src/`
- [x] Pass parsed waypoints with POI data from the loader in `apps/journal/app/routes/routes.$id.tsx`
- [x] Render POI details (phone, website, opening hours, address) for waypoints in the Journal route detail page
- [x] Add i18n keys for POI section labels (phone, website, opening hours, address)

View file

@ -0,0 +1,20 @@
## Purpose
The Journal route detail page displays a saved route with its metadata, map, elevation, day breakdown, and waypoint POI details.
## Requirements
### Requirement: POI metadata on route detail waypoints
The Journal route detail page SHALL display POI metadata (phone, address, website, opening hours) for waypoints that have associated OpenStreetMap POI data.
#### Scenario: POI metadata displayed on waypoints
- **WHEN** a route detail page is loaded and a waypoint has POI metadata from the Planner
- **THEN** the waypoint displays the POI name, icon, and category alongside its coordinates
#### Scenario: Phone, address, and website shown
- **WHEN** a waypoint has POI metadata including phone, address, or website
- **THEN** those details are shown in the waypoint detail section with appropriate links (tel: for phone, mailto: or https: for website)
#### Scenario: Waypoints without POI data display normally
- **WHEN** a waypoint on the route detail page has no associated POI metadata
- **THEN** the waypoint displays as before with coordinates and name only, with no empty POI sections shown

View file

@ -103,4 +103,55 @@ describe("generateGpx", () => {
expect(gpx).toContain("<name>Day 1: A - B</name>");
expect(gpx).toContain("<name>Day 2: B - C</name>");
});
it("encodes POI metadata as trails:poi extensions", () => {
const gpx = generateGpx({
waypoints: [
{
lat: 52.52,
lon: 13.405,
name: "Bike Shop",
osmId: 123456,
poiTags: { phone: "+49 30 123", website: "https://example.com", opening_hours: "Mo-Fr 09:00-18:00" },
},
],
});
expect(gpx).toContain('xmlns:trails="https://trails.cool/gpx/1"');
expect(gpx).toContain('<trails:poi osmId="123456">');
expect(gpx).toContain('<trails:tag k="phone" v="+49 30 123"/>');
expect(gpx).toContain('<trails:tag k="website" v="https://example.com"/>');
expect(gpx).toContain('<trails:tag k="opening_hours" v="Mo-Fr 09:00-18:00"/>');
});
it("roundtrips POI metadata through generate + parse", async () => {
const gpx = generateGpx({
waypoints: [
{
lat: 52.52,
lon: 13.405,
name: "Campsite",
osmId: 987,
poiTags: { phone: "+49 30 999", website: "https://camp.example" },
},
{ lat: 48.0, lon: 11.0, name: "Plain waypoint" },
],
});
const parsed = await parseGpxAsync(gpx);
expect(parsed.waypoints).toHaveLength(2);
const poi = parsed.waypoints[0]!;
expect(poi.osmId).toBe(987);
expect(poi.poiTags?.phone).toBe("+49 30 999");
expect(poi.poiTags?.website).toBe("https://camp.example");
const plain = parsed.waypoints[1]!;
expect(plain.osmId).toBeUndefined();
expect(plain.poiTags).toBeUndefined();
});
it("omits extensions element when waypoint has no POI data", () => {
const gpx = generateGpx({
waypoints: [{ lat: 52.0, lon: 13.0, name: "Plain" }],
});
expect(gpx).not.toContain("<extensions>");
expect(gpx).not.toContain("trails:poi");
});
});

View file

@ -17,7 +17,8 @@ export function generateGpx(options: {
/** When true, splits tracks at overnight waypoints into separate <trk> elements per day */
splitByDay?: boolean;
}): string {
const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0;
const hasPoiWaypoints = options.waypoints?.some((w) => w.osmId || w.poiTags) ?? false;
const hasExtensions = (options.noGoAreas && options.noGoAreas.length > 0) || hasPoiWaypoints;
const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<gpx version="1.1" creator="trails.cool"',
@ -42,6 +43,20 @@ export function generateGpx(options: {
if (wpt.isDayBreak) {
lines.push(" <type>overnight</type>");
}
if (wpt.osmId !== undefined || (wpt.poiTags && Object.keys(wpt.poiTags).length > 0)) {
lines.push(" <extensions>");
const osmIdAttr = wpt.osmId !== undefined ? ` osmId="${wpt.osmId}"` : "";
lines.push(` <trails:poi${osmIdAttr}>`);
if (wpt.poiTags) {
for (const [k, v] of Object.entries(wpt.poiTags)) {
if (v !== undefined) {
lines.push(` <trails:tag k="${escapeXml(k)}" v="${escapeXml(v)}"/>`);
}
}
}
lines.push(" </trails:poi>");
lines.push(" </extensions>");
}
lines.push(" </wpt>");
}
}

View file

@ -61,7 +61,25 @@ function parseWaypoints(doc: Document): Waypoint[] {
const name = wpt.querySelector("name")?.textContent ?? undefined;
const type = wpt.querySelector("type")?.textContent ?? undefined;
const isDayBreak = type === "overnight" ? true : undefined;
return { lat, lon, name, isDayBreak };
const poiEl = wpt.querySelector("poi, trails\\:poi");
let osmId: number | undefined;
let poiTags: Waypoint["poiTags"] | undefined;
if (poiEl) {
const rawOsmId = poiEl.getAttribute("osmId");
if (rawOsmId) osmId = parseInt(rawOsmId, 10);
const tagEls = poiEl.querySelectorAll("tag, trails\\:tag");
if (tagEls.length > 0) {
poiTags = {};
for (const tagEl of Array.from(tagEls)) {
const k = tagEl.getAttribute("k");
const v = tagEl.getAttribute("v");
if (k && v) (poiTags as Record<string, string>)[k] = v;
}
}
}
return { lat, lon, name, isDayBreak, osmId, poiTags };
});
}

View file

@ -225,6 +225,13 @@ export default {
elevationGain: "Höhenmeter",
dayBreakdown: "Tagesübersicht",
dayLabel: "Tag {{n}}",
waypoints: "Wegpunkte",
poi: {
phone: "Telefon",
website: "Website",
openingHours: "Öffnungszeiten",
address: "Adresse",
},
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
noMapPreview: "Keine Kartenvorschau",
saveChanges: "Änderungen speichern",

View file

@ -225,6 +225,13 @@ export default {
elevationGain: "Elevation Gain",
dayBreakdown: "Day Breakdown",
dayLabel: "Day {{n}}",
waypoints: "Waypoints",
poi: {
phone: "Phone",
website: "Website",
openingHours: "Hours",
address: "Address",
},
noRoutesYet: "No routes yet. Create your first route!",
noMapPreview: "No map preview",
saveChanges: "Save Changes",

View file

@ -4,11 +4,26 @@
* These types are used by both the Planner and Journal apps.
*/
export interface WaypointPoiTags {
phone?: string;
website?: string;
opening_hours?: string;
"addr:street"?: string;
"addr:housenumber"?: string;
"addr:postcode"?: string;
"addr:city"?: string;
amenity?: string;
tourism?: string;
shop?: string;
}
export interface Waypoint {
lat: number;
lon: number;
name?: string;
isDayBreak?: boolean;
osmId?: number;
poiTags?: WaypointPoiTags;
}
export interface RouteMetadata {