feat: never drop cluster spots + theme-aware spot badges + FR settings
Backend: replace the bounded cluster-event channel (which dropped spots under RBN bursts once enrichment/UI fell behind) with an unbounded clusterQueue (slice + sync.Cond). The socket-read goroutine still never blocks, but a burst now grows the backlog and drains instead of losing spots. Head compaction keeps a sustained burst from growing the backing array without bound. ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country, Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so they read correctly on every theme. Replace the heavy full-cell band/mode fills with small rounded pills (cellChip). Only NEW MODE pills the mode cell — NEW SLOT (band and mode each worked, just not together) no longer highlights the mode cell, which wrongly implied the mode was new. App: stage incoming spots ~50ms and resolve their slot status before inserting, so a row paints with its badge already on instead of flashing plain text then flipping to a pill. Self-spot toast still fires per spot. i18n: translate the General settings that were still English — telemetry and live-status toggles, the Main view pane pickers and their options. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -113,6 +113,23 @@ function tok(name: string, text: string): Badge {
|
||||
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>;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||
border: `1px solid var(--${name}-border)`, fontWeight: 700, fontSize: 9,
|
||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{txt}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||
@@ -129,7 +146,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||
@@ -204,7 +221,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: '#166534' },
|
||||
cellStyle: { color: 'var(--success)' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
@@ -219,10 +236,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||
: undefined),
|
||||
// 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),
|
||||
},
|
||||
{
|
||||
@@ -231,16 +246,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||
cellStyle: (p: any) => {
|
||||
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>,
|
||||
// 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');
|
||||
@@ -252,7 +262,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||
@@ -289,7 +299,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||
@@ -298,31 +308,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
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: '#7a6b50', fontSize: 10 },
|
||||
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: '#7a6b50' },
|
||||
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: '#9a8870', fontSize: 10 },
|
||||
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: '#7a6b50' },
|
||||
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: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||
|
||||
@@ -540,6 +540,7 @@ function AutostartPanelComponent() {
|
||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||
// can own its state; embedded inside GeneralPanel.
|
||||
function TelemetryToggle() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -549,8 +550,8 @@ function TelemetryToggle() {
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||
Send anonymous usage statistics
|
||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
||||
{t('settings.telemetry')}
|
||||
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -560,6 +561,7 @@ function TelemetryToggle() {
|
||||
// events — a small web script on your server renders it for the QRZ page. Only
|
||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||
function LiveStatusToggle() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -569,7 +571,7 @@ function LiveStatusToggle() {
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -578,25 +580,20 @@ function LiveStatusToggle() {
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'map1', label: 'Map — great-circle + beam' },
|
||||
{ value: 'map2', label: 'Map — locator (street)' },
|
||||
{ value: 'cluster', label: 'Cluster spots' },
|
||||
{ value: 'worked', label: 'Worked before' },
|
||||
{ value: 'recent', label: 'Recent QSOs' },
|
||||
{ value: 'netcontrol', label: 'Net control' },
|
||||
];
|
||||
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||
const options = [
|
||||
...MAIN_PANE_OPTIONS,
|
||||
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
||||
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
||||
].sort((a, b) => a.label.localeCompare(b.label));
|
||||
...MAIN_PANE_VALUES,
|
||||
...(flexAvailable ? ['flex'] : []),
|
||||
...(icomAvailable ? ['icom'] : []),
|
||||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
}, []);
|
||||
@@ -609,11 +606,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
};
|
||||
return (
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Left pane</span>
|
||||
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
|
||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -622,7 +619,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Right pane</span>
|
||||
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
|
||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
Reference in New Issue
Block a user