@mytec: iter1.1.1 ready for testing

This commit is contained in:
2026-01-30 23:15:51 +02:00
parent 0fb19476cd
commit b7d008fe26
8 changed files with 422 additions and 11 deletions

View File

@@ -9,7 +9,8 @@
"Bash(tree:*)",
"Bash(python:*)",
"Bash(pip --version:*)",
"Bash(pip install:*)"
"Bash(pip install:*)",
"Bash(npx vite build:*)"
]
}
}

View File

@@ -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() {
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
{/* Undo / Redo buttons */}
<div className="hidden sm:flex items-center gap-1">
<button
onClick={handleUndo}
disabled={!canUndo}
className="text-slate-400 hover:text-white disabled:text-slate-600 disabled:cursor-not-allowed p-1 transition-colors"
title="Undo (Ctrl+Z)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
</svg>
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
className="text-slate-400 hover:text-white disabled:text-slate-600 disabled:cursor-not-allowed p-1 transition-colors"
title="Redo (Ctrl+Shift+Z)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M12.207 2.232a.75.75 0 0 0 .025 1.06l4.146 3.958H6.375a5.375 5.375 0 0 0 0 10.75H9.25a.75.75 0 0 0 0-1.5H6.375a3.875 3.875 0 0 1 0-7.75h10.003l-4.146 3.957a.75.75 0 0 0 1.036 1.085l5.5-5.25a.75.75 0 0 0 0-1.085l-5.5-5.25a.75.75 0 0 0-1.06.025Z" clipRule="evenodd" />
</svg>
</button>
</div>
<button
onClick={() => setShowShortcuts(!showShortcuts)}
className="text-slate-400 hover:text-white text-sm hidden sm:inline"
@@ -418,6 +516,20 @@ export default function App() {
</li>
</ul>
</div>
{/* Edit */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Edit</h4>
<ul className="space-y-1">
<li className="flex justify-between gap-4">
<span>Undo</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Z</kbd>
</li>
<li className="flex justify-between gap-4">
<span>Redo</span>
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Shift+Z</kbd>
</li>
</ul>
</div>
{/* General */}
<div>
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">General</h4>

View File

@@ -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() {
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => handleLoad(project.id, project.name)}
onClick={() => handleLoadRequest(project.id, project.name)}
disabled={isLoading}
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded
min-w-[40px] min-h-[32px] flex items-center justify-center disabled:opacity-50"
@@ -150,7 +186,7 @@ export default function ProjectPanel() {
Load
</button>
<button
onClick={() => handleDelete(project.id, project.name)}
onClick={() => handleDeleteRequest(project.id, project.name)}
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded
min-w-[32px] min-h-[32px] flex items-center justify-center"
>
@@ -161,6 +197,27 @@ export default function ProjectPanel() {
))}
</div>
)}
{/* Confirmation dialog */}
{confirmAction && (
<ConfirmDialog
title={
confirmAction.type === 'delete'
? 'Delete Project?'
: 'Unsaved Changes'
}
message={
confirmAction.type === 'delete'
? `Delete project "${confirmAction.name}"? This cannot be undone.`
: `You have unsaved changes. Load project "${confirmAction.name}" anyway?`
}
confirmLabel={confirmAction.type === 'delete' ? 'Delete' : 'Load'}
cancelLabel="Cancel"
variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
onConfirm={handleConfirm}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}

View File

@@ -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<HTMLButtonElement>(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}
</button>

View File

@@ -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]);
}

View File

@@ -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<DirtyState>((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<string | null>(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 };
}

View File

@@ -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<HistoryState>((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 };
});
}

View File

@@ -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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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<SitesState>((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();