From b496cd9b02617a7d6f562f9c1fa138fff16dda13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 10 Apr 2026 23:56:57 +0200 Subject: [PATCH] Add Journal day breakdown and elevation chart dividers - routes.server.ts: Extract dayBreaks from parsed GPX waypoints on save - routes.$id.tsx: Compute per-day stats in loader, render day breakdown section with distance/ascent/descent per day - ElevationChart: Dashed vertical lines at day boundaries - i18n: Add Journal day breakdown keys (en + de) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/routes.server.ts | 10 ++++- apps/journal/app/routes/routes.$id.tsx | 46 +++++++++++++++++++++- openspec/changes/multi-day-routes/tasks.md | 8 ++-- packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 49e13dd..1bc0880 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -19,11 +19,13 @@ export async function createRoute(ownerId: string, input: RouteInput) { let distance: number | null = null; let elevationGain: number | null = null; let elevationLoss: number | null = null; + let dayBreaks: number[] = []; if (input.gpx) { const stats = await computeRouteStats(input.gpx); distance = stats.distance; elevationGain = stats.elevationGain; elevationLoss = stats.elevationLoss; + dayBreaks = stats.dayBreaks; } await db.insert(routes).values({ @@ -36,6 +38,7 @@ export async function createRoute(ownerId: string, input: RouteInput) { distance, elevationGain, elevationLoss, + dayBreaks, }); if (input.gpx) { @@ -110,6 +113,7 @@ export async function updateRoute( updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; + updateData.dayBreaks = stats.dayBreaks; // Get next version number const existingVersions = await db @@ -151,13 +155,17 @@ export async function deleteRoute(id: string, ownerId: string) { async function computeRouteStats(gpxString: string) { try { const gpxData = await parseGpxAsync(gpxString); + const dayBreaks = gpxData.waypoints + .map((w, i) => (w.isDayBreak ? i : -1)) + .filter((i) => i >= 0); return { distance: gpxData.distance, elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, + dayBreaks, }; } catch { - return { distance: null, elevationGain: null, elevationLoss: null }; + return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[] }; } } diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 662ba9b..0ee74d3 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -19,6 +19,19 @@ export async function loader({ params, request }: Route.LoaderArgs) { const user = await getSessionUser(request); const isOwner = user?.id === route.ownerId; + // Compute per-day stats if route has day breaks and GPX + let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = []; + if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) { + try { + const { computeDays } = await import("@trails-cool/gpx"); + const { parseGpxAsync } = await import("@trails-cool/gpx"); + const gpxData = await parseGpxAsync(route.gpx); + dayStats = computeDays(gpxData.waypoints, gpxData.tracks); + } catch { + // Fall back to no day stats + } + } + return data({ route: { id: route.id, @@ -29,10 +42,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { elevationLoss: route.elevationLoss, routingProfile: route.routingProfile, hasGpx: !!route.gpx, + dayBreaks: route.dayBreaks ?? [], geojson: routeWithGeojson?.geojson ?? null, createdAt: route.createdAt.toISOString(), updatedAt: route.updatedAt.toISOString(), }, + dayStats, versions: route.versions.map((v) => ({ version: v.version, changeDescription: v.changeDescription, @@ -79,7 +94,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) { } export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { - const { route, versions, isOwner } = loaderData; + const { route, dayStats, versions, isOwner } = loaderData; const { t } = useTranslation("journal"); const [editLoading, setEditLoading] = useState(false); @@ -156,6 +171,35 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { )} + {dayStats.length > 1 && ( +
+

{t("routes.dayBreakdown")}

+
+ {dayStats.map((day) => ( +
+ + {t("routes.dayLabel", { n: day.dayNumber })} + + + {day.startName && day.endName + ? `${day.startName} → ${day.endName}` + : day.startName || day.endName || ""} + + + {(day.distance / 1000).toFixed(1)} km + + + ↑{day.ascent} m + + + ↓{day.descent} m + +
+ ))} +
+
+ )} + {route.geojson && (
diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md index 076edc3..5a16b03 100644 --- a/openspec/changes/multi-day-routes/tasks.md +++ b/openspec/changes/multi-day-routes/tasks.md @@ -30,15 +30,15 @@ ## 6. Journal Integration -- [ ] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column -- [ ] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`) -- [ ] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty +- [x] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column +- [x] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`) +- [x] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty - [ ] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist ## 7. i18n - [x] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary -- [ ] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels +- [x] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels ## 8. Testing diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 86c398d..1a0527e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -135,6 +135,8 @@ export default { delete: "Route löschen", distance: "Strecke", elevationGain: "Höhenmeter", + dayBreakdown: "Tagesübersicht", + dayLabel: "Tag {{n}}", noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!", noMapPreview: "Keine Kartenvorschau", saveChanges: "Änderungen speichern", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e1ff75a..da27412 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -135,6 +135,8 @@ export default { delete: "Delete Route", distance: "Distance", elevationGain: "Elevation Gain", + dayBreakdown: "Day Breakdown", + dayLabel: "Day {{n}}", noRoutesYet: "No routes yet. Create your first route!", noMapPreview: "No map preview", saveChanges: "Save Changes",