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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue