rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
@@ -0,0 +1,297 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, Star } 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 { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView } from '@/types';
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
type Props = {
wb: WorkedBeforeView | null;
busy: boolean;
currentCall: string;
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
function fmtDateTime(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
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())}`;
}
function fmtDate(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
const bandPill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
}}>{p.value}</span>
: '';
const modePill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
}}>{p.value}</span>
: '';
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
const flagRenderer = (p: any) => {
if (p.value === 'Y') {
return <span style={{
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
fontSize: 10, fontWeight: 700, lineHeight: '16px',
}}>Y</span>;
}
return <span style={{ color: '#a8a29e' }}></span>;
};
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
];
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const hasCall = currentCall.trim() !== '';
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
function onGridReady(e: GridReadyEvent) {
try {
const raw = localStorage.getItem(COL_STATE_KEY);
if (raw) {
const state = JSON.parse(raw) as ColumnState[];
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
}
} catch {}
}
const saveColumnState = useCallback(() => {
try {
const state = gridRef.current?.api?.getColumnState();
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []);
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();
}
// Empty / loading / no-call states.
if (!hasCall) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
Type a callsign in the entry strip to see prior contacts.
</div>
);
}
if (busy && count === 0) {
return (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
checking
</div>
);
}
if (count === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<Star className="size-8 text-primary fill-current" />
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
</div>
);
}
return (
<>
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
<div className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
</div>
<div className="text-[11px] text-muted-foreground">
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
</div>
{wb?.dxcc_name && (
<div className="text-[11px] text-muted-foreground">
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
<span> · {wb.dxcc_count} entity QSOs</span>
)}
</div>
)}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<WorkedEntry>
ref={gridRef}
theme={hamlogTheme}
rowData={entries}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
onGridReady={onGridReady}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
{count > entries.length && (
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
+ {count - entries.length} older QSOs (not shown capped for performance)
</div>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Worked-before columns</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Worked-before table.
</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">{group}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
</div>
</div>
<div className="flex flex-col 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}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}