route-surface-breakdown (Phase 1): Planner-path surface/waytype bars

Synchronous path + rendering for the surface/waytype breakdown:
- map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance-
  weighted metres per surface + waytype category (unit-tested);
- `SurfaceBreakdownSchema` in @trails-cool/api;
- nullable `surfaceBreakdown` jsonb on routes + activities;
- Planner `SaveToJournalButton` computes it from the BRouter waytags already in
  routeData and sends it; `api.save-to-journal` forwards it; the journal route
  callback validates + persists (journal is the authoritative validator);
- `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes,
  legend category · % · km largest-first, unknown → "other", hidden when empty)
  on route + activity detail; journal gains a @trails-cool/map-core dep;
- i18n journal.surface.* (en + de).

Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that
seeds a breakdown) follows in a separate PR.

Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component
(jsdom, journal 326). typecheck + lint green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-14 18:46:49 +02:00
parent e1e3dc5c81
commit 3a1c34317d
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
21 changed files with 388 additions and 18 deletions

View file

@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
afterEach(cleanup);
describe("SurfaceBreakdown", () => {
it("renders nothing without a breakdown", () => {
const { container } = render(<SurfaceBreakdown breakdown={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when every bucket is zero", () => {
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
expect(container.firstChild).toBeNull();
});
it("renders a legend item per non-zero category with its percentage", () => {
const { container, getByText } = render(
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
);
// 2 surface + 1 waytype = 3 legend entries
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
});
it("sorts segments largest-first within a dimension", () => {
const { container } = render(
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
);
// first bar's first segment is the larger (asphalt 70%)
const firstBar = container.querySelector("div.flex.h-3");
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
expect(widths[0]).toBe("70%");
expect(widths[1]).toBe("30%");
});
});

View file

@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import {
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
} from "@trails-cool/map-core";
import { formatDistanceKm } from "~/lib/stats";
export interface SurfaceBreakdownData {
surface: Record<string, number>;
highway: Record<string, number>;
}
function Bar({
title,
data,
colorFor,
}: {
title: string;
data: Record<string, number>;
colorFor: (category: string) => string;
}) {
const { t } = useTranslation("journal");
const entries = Object.entries(data)
.filter(([, m]) => m > 0)
.sort((a, b) => b[1] - a[1]);
const total = entries.reduce((s, [, m]) => s + m, 0);
if (total <= 0) return null;
const label = (cat: string) =>
cat === "unknown"
? t("surface.other")
: t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") });
return (
<div className="mt-3 first:mt-0">
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
<div className="flex h-3 w-full overflow-hidden rounded">
{entries.map(([cat, m]) => (
<div
key={cat}
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
title={`${label(cat)} · ${formatDistanceKm(m)}`}
/>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{entries.map(([cat, m]) => (
<li key={cat} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorFor(cat) }}
aria-hidden
/>
<span>{label(cat)}</span>
<span className="tabular-nums text-gray-400">
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* Surface + waytype proportion bars (route-surface-breakdown). Renders nothing
* when there's no breakdown data. Colours come from the shared map-core
* palettes; unknown tags collapse into "other".
*/
export function SurfaceBreakdown({
breakdown,
className,
}: {
breakdown: SurfaceBreakdownData | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!breakdown) return null;
const hasSurface = Object.values(breakdown.surface).some((m) => m > 0);
const hasHighway = Object.values(breakdown.highway).some((m) => m > 0);
if (!hasSurface && !hasHighway) return null;
return (
<div className={className}>
<Bar
title={t("surface.surface")}
data={breakdown.surface}
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
/>
<Bar
title={t("surface.waytype")}
data={breakdown.highway}
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
/>
</div>
);
}

View file

@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { eq, desc, and, inArray, isNotNull } from "drizzle-orm";
import { getDb } from "./db.ts";
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import type { Visibility, SurfaceBreakdown } from "@trails-cool/db/schema/journal";
import { sql } from "drizzle-orm";
import { processGpx, writeGeom } from "./gpx-save.server.ts";
import type { ProcessedGpx } from "./gpx-save.server.ts";
@ -20,6 +20,7 @@ export interface RouteInput {
elevationLoss?: number | null;
dayBreaks?: number[];
synthetic?: boolean;
surfaceBreakdown?: SurfaceBreakdown;
}
export async function createRoute(ownerId: string, input: RouteInput) {
@ -139,6 +140,7 @@ export async function updateRoute(route: OwnedRef, input: Partial<RouteInput>) {
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.visibility !== undefined) updateData.visibility = input.visibility;
if (input.surfaceBreakdown !== undefined) updateData.surfaceBreakdown = input.surfaceBreakdown;
if (input.gpx && processed) {
const { stats } = processed;

View file

@ -65,6 +65,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine
duration: activity.duration,
movingTimeSec,
elevation,
surfaceBreakdown: activity.surfaceBreakdown ?? null,
routeId: activity.routeId,
hasGpx: !!activity.gpx,
geojson: activity.geojson ?? null,

View file

@ -7,6 +7,7 @@ import { ClientMap } from "~/components/ClientMap";
import { SportBadge } from "~/components/SportBadge";
import { StatRow } from "~/components/StatRow";
import { ElevationProfile } from "~/components/ElevationProfile";
import { SurfaceBreakdown } from "~/components/SurfaceBreakdown";
import { activityStatItems } from "~/lib/stats";
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
import {
@ -142,6 +143,8 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
/>
)}
<SurfaceBreakdown breakdown={activity.surfaceBreakdown} className="mt-6" />
{activity.routeId && (
<div className="mt-6">
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">

View file

@ -1,4 +1,5 @@
import { data } from "react-router";
import { SurfaceBreakdownSchema } from "@trails-cool/api";
import type { Route } from "./+types/api.routes.$id.callback";
import { verifyRouteToken } from "~/lib/jwt.server";
import { updateRoute, getRoute } from "~/lib/routes.server";
@ -56,16 +57,23 @@ export async function action({ params, request }: Route.ActionArgs) {
// Parse GPX from request body
const body = await request.json();
const { gpx } = body as { gpx: string };
const { gpx, surfaceBreakdown } = body as { gpx: string; surfaceBreakdown?: unknown };
if (!gpx) {
return data({ error: "Missing GPX data" }, { status: 400, headers: corsHeaders() });
}
// Optional, best-effort surface/waytype breakdown from the Planner's BRouter
// waytags (route-surface-breakdown, Path 1) — stored if well-formed, ignored otherwise.
const sb = SurfaceBreakdownSchema.safeParse(surfaceBreakdown);
// Update route with new GPX (creates new version). Authorization
// here comes from the verified single-use route token, not a
// session — vouchOwnership marks that explicitly.
await updateRoute(vouchOwnership(route), { gpx });
await updateRoute(vouchOwnership(route), {
gpx,
...(sb.success ? { surfaceBreakdown: sb.data } : {}),
});
return data({ success: true, routeId: params.id }, { headers: corsHeaders() });
} catch (e) {

View file

@ -126,6 +126,7 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
hasGpx: !!route.gpx,
dayBreaks: route.dayBreaks ?? [],
elevation,
surfaceBreakdown: route.surfaceBreakdown ?? null,
geojson: routeWithGeojson?.geojson ?? null,
visibility: route.visibility,
createdAt: route.createdAt.toISOString(),

View file

@ -6,6 +6,7 @@ import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { StatRow } from "~/components/StatRow";
import { ElevationProfile } from "~/components/ElevationProfile";
import { SurfaceBreakdown } from "~/components/SurfaceBreakdown";
import { activityStatItems } from "~/lib/stats";
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
@ -332,6 +333,8 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
/>
)}
<SurfaceBreakdown breakdown={route.surfaceBreakdown} className="mt-6" />
{isEmpty && (
<div className="mt-6 flex flex-col items-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-6 py-12 text-center">
<div className="text-4xl" aria-hidden="true">🗺</div>

View file

@ -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:",