trails/apps/journal/app/routes/routes.$id.tsx
Ullrich Schäfer 536a8f98b9
journal-elevation-profile: elevation chart + map↔chart sync
Implements the journal-elevation-profile change (specs/journal-elevation-profile):

- gpx: `elevationSeries(tracks)` → { d, e, lat, lng }[] with cumulative distance,
  downsampled (keeps first/last), empty when <2 points carry elevation.
- ElevationProfile: read-only SVG area chart (vertical gradient fill), highest/
  lowest summary + a hover readout. Reports hovered index (onActive) and clicked
  index (onSeek); draws a marker at the active index. Renders nothing for an
  empty series.
- RouteMapThumbnail: ActiveMarker (CircleMarker at the chart's active point),
  HoverTracker (route hover → nearest sample → onHoverIndex), Recenter (panTo on
  chart click). Props forwarded through ClientMap.
- Wired into the activity + route detail pages via a shared activeIndex/centerOn
  state; loaders expose the series (activity reuses the moving-time parse).
- i18n journal.elevation.{highest,lowest} in en + de.

Ascent/descent stay in the stat row (#532); the chart summary shows highest/
lowest to avoid duplication.

Tests: elevationSeries unit; ElevationProfile component (jsdom); e2e creates an
activity from an elevation GPX and asserts the chart renders. typecheck + lint +
unit (gpx 67, journal 315) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:34:45 +02:00

404 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useMemo } from "react";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { StatRow } from "~/components/StatRow";
import { ElevationProfile } from "~/components/ElevationProfile";
import { activityStatItems } from "~/lib/stats";
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
export async function loader({ params, request }: Route.LoaderArgs) {
return data(await loadRouteDetail(request, params.id));
}
export async function action({ params, request }: Route.ActionArgs) {
return routeDetailAction(request, params.id);
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const route = (loaderData as { route?: { name: string; description: string | null; visibility: string } })?.route;
const name = route?.name ?? "Route";
const title = `${name} — trails.cool`;
const tags: Array<Record<string, string>> = [{ title }];
// Only emit Open Graph / Twitter Card tags for publicly-reachable content.
// Private routes are gated before meta is even called, but belt-and-braces.
if (route && (route.visibility === "public" || route.visibility === "unlisted")) {
const description = (route.description && route.description.length > 0)
? route.description.slice(0, 280)
: `A route on trails.cool`;
tags.push(
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "article" },
{ property: "og:site_name", content: "trails.cool" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
);
}
return tags;
}
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
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);
// Elevation profile ↔ map sync via a shared "active" sample index.
const elevation = route.elevation;
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [centerOn, setCenterOn] = useState<{ lat: number; lng: number; v: number } | null>(null);
const hoverSeries = useMemo(
() => elevation.map((s) => [s.lat, s.lng] as [number, number]),
[elevation],
);
const activePoint =
activeIndex != null && elevation[activeIndex]
? { lat: elevation[activeIndex]!.lat, lng: elevation[activeIndex]!.lng }
: null;
const seekElevation = (i: number) => {
const s = elevation[i];
if (s) setCenterOn((prev) => ({ lat: s.lat, lng: s.lng, v: (prev?.v ?? 0) + 1 }));
setActiveIndex(i);
};
const pushStatus = typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("push")
: null;
const pushErrorCode = typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("code")
: null;
// A route is "empty" when it has no geometry and no computed distance —
// i.e. nobody has planned any waypoints yet. Surfaced as a dedicated
// empty-state below so the page isn't a dead end.
const isEmpty = !route.geojson && route.distance == null;
const handleEditInPlanner = useCallback(async () => {
setEditLoading(true);
try {
const resp = await fetch(`/api/routes/${route.id}/edit-in-planner`, { method: "POST" });
const result = await resp.json();
if (result.url) {
window.location.href = result.url;
}
} finally {
setEditLoading(false);
}
}, [route.id]);
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{route.name}</h1>
{route.description && (
<p className="mt-2 text-gray-600">{route.description}</p>
)}
</div>
{isOwner && !isEmpty && (
<div className="flex flex-wrap gap-2">
<button
onClick={handleEditInPlanner}
disabled={editLoading}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{editLoading ? t("routes.opening") : t("routes.editInPlanner")}
</button>
<a
href={`/routes/${route.id}/edit`}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
{t("routes.edit")}
</a>
{route.hasGpx && (
<a
href={`/api/routes/${route.id}/gpx`}
download
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
{t("routes.exportGpx")}
</a>
)}
{wahooPush?.canPush && (() => {
const latest = wahooPush.latest;
const matches =
latest?.pushedAt &&
latest.lastPushedVersion === wahooPush.currentVersion;
const localNewer =
latest?.pushedAt &&
latest.lastPushedVersion != null &&
latest.lastPushedVersion < wahooPush.currentVersion;
if (matches) {
return (
<span
className="rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-800"
title={t("routes.sendToWahooHelp")}
>
{t("routes.sentToWahoo", {
date: new Date(latest!.pushedAt!).toLocaleDateString(i18n.language),
})}
</span>
);
}
return (
<form method="post" action={`/api/sync/push/wahoo/${route.id}`} className="inline">
{localNewer && (
<span className="mr-2 rounded-md bg-amber-50 px-3 py-1.5 text-sm text-amber-900">
{t("routes.onWahooNewer", { n: latest!.lastPushedVersion })}
</span>
)}
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
title={t("routes.sendToWahooHelp")}
>
{localNewer ? t("routes.sendUpdatedVersion") : t("routes.sendToWahoo")}
</button>
{latest?.error && (
<span className="ml-2 text-sm text-red-700">
{t("routes.sendToWahooFailed", { error: latest.error })}
</span>
)}
</form>
);
})()}
</div>
)}
</div>
{pushStatus && (
<div
className={`mt-4 rounded-md px-3 py-2 text-sm ${
pushStatus === "success"
? "bg-green-50 text-green-800"
: "bg-amber-50 text-amber-900"
}`}
>
{pushStatus === "success" && t("routes.sendToWahooBanner.success")}
{pushStatus === "needs_permission" && t("routes.sendToWahooBanner.needsPermission")}
{pushStatus === "no_connection" && t("routes.sendToWahooBanner.noConnection")}
{pushStatus === "no_geometry" && t("routes.sendToWahooBanner.noGeometry")}
{pushStatus === "error" && pushErrorCode === "validation" &&
t("routes.sendToWahooBanner.validation")}
{pushStatus === "error" && pushErrorCode === "rate_limit" &&
t("routes.sendToWahooBanner.rateLimit")}
{pushStatus === "error" && pushErrorCode === "token_expired" &&
t("routes.sendToWahooBanner.tokenExpired")}
{pushStatus === "error" && (!pushErrorCode || pushErrorCode === "generic") &&
t("routes.sendToWahooBanner.generic")}
</div>
)}
<StatRow
size="lg"
className="mt-6"
items={activityStatItems(
{
distance: route.distance,
elevationGain: route.elevationGain,
elevationLoss: route.elevationLoss,
},
t,
)}
/>
{dayStats.length > 1 && (
<div className="mt-6">
<h2 className="text-lg font-semibold text-gray-900">{t("routes.dayBreakdown")}</h2>
<div className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{dayStats.map((day) => (
<div
key={day.dayNumber}
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 transition-colors"
onMouseEnter={() => setHighlightedDay(day.dayNumber)}
onMouseLeave={() => setHighlightedDay(null)}
>
<span className="text-sm font-medium text-gray-700">
{t("routes.dayLabel", { n: day.dayNumber })}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">
{day.startName && day.endName
? `${day.startName}${day.endName}`
: day.startName || day.endName || ""}
</span>
<span className="text-sm tabular-nums text-gray-700">
{(day.distance / 1000).toFixed(1)} km
</span>
<span className="text-xs text-gray-500">
{day.ascent} m
</span>
<span className="text-xs text-gray-500">
{day.descent} m
</span>
{route.hasGpx && (
<a
href={`/api/routes/${route.id}/gpx?day=${day.dayNumber}`}
download
className="shrink-0 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 hover:text-gray-700"
title={t("routes.exportGpx")}
>
GPX
</a>
)}
</div>
))}
</div>
</div>
)}
{waypoints.some((w) => w.osmId || w.poiTags || w.note) && (
<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.note || 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.note && (
<p className="mt-1 whitespace-pre-wrap text-sm text-gray-600">{w.note}</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}
activePoint={activePoint}
hoverSeries={hoverSeries}
onHoverIndex={setActiveIndex}
centerOn={centerOn}
/>
</div>
)}
{elevation.length > 1 && (
<ElevationProfile
className="mt-4"
series={elevation}
activeIndex={activeIndex}
onActive={setActiveIndex}
onSeek={seekElevation}
labels={{ highest: t("elevation.highest"), lowest: t("elevation.lowest") }}
/>
)}
{isEmpty && (
<div className="mt-6 flex flex-col items-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-6 py-12 text-center">
<div className="text-4xl" aria-hidden="true">🗺</div>
<h2 className="mt-4 text-lg font-semibold text-gray-900">
{t("routes.empty.heading")}
</h2>
<p className="mt-2 max-w-md text-sm text-gray-600">
{isOwner ? t("routes.empty.bodyOwner") : t("routes.empty.bodyViewer")}
</p>
{isOwner && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
onClick={handleEditInPlanner}
disabled={editLoading}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{editLoading ? t("routes.opening") : t("routes.empty.openInPlanner")}
</button>
<a
href={`/routes/${route.id}/edit`}
className="text-sm text-gray-600 underline hover:text-gray-900"
>
{t("routes.empty.uploadGpx")}
</a>
</div>
)}
</div>
)}
{versions.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Version History</h2>
<ul className="mt-3 divide-y divide-gray-200">
{versions.map((v) => (
<li key={v.version} className="py-2">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-700">v{v.version}</span>
<span className="text-sm text-gray-500">
<ClientDate iso={v.createdAt} />
</span>
</div>
{v.changeDescription && (
<p className="text-sm text-gray-500">{v.changeDescription}</p>
)}
</li>
))}
</ul>
</div>
)}
{isOwner && (
<div className="mt-8 border-t border-gray-200 pt-6">
<form method="post" onSubmit={(e) => {
if (!confirm("Are you sure you want to delete this route?")) {
e.preventDefault();
}
}}>
<input type="hidden" name="intent" value="delete" />
<button
type="submit"
className="rounded-md bg-red-50 px-4 py-2 text-sm text-red-700 hover:bg-red-100"
>
{t("routes.delete")}
</button>
</form>
</div>
)}
</div>
);
}