trails/apps/planner/app/components/ExportButton.tsx
Ullrich Schäfer 861701e881
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>
2026-05-17 23:32:38 +02:00

173 lines
6.3 KiB
TypeScript

import { useCallback, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
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;
if (!geojsonStr) return [];
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length > 0) {
return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))];
}
} catch { /* invalid geojson */ }
return [];
}
function getWaypoints(yjs: YjsState) {
return yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
lat: yMap.get("lat") as number,
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,
}));
}
function getNoGoAreas(yjs: YjsState): NoGoArea[] {
return yjs.noGoAreas.toArray().map((yMap: Y.Map<unknown>) => ({
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3);
}
function download(gpx: string, filename: string) {
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function ExportButton({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close dropdown on outside mousedown
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleExportRoute = useCallback(() => {
const tracks = getTracks(yjs);
const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
download(gpx, "route.gpx");
setOpen(false);
}, [yjs]);
const handleExportPlan = useCallback(() => {
const tracks = getTracks(yjs);
const waypoints = getWaypoints(yjs);
const noGoAreas = getNoGoAreas(yjs);
const notes = yjs.notes.toString() || undefined;
const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas });
download(gpx, "route-plan.gpx");
setOpen(false);
}, [yjs]);
const handleExportDays = useCallback(() => {
const tracks = getTracks(yjs);
const waypoints = getWaypoints(yjs);
const allPoints = tracks.flat();
if (allPoints.length === 0 || waypoints.length === 0) return;
const days = computeDays(waypoints, tracks);
if (days.length <= 1) {
// Single day — just export the full route
const gpx = generateGpx({ name: "trails.cool route", tracks });
download(gpx, "route.gpx");
setOpen(false);
return;
}
// Find closest track index for each waypoint
const wpTrackIndices = waypoints.map((wp) => {
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < allPoints.length; i++) {
const dx = allPoints[i]!.lat - wp.lat;
const dy = allPoints[i]!.lon - wp.lon;
const d = dx * dx + dy * dy;
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
});
for (const day of days) {
const startIdx = wpTrackIndices[day.startWaypointIndex]!;
const endIdx = wpTrackIndices[day.endWaypointIndex]!;
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
const dayName = day.startName && day.endName
? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}`
: `Day ${day.dayNumber}`;
const gpx = generateGpx({ name: dayName, tracks: [dayPoints] });
const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`;
download(gpx, filename);
}
setOpen(false);
}, [yjs]);
const hasMultipleDays = (() => {
const waypoints = getWaypoints(yjs);
return waypoints.some((w) => w.isDayBreak);
})();
return (
<div ref={ref} className="relative z-[1001]">
<div className="flex">
<button
onClick={handleExportRoute}
className="rounded-l bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
{t("exportGpx")}
</button>
<button
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
className="rounded-r border-l border-gray-300 bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
</button>
</div>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded border border-gray-200 bg-white py-1 shadow-lg">
<button
onClick={handleExportRoute}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportRoute")}</span>
<span className="block text-xs text-gray-400">{t("exportRouteDesc")}</span>
</button>
<button
onClick={handleExportPlan}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportPlan")}</span>
<span className="block text-xs text-gray-400">{t("exportPlanDesc")}</span>
</button>
{hasMultipleDays && (
<button
onClick={handleExportDays}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportDays")}</span>
<span className="block text-xs text-gray-400">{t("exportDaysDesc")}</span>
</button>
)}
</div>
)}
</div>
);
}