fix(settings): Preferences stopped lagging behind the keyboard

Typing a cluster macro cost two things per character. The state lived on
SettingsModal, so every keystroke in one of those twenty-four boxes
re-rendered the WHOLE preferences dialog — every list, every form, every
panel. And the save wrote through to Go and into the database on each
one: a round trip per character.

The editor is now its own module-scoped component with its own state, so
a keystroke re-renders twelve rows. And writeUiPrefDebounced holds the
database write until the typing stops, while the local cache — which is
what everything reads back — is still written at once. Pending values are
flushed when the page goes away, so typing and immediately closing does
not lose the last word.

The behaviour is unchanged: still saved as you type, still no Save
button, because a text box whose contents only take effect on some other
button is how work gets lost.

Also folds the satellite changelog into one [NEW] entry. Satellites are
new in this version — nobody reading the notes has seen any of it — so a
running account of how it was built, tab then tracking then rotator then
where the settings moved to, is the wrong shape. One entry saying what it
does.
This commit is contained in:
2026-09-07 23:25:46 +02:00
parent fa6e30545a
commit 7ff0c2ac69
4 changed files with 117 additions and 74 deletions
+61 -46
View File
@@ -916,6 +916,66 @@ const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
const relayCountUI = (type: string) =>
type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5;
// The twelve cluster command macros.
//
// Module-scoped, with its own state, and that is the point rather than tidiness:
// nested inside SettingsModal every keystroke in one of these twenty-four boxes
// re-rendered the WHOLE preferences dialog — every list, every form, every
// panel — and the letters arrived visibly after the finger had left the key.
// Here a keystroke re-renders twelve rows.
//
// Written on every keystroke as before. This panel has no Save button, and a
// text box whose contents only take effect on some other button is how work
// gets lost; the database write is what waits (see saveClusterMacros).
function ClusterMacroEditor() {
const { t } = useI18n();
const [macros, setMacros] = useState<ClusterMacro[]>(loadClusterMacros);
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
setMacros((cur) => {
const next = cur.map((m, j) => (j === i ? { ...m, ...patch } : m));
saveClusterMacros(next);
return next;
});
};
return (
<div className="border-t border-border/60 pt-3 space-y-2">
<div>
<span className="text-sm font-medium">{t('clu.macros')}</span>
</div>
{/* Two columns of six: twelve rows stacked would push everything else
in this panel off the screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
{macros.map((m, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
<Input
className="h-8 w-28 shrink-0 text-xs"
placeholder={t('clu.macroLabel')}
value={m.label}
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}
title={m.cmd}
maxLength={500}
onChange={(e) => setMacro(i, { cmd: e.target.value })}
/>
</div>
))}
</div>
</div>
);
}
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
// isn't remounted on every parent render. Polls once a second while shown.
@@ -1950,9 +2010,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free.
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
@@ -5975,14 +6032,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of
// text boxes whose contents only take effect on some other button is how
// work gets lost.
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
setClusterMacros(next);
saveClusterMacros(next);
};
return (
<>
<SectionHeader
@@ -6075,41 +6124,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{t('clu.autoConnect')}
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<div>
<span className="text-sm font-medium">{t('clu.macros')}</span>
</div>
{/* Two columns of six: twelve rows stacked would push everything else
in this panel off the screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
{clusterMacros.map((m, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
<Input
className="h-8 w-28 shrink-0 text-xs"
placeholder={t('clu.macroLabel')}
value={m.label}
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}
title={m.cmd}
maxLength={500}
onChange={(e) => setMacro(i, { cmd: e.target.value })}
/>
</div>
))}
</div>
</div>
<ClusterMacroEditor />
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />