Files
rfcp/RFCP-Iteration9.1-Final-Polish.md
2026-01-30 14:55:45 +02:00

589 lines
12 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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!