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.
voi
) : (
{ restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
- }, [awardCols, awardShown]);
+ // Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
+ // causes here (awardCols/awardShown) missed the others — switching the UI
+ // language rebuilds the memo through `t`, which set restoringRef and left it
+ // set, so every column change for the rest of the session was silently
+ // discarded and the layout reverted on reload. Any future dependency of the
+ // memo is now covered for free.
+ }, [columnDefs]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
diff --git a/frontend/src/components/WorkedBeforeGrid.tsx b/frontend/src/components/WorkedBeforeGrid.tsx
index ecf81e0..0d7caeb 100644
--- a/frontend/src/components/WorkedBeforeGrid.tsx
+++ b/frontend/src/components/WorkedBeforeGrid.tsx
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
-import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
+import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -113,15 +113,18 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
- 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, so it
+ // used to miss the cache every time and then save under the scoped key.
+ 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 events fired by a column rebuild
@@ -137,7 +140,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
- }, [awardCols]);
+ // columnDefs itself, not the reasons it was rebuilt — see the same note in
+ // RecentQSOsGrid: a language change rebuilt the memo and left saving off.
+ }, [columnDefs]);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
diff --git a/frontend/src/lib/gridPrefs.ts b/frontend/src/lib/gridPrefs.ts
index aa6be5c..32ecdcb 100644
--- a/frontend/src/lib/gridPrefs.ts
+++ b/frontend/src/lib/gridPrefs.ts
@@ -19,6 +19,26 @@ let lsScope = '';
// changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`;
+ markReady();
+}
+
+// The active profile is only known after an async call, but the grids mount and
+// read their state on the first paint. A grid that read before the scope was set
+// looked up the UNSCOPED cache key (always a miss) and then saved under the
+// scoped one — so its layout could never be restored, only rewritten. Grids now
+// wait for this instead of racing it.
+//
+// The 5 s fallback matters: if the profile lookup fails outright, the grids must
+// still come up with whatever the unscoped cache holds rather than hang with no
+// columns restored.
+let markReady: () => void = () => {};
+const gridPrefsReady = new Promise((resolve) => {
+ markReady = resolve;
+ setTimeout(resolve, 5000);
+});
+
+export function whenGridPrefsReady(): Promise {
+ return gridPrefsReady;
}
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
}
// loadRemote pulls the portable copy from the DB (null if none / unset).
-export async function loadRemote(key: string): Promise {
- try {
- const v = await GetUIPref(key);
- const parsed = v ? JSON.parse(v) : null;
- return Array.isArray(parsed) ? parsed : null;
- } catch {
- return null;
+//
+// GetUIPref returns an ERROR — not "" — while the settings store is still
+// coming up and not yet scoped to the active profile. Treating that as "no
+// preference" was the silent data-loss path: the grid rendered defaults and the
+// first column event then wrote those defaults over the good saved copy. So
+// retry for a few seconds instead, and only give up on a real absence.
+export async function loadRemote(key: string, attempts = 10): Promise {
+ for (let i = 0; i < attempts; i++) {
+ try {
+ const v = await GetUIPref(key);
+ const parsed = v ? JSON.parse(v) : null;
+ return Array.isArray(parsed) ? parsed : null;
+ } catch {
+ // Not ready yet (or a parse failure on a corrupt value — one more read
+ // costs nothing). 300 ms × 10 covers a slow startup without hanging.
+ await new Promise((r) => setTimeout(r, 300));
+ }
}
+ return null;
}
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend.
+// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
+// frame, so dragging one border used to fire dozens of settings writes; the
+// cache write stays synchronous so nothing is lost if the window closes.
+const pendingDB = new Map();
+const dbTimers = new Map();
+
export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state);
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
- SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
+ pendingDB.set(key, json);
+ const prev = dbTimers.get(key);
+ if (prev) clearTimeout(prev);
+ dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
+}
+
+function flushOne(key: string) {
+ const json = pendingDB.get(key);
+ dbTimers.delete(key);
+ if (json == null) return;
+ pendingDB.delete(key);
+ SetUIPref(key, json).catch(() => {
+ // The store may not be scoped yet at startup. Keep the value and try once
+ // more shortly — dropping it here is how a fresh profile ended up with no
+ // portable copy at all.
+ pendingDB.set(key, json);
+ if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
+ });
+}
+
+// flushGridPrefs writes any debounced state immediately. Call it when the app is
+// about to close so a resize made in the last moments still reaches the DB.
+export function flushGridPrefs() {
+ for (const key of Array.from(pendingDB.keys())) {
+ const t = dbTimers.get(key);
+ if (t) clearTimeout(t);
+ flushOne(key);
+ }
}
// seedLocal writes a value into the cache without touching the DB (used after