🗺️
diff --git a/apps/journal/package.json b/apps/journal/package.json
index 6e33431..b54967d 100644
--- a/apps/journal/package.json
+++ b/apps/journal/package.json
@@ -27,6 +27,7 @@
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/jobs": "workspace:*",
+ "@trails-cool/map-core": "workspace:*",
"@trails-cool/sentry-config": "workspace:*",
"@trails-cool/types": "workspace:*",
"drizzle-orm": "catalog:",
diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx
index 056d4bc..d464cf4 100644
--- a/apps/planner/app/components/SaveToJournalButton.tsx
+++ b/apps/planner/app/components/SaveToJournalButton.tsx
@@ -1,7 +1,9 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
+import { computeSurfaceBreakdown } from "@trails-cool/map-core";
import type { YjsState } from "~/lib/use-yjs";
import { buildPlanGpx } from "~/lib/gpx-export";
+import { readComputedRoute } from "~/lib/route-data";
interface SaveToJournalButtonProps {
yjs: YjsState;
@@ -24,6 +26,19 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal
// route round-trips correctly through the journal.
const gpx = buildPlanGpx(yjs);
+ // Distance-weighted surface/waytype breakdown from the BRouter waytags
+ // already in routeData — sent alongside the GPX so the journal can show
+ // it without re-deriving (route-surface-breakdown, Path 1).
+ const route = readComputedRoute(yjs.routeData);
+ const breakdown =
+ route.coordinates && route.coordinates.length > 1
+ ? computeSurfaceBreakdown(route.coordinates, route.surfaces, route.highways)
+ : null;
+ const surfaceBreakdown =
+ breakdown && (Object.keys(breakdown.surface).length > 0 || Object.keys(breakdown.highway).length > 0)
+ ? breakdown
+ : undefined;
+
// POST to the planner's server-side proxy. The proxy attaches the
// journal Bearer token (stored on the session row) and forwards
// the GPX. Token never leaves the planner server — see
@@ -31,7 +46,7 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal
const response = await fetch("/api/save-to-journal", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ sessionId, gpx }),
+ body: JSON.stringify({ sessionId, gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }),
});
if (!response.ok) {
diff --git a/apps/planner/app/routes/api.save-to-journal.ts b/apps/planner/app/routes/api.save-to-journal.ts
index 29ac08e..1f4e2b9 100644
--- a/apps/planner/app/routes/api.save-to-journal.ts
+++ b/apps/planner/app/routes/api.save-to-journal.ts
@@ -21,6 +21,7 @@ import { validateFetchUrl, getCallbackAllowedHosts } from "~/lib/url-validation.
interface SaveRequestBody {
sessionId?: unknown;
gpx?: unknown;
+ surfaceBreakdown?: unknown;
}
const MAX_GPX_BYTES = 5 * 1024 * 1024; // 5 MB — same ceiling as the Yjs doc cap
@@ -39,6 +40,12 @@ export async function action({ request }: Route.ActionArgs) {
const sessionId = typeof body.sessionId === "string" ? body.sessionId : "";
const gpx = typeof body.gpx === "string" ? body.gpx : "";
+ // Optional, best-effort decoration — forwarded as-is; the journal callback is
+ // the authoritative validator (planner doesn't depend on @trails-cool/api).
+ const surfaceBreakdown =
+ body.surfaceBreakdown && typeof body.surfaceBreakdown === "object"
+ ? body.surfaceBreakdown
+ : undefined;
if (!sessionId) return data({ error: "sessionId required" }, { status: 400 });
if (!gpx) return data({ error: "gpx required" }, { status: 400 });
@@ -69,7 +76,7 @@ export async function action({ request }: Route.ActionArgs) {
"Content-Type": "application/json",
Authorization: `Bearer ${session.callbackToken}`,
},
- body: JSON.stringify({ gpx }),
+ body: JSON.stringify({ gpx, ...(surfaceBreakdown ? { surfaceBreakdown } : {}) }),
});
} catch {
return data({ error: "journal unreachable" }, { status: 502 });
diff --git a/openspec/changes/route-surface-breakdown/tasks.md b/openspec/changes/route-surface-breakdown/tasks.md
index 68d50a6..55a0a52 100644
--- a/openspec/changes/route-surface-breakdown/tasks.md
+++ b/openspec/changes/route-surface-breakdown/tasks.md
@@ -1,24 +1,27 @@
+
+
## 1. Shared derivation + shape
-- [ ] 1.1 Pure `surfaceBreakdown(coordinates, surfaces, highways)` helper: haversine-weight each segment into per-surface + per-waytype buckets → `{ surface: Record
, highway: Record }` (metres). Unit-test.
-- [ ] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api` (record of category → metres ≥ 0, surface + highway).
+- [x] 1.1 `computeSurfaceBreakdown(coordinates, surfaces, highways)` in `@trails-cool/map-core` → `{ surface, highway }` metres; unit-tested.
+- [x] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api`.
## 2. Schema
-- [ ] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; `pnpm db:push`.
+- [x] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; pushed to dev + e2e DBs.
## 3. Path 1 — synchronous (Planner routes)
-- [ ] 3.1 `apps/planner/.../api.save-to-journal`: compute the breakdown from the session `EnrichedRoute` (coords + surfaces + highways) and add `surfaceBreakdown` to the POST body.
-- [ ] 3.2 Journal route callback validates the optional field and persists it (overwrite on re-save).
+- [x] 3.1 `SaveToJournalButton` computes the breakdown from `readComputedRoute` and sends it; `api.save-to-journal` forwards it (journal is the authoritative validator — planner has no `@trails-cool/api` dep).
+- [x] 3.2 Journal route callback validates the optional field with `SurfaceBreakdownSchema` and persists via `updateRoute` (overwrite on re-save).
## 4. Render
-- [ ] 4.1 Route + activity detail loaders expose `surfaceBreakdown`.
-- [ ] 4.2 `SurfaceBreakdown` component: stacked bar per dimension (surface, waytype), `map-core` `SURFACE_COLORS`/`HIGHWAY_COLORS` (DEFAULT → "other"), legend category · km · % (largest first), hidden when empty. Mount on `routes.$id.tsx` and `activities.$id.tsx`.
-- [ ] 4.3 i18n `journal.surface.*` (label + category names + "other") en + de.
+- [x] 4.1 Route + activity detail loaders expose `surfaceBreakdown`.
+- [x] 4.2 `SurfaceBreakdown` component (stacked bar per dimension, `map-core` palettes, legend category · % · km largest-first, unknown → "other", hidden when empty); mounted on `routes.$id.tsx` and `activities.$id.tsx`.
+- [x] 4.3 i18n `journal.surface.*` (surface/waytype labels, common categories, "other") en + de.
-## 5. Path 2 — async backfill + SSE
+## 5. Path 2 — async backfill + SSE (PHASE 2 — follow-up PR)
- [ ] 5.1 Journal-side Overpass client: `way[highway]` in bbox + parse (mirror the planner's `buildQuery`/`parseResponse` + rate-limit handling; route via the `/api/overpass` proxy).
- [ ] 5.2 Map-match helper: nearest OSM way per route segment → surface/highway; unmatched → unknown. Unit-test the matcher on a small fixture.
@@ -32,7 +35,7 @@
## 7. Tests & checks
-- [ ] 7.1 Unit: `surfaceBreakdown` weighting; map-matcher nearest-way + unknown bucket.
-- [ ] 7.2 Component (jsdom): bars/proportions, "other" bucket, hidden when empty.
-- [ ] 7.3 E2E: a route/activity with a stored breakdown shows the bars (seed the breakdown to avoid a live Overpass call in CI).
-- [ ] 7.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
+- [x] 7.1 Unit: `computeSurfaceBreakdown` weighting + unknown bucket (map-matcher unit is Phase 2).
+- [x] 7.2 Component (jsdom): bars/proportions, largest-first, hidden when empty/null.
+- [ ] 7.3 E2E: a route/activity with a stored breakdown shows the bars — **Phase 2** (needs a seed path; deferred to land with the backfill that creates the data). Phase 1 render verified in the browser + component test.
+- [x] 7.4 Phase 1: typecheck + lint + unit (map-core 36, journal 326) green; verified in the browser.
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index b8b4a2d..b157e77 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -42,6 +42,9 @@ export {
type ComputeRouteRequest,
} from "./routes.ts";
+// Surface / waytype breakdown
+export { SurfaceBreakdownSchema, type SurfaceBreakdown } from "./surface-breakdown.ts";
+
// Activities
export {
ActivitySummarySchema, ActivityDetailSchema,
diff --git a/packages/api/src/surface-breakdown.ts b/packages/api/src/surface-breakdown.ts
new file mode 100644
index 0000000..1587f34
--- /dev/null
+++ b/packages/api/src/surface-breakdown.ts
@@ -0,0 +1,15 @@
+import { z } from "zod";
+
+/**
+ * Distance-weighted surface/waytype breakdown (metres per category). Shared
+ * wire shape for the planner→journal handoff and the route/activity read APIs.
+ * Mirrors `SurfaceBreakdown` in `@trails-cool/map-core`.
+ */
+const MetresByCategory = z.record(z.string(), z.number().min(0));
+
+export const SurfaceBreakdownSchema = z.object({
+ surface: MetresByCategory,
+ highway: MetresByCategory,
+});
+
+export type SurfaceBreakdown = z.infer;
diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts
index a3c99b2..b13c46c 100644
--- a/packages/db/src/schema/journal.ts
+++ b/packages/db/src/schema/journal.ts
@@ -91,6 +91,15 @@ export const magicTokens = journalSchema.table("magic_tokens", {
*/
export type Visibility = "private" | "unlisted" | "public";
+/**
+ * Distance-weighted surface/waytype breakdown (metres per category), derived
+ * from BRouter waytags at Planner save or backfilled via Overpass.
+ */
+export type SurfaceBreakdown = {
+ surface: Record;
+ highway: Record;
+};
+
export const routes = journalSchema.table("routes", {
id: text("id").primaryKey(),
ownerId: text("owner_id")
@@ -106,6 +115,7 @@ export const routes = journalSchema.table("routes", {
elevationLoss: real("elevation_loss"),
dayBreaks: jsonb("day_breaks").$type(),
tags: jsonb("tags").$type(),
+ surfaceBreakdown: jsonb("surface_breakdown").$type(),
plannerState: bytea("planner_state"),
visibility: text("visibility").$type().notNull().default("private"),
/**
@@ -173,6 +183,9 @@ export const activities = journalSchema.table("activities", {
description: text("description").default(""),
// Sport / activity type (NULL = unspecified). See SPORT_TYPES above.
sportType: text("sport_type").$type(),
+ // Distance-weighted surface/waytype breakdown (backfilled via Overpass for
+ // imported/uploaded activities). NULL until derived.
+ surfaceBreakdown: jsonb("surface_breakdown").$type(),
gpx: text("gpx"),
geom: lineString("geom"),
startedAt: timestamp("started_at", { withTimezone: true }),
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index f2200fd..3dcd4fc 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -418,6 +418,38 @@ export default {
weeklyDistance: "Wochendistanz (letzte 12 Wochen)",
weekOf: "Woche vom {{date}}",
},
+ surface: {
+ surface: "Oberfläche",
+ waytype: "Wegtyp",
+ other: "Sonstige",
+ cat: {
+ asphalt: "Asphalt",
+ paved: "Befestigt",
+ concrete: "Beton",
+ paving_stones: "Pflastersteine",
+ cobblestone: "Kopfsteinpflaster",
+ gravel: "Schotter",
+ fine_gravel: "Feinschotter",
+ compacted: "Verdichtet",
+ ground: "Naturboden",
+ dirt: "Erde",
+ grass: "Gras",
+ sand: "Sand",
+ unpaved: "Unbefestigt",
+ path: "Pfad",
+ track: "Wirtschaftsweg",
+ residential: "Wohnstraße",
+ cycleway: "Radweg",
+ footway: "Fußweg",
+ primary: "Hauptstraße",
+ secondary: "Landstraße",
+ tertiary: "Nebenstraße",
+ service: "Erschließungsweg",
+ unclassified: "Kleine Straße",
+ bridleway: "Reitweg",
+ steps: "Treppe",
+ },
+ },
settings: {
title: "Einstellungen",
nav: {
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 16d3eb5..83493f35 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -418,6 +418,38 @@ export default {
weeklyDistance: "Weekly distance (last 12 weeks)",
weekOf: "Week of {{date}}",
},
+ surface: {
+ surface: "Surface",
+ waytype: "Way type",
+ other: "other",
+ cat: {
+ asphalt: "Asphalt",
+ paved: "Paved",
+ concrete: "Concrete",
+ paving_stones: "Paving stones",
+ cobblestone: "Cobblestone",
+ gravel: "Gravel",
+ fine_gravel: "Fine gravel",
+ compacted: "Compacted",
+ ground: "Ground",
+ dirt: "Dirt",
+ grass: "Grass",
+ sand: "Sand",
+ unpaved: "Unpaved",
+ path: "Path",
+ track: "Track",
+ residential: "Residential road",
+ cycleway: "Cycleway",
+ footway: "Footway",
+ primary: "Primary road",
+ secondary: "Secondary road",
+ tertiary: "Tertiary road",
+ service: "Service road",
+ unclassified: "Minor road",
+ bridleway: "Bridleway",
+ steps: "Steps",
+ },
+ },
settings: {
title: "Settings",
nav: {
diff --git a/packages/map-core/src/index.ts b/packages/map-core/src/index.ts
index 48939dd..821e82e 100644
--- a/packages/map-core/src/index.ts
+++ b/packages/map-core/src/index.ts
@@ -21,3 +21,6 @@ export {
} from "./z-index.ts";
export { SNAP_DISTANCE_METERS } from "./snap.ts";
+
+export { computeSurfaceBreakdown } from "./surface-breakdown.ts";
+export type { SurfaceBreakdown } from "./surface-breakdown.ts";
diff --git a/packages/map-core/src/surface-breakdown.test.ts b/packages/map-core/src/surface-breakdown.test.ts
new file mode 100644
index 0000000..1ef157e
--- /dev/null
+++ b/packages/map-core/src/surface-breakdown.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { computeSurfaceBreakdown } from "./surface-breakdown.ts";
+
+// ~0.001° lon at the equator ≈ 111 m; exact value doesn't matter, only ratios.
+const coords: [number, number][] = [
+ [0, 0],
+ [0.001, 0],
+ [0.002, 0],
+ [0.003, 0],
+];
+
+describe("computeSurfaceBreakdown", () => {
+ it("weights segments by distance into surface and waytype buckets", () => {
+ const { surface, highway } = computeSurfaceBreakdown(
+ coords,
+ ["asphalt", "asphalt", "gravel"],
+ ["residential", "residential", "track"],
+ );
+ // segments 0,1 asphalt; segment 2 gravel — equal lengths → 2:1
+ expect(surface.asphalt! / surface.gravel!).toBeCloseTo(2, 5);
+ expect(highway.residential! / highway.track!).toBeCloseTo(2, 5);
+ expect(Object.keys(surface)).toEqual(["asphalt", "gravel"]);
+ });
+
+ it("buckets missing/empty values as unknown", () => {
+ const { surface } = computeSurfaceBreakdown(coords, ["asphalt", "", "asphalt"], ["", "", ""]);
+ expect(surface.asphalt).toBeGreaterThan(0);
+ expect(surface.unknown).toBeGreaterThan(0);
+ });
+
+ it("returns empty buckets for a degenerate track", () => {
+ expect(computeSurfaceBreakdown([[0, 0]], [], [])).toEqual({ surface: {}, highway: {} });
+ });
+});
diff --git a/packages/map-core/src/surface-breakdown.ts b/packages/map-core/src/surface-breakdown.ts
new file mode 100644
index 0000000..86d6773
--- /dev/null
+++ b/packages/map-core/src/surface-breakdown.ts
@@ -0,0 +1,52 @@
+/**
+ * Distance-weighted surface / waytype breakdown for a route. Both derivation
+ * paths (BRouter waytags at Planner save, and the async Overpass backfill) feed
+ * their per-segment `surfaces`/`highways` arrays through this to produce the
+ * same compact summary that gets stored and rendered.
+ */
+export interface SurfaceBreakdown {
+ /** metres per surface category (e.g. asphalt, gravel, path) */
+ surface: Record;
+ /** metres per waytype/highway category */
+ highway: Record;
+}
+
+function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
+ const R = 6371000;
+ const toRad = (d: number) => (d * Math.PI) / 180;
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+ const a =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+/**
+ * Sum each coordinate segment's length into its surface and waytype buckets.
+ * `coordinates` are `[lon, lat, ...]` (GeoJSON axis order); `surfaces[i]` /
+ * `highways[i]` describe the segment from point `i` to `i+1`. Missing/empty
+ * values bucket as `"unknown"`. Returns metres per category.
+ */
+export function computeSurfaceBreakdown(
+ coordinates: ReadonlyArray,
+ surfaces: readonly string[],
+ highways: readonly string[],
+): SurfaceBreakdown {
+ const surface: Record = {};
+ const highway: Record = {};
+
+ for (let i = 0; i < coordinates.length - 1; i++) {
+ const a = coordinates[i];
+ const b = coordinates[i + 1];
+ if (!a || !b || a.length < 2 || b.length < 2) continue;
+ const d = haversineMeters(a[1]!, a[0]!, b[1]!, b[0]!);
+ if (!(d > 0)) continue;
+ const s = surfaces[i] || "unknown";
+ const h = highways[i] || "unknown";
+ surface[s] = (surface[s] ?? 0) + d;
+ highway[h] = (highway[h] ?? 0) + d;
+ }
+
+ return { surface, highway };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 22a205a..8f5cad2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -241,6 +241,9 @@ importers:
'@trails-cool/jobs':
specifier: workspace:*
version: link:../../packages/jobs
+ '@trails-cool/map-core':
+ specifier: workspace:*
+ version: link:../../packages/map-core
'@trails-cool/sentry-config':
specifier: workspace:*
version: link:../../packages/sentry-config