Files
OpsLog/frontend/src/components/ClusterGrid.tsx
T
rouggyandClaude Opus 4.8 5abe4bd0c3 fix: restore theme on restart + consistent spot pills + red DVK TX
theme: self-heal the persisted theme after mount. The synchronous boot
read only looks at localStorage, which is empty when the WebView cleared
its storage or when syncPortablePrefs ran before the backend wired its
settings store (GetUIPref returned "" with no error) — so a restart could
silently land on the default light theme. ThemeProvider now re-reads the
portable pref from the DB once the backend is up (retrying briefly to ride
out a slow startup) and applies it, without clobbering a manual pick.

ClusterGrid: the Call cell now uses a danger pill for NEW DXCC, consistent
with the NEW BAND / NEW MODE pills, instead of a full-cell red fill. cellChip
inherits the column's font size (no fixed 9px / height) so a pill around a
callsign stays the same size as the plain callsigns beside it.

DvkPanel: the voice-keyer TX indicator (LED + label) is red while
transmitting, not orange.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 19:28:55 +02:00

519 lines
22 KiB
TypeScript

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 } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => 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;
};
type Props = {
rows: ClusterSpot[];
spotStatus: Record<string, SpotStatusEntry>;
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<ClusterSpot> & { 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)
];
}
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
type Badge = { text: string; fg: string; bg: string; bd: string };
// tok builds a badge from a semantic status token (success/warning/caution/
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
function tok(name: string, text: string): Badge {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return (
<span style={{
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span>
);
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
switch (s?.status) {
case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return tok('caution', t('clg2.newSlot'));
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
}
}
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',
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 → a danger pill around the call, consistent with the NEW BAND /
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
// a plain spot inherits the theme's normal text colour so callsigns blend in
// with the rest of the row instead of always shouting a colour.
cellRenderer: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return cellChip(p.value, 'danger');
const color = s?.worked_call ? 'var(--info)' : undefined;
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
},
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 out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
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'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const s = statusFor(p);
const badges: Badge[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
))}
</span>
);
},
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 → small warning pill around the band text.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : 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) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : 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<string, string> = { 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<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), [COL_CATALOG]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
// 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 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 });
loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
}
const saveColumnState = useCallback(() => {
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
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 (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('clg2.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> {t('clg2.columns')}
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<ClusterSpot>
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}`}
/>
</div>
</div>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
<DialogDescription>
{t('clg2.pickerDesc')}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto py-2">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1">
{cols.map((c) => (
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox
checked={isColVisible(c.colId!)}
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
/>
{c.label}
</label>
))}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}