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 {