trails/apps/journal/app/routes/routes.$id.tsx
Ullrich Schäfer db3eeed60f
Apply wahoo-route-update: PUT on re-push instead of duplicate POST
Re-pushing an edited route to Wahoo now updates the existing remote
route via PUT against the stored remote_id instead of POSTing a new
copy. external_id drops the version suffix and identifies the logical
route. sync_pushes is keyed by (user, route, provider) and tracks
last_pushed_version for the "local newer" UI state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:08:16 +02:00

464 lines
18 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 } from "react";
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { and, eq } from "drizzle-orm";
import { canView, getSessionUser } from "~/lib/auth.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { getDb } from "~/lib/db";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getConnection } from "~/lib/sync/connections.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
export async function loader({ params, request }: Route.LoaderArgs) {
const [routeWithVersions, routeWithGeojson] = await Promise.all([
getRouteWithVersions(params.id),
getRoute(params.id),
]);
if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 });
const route = routeWithVersions;
const user = await getSessionUser(request);
const isOwner = user?.id === route.ownerId;
// Visibility gate: public always renders, unlisted renders on direct link,
// private requires ownership. Return 404 (not 403) to avoid leaking existence.
if (!canView(route, user, { asDirectLink: true })) {
throw data({ error: "Route not found" }, { status: 404 });
}
// Compute per-day stats if route has day breaks and GPX
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) {
try {
const { computeDays } = await import("@trails-cool/gpx");
const { parseGpxAsync } = await import("@trails-cool/gpx");
const gpxData = await parseGpxAsync(route.gpx);
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
} catch {
// Fall back to no day stats
}
}
const currentVersion = route.versions[0]?.version ?? 1;
// Wahoo push state — only meaningful for the owner. The single
// sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`,
// which we compare against the current local version to render one of
// three states: matches, local newer, or last attempt failed.
let wahooPush:
| {
canPush: boolean;
needsReauth: boolean;
currentVersion: number;
latest: {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
} | null;
}
| null = null;
if (isOwner && user && !!route.gpx) {
const connection = await getConnection(user.id, "wahoo");
let latest:
| {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
}
| null = null;
if (connection) {
const db = getDb();
const [row] = await db
.select()
.from(syncPushes)
.where(
and(
eq(syncPushes.userId, user.id),
eq(syncPushes.routeId, route.id),
eq(syncPushes.provider, "wahoo"),
),
)
.limit(1);
latest = row
? {
pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null,
remoteId: row.remoteId,
lastPushedVersion: row.lastPushedVersion,
error: row.error,
}
: null;
}
wahooPush = {
canPush: !!connection,
needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"),
currentVersion,
latest,
};
}
return data({
route: {
id: route.id,
name: route.name,
description: route.description,
distance: route.distance,
elevationGain: route.elevationGain,
elevationLoss: route.elevationLoss,
routingProfile: route.routingProfile,
hasGpx: !!route.gpx,
dayBreaks: route.dayBreaks ?? [],
geojson: routeWithGeojson?.geojson ?? null,
visibility: route.visibility,
createdAt: route.createdAt.toISOString(),
updatedAt: route.updatedAt.toISOString(),
},
dayStats,
versions: route.versions.map((v) => ({
version: v.version,
changeDescription: v.changeDescription,
createdAt: v.createdAt.toISOString(),
})),
isOwner,
wahooPush,
});
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await deleteRoute(params.id, user.id);
return redirect("/routes");
}
if (intent === "update") {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const gpxFile = formData.get("gpx") as File | null;
const input: Record<string, unknown> = {};
if (name) input.name = name;
if (description !== null) input.description = description;
if (gpxFile && gpxFile.size > 0) {
input.gpx = await gpxFile.text();
}
await updateRoute(params.id, user.id, input as { name?: string; description?: string; gpx?: string });
return redirect(`/routes/${params.id}`);
}
return data({ error: "Unknown action" }, { status: 400 });
}
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, versions, isOwner, wahooPush } = loaderData;
const { t, i18n } = useTranslation("journal");
const [editLoading, setEditLoading] = useState(false);
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
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>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
{route.distance != null && (
<div className="rounded-md bg-gray-50 p-4">
<p className="text-2xl font-bold text-gray-900">
{(route.distance / 1000).toFixed(1)} km
</p>
<p className="text-sm text-gray-500">{t("routes.distance")}</p>
</div>
)}
{route.elevationGain != null && (
<div className="rounded-md bg-gray-50 p-4">
<p className="text-2xl font-bold text-gray-900"> {route.elevationGain} m</p>
<p className="text-sm text-gray-500">Ascent</p>
</div>
)}
{route.elevationLoss != null && (
<div className="rounded-md bg-gray-50 p-4">
<p className="text-2xl font-bold text-gray-900"> {route.elevationLoss} m</p>
<p className="text-sm text-gray-500">Descent</p>
</div>
)}
</div>
{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>
)}
{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} />
</div>
)}
{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>
);
}