feat(cluster): hold the list still while it is being read, and stop truncating filters

Two things reported together, both about a list that fights back.

The GRID froze nothing: every spot landed at the top and pushed the rest
down, so a few rows in, the callsign under the pointer had moved by the
time the click arrived. It now stops redrawing as soon as it is scrolled
away from the top and says how many spots are waiting; reaching the top
again, or clicking the notice, releases it. The arrivals are counted by
finding the frozen top row in the live list rather than by comparing
lengths — the list is a ring buffer, so once it is full a length
comparison would report nothing new for the rest of the evening.

The COMMAND BUTTONS were capped at 120 characters. A DXSpider filter
naming the prefixes an operator wants runs well past that, and the field
just stopped accepting keystrokes: the command was saved truncated with
nothing to say why. 500 now, with the full text in the tooltip since the
box cannot show it.
This commit is contained in:
2026-08-24 22:19:54 +02:00
parent f9113b6ad3
commit b2b93e2839
4 changed files with 64 additions and 6 deletions
+48 -1
View File
@@ -588,6 +588,44 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
if (state) saveState(COL_STATE_KEY, state);
}, []);
// HOLDING THE LIST STILL WHILE IT IS BEING READ.
//
// A busy evening puts a spot on the list every second or two, each landing at
// the top and pushing everything below it down. At the top of the list that is
// exactly right — it is what makes the panel live. A few rows down it makes
// the panel unusable: the callsign under the pointer has moved by the time the
// click lands, and the operator ends up chasing the row they wanted.
//
// So the grid freezes as soon as it is scrolled away from the top, and says
// how many spots are waiting. Scrolling back to the top releases it, as does
// clicking the notice. Nothing is lost — the spots keep arriving in the shared
// list; this only decides when the GRID is allowed to redraw with them.
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
const shown = held ?? rows;
// How many arrived since the freeze. Counted by finding the frozen top row in
// the live list rather than by comparing lengths: the list is a ring buffer,
// so once it is full the length stops growing and a length comparison would
// report nothing new for the rest of the evening.
const spotID = (r: ClusterSpot) => `${(r as any).received_at}-${r.dx_call}-${(r as any).source_id}`;
const waiting = useMemo(() => {
if (!held || held.length === 0) return 0;
const top = spotID(held[0]);
const i = rows.findIndex((r) => spotID(r) === top);
return i < 0 ? rows.length : i; // fell out of the buffer: everything is new
}, [held, rows]);
const onBodyScroll = (e: { top: number }) => {
const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
if (!down && held) setHeld(null);
if (down && !held) setHeld(rows);
};
const release = () => {
setHeld(null);
gridRef.current?.api?.ensureIndexVisible(0, 'top');
};
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
if (e.data && onSpotSelect) onSpotSelect(e.data);
}
@@ -646,7 +684,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
<AgGridReact<ClusterSpot>
ref={gridRef}
theme={hamlogTheme}
rowData={rows}
rowData={shown}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
context={context}
@@ -658,11 +696,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
onSortChanged={saveColumnState}
onRowClicked={handleRowClicked}
onRowDoubleClicked={handleRowDoubleClicked}
onBodyScroll={onBodyScroll}
animateRows={false}
suppressCellFocus
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
/>
</div>
{/* Only when something is actually waiting: a frozen list with nothing
new to show needs no announcement. */}
{waiting > 0 && (
<button type="button" onClick={release}
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
{t('clg2.newSpots', { n: waiting })}
</button>
)}
</div>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
+8 -1
View File
@@ -4918,11 +4918,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
maxLength={24}
onChange={(e) => setMacro(i, { label: e.target.value })}
/>
{/* 500, not 120. A DXSpider filter is a list of prefixes and
an operator's own list of wanted countries runs past a
hundred characters easily the field simply stopped
accepting keystrokes, with nothing to say why, and the
command was saved truncated. The title shows the whole
thing, since the box cannot. */}
<Input
className="h-8 flex-1 min-w-0 font-mono text-xs"
placeholder={t('clu.macroCmd')}
value={m.cmd}
maxLength={120}
title={m.cmd}
maxLength={500}
onChange={(e) => setMacro(i, { cmd: e.target.value })}
/>
</div>