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:
parent
f1a314a70d
commit
861701e881
13 changed files with 341 additions and 9 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue