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:
parent
efdb3c1973
commit
94016fd4c4
6 changed files with 190 additions and 52 deletions
54
apps/planner/app/components/DayBreakdown.tsx
Normal file
54
apps/planner/app/components/DayBreakdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue