Files
rfcp/docs/devlog/front/RFCP-Iteration9.1-Final-Polish.md
2026-01-30 20:39:13 +02:00

12 KiB

RFCP - Iteration 9.1: Final Polish & Safety

Issue 1: Coverage Settings Still Use Old Sliders

Problem: Coverage Settings panel has sliders but not NumberInput components.

Solution: Replace all sliders with NumberInput.

File: frontend/src/App.tsx (Coverage Settings section)

import { NumberInput } from '@/components/common/NumberInput';

// Replace:
<Slider label="Radius" min={1} max={100} ... />

// With:
<NumberInput
  label="Radius"
  value={settings.radius}
  onChange={(v) => updateSettings({ radius: v })}
  min={1}
  max={100}
  step={1}
  unit="km"
  showSlider={true}
/>

<NumberInput
  label="Resolution"
  value={settings.resolution}
  onChange={(v) => updateSettings({ resolution: v })}
  min={50}
  max={500}
  step={50}
  unit="m"
  showSlider={true}
/>

<NumberInput
  label="Min Signal"
  value={settings.rsrpThreshold}
  onChange={(v) => updateSettings({ rsrpThreshold: v })}
  min={-140}
  max={-50}
  step={5}
  unit="dBm"
  showSlider={true}
/>

<NumberInput
  label="Heatmap Opacity"
  value={Math.round(heatmapOpacity * 100)}
  onChange={(v) => setHeatmapOpacity(v / 100)}
  min={10}
  max={100}
  step={5}
  unit="%"
  showSlider={true}
/>

<NumberInput
  label="Terrain Opacity"
  value={Math.round(terrainOpacity * 100)}
  onChange={(v) => setTerrainOpacity(v / 100)}
  min={10}
  max={100}
  step={5}
  unit="%"
  showSlider={true}
/>

Issue 2: Calculation Radius Color Confusion

Problem: Calculation bounds circle is cyan (#00bcd4) - same as weak signal color!

Current:

// Blue circle - confuses with RSRP gradient
pathOptions: { 
  color: '#00bcd4',  // ← Same as weak signal!
  weight: 2,
  opacity: 0.5,
  dashArray: '5, 5'
}

Solution: Use neutral color that's NOT in RSRP gradient.

Option A: Orange (Recommended)

pathOptions: {
  color: '#ff9800',  // Orange - clearly different
  weight: 2,
  opacity: 0.6,
  dashArray: '5, 5',
  fillOpacity: 0
}

Option B: Purple

pathOptions: {
  color: '#9c27b0',  // Purple - not in gradient
  weight: 2,
  opacity: 0.6,
  dashArray: '5, 5',
  fillOpacity: 0
}

Option C: White/Gray

pathOptions: {
  color: '#ffffff',  // White
  weight: 2,
  opacity: 0.7,
  dashArray: '5, 5',
  fillOpacity: 0
}

Recommended: Orange - highly visible, clearly different from RSRP colors.

File: Find where calculation bounds Circle/Rectangle is rendered (likely in Map.tsx or coverage component)

// Find this:
<Circle
  center={[site.lat, site.lon]}
  radius={calculationRadius * 1000}
  pathOptions={{ color: '#00bcd4', ... }}
/>

// Change to:
<Circle
  center={[site.lat, site.lon]}
  radius={calculationRadius * 1000}
  pathOptions={{
    color: '#ff9800',     // Orange - not in RSRP gradient
    weight: 2,
    opacity: 0.6,
    dashArray: '10, 5',   // Longer dashes
    fillOpacity: 0
  }}
/>

Issue 3: Delete Confirmation Dialog

Problem: Accidentally deleting sites with no confirmation!

Solution: Add confirmation dialog before delete.

File: frontend/src/components/common/ConfirmDialog.tsx (new)

import { useEffect, useRef } from 'react';

interface ConfirmDialogProps {
  title: string;
  message: string;
  confirmLabel?: string;
  cancelLabel?: string;
  onConfirm: () => void;
  onCancel: () => void;
  danger?: boolean;
}

export function ConfirmDialog({
  title,
  message,
  confirmLabel = 'Confirm',
  cancelLabel = 'Cancel',
  onConfirm,
  onCancel,
  danger = false
}: ConfirmDialogProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    // Focus confirm button
    dialogRef.current?.querySelector('button')?.focus();
    
    // Handle Esc key
    const handleEsc = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onCancel();
      }
    };
    
    window.addEventListener('keydown', handleEsc);
    return () => window.removeEventListener('keydown', handleEsc);
  }, [onCancel]);
  
  return (
    <div className="confirm-dialog-overlay">
      <div className="confirm-dialog" ref={dialogRef}>
        <h3>{title}</h3>
        <p>{message}</p>
        
        <div className="dialog-actions">
          <button
            onClick={onCancel}
            className="btn-secondary"
          >
            {cancelLabel}
          </button>
          <button
            onClick={onConfirm}
            className={danger ? 'btn-danger' : 'btn-primary'}
            autoFocus
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
}

CSS:

.confirm-dialog-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.confirm-dialog {
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 24px;
  max-width: 400px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.confirm-dialog h3 {
  margin: 0 0 12px;
  font-size: 18px;
}

.confirm-dialog p {
  margin: 0 0 20px;
  color: var(--text-secondary);
}

.dialog-actions {
  display: flex;
  gap: 12px;
  justify-content: flex-end;
}

.btn-danger {
  background: #dc2626;
  color: white;
}

.btn-danger:hover {
  background: #b91c1c;
}

Usage in SiteList.tsx:

const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);

// Delete button
<button onClick={() => setDeleteConfirm(site.id)}>
  🗑️ Delete
</button>

// Render dialog
{deleteConfirm && (
  <ConfirmDialog
    title="Delete Site?"
    message={`Are you sure you want to delete "${sites.find(s => s.id === deleteConfirm)?.name}"? This cannot be undone.`}
    confirmLabel="Delete"
    cancelLabel="Cancel"
    danger={true}
    onConfirm={async () => {
      await deleteSite(deleteConfirm);
      setDeleteConfirm(null);
      toast.success('Site deleted');
    }}
    onCancel={() => setDeleteConfirm(null)}
  />
)}

Issue 4: Undo/Redo System

Problem: Accidents happen - need undo for destructive operations.

Solution: Simple undo stack with toast action.

File: frontend/src/store/history.ts (new)

import { create } from 'zustand';

interface HistoryAction {
  type: 'delete_site' | 'delete_sector' | 'batch_edit';
  timestamp: number;
  data: any;
  description: string;
}

interface HistoryState {
  undoStack: HistoryAction[];
  redoStack: HistoryAction[];
  
  pushUndo: (action: HistoryAction) => void;
  undo: () => HistoryAction | null;
  redo: () => HistoryAction | null;
  clear: () => void;
}

export const useHistoryStore = create<HistoryState>((set, get) => ({
  undoStack: [],
  redoStack: [],
  
  pushUndo: (action) => {
    set({
      undoStack: [...get().undoStack, action],
      redoStack: [] // Clear redo stack on new action
    });
    
    // Limit stack size to 50 actions
    if (get().undoStack.length > 50) {
      set({ undoStack: get().undoStack.slice(-50) });
    }
  },
  
  undo: () => {
    const { undoStack, redoStack } = get();
    if (undoStack.length === 0) return null;
    
    const action = undoStack[undoStack.length - 1];
    
    set({
      undoStack: undoStack.slice(0, -1),
      redoStack: [...redoStack, action]
    });
    
    return action;
  },
  
  redo: () => {
    const { undoStack, redoStack } = get();
    if (redoStack.length === 0) return null;
    
    const action = redoStack[redoStack.length - 1];
    
    set({
      undoStack: [...undoStack, action],
      redoStack: redoStack.slice(0, -1)
    });
    
    return action;
  },
  
  clear: () => set({ undoStack: [], redoStack: [] })
}));

Integration in sites.ts:

import { useHistoryStore } from './history';

const deleteSite = async (id: string) => {
  const site = sites.find(s => s.id === id);
  if (!site) return;
  
  // Push to undo stack
  useHistoryStore.getState().pushUndo({
    type: 'delete_site',
    timestamp: Date.now(),
    data: site,
    description: `Delete "${site.name}"`
  });
  
  // Perform delete
  await db.sites.delete(id);
  set((state) => ({
    sites: state.sites.filter((s) => s.id !== id)
  }));
  
  useCoverageStore.getState().clearCoverage();
};

Undo Handler:

// In App.tsx or main component
const handleUndo = async () => {
  const action = useHistoryStore.getState().undo();
  if (!action) {
    toast.error('Nothing to undo');
    return;
  }
  
  switch (action.type) {
    case 'delete_site':
      // Restore deleted site
      await useSitesStore.getState().addSite(action.data);
      toast.success(`Restored "${action.data.name}"`);
      break;
      
    // ... other action types
  }
};

const handleRedo = async () => {
  const action = useHistoryStore.getState().redo();
  if (!action) {
    toast.error('Nothing to redo');
    return;
  }
  
  switch (action.type) {
    case 'delete_site':
      // Re-delete site
      await useSitesStore.getState().deleteSite(action.data.id);
      toast.success(`Deleted "${action.data.name}"`);
      break;
  }
};

Keyboard Shortcuts:

// Add to useKeyboardShortcuts.ts
case 'z':
  if (e.ctrlKey || e.metaKey) {
    e.preventDefault();
    if (e.shiftKey) {
      handleRedo(); // Ctrl+Shift+Z = Redo
    } else {
      handleUndo(); // Ctrl+Z = Undo
    }
  }
  break;

UI Indicator:

// Show undo/redo availability
const { undoStack, redoStack } = useHistoryStore();

<div className="history-controls">
  <button
    onClick={handleUndo}
    disabled={undoStack.length === 0}
    title="Undo (Ctrl+Z)"
  >
     Undo
  </button>
  <button
    onClick={handleRedo}
    disabled={redoStack.length === 0}
    title="Redo (Ctrl+Shift+Z)"
  >
     Redo
  </button>
</div>

Alternative: Simpler Undo (Toast-based)

Lighter approach without full undo/redo system:

const deleteSite = async (id: string) => {
  const site = sites.find(s => s.id === id);
  if (!site) return;
  
  // Show toast with undo action
  const toastId = toast.success('Site deleted', {
    duration: 10000, // 10 seconds to undo
    action: {
      label: 'Undo',
      onClick: async () => {
        // Restore site
        await addSite(site);
        toast.success('Site restored');
      }
    }
  });
  
  // Perform delete
  await db.sites.delete(id);
  set((state) => ({
    sites: state.sites.filter((s) => s.id !== id)
  }));
};

This is much simpler and covers 90% of use cases!


Priority 1 (Must Fix):

  1. Coverage Settings → NumberInput (consistency)
  2. Calculation radius color → Orange (clarity)
  3. Delete confirmation dialog (safety)

Priority 2 (Nice to Have): 4. Toast-based undo (simple, effective)

Priority 3 (Future): 5. 🔮 Full undo/redo system (complex, might be overkill)


Testing

Coverage Settings:

  • All sliders replaced with NumberInput
  • Can type exact values (e.g., "73 km")
  • Arrows work (+/- 1)
  • Slider still works

Calculation Radius:

  • Circle is orange (not cyan)
  • Clearly different from RSRP gradient
  • Visible on all map styles

Delete Confirmation:

  • Click delete → dialog appears
  • Can cancel (Esc key works)
  • Can confirm → site deleted
  • No accidental deletes

Undo:

  • Delete site → toast with "Undo" button
  • Click Undo → site restored
  • Ctrl+Z also works
  • Undo expires after 10s

Build & Deploy

cd /opt/rfcp/frontend
npm run build
sudo systemctl reload caddy

Commit Message

fix(ux): coverage settings number inputs, calc radius color

- Replaced Coverage Settings sliders with NumberInput components
- Changed calculation radius color: cyan → orange (avoid RSRP conflict)
- Added delete confirmation dialog with Esc key support
- Implemented toast-based undo for delete operations (10s window)

Prevents accidental data loss and improves input consistency.

🚀 Ready for Iteration 9.1!