feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped)

This commit is contained in:
2026-07-22 15:38:08 +02:00
parent 5aac28f564
commit 4c75680689
10 changed files with 284 additions and 39 deletions
+37 -12
View File
@@ -116,6 +116,9 @@ type ModePreset = ModePresetForm;
type WB = WorkedBeforeView;
type CATState = Omit<catModels.RigState, 'convertValues'>;
// Safety cap on how many rows an ACTIVE filter may load (the normal row
// threshold is bypassed while filtering — see buildActiveFilter).
const FILTERED_LIMIT_CAP = 10000;
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
@@ -1431,13 +1434,21 @@ export default function App() {
// The full filter sent to the backend = the builder conditions + the quick
// callsign search box (always ANDed) + the on-screen row threshold.
const buildActiveFilter = useCallback((): QueryFilter => ({
quick_callsign: filterCallsign,
conditions: activeFilter.conditions ?? [],
match: activeFilter.match ?? 'AND',
limit: qsoLimit,
offset: 0,
}), [filterCallsign, activeFilter, qsoLimit]);
// When a filter is ACTIVE the row threshold is lifted to a high safety cap:
// the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
// it to a filter meant "200 matches, only the first 100 shown". A filtered
// result set is almost always small, and the cap protects against a
// broad filter (e.g. band=20m) pulling half the log.
const buildActiveFilter = useCallback((): QueryFilter => {
const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
return {
quick_callsign: filterCallsign,
conditions: activeFilter.conditions ?? [],
match: activeFilter.match ?? 'AND',
limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
offset: 0,
};
}, [filterCallsign, activeFilter, qsoLimit]);
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
// surface the error — used by the startup retry, since the logbook DB (a remote
@@ -3729,7 +3740,12 @@ export default function App() {
case 'netcontrol':
return (
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
qsoMenuHandlers={{
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
onDelete: (ids: number[]) => setDeletingIds(ids),
}} />
</div>
);
case 'recent':
@@ -4796,9 +4812,13 @@ export default function App() {
)}
</div>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-warning">Limit reached raise Max to see more.</span>
)}
{(activeFilter.conditions?.length || filterCallsign)
? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
<span className="text-warning">{matchCount.toLocaleString('en-US')} matches only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
))
: (qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-warning">Limit reached raise Max to see more.</span>
))}
<Label className="text-[11px] text-muted-foreground">Max</Label>
<Input
type="number"
@@ -5052,7 +5072,12 @@ export default function App() {
tune the rig. */}
{netEnabled && (
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
qsoMenuHandlers={{
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
onDelete: (ids: number[]) => setDeletingIds(ids),
}} />
</TabsContent>
)}
+13 -6
View File
@@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
// lists every real (band, mode) the entity was worked on.
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
// DXCC-style grouping instead (Settings → General), where all digital modes
// count as ONE — then FT4 after FT8 is just "worked".
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
const normMode = (m: string): string => {
const u = (m || '').toUpperCase().trim();
if (!groupDigital) return u;
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
};
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = (currentMode || '').toUpperCase().trim();
const curMode = normMode(currentMode);
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
+55 -4
View File
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
// the worked-before grid here offers the SAME context menu on logged QSOs.
type QSOMenuHandlers = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
};
type Props = {
onLogged?: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
qsoMenuHandlers?: QSOMenuHandlers;
};
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
const { t } = useI18n();
const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>('');
@@ -70,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [looking, setLooking] = useState(false);
const rosterGrid = useRef<any>(null);
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
// external drop zones need each grid's api + the OTHER grid's container div.
const onAirApi = useRef<any>(null);
const onAirWrap = useRef<HTMLDivElement | null>(null);
const rosterWrap = useRef<HTMLDivElement | null>(null);
const dropZonesDone = useRef({ roster: false, onair: false });
// Callbacks live in refs so the drop-zone closures (registered once) always
// call the CURRENT activate/deactivate, not a stale first-render one.
const activateRef = useRef<(call: string) => void>(() => {});
const deactivateRef = useRef<(id?: number) => void>(() => {});
const wireDropZones = useCallback(() => {
const rApi = rosterGrid.current?.api;
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
dropZonesDone.current.roster = true;
rApi.addRowDropZone({
getContainer: () => onAirWrap.current!,
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
});
}
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
dropZonesDone.current.onair = true;
onAirApi.current.addRowDropZone({
getContainer: () => rosterWrap.current!,
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
});
}
}, []);
// Worked-before for the clicked station (see if/when we contacted it before).
const [wbCall, setWbCall] = useState('');
@@ -195,6 +237,10 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Keep the drop-zone closures pointed at the CURRENT handlers.
activateRef.current = activate;
deactivateRef.current = deactivate;
// Log EVERYONE still on air (end of net): each draft is written to the logbook
// exactly as the single-log button would.
async function logAll() {
@@ -258,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
}
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
@@ -307,12 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div>
<div className="flex flex-col min-h-0 flex-1">
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
@@ -360,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={(wb.entries ?? []) as any}
total={wb.count}
storageKey="net.wb"
{...qsoMenuHandlers}
/>
)}
</div>
@@ -373,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station>
ref={rosterGrid}
@@ -382,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={() => wireDropZones()}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false}
+47 -5
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
@@ -33,6 +33,11 @@ type Props = {
// Bump `seq` to programmatically select the single row with this id (e.g. NET
// Control auto-advancing to the next on-air station after logging one).
selectRowSignal?: { id: number; seq: number };
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
// row onto the roster to log it). Pair with onGridApi so the parent can
// register external drop zones via api.addRowDropZone.
rowDragCall?: boolean;
onGridApi?: (api: any) => void;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
@@ -252,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -260,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
// (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null);
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
@@ -319,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
col.rowDrag = true;
}
return col;
});
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
@@ -333,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t, awardShown]);
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
// Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -356,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
}), []);
function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
@@ -371,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
@@ -400,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
function onSelectionChanged() {
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
const api = gridRef.current?.api;
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
@@ -480,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
return (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
{/* Live selection count — visible without opening the context menu. */}
{selCount > 0 && (
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
{t('rqg.selectedCount', { n: selCount })}
</span>
)}
{/* Select every loaded row that passes the active column filters — so a
filtered view can be selected in one click (then send to LoTW, bulk
edit, export…). Once everything is selected the same button flips to
"Unselect all". */}
{(() => {
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
return (
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
onClick={() => {
const api: any = gridRef.current?.api;
if (!api) return;
if (allSelected) api.deselectAll();
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
else api.selectAll('filtered');
}}>
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
</Button>
);
})()}
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('rqg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
@@ -1149,6 +1149,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
@@ -4619,6 +4620,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
File diff suppressed because one or more lines are too long
+1
View File
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
// Cluster filter selections — restored on reopen.