Show POI details (phone, website, opening hours) on Journal route detail page

Waypoints snapped to OSM POIs in the Planner now carry their metadata all
the way through to the Journal:

- Extend Waypoint type with osmId and poiTags fields
- Extract osmId/poiTags from Yjs Y.Map in ExportButton and SaveToJournalButton
- Encode POI metadata as <trails:poi> extensions in GPX <wpt> elements
- Parse <trails:poi> extensions back in the GPX parser
- Display phone, website, opening hours, address on Journal route detail
- E2E test for the full roundtrip; seed endpoint now defaults to public visibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-17 23:32:38 +02:00
parent f1a314a70d
commit 861701e881
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
13 changed files with 341 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;

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

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