From 5604fe1c82f3de75de6c756384d66fc40b505d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 00:12:27 +0200 Subject: [PATCH] Add per-day map coloring and remaining unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouteMapThumbnail: Color route segments per day with alternating colors when dayBreaks are present, pass through from route detail page - overnight.test.ts: 6 tests for isOvernight/setOvernight with Y.Doc - daybreaks-extraction.test.ts: 3 tests for GPX → dayBreaks index extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/RouteMapThumbnail.client.tsx | 58 +++++++++++++- apps/journal/app/routes/routes.$id.tsx | 2 +- apps/planner/app/lib/overnight.test.ts | 80 +++++++++++++++++++ openspec/changes/multi-day-routes/tasks.md | 6 +- packages/gpx/src/daybreaks-extraction.test.ts | 68 ++++++++++++++++ 5 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 apps/planner/app/lib/overnight.test.ts create mode 100644 packages/gpx/src/daybreaks-extraction.test.ts diff --git a/apps/journal/app/components/RouteMapThumbnail.client.tsx b/apps/journal/app/components/RouteMapThumbnail.client.tsx index 3c48b7f..54b7c72 100644 --- a/apps/journal/app/components/RouteMapThumbnail.client.tsx +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -19,13 +19,16 @@ function FitBounds({ data }: { data: GeoJsonObject }) { return null; } +const DAY_COLORS = ["#2563eb", "#8B6D3A", "#059669", "#9333ea", "#dc2626", "#0891b2"]; + interface RouteMapProps { geojson: string; interactive?: boolean; className?: string; + dayBreaks?: number[]; } -export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapProps) { +export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks }: RouteMapProps) { const data: GeoJsonObject = JSON.parse(geojson); return ( @@ -44,8 +47,59 @@ export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapP url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution={interactive ? '© OpenStreetMap' : undefined} /> - + {dayBreaks && dayBreaks.length > 0 ? ( + + ) : ( + + )} ); } + +function DayColoredRoute({ data, dayBreaks }: { data: GeoJsonObject; dayBreaks: number[] }) { + // Extract coordinates from the GeoJSON LineString + const geometry = (data as { type: string; coordinates?: number[][] }).coordinates + ?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates); + + if (!geometry || geometry.length < 2) { + return ; + } + + // Split coordinates into segments at approximate day break points + // dayBreaks are waypoint indices — we split the line evenly since we don't + // have exact waypoint-to-coordinate mapping in the Journal context + const totalPoints = geometry.length; + const numDays = dayBreaks.length + 1; + const pointsPerDay = Math.ceil(totalPoints / numDays); + + const segments: Array<{ coords: number[][]; color: string }> = []; + for (let d = 0; d < numDays; d++) { + const start = d * pointsPerDay; + const end = Math.min((d + 1) * pointsPerDay + 1, totalPoints); + if (start >= totalPoints) break; + segments.push({ + coords: geometry.slice(start, end), + color: DAY_COLORS[d % DAY_COLORS.length]!, + }); + } + + return ( + <> + {segments.map((seg, i) => { + const segData: GeoJsonObject = { + type: "Feature", + geometry: { type: "LineString", coordinates: seg.coords }, + properties: {}, + } as unknown as GeoJsonObject; + return ( + + ); + })} + + ); +} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 0ee74d3..a65467e 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -202,7 +202,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {route.geojson && (
- + 0 ? route.dayBreaks : undefined} />
)} diff --git a/apps/planner/app/lib/overnight.test.ts b/apps/planner/app/lib/overnight.test.ts new file mode 100644 index 0000000..43a4528 --- /dev/null +++ b/apps/planner/app/lib/overnight.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import { setOvernight, isOvernight } from "./overnight.ts"; +import type { YjsState } from "./use-yjs.ts"; + +function createTestYjs(): YjsState { + const doc = new Y.Doc(); + const waypoints = doc.getArray>("waypoints"); + return { doc, waypoints } as unknown as YjsState; +} + +function addWaypoint(yjs: YjsState, lat: number, lon: number): void { + const yMap = new Y.Map(); + yMap.set("lat", lat); + yMap.set("lon", lon); + yjs.waypoints.push([yMap]); +} + +function createDocMap(): Y.Map { + const doc = new Y.Doc(); + const arr = doc.getArray>("test"); + const yMap = new Y.Map(); + arr.push([yMap]); + return yMap; +} + +describe("isOvernight", () => { + it("returns false for a waypoint without overnight flag", () => { + const yMap = createDocMap(); + yMap.set("lat", 52.52); + yMap.set("lon", 13.405); + expect(isOvernight(yMap)).toBe(false); + }); + + it("returns true for a waypoint with overnight flag", () => { + const yMap = createDocMap(); + yMap.set("lat", 52.52); + yMap.set("lon", 13.405); + yMap.set("overnight", true); + expect(isOvernight(yMap)).toBe(true); + }); + + it("returns false for non-boolean overnight value", () => { + const yMap = createDocMap(); + yMap.set("overnight", "yes"); + expect(isOvernight(yMap)).toBe(false); + }); +}); + +describe("setOvernight", () => { + it("sets overnight flag on a waypoint", () => { + const yjs = createTestYjs(); + addWaypoint(yjs, 52.52, 13.405); + + setOvernight(yjs, 0, true); + + const yMap = yjs.waypoints.get(0)!; + expect(yMap.get("overnight")).toBe(true); + }); + + it("clears overnight flag from a waypoint", () => { + const yjs = createTestYjs(); + addWaypoint(yjs, 52.52, 13.405); + + setOvernight(yjs, 0, true); + expect(yjs.waypoints.get(0)!.get("overnight")).toBe(true); + + setOvernight(yjs, 0, false); + expect(yjs.waypoints.get(0)!.get("overnight")).toBeUndefined(); + }); + + it("does nothing for out-of-bounds index", () => { + const yjs = createTestYjs(); + addWaypoint(yjs, 52.52, 13.405); + + // Should not throw + setOvernight(yjs, 5, true); + expect(yjs.waypoints.get(0)!.get("overnight")).toBeUndefined(); + }); +}); diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md index da35bdb..c4e1558 100644 --- a/openspec/changes/multi-day-routes/tasks.md +++ b/openspec/changes/multi-day-routes/tasks.md @@ -33,7 +33,7 @@ - [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 +- [x] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist ## 7. i18n @@ -43,9 +43,9 @@ ## 8. Testing - [x] 8.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases -- [ ] 8.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map +- [x] 8.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map - [x] 8.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved -- [ ] 8.4 Unit tests for `dayBreaks` extraction in route update logic +- [x] 8.4 Unit tests for `dayBreaks` extraction in route update logic - [ ] 8.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats - [ ] 8.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata - [ ] 8.7 E2E test: save multi-day route to Journal, verify day breakdown displays on route detail page diff --git a/packages/gpx/src/daybreaks-extraction.test.ts b/packages/gpx/src/daybreaks-extraction.test.ts new file mode 100644 index 0000000..b43e3f0 --- /dev/null +++ b/packages/gpx/src/daybreaks-extraction.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { generateGpx } from "./generate.ts"; +import { parseGpxAsync } from "./parse.ts"; + +/** + * Tests the dayBreaks extraction pattern used by the Journal's updateRoute: + * parse GPX → find waypoints with isDayBreak → extract indices + */ +describe("dayBreaks extraction from GPX", () => { + it("extracts empty dayBreaks when no overnight waypoints", async () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 50.98, lon: 11.028, name: "Erfurt" }, + ], + tracks: [[ + { lat: 52.52, lon: 13.405, ele: 34 }, + { lat: 50.98, lon: 11.028, ele: 195 }, + ]], + }); + const parsed = await parseGpxAsync(gpx); + const dayBreaks = parsed.waypoints + .map((w, i) => (w.isDayBreak ? i : -1)) + .filter((i) => i >= 0); + expect(dayBreaks).toEqual([]); + }); + + it("extracts single dayBreak index", async () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true }, + { lat: 50.98, lon: 11.028, name: "Erfurt" }, + ], + tracks: [[ + { lat: 52.52, lon: 13.405, ele: 34 }, + { lat: 51.84, lon: 12.243, ele: 80 }, + { lat: 50.98, lon: 11.028, ele: 195 }, + ]], + }); + const parsed = await parseGpxAsync(gpx); + const dayBreaks = parsed.waypoints + .map((w, i) => (w.isDayBreak ? i : -1)) + .filter((i) => i >= 0); + expect(dayBreaks).toEqual([1]); + }); + + it("extracts multiple dayBreak indices", async () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 0, lon: 0, name: "A" }, + { lat: 1, lon: 0, name: "B", isDayBreak: true }, + { lat: 2, lon: 0, name: "C" }, + { lat: 3, lon: 0, name: "D", isDayBreak: true }, + { lat: 4, lon: 0, name: "E" }, + ], + tracks: [[ + { lat: 0, lon: 0 }, { lat: 1, lon: 0 }, { lat: 2, lon: 0 }, + { lat: 3, lon: 0 }, { lat: 4, lon: 0 }, + ]], + }); + const parsed = await parseGpxAsync(gpx); + const dayBreaks = parsed.waypoints + .map((w, i) => (w.isDayBreak ? i : -1)) + .filter((i) => i >= 0); + expect(dayBreaks).toEqual([1, 3]); + }); +});