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!
Recommended Priority
Priority 1 (Must Fix):
- ✅ Coverage Settings → NumberInput (consistency)
- ✅ Calculation radius color → Orange (clarity)
- ✅ 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!