- {route.distance != null && (
-
{(route.distance / 1000).toFixed(1)} km
- )}
- {route.elevationGain != null && (
-
↑ {route.elevationGain} m
- )}
+
+
+ {route.geojson ? (
+
}>
+
+
+ ) : (
+
+ {t("routes.noMapPreview")}
+
+ )}
+
+
+
+
{route.name}
+
+ {route.distance != null && (
+ {(route.distance / 1000).toFixed(1)} km
+ )}
+ {route.elevationGain != null && (
+ ↑ {route.elevationGain} m
+ )}
+
+
+
+
+
+
diff --git a/openspec/changes/journal-route-previews/.openspec.yaml b/openspec/changes/journal-route-previews/.openspec.yaml
new file mode 100644
index 0000000..c430c5f
--- /dev/null
+++ b/openspec/changes/journal-route-previews/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-03
diff --git a/openspec/changes/journal-route-previews/design.md b/openspec/changes/journal-route-previews/design.md
new file mode 100644
index 0000000..c61ff49
--- /dev/null
+++ b/openspec/changes/journal-route-previews/design.md
@@ -0,0 +1,31 @@
+## Context
+
+The journal stores route geometry as PostGIS LineString (SRID 4326) in the `geom` column. The `@trails-cool/map` package provides `MapView` and `RouteLayer` components built on React Leaflet. Currently these are only used by the planner — the journal has zero map UI.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Show route shape on list pages as small map thumbnails that auto-fit to the route bounds
+- Show interactive read-only map on detail pages with route overlay
+- Use existing `@trails-cool/map` components — no new map library
+- Keep list pages fast (geometry is small as GeoJSON, lazy-load Leaflet)
+
+**Non-Goals:**
+- Editable maps in the journal (editing happens in the planner)
+- Server-side map image generation (static tiles) — use client-side Leaflet for both
+- Elevation profile on detail pages — future work
+
+## Decisions
+
+**GeoJSON from PostGIS:** Use `ST_AsGeoJSON(geom)` in SQL queries to convert geometry to GeoJSON strings. Parse on the server and include in loader data. This avoids sending raw GPX to the client just for rendering.
+
+**List thumbnails:** Small `MapView` (e.g., 200x150px) with `RouteLayer`, no controls, no interaction (`dragging: false`, `zoomControl: false`). Auto-fit bounds to route with padding. Lazy-loaded via `Suspense` to keep initial page load fast.
+
+**Detail page map:** Full-width `MapView` with standard controls, auto-fit to route. Same `RouteLayer` component.
+
+**Fallback for routes without geometry:** Show a placeholder (e.g., muted map icon) when `geom` is null. This handles legacy routes created before the PostGIS fix.
+
+## Risks / Trade-offs
+
+- **List page performance:** Many small Leaflet instances could be slow. Mitigate with lazy loading and keeping the component simple (no tile layers until visible via Intersection Observer, or just accept the trade-off for v1).
+- **Geometry size:** A route with 5000 points produces ~100KB of GeoJSON. For list pages, we could simplify the geometry server-side with `ST_Simplify()` to reduce payload.
diff --git a/openspec/changes/journal-route-previews/proposal.md b/openspec/changes/journal-route-previews/proposal.md
new file mode 100644
index 0000000..53d98bf
--- /dev/null
+++ b/openspec/changes/journal-route-previews/proposal.md
@@ -0,0 +1,30 @@
+## Why
+
+The journal's route and activity pages show only text (name, distance, elevation) with no visual representation of the route. Users can't tell routes apart without clicking into each one. Every other route planning app shows a map preview — it's table-stakes UX.
+
+The PostGIS `geom` column is already populated, and `@trails-cool/map` provides `MapView` + `RouteLayer` components. The infrastructure exists, just needs wiring up.
+
+## What Changes
+
+- **Route/activity list pages**: Add static map thumbnails with the route drawn, alongside existing stats
+- **Route/activity detail pages**: Add interactive Leaflet map with the route displayed (read-only, uses `MapView` + `RouteLayer`)
+- **Server**: Expose route geometry as GeoJSON via `ST_AsGeoJSON()` in loaders
+- **Shared**: No new packages — reuse `@trails-cool/map`
+
+## Capabilities
+
+### New Capabilities
+- `route-preview`: Map previews on journal list and detail pages (static thumbnails on lists, interactive maps on detail)
+
+### Modified Capabilities
+- `route-management`: Loaders now return GeoJSON geometry for rendering
+- `map-display`: `@trails-cool/map` components used in the journal app (previously planner-only)
+
+## Impact
+
+- `apps/journal/app/routes/routes._index.tsx` — add map thumbnails to route cards
+- `apps/journal/app/routes/routes.$id.tsx` — add interactive map to detail page
+- `apps/journal/app/routes/activities._index.tsx` — add map thumbnails to activity cards
+- `apps/journal/app/routes/activities.$id.tsx` — add interactive map to detail page
+- `apps/journal/app/lib/routes.server.ts` — expose GeoJSON from PostGIS
+- `apps/journal/app/lib/activities.server.ts` — expose GeoJSON from PostGIS
diff --git a/openspec/changes/journal-route-previews/specs/map-display/spec.md b/openspec/changes/journal-route-previews/specs/map-display/spec.md
new file mode 100644
index 0000000..6a0422b
--- /dev/null
+++ b/openspec/changes/journal-route-previews/specs/map-display/spec.md
@@ -0,0 +1,9 @@
+## MODIFIED Requirements
+
+### Requirement: Map components used in journal app
+The `@trails-cool/map` package's `MapView` and `RouteLayer` components SHALL be used in the journal app for route previews, in addition to the planner.
+
+#### Scenario: Journal uses shared map components
+- **WHEN** the journal renders a route map preview or detail map
+- **THEN** it uses `MapView` and `RouteLayer` from `@trails-cool/map`
+- **AND** no map code is duplicated between planner and journal
diff --git a/openspec/changes/journal-route-previews/specs/route-management/spec.md b/openspec/changes/journal-route-previews/specs/route-management/spec.md
new file mode 100644
index 0000000..937fcb7
--- /dev/null
+++ b/openspec/changes/journal-route-previews/specs/route-management/spec.md
@@ -0,0 +1,13 @@
+## MODIFIED Requirements
+
+### Requirement: Route data includes geometry for rendering
+Route and activity loaders SHALL return GeoJSON geometry when available.
+
+#### Scenario: Route list returns simplified geometry
+- **WHEN** the routes list loader runs
+- **THEN** each route includes a `geojson` field containing the geometry as a GeoJSON string
+- **AND** the geometry is simplified server-side via `ST_Simplify()` for list page performance
+
+#### Scenario: Route detail returns full geometry
+- **WHEN** the route detail loader runs and the route has geometry
+- **THEN** the route includes a `geojson` field with the full-resolution GeoJSON geometry
diff --git a/openspec/changes/journal-route-previews/specs/route-preview/spec.md b/openspec/changes/journal-route-previews/specs/route-preview/spec.md
new file mode 100644
index 0000000..6b4a297
--- /dev/null
+++ b/openspec/changes/journal-route-previews/specs/route-preview/spec.md
@@ -0,0 +1,34 @@
+## ADDED Requirements
+
+### Requirement: Route map preview on list pages
+Route and activity list pages SHALL show a small map thumbnail for each item that has geometry.
+
+#### Scenario: Route with geometry
+- **WHEN** the routes list page loads and a route has a `geom` column
+- **THEN** a small map thumbnail is rendered showing the route path
+- **AND** the map auto-fits to the route bounds
+
+#### Scenario: Route without geometry
+- **WHEN** a route has no `geom` (legacy route)
+- **THEN** a placeholder is shown instead of a map thumbnail
+
+#### Scenario: Activity with geometry
+- **WHEN** the activities list page loads and an activity has a `geom` column
+- **THEN** a small map thumbnail is rendered showing the activity path
+
+### Requirement: Interactive map on detail pages
+Route and activity detail pages SHALL show an interactive read-only map with the route/activity drawn.
+
+#### Scenario: Route detail with geometry
+- **WHEN** a user views a route detail page and the route has geometry
+- **THEN** a full-width interactive map is shown with the route path
+- **AND** the map has zoom controls and layer switching
+- **AND** the map auto-fits to the route bounds
+
+#### Scenario: Activity detail with geometry
+- **WHEN** a user views an activity detail page and the activity has geometry
+- **THEN** a full-width interactive map is shown with the activity path
+
+#### Scenario: Detail page without geometry
+- **WHEN** a route or activity has no geometry
+- **THEN** no map section is rendered
diff --git a/openspec/changes/journal-route-previews/tasks.md b/openspec/changes/journal-route-previews/tasks.md
new file mode 100644
index 0000000..7849b11
--- /dev/null
+++ b/openspec/changes/journal-route-previews/tasks.md
@@ -0,0 +1,37 @@
+## 1. Server — Expose GeoJSON
+
+- [x] 1.1 Add `geojsonFromGeom()` helper using `ST_AsGeoJSON(geom)` to convert PostGIS geometry to GeoJSON string
+- [x] 1.2 Update `listRoutes()` to return simplified GeoJSON per route via `ST_AsGeoJSON(ST_Simplify(geom, 0.001))`
+- [x] 1.3 Update `getRoute()` to return full-resolution GeoJSON
+- [x] 1.4 Update `listActivities()` to return simplified GeoJSON per activity
+- [x] 1.5 Update `getActivity()` to return full-resolution GeoJSON
+
+## 2. Route List Page — Map Thumbnails
+
+- [x] 2.1 Create `RouteMapThumbnail` component: small MapView + RouteLayer, no controls, auto-fit bounds
+- [x] 2.2 Add thumbnail to each route card in `routes._index.tsx` (lazy-loaded via Suspense)
+- [x] 2.3 Show placeholder when route has no geometry
+
+## 3. Activity List Page — Map Thumbnails
+
+- [x] 3.1 Add thumbnail to each activity card in `activities._index.tsx` (reuse `RouteMapThumbnail`)
+- [x] 3.2 Show placeholder when activity has no geometry
+
+## 4. Route Detail Page — Interactive Map
+
+- [x] 4.1 Add full-width MapView + RouteLayer to `routes.$id.tsx`, auto-fit bounds
+- [x] 4.2 Skip map section when route has no geometry
+
+## 5. Activity Detail Page — Interactive Map
+
+- [x] 5.1 Add full-width MapView + RouteLayer to `activities.$id.tsx`, auto-fit bounds
+- [x] 5.2 Skip map section when activity has no geometry
+
+## 6. i18n
+
+- [x] 6.1 Add translation keys for map placeholder text (en + de)
+
+## 7. Testing
+
+- [x] 7.1 E2E: route detail page shows map when route has geometry
+- [x] 7.2 E2E: route list page renders without errors
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 9f6fd53..2f1b70a 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -123,6 +123,7 @@ export default {
distance: "Strecke",
elevationGain: "Höhenmeter",
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
+ noMapPreview: "Keine Kartenvorschau",
saveChanges: "Änderungen speichern",
},
activities: {
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 8afc420..74bbfad 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -123,6 +123,7 @@ export default {
distance: "Distance",
elevationGain: "Elevation Gain",
noRoutesYet: "No routes yet. Create your first route!",
+ noMapPreview: "No map preview",
saveChanges: "Save Changes",
},
activities: {
From 743169550e8a2a33ea3f57633ddeaf6921e0e9ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 4 Apr 2026 10:04:01 +0100
Subject: [PATCH 054/723] Add undo/redo to planner
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Yjs UndoManager tracks waypoints, no-go areas, and notes. Only
local mutations (origin "local") are undoable — remote changes
from other users are not.
- Keyboard shortcuts: Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z/Y (redo)
- Shortcuts suppressed in text inputs (browser-native undo there)
- Undo/redo buttons in header with disabled state
- stopCapturing on drag to isolate drag as one undo step
- All mutation sites wrapped with "local" origin
- 5 unit tests for UndoManager behavior
- Archived undo-redo change
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/planner/app/components/NoGoAreaLayer.tsx | 4 +-
apps/planner/app/components/NotesPanel.tsx | 2 +-
apps/planner/app/components/PlannerMap.tsx | 34 +++---
apps/planner/app/components/SessionView.tsx | 21 ++++
.../app/components/WaypointSidebar.tsx | 8 +-
apps/planner/app/lib/use-undo.test.ts | 105 ++++++++++++++++++
apps/planner/app/lib/use-undo.ts | 58 ++++++++++
apps/planner/app/lib/use-yjs.ts | 8 ++
.../2026-04-04-undo-redo}/.openspec.yaml | 0
.../2026-04-04-undo-redo}/design.md | 0
.../2026-04-04-undo-redo}/proposal.md | 0
.../2026-04-04-undo-redo}/tasks.md | 24 ++--
packages/i18n/src/locales/de.ts | 2 +
packages/i18n/src/locales/en.ts | 2 +
14 files changed, 236 insertions(+), 32 deletions(-)
create mode 100644 apps/planner/app/lib/use-undo.test.ts
create mode 100644 apps/planner/app/lib/use-undo.ts
rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/.openspec.yaml (100%)
rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/design.md (100%)
rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/proposal.md (100%)
rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/tasks.md (76%)
diff --git a/apps/planner/app/components/NoGoAreaLayer.tsx b/apps/planner/app/components/NoGoAreaLayer.tsx
index 2111e7d..b6508ee 100644
--- a/apps/planner/app/components/NoGoAreaLayer.tsx
+++ b/apps/planner/app/components/NoGoAreaLayer.tsx
@@ -47,7 +47,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay
polygon.on("contextmenu", (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
suppressObserverRef.current = true;
- noGoAreas.delete(i, 1);
+ doc.transact(() => noGoAreas.delete(i, 1), "local");
suppressObserverRef.current = false;
syncLayers();
});
@@ -101,7 +101,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay
yMap.set("points", points);
doc.transact(() => {
noGoAreas.push([yMap]);
- });
+ }, "local");
// Exit draw mode
onToggle();
diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx
index 2c870bf..caaf9bb 100644
--- a/apps/planner/app/components/NotesPanel.tsx
+++ b/apps/planner/app/components/NotesPanel.tsx
@@ -42,7 +42,7 @@ export function NotesPanel({ yjs }: NotesPanelProps) {
yjs.doc.transact(() => {
yjs.notes.delete(0, yjs.notes.length);
yjs.notes.insert(0, newValue);
- });
+ }, "local");
isLocalChange.current = false;
},
[yjs.notes, yjs.doc],
diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx
index 73d8a86..f1986d6 100644
--- a/apps/planner/app/components/PlannerMap.tsx
+++ b/apps/planner/app/components/PlannerMap.tsx
@@ -294,23 +294,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const addWaypoint = useCallback(
(lat: number, lng: number) => {
- const yMap = new Y.Map();
- yMap.set("lat", lat);
- yMap.set("lon", lng);
- yjs.waypoints.push([yMap]);
+ yjs.doc.transact(() => {
+ const yMap = new Y.Map();
+ yMap.set("lat", lat);
+ yMap.set("lon", lng);
+ yjs.waypoints.push([yMap]);
+ }, "local");
},
- [yjs.waypoints],
+ [yjs.doc, yjs.waypoints],
);
const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => {
- const yMap = new Y.Map();
- yMap.set("lat", lat);
- yMap.set("lon", lon);
- // Insert after the segment's start waypoint
- yjs.waypoints.insert(segmentIndex + 1, [yMap]);
+ yjs.doc.transact(() => {
+ const yMap = new Y.Map();
+ yMap.set("lat", lat);
+ yMap.set("lon", lon);
+ yjs.waypoints.insert(segmentIndex + 1, [yMap]);
+ }, "local");
},
- [yjs.waypoints],
+ [yjs.doc, yjs.waypoints],
);
const handleRouteInsert = useCallback(
@@ -329,7 +332,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
yjs.doc.transact(() => {
yMap.set("lat", lat);
yMap.set("lon", lng);
- });
+ }, "local");
}
},
[yjs.waypoints, yjs.doc],
@@ -337,7 +340,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const deleteWaypoint = useCallback(
(index: number) => {
- yjs.waypoints.delete(index, 1);
+ yjs.doc.transact(() => {
+ yjs.waypoints.delete(index, 1);
+ }, "local");
},
[yjs.waypoints],
);
@@ -395,7 +400,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
yMap.set("points", area.points);
yjs.noGoAreas.push([yMap]);
}
- });
+ }, "local");
} catch {
onImportError?.(t("importGpxError"));
}
@@ -449,6 +454,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
},
dragstart: () => {
waypointDraggingRef.current = true;
+ yjs.undoManager.stopCapturing();
routeInteractionSuspendedRef.current = true;
},
dragend: (e) => {
diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx
index 0964f79..6cea400 100644
--- a/apps/planner/app/components/SessionView.tsx
+++ b/apps/planner/app/components/SessionView.tsx
@@ -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 { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
@@ -187,6 +188,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas);
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs);
+ const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
+ useUndoShortcuts(yjs?.undoManager ?? null);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const { toasts, addToast } = useToasts();
useAwarenessToasts(yjs, t, addToast);
@@ -225,6 +228,24 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
+
{callbackUrl && callbackToken && (
diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx
index a041581..bc98ac9 100644
--- a/apps/planner/app/components/WaypointSidebar.tsx
+++ b/apps/planner/app/components/WaypointSidebar.tsx
@@ -36,8 +36,10 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
}, [yjs.waypoints]);
const deleteWaypoint = useCallback(
- (index: number) => yjs.waypoints.delete(index, 1),
- [yjs.waypoints],
+ (index: number) => {
+ yjs.doc.transact(() => yjs.waypoints.delete(index, 1), "local");
+ },
+ [yjs.doc, yjs.waypoints],
);
const moveWaypoint = useCallback(
@@ -53,7 +55,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
yjs.waypoints.insert(to, [yMap]);
- });
+ }, "local");
},
[yjs.waypoints, yjs.doc],
);
diff --git a/apps/planner/app/lib/use-undo.test.ts b/apps/planner/app/lib/use-undo.test.ts
new file mode 100644
index 0000000..0ec0fe0
--- /dev/null
+++ b/apps/planner/app/lib/use-undo.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from "vitest";
+import * as Y from "yjs";
+
+describe("Y.UndoManager with tracked origins", () => {
+ it("tracks mutations with 'local' origin", () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) });
+
+ doc.transact(() => arr.push(["a"]), "local");
+ expect(arr.toArray()).toEqual(["a"]);
+ expect(um.undoStack.length).toBe(1);
+
+ um.undo();
+ expect(arr.toArray()).toEqual([]);
+ expect(um.redoStack.length).toBe(1);
+
+ um.redo();
+ expect(arr.toArray()).toEqual(["a"]);
+ });
+
+ it("does not track mutations without 'local' origin", () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) });
+
+ // No origin
+ doc.transact(() => arr.push(["init"]));
+ expect(arr.toArray()).toEqual(["init"]);
+ expect(um.undoStack.length).toBe(0);
+
+ // Different origin
+ doc.transact(() => arr.push(["remote"]), "remote");
+ expect(arr.toArray()).toEqual(["init", "remote"]);
+ expect(um.undoStack.length).toBe(0);
+ });
+
+ it("groups rapid changes within captureTimeout", async () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], {
+ trackedOrigins: new Set(["local"]),
+ captureTimeout: 100,
+ });
+
+ doc.transact(() => arr.push(["a"]), "local");
+ doc.transact(() => arr.push(["b"]), "local");
+ // Both within timeout — should be one undo step
+ expect(um.undoStack.length).toBe(1);
+
+ um.undo();
+ expect(arr.toArray()).toEqual([]);
+ });
+
+ it("stopCapturing separates undo steps", () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], {
+ trackedOrigins: new Set(["local"]),
+ captureTimeout: 10000,
+ });
+
+ doc.transact(() => arr.push(["a"]), "local");
+ um.stopCapturing();
+ doc.transact(() => arr.push(["b"]), "local");
+
+ expect(um.undoStack.length).toBe(2);
+
+ um.undo();
+ expect(arr.toArray()).toEqual(["a"]);
+
+ um.undo();
+ expect(arr.toArray()).toEqual([]);
+ });
+
+ it("tracks multiple shared types", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray("waypoints");
+ const noGoAreas = doc.getArray("noGoAreas");
+ const notes = doc.getText("notes");
+ const um = new Y.UndoManager([waypoints, noGoAreas, notes], {
+ trackedOrigins: new Set(["local"]),
+ });
+
+ doc.transact(() => waypoints.push(["wp1"]), "local");
+ um.stopCapturing();
+ doc.transact(() => notes.insert(0, "hello"), "local");
+ um.stopCapturing();
+ doc.transact(() => noGoAreas.push(["nogo1"]), "local");
+
+ expect(um.undoStack.length).toBe(3);
+
+ // Undo in reverse order
+ um.undo(); // undo noGoAreas
+ expect(noGoAreas.toArray()).toEqual([]);
+ expect(notes.toString()).toBe("hello");
+
+ um.undo(); // undo notes
+ expect(notes.toString()).toBe("");
+ expect(waypoints.toArray()).toEqual(["wp1"]);
+
+ um.undo(); // undo waypoints
+ expect(waypoints.toArray()).toEqual([]);
+ });
+});
diff --git a/apps/planner/app/lib/use-undo.ts b/apps/planner/app/lib/use-undo.ts
new file mode 100644
index 0000000..f4d1fa3
--- /dev/null
+++ b/apps/planner/app/lib/use-undo.ts
@@ -0,0 +1,58 @@
+import { useState, useEffect, useCallback } from "react";
+import * as Y from "yjs";
+
+export function useUndo(undoManager: Y.UndoManager | null): { canUndo: boolean; canRedo: boolean; undo: () => void; redo: () => void } {
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+
+ useEffect(() => {
+ if (!undoManager) return;
+ const update = () => {
+ setCanUndo(undoManager.undoStack.length > 0);
+ setCanRedo(undoManager.redoStack.length > 0);
+ };
+ undoManager.on("stack-item-added", update);
+ undoManager.on("stack-item-popped", update);
+ undoManager.on("stack-item-updated", update);
+ update();
+ return () => {
+ undoManager.off("stack-item-added", update);
+ undoManager.off("stack-item-popped", update);
+ undoManager.off("stack-item-updated", update);
+ };
+ }, [undoManager]);
+
+ const undo = useCallback(() => undoManager?.undo(), [undoManager]);
+ const redo = useCallback(() => undoManager?.redo(), [undoManager]);
+
+ return { canUndo, canRedo, undo, redo };
+}
+
+export function useUndoShortcuts(undoManager: Y.UndoManager | null) {
+ useEffect(() => {
+ if (!undoManager) return;
+
+ const handler = (e: KeyboardEvent) => {
+ // Suppress in text inputs — let browser-native undo handle those
+ const tag = (e.target as HTMLElement)?.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) {
+ return;
+ }
+
+ const isMac = navigator.platform.includes("Mac");
+ const mod = isMac ? e.metaKey : e.ctrlKey;
+ if (!mod) return;
+
+ if (e.key === "z" && !e.shiftKey) {
+ e.preventDefault();
+ undoManager.undo();
+ } else if ((e.key === "z" && e.shiftKey) || e.key === "y") {
+ e.preventDefault();
+ undoManager.redo();
+ }
+ };
+
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [undoManager]);
+}
diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts
index bec224d..95135dd 100644
--- a/apps/planner/app/lib/use-yjs.ts
+++ b/apps/planner/app/lib/use-yjs.ts
@@ -34,6 +34,7 @@ export interface YjsState {
routeData: Y.Map
;
noGoAreas: Y.Array>;
notes: Y.Text;
+ undoManager: Y.UndoManager;
awareness: WebsocketProvider["awareness"];
connected: boolean;
setUserName: (name: string) => void;
@@ -119,6 +120,11 @@ export function useYjs(
});
}
+ const undoManager = new Y.UndoManager([waypoints, noGoAreas, notes], {
+ captureTimeout: 500,
+ trackedOrigins: new Set(["local"]),
+ });
+
const updateState = (connected: boolean) => {
setState({
doc,
@@ -127,6 +133,7 @@ export function useYjs(
routeData,
noGoAreas,
notes,
+ undoManager,
awareness: provider.awareness,
connected,
setUserName,
@@ -143,6 +150,7 @@ export function useYjs(
clearInterval(saveInterval);
// Clear localStorage on clean disconnect (session close)
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
+ undoManager.destroy();
provider.destroy();
doc.destroy();
providerRef.current = null;
diff --git a/openspec/changes/undo-redo/.openspec.yaml b/openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml
similarity index 100%
rename from openspec/changes/undo-redo/.openspec.yaml
rename to openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml
diff --git a/openspec/changes/undo-redo/design.md b/openspec/changes/archive/2026-04-04-undo-redo/design.md
similarity index 100%
rename from openspec/changes/undo-redo/design.md
rename to openspec/changes/archive/2026-04-04-undo-redo/design.md
diff --git a/openspec/changes/undo-redo/proposal.md b/openspec/changes/archive/2026-04-04-undo-redo/proposal.md
similarity index 100%
rename from openspec/changes/undo-redo/proposal.md
rename to openspec/changes/archive/2026-04-04-undo-redo/proposal.md
diff --git a/openspec/changes/undo-redo/tasks.md b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md
similarity index 76%
rename from openspec/changes/undo-redo/tasks.md
rename to openspec/changes/archive/2026-04-04-undo-redo/tasks.md
index bde74b7..5c138d8 100644
--- a/openspec/changes/undo-redo/tasks.md
+++ b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md
@@ -1,8 +1,8 @@
## 1. Core: UndoManager Setup
-- [ ] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`)
-- [ ] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function.
-- [ ] 1.3 Add `"local"` origin to all mutation sites:
+- [x] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`)
+- [x] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function.
+- [x] 1.3 Add `"local"` origin to all mutation sites:
- `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")`
- `WaypointSidebar.tsx`: wrap `deleteWaypoint` and `moveWaypoint` in `doc.transact(() => { ... }, "local")`
- `NoGoAreaLayer.tsx`: add `"local"` origin to the `pm:create` and contextmenu delete transactions
@@ -11,24 +11,24 @@
## 2. Keyboard Shortcuts
-- [ ] 2.1 Create a `useUndoShortcuts` hook (in `use-undo.ts` or separate file) that registers a global `keydown` listener for Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y (redo). Calls `undoManager.undo()` / `undoManager.redo()` and calls `e.preventDefault()`.
-- [ ] 2.2 Suppress the shortcut when the active element is ``, `