589 lines
12 KiB
Markdown
589 lines
12 KiB
Markdown
# 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)
|
||
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
// 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)**
|
||
```typescript
|
||
pathOptions: {
|
||
color: '#ff9800', // Orange - clearly different
|
||
weight: 2,
|
||
opacity: 0.6,
|
||
dashArray: '5, 5',
|
||
fillOpacity: 0
|
||
}
|
||
```
|
||
|
||
**Option B: Purple**
|
||
```typescript
|
||
pathOptions: {
|
||
color: '#9c27b0', // Purple - not in gradient
|
||
weight: 2,
|
||
opacity: 0.6,
|
||
dashArray: '5, 5',
|
||
fillOpacity: 0
|
||
}
|
||
```
|
||
|
||
**Option C: White/Gray**
|
||
```typescript
|
||
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)
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
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:**
|
||
```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:**
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
// 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:**
|
||
|
||
```typescript
|
||
// 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:**
|
||
|
||
```typescript
|
||
// 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:**
|
||
|
||
```typescript
|
||
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):**
|
||
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
|
||
|
||
```bash
|
||
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!
|