Add per-day map coloring and remaining unit tests

- 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) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 00:12:27 +02:00
parent 77174f192d
commit 5604fe1c82
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 208 additions and 6 deletions

View file

@ -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 ? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
/>
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
{dayBreaks && dayBreaks.length > 0 ? (
<DayColoredRoute data={data} dayBreaks={dayBreaks} />
) : (
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
)}
<FitBounds data={data} />
</MapContainer>
);
}
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 <GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />;
}
// 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 (
<GeoJSON
key={i}
data={segData}
style={{ color: seg.color, weight: 3, opacity: 0.8 }}
/>
);
})}
</>
);
}

View file

@ -202,7 +202,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
{route.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<ClientMap geojson={route.geojson} interactive className="h-full w-full" />
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} />
</div>
)}

View file

@ -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<Y.Map<unknown>>("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<unknown> {
const doc = new Y.Doc();
const arr = doc.getArray<Y.Map<unknown>>("test");
const yMap = new Y.Map<unknown>();
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();
});
});

View file

@ -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

View file

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