From 861701e881a72401fb50ed59276f3ac83889900e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 17 May 2026 23:32:38 +0200 Subject: [PATCH 1/3] 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 extensions in GPX elements - Parse 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 --- apps/journal/app/routes/api.e2e.seed.ts | 2 + apps/journal/app/routes/routes.$id.tsx | 75 ++++++++++++++-- apps/planner/app/components/ExportButton.tsx | 3 + .../app/components/SaveToJournalButton.tsx | 3 + e2e/integration.test.ts | 51 +++++++++++ .../changes/journal-poi-details/design.md | 88 +++++++++++++++++++ openspec/changes/journal-poi-details/tasks.md | 11 +++ packages/gpx/src/generate.test.ts | 51 +++++++++++ packages/gpx/src/generate.ts | 17 +++- packages/gpx/src/parse.ts | 20 ++++- packages/i18n/src/locales/de.ts | 7 ++ packages/i18n/src/locales/en.ts | 7 ++ packages/types/src/index.ts | 15 ++++ 13 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 openspec/changes/journal-poi-details/design.md create mode 100644 openspec/changes/journal-poi-details/tasks.md diff --git a/apps/journal/app/routes/api.e2e.seed.ts b/apps/journal/app/routes/api.e2e.seed.ts index 6d0b67b..91301a0 100644 --- a/apps/journal/app/routes/api.e2e.seed.ts +++ b/apps/journal/app/routes/api.e2e.seed.ts @@ -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); diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index a85c066..8e44af4 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -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 }> = []; + 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 | 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(null); @@ -387,6 +398,56 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { )} + {waypoints.some((w) => w.osmId || w.poiTags) && ( +
+

{t("routes.waypoints")}

+
    + {waypoints.filter((w) => w.osmId || w.poiTags || w.name).map((w, i) => ( +
  • +

    + {w.name ?? `${w.lat.toFixed(5)}, ${w.lon.toFixed(5)}`} +

    + {w.poiTags && ( +
    + {w.poiTags.phone && ( +
    +
    {t("routes.poi.phone")}
    +
    {w.poiTags.phone}
    +
    + )} + {w.poiTags.website && ( +
    +
    {t("routes.poi.website")}
    +
    {w.poiTags.website}
    +
    + )} + {w.poiTags.opening_hours && ( +
    +
    {t("routes.poi.openingHours")}
    +
    {w.poiTags.opening_hours}
    +
    + )} + {(w.poiTags["addr:street"] || w.poiTags["addr:city"]) && ( +
    +
    {t("routes.poi.address")}
    +
    + {[ + 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(", ")} +
    +
    + )} +
    + )} +
  • + ))} +
+
+ )} + {route.geojson && (
0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} /> diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index d673d0b..f58eb3b 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -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, })); } diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index a2e178a..2ba7885 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -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; diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts index c4b5c3e..4bad37c 100644 --- a/e2e/integration.test.ts +++ b/e2e/integration.test.ts @@ -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 = ` + + + Bike Shop Berlin + + + + + + + + + + 34 + 40 + 35 + +`; + + 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. diff --git a/openspec/changes/journal-poi-details/design.md b/openspec/changes/journal-poi-details/design.md new file mode 100644 index 0000000..e35819e --- /dev/null +++ b/openspec/changes/journal-poi-details/design.md @@ -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 + ↓ + 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 `` + +**`generate.ts`**: When a waypoint has `osmId` or `poiTags`, emit a `` block inside ``. Always declare the `trails:` namespace on the root `` element (already done for noGoAreas, just extend the condition). + +```xml + + Fahrradhändler Müller + + + + + + + + +``` + +**`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 diff --git a/openspec/changes/journal-poi-details/tasks.md b/openspec/changes/journal-poi-details/tasks.md new file mode 100644 index 0000000..c81161e --- /dev/null +++ b/openspec/changes/journal-poi-details/tasks.md @@ -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 `` extensions in `packages/gpx/src/generate.ts` +- [x] Parse `` 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) diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts index f92fab1..ac160ee 100644 --- a/packages/gpx/src/generate.test.ts +++ b/packages/gpx/src/generate.test.ts @@ -103,4 +103,55 @@ describe("generateGpx", () => { expect(gpx).toContain("Day 1: A - B"); expect(gpx).toContain("Day 2: B - C"); }); + + 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(''); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + }); + + 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(""); + expect(gpx).not.toContain("trails:poi"); + }); }); diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index 064a5be..b7d1a39 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -17,7 +17,8 @@ export function generateGpx(options: { /** When true, splits tracks at overnight waypoints into separate 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[] = [ '', 'overnight"); } + if (wpt.osmId !== undefined || (wpt.poiTags && Object.keys(wpt.poiTags).length > 0)) { + lines.push(" "); + const osmIdAttr = wpt.osmId !== undefined ? ` osmId="${wpt.osmId}"` : ""; + lines.push(` `); + if (wpt.poiTags) { + for (const [k, v] of Object.entries(wpt.poiTags)) { + if (v !== undefined) { + lines.push(` `); + } + } + } + lines.push(" "); + lines.push(" "); + } lines.push(" "); } } diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 8e71d0f..9436a64 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -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)[k] = v; + } + } + } + + return { lat, lon, name, isDayBreak, osmId, poiTags }; }); } diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index ee6346d..78fb7fa 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 5bb5eca..d7ebc2a 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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", diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9681bf6..a391319 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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 { From faf22278960071bc1049ff7166db2e7c790b4923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 17 May 2026 23:33:37 +0200 Subject: [PATCH 2/3] Archive journal-poi-details change; sync journal-route-detail spec Co-Authored-By: Claude Sonnet 4.6 --- .../.openspec.yaml | 0 .../2026-05-17-journal-poi-details}/design.md | 0 .../proposal.md | 0 .../specs/journal-route-detail/spec.md | 0 .../2026-05-17-journal-poi-details}/tasks.md | 0 openspec/specs/journal-route-detail/spec.md | 20 +++++++++++++++++++ 6 files changed, 20 insertions(+) rename openspec/changes/{journal-poi-details => archive/2026-05-17-journal-poi-details}/.openspec.yaml (100%) rename openspec/changes/{journal-poi-details => archive/2026-05-17-journal-poi-details}/design.md (100%) rename openspec/changes/{journal-poi-details => archive/2026-05-17-journal-poi-details}/proposal.md (100%) rename openspec/changes/{journal-poi-details => archive/2026-05-17-journal-poi-details}/specs/journal-route-detail/spec.md (100%) rename openspec/changes/{journal-poi-details => archive/2026-05-17-journal-poi-details}/tasks.md (100%) create mode 100644 openspec/specs/journal-route-detail/spec.md diff --git a/openspec/changes/journal-poi-details/.openspec.yaml b/openspec/changes/archive/2026-05-17-journal-poi-details/.openspec.yaml similarity index 100% rename from openspec/changes/journal-poi-details/.openspec.yaml rename to openspec/changes/archive/2026-05-17-journal-poi-details/.openspec.yaml diff --git a/openspec/changes/journal-poi-details/design.md b/openspec/changes/archive/2026-05-17-journal-poi-details/design.md similarity index 100% rename from openspec/changes/journal-poi-details/design.md rename to openspec/changes/archive/2026-05-17-journal-poi-details/design.md diff --git a/openspec/changes/journal-poi-details/proposal.md b/openspec/changes/archive/2026-05-17-journal-poi-details/proposal.md similarity index 100% rename from openspec/changes/journal-poi-details/proposal.md rename to openspec/changes/archive/2026-05-17-journal-poi-details/proposal.md diff --git a/openspec/changes/journal-poi-details/specs/journal-route-detail/spec.md b/openspec/changes/archive/2026-05-17-journal-poi-details/specs/journal-route-detail/spec.md similarity index 100% rename from openspec/changes/journal-poi-details/specs/journal-route-detail/spec.md rename to openspec/changes/archive/2026-05-17-journal-poi-details/specs/journal-route-detail/spec.md diff --git a/openspec/changes/journal-poi-details/tasks.md b/openspec/changes/archive/2026-05-17-journal-poi-details/tasks.md similarity index 100% rename from openspec/changes/journal-poi-details/tasks.md rename to openspec/changes/archive/2026-05-17-journal-poi-details/tasks.md diff --git a/openspec/specs/journal-route-detail/spec.md b/openspec/specs/journal-route-detail/spec.md new file mode 100644 index 0000000..6236dc7 --- /dev/null +++ b/openspec/specs/journal-route-detail/spec.md @@ -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 From d55981d1bf3fccf90d5bcfb00d0c9b4e14d09611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 17 May 2026 23:35:06 +0200 Subject: [PATCH 3/3] Add docs/gpx-extensions.md documenting the trails: GPX namespace Co-Authored-By: Claude Sonnet 4.6 --- docs/gpx-extensions.md | 101 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/gpx-extensions.md diff --git a/docs/gpx-extensions.md b/docs/gpx-extensions.md new file mode 100644 index 0000000..3b231e1 --- /dev/null +++ b/docs/gpx-extensions.md @@ -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 `` element whenever any extension is present. + +--- + +## `` — POI metadata on waypoints + +Stored inside ``. Carries the OpenStreetMap node ID and key tags for waypoints that were snapped to a POI in the Planner. + +### Syntax + +```xml + + Bike Shop Berlin + + + + + + + + + + + + +``` + +### Attributes + +| Attribute | Required | Description | +|-----------|----------|-------------| +| `osmId` | no | OSM node ID (integer). Present when the waypoint was snapped to a known OSM node. | + +### Child elements + +Each `` 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. + +--- + +## `` — Planning metadata on routes + +Stored inside the top-level `` element. Carries no-go areas defined in the Planner. + +### Syntax + +```xml + + + + + + + + + +``` + +### Elements + +| Element | Description | +|---------|-------------| +| `` | Container for all planning metadata. | +| `` | A no-go area polygon. Requires at least 3 `` children. Multiple `` elements are allowed. | +| `` | 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 `` 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.