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:
parent
77174f192d
commit
5604fe1c82
5 changed files with 208 additions and 6 deletions
|
|
@ -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 ? '© <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 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
80
apps/planner/app/lib/overnight.test.ts
Normal file
80
apps/planner/app/lib/overnight.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
68
packages/gpx/src/daybreaks-extraction.test.ts
Normal file
68
packages/gpx/src/daybreaks-extraction.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue