feat: Alerts are persistent for the 3 last ones for 10minutes

This commit is contained in:
2026-07-07 19:52:52 +02:00
parent ea8e954eef
commit 39f095ae75
2 changed files with 50 additions and 0 deletions
+48
View File
@@ -793,6 +793,19 @@ export default function App() {
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
// Persistent spot-alert tray: keep the last few fired alerts visible (they used
// to vanish with the 3 s toast, lost if you were on another window). Each stays
// ~10 min and is clickable to tune the rig to its freq/mode.
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
const ALERT_TTL_MS = 10 * 60 * 1000;
useEffect(() => {
const id = window.setInterval(() => {
setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
}, 20000);
return () => window.clearInterval(id);
}, []);
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
@@ -1197,9 +1210,17 @@ export default function App() {
if (p?.visual) {
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? `${p.country}` : ''}`);
// Also keep it in the persistent tray (clickable, ~10 min).
const a: RecentAlert = {
id: Date.now() + Math.random(), rule, call, band,
mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
};
setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
}
});
return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
@@ -4364,6 +4385,33 @@ export default function App() {
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
)}
{/* Persistent spot-alert tray (top-right). Each card stays ~10 min; click it
to tune the rig to its freq/mode + fill the callsign; × dismisses it. */}
{recentAlerts.length > 0 && (
<div className="fixed top-16 right-3 z-[60] flex flex-col gap-2 w-72 max-w-[85vw]">
{recentAlerts.map((a) => (
<div key={a.id} className="flex items-stretch rounded-lg border border-warning/50 bg-card shadow-lg overflow-hidden">
<button type="button"
title={t('alert.tuneHint')}
onClick={() => handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call })}
className="flex-1 min-w-0 text-left px-3 py-2 hover:bg-warning/10 transition-colors">
<div className="flex items-center gap-1.5">
<span>🔔</span>
<span className="font-bold text-sm truncate">{a.call}</span>
{a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
</div>
</button>
<button type="button" title={t('alert.dismiss')}
onClick={() => setRecentAlerts((prev) => prev.filter((x) => x.id !== a.id))}
className="shrink-0 px-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">×</button>
</div>
))}
</div>
)}
{showDuplicates && (
<DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
)}
+2
View File
@@ -21,6 +21,7 @@ const en: Dict = {
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss',
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
// Duplicates modal
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
@@ -228,6 +229,7 @@ const fr: Dict = {
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer',
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',