Merge pull request #391 from trails-cool/stigi/journal-poi-details
Show POI details on Journal route detail page
This commit is contained in:
commit
bcf551cd27
18 changed files with 462 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
101
docs/gpx-extensions.md
Normal file
101
docs/gpx-extensions.md
Normal file
|
|
@ -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 `<gpx>` element whenever any extension is present.
|
||||
|
||||
---
|
||||
|
||||
## `<trails:poi>` — POI metadata on waypoints
|
||||
|
||||
Stored inside `<wpt><extensions>`. Carries the OpenStreetMap node ID and key tags for waypoints that were snapped to a POI in the Planner.
|
||||
|
||||
### Syntax
|
||||
|
||||
```xml
|
||||
<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://example.com"/>
|
||||
<trails:tag k="opening_hours" v="Mo-Fr 09:00-18:00"/>
|
||||
<trails:tag k="addr:street" v="Unter den Linden"/>
|
||||
<trails:tag k="addr:housenumber" v="1"/>
|
||||
<trails:tag k="addr:postcode" v="10117"/>
|
||||
<trails:tag k="addr:city" v="Berlin"/>
|
||||
</trails:poi>
|
||||
</extensions>
|
||||
</wpt>
|
||||
```
|
||||
|
||||
### Attributes
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `osmId` | no | OSM node ID (integer). Present when the waypoint was snapped to a known OSM node. |
|
||||
|
||||
### Child elements
|
||||
|
||||
Each `<trails:tag>` 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.
|
||||
|
||||
---
|
||||
|
||||
## `<trails:planning>` — Planning metadata on routes
|
||||
|
||||
Stored inside the top-level `<extensions>` element. Carries no-go areas defined in the Planner.
|
||||
|
||||
### Syntax
|
||||
|
||||
```xml
|
||||
<extensions>
|
||||
<trails:planning>
|
||||
<trails:nogo>
|
||||
<trails:point lat="52.51" lon="13.40"/>
|
||||
<trails:point lat="52.51" lon="13.41"/>
|
||||
<trails:point lat="52.52" lon="13.41"/>
|
||||
</trails:nogo>
|
||||
</trails:planning>
|
||||
</extensions>
|
||||
```
|
||||
|
||||
### Elements
|
||||
|
||||
| Element | Description |
|
||||
|---------|-------------|
|
||||
| `<trails:planning>` | Container for all planning metadata. |
|
||||
| `<trails:nogo>` | A no-go area polygon. Requires at least 3 `<trails:point>` children. Multiple `<trails:nogo>` elements are allowed. |
|
||||
| `<trails:point lat="…" lon="…"/>` | 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 `<extensions>` 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
20
openspec/specs/journal-route-detail/spec.md
Normal file
20
openspec/specs/journal-route-detail/spec.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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