diff --git a/app.go b/app.go index 0f74df8..8ade62d 100644 --- a/app.go +++ b/app.go @@ -8497,7 +8497,8 @@ type SpotQuery struct { // // "new" — entity never worked // "new-band" — entity worked but never on this band -// "new-slot" — entity worked on this band but not in this mode +// "new-mode" — band worked, but this MODE never worked on the entity (any band) +// "new-slot" — band & mode each worked before, but not this band+mode together // "worked" — exact band+mode already in the log // "" — couldn't resolve the entity (no cty.dat match) type SpotStatus struct { @@ -8586,12 +8587,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { out[i].Status = "new-band" continue } - // Without a mode we can't distinguish "new slot" from "worked"; + // Without a mode we can't distinguish the rest from "worked"; // the safer default is "worked" so we never falsely claim "new". if out[i].Mode == "" { out[i].Status = "worked" continue } + // Band already worked. If this MODE was never worked on the entity (any + // band) → new-mode. If the mode was worked elsewhere but not on THIS + // band+mode → new-slot. Otherwise → worked. + if _, m := e.Modes[out[i].Mode]; !m { + out[i].Status = "new-mode" + continue + } if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { out[i].Status = "new-slot" continue diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54197c3..fdc1383 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -729,7 +729,7 @@ export default function App() { const [clusterLockMode, setClusterLockMode] = useState(false); // Status filter chips. Empty set = show every status (including // already-worked). Otherwise only matching spots pass. - type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked'; + type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked'; const [clusterStatusFilter, setClusterStatusFilter] = useState>(new Set()); // Mode filter chips. Empty set = show every mode. Categories map the // inferred per-spot mode onto SSB (phone) / CW / DATA (digital). @@ -2638,8 +2638,9 @@ export default function App() { {([ { k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' }, { k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' }, + { k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, - { k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' }, + // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.) ]).map((s) => { const on = clusterStatusFilter.has(s.k); return ( @@ -2736,7 +2737,7 @@ export default function App() {
openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} - onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> + onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
); case 'flex': @@ -3554,6 +3555,7 @@ export default function App() { onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} + onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} />
@@ -3729,7 +3731,7 @@ export default function App() { openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} - onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> + onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} /> {/* Opened on demand from Tools → QSL Manager; closable via the diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index ea418e7..aaa7561 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, @@ -121,6 +121,7 @@ function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string switch (s?.status) { case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' }; case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' }; + case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' }; case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' }; default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null; } @@ -159,6 +160,7 @@ const COL_CATALOG: ColEntry[] = [ const s = statusFor(p); if (s?.status === 'new') return 'NEW DXCC'; if (s?.status === 'new-band') return 'NEW BAND'; + if (s?.status === 'new-mode') return 'NEW MODE'; if (s?.status === 'new-slot') return 'NEW SLOT'; return s?.worked_call ? 'WKD CALL' : ''; }, @@ -212,12 +214,22 @@ const COL_CATALOG: ColEntry[] = [ defaultVisible: true, cellClass: 'font-mono', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', - // NEW SLOT (mode not yet worked on this band) → fill the cell. - cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot' - ? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 } - : undefined), + // Fill the mode cell: teal = NEW MODE (mode never worked on this entity), + // yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere). + cellStyle: (p: any) => { + const st = statusFor(p)?.status; + // Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the + // Status badge text tells them apart. + if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }; + return undefined; + }, cellRenderer: (p: any) => p.value ? p.value : , - tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined), + tooltipValueGetter: (p: any) => { + const st = statusFor(p)?.status; + if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)'; + if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)'; + return undefined; + }, }, { group: 'Spot', label: 'Pfx', colId: 'pfx', @@ -327,6 +339,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { // change below. const context = useMemo(() => ({ spotStatus }), [spotStatus]); + // Spot statuses arrive asynchronously (~after the rows render). The Call/Band/ + // Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid + // won't re-render those cells on its own — force a refresh so e.g. a worked call + // turns blue once its status loads. + useEffect(() => { + gridRef.current?.api?.refreshCells({ force: true }); + }, [spotStatus]); + function onGridReady(e: GridReadyEvent) { const local = loadLocal(COL_STATE_KEY); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index 7086b83..bc8beff 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -139,18 +139,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b const from = gridToLatLon(fromGrid); const to = gridToLatLon(toGrid); - if (from && to) { + // Station marker + antenna beam/boom are drawn whenever the station grid is + // known — independent of any DX. The antenna is always pointed somewhere, so + // the beam heading should show even before a callsign is entered. + if (from) { L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' }) .bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .addTo(wo); - L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' }) - .bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) - .addTo(wo); - const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128); - // smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the - // line, which makes a smooth arc look angular/bumpy). - L.polyline(unwrapLon(pts) as L.LatLngExpression[], - { color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo); // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── if (beamAzimuths && beamAzimuths.length) { @@ -204,16 +199,30 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 }) .bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo); } + } - if (autoZoom) { + // DX marker + great-circle arc — only when a DX grid is known (callsign entered). + let arcPts: [number, number][] | null = null; + if (from && to) { + L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' }) + .bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) + .addTo(wo); + arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128); + // smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line). + L.polyline(unwrapLon(arcPts) as L.LatLngExpression[], + { color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo); + } + + if (autoZoom) { + if (from && to && arcPts) { const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); - pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); + arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression)); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); + } else if (to) { + wm.setView([to.lat, to.lon], 3); + } else if (from) { + wm.setView([from.lat, from.lon], 3); } - } else if (autoZoom && to) { - wm.setView([to.lat, to.lon], 3); - } else if (autoZoom && from) { - wm.setView([from.lat, from.lon], 3); } setTimeout(() => { wm.invalidateSize(); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 4ab70d9..477db40 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react'; +import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react'; export type QSOMenuState = { x: number; y: number; ids: number[] } | null; @@ -15,6 +15,7 @@ type Props = { onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; + onDelete?: (ids: number[]) => void; }; const UPLOAD_TARGETS: { service: string; label: string }[] = [ @@ -31,7 +32,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [ // or picks a command. (We deliberately do NOT close on scroll/resize: the QSO // list auto-refreshes and AG Grid fires internal scroll events on refresh, // which used to dismiss the menu the instant it appeared.) -export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) { +export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) { useEffect(() => { if (!menu) return; const close = () => onClose(); @@ -159,6 +160,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ ))} )} + + {onDelete && ( + <> +
+ + + )}
); } diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 3cfe28a..c3ad4ef 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -56,6 +56,7 @@ type Props = { onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; + onDelete?: (ids: number[]) => void; // One column per defined award; the cell shows the reference this QSO counts // for (from row.award_refs[CODE], attached by the parent). Hidden by default. awardCols?: { code: string; name: string }[]; @@ -222,7 +223,7 @@ export const GROUP_ORDER = [ 'Contest', 'Propagation', 'My station', 'Misc', ]; -export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) { +export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -395,6 +396,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda onBulkEdit={onBulkEdit} onExportSelected={onExportSelected} onExportFiltered={onExportFiltered} + onDelete={onDelete} /> diff --git a/frontend/src/components/WorkedBeforeGrid.tsx b/frontend/src/components/WorkedBeforeGrid.tsx index 8ca8bc7..0f86a53 100644 --- a/frontend/src/components/WorkedBeforeGrid.tsx +++ b/frontend/src/components/WorkedBeforeGrid.tsx @@ -53,6 +53,7 @@ type Props = { onSendTo?: (service: string, ids: number[]) => void; onSendRecording?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void; + onDelete?: (ids: number[]) => void; // One column per defined award (cell = the reference this QSO counts for). awardCols?: { code: string; name: string }[]; }; @@ -67,7 +68,7 @@ function fmtDate(s: any): string { return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; } -export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) { +export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -253,6 +254,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on onSendTo={onSendTo} onSendRecording={onSendRecording} onSendEQSL={onSendEQSL} + onDelete={onDelete} /> {count > entries.length && ( diff --git a/internal/applog/applog.go b/internal/applog/applog.go index e97bb2b..c4bf6d2 100644 --- a/internal/applog/applog.go +++ b/internal/applog/applog.go @@ -11,14 +11,16 @@ import ( "log" "os" "path/filepath" + "runtime/debug" "sync" "time" ) var ( - mu sync.Mutex - file *os.File - path string + mu sync.Mutex + file *os.File + path string + crashFile *os.File // kept open so the runtime can write a crash traceback to it ) // Init opens (creates) the log file in dataDir. On rotation we truncate @@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) { file = f path = logPath + // Capture a full traceback on a FATAL crash (a Go panic that escapes our + // recover()s, or a runtime-fatal error like a concurrent map write, or a + // Windows access violation routed through the Go signal handler) into a + // dedicated file the runtime writes directly — so otherwise-silent process + // deaths leave a stack we can read. + if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil { + crashFile = cf + _ = debug.SetCrashOutput(cf, debug.CrashOptions{}) + } + // Redirect log.Print* and the standard logger to the file too, so // any third-party output stays consistent. log.SetOutput(io.MultiWriter(file, os.Stderr)) diff --git a/internal/qso/qso.go b/internal/qso/qso.go index b9922ee..e34bcad 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1621,8 +1621,9 @@ func scanAwardQSO(s scanner) (QSO, error) { // NEW / NEW SLOT / WORKED in constant time after one batched query. type EntitySlot struct { Country string - Bands map[string]struct{} // bands worked, any mode - Slots map[string]map[string]struct{} // band → modes worked + Bands map[string]struct{} // bands worked, any mode + Modes map[string]struct{} // modes worked, any band + Slots map[string]map[string]struct{} // band → modes worked } // EntitySlotMap returns slot data for every QSO, grouped by DXCC entity NUMBER. @@ -1667,11 +1668,13 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store e = &EntitySlot{ Country: country, Bands: make(map[string]struct{}), + Modes: make(map[string]struct{}), Slots: make(map[string]map[string]struct{}), } out[key] = e } e.Bands[band] = struct{}{} + e.Modes[mode] = struct{}{} bandSlots, ok := e.Slots[band] if !ok { bandSlots = make(map[string]struct{})