fix: Showing beam heading on map even if no call is entered

This commit is contained in:
2026-06-22 21:46:41 +02:00
parent 824971d0a1
commit 79dc20a859
9 changed files with 109 additions and 36 deletions
+10 -2
View File
@@ -8497,7 +8497,8 @@ type SpotQuery struct {
// //
// "new" — entity never worked // "new" — entity never worked
// "new-band" — entity worked but never on this band // "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 // "worked" — exact band+mode already in the log
// "" — couldn't resolve the entity (no cty.dat match) // "" — couldn't resolve the entity (no cty.dat match)
type SpotStatus struct { type SpotStatus struct {
@@ -8586,12 +8587,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out[i].Status = "new-band" out[i].Status = "new-band"
continue 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". // the safer default is "worked" so we never falsely claim "new".
if out[i].Mode == "" { if out[i].Mode == "" {
out[i].Status = "worked" out[i].Status = "worked"
continue 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 { if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
out[i].Status = "new-slot" out[i].Status = "new-slot"
continue continue
+6 -4
View File
@@ -729,7 +729,7 @@ export default function App() {
const [clusterLockMode, setClusterLockMode] = useState(false); const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including // Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass. // 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<Set<SpotStatusKey>>(new Set()); const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the // Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital). // 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' 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-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: '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) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
return ( return (
@@ -2736,7 +2737,7 @@ export default function App() {
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden"> <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)} <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} 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)} />
</div> </div>
); );
case 'flex': case 'flex':
@@ -3554,6 +3555,7 @@ export default function App() {
onBulkEdit={openBulkEdit} onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF} onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF} onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/> />
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30"> <div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
@@ -3729,7 +3731,7 @@ export default function App() {
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)} <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} 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)} />
</TabsContent> </TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the {/* Opened on demand from Tools → QSL Manager; closable via the
+26 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
@@ -121,6 +121,7 @@ function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string
switch (s?.status) { switch (s?.status) {
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' }; case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' }; 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' }; case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null; 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); const s = statusFor(p);
if (s?.status === 'new') return 'NEW DXCC'; if (s?.status === 'new') return 'NEW DXCC';
if (s?.status === 'new-band') return 'NEW BAND'; if (s?.status === 'new-band') return 'NEW BAND';
if (s?.status === 'new-mode') return 'NEW MODE';
if (s?.status === 'new-slot') return 'NEW SLOT'; if (s?.status === 'new-slot') return 'NEW SLOT';
return s?.worked_call ? 'WKD CALL' : ''; return s?.worked_call ? 'WKD CALL' : '';
}, },
@@ -212,12 +214,22 @@ const COL_CATALOG: ColEntry[] = [
defaultVisible: true, defaultVisible: true,
cellClass: 'font-mono', cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', 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. // Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot' // yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 } cellStyle: (p: any) => {
: undefined), 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 : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>, cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>,
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', group: 'Spot', label: 'Pfx', colId: 'pfx',
@@ -327,6 +339,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// change below. // change below.
const context = useMemo(() => ({ spotStatus }), [spotStatus]); 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) { function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
+22 -13
View File
@@ -139,18 +139,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const from = gridToLatLon(fromGrid); const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid); 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' }) L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo); .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) ── // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (beamAzimuths && beamAzimuths.length) { if (beamAzimuths && beamAzimuths.length) {
@@ -204,17 +199,31 @@ 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 }) 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); .bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
} }
}
// 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 (autoZoom) {
if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); 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 }); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} } else if (to) {
} else if (autoZoom && to) {
wm.setView([to.lat, to.lon], 3); wm.setView([to.lat, to.lon], 3);
} else if (autoZoom && from) { } else if (from) {
wm.setView([from.lat, from.lon], 3); wm.setView([from.lat, from.lon], 3);
} }
}
setTimeout(() => { wm.invalidateSize(); }, 0); setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
+16 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; 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; export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -15,6 +15,7 @@ type Props = {
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onDelete?: (ids: number[]) => void;
}; };
const UPLOAD_TARGETS: { service: string; label: string }[] = [ 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 // 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, // list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.) // 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(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
const close = () => onClose(); const close = () => onClose();
@@ -159,6 +160,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
))} ))}
</> </>
)} )}
{onDelete && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
onClick={() => { onDelete(menu.ids); onClose(); }}
>
<Trash2 className="size-4" />
<span>Delete {n} QSO{n > 1 ? 's' : ''}</span>
</button>
</>
)}
</div> </div>
); );
} }
+3 -1
View File
@@ -56,6 +56,7 @@ type Props = {
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onDelete?: (ids: number[]) => void;
// One column per defined award; the cell shows the reference this QSO counts // 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. // for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
@@ -222,7 +223,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc', '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<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -395,6 +396,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onBulkEdit={onBulkEdit} onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected} onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered} onExportFiltered={onExportFiltered}
onDelete={onDelete}
/> />
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}> <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
+3 -1
View File
@@ -53,6 +53,7 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void; onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for). // One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
}; };
@@ -67,7 +68,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; 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<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -253,6 +254,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo} onSendTo={onSendTo}
onSendRecording={onSendRecording} onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL} onSendEQSL={onSendEQSL}
onDelete={onDelete}
/> />
{count > entries.length && ( {count > entries.length && (
+13
View File
@@ -11,6 +11,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"sync" "sync"
"time" "time"
) )
@@ -19,6 +20,7 @@ var (
mu sync.Mutex mu sync.Mutex
file *os.File file *os.File
path string 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 // Init opens (creates) the log file in dataDir. On rotation we truncate
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
file = f file = f
path = logPath 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 // Redirect log.Print* and the standard logger to the file too, so
// any third-party output stays consistent. // any third-party output stays consistent.
log.SetOutput(io.MultiWriter(file, os.Stderr)) log.SetOutput(io.MultiWriter(file, os.Stderr))
+3
View File
@@ -1622,6 +1622,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
type EntitySlot struct { type EntitySlot struct {
Country string Country string
Bands map[string]struct{} // bands worked, any mode 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 Slots map[string]map[string]struct{} // band → modes worked
} }
@@ -1667,11 +1668,13 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
e = &EntitySlot{ e = &EntitySlot{
Country: country, Country: country,
Bands: make(map[string]struct{}), Bands: make(map[string]struct{}),
Modes: make(map[string]struct{}),
Slots: make(map[string]map[string]struct{}), Slots: make(map[string]map[string]struct{}),
} }
out[key] = e out[key] = e
} }
e.Bands[band] = struct{}{} e.Bands[band] = struct{}{}
e.Modes[mode] = struct{}{}
bandSlots, ok := e.Slots[band] bandSlots, ok := e.Slots[band]
if !ok { if !ok {
bandSlots = make(map[string]struct{}) bandSlots = make(map[string]struct{})