diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index ad9df0d..61f41ab 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -9,7 +9,8 @@
"Bash(tree:*)",
"Bash(python:*)",
"Bash(pip --version:*)",
- "Bash(pip install:*)"
+ "Bash(pip install:*)",
+ "Bash(npx vite build:*)"
]
}
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d78e1d2..dba0faf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,10 +3,13 @@ import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import { useSettingsStore } from '@/store/settings.ts';
+import { useHistoryStore, pushToFuture, pushToPast } from '@/store/history.ts';
import { RFCalculator } from '@/rf/calculator.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
+import { useUnsavedChanges } from '@/hooks/useUnsavedChanges.ts';
import { logger } from '@/utils/logger.ts';
+import { db } from '@/db/schema.ts';
import MapView from '@/components/map/Map.tsx';
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
@@ -26,6 +29,26 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
const calculator = new RFCalculator();
+/**
+ * Restore a sites snapshot: replace all sites in IndexedDB + Zustand.
+ * Used by undo/redo.
+ */
+async function restoreSites(snapshot: Site[]) {
+ // Clear current DB entries
+ await db.sites.clear();
+ // Write snapshot sites to DB
+ for (const site of snapshot) {
+ await db.sites.put({
+ id: site.id,
+ data: JSON.stringify(site),
+ createdAt: new Date(site.createdAt).getTime(),
+ updatedAt: new Date(site.updatedAt).getTime(),
+ });
+ }
+ // Reload from DB to sync Zustand
+ await useSitesStore.getState().loadSites();
+}
+
export default function App() {
const loadSites = useSitesStore((s) => s.loadSites);
const sites = useSitesStore((s) => s.sites);
@@ -52,6 +75,13 @@ export default function App() {
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
+ // History (undo/redo)
+ const canUndo = useHistoryStore((s) => s.canUndo);
+ const canRedo = useHistoryStore((s) => s.canRedo);
+
+ // Unsaved changes detection + beforeunload
+ useUnsavedChanges();
+
const [modalState, setModalState] = useState<{
isOpen: boolean;
mode: 'create' | 'edit';
@@ -202,6 +232,49 @@ export default function App() {
}
}, [kbDeleteTarget, sites, addToast]);
+ // === Undo / Redo ===
+ const handleUndo = useCallback(async () => {
+ const snapshot = useHistoryStore.getState().undo();
+ if (!snapshot) return;
+
+ // Save current state to redo stack before restoring
+ const currentSites = useSitesStore.getState().sites;
+ const currentSettings = useCoverageStore.getState().settings;
+ pushToFuture({
+ sites: structuredClone(currentSites),
+ settings: { ...currentSettings },
+ timestamp: Date.now(),
+ action: 'before undo',
+ });
+
+ // Restore sites from snapshot
+ await restoreSites(snapshot.sites);
+ useCoverageStore.getState().updateSettings(snapshot.settings);
+ useCoverageStore.getState().clearCoverage();
+ addToast('Undo', 'info');
+ }, [addToast]);
+
+ const handleRedo = useCallback(async () => {
+ const snapshot = useHistoryStore.getState().redo();
+ if (!snapshot) return;
+
+ // Save current state to undo stack before restoring
+ const currentSites = useSitesStore.getState().sites;
+ const currentSettings = useCoverageStore.getState().settings;
+ pushToPast({
+ sites: structuredClone(currentSites),
+ settings: { ...currentSettings },
+ timestamp: Date.now(),
+ action: 'before redo',
+ });
+
+ // Restore sites from snapshot
+ await restoreSites(snapshot.sites);
+ useCoverageStore.getState().updateSettings(snapshot.settings);
+ useCoverageStore.getState().clearCoverage();
+ addToast('Redo', 'info');
+ }, [addToast]);
+
// Calculate coverage (with better error handling)
const handleCalculate = useCallback(async () => {
const currentSites = useSitesStore.getState().sites;
@@ -305,6 +378,8 @@ export default function App() {
onDeleteRequest: useCallback((id: string, name: string) => {
setKbDeleteTarget({ id, name });
}, []),
+ onUndo: handleUndo,
+ onRedo: handleRedo,
});
return (
@@ -319,6 +394,29 @@ export default function App() {
+ {/* Undo / Redo buttons */}
+
+ {/* Edit */}
+
+
Edit
+
+ -
+ Undo
+ Ctrl+Z
+
+ -
+ Redo
+ Ctrl+Shift+Z
+
+
+
{/* General */}
General
diff --git a/frontend/src/components/panels/ProjectPanel.tsx b/frontend/src/components/panels/ProjectPanel.tsx
index 8513e63..78e64fe 100644
--- a/frontend/src/components/panels/ProjectPanel.tsx
+++ b/frontend/src/components/panels/ProjectPanel.tsx
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useProjectsStore } from '@/store/projects.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
+import { useDirtyStore } from '@/hooks/useUnsavedChanges.ts';
import Button from '@/components/ui/Button.tsx';
+import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
import { logger } from '@/utils/logger.ts';
export default function ProjectPanel() {
@@ -12,11 +14,19 @@ export default function ProjectPanel() {
const loadProject = useProjectsStore((s) => s.loadProject);
const deleteProject = useProjectsStore((s) => s.deleteProject);
const addToast = useToastStore((s) => s.addToast);
+ const isDirty = useDirtyStore((s) => s.isDirty);
const [projectName, setProjectName] = useState('');
const [showSaveForm, setShowSaveForm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ // Confirm dialog state
+ const [confirmAction, setConfirmAction] = useState<{
+ type: 'load' | 'delete';
+ id: string;
+ name: string;
+ } | null>(null);
+
useEffect(() => {
loadProjects();
}, [loadProjects]);
@@ -30,6 +40,7 @@ export default function ProjectPanel() {
try {
await saveProject(name);
+ useDirtyStore.getState().markClean();
addToast(`Project "${name}" saved`, 'success');
setProjectName('');
setShowSaveForm(false);
@@ -39,11 +50,20 @@ export default function ProjectPanel() {
}
};
- const handleLoad = async (id: string, name: string) => {
+ const handleLoadRequest = (id: string, name: string) => {
+ if (isDirty) {
+ setConfirmAction({ type: 'load', id, name });
+ } else {
+ executeLoad(id, name);
+ }
+ };
+
+ const executeLoad = async (id: string, name: string) => {
setIsLoading(true);
try {
const project = await loadProject(id);
if (project) {
+ useDirtyStore.getState().markClean();
addToast(`Loaded project "${name}" (${project.sites.length} sites)`, 'success');
} else {
addToast('Project not found', 'error');
@@ -56,7 +76,11 @@ export default function ProjectPanel() {
}
};
- const handleDelete = async (id: string, name: string) => {
+ const handleDeleteRequest = (id: string, name: string) => {
+ setConfirmAction({ type: 'delete', id, name });
+ };
+
+ const executeDelete = async (id: string, name: string) => {
try {
await deleteProject(id);
addToast(`Project "${name}" deleted`, 'info');
@@ -66,6 +90,18 @@ export default function ProjectPanel() {
}
};
+ const handleConfirm = async () => {
+ if (!confirmAction) return;
+ const { type, id, name } = confirmAction;
+ setConfirmAction(null);
+
+ if (type === 'load') {
+ await executeLoad(id, name);
+ } else {
+ await executeDelete(id, name);
+ }
+ };
+
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString(undefined, {
month: 'short',
@@ -142,7 +178,7 @@ export default function ProjectPanel() {
)}
+
+ {/* Confirmation dialog */}
+ {confirmAction && (
+ setConfirmAction(null)}
+ />
+ )}
);
}
diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx
index 57ea5fb..eb2fd0d 100644
--- a/frontend/src/components/ui/ConfirmDialog.tsx
+++ b/frontend/src/components/ui/ConfirmDialog.tsx
@@ -7,9 +7,17 @@ interface ConfirmDialogProps {
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
+ /** @deprecated Use variant instead */
danger?: boolean;
+ variant?: 'danger' | 'warning' | 'info';
}
+const VARIANT_STYLES = {
+ danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
+ warning: 'bg-amber-500 hover:bg-amber-600 focus:ring-amber-400',
+ info: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
+} as const;
+
export default function ConfirmDialog({
title,
message,
@@ -18,11 +26,15 @@ export default function ConfirmDialog({
onConfirm,
onCancel,
danger = false,
+ variant,
}: ConfirmDialogProps) {
const confirmRef = useRef(null);
+ // Resolve variant: explicit variant prop takes priority, then legacy danger boolean
+ const resolvedVariant = variant ?? (danger ? 'danger' : 'info');
+
useEffect(() => {
- // Auto-focus the cancel button (safer default)
+ // Auto-focus the confirm button
confirmRef.current?.focus();
const handleKey = (e: KeyboardEvent) => {
@@ -67,10 +79,7 @@ export default function ConfirmDialog({
ref={confirmRef}
onClick={onConfirm}
className={`px-4 py-2 text-sm rounded-md text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
- ${danger
- ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
- : 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
- }`}
+ ${VARIANT_STYLES[resolvedVariant]}`}
>
{confirmLabel}
diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts
index d46c341..d83302d 100644
--- a/frontend/src/hooks/useKeyboardShortcuts.ts
+++ b/frontend/src/hooks/useKeyboardShortcuts.ts
@@ -9,6 +9,8 @@ interface ShortcutHandlers {
onCloseForm: () => void;
onShowShortcuts?: () => void;
onDeleteRequest?: (siteId: string, siteName: string) => void;
+ onUndo?: () => void;
+ onRedo?: () => void;
}
function isInputActive(): boolean {
@@ -23,6 +25,8 @@ export function useKeyboardShortcuts({
onCloseForm,
onShowShortcuts,
onDeleteRequest,
+ onUndo,
+ onRedo,
}: ShortcutHandlers) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -36,6 +40,20 @@ export function useKeyboardShortcuts({
return;
}
+ // Undo: Ctrl+Z (or Cmd+Z on Mac)
+ if (modKey && e.key === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ onUndo?.();
+ return;
+ }
+
+ // Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
+ if (modKey && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
+ e.preventDefault();
+ onRedo?.();
+ return;
+ }
+
// Escape always works
if (e.key === 'Escape') {
useSitesStore.getState().selectSite(null);
@@ -118,5 +136,5 @@ export function useKeyboardShortcuts({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest]);
+ }, [onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest, onUndo, onRedo]);
}
diff --git a/frontend/src/hooks/useUnsavedChanges.ts b/frontend/src/hooks/useUnsavedChanges.ts
new file mode 100644
index 0000000..67750ec
--- /dev/null
+++ b/frontend/src/hooks/useUnsavedChanges.ts
@@ -0,0 +1,76 @@
+import { useEffect, useRef, useCallback } from 'react';
+import { useSitesStore } from '@/store/sites.ts';
+import { useCoverageStore } from '@/store/coverage.ts';
+import { create } from 'zustand';
+
+interface DirtyState {
+ isDirty: boolean;
+ lastSavedSnapshot: string | null;
+ markDirty: () => void;
+ markClean: () => void;
+ setSnapshot: (snapshot: string) => void;
+}
+
+export const useDirtyStore = create((set) => ({
+ isDirty: false,
+ lastSavedSnapshot: null,
+ markDirty: () => set({ isDirty: true }),
+ markClean: () => set({ isDirty: false }),
+ setSnapshot: (snapshot: string) => set({ lastSavedSnapshot: snapshot, isDirty: false }),
+}));
+
+function getCurrentSnapshot(): string {
+ const sites = useSitesStore.getState().sites;
+ const settings = useCoverageStore.getState().settings;
+ return JSON.stringify({ sites: sites.map((s) => s.id), settings });
+}
+
+/**
+ * Hook that tracks unsaved changes and warns on page unload.
+ * Call markClean() after a successful save.
+ */
+export function useUnsavedChanges() {
+ const isDirty = useDirtyStore((s) => s.isDirty);
+ const prevSnapshot = useRef(null);
+
+ // Take initial snapshot
+ useEffect(() => {
+ prevSnapshot.current = getCurrentSnapshot();
+ }, []);
+
+ // Watch sites array for changes and mark dirty
+ useEffect(() => {
+ const unsub = useSitesStore.subscribe((state) => {
+ const snap = JSON.stringify(state.sites.map((s) => s.id));
+ if (prevSnapshot.current !== null && snap !== prevSnapshot.current) {
+ useDirtyStore.getState().markDirty();
+ }
+ prevSnapshot.current = snap;
+ });
+ return unsub;
+ }, []);
+
+ // beforeunload warning
+ useEffect(() => {
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (useDirtyStore.getState().isDirty) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload);
+ }, []);
+
+ const markClean = useCallback(() => {
+ prevSnapshot.current = getCurrentSnapshot();
+ useDirtyStore.getState().markClean();
+ }, []);
+
+ const markDirty = useCallback(() => {
+ useDirtyStore.getState().markDirty();
+ }, []);
+
+ return { isDirty, markClean, markDirty };
+}
diff --git a/frontend/src/store/history.ts b/frontend/src/store/history.ts
new file mode 100644
index 0000000..e5b401b
--- /dev/null
+++ b/frontend/src/store/history.ts
@@ -0,0 +1,108 @@
+import { create } from 'zustand';
+import type { Site, CoverageSettings } from '@/types/index.ts';
+
+export interface ProjectSnapshot {
+ sites: Site[];
+ settings: CoverageSettings;
+ timestamp: number;
+ action: string;
+}
+
+interface HistoryState {
+ past: ProjectSnapshot[];
+ future: ProjectSnapshot[];
+ maxHistory: number;
+
+ // Actions
+ push: (snapshot: ProjectSnapshot) => void;
+ undo: () => ProjectSnapshot | null;
+ redo: () => ProjectSnapshot | null;
+ clear: () => void;
+
+ // Computed (not real zustand selectors — use getState())
+ canUndo: boolean;
+ canRedo: boolean;
+}
+
+export const useHistoryStore = create((set, get) => ({
+ past: [],
+ future: [],
+ maxHistory: 50,
+ canUndo: false,
+ canRedo: false,
+
+ push: (snapshot: ProjectSnapshot) => {
+ set((state) => {
+ const past = [...state.past, snapshot];
+ // Trim oldest if exceeding max
+ if (past.length > state.maxHistory) {
+ past.shift();
+ }
+ return {
+ past,
+ future: [], // new action clears redo stack
+ canUndo: true,
+ canRedo: false,
+ };
+ });
+ },
+
+ undo: () => {
+ const { past } = get();
+ if (past.length === 0) return null;
+
+ const snapshot = past[past.length - 1];
+ set((state) => {
+ const newPast = state.past.slice(0, -1);
+ return {
+ past: newPast,
+ // We don't push to future here — the caller does that with the current state
+ canUndo: newPast.length > 0,
+ };
+ });
+ return snapshot;
+ },
+
+ redo: () => {
+ const { future } = get();
+ if (future.length === 0) return null;
+
+ const snapshot = future[future.length - 1];
+ set((state) => {
+ const newFuture = state.future.slice(0, -1);
+ return {
+ future: newFuture,
+ canRedo: newFuture.length > 0,
+ };
+ });
+ return snapshot;
+ },
+
+ clear: () => {
+ set({ past: [], future: [], canUndo: false, canRedo: false });
+ },
+}));
+
+/**
+ * Push the current state to the redo stack.
+ * Called by the undo handler before restoring a previous snapshot.
+ */
+export function pushToFuture(snapshot: ProjectSnapshot) {
+ useHistoryStore.setState((state) => ({
+ future: [...state.future, snapshot],
+ canRedo: true,
+ }));
+}
+
+/**
+ * Push the current state to the undo stack before restoring from redo.
+ */
+export function pushToPast(snapshot: ProjectSnapshot) {
+ useHistoryStore.setState((state) => {
+ const past = [...state.past, snapshot];
+ if (past.length > state.maxHistory) {
+ past.shift();
+ }
+ return { past, canUndo: true };
+ });
+}
diff --git a/frontend/src/store/sites.ts b/frontend/src/store/sites.ts
index 9cc6bae..eee4b27 100644
--- a/frontend/src/store/sites.ts
+++ b/frontend/src/store/sites.ts
@@ -3,6 +3,21 @@ import { v4 as uuidv4 } from 'uuid';
import type { Site, SiteFormData } from '@/types/index.ts';
import { db } from '@/db/schema.ts';
import { useCoverageStore } from '@/store/coverage.ts';
+import { useHistoryStore } from '@/store/history.ts';
+
+/**
+ * Capture a snapshot of current state and push it to the undo stack.
+ * Call this BEFORE mutating state.
+ */
+function pushSnapshot(action: string, sites: Site[]) {
+ const settings = useCoverageStore.getState().settings;
+ useHistoryStore.getState().push({
+ sites: structuredClone(sites),
+ settings: { ...settings },
+ timestamp: Date.now(),
+ action,
+ });
+}
const SITE_COLORS = [
'#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6',
@@ -65,6 +80,7 @@ export const useSitesStore = create((set, get) => ({
},
addSite: async (data: SiteFormData) => {
+ pushSnapshot('add site', get().sites);
const id = uuidv4();
const now = new Date();
const colorIndex = get().sites.length % SITE_COLORS.length;
@@ -90,6 +106,7 @@ export const useSitesStore = create((set, get) => ({
const sites = get().sites;
const existing = sites.find((s) => s.id === id);
if (!existing) return;
+ pushSnapshot('update site', sites);
const updated: Site = {
...existing,
@@ -108,6 +125,7 @@ export const useSitesStore = create((set, get) => ({
},
deleteSite: async (id: string) => {
+ pushSnapshot('delete site', get().sites);
await db.sites.delete(id);
set((state) => ({
sites: state.sites.filter((s) => s.id !== id),
@@ -127,6 +145,7 @@ export const useSitesStore = create((set, get) => ({
cloneSiteAsSectors: async (siteId: string, sectorCount: 2 | 3) => {
const source = get().sites.find((s) => s.id === siteId);
if (!source) return;
+ pushSnapshot('clone as sectors', get().sites);
const spacing = 360 / sectorCount;
const addSite = get().addSite;
@@ -159,6 +178,7 @@ export const useSitesStore = create((set, get) => ({
cloneSector: async (siteId: string) => {
const source = get().sites.find((s) => s.id === siteId);
if (!source) return;
+ pushSnapshot('clone sector', get().sites);
// Count existing sectors at this location to determine naming
const colocated = get().sites.filter(
@@ -196,6 +216,7 @@ export const useSitesStore = create((set, get) => ({
// Import sites from parsed data
importSites: async (sitesData: SiteFormData[]) => {
+ pushSnapshot('import sites', get().sites);
const addSite = get().addSite;
let count = 0;
for (const data of sitesData) {
@@ -229,6 +250,7 @@ export const useSitesStore = create((set, get) => ({
batchUpdateHeight: async (adjustment: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch update height', sites);
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
@@ -257,6 +279,7 @@ export const useSitesStore = create((set, get) => ({
batchSetHeight: async (height: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch set height', sites);
const selectedSet = new Set(selectedSiteIds);
const clampedHeight = Math.max(1, Math.min(100, height));
const now = new Date();
@@ -286,6 +309,7 @@ export const useSitesStore = create((set, get) => ({
batchAdjustAzimuth: async (delta: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch adjust azimuth', sites);
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
@@ -315,6 +339,7 @@ export const useSitesStore = create((set, get) => ({
batchSetAzimuth: async (azimuth: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch set azimuth', sites);
const selectedSet = new Set(selectedSiteIds);
const clamped = ((azimuth % 360) + 360) % 360;
const now = new Date();
@@ -344,6 +369,7 @@ export const useSitesStore = create((set, get) => ({
batchAdjustPower: async (delta: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch adjust power', sites);
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
@@ -372,6 +398,7 @@ export const useSitesStore = create((set, get) => ({
batchSetPower: async (power: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch set power', sites);
const selectedSet = new Set(selectedSiteIds);
const clamped = Math.max(10, Math.min(50, power));
const now = new Date();
@@ -401,6 +428,7 @@ export const useSitesStore = create((set, get) => ({
batchAdjustTilt: async (delta: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch adjust tilt', sites);
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
@@ -430,6 +458,7 @@ export const useSitesStore = create((set, get) => ({
batchSetTilt: async (tilt: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch set tilt', sites);
const selectedSet = new Set(selectedSiteIds);
const clamped = Math.max(-90, Math.min(90, tilt));
const now = new Date();
@@ -459,6 +488,7 @@ export const useSitesStore = create((set, get) => ({
batchSetFrequency: async (frequency: number) => {
const { sites, selectedSiteIds } = get();
+ pushSnapshot('batch set frequency', sites);
const selectedSet = new Set(selectedSiteIds);
const clamped = Math.max(100, Math.min(6000, frequency));
const now = new Date();