fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts

The MODIFIED hint in the Customize Dashboard panel was driven by
`presetActive`, recomputed on every save/load via strict deep-equal
against each preset. Any drift between a saved layout and the current
defaults — older app versions that hadn't yet had some new perf cells
added, prior buggy merges that appended new registry keys to the end
of perfCells, or stale `visible` values from intermediate dev builds —
left `presetActive` undefined forever and pinned the panel in MODIFIED
state for users who had not actually edited anything.

Split the two concerns:

- `presetActive` keeps driving the chip highlight (recomputed). When
  the layout happens to match a preset exactly the chip lights up.
- New `userModified` boolean drives the MODIFIED indicator. Set to true
  only on actual edits through the panel (visibility / density /
  ordering / select changes) and on JSON import; cleared by applying a
  preset and by Reset.

Legacy saves without the field load as `userModified: false` so the
indicator no longer fires retroactively on data the user never
touched. Also tighten `_mergeWithDefaults` so newly-added registry
keys land at their canonical positions (subsequence detection) when
the saved order is consistent with defaults, which keeps the chip
highlight stable across upgrades.
This commit is contained in:
2026-05-16 17:05:12 +03:00
parent f1b0f0eab2
commit e4bf58da19
2 changed files with 109 additions and 31 deletions
@@ -45,6 +45,7 @@ import {
import {
ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH,
} from '../core/icons.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
const ICON_DRAG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
@@ -173,6 +174,10 @@ function _renderPanelBody(): void {
${_renderActions()}
`;
_bindHandlers(body);
// Enhance the compact <select> elements rendered above into MiniSelect
// popups. The native elements stay in the DOM hidden, so the change
// handlers attached in _bindHandlers continue to fire on selection.
enhanceMiniSelects(body, 'select.dash-cust-mini-select');
if (_focusAfterRender) {
const el = body.querySelector<HTMLElement>(_focusAfterRender);
el?.focus();
@@ -189,9 +194,9 @@ function _renderPresets(layout: DashboardLayoutV1): string {
${t('dashboard.customize.preset.' + name)}
</button>`;
}).join('');
const modifiedHint = layout.presetActive
? ''
: `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`;
const modifiedHint = layout.userModified
? `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`
: '';
return `<section class="dash-cust-section">
<h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
<div class="dash-cust-chips">${chips}</div>
@@ -110,8 +110,18 @@ export interface DashboardLayoutV1 {
perfCells: PerfCellConfig[];
global: GlobalConfig;
/** Active preset key when the layout matches a built-in unmodified.
* Cleared on any user edit so the panel can show "modified" state. */
* Drives the chip highlight in the customize panel — recomputed on
* every save/load so a user who toggles back to a preset's exact
* shape sees the chip light up again. */
presetActive?: string;
/** True iff the user has made changes via the customize panel since
* the last preset apply / reset / import. Drives the "MODIFIED"
* indicator. Kept separate from `presetActive` so that legacy
* layouts whose data drifted from every preset (older app versions
* saved with different defaults, prior buggy migrations) don't
* flash a perpetual "MODIFIED" warning at users who never actually
* customized anything. */
userModified?: boolean;
}
const _defaultSection = (key: string, visible = true): SectionConfig => ({
@@ -226,6 +236,7 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo
perfCells: layout.perfCells.map(c => ({ ...c })),
global: { ...layout.global },
presetActive,
userModified: layout.userModified,
};
}
@@ -429,16 +440,22 @@ async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
}
}
/** Apply a built-in preset and persist it. */
/** Apply a built-in preset and persist it. Clears the `userModified`
* flag — applying a preset is the user's signal that they want to
* start from a clean baseline. */
export function applyDashboardPreset(name: string): void {
const factory = PRESETS[name];
if (!factory) return;
saveDashboardLayout(factory());
const next = factory();
next.userModified = false;
saveDashboardLayout(next);
}
/** Reset to the studio default. */
/** Reset to the studio default. Clears `userModified`. */
export function resetDashboardLayout(): void {
saveDashboardLayout(PRESETS.studio());
const next = PRESETS.studio();
next.userModified = false;
saveDashboardLayout(next);
}
/** Export the current layout as a downloadable JSON string. */
@@ -453,6 +470,7 @@ export function importDashboardLayoutJson(json: string): boolean {
if (!parsed || typeof parsed !== 'object') return false;
const merged = _mergeWithDefaults(parsed);
merged.presetActive = undefined;
merged.userModified = true;
saveDashboardLayout(merged);
return true;
} catch (e) {
@@ -514,6 +532,7 @@ export function setSectionVisible(layout: DashboardLayoutV1, key: string, visibl
const s = next.sections.find(s => s.key === key);
if (s) s.visible = visible;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -529,6 +548,7 @@ export function setSectionOrder(layout: DashboardLayoutV1, orderedKeys: string[]
for (const s of map.values()) reordered.push(s);
next.sections = reordered;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -537,6 +557,7 @@ export function setSectionDensity(layout: DashboardLayoutV1, key: string, densit
const s = next.sections.find(s => s.key === key);
if (s) s.density = density;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -545,6 +566,7 @@ export function setSectionCollapsedDefault(layout: DashboardLayoutV1, key: strin
const s = next.sections.find(s => s.key === key);
if (s) s.collapsedDefault = collapsed;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -553,6 +575,7 @@ export function setPerfCellVisible(layout: DashboardLayoutV1, key: string, visib
const c = next.perfCells.find(c => c.key === key);
if (c) c.visible = visible;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -567,6 +590,7 @@ export function setPerfCellOrder(layout: DashboardLayoutV1, orderedKeys: string[
for (const c of map.values()) reordered.push(c);
next.perfCells = reordered;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -575,6 +599,7 @@ export function setPerfCellMode(layout: DashboardLayoutV1, key: string, mode: Pe
const c = next.perfCells.find(c => c.key === key);
if (c) c.mode = mode;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -583,6 +608,7 @@ export function setPerfCellWindow(layout: DashboardLayoutV1, key: string, window
const c = next.perfCells.find(c => c.key === key);
if (c) c.window = window;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -590,6 +616,7 @@ export function setGlobalPerfWindow(layout: DashboardLayoutV1, window: SampleWin
const next = _clone(layout);
next.global.perfWindow = window;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -598,6 +625,7 @@ export function setPerfCellYScale(layout: DashboardLayoutV1, key: string, yScale
const c = next.perfCells.find(c => c.key === key);
if (c) c.yScale = yScale;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -605,6 +633,7 @@ export function setGlobalPerfMode(layout: DashboardLayoutV1, mode: 'system' | 'a
const next = _clone(layout);
next.global.perfMode = mode;
next.presetActive = undefined;
next.userModified = true;
return next;
}
@@ -612,53 +641,97 @@ export function setGlobalConfig(layout: DashboardLayoutV1, patch: Partial<Global
const next = _clone(layout);
next.global = { ...next.global, ...patch };
next.presetActive = undefined;
next.userModified = true;
return next;
}
// ── Internal: merge / migrate ────────────────────────────────────────────
/** Merge a saved keyed array (sections, perfCells) with the canonical
* defaults. Unknown keys in `saved` are dropped; missing keys are filled
* from defaults.
*
* Ordering: when the saved keys form a *subsequence* of the canonical
* order — i.e. the user never manually reordered, they just happen to
* have an older snapshot missing newly-added registry entries — the
* merged result uses the canonical order. Without this, every new
* default key added in a later release would land at the end of the
* user's saved list, drifting the layout off every preset and pinning
* the customize panel in "MODIFIED" forever after an upgrade.
*
* When the user *did* reorder (saved order is not a subsequence of
* default), we preserve their order and append any new default keys at
* the end — those entries are new to the user, so any position is a
* guess; end-of-list is at least predictable. */
function _mergeKeyedArray<T extends { key: string }>(
defaults: T[],
saved: T[],
mergeItem: (def: T, s: T) => T,
): T[] {
const defByKey = new Map(defaults.map(d => [d.key, d]));
const savedKnown = saved.filter(s => defByKey.has(s.key));
const savedByKey = new Map(savedKnown.map(s => [s.key, s]));
const defaultKeys = defaults.map(d => d.key);
const savedKeys = savedKnown.map(s => s.key);
let i = 0;
for (const dk of defaultKeys) {
if (i < savedKeys.length && dk === savedKeys[i]) i++;
}
const userReordered = i !== savedKeys.length;
const finalKeys = userReordered
? [...savedKeys, ...defaultKeys.filter(k => !savedByKey.has(k))]
: defaultKeys;
return finalKeys.map(k => {
const def = defByKey.get(k)!;
const s = savedByKey.get(k);
return s ? mergeItem(def, s) : { ...def };
});
}
/** Merge a (possibly partial or older) layout with current defaults. New
* registry keys not in the saved layout are appended to the end with
* default settings; unknown keys in the saved layout are dropped. */
* registry keys not in the saved layout are inserted at their canonical
* positions when the saved order is consistent with defaults; otherwise
* appended at the end (see `_mergeKeyedArray`). Unknown keys in the
* saved layout are dropped. */
function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
const base = _clone(DEFAULT_LAYOUT);
if (!input || typeof input !== 'object') return base;
const obj = input as Partial<DashboardLayoutV1>;
if (Array.isArray(obj.sections)) {
const known = new Map(base.sections.map(s => [s.key, s]));
const reordered: SectionConfig[] = [];
for (const s of obj.sections as SectionConfig[]) {
const def = known.get(s.key);
if (!def) continue;
reordered.push({
base.sections = _mergeKeyedArray(
base.sections,
obj.sections as SectionConfig[],
(def, s) => ({
...def,
...s,
options: { ...def.options, ...(s.options || {}) },
});
known.delete(s.key);
}
for (const s of known.values()) reordered.push(s);
base.sections = reordered;
}),
);
}
if (Array.isArray(obj.perfCells)) {
const known = new Map(base.perfCells.map(c => [c.key, c]));
const reordered: PerfCellConfig[] = [];
for (const c of obj.perfCells as PerfCellConfig[]) {
const def = known.get(c.key);
if (!def) continue;
reordered.push({ ...def, ...c });
known.delete(c.key);
}
for (const c of known.values()) reordered.push(c);
base.perfCells = reordered;
base.perfCells = _mergeKeyedArray(
base.perfCells,
obj.perfCells as PerfCellConfig[],
(def, c) => ({ ...def, ...c }),
);
}
if (obj.global && typeof obj.global === 'object') {
base.global = { ...base.global, ...obj.global };
}
// Legacy saves (before `userModified` existed) omit the field. Treat
// them as un-modified so existing users with naturally-drifted layouts
// don't see a perpetual MODIFIED hint. New mutations through the panel
// will set the flag and surface MODIFIED correctly going forward.
base.userModified = typeof obj.userModified === 'boolean'
? obj.userModified
: false;
base.presetActive = _computeActivePreset(base);
return base;
}