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]);
+ });
+});