From 678c8821c2282ee6a993e9458444d4edb0c6677e Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 27 Jul 2026 11:56:43 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20column=20layouts=20would=20not=20stick?= =?UTF-8?q?=20=E2=80=94=20five=20separate=20faults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. A language change rebuilt the column defs, which sets the anti-clobber guard, but the effect that clears it listened to only two of the rebuild's causes. Guard stuck on → every column change was silently discarded for the rest of the session. Now keyed on the rebuilt defs themselves, so any future cause is covered. 2. The cluster grid had no guard at all: the same rebuild wrote its defaults over the saved layout, in the cache AND the database. Unrecoverable. 3. Worked-before and cluster read their state before the active profile was known, so they looked up the unscoped cache key (always a miss) and then saved under the scoped one. They now wait for the scope and remount on a profile switch, like the main log. 4. GetUIPref returns an ERROR, not "", while the settings store is not yet scoped — deliberately distinct from "unset". The frontend read it as "no preference", rendered defaults, and the first column event overwrote the good copy. It now retries instead of giving up. 5. The QSL manager had no storageKey, so it shared the main log's layout and each rewrote the other. Also: the DB write is debounced (a resize drag fired dozens of writes) with a flush on close, and a rejected write is retried rather than dropped. --- changelog.json | 12 +++ frontend/src/App.tsx | 17 ++++- frontend/src/components/ClusterGrid.tsx | 45 ++++++++--- frontend/src/components/QSLManagerModal.tsx | 5 ++ frontend/src/components/RecentQSOsGrid.tsx | 8 +- frontend/src/components/WorkedBeforeGrid.tsx | 23 +++--- frontend/src/lib/gridPrefs.ts | 80 ++++++++++++++++++-- 7 files changed, 156 insertions(+), 34 deletions(-) diff --git a/changelog.json b/changelog.json index 518ceef..18fbaaa 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,16 @@ [ + { + "version": "0.21.5", + "date": "2026-07-27", + "en": [ + "Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.", + "ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier)." + ], + "fr": [ + "Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.", + "Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur)." + ] + }, { "version": "0.21.4", "date": "2026-07-26", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f2ee1cc..c05040e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -95,7 +95,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { RotorCompass } from '@/components/RotorCompass'; import { writeUiPref } from '@/lib/uiPref'; -import { setGridPrefsProfile } from '@/lib/gridPrefs'; +import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs'; import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel'; import { Button } from '@/components/ui/button'; @@ -2356,6 +2356,14 @@ export default function App() { }).catch(() => {}); }, []); + // A column resized in the last seconds before quitting must still reach the + // database: the DB write is debounced, so flush it on the way out. + useEffect(() => { + const flush = () => flushGridPrefs(); + window.addEventListener('beforeunload', flush); + return () => { window.removeEventListener('beforeunload', flush); flush(); }; + }, []); + // Every setting is per-profile, so when the active profile changes the whole // main UI re-reads its config (station identity, lists, CAT, keyer). The Go // side reloads its managers; this keeps the React state in sync. @@ -4147,7 +4155,7 @@ export default function App() {
- +
{clusterShowFilters && renderClusterFilters()}
@@ -4156,7 +4164,7 @@ export default function App() { case 'worked': return (
- openEdit(q.id as number)} + openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields} @@ -5485,6 +5493,7 @@ export default function App() { } return ( - openEdit(q.id as number)} + openEdit(q.id as number)} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields} diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index e36cce3..f47846e 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -12,7 +12,7 @@ import { 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 { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs'; import { useI18n } from '@/lib/i18n'; type TFn = (key: string, vars?: Record) => string; @@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { // Localized column catalog — rebuilt when the language changes. const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); - const columnDefs = useMemo[]>(() => COL_CATALOG.map((c) => { - const { group: _g, label: _l, defaultVisible, ...rest } = c; - return { ...rest, hide: !defaultVisible }; - }), [COL_CATALOG]); + // A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the + // matching column events. Without this guard those events were persisted, so a + // single language change overwrote the saved cluster layout — in the cache AND + // in the database, with nothing to restore from. + const restoringRef = useRef(true); + + const columnDefs = useMemo[]>(() => { + restoringRef.current = true; + return COL_CATALOG.map((c) => { + const { group: _g, label: _l, defaultVisible, ...rest } = c; + return { ...rest, hide: !defaultVisible }; + }); + }, [COL_CATALOG]); + + // Re-apply the saved state after every rebuild, then re-enable saving. + useEffect(() => { + const api = gridRef.current?.api; + const local = loadLocal(COL_STATE_KEY); + if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); + const tm = window.setTimeout(() => { restoringRef.current = false; }, 0); + return () => window.clearTimeout(tm); + }, [columnDefs]); const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, filter: true, suppressMovable: false, @@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { gridRef.current?.api?.refreshCells({ force: true }); }, [spotStatus]); - function onGridReady(e: GridReadyEvent) { + // Restore AFTER the profile scope is known — this grid has no key= remount to + // save it from reading the wrong (unscoped) cache key at first paint. + async function onGridReady(e: GridReadyEvent) { + await whenGridPrefsReady(); 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 remote = await loadRemote(COL_STATE_KEY); + if (remote && !local) { + e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); + seedLocal(COL_STATE_KEY, remote); + } } const saveColumnState = useCallback(() => { + if (restoringRef.current) return; // ignore the events fired by a column rebuild const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state); }, []); diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 6f0472a..b470cec 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi // drives which QSOs the apply-form below updates; a search selects all.