Add sidebar day breakdown and overnight toggle UI

- DayBreakdown: Collapsible day sections with per-day stats
- WaypointSidebar: Overnight toggle button (moon icon), day-grouped view
  when any waypoint is marked overnight, route summary in header
- SessionView: Wire useDays() hook into sidebar via SidebarTabs
- i18n: Add multiDay keys for en + de (day labels, overnight, stats)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-10 23:53:43 +02:00
parent efdb3c1973
commit 94016fd4c4
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 190 additions and 52 deletions

View file

@ -0,0 +1,54 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { DayStage } from "@trails-cool/gpx";
interface DayBreakdownProps {
days: DayStage[];
children: (dayStage: DayStage, waypointIndices: { start: number; end: number }) => React.ReactNode;
}
export function DayBreakdown({ days, children }: DayBreakdownProps) {
const { t } = useTranslation();
const [expandedDay, setExpandedDay] = useState(1);
return (
<div className="flex flex-col">
{days.map((day) => {
const isExpanded = expandedDay === day.dayNumber;
return (
<div key={day.dayNumber}>
<button
onClick={() => setExpandedDay(isExpanded ? -1 : day.dayNumber)}
className="flex w-full items-center gap-2 border-b border-gray-100 px-4 py-2 text-left hover:bg-gray-50"
>
<span className="text-xs font-semibold text-gray-500">
{t("planner.multiDay.dayLabel", { n: day.dayNumber })}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-gray-600">
{day.startName && day.endName
? `${day.startName}${day.endName}`
: day.startName || day.endName || ""}
</span>
<span className="shrink-0 text-xs tabular-nums text-gray-500">
{(day.distance / 1000).toFixed(1)} km
</span>
<span className="shrink-0 text-xs text-gray-400">
{isExpanded ? "▾" : "▸"}
</span>
</button>
{isExpanded && (
<>
<div className="flex gap-3 border-b border-gray-100 bg-gray-50 px-4 py-1.5 text-[10px] text-gray-500">
<span> {day.ascent} m</span>
<span> {day.descent} m</span>
</div>
{children(day, { start: day.startWaypointIndex, end: day.endWaypointIndex })}
</>
)}
</div>
);
})}
</div>
);
}

View file

@ -5,6 +5,7 @@ import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react";
import { useYjs, type YjsState } from "~/lib/use-yjs";
import { useRouting, type RouteError } from "~/lib/use-routing";
import { useDays } from "~/lib/use-days";
import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
@ -144,6 +145,7 @@ function ColorModeToggle({ yjs }: { yjs: YjsState }) {
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
const { t } = useTranslation("planner");
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
const days = useDays(yjs);
return (
<aside className="hidden w-72 border-l border-gray-200 bg-white md:flex md:flex-col">
@ -164,7 +166,7 @@ function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnTyp
<div className="flex-1 overflow-hidden">
{tab === "waypoints" ? (
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
<WaypointSidebar yjs={yjs} routeStats={routeStats} days={days} />
</Suspense>
) : (
<NotesPanel yjs={yjs} />

View file

@ -1,11 +1,16 @@
import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
import type { DayStage } from "@trails-cool/gpx";
import { setOvernight, isOvernight } from "~/lib/overnight";
import { DayBreakdown } from "./DayBreakdown";
interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
@ -13,6 +18,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
@ -23,9 +29,11 @@ interface WaypointSidebarProps {
elevationGain?: number;
elevationLoss?: number;
};
days: DayStage[];
}
export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
export function WaypointSidebar({ yjs, routeStats, days }: WaypointSidebarProps) {
const { t } = useTranslation();
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
useEffect(() => {
@ -47,92 +55,144 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
if (from === to || from < 0 || to < 0) return;
const item = yjs.waypoints.get(from);
if (!item) return;
const data = { lat: item.get("lat") as number, lon: item.get("lon") as number, name: item.get("name") as string | undefined };
const data = {
lat: item.get("lat") as number,
lon: item.get("lon") as number,
name: item.get("name") as string | undefined,
overnight: isOvernight(item),
};
yjs.doc.transact(() => {
yjs.waypoints.delete(from, 1);
const yMap = new Y.Map();
yMap.set("lat", data.lat);
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
if (data.overnight) yMap.set("overnight", true);
yjs.waypoints.insert(to, [yMap]);
}, "local");
},
[yjs.waypoints, yjs.doc],
);
const toggleOvernight = useCallback(
(index: number) => {
const wp = waypoints[index];
if (!wp) return;
setOvernight(yjs, index, !wp.overnight);
},
[yjs, waypoints],
);
const hasMultipleDays = days.length > 1;
const renderWaypointRow = (wp: WaypointData, i: number) => (
<li key={i} className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-700">
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
</p>
</div>
{wp.overnight && (
<span className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-50 border border-amber-200">
{t("planner.multiDay.overnight")}
</span>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{/* Overnight toggle — not on first or last waypoint */}
{i > 0 && i < waypoints.length - 1 && (
<button
onClick={() => toggleOvernight(i)}
className={`rounded p-1 ${wp.overnight ? "text-amber-600 hover:bg-amber-100" : "text-gray-400 hover:bg-gray-200 hover:text-gray-600"}`}
title={wp.overnight ? t("planner.multiDay.removeOvernight") : t("planner.multiDay.markOvernight")}
>
</button>
)}
{i > 0 && (
<button
onClick={() => moveWaypoint(i, i - 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move up"
>
</button>
)}
{i < waypoints.length - 1 && (
<button
onClick={() => moveWaypoint(i, i + 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move down"
>
</button>
)}
<button
onClick={() => deleteWaypoint(i)}
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
title="Delete"
>
×
</button>
</div>
</li>
);
return (
<div className="flex h-full flex-col">
{/* Header with route summary */}
<div className="border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">
Waypoints ({waypoints.length})
{t("planner.sidebar.waypoints")} ({waypoints.length})
</h2>
{routeStats && routeStats.distance !== undefined && (
<p className="mt-0.5 text-xs text-gray-500">
{(routeStats.distance / 1000).toFixed(1)} km
{routeStats.elevationGain !== undefined && ` · ↑${routeStats.elevationGain} m`}
{hasMultipleDays && ` · ${t("planner.multiDay.dayCount", { count: days.length })}`}
</p>
)}
</div>
{/* Waypoint list */}
<div className="flex-1 overflow-y-auto">
{waypoints.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-gray-500">
Click on the map to add waypoints
</p>
) : hasMultipleDays ? (
<DayBreakdown days={days}>
{(_day, { start, end }) => (
<ul className="divide-y divide-gray-100">
{waypoints.slice(start, end + 1).map((wp, offset) => renderWaypointRow(wp, start + offset))}
</ul>
)}
</DayBreakdown>
) : (
<ul className="divide-y divide-gray-100">
{waypoints.map((wp, i) => (
<li key={i} className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-700">
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{i > 0 && (
<button
onClick={() => moveWaypoint(i, i - 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move up"
>
</button>
)}
{i < waypoints.length - 1 && (
<button
onClick={() => moveWaypoint(i, i + 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move down"
>
</button>
)}
<button
onClick={() => deleteWaypoint(i)}
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
title="Delete"
>
×
</button>
</div>
</li>
))}
{waypoints.map((wp, i) => renderWaypointRow(wp, i))}
</ul>
)}
</div>
{routeStats && routeStats.distance !== undefined && (
{/* Stats footer — only shown for single-day view (multi-day shows per-day stats inline) */}
{!hasMultipleDays && routeStats && routeStats.distance !== undefined && (
<div className="border-t border-gray-200 px-4 py-3">
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<p className="font-medium text-gray-900">
{(routeStats.distance / 1000).toFixed(1)} km
</p>
<p className="text-gray-500">Distance</p>
<p className="text-gray-500">{t("planner.multiDay.distance")}</p>
</div>
{routeStats.elevationGain !== undefined && (
<div>
<p className="font-medium text-gray-900">
{routeStats.elevationGain} m
</p>
<p className="text-gray-500">Ascent</p>
<p className="text-gray-500">{t("planner.multiDay.ascent")}</p>
</div>
)}
{routeStats.elevationLoss !== undefined && (
@ -140,7 +200,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
<p className="font-medium text-gray-900">
{routeStats.elevationLoss} m
</p>
<p className="text-gray-500">Descent</p>
<p className="text-gray-500">{t("planner.multiDay.descent")}</p>
</div>
)}
</div>

View file

@ -6,10 +6,10 @@
## 2. Sidebar Day Breakdown
- [ ] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
- [ ] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
- [ ] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
- [ ] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
- [x] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
- [x] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
- [x] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
- [x] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
## 3. Map Integration
@ -37,7 +37,7 @@
## 7. i18n
- [ ] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
- [x] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
- [ ] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels
## 8. Testing

View file

@ -58,6 +58,17 @@ export default {
waypoints: "Wegpunkte",
notes: "Notizen",
},
multiDay: {
dayLabel: "Tag {{n}}",
dayCount: "{{count}} Tage",
dayCount_one: "1 Tag",
overnight: "Übernachtung",
markOvernight: "Als Übernachtung markieren",
removeOvernight: "Übernachtung entfernen",
distance: "Distanz",
ascent: "Anstieg",
descent: "Abstieg",
},
noGoAreas: {
draw: "Sperrgebiet zeichnen",
cancel: "Sperrgebiet abbrechen",

View file

@ -58,6 +58,17 @@ export default {
waypoints: "Waypoints",
notes: "Notes",
},
multiDay: {
dayLabel: "Day {{n}}",
dayCount: "{{count}} days",
dayCount_one: "1 day",
overnight: "Overnight",
markOvernight: "Mark as overnight stop",
removeOvernight: "Remove overnight stop",
distance: "Distance",
ascent: "Ascent",
descent: "Descent",
},
noGoAreas: {
draw: "Draw no-go area",
cancel: "Cancel no-go area",