From b7d008fe262ce256a0d2315648b3e7e02305a346 Mon Sep 17 00:00:00 2001 From: mytec Date: Fri, 30 Jan 2026 23:15:51 +0200 Subject: [PATCH] @mytec: iter1.1.1 ready for testing --- .claude/settings.local.json | 3 +- frontend/src/App.tsx | 112 ++++++++++++++++++ .../src/components/panels/ProjectPanel.tsx | 65 +++++++++- frontend/src/components/ui/ConfirmDialog.tsx | 19 ++- frontend/src/hooks/useKeyboardShortcuts.ts | 20 +++- frontend/src/hooks/useUnsavedChanges.ts | 76 ++++++++++++ frontend/src/store/history.ts | 108 +++++++++++++++++ frontend/src/store/sites.ts | 30 +++++ 8 files changed, 422 insertions(+), 11 deletions(-) create mode 100644 frontend/src/hooks/useUnsavedChanges.ts create mode 100644 frontend/src/store/history.ts 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

+ +
{/* 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();