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

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