import { create } from 'zustand'; 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', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', ]; interface SitesState { sites: Site[]; selectedSiteId: string | null; editingSiteId: string | null; isPlacingMode: boolean; selectedSiteIds: string[]; loadSites: () => Promise; addSite: (data: SiteFormData) => Promise; updateSite: (id: string, data: Partial) => Promise; deleteSite: (id: string) => Promise; selectSite: (id: string | null) => void; setEditingSite: (id: string | null) => void; togglePlacingMode: () => void; setPlacingMode: (val: boolean) => void; // Multi-sector cloneSiteAsSectors: (siteId: string, sectorCount: 2 | 3) => Promise; cloneSector: (siteId: string) => Promise; // Import/export importSites: (sitesData: SiteFormData[]) => Promise; // Batch operations toggleSiteSelection: (siteId: string) => void; selectAllSites: () => void; clearSelection: () => void; batchUpdateHeight: (adjustment: number) => Promise; batchSetHeight: (height: number) => Promise; batchAdjustAzimuth: (delta: number) => Promise; batchSetAzimuth: (azimuth: number) => Promise; batchAdjustPower: (delta: number) => Promise; batchSetPower: (power: number) => Promise; batchAdjustTilt: (delta: number) => Promise; batchSetTilt: (tilt: number) => Promise; batchSetFrequency: (frequency: number) => Promise; } export const useSitesStore = create((set, get) => ({ sites: [], selectedSiteId: null, editingSiteId: null, isPlacingMode: false, selectedSiteIds: [], loadSites: async () => { const dbSites = await db.sites.toArray(); const sites: Site[] = dbSites.map((s) => ({ ...JSON.parse(s.data), createdAt: new Date(s.createdAt), updatedAt: new Date(s.updatedAt), })); set({ sites }); }, addSite: async (data: SiteFormData) => { pushSnapshot('add site', get().sites); const id = uuidv4(); const now = new Date(); const colorIndex = get().sites.length % SITE_COLORS.length; const site: Site = { ...data, id, color: data.color || SITE_COLORS[colorIndex], visible: true, createdAt: now, updatedAt: now, }; await db.sites.put({ id, data: JSON.stringify(site), createdAt: now.getTime(), updatedAt: now.getTime(), }); set((state) => ({ sites: [...state.sites, site] })); return site; }, updateSite: async (id: string, data: Partial) => { const sites = get().sites; const existing = sites.find((s) => s.id === id); if (!existing) return; pushSnapshot('update site', sites); const updated: Site = { ...existing, ...data, updatedAt: new Date(), }; await db.sites.put({ id, data: JSON.stringify(updated), createdAt: existing.createdAt.getTime(), updatedAt: updated.updatedAt.getTime(), }); set((state) => ({ sites: state.sites.map((s) => (s.id === id ? updated : s)), })); }, deleteSite: async (id: string) => { pushSnapshot('delete site', get().sites); await db.sites.delete(id); set((state) => ({ sites: state.sites.filter((s) => s.id !== id), selectedSiteId: state.selectedSiteId === id ? null : state.selectedSiteId, editingSiteId: state.editingSiteId === id ? null : state.editingSiteId, })); // Clear stale coverage data useCoverageStore.getState().clearCoverage(); }, selectSite: (id: string | null) => set({ selectedSiteId: id }), setEditingSite: (id: string | null) => set({ editingSiteId: id }), togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })), setPlacingMode: (val: boolean) => set({ isPlacingMode: val }), // Multi-sector: clone a site into 2 or 3 co-located sector sites 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; for (let i = 0; i < sectorCount; i++) { const azimuth = Math.round(i * spacing) % 360; const label = sectorCount === 2 ? ['Alpha', 'Beta'][i] : ['Alpha', 'Beta', 'Gamma'][i]; await addSite({ name: `${source.name}-${label}`, lat: source.lat, lon: source.lon, height: source.height, power: source.power, gain: source.gain >= 15 ? source.gain : 18, // sector gain default frequency: source.frequency, antennaType: 'sector', azimuth, beamwidth: sectorCount === 2 ? 90 : 65, color: '', visible: true, notes: `Sector ${i + 1} (${label}) of ${source.name}`, }); } }, // Clone a single sector: duplicate site at same location with 120° azimuth offset (tri-sector spacing) 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( (s) => s.lat === source.lat && s.lon === source.lon ); const sectorLabels = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']; const sectorIndex = colocated.length; // 0-indexed, next sector const label = sectorLabels[sectorIndex] ?? `S${sectorIndex + 1}`; // Base name without any existing sector suffix const baseName = source.name.replace(/-(Alpha|Beta|Gamma|Delta|Epsilon|Zeta|S\d+|clone)$/i, ''); const addSite = get().addSite; const newAzimuth = ((source.azimuth ?? 0) + 120) % 360; // 120° for tri-sector await addSite({ name: `${baseName}-${label}`, lat: source.lat, lon: source.lon, height: source.height, power: source.power, gain: source.gain >= 15 ? source.gain : 18, // sector gain default frequency: source.frequency, antennaType: 'sector', azimuth: newAzimuth, beamwidth: source.beamwidth ?? 65, color: '', visible: true, notes: `Sector ${label} at ${baseName}`, }); // Clear coverage to force recalculation useCoverageStore.getState().clearCoverage(); }, // 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) { await addSite(data); count++; } return count; }, // Batch operations toggleSiteSelection: (siteId: string) => { set((state) => { const isSelected = state.selectedSiteIds.includes(siteId); return { selectedSiteIds: isSelected ? state.selectedSiteIds.filter((id) => id !== siteId) : [...state.selectedSiteIds, siteId], }; }); }, selectAllSites: () => { set((state) => ({ selectedSiteIds: state.sites.map((s) => s.id), })); }, clearSelection: () => { set({ selectedSiteIds: [] }); }, batchUpdateHeight: async (adjustment: number) => { const { sites, selectedSiteIds } = get(); pushSnapshot('batch update height', sites); const selectedSet = new Set(selectedSiteIds); const now = new Date(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, height: Math.max(1, Math.min(100, site.height + adjustment)), updatedAt: now, }; }); // Persist to IndexedDB const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); }, 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(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, height: clampedHeight, updatedAt: now, }; }); // Persist to IndexedDB const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); }, batchAdjustAzimuth: async (delta: number) => { const { sites, selectedSiteIds } = get(); pushSnapshot('batch adjust azimuth', sites); const selectedSet = new Set(selectedSiteIds); const now = new Date(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; const current = site.azimuth ?? 0; return { ...site, azimuth: ((current + delta) % 360 + 360) % 360, updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, 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(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, azimuth: clamped, updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, batchAdjustPower: async (delta: number) => { const { sites, selectedSiteIds } = get(); pushSnapshot('batch adjust power', sites); const selectedSet = new Set(selectedSiteIds); const now = new Date(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, power: Math.max(10, Math.min(50, site.power + delta)), updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, 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(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, power: clamped, updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, batchAdjustTilt: async (delta: number) => { const { sites, selectedSiteIds } = get(); pushSnapshot('batch adjust tilt', sites); const selectedSet = new Set(selectedSiteIds); const now = new Date(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; const current = site.tilt ?? 0; return { ...site, tilt: Math.max(-90, Math.min(90, current + delta)), updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, 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(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, tilt: clamped, updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, 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(); const updatedSites = sites.map((site) => { if (!selectedSet.has(site.id)) return site; return { ...site, frequency: clamped, updatedAt: now, }; }); const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id)); for (const site of toUpdate) { await db.sites.put({ id: site.id, data: JSON.stringify(site), createdAt: site.createdAt.getTime(), updatedAt: now.getTime(), }); } set({ sites: updatedSites }); useCoverageStore.getState().clearCoverage(); }, }));