From 40e541fcce502ee6fe2d2d6519dada6d67bd2828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 22:33:37 +0100 Subject: [PATCH 1/7] Implement Planner-Journal handoff (Group 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT-based handoff between Journal and Planner: Journal side: - JWT token generation scoped to route_id with 7-day expiry (jose) - "Edit in Planner" button on route detail page — generates JWT, redirects to Planner /new with callback URL, token, and GPX - Callback endpoint (POST /api/routes/:id/callback) validates JWT and creates new route version from received GPX Planner side: - /new route accepts callback, token, returnUrl, gpx params - Creates session with callback metadata, initializes with GPX - "Save to Journal" button POSTs GPX with Bearer token to callback - "Return to Journal" link shown after successful save All 6 Group 9 tasks complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/jwt.server.ts | 27 ++++++ apps/journal/app/routes.ts | 1 + .../app/routes/api.routes.$id.callback.ts | 52 +++++++++++ apps/journal/app/routes/routes.$id.tsx | 33 +++++++ apps/journal/package.json | 1 + .../app/components/SaveToJournalButton.tsx | 93 +++++++++++++++++++ apps/planner/app/components/SessionView.tsx | 18 +++- apps/planner/app/routes.ts | 1 + apps/planner/app/routes/new.tsx | 36 +++++++ apps/planner/app/routes/session.$id.tsx | 16 +++- openspec/changes/phase-1-mvp/tasks.md | 12 +-- pnpm-lock.yaml | 8 ++ 12 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 apps/journal/app/lib/jwt.server.ts create mode 100644 apps/journal/app/routes/api.routes.$id.callback.ts create mode 100644 apps/planner/app/components/SaveToJournalButton.tsx create mode 100644 apps/planner/app/routes/new.tsx diff --git a/apps/journal/app/lib/jwt.server.ts b/apps/journal/app/lib/jwt.server.ts new file mode 100644 index 0000000..5bf62a4 --- /dev/null +++ b/apps/journal/app/lib/jwt.server.ts @@ -0,0 +1,27 @@ +import { SignJWT, jwtVerify } from "jose"; + +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production", +); + +const ISSUER = process.env.ORIGIN ?? "http://localhost:3000"; + +export async function createRouteToken(routeId: string, permissions: string[] = ["read", "write"]): Promise { + return new SignJWT({ route_id: routeId, permissions }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(ISSUER) + .setExpirationTime("7d") + .setIssuedAt() + .sign(JWT_SECRET); +} + +export async function verifyRouteToken(token: string): Promise<{ routeId: string; permissions: string[] }> { + const { payload } = await jwtVerify(token, JWT_SECRET, { + issuer: ISSUER, + }); + + return { + routeId: payload.route_id as string, + permissions: payload.permissions as string[], + }; +} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 8f69788..eb4200b 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -12,5 +12,6 @@ export default [ route("routes/new", "routes/routes.new.tsx"), route("routes/:id", "routes/routes.$id.tsx"), route("routes/:id/edit", "routes/routes.$id.edit.tsx"), + route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"), route("users/:username", "routes/users.$username.tsx"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/api.routes.$id.callback.ts b/apps/journal/app/routes/api.routes.$id.callback.ts new file mode 100644 index 0000000..e656e38 --- /dev/null +++ b/apps/journal/app/routes/api.routes.$id.callback.ts @@ -0,0 +1,52 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.routes.$id.callback"; +import { verifyRouteToken } from "~/lib/jwt.server"; +import { updateRoute, getRoute } from "~/lib/routes.server"; + +export async function action({ params, request }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + + // Verify JWT token from Authorization header or body + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + + if (!token) { + return data({ error: "Missing authorization token" }, { status: 401 }); + } + + try { + const { routeId, permissions } = await verifyRouteToken(token); + + // Verify token is for this route + if (routeId !== params.id) { + return data({ error: "Token not valid for this route" }, { status: 403 }); + } + + if (!permissions.includes("write")) { + return data({ error: "Token does not have write permission" }, { status: 403 }); + } + + // Get route to verify it exists + const route = await getRoute(params.id); + if (!route) { + return data({ error: "Route not found" }, { status: 404 }); + } + + // Parse GPX from request body + const body = await request.json(); + const { gpx } = body as { gpx: string }; + + if (!gpx) { + return data({ error: "Missing GPX data" }, { status: 400 }); + } + + // Update route with new GPX (creates new version) + await updateRoute(params.id, route.ownerId, { gpx }); + + return data({ success: true, routeId: params.id }); + } catch (e) { + return data({ error: (e as Error).message }, { status: 401 }); + } +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 75a4ee9..dc83feb 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -2,6 +2,7 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.$id"; import { getSessionUser } from "~/lib/auth.server"; import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; +import { createRouteToken } from "~/lib/jwt.server"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -73,6 +74,29 @@ export async function action({ params, request }: Route.ActionArgs) { }); } + if (intent === "edit-in-planner") { + const route = await getRouteWithVersions(params.id); + if (!route) return data({ error: "Route not found" }, { status: 404 }); + + const token = await createRouteToken(params.id); + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + const callbackUrl = `${origin}/api/routes/${params.id}/callback`; + const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001"; + + const plannerParams = new URLSearchParams({ + callback: callbackUrl, + token, + returnUrl: `${origin}/routes/${params.id}`, + }); + + // If route has GPX, include it + if (route.gpx) { + plannerParams.set("gpx", encodeURIComponent(route.gpx)); + } + + return redirect(`${plannerUrl}/new?${plannerParams}`); + } + return data({ error: "Unknown action" }, { status: 400 }); } @@ -95,6 +119,15 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {isOwner && (
+
+ + +
(null); + + const handleSave = useCallback(async () => { + setSaving(true); + setError(null); + + try { + // Build GPX from current Yjs state + const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ + lat: yMap.get("lat") as number, + lon: yMap.get("lon") as number, + name: yMap.get("name") as string | undefined, + })); + + let tracks: TrackPoint[][] = []; + const geojsonStr = yjs.routeData.get("geojson") as string | undefined; + if (geojsonStr) { + try { + const geojson = JSON.parse(geojsonStr); + const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; + if (coords.length > 0) { + tracks = [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; + } + } catch { /* invalid geojson */ } + } + + const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); + + // POST to Journal callback + const response = await fetch(callbackUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${callbackToken}`, + }, + body: JSON.stringify({ gpx }), + }); + + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error ?? "Save failed"); + } + + setSaved(true); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }, [yjs, callbackUrl, callbackToken]); + + if (saved) { + return ( +
+ Saved! + {returnUrl && ( + + Return to Journal + + )} +
+ ); + } + + return ( +
+ + {error && {error}} +
+ ); +} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 17bee15..93bf228 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -3,6 +3,7 @@ import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; +import { SaveToJournalButton } from "~/components/SaveToJournalButton"; import { YjsDebugPanel } from "~/components/YjsDebugPanel"; const PlannerMap = lazy(() => @@ -15,7 +16,14 @@ const ElevationChart = lazy(() => import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })), ); -export function SessionView({ sessionId }: { sessionId: string }) { +interface SessionViewProps { + sessionId: string; + callbackUrl?: string; + callbackToken?: string; + returnUrl?: string; +} + +export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl }: SessionViewProps) { const yjs = useYjs(sessionId); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); @@ -40,6 +48,14 @@ export function SessionView({ sessionId }: { sessionId: string }) {
+ {callbackUrl && callbackToken && ( + + )} {computing && ( Computing route... diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index 584332b..1abbf77 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), + route("new", "routes/new.tsx"), route("api/sessions", "routes/api.sessions.ts"), route("api/route", "routes/api.route.ts"), route("session/:id", "routes/session.$id.tsx"), diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx new file mode 100644 index 0000000..7b11d2c --- /dev/null +++ b/apps/planner/app/routes/new.tsx @@ -0,0 +1,36 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/new"; +import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions"; +import { parseGpx } from "@trails-cool/gpx"; + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const callbackUrl = url.searchParams.get("callback"); + const token = url.searchParams.get("token"); + const returnUrl = url.searchParams.get("returnUrl"); + const gpxEncoded = url.searchParams.get("gpx"); + + // Create a session with callback info + const session = await createSession({ + callbackUrl: callbackUrl ?? undefined, + callbackToken: token ?? undefined, + }); + + // Initialize with GPX waypoints if provided + if (gpxEncoded) { + try { + const gpx = decodeURIComponent(gpxEncoded); + const gpxData = parseGpx(gpx); + initializeSessionWithWaypoints(session.id, gpxData.waypoints); + } catch { + // Continue with empty session if GPX is invalid + } + } + + // Store returnUrl in the session URL for later + const sessionUrl = returnUrl + ? `/session/${session.id}?returnUrl=${encodeURIComponent(returnUrl)}` + : `/session/${session.id}`; + + return redirect(sessionUrl); +} diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index e99de06..fca4983 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -1,4 +1,4 @@ -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import type { Route } from "./+types/session.$id"; import { getSession } from "~/lib/sessions"; import { data } from "react-router"; @@ -20,12 +20,15 @@ export async function loader({ params }: Route.LoaderArgs) { } return data({ sessionId: session.id, - hasCallback: !!session.callbackUrl, + callbackUrl: session.callbackUrl ?? null, + callbackToken: session.callbackToken ?? null, }); } -export default function SessionPage() { +export default function SessionPage({ loaderData }: Route.ComponentProps) { const { id } = useParams(); + const [searchParams] = useSearchParams(); + const returnUrl = searchParams.get("returnUrl") ?? undefined; return (
@@ -44,7 +47,12 @@ export default function SessionPage() {
} > - + )} diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index 5411fb1..d86ccec 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -89,12 +89,12 @@ ## 9. Planner-Journal Handoff -- [ ] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry) -- [ ] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX) -- [ ] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata) -- [ ] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL) -- [ ] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version) -- [ ] 9.6 Implement "Return to Journal" link after successful save +- [x] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry) +- [x] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX) +- [x] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata) +- [x] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL) +- [x] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version) +- [x] 9.6 Implement "Return to Journal" link after successful save ## 10. Journal — Activity Feed diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfd3763..04cd0cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: isbot: specifier: ^5.1.0 version: 5.1.36 + jose: + specifier: ^6.2.2 + version: 6.2.2 react: specifier: 'catalog:' version: 19.2.4 @@ -2177,6 +2180,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4624,6 +4630,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} jsdom@29.0.1: From 3567dac259057f9d8979067f5fa278546d363c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 07:29:23 +0100 Subject: [PATCH 2/7] Allow repeated saves to Journal from Planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save button stays visible after saving — users can keep editing and save again. "Saved!" shows as inline confirmation, "Return to Journal" link appears alongside the save button. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/SaveToJournalButton.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index f13ac4c..113bb4e 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -65,19 +65,6 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } }, [yjs, callbackUrl, callbackToken]); - if (saved) { - return ( -
- Saved! - {returnUrl && ( - - Return to Journal - - )} -
- ); - } - return (
+ {saved && Saved!} {error && {error}} + {saved && returnUrl && ( + + Return to Journal + + )}
); } From 8559b89be6b8e5f7cc2fcb9790209a15f307f4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 09:41:06 +0100 Subject: [PATCH 3/7] Fix Edit in Planner: use client-side redirect for cross-origin React Router's redirect() can't navigate to a different origin (localhost:3001). Return the Planner URL as data and redirect via window.location.href on the client. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes/routes.$id.tsx | 35 ++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index dc83feb..13bf944 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from "react"; import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.$id"; import { getSessionUser } from "~/lib/auth.server"; @@ -94,7 +95,7 @@ export async function action({ params, request }: Route.ActionArgs) { plannerParams.set("gpx", encodeURIComponent(route.gpx)); } - return redirect(`${plannerUrl}/new?${plannerParams}`); + return data({ redirect: `${plannerUrl}/new?${plannerParams}` }); } return data({ error: "Unknown action" }, { status: 400 }); @@ -107,6 +108,22 @@ export function meta({ data: loaderData }: Route.MetaArgs) { export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { const { route, versions, isOwner } = loaderData; + const [editLoading, setEditLoading] = useState(false); + + const handleEditInPlanner = useCallback(async () => { + setEditLoading(true); + try { + const formData = new FormData(); + formData.set("intent", "edit-in-planner"); + const resp = await fetch(`/routes/${route.id}`, { method: "POST", body: formData }); + const result = await resp.json(); + if (result.redirect) { + window.location.href = result.redirect; + } + } finally { + setEditLoading(false); + } + }, [route.id]); return (
@@ -119,15 +136,13 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
{isOwner && (
-
- - -
+ Date: Tue, 24 Mar 2026 09:53:01 +0100 Subject: [PATCH 4/7] Fix Edit in Planner: create session via API, avoid URL size limit GPX data was being encoded in URL params, causing HTTP 431 (Request Header Fields Too Large). Now the Journal creates a Planner session via POST to /api/sessions with GPX in the request body, then redirects to the session URL (154 chars vs 100KB+). Also moved edit-in-planner logic to dedicated API route for proper JSON response handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes.ts | 1 + .../routes/api.routes.$id.edit-in-planner.ts | 41 +++++++++++++++++++ apps/journal/app/routes/routes.$id.tsx | 32 ++------------- 3 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 apps/journal/app/routes/api.routes.$id.edit-in-planner.ts diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index eb4200b..58bccc4 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -13,5 +13,6 @@ export default [ route("routes/:id", "routes/routes.$id.tsx"), route("routes/:id/edit", "routes/routes.$id.edit.tsx"), route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"), + route("api/routes/:id/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"), route("users/:username", "routes/users.$username.tsx"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts new file mode 100644 index 0000000..212e06c --- /dev/null +++ b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts @@ -0,0 +1,41 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.routes.$id.edit-in-planner"; +import { getSessionUser } from "~/lib/auth.server"; +import { getRouteWithVersions } from "~/lib/routes.server"; +import { createRouteToken } from "~/lib/jwt.server"; + +export async function action({ params, request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return data({ error: "Not authenticated" }, { status: 401 }); + + const route = await getRouteWithVersions(params.id); + if (!route) return data({ error: "Route not found" }, { status: 404 }); + if (route.ownerId !== user.id) return data({ error: "Not authorized" }, { status: 403 }); + + const token = await createRouteToken(params.id); + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + const callbackUrl = `${origin}/api/routes/${params.id}/callback`; + const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001"; + const returnUrl = `${origin}/routes/${params.id}`; + + // Create Planner session via API (POST body, not URL params) + const sessionResp = await fetch(`${plannerUrl}/api/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + callbackUrl, + callbackToken: token, + gpx: route.gpx ?? undefined, + }), + }); + + if (!sessionResp.ok) { + return data({ error: "Failed to create Planner session" }, { status: 502 }); + } + + const session = (await sessionResp.json()) as { url: string }; + + return data({ + url: `${plannerUrl}${session.url}?returnUrl=${encodeURIComponent(returnUrl)}`, + }); +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 13bf944..281fe60 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -3,7 +3,6 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.$id"; import { getSessionUser } from "~/lib/auth.server"; import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; -import { createRouteToken } from "~/lib/jwt.server"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -75,29 +74,6 @@ export async function action({ params, request }: Route.ActionArgs) { }); } - if (intent === "edit-in-planner") { - const route = await getRouteWithVersions(params.id); - if (!route) return data({ error: "Route not found" }, { status: 404 }); - - const token = await createRouteToken(params.id); - const origin = process.env.ORIGIN ?? "http://localhost:3000"; - const callbackUrl = `${origin}/api/routes/${params.id}/callback`; - const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001"; - - const plannerParams = new URLSearchParams({ - callback: callbackUrl, - token, - returnUrl: `${origin}/routes/${params.id}`, - }); - - // If route has GPX, include it - if (route.gpx) { - plannerParams.set("gpx", encodeURIComponent(route.gpx)); - } - - return data({ redirect: `${plannerUrl}/new?${plannerParams}` }); - } - return data({ error: "Unknown action" }, { status: 400 }); } @@ -113,12 +89,10 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { const handleEditInPlanner = useCallback(async () => { setEditLoading(true); try { - const formData = new FormData(); - formData.set("intent", "edit-in-planner"); - const resp = await fetch(`/routes/${route.id}`, { method: "POST", body: formData }); + const resp = await fetch(`/api/routes/${route.id}/edit-in-planner`, { method: "POST" }); const result = await resp.json(); - if (result.redirect) { - window.location.href = result.redirect; + if (result.url) { + window.location.href = result.url; } } finally { setEditLoading(false); From 2e2dbe05c1a44c89bdc391d90033cdd67b35457c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 20:57:59 +0100 Subject: [PATCH 5/7] Fix GPX export: use API route with direct download link React Router page actions wrap responses, preventing raw file downloads. Moved GPX export to GET /api/routes/:id/gpx and changed the button to a plain link. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes.ts | 1 + apps/journal/app/routes/api.routes.$id.gpx.ts | 16 +++++++++++ apps/journal/app/routes/routes.$id.tsx | 27 +++++-------------- 3 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 apps/journal/app/routes/api.routes.$id.gpx.ts diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 58bccc4..66acaab 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -14,5 +14,6 @@ export default [ route("routes/:id/edit", "routes/routes.$id.edit.tsx"), route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"), route("api/routes/:id/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"), + route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"), route("users/:username", "routes/users.$username.tsx"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/api.routes.$id.gpx.ts b/apps/journal/app/routes/api.routes.$id.gpx.ts new file mode 100644 index 0000000..f3b721d --- /dev/null +++ b/apps/journal/app/routes/api.routes.$id.gpx.ts @@ -0,0 +1,16 @@ +import type { Route } from "./+types/api.routes.$id.gpx"; +import { getRouteWithVersions } from "~/lib/routes.server"; + +export async function loader({ params }: Route.LoaderArgs) { + const route = await getRouteWithVersions(params.id); + if (!route?.gpx) { + return new Response("No GPX data", { status: 404 }); + } + + return new Response(route.gpx, { + headers: { + "Content-Type": "application/gpx+xml", + "Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`, + }, + }); +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 281fe60..378d747 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -62,18 +62,6 @@ export async function action({ params, request }: Route.ActionArgs) { return redirect(`/routes/${params.id}`); } - if (intent === "export-gpx") { - const route = await getRouteWithVersions(params.id); - if (!route?.gpx) return data({ error: "No GPX data" }, { status: 400 }); - - return new Response(route.gpx, { - headers: { - "Content-Type": "application/gpx+xml", - "Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`, - }, - }); - } - return data({ error: "Unknown action" }, { status: 400 }); } @@ -123,16 +111,15 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { > Edit -
- - -
+ + )}
)}
From 14b179bb0c5b3b576dabf31dc5d9d3338e9a1aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 21:12:57 +0100 Subject: [PATCH 6/7] Fix waypoint loading in Edit in Planner flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues fixed: 1. GPX parser used browser DOMParser which doesn't exist in Node/Vite SSR. Added async parseGpxAsync() using linkedom for server-side parsing. 2. Server-side session initialization stored waypoints in a Yjs doc instance separate from the Vite WebSocket plugin's doc store. Moved waypoint initialization to client-side: API returns parsed waypoints, client adds them to Yjs after sync. 3. GPX was encoded in URL params causing HTTP 431. Now the Journal creates a Planner session via API (POST body), and only passes compact waypoint coordinates in URL params. Verified: Journal route → Edit in Planner → 4 waypoints loaded, route computed (79.3km), elevation profile, Save to Journal ready. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/api.routes.$id.edit-in-planner.ts | 13 +- apps/planner/app/components/SessionView.tsx | 5 +- apps/planner/app/lib/use-yjs.ts | 26 +++- apps/planner/app/routes/api.sessions.ts | 10 +- apps/planner/app/routes/session.$id.tsx | 6 + package.json | 1 + packages/gpx/src/index.ts | 2 +- packages/gpx/src/parse.ts | 34 ++++- pnpm-lock.yaml | 117 ++++++++++++++++++ 9 files changed, 201 insertions(+), 13 deletions(-) diff --git a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts index 212e06c..5bd38cf 100644 --- a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts +++ b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts @@ -33,9 +33,18 @@ export async function action({ params, request }: Route.ActionArgs) { return data({ error: "Failed to create Planner session" }, { status: 502 }); } - const session = (await sessionResp.json()) as { url: string }; + const session = (await sessionResp.json()) as { + url: string; + initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; + }; + + // Encode waypoints in URL params (small — just coordinates, not full GPX) + const urlParams = new URLSearchParams({ returnUrl }); + if (session.initialWaypoints?.length) { + urlParams.set("waypoints", JSON.stringify(session.initialWaypoints)); + } return data({ - url: `${plannerUrl}${session.url}?returnUrl=${encodeURIComponent(returnUrl)}`, + url: `${plannerUrl}${session.url}?${urlParams}`, }); } diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 93bf228..779e4f1 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -21,10 +21,11 @@ interface SessionViewProps { callbackUrl?: string; callbackToken?: string; returnUrl?: string; + initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; } -export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl }: SessionViewProps) { - const yjs = useYjs(sessionId); +export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) { + const yjs = useYjs(sessionId, initialWaypoints); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index 066c7fb..e7f4d67 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -36,10 +36,14 @@ export interface YjsState { connected: boolean; } -export function useYjs(sessionId: string): YjsState | null { +export function useYjs( + sessionId: string, + initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>, +): YjsState | null { const [state, setState] = useState(null); const providerRef = useRef(null); const docRef = useRef(null); + const initializedWaypoints = useRef(false); useEffect(() => { const doc = new Y.Doc(); @@ -52,10 +56,28 @@ export function useYjs(sessionId: string): YjsState | null { const waypoints = doc.getArray>("waypoints"); const routeData = doc.getMap("routeData"); - // Use persistent identity const { color, name } = getOrCreateUserIdentity(); provider.awareness.setLocalStateField("user", { color, name }); + // Initialize waypoints once after first sync + if (initialWaypoints?.length && !initializedWaypoints.current) { + (provider as unknown as { on(event: string, cb: () => void): void }).on("synced", () => { + // Only add if the doc is empty (avoid duplicating on reconnect) + if (waypoints.length === 0 && !initializedWaypoints.current) { + initializedWaypoints.current = true; + doc.transact(() => { + for (const wp of initialWaypoints) { + const yMap = new Y.Map(); + yMap.set("lat", wp.lat); + yMap.set("lon", wp.lon); + if (wp.name) yMap.set("name", wp.name); + waypoints.push([yMap]); + } + }); + } + }); + } + const updateState = (connected: boolean) => { setState({ doc, diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 8fc4eb9..66c437b 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -1,7 +1,7 @@ import { data } from "react-router"; import type { Route } from "./+types/api.sessions"; -import { createSession, initializeSessionWithWaypoints, listSessions } from "~/lib/sessions"; -import { parseGpx } from "@trails-cool/gpx"; +import { createSession, listSessions } from "~/lib/sessions"; +import { parseGpxAsync } from "@trails-cool/gpx"; export async function action({ request }: Route.ActionArgs) { if (request.method !== "POST") { @@ -17,10 +17,11 @@ export async function action({ request }: Route.ActionArgs) { const session = await createSession({ callbackUrl, callbackToken }); + let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined; if (gpx) { try { - const gpxData = parseGpx(gpx); - initializeSessionWithWaypoints(session.id, gpxData.waypoints); + const gpxData = await parseGpxAsync(gpx); + initialWaypoints = gpxData.waypoints; } catch (_e) { // Continue with empty session if GPX is invalid } @@ -30,6 +31,7 @@ export async function action({ request }: Route.ActionArgs) { { sessionId: session.id, url: `/session/${session.id}`, + initialWaypoints, }, { status: 201 }, ); diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index fca4983..101c881 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -29,6 +29,11 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { const { id } = useParams(); const [searchParams] = useSearchParams(); const returnUrl = searchParams.get("returnUrl") ?? undefined; + const waypointsParam = searchParams.get("waypoints"); + let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined; + if (waypointsParam) { + try { initialWaypoints = JSON.parse(waypointsParam); } catch { /* ignore */ } + } return (
@@ -52,6 +57,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { callbackUrl={loaderData.callbackUrl ?? undefined} callbackToken={loaderData.callbackToken ?? undefined} returnUrl={returnUrl} + initialWaypoints={initialWaypoints} /> )} diff --git a/package.json b/package.json index 142ae2d..4f7f882 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.0.1", "leaflet": "^1.9.4", + "linkedom": "^0.18.12", "playwright": "^1.58.2", "postgres": "^3.4.8", "prettier": "^3.8.1", diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index 6dc0a8d..46ec02a 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,3 +1,3 @@ -export { parseGpx } from "./parse"; +export { parseGpx, parseGpxAsync } from "./parse"; export { generateGpx } from "./generate"; export type { GpxData, TrackPoint, ElevationProfile } from "./types"; diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index d1069ea..b9cb5dc 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -4,9 +4,39 @@ import type { GpxData, TrackPoint, ElevationProfile } from "./types"; /** * Parse a GPX XML string into structured data. */ +let _LinkedDOMParser: typeof DOMParser | null = null; + +async function getDOMParser(): Promise { + if (typeof DOMParser !== "undefined") return DOMParser; + if (!_LinkedDOMParser) { + const linkedom = await import("linkedom"); + _LinkedDOMParser = linkedom.DOMParser as unknown as typeof DOMParser; + } + return _LinkedDOMParser; +} + export function parseGpx(xml: string): GpxData { - const parser = new DOMParser(); - const doc = parser.parseFromString(xml, "application/xml"); + // Synchronous path for browser + if (typeof DOMParser !== "undefined") { + return parseGpxWithParser(new DOMParser(), xml); + } + // Fallback: try linkedom synchronously (works in Node with top-level await or CJS) + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DOMParser: LP } = require("linkedom"); + return parseGpxWithParser(new LP() as unknown as DOMParser, xml); + } catch { + throw new Error("DOMParser not available — install linkedom for Node.js"); + } +} + +export async function parseGpxAsync(xml: string): Promise { + const Parser = await getDOMParser(); + return parseGpxWithParser(new Parser(), xml); +} + +function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { + const doc = parser.parseFromString(xml, "application/xml") as unknown as Document; const parserError = doc.querySelector("parsererror"); if (parserError) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04cd0cd..7423955 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + linkedom: + specifier: ^0.18.12 + version: 0.18.12 playwright: specifier: ^1.58.2 version: 1.58.2 @@ -1545,6 +1548,9 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@5.0.4: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} @@ -1621,13 +1627,23 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1688,6 +1704,19 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -1897,10 +1926,18 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2110,9 +2147,15 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2299,6 +2342,15 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2392,6 +2444,9 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2771,6 +2826,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -4132,6 +4190,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -4206,13 +4266,25 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} + cssom@0.5.0: {} + csstype@3.2.3: {} data-urls@7.0.0: @@ -4248,6 +4320,24 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -4284,8 +4374,12 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4576,10 +4670,19 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@3.0.3: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4734,6 +4837,14 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linkedom@0.18.12: + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4802,6 +4913,10 @@ snapshots: node-releases@2.0.36: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-inspect@1.13.4: {} obug@2.1.1: {} @@ -5179,6 +5294,8 @@ snapshots: typescript@5.9.3: {} + uhyphen@0.2.0: {} + undici-types@7.18.2: {} undici@7.24.5: {} From 545469eae0ae7f24ea7ef68909c092bf32593d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 21:21:16 +0100 Subject: [PATCH 7/7] Fix SSR hydration mismatch for dates Created ClientDate component that renders dates client-only via useEffect to prevent server/client locale mismatches. Server renders with de-DE as default, client updates to user's locale. Applied to route detail (version dates) and route list (updated dates). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/components/ClientDate.tsx | 15 +++++++++++++++ apps/journal/app/routes/routes.$id.tsx | 3 ++- apps/journal/app/routes/routes._index.tsx | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 apps/journal/app/components/ClientDate.tsx diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx new file mode 100644 index 0000000..e2fcf06 --- /dev/null +++ b/apps/journal/app/components/ClientDate.tsx @@ -0,0 +1,15 @@ +import { useState, useEffect } from "react"; + +/** + * Renders a date only on the client to avoid SSR hydration mismatches. + * Server renders empty, client fills in with user's locale. + */ +export function ClientDate({ iso }: { iso: string }) { + const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US")); + + useEffect(() => { + setFormatted(new Date(iso).toLocaleDateString()); + }, [iso]); + + return ; +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 378d747..6e90df0 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -3,6 +3,7 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes.$id"; import { getSessionUser } from "~/lib/auth.server"; import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; +import { ClientDate } from "~/components/ClientDate"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -156,7 +157,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
v{v.version} - {new Date(v.createdAt).toLocaleDateString()} +
{v.changeDescription && ( diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index 59a30c5..506f07f 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -2,6 +2,7 @@ import { data, redirect } from "react-router"; import type { Route } from "./+types/routes._index"; import { getSessionUser } from "~/lib/auth.server"; import { listRoutes } from "~/lib/routes.server"; +import { ClientDate } from "~/components/ClientDate"; export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); @@ -53,7 +54,7 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) {

{route.name}

- {new Date(route.updatedAt).toLocaleDateString()} +