import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AllCommunityModule, ModuleRegistry, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, } from 'ag-grid-community'; import { hamlogGridTheme } from '@/lib/gridTheme'; import { AgGridReact } from 'ag-grid-react'; import { Columns3, FilterX } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs'; import { useI18n } from '@/lib/i18n'; type TFn = (key: string, vars?: Record) => string; ModuleRegistry.registerModules([AllCommunityModule]); const hamlogTheme = hamlogGridTheme; export type ClusterSpot = { source_id: number; source_name: string; spotter: string; dx_call: string; freq_khz: number; freq_hz: number; band?: string; comment?: string; locator?: string; time_utc?: string; country?: string; continent?: string; cqz?: number; ituz?: number; distance_km?: number; sp_deg?: number; lp_deg?: number; received_at: string; raw: string; repeats?: number; pota_ref?: string; pota_name?: string; }; export type SpotStatusEntry = { status?: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string; }; type Props = { rows: ClusterSpot[]; spotStatus: Record; onSpotClick?: (s: ClusterSpot) => void; }; const COL_STATE_KEY = 'hamlog.clusterColState.v1'; // Extracts the prefix from a callsign — drops portable suffixes (/P, /MM // etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing // digits after the last letter group (DL2SBY → DL2). function fmtPfx(call: string): string { if (!call) return ''; const c = call.trim().toUpperCase(); const base = c.includes('/') ? c.split('/')[0] : c; // If "base" is a callsign rather than a bare prefix (like DL2SBY), cut // at the last digit to get DL2. let lastDigit = -1; for (let i = 0; i < base.length; i++) { if (base[i] >= '0' && base[i] <= '9') lastDigit = i; } return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base; } // Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC // "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style. function fmtDateTimeUTC(s: any): string { if (!s) return ''; const d = new Date(s); if (isNaN(d.getTime())) return String(s); const p = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`; } type ColEntry = ColDef & { group: string; label: string; defaultVisible?: boolean }; // statusFor resolves the precomputed spot status (new / new-band / new-slot / // worked-call) for an ag-Grid cell's row. function statusFor(p: any): SpotStatusEntry | undefined { return p?.context?.spotStatus?.[ spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) ]; } // Spot status is shown by COLOURING THE TEXT, never by a pill or a badge. // // The pills were dropped: a rounded box has its own height and padding, so it // sat off the row's baseline and the callsign inside it no longer lined up with // the plain callsigns above and below. A whole column of them read as chrome // rather than as data. Same reasoning — and the same semantic tokens — as the // Y/N/R QSL columns in lib/qslStatus.ts. // // Which cell is coloured IS the message, so one colour is enough for all three: // // yellow call → new DXCC yellow band → new band yellow mode → new mode // blue call → already worked const NEW = 'var(--warning)'; // yellow: something here is new const WKD = 'var(--info)'; // blue: this callsign is already in the log // cellText renders a cell value in an optional colour. An empty value keeps the // muted dash the grid used before, so blank cells still read as "nothing here" // rather than as a gap. function cellText(value: any, color: string | null): any { const txt = value === undefined || value === null || value === '' ? '' : String(value); if (!txt) return ; return {txt}; } // statusColor is the colour for a resolved status in the Status column. County, // POTA and prefix keep their own tokens: they are orthogonal to the band/mode/ // DXCC story and an operator filters on them separately. function statusColor(s: SpotStatusEntry | undefined): string | null { switch (s?.status) { case 'new': case 'new-band': case 'new-mode': case 'new-slot': return NEW; default: return s?.worked_call ? WKD : null; } } // isDull reports a spot that "represents nothing" under the current worked/slot // rules: the entity/band/mode is already worked (status resolved, not new in any // dimension), the callsign itself isn't in the log, and there's no POTA / county // / prefix novelty. Such rows are dimmed so the eye skips them. Because the // status obeys the DX-cluster "same slot" option and the digital-mode grouping, // what counts as dull follows those settings automatically. Unresolved statuses // (still loading, or entity unknown) are NOT dimmed — that would flicker. function isDull(s: SpotStatusEntry | undefined): boolean { if (!s || !s.status) return false; if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false; return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx); } const makeColCatalog = (t: TFn): ColEntry[] => [ { group: 'Spot', label: t('clg2.c.time'), colId: 'time', headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono', defaultVisible: true, sort: 'desc', // Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A // lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts // below "2359Z", so spots received just after midnight fell to the bottom // and looked like the cluster had stopped. received_at is a full datetime // that keeps ordering correct across 0000Z. comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => { const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0; const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0; return ta - tb; }, cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.call'), colId: 'call', headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120, defaultVisible: true, cellClass: 'font-mono', // NEW DXCC → yellow call. Already worked → blue call. Anything else keeps // the theme's normal ink so ordinary callsigns don't shout. cellRenderer: (p: any) => { const s = statusFor(p); const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null; return {p.value ?? ''}; }, tooltipValueGetter: (p: any) => { const s = statusFor(p); return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined; }, }, { group: 'Spot', label: t('clg2.c.status'), colId: 'status', headerName: t('clg2.c.status'), width: 120, sortable: true, defaultVisible: true, // Spells the status out in words so NEW SLOT (and the others) is obvious at // the row level, not just a single coloured cell — NEW SLOT in particular // colours no cell at all, since neither the band nor the mode is new on its // own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it. valueGetter: (p: any) => { const s = statusFor(p); const parts: string[] = []; if (s?.status === 'new') parts.push(t('clg2.newDxcc')); else if (s?.status === 'new-band') parts.push(t('clg2.newBand')); else if (s?.status === 'new-mode') parts.push(t('clg2.newMode')); else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot')); else if (s?.worked_call) parts.push(t('clg2.wkdCall')); if (s?.new_county) parts.push(t('clg2.newCounty')); if (s?.new_pota) parts.push(t('clg2.newPota')); if (s?.new_pfx) parts.push(t('clg2.newPfx')); return parts.join(' '); }, cellRenderer: (p: any) => { const s = statusFor(p); const parts: { text: string; color: string }[] = []; const main = statusColor(s); if (main) { const label = s?.status === 'new' ? t('clg2.newDxcc') : s?.status === 'new-band' ? t('clg2.newBand') : s?.status === 'new-mode' ? t('clg2.newMode') : s?.status === 'new-slot' ? t('clg2.newSlot') : t('clg2.wkdCall'); parts.push({ text: label, color: main }); } if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' }); if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' }); if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' }); if (parts.length === 0) return ; return ( {parts.map((pt, i) => ( {i > 0 ? ' · ' : ''}{pt.text} ))} ); }, tooltipValueGetter: (p: any) => { const s = statusFor(p); if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' }); if (s?.status === 'new-band') return t('clg2.tipNewBand'); if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand'); if (s?.worked_call) return t('clg2.tipWorkedCall'); return undefined; }, }, { group: 'Spot', label: t('clg2.c.pota'), colId: 'pota', headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono', defaultVisible: true, cellStyle: { color: 'var(--success)' }, tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined), }, { group: 'Spot', label: t('clg2.c.freq'), colId: 'freq', headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono', defaultVisible: true, valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '', comparator: (a, b) => (a ?? 0) - (b ?? 0), }, { group: 'Spot', label: t('clg2.c.band'), colId: 'band', headerName: t('clg2.c.band'), field: 'band' as any, width: 75, defaultVisible: true, cellClass: 'font-mono', // NEW BAND for this entity → the band text turns yellow. cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null), tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined), }, { group: 'Spot', label: t('clg2.c.mode'), colId: 'mode', headerName: t('clg2.c.mode'), colSpan: undefined, width: 80, defaultVisible: true, cellClass: 'font-mono', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', // Only NEW MODE pills the mode cell — there the mode itself is genuinely new // for the entity. NEW SLOT means band AND mode were each worked before (just // not together), so highlighting the mode cell would wrongly imply "CW is new"; // that case is signalled by the Status badge alone. cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null), tooltipValueGetter: (p: any) => { const st = statusFor(p)?.status; if (st === 'new-mode') return t('clg2.tipNewMode'); if (st === 'new-slot') return t('clg2.tipNewSlot'); return undefined; }, }, { group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx', headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono', valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''), cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz', headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => p.value ? String(p.value) : '', }, { group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz', headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => p.value ? String(p.value) : '', }, { group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km', headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => p.value ? String(p.value) : '', comparator: (a, b) => (a ?? 0) - (b ?? 0), }, { group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg', headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '', comparator: (a, b) => (a ?? 0) - (b ?? 0), }, { group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg', headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '', comparator: (a, b) => (a ?? 0) - (b ?? 0), }, { group: 'Spot', label: t('clg2.c.country'), colId: 'country', headerName: t('clg2.c.country'), width: 140, defaultVisible: true, valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[ spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) ]?.country ?? '', cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.continent'), colId: 'continent', headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono', defaultVisible: true, valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[ spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz) ]?.continent ?? '', cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 }, }, { group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter', headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono', defaultVisible: true, valueFormatter: (p) => cleanSpotter(p.value ?? ''), cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.source'), colId: 'source', headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100, defaultVisible: true, cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 }, }, { group: 'Spot', label: t('clg2.c.locator'), colId: 'locator', headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono', cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.comment'), colId: 'comment', headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true, cellStyle: { color: 'var(--muted-foreground)' }, }, { group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at', headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTimeUTC(p.value), }, { group: 'Spot', label: t('clg2.c.raw'), colId: 'raw', headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono', }, ]; const GROUP_ORDER = ['Spot', 'Geo']; const CLG_GRP_KEYS: Record = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' }; const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g); export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { const { t } = useI18n(); const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); // Localized column catalog — rebuilt when the language changes. const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); // A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the // matching column events. Without this guard those events were persisted, so a // single language change overwrote the saved cluster layout — in the cache AND // in the database, with nothing to restore from. const restoringRef = useRef(true); const columnDefs = useMemo[]>(() => { restoringRef.current = true; return COL_CATALOG.map((c) => { const { group: _g, label: _l, defaultVisible, ...rest } = c; return { ...rest, hide: !defaultVisible }; }); }, [COL_CATALOG]); // Re-apply the saved state after every rebuild, then re-enable saving. useEffect(() => { const api = gridRef.current?.api; const local = loadLocal(COL_STATE_KEY); if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); const tm = window.setTimeout(() => { restoringRef.current = false; }, 0); return () => window.clearTimeout(tm); }, [columnDefs]); const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, filter: true, suppressMovable: false, // Dim "represents nothing" spots at the CELL level, via a class that // refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on // every status update, which re-rendered the whole grid continuously and // pegged the CPU on a busy cluster (the 0.23.6 slowdown). cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) }, }), []); // Pass spotStatus through AG Grid's context so cell renderers can look up // per-cell highlight without re-rendering the whole grid when the map // updates. We refresh cells whose values depend on it after each prop // change below. const context = useMemo(() => ({ spotStatus }), [spotStatus]); // Spot statuses arrive asynchronously (~after the rows render). The Call/Band/ // Mode cellStyles and the dimmed "represents nothing" class depend on them but // the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a // refresh so e.g. a worked call turns blue once its status loads. // // THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a // full refreshCells that often is pure churn on a slow PC. Coalesce the bursts // into one refresh every 200 ms (still imperceptible) instead of one per update. const refreshPending = useRef(undefined); useEffect(() => { if (refreshPending.current !== undefined) return; // a refresh is already queued refreshPending.current = window.setTimeout(() => { refreshPending.current = undefined; gridRef.current?.api?.refreshCells({ force: true }); }, 200); }, [spotStatus]); useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []); // Restore AFTER the profile scope is known — this grid has no key= remount to // save it from reading the wrong (unscoped) cache key at first paint. async function onGridReady(e: GridReadyEvent) { await whenGridPrefsReady(); const local = loadLocal(COL_STATE_KEY); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); const remote = await loadRemote(COL_STATE_KEY); if (remote && !local) { e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); seedLocal(COL_STATE_KEY, remote); } } const saveColumnState = useCallback(() => { if (restoringRef.current) return; // ignore the events fired by a column rebuild const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state); }, []); function handleRowClicked(e: RowClickedEvent) { if (e.data && onSpotClick) onSpotClick(e.data); } function isColVisible(colId: string): boolean { const col = gridRef.current?.api?.getColumn(colId); return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible; } function setColVisible(colId: string, visible: boolean) { const api = gridRef.current?.api; if (!api) return; api.setColumnsVisible([colId], visible); saveColumnState(); } function showAll(group?: string) { const api = gridRef.current?.api; if (!api) return; const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!); api.setColumnsVisible(ids, true); saveColumnState(); } function hideAll(group?: string) { const api = gridRef.current?.api; if (!api) return; const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!); api.setColumnsVisible(ids, false); saveColumnState(); } function resetDefaults() { const api = gridRef.current?.api; if (!api) return; const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!); const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!); api.setColumnsVisible(visible, true); api.setColumnsVisible(hidden, false); saveColumnState(); } return ( <>
ref={gridRef} theme={hamlogTheme} rowData={rows} columnDefs={columnDefs} defaultColDef={defaultColDef} context={context} onGridReady={onGridReady} onColumnResized={saveColumnState} onColumnMoved={saveColumnState} onColumnPinned={saveColumnState} onColumnVisible={saveColumnState} onSortChanged={saveColumnState} onRowClicked={handleRowClicked} animateRows={false} suppressCellFocus getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`} />
{t('clg2.pickerTitle')} {t('clg2.pickerDesc')}
{GROUP_ORDER.map((group) => { const cols = COL_CATALOG.filter((c) => c.group === group); if (cols.length === 0) return null; return (
{groupLabel(t, group)}
{cols.map((c) => ( ))}
); })}
); }