From acccfcc53b5d9c2c39937b941048e60a46a617eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 16:39:35 +0100 Subject: [PATCH 01/15] Enable Grafana OAuth email lookup to reuse existing users Without this, GitHub OAuth creates a new Grafana user on every login instead of linking to the existing account by email. Co-Authored-By: Claude Opus 4.6 (1M context) --- infrastructure/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index c97b378..3f59adf 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -154,6 +154,7 @@ services: GF_AUTH_GITHUB_CLIENT_SECRET: ${GF_AUTH_GITHUB_CLIENT_SECRET:-} GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool GF_AUTH_GITHUB_SCOPES: user:email,read:org + GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP: "true" GF_AUTH_DISABLE_LOGIN_FORM: "true" GF_SMTP_ENABLED: "true" GF_SMTP_HOST: ${GF_SMTP_HOST:-} From b4c3f97296f9c4cf886e6cfd55c1b114e452f697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 17:05:02 +0100 Subject: [PATCH 02/15] Populate PostGIS geom column from GPX track data Extract coordinates from parsed GPX tracks and store as PostGIS LineString geometry via ST_GeomFromGeoJSON. Applied to: - createRoute / updateRoute (routes.server.ts) - createActivity / createRouteFromActivity (activities.server.ts) Adds lineStringFromCoords() helper to the journal schema that builds the SQL expression from [lon, lat] coordinate pairs. Unblocks spatial queries (e.g. route discovery via ST_Intersects). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/activities.server.ts | 20 +++++++++++++++++++- apps/journal/app/lib/routes.server.ts | 11 +++++++++-- packages/db/src/schema/journal.ts | 18 +++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index e51c7b4..ecb9b1a 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { activities, routes } from "@trails-cool/db/schema/journal"; +import { activities, routes, lineStringFromCoords } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; +import type { SQL } from "drizzle-orm"; export interface ActivityInput { name: string; @@ -19,6 +20,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) { let elevationGain: number | null = null; let elevationLoss: number | null = null; let startedAt: Date | null = null; + let geom: SQL | null = null; if (input.gpx) { try { @@ -28,6 +30,9 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain = gpxData.elevation.gain; elevationLoss = gpxData.elevation.loss; + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length >= 2) geom = lineStringFromCoords(coords); + // Try to extract start time from first track point if (gpxData.tracks[0]?.[0]?.time) { startedAt = new Date(gpxData.tracks[0][0].time); @@ -48,6 +53,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain, elevationLoss, startedAt, + geom, }); return id; @@ -82,6 +88,17 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin if (!activity?.gpx) return null; const routeId = randomUUID(); + let routeGeom: SQL | null = null; + if (activity.gpx) { + try { + const gpxData = await parseGpxAsync(activity.gpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length >= 2) routeGeom = lineStringFromCoords(coords); + } catch { + // Continue without geom + } + } + await db.insert(routes).values({ id: routeId, ownerId, @@ -91,6 +108,7 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin distance: activity.distance, elevationGain: activity.elevationGain, elevationLoss: activity.elevationLoss, + geom: routeGeom, }); // Link the activity to the new route diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 1d36af3..2b0efe4 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { routes, routeVersions } from "@trails-cool/db/schema/journal"; +import { routes, routeVersions, lineStringFromCoords } from "@trails-cool/db/schema/journal"; import { parseGpx } from "@trails-cool/gpx"; +import type { SQL } from "drizzle-orm"; export interface RouteInput { name: string; @@ -18,12 +19,14 @@ export async function createRoute(ownerId: string, input: RouteInput) { let distance: number | null = null; let elevationGain: number | null = null; let elevationLoss: number | null = null; + let geom: SQL | null = null; if (input.gpx) { const stats = computeRouteStats(input.gpx); distance = stats.distance; elevationGain = stats.elevationGain; elevationLoss = stats.elevationLoss; + geom = stats.geom; } await db.insert(routes).values({ @@ -36,6 +39,7 @@ export async function createRoute(ownerId: string, input: RouteInput) { distance, elevationGain, elevationLoss, + geom, }); // Create initial version if GPX provided @@ -99,6 +103,7 @@ export async function updateRoute( updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; + updateData.geom = stats.geom; // Get next version number const existingVersions = await db @@ -136,6 +141,7 @@ export async function deleteRoute(id: string, ownerId: string) { function computeRouteStats(gpxString: string) { try { const gpxData = parseGpx(gpxString); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); return { distance: Math.round( gpxData.elevation.profile.length > 0 @@ -144,8 +150,9 @@ function computeRouteStats(gpxString: string) { ), elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, + geom: coords.length >= 2 ? lineStringFromCoords(coords) : null, }; } catch { - return { distance: null, elevationGain: null, elevationLoss: null }; + return { distance: null, elevationGain: null, elevationLoss: null, geom: null }; } } diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 788dcad..0454d2d 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -7,6 +7,7 @@ import { jsonb, customType, } from "drizzle-orm/pg-core"; +import { sql, type SQL } from "drizzle-orm"; const bytea = customType<{ data: Buffer }>({ dataType() { @@ -14,12 +15,27 @@ const bytea = customType<{ data: Buffer }>({ }, }); -const lineString = customType<{ data: string }>({ +const lineString = customType<{ data: string | SQL }>({ dataType() { return "geometry(LineString, 4326)"; }, + toDriver(value) { + return value; + }, }); +/** + * Build a SQL expression to create a PostGIS LineString from coordinate pairs. + * Coordinates are [lon, lat] (GeoJSON order). + */ +export function lineStringFromCoords(coords: [number, number][]): SQL { + const geojson = JSON.stringify({ + type: "LineString", + coordinates: coords, + }); + return sql`ST_GeomFromGeoJSON(${geojson})`; +} + export const journalSchema = pgSchema("journal"); export const users = journalSchema.table("users", { From 6ed828ef9c0c2dc2199edbb416273f76883a8856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 17:32:07 +0100 Subject: [PATCH 03/15] Fix PostGIS geom population: use parseGpxAsync + raw SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial approach (#150) didn't work because: 1. routes.server.ts used parseGpx (sync, needs browser DOMParser) instead of parseGpxAsync (uses linkedom on Node.js) — GPX parsing silently failed, so stats AND geom were never populated 2. Drizzle's customType doesn't pass SQL expressions through toDriver Fixes: - Switch to parseGpxAsync for all server-side GPX parsing - Use db.execute() with raw ST_GeomFromGeoJSON SQL after insert - Remove unused lineStringFromCoords helper from schema - Share setGeomFromGpx between routes and activities - Add unit tests for GPX→GeoJSON coordinate extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/activities.server.ts | 28 +++------- apps/journal/app/lib/routes.server.ts | 48 ++++++++++++------ packages/db/src/schema/journal.ts | 18 +------ packages/gpx/src/geom.test.ts | 62 +++++++++++++++++++++++ 4 files changed, 104 insertions(+), 52 deletions(-) create mode 100644 packages/gpx/src/geom.test.ts diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index ecb9b1a..4e48b8c 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { activities, routes, lineStringFromCoords } from "@trails-cool/db/schema/journal"; +import { activities, routes } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; -import type { SQL } from "drizzle-orm"; +import { setGeomFromGpx } from "./routes.server.ts"; export interface ActivityInput { name: string; @@ -20,8 +20,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) { let elevationGain: number | null = null; let elevationLoss: number | null = null; let startedAt: Date | null = null; - let geom: SQL | null = null; - if (input.gpx) { try { const gpxData = await parseGpxAsync(input.gpx); @@ -30,9 +28,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain = gpxData.elevation.gain; elevationLoss = gpxData.elevation.loss; - const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); - if (coords.length >= 2) geom = lineStringFromCoords(coords); - // Try to extract start time from first track point if (gpxData.tracks[0]?.[0]?.time) { startedAt = new Date(gpxData.tracks[0][0].time); @@ -53,9 +48,12 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain, elevationLoss, startedAt, - geom, }); + if (input.gpx) { + await setGeomFromGpx(id, "activities", input.gpx); + } + return id; } @@ -88,17 +86,6 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin if (!activity?.gpx) return null; const routeId = randomUUID(); - let routeGeom: SQL | null = null; - if (activity.gpx) { - try { - const gpxData = await parseGpxAsync(activity.gpx); - const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); - if (coords.length >= 2) routeGeom = lineStringFromCoords(coords); - } catch { - // Continue without geom - } - } - await db.insert(routes).values({ id: routeId, ownerId, @@ -108,9 +95,10 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin distance: activity.distance, elevationGain: activity.elevationGain, elevationLoss: activity.elevationLoss, - geom: routeGeom, }); + await setGeomFromGpx(routeId, "routes", activity.gpx); + // Link the activity to the new route await db .update(activities) diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 2b0efe4..c9f1b45 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { routes, routeVersions, lineStringFromCoords } from "@trails-cool/db/schema/journal"; -import { parseGpx } from "@trails-cool/gpx"; -import type { SQL } from "drizzle-orm"; +import { routes, routeVersions } from "@trails-cool/db/schema/journal"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { sql } from "drizzle-orm"; export interface RouteInput { name: string; @@ -19,14 +19,11 @@ export async function createRoute(ownerId: string, input: RouteInput) { let distance: number | null = null; let elevationGain: number | null = null; let elevationLoss: number | null = null; - let geom: SQL | null = null; - if (input.gpx) { - const stats = computeRouteStats(input.gpx); + const stats = await computeRouteStats(input.gpx); distance = stats.distance; elevationGain = stats.elevationGain; elevationLoss = stats.elevationLoss; - geom = stats.geom; } await db.insert(routes).values({ @@ -39,9 +36,12 @@ export async function createRoute(ownerId: string, input: RouteInput) { distance, elevationGain, elevationLoss, - geom, }); + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } + // Create initial version if GPX provided if (input.gpx) { await db.insert(routeVersions).values({ @@ -99,11 +99,10 @@ export async function updateRoute( if (input.gpx) { updateData.gpx = input.gpx; - const stats = computeRouteStats(input.gpx); + const stats = await computeRouteStats(input.gpx); updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; - updateData.geom = stats.geom; // Get next version number const existingVersions = await db @@ -127,6 +126,10 @@ export async function updateRoute( .update(routes) .set(updateData) .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); + + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } } export async function deleteRoute(id: string, ownerId: string) { @@ -138,10 +141,9 @@ export async function deleteRoute(id: string, ownerId: string) { return result.length > 0; } -function computeRouteStats(gpxString: string) { +async function computeRouteStats(gpxString: string) { try { - const gpxData = parseGpx(gpxString); - const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + const gpxData = await parseGpxAsync(gpxString); return { distance: Math.round( gpxData.elevation.profile.length > 0 @@ -150,9 +152,25 @@ function computeRouteStats(gpxString: string) { ), elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, - geom: coords.length >= 2 ? lineStringFromCoords(coords) : null, }; } catch { - return { distance: null, elevationGain: null, elevationLoss: null, geom: null }; + return { distance: null, elevationGain: null, elevationLoss: null }; } } + +async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxString: string) { + try { + const gpxData = await parseGpxAsync(gpxString); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length < 2) return; + const geojson = JSON.stringify({ type: "LineString", coordinates: coords }); + const db = getDb(); + await db.execute( + sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`, + ); + } catch (e) { + console.error(`Failed to set geom for ${table}/${id}:`, e); + } +} + +export { setGeomFromGpx }; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 0454d2d..788dcad 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -7,7 +7,6 @@ import { jsonb, customType, } from "drizzle-orm/pg-core"; -import { sql, type SQL } from "drizzle-orm"; const bytea = customType<{ data: Buffer }>({ dataType() { @@ -15,27 +14,12 @@ const bytea = customType<{ data: Buffer }>({ }, }); -const lineString = customType<{ data: string | SQL }>({ +const lineString = customType<{ data: string }>({ dataType() { return "geometry(LineString, 4326)"; }, - toDriver(value) { - return value; - }, }); -/** - * Build a SQL expression to create a PostGIS LineString from coordinate pairs. - * Coordinates are [lon, lat] (GeoJSON order). - */ -export function lineStringFromCoords(coords: [number, number][]): SQL { - const geojson = JSON.stringify({ - type: "LineString", - coordinates: coords, - }); - return sql`ST_GeomFromGeoJSON(${geojson})`; -} - export const journalSchema = pgSchema("journal"); export const users = journalSchema.table("users", { diff --git a/packages/gpx/src/geom.test.ts b/packages/gpx/src/geom.test.ts new file mode 100644 index 0000000..a1521ed --- /dev/null +++ b/packages/gpx/src/geom.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { parseGpxAsync } from "./parse.ts"; + +const sampleGpx = ` + + + + 34 + 113 + 519 + + +`; + +describe("GPX to geometry coordinates", () => { + it("extracts [lon, lat] coordinates from track points", async () => { + const gpxData = await parseGpxAsync(sampleGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(3); + // GeoJSON order: [lon, lat] + expect(coords[0]).toEqual([13.405, 52.52]); + expect(coords[1]).toEqual([13.74, 51.05]); + expect(coords[2]).toEqual([11.576, 48.137]); + }); + + it("produces valid GeoJSON LineString", async () => { + const gpxData = await parseGpxAsync(sampleGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + const geojson = { type: "LineString", coordinates: coords }; + + expect(geojson.type).toBe("LineString"); + expect(geojson.coordinates).toHaveLength(3); + // Verify serialization doesn't throw + expect(() => JSON.stringify(geojson)).not.toThrow(); + }); + + it("handles empty GPX gracefully", async () => { + const emptyGpx = ` + + + `; + const gpxData = await parseGpxAsync(emptyGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(0); + }); + + it("handles single point (insufficient for LineString)", async () => { + const singlePointGpx = ` + + + 34 + + `; + const gpxData = await parseGpxAsync(singlePointGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(1); + // Caller should check coords.length >= 2 before creating LineString + }); +}); From d5bcecb3457f6b878995c078eb7a4811988c0f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:19:55 +0100 Subject: [PATCH 04/15] Fix distance calculation for GPX files without elevation data Distance was only stored in the elevation profile array, which was empty when track points had no elements. Now computed via haversine independently and exposed as gpxData.distance. Tested: berlin-dresden-radweg-2021.gpx (5660 points, no elevation) now correctly reports 248.8 km. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/activities.server.ts | 3 +-- apps/journal/app/lib/routes.server.ts | 6 +----- packages/gpx/src/parse.ts | 8 ++++---- packages/gpx/src/types.ts | 2 ++ 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 4e48b8c..e3a399c 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -23,8 +23,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) { if (input.gpx) { try { const gpxData = await parseGpxAsync(input.gpx); - const profile = gpxData.elevation.profile; - distance = profile.length > 0 ? Math.round(profile[profile.length - 1]!.distance) : null; + distance = gpxData.distance || null; elevationGain = gpxData.elevation.gain; elevationLoss = gpxData.elevation.loss; diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index c9f1b45..e8bd8d3 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -145,11 +145,7 @@ async function computeRouteStats(gpxString: string) { try { const gpxData = await parseGpxAsync(gpxString); return { - distance: Math.round( - gpxData.elevation.profile.length > 0 - ? gpxData.elevation.profile[gpxData.elevation.profile.length - 1]!.distance - : 0, - ), + distance: gpxData.distance, elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, }; diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index d1b118b..70a3a0e 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -46,9 +46,9 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { const name = doc.querySelector("metadata > name")?.textContent ?? undefined; const waypoints = parseWaypoints(doc); const tracks = parseTracks(doc); - const elevation = computeElevation(tracks); + const { totalDistance, ...elevation } = computeElevation(tracks); - return { name, waypoints, tracks, elevation }; + return { name, waypoints, tracks, distance: totalDistance, elevation }; } function parseWaypoints(doc: Document): Waypoint[] { @@ -85,7 +85,7 @@ function parseTracks(doc: Document): TrackPoint[][] { return tracks; } -function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] { +function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } { let gain = 0; let loss = 0; const profile: ElevationProfile[] = []; @@ -112,7 +112,7 @@ function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] { } } - return { gain: Math.round(gain), loss: Math.round(loss), profile }; + return { totalDistance: Math.round(totalDistance), gain: Math.round(gain), loss: Math.round(loss), profile }; } /** Haversine distance between two points in meters */ diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts index d7861cc..bb7d0a2 100644 --- a/packages/gpx/src/types.ts +++ b/packages/gpx/src/types.ts @@ -18,6 +18,8 @@ export interface GpxData { name?: string; waypoints: Waypoint[]; tracks: TrackPoint[][]; + /** Total distance in meters (haversine, works with or without elevation data) */ + distance: number; elevation: { gain: number; loss: number; From 5f3d4ae84694a577345092cc0928d135c2e4253c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:29:04 +0100 Subject: [PATCH 05/15] Remove sync parseGpx, use parseGpxAsync everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseGpx (sync) needs browser DOMParser which doesn't exist on the server — it silently failed in every server-side caller. Removed the export and migrated all callers to parseGpxAsync. Updated tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/new.tsx | 4 ++-- packages/gpx/src/generate.test.ts | 6 +++--- packages/gpx/src/index.ts | 2 +- packages/gpx/src/parse.test.ts | 36 +++++++++++++++++++------------ packages/gpx/src/parse.ts | 2 +- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index f37c72c..8a9f948 100644 --- a/apps/planner/app/routes/new.tsx +++ b/apps/planner/app/routes/new.tsx @@ -1,7 +1,7 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/new"; import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions"; -import { parseGpx } from "@trails-cool/gpx"; +import { parseGpxAsync } from "@trails-cool/gpx"; import { checkRateLimit } from "~/lib/rate-limit"; export async function loader({ request }: Route.LoaderArgs) { @@ -35,7 +35,7 @@ export async function loader({ request }: Route.LoaderArgs) { if (gpxEncoded) { try { const gpx = decodeURIComponent(gpxEncoded); - const gpxData = parseGpx(gpx); + const gpxData = await parseGpxAsync(gpx); initializeSessionWithWaypoints(session.id, gpxData.waypoints); } catch { // Continue with empty session if GPX is invalid diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts index 6b4fd34..0eefc9f 100644 --- a/packages/gpx/src/generate.test.ts +++ b/packages/gpx/src/generate.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { generateGpx } from "./generate.ts"; -import { parseGpx } from "./parse.ts"; +import { parseGpxAsync } from "./parse.ts"; describe("generateGpx", () => { it("generates valid GPX with name", () => { @@ -36,13 +36,13 @@ describe("generateGpx", () => { expect(gpx).toContain("Route <A> & B"); }); - it("produces round-trippable GPX", () => { + it("produces round-trippable GPX", async () => { const original = generateGpx({ name: "Round Trip", waypoints: [{ lat: 52.52, lon: 13.405, name: "Start" }], tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]], }); - const parsed = parseGpx(original); + const parsed = await parseGpxAsync(original); expect(parsed.name).toBe("Round Trip"); expect(parsed.waypoints).toHaveLength(1); expect(parsed.waypoints[0]!.name).toBe("Start"); diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index edc7f3d..e84a560 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,3 +1,3 @@ -export { parseGpx, parseGpxAsync } from "./parse.ts"; +export { parseGpxAsync } from "./parse.ts"; export { generateGpx } from "./generate.ts"; export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts index f38ac8b..6e05634 100644 --- a/packages/gpx/src/parse.test.ts +++ b/packages/gpx/src/parse.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseGpx } from "./parse.ts"; +import { parseGpxAsync } from "./parse.ts"; const sampleGpx = ` @@ -15,42 +15,50 @@ const sampleGpx = ` `; -describe("parseGpx", () => { - it("parses route name", () => { - const result = parseGpx(sampleGpx); +describe("parseGpxAsync", () => { + it("parses route name", async () => { + const result = await parseGpxAsync(sampleGpx); expect(result.name).toBe("Test Route"); }); - it("parses waypoints with lat, lon, and name", () => { - const result = parseGpx(sampleGpx); + it("parses waypoints with lat, lon, and name", async () => { + const result = await parseGpxAsync(sampleGpx); expect(result.waypoints).toHaveLength(2); expect(result.waypoints[0]).toEqual({ lat: 52.52, lon: 13.405, name: "Berlin" }); expect(result.waypoints[1]).toEqual({ lat: 48.137, lon: 11.576, name: "Munich" }); }); - it("parses track points with elevation", () => { - const result = parseGpx(sampleGpx); + it("parses track points with elevation", async () => { + const result = await parseGpxAsync(sampleGpx); expect(result.tracks).toHaveLength(1); expect(result.tracks[0]).toHaveLength(3); expect(result.tracks[0]![0]).toEqual({ lat: 52.52, lon: 13.405, ele: 34, time: undefined }); }); - it("computes elevation gain and loss", () => { - const result = parseGpx(sampleGpx); + it("computes elevation gain and loss", async () => { + const result = await parseGpxAsync(sampleGpx); expect(result.elevation.gain).toBeGreaterThan(0); expect(result.elevation.loss).toBe(0); // monotonically increasing elevation expect(result.elevation.gain).toBe(485); // 113-34 + 519-113 }); - it("builds elevation profile", () => { - const result = parseGpx(sampleGpx); + it("builds elevation profile", async () => { + const result = await parseGpxAsync(sampleGpx); expect(result.elevation.profile).toHaveLength(3); expect(result.elevation.profile[0]!.distance).toBe(0); expect(result.elevation.profile[0]!.elevation).toBe(34); expect(result.elevation.profile[2]!.distance).toBeGreaterThan(0); }); - it("throws on invalid XML", () => { - expect(() => parseGpx("not xml at all <<<<")).toThrow("Invalid GPX XML"); + it("computes total distance independently of elevation", async () => { + const result = await parseGpxAsync(sampleGpx); + expect(result.distance).toBeGreaterThan(0); + // Berlin to Munich is ~500km, our 3-point track should be in that range + expect(result.distance).toBeGreaterThan(400_000); + expect(result.distance).toBeLessThan(600_000); + }); + + it("throws on invalid XML", async () => { + await expect(parseGpxAsync("not xml at all <<<<")).rejects.toThrow(); }); }); diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 70a3a0e..1ff1b79 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -15,7 +15,7 @@ async function getDOMParser(): Promise { return _LinkedDOMParser; } -export function parseGpx(xml: string): GpxData { +function parseGpx(xml: string): GpxData { // Synchronous path for browser if (typeof DOMParser !== "undefined") { return parseGpxWithParser(new DOMParser(), xml); From 79942bb99ec2fa485bff4162ac44f60152db77df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:40:11 +0100 Subject: [PATCH 06/15] Extract start/end from track when GPX has no waypoints The planner's GPX import only used elements, which many GPX files don't have. Now falls back to the first and last track point, giving the planner two endpoints to route between. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/new.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index 8a9f948..aa01380 100644 --- a/apps/planner/app/routes/new.tsx +++ b/apps/planner/app/routes/new.tsx @@ -36,7 +36,31 @@ export async function loader({ request }: Route.LoaderArgs) { try { const gpx = decodeURIComponent(gpxEncoded); const gpxData = await parseGpxAsync(gpx); - initializeSessionWithWaypoints(session.id, gpxData.waypoints); + // Use explicit waypoints if present, otherwise extract from track segments + let waypoints = gpxData.waypoints; + if (waypoints.length === 0 && gpxData.tracks.length > 0) { + const extracted: Array<{ lat: number; lon: number }> = []; + for (const seg of gpxData.tracks) { + if (seg.length === 0) continue; + const first = seg[0]!; + // Deduplicate: skip if same as previous waypoint + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { + extracted.push({ lat: first.lat, lon: first.lon }); + } + } + // Add the end of the last segment + const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; + if (lastSeg.length > 0) { + const last = lastSeg[lastSeg.length - 1]!; + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { + extracted.push({ lat: last.lat, lon: last.lon }); + } + } + waypoints = extracted; + } + initializeSessionWithWaypoints(session.id, waypoints); } catch { // Continue with empty session if GPX is invalid } From e0f7c5b806b6052e78619a2d6098d07f04cc1b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:45:05 +0100 Subject: [PATCH 07/15] Apply track-segment waypoint extraction to sessions API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as new.tsx — the journal→planner handoff also only used elements from GPX. Now both code paths extract waypoints from track segments when no explicit waypoints exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/api.sessions.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 3778341..26ad9c5 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -23,7 +23,29 @@ export async function action({ request }: Route.ActionArgs) { if (gpx) { try { const gpxData = await parseGpxAsync(gpx); - initialWaypoints = gpxData.waypoints; + // Use explicit waypoints if present, otherwise extract from track segments + if (gpxData.waypoints.length > 0) { + initialWaypoints = gpxData.waypoints; + } else if (gpxData.tracks.length > 0) { + const extracted: Array<{ lat: number; lon: number }> = []; + for (const seg of gpxData.tracks) { + if (seg.length === 0) continue; + const first = seg[0]!; + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { + extracted.push({ lat: first.lat, lon: first.lon }); + } + } + const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; + if (lastSeg.length > 0) { + const last = lastSeg[lastSeg.length - 1]!; + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { + extracted.push({ lat: last.lat, lon: last.lon }); + } + } + initialWaypoints = extracted; + } } catch { // Continue with empty session if GPX is invalid } From cc90c32a0ee208c6bd4ec0df5d3d7f187510fb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:47:17 +0100 Subject: [PATCH 08/15] Extract shared extractWaypoints into @trails-cool/gpx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new.tsx and api.sessions.ts had duplicated track-segment waypoint extraction logic. Moved to packages/gpx as extractWaypoints(gpxData) — uses elements when present, falls back to start of each track segment + end of last. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/api.sessions.ts | 27 +++----------------- apps/planner/app/routes/new.tsx | 28 ++------------------- packages/gpx/src/index.ts | 1 + packages/gpx/src/waypoints.ts | 33 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 50 deletions(-) create mode 100644 packages/gpx/src/waypoints.ts diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 26ad9c5..3364385 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, listSessions } from "~/lib/sessions"; -import { parseGpxAsync } from "@trails-cool/gpx"; +import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { withDb } from "@trails-cool/db"; export async function action({ request }: Route.ActionArgs) { @@ -23,29 +23,8 @@ export async function action({ request }: Route.ActionArgs) { if (gpx) { try { const gpxData = await parseGpxAsync(gpx); - // Use explicit waypoints if present, otherwise extract from track segments - if (gpxData.waypoints.length > 0) { - initialWaypoints = gpxData.waypoints; - } else if (gpxData.tracks.length > 0) { - const extracted: Array<{ lat: number; lon: number }> = []; - for (const seg of gpxData.tracks) { - if (seg.length === 0) continue; - const first = seg[0]!; - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { - extracted.push({ lat: first.lat, lon: first.lon }); - } - } - const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; - if (lastSeg.length > 0) { - const last = lastSeg[lastSeg.length - 1]!; - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { - extracted.push({ lat: last.lat, lon: last.lon }); - } - } - initialWaypoints = extracted; - } + const wps = extractWaypoints(gpxData); + if (wps.length > 0) initialWaypoints = wps; } catch { // Continue with empty session if GPX is invalid } diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index aa01380..4150c29 100644 --- a/apps/planner/app/routes/new.tsx +++ b/apps/planner/app/routes/new.tsx @@ -1,7 +1,7 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/new"; import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions"; -import { parseGpxAsync } from "@trails-cool/gpx"; +import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { checkRateLimit } from "~/lib/rate-limit"; export async function loader({ request }: Route.LoaderArgs) { @@ -36,31 +36,7 @@ export async function loader({ request }: Route.LoaderArgs) { try { const gpx = decodeURIComponent(gpxEncoded); const gpxData = await parseGpxAsync(gpx); - // Use explicit waypoints if present, otherwise extract from track segments - let waypoints = gpxData.waypoints; - if (waypoints.length === 0 && gpxData.tracks.length > 0) { - const extracted: Array<{ lat: number; lon: number }> = []; - for (const seg of gpxData.tracks) { - if (seg.length === 0) continue; - const first = seg[0]!; - // Deduplicate: skip if same as previous waypoint - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { - extracted.push({ lat: first.lat, lon: first.lon }); - } - } - // Add the end of the last segment - const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; - if (lastSeg.length > 0) { - const last = lastSeg[lastSeg.length - 1]!; - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { - extracted.push({ lat: last.lat, lon: last.lon }); - } - } - waypoints = extracted; - } - initializeSessionWithWaypoints(session.id, waypoints); + initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData)); } catch { // Continue with empty session if GPX is invalid } diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index e84a560..f8887f2 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,3 +1,4 @@ export { parseGpxAsync } from "./parse.ts"; export { generateGpx } from "./generate.ts"; +export { extractWaypoints } from "./waypoints.ts"; export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts new file mode 100644 index 0000000..eca8da4 --- /dev/null +++ b/packages/gpx/src/waypoints.ts @@ -0,0 +1,33 @@ +import type { GpxData } from "./types.ts"; + +/** + * Extract waypoints from parsed GPX data. + * Uses explicit elements if present, otherwise extracts the start + * of each track segment + end of the last segment. + */ +export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string }> { + if (gpxData.waypoints.length > 0) return gpxData.waypoints; + if (gpxData.tracks.length === 0) return []; + + const result: Array<{ lat: number; lon: number }> = []; + for (const seg of gpxData.tracks) { + if (seg.length === 0) continue; + const first = seg[0]!; + const prev = result[result.length - 1]; + if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { + result.push({ lat: first.lat, lon: first.lon }); + } + } + + // Add end of last segment + const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; + if (lastSeg.length > 0) { + const last = lastSeg[lastSeg.length - 1]!; + const prev = result[result.length - 1]; + if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { + result.push({ lat: last.lat, lon: last.lon }); + } + } + + return result; +} From 0e27d6eaef26d2630785622cbbfad1573396aedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 22:36:13 +0100 Subject: [PATCH 09/15] Use Douglas-Peucker simplification for single-segment GPX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For GPX files with one long track segment, extracting only start/end gives just 2 waypoints. Now uses Douglas-Peucker line simplification (epsilon=0.05° ≈ 5km) to find significant turning points. Result: berlin-dresden-radweg (249km, 5660 points) → 10 waypoints that capture the route's key direction changes. Multi-segment GPX files still use segment endpoints as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/gpx/src/waypoints.ts | 84 ++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts index eca8da4..4d4da9b 100644 --- a/packages/gpx/src/waypoints.ts +++ b/packages/gpx/src/waypoints.ts @@ -2,32 +2,96 @@ import type { GpxData } from "./types.ts"; /** * Extract waypoints from parsed GPX data. - * Uses explicit elements if present, otherwise extracts the start - * of each track segment + end of the last segment. + * Uses explicit elements if present, otherwise simplifies the + * track using Douglas-Peucker to find significant turning points. */ export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string }> { if (gpxData.waypoints.length > 0) return gpxData.waypoints; if (gpxData.tracks.length === 0) return []; - const result: Array<{ lat: number; lon: number }> = []; + // Collect start of each segment + end of last (for multi-segment GPX) + const segmentEndpoints: Array<{ lat: number; lon: number }> = []; for (const seg of gpxData.tracks) { if (seg.length === 0) continue; const first = seg[0]!; - const prev = result[result.length - 1]; + const prev = segmentEndpoints[segmentEndpoints.length - 1]; if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { - result.push({ lat: first.lat, lon: first.lon }); + segmentEndpoints.push({ lat: first.lat, lon: first.lon }); } } - - // Add end of last segment const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; if (lastSeg.length > 0) { const last = lastSeg[lastSeg.length - 1]!; - const prev = result[result.length - 1]; + const prev = segmentEndpoints[segmentEndpoints.length - 1]; if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { - result.push({ lat: last.lat, lon: last.lon }); + segmentEndpoints.push({ lat: last.lat, lon: last.lon }); } } - return result; + // If multi-segment already gives us enough waypoints, use those + if (segmentEndpoints.length > 2) return segmentEndpoints; + + // Single segment: simplify the track to find key turning points + const allPoints = gpxData.tracks.flat().map((p) => ({ lat: p.lat, lon: p.lon })); + if (allPoints.length < 2) return allPoints; + + return douglasPeucker(allPoints, 0.05); +} + +/** + * Douglas-Peucker line simplification. + * Epsilon is in degrees (~0.005° ≈ 500m at mid-latitudes). + * Recursively finds the point with maximum perpendicular distance + * from the line between start and end, keeping significant turns. + */ +function douglasPeucker( + points: Array<{ lat: number; lon: number }>, + epsilon: number, +): Array<{ lat: number; lon: number }> { + if (points.length <= 2) return points; + + let maxDist = 0; + let maxIdx = 0; + + const start = points[0]!; + const end = points[points.length - 1]!; + + for (let i = 1; i < points.length - 1; i++) { + const dist = perpendicularDistance(points[i]!, start, end); + if (dist > maxDist) { + maxDist = dist; + maxIdx = i; + } + } + + if (maxDist > epsilon) { + const left = douglasPeucker(points.slice(0, maxIdx + 1), epsilon); + const right = douglasPeucker(points.slice(maxIdx), epsilon); + return [...left.slice(0, -1), ...right]; + } + + return [start, end]; +} + +function perpendicularDistance( + point: { lat: number; lon: number }, + lineStart: { lat: number; lon: number }, + lineEnd: { lat: number; lon: number }, +): number { + const dx = lineEnd.lon - lineStart.lon; + const dy = lineEnd.lat - lineStart.lat; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + const px = point.lon - lineStart.lon; + const py = point.lat - lineStart.lat; + return Math.sqrt(px * px + py * py); + } + const t = Math.max(0, Math.min(1, + ((point.lon - lineStart.lon) * dx + (point.lat - lineStart.lat) * dy) / lenSq, + )); + const projLon = lineStart.lon + t * dx; + const projLat = lineStart.lat + t * dy; + const px = point.lon - projLon; + const py = point.lat - projLat; + return Math.sqrt(px * px + py * py); } From 2b0b89add84de17de8263f563fa8f3da231324e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 22:40:01 +0100 Subject: [PATCH 10/15] Use epsilon 0.005 for Douglas-Peucker waypoint extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/gpx/src/waypoints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts index 4d4da9b..dddfffb 100644 --- a/packages/gpx/src/waypoints.ts +++ b/packages/gpx/src/waypoints.ts @@ -35,7 +35,7 @@ export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: nu const allPoints = gpxData.tracks.flat().map((p) => ({ lat: p.lat, lon: p.lon })); if (allPoints.length < 2) return allPoints; - return douglasPeucker(allPoints, 0.05); + return douglasPeucker(allPoints, 0.005); } /** From 02939ca828fe249568848ef8cab53e552af41523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 09:13:07 +0100 Subject: [PATCH 11/15] Update specs to match implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses spec drift items #3, 5, 6, 7, 8, 9, 10, 11, 12 from #147: - transactional-emails: Resend → Nodemailer + SMTP - infrastructure: CX21 → cx23 - secret-management: single secrets.env → split app/infra files - brouter-integration: 5s failover delay → instant via clientID election - brouter-integration: 2 profiles → 5 (trekking, fastbike, safety, shortest, car) - observability: add version field to health response - observability: add brouter_request_duration_seconds metric - planner-session: remove 30-day max ceiling (not enforced) - shared-packages: clarify map package scope vs planner-specific features Co-Authored-By: Claude Opus 4.6 (1M context) --- openspec/specs/brouter-integration/spec.md | 4 ++-- openspec/specs/infrastructure/spec.md | 2 +- openspec/specs/observability/spec.md | 8 ++++++-- openspec/specs/planner-session/spec.md | 2 +- openspec/specs/secret-management/spec.md | 6 +++--- openspec/specs/shared-packages/spec.md | 2 +- openspec/specs/transactional-emails/spec.md | 2 +- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/openspec/specs/brouter-integration/spec.md b/openspec/specs/brouter-integration/spec.md index b2e8053..6757712 100644 --- a/openspec/specs/brouter-integration/spec.md +++ b/openspec/specs/brouter-integration/spec.md @@ -32,7 +32,7 @@ The Planner SHALL elect one participant per session as the "routing host" who is #### Scenario: Host failover - **WHEN** the current routing host disconnects -- **THEN** the longest-connected remaining participant becomes the new host within 5 seconds +- **THEN** failover is immediate via deterministic Yjs clientID election: the client with the lowest remaining ID becomes host instantly ### Requirement: Route broadcast The routing host SHALL store computed route results in the Yjs document so that all participants receive route updates automatically. @@ -45,7 +45,7 @@ The routing host SHALL store computed route results in the Yjs document so that The Planner SHALL support selecting a routing profile that determines how BRouter computes the route. #### Scenario: Switch routing profile -- **WHEN** a user changes the routing profile from "trekking" to "shortest" +- **WHEN** a user changes the routing profile (available profiles: `trekking`, `fastbike`, `safety`, `shortest`, `car`) - **THEN** the profile change syncs via Yjs and the routing host recomputes the route ### Requirement: BRouter API proxy diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 61fb6c5..a1bb45b 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -5,7 +5,7 @@ Infrastructure SHALL be provisioned on Hetzner Cloud using Terraform with the He #### Scenario: Provision server - **WHEN** `terraform apply` is run -- **THEN** a Hetzner CX21 server (2 vCPU, 4 GB RAM, 40 GB SSD) is created with Docker installed +- **THEN** a Hetzner cx23 server (2 vCPU, 4 GB RAM, 40 GB SSD) is created with Docker installed ### Requirement: Docker Compose deployment All services SHALL be deployed via Docker Compose on the Hetzner server. diff --git a/openspec/specs/observability/spec.md b/openspec/specs/observability/spec.md index 850e803..88ae7ca 100644 --- a/openspec/specs/observability/spec.md +++ b/openspec/specs/observability/spec.md @@ -5,11 +5,11 @@ Both apps SHALL expose a `/health` endpoint returning service and database statu #### Scenario: Healthy service - **WHEN** the app is running and the database is reachable -- **THEN** `GET /health` returns `{ "status": "ok", "db": "connected" }` with HTTP 200 +- **THEN** `GET /health` returns `{ "status": "ok", "db": "connected", "version": "" }` with HTTP 200 (version from `SENTRY_RELEASE` env var, defaults to `"dev"`) #### Scenario: Degraded service - **WHEN** the app is running but the database is unreachable -- **THEN** `GET /health` returns `{ "status": "degraded", "db": "unreachable" }` with HTTP 503 +- **THEN** `GET /health` returns `{ "status": "degraded", "db": "unreachable", "version": "" }` with HTTP 503 ### Requirement: Prometheus metrics Both apps SHALL expose a `/metrics` endpoint with Prometheus-formatted metrics. @@ -26,6 +26,10 @@ Both apps SHALL expose a `/metrics` endpoint with Prometheus-formatted metrics. - **WHEN** the Planner is running - **THEN** `planner_active_sessions` and `planner_connected_clients` gauges reflect current state +#### Scenario: BRouter latency metrics +- **WHEN** the Planner proxies a routing request to BRouter +- **THEN** `brouter_request_duration_seconds` histogram is updated (buckets: 0.1s to 10s) + ### Requirement: Structured logging Both apps SHALL output structured JSON logs in production. diff --git a/openspec/specs/planner-session/spec.md b/openspec/specs/planner-session/spec.md index accceb9..414aa90 100644 --- a/openspec/specs/planner-session/spec.md +++ b/openspec/specs/planner-session/spec.md @@ -45,7 +45,7 @@ The Planner SHALL persist Yjs session state to PostgreSQL so that sessions survi - **THEN** reconnecting clients recover the full session state from PostgreSQL ### Requirement: Session expiry -The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, max: 30 days). +The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, no hard ceiling enforced). #### Scenario: Session expires - **WHEN** no edits are made to a session for 7 days diff --git a/openspec/specs/secret-management/spec.md b/openspec/specs/secret-management/spec.md index abaa0ae..3d64bb1 100644 --- a/openspec/specs/secret-management/spec.md +++ b/openspec/specs/secret-management/spec.md @@ -1,15 +1,15 @@ ## Requirements ### Requirement: Encrypted secrets in repository -Production secrets SHALL be stored as a SOPS-encrypted file in the repository, decryptable only with a single age private key. +Production secrets SHALL be stored as SOPS-encrypted files in the repository, decryptable only with a single age private key. Secrets are split into two files: `secrets.app.env` (application secrets) and `secrets.infra.env` (infrastructure secrets). #### Scenario: Edit secrets -- **WHEN** a developer runs `sops infrastructure/secrets.env` +- **WHEN** a developer runs `sops infrastructure/secrets.app.env` or `sops infrastructure/secrets.infra.env` - **THEN** the file is decrypted in a temporary editor, and re-encrypted on save #### Scenario: CD decryption - **WHEN** the CD workflow runs -- **THEN** the encrypted secrets file is decrypted using the AGE_SECRET_KEY GitHub secret and provided to docker-compose as an env file +- **THEN** both encrypted secrets files are decrypted using the AGE_SECRET_KEY GitHub secret and merged at deploy time as env files for docker-compose #### Scenario: Secret audit trail - **WHEN** a secret is changed diff --git a/openspec/specs/shared-packages/spec.md b/openspec/specs/shared-packages/spec.md index e40a8e1..ec7aa45 100644 --- a/openspec/specs/shared-packages/spec.md +++ b/openspec/specs/shared-packages/spec.md @@ -27,7 +27,7 @@ The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoin - **THEN** it returns elevation gain, loss, and a profile array of distance/elevation pairs ### Requirement: Map rendering package -The `@trails-cool/map` package SHALL provide React components for rendering Leaflet maps with configurable base layers and route overlays. +The `@trails-cool/map` package SHALL provide core React components (MapView, RouteLayer) for rendering Leaflet maps with configurable base layers and route overlays. Interactive features (route drag-reshape, ghost markers, no-go area drawing, elevation chart, cursor tracking, colored routes) are implemented directly in the Planner app since they are Planner-specific. #### Scenario: Render map component - **WHEN** the map package's MapView component is rendered with a center and zoom diff --git a/openspec/specs/transactional-emails/spec.md b/openspec/specs/transactional-emails/spec.md index d88f2c3..4e50bee 100644 --- a/openspec/specs/transactional-emails/spec.md +++ b/openspec/specs/transactional-emails/spec.md @@ -5,7 +5,7 @@ The system SHALL provide a provider-agnostic email sending function that support #### Scenario: Send email in production - **WHEN** the system sends a transactional email in production -- **THEN** the email is delivered via the configured provider (Resend) to the recipient +- **THEN** the email is delivered via Nodemailer with SMTP (configured via `SMTP_URL` connection string and `SMTP_FROM` env var) to the recipient #### Scenario: Dev mode skips sending - **WHEN** the system sends a transactional email in development From e083ee134d2be95d5a4a0c9b72e96c03f0806fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 17:02:15 +0100 Subject: [PATCH 12/15] Wire planner metrics gauges to actual session/client counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plannerActiveSessions and plannerConnectedClients were registered but never updated — they always reported 0. Now incremented on WebSocket connect and decremented on close. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/yjs-server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/planner/app/lib/yjs-server.ts b/apps/planner/app/lib/yjs-server.ts index ccb22ba..af9e53f 100644 --- a/apps/planner/app/lib/yjs-server.ts +++ b/apps/planner/app/lib/yjs-server.ts @@ -6,6 +6,7 @@ import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; import type { IncomingMessage, Server } from "node:http"; import { saveSessionState, loadSessionState, touchSession } from "./sessions.ts"; +import { plannerActiveSessions, plannerConnectedClients } from "./metrics.server.ts"; const messageSync = 0; const messageAwareness = 1; @@ -159,10 +160,13 @@ export function setupYjsWebSocket(server: Server): WebSocketServer { }); wss.on("connection", async (ws: WebSocket, _request: IncomingMessage, sessionId: string) => { + const isNewSession = !docs.has(sessionId); const doc = await getOrLoadDoc(sessionId); const awareness = getAwareness(sessionId, doc); conns.set(ws, { sessionId, clientIds: new Set() }); + plannerConnectedClients.inc(); + if (isNewSession) plannerActiveSessions.inc(); // Broadcast doc updates to all connections in this session const onUpdate = (update: Uint8Array, origin: unknown) => { @@ -218,11 +222,13 @@ export function setupYjsWebSocket(server: Server): WebSocketServer { ); } conns.delete(ws); + plannerConnectedClients.dec(); doc.off("update", onUpdate); // Save when last client leaves const hasClients = Array.from(conns.values()).some((m) => m.sessionId === sessionId); if (!hasClients) { + plannerActiveSessions.dec(); saveSessionState(sessionId).catch(() => {}); } }); From a3ad073d96833dde2a93e3818e7fb15d725c382a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 09:15:15 +0100 Subject: [PATCH 13/15] Guard collectDefaultMetrics against duplicate registration Importing metrics.server.ts from yjs-server.ts caused collectDefaultMetrics() to re-register during HMR, crashing the planner dev server. Check if metrics already exist first. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/metrics.server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/lib/metrics.server.ts b/apps/planner/app/lib/metrics.server.ts index 85590c6..059e5ab 100644 --- a/apps/planner/app/lib/metrics.server.ts +++ b/apps/planner/app/lib/metrics.server.ts @@ -1,7 +1,10 @@ import client from "prom-client"; // Collect default Node.js metrics (event loop, heap, GC) -client.collectDefaultMetrics(); +// Guard against duplicate registration during HMR +if (!client.register.getSingleMetric("process_cpu_user_seconds_total")) { + client.collectDefaultMetrics(); +} export const httpRequestDuration = new client.Histogram({ name: "http_request_duration_seconds", From d97992e96798c49e14aa496f0d23af82b3a73d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:02:21 +0100 Subject: [PATCH 14/15] Guard all metric registrations against re-evaluation Vite's dev server can re-evaluate modules, causing prom-client "already registered" errors for all metrics, not just default ones. Use getOrCreate pattern to reuse existing metrics from the registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/metrics.server.ts | 54 ++++++++++++++++---------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/planner/app/lib/metrics.server.ts b/apps/planner/app/lib/metrics.server.ts index 059e5ab..46d2060 100644 --- a/apps/planner/app/lib/metrics.server.ts +++ b/apps/planner/app/lib/metrics.server.ts @@ -1,32 +1,44 @@ import client from "prom-client"; -// Collect default Node.js metrics (event loop, heap, GC) -// Guard against duplicate registration during HMR +// Guard all metric registration — Vite's dev server can re-evaluate +// this module, causing "already registered" errors. +function getOrCreate(name: string, create: () => T): T { + return (client.register.getSingleMetric(name) as T) ?? create(); +} + if (!client.register.getSingleMetric("process_cpu_user_seconds_total")) { client.collectDefaultMetrics(); } -export const httpRequestDuration = new client.Histogram({ - name: "http_request_duration_seconds", - help: "Duration of HTTP requests in seconds", - labelNames: ["method", "route", "status"] as const, - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], -}); +export const httpRequestDuration = getOrCreate("http_request_duration_seconds", () => + new client.Histogram({ + name: "http_request_duration_seconds", + help: "Duration of HTTP requests in seconds", + labelNames: ["method", "route", "status"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + }), +); -export const plannerActiveSessions = new client.Gauge({ - name: "planner_active_sessions", - help: "Number of active planner sessions", -}); +export const plannerActiveSessions = getOrCreate("planner_active_sessions", () => + new client.Gauge({ + name: "planner_active_sessions", + help: "Number of active planner sessions", + }), +); -export const plannerConnectedClients = new client.Gauge({ - name: "planner_connected_clients", - help: "Number of connected WebSocket clients", -}); +export const plannerConnectedClients = getOrCreate("planner_connected_clients", () => + new client.Gauge({ + name: "planner_connected_clients", + help: "Number of connected WebSocket clients", + }), +); -export const brouterRequestDuration = new client.Histogram({ - name: "brouter_request_duration_seconds", - help: "Duration of BRouter API requests in seconds", - buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], -}); +export const brouterRequestDuration = getOrCreate("brouter_request_duration_seconds", () => + new client.Histogram({ + name: "brouter_request_duration_seconds", + help: "Duration of BRouter API requests in seconds", + buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], + }), +); export const registry = client.register; From 55076ef440113b826c90c60c87487d9eb52bb2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:15:08 +0100 Subject: [PATCH 15/15] Reduce Prometheus scrape interval from 15s to 5s Co-Authored-By: Claude Opus 4.6 (1M context) --- infrastructure/prometheus/prometheus.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index 2bda741..eefe45a 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -1,6 +1,6 @@ global: - scrape_interval: 15s - evaluation_interval: 15s + scrape_interval: 5s + evaluation_interval: 5s scrape_configs: - job_name: "journal"