@mytec iter9.1 start
This commit is contained in:
588
RFCP-Iteration9.1-Final-Polish.md
Normal file
588
RFCP-Iteration9.1-Final-Polish.md
Normal file
@@ -0,0 +1,588 @@
|
||||
# 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!
|
||||
Reference in New Issue
Block a user