From 715c3bbb98ef2554cee8f65ba6fee19dabccb2aa Mon Sep 17 00:00:00 2001 From: lspassos1 Date: Wed, 25 Mar 2026 21:37:12 +0000 Subject: [PATCH 1/3] feat(settings): add save feedback for preferences changes --- src/app/country-intel.ts | 13 +-- src/app/event-handlers.ts | 15 +-- src/components/UnifiedSettings.ts | 13 +-- src/services/preferences-content.ts | 154 +++++++++++++++----------- src/utils/toast.ts | 15 +++ tests/settings-save-feedback.test.mjs | 45 ++++++++ 6 files changed, 156 insertions(+), 99 deletions(-) create mode 100644 src/utils/toast.ts create mode 100644 tests/settings-save-feedback.test.mjs diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 7f0e86adbc..8ab0df3c54 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -42,6 +42,7 @@ import { getNearbyInfrastructure } from '@/services/related-assets'; import { toFlagEmoji } from '@/utils/country-flag'; import { buildDependencyGraph } from '@/services/infrastructure-cascade'; import { getActiveFrameworkForPanel, subscribeFrameworkChange } from '@/services/analysis-framework-store'; +import { showToast } from '@/utils/toast'; type IntlDisplayNamesCtor = new ( locales: string | string[], @@ -980,7 +981,7 @@ export class CountryIntelManager implements AppModule { openCountryStory(code: string, name: string): void { if (!dataFreshness.hasSufficientData() || this.ctx.latestClusters.length === 0) { - this.showToast('Data still loading — try again in a moment'); + showToast('Data still loading — try again in a moment'); return; } const posturePanel = this.ctx.panels['strategic-posture'] as StrategicPosturePanel | undefined; @@ -997,16 +998,6 @@ export class CountryIntelManager implements AppModule { openStoryModal(data); } - showToast(msg: string): void { - document.querySelector('.toast-notification')?.remove(); - const el = document.createElement('div'); - el.className = 'toast-notification'; - el.textContent = msg; - document.body.appendChild(el); - requestAnimationFrame(() => el.classList.add('visible')); - setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); - } - private getCountryStrikes(code: string, hasGeoShape: boolean): typeof this.ctx.intelligenceCache.iranEvents & object { if (!this.ctx.intelligenceCache.iranEvents) return []; const seen = new Set(); diff --git a/src/app/event-handlers.ts b/src/app/event-handlers.ts index 7c5ac0fe1a..b4f003a5d6 100644 --- a/src/app/event-handlers.ts +++ b/src/app/event-handlers.ts @@ -66,6 +66,7 @@ import { AuthHeaderWidget } from '@/components/AuthHeaderWidget'; import { t } from '@/services/i18n'; import { TvModeController } from '@/services/tv-mode'; import { getAuthState, subscribeAuthState } from '@/services/auth-state'; +import { showToast } from '@/utils/toast'; export interface EventHandlerCallbacks { updateSearchIndex: () => void; @@ -1039,7 +1040,7 @@ export class EventHandlerManager implements AppModule { const allSources = this.getAllSourceNames(); const currentlyEnabled = allSources.filter(n => !this.ctx.disabledSources.has(n)).length; if (currentlyEnabled + 1 > FREE_MAX_SOURCES) { - this.showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); + showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); return; } } @@ -1056,7 +1057,7 @@ export class EventHandlerManager implements AppModule { const currentlyEnabled = allSources.filter(n => !this.ctx.disabledSources.has(n)).length; const wouldEnable = names.filter(n => this.ctx.disabledSources.has(n) && allSources.includes(n)).length; if (currentlyEnabled + wouldEnable > FREE_MAX_SOURCES) { - this.showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); + showToast(t('modals.settingsWindow.freeSourceLimit', { max: String(FREE_MAX_SOURCES) })); return; } } @@ -1255,16 +1256,6 @@ export class EventHandlerManager implements AppModule { } } - showToast(msg: string): void { - document.querySelector('.toast-notification')?.remove(); - const el = document.createElement('div'); - el.className = 'toast-notification'; - el.textContent = msg; - document.body.appendChild(el); - requestAnimationFrame(() => el.classList.add('visible')); - setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); - } - shouldShowIntelligenceNotifications(): boolean { return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled(); } diff --git a/src/components/UnifiedSettings.ts b/src/components/UnifiedSettings.ts index e9b7136788..78fd6d8f9f 100644 --- a/src/components/UnifiedSettings.ts +++ b/src/components/UnifiedSettings.ts @@ -5,21 +5,12 @@ import { SITE_VARIANT } from '@/config/variant'; import { t } from '@/services/i18n'; import type { MapProvider } from '@/config/basemap'; import { escapeHtml } from '@/utils/sanitize'; +import { showToast } from '@/utils/toast'; import type { PanelConfig } from '@/types'; import { renderPreferences } from '@/services/preferences-content'; import { getAuthState } from '@/services/auth-state'; import { track } from '@/services/analytics'; -function showToast(msg: string): void { - document.querySelector('.toast-notification')?.remove(); - const el = document.createElement('div'); - el.className = 'toast-notification'; - el.textContent = msg; - document.body.appendChild(el); - requestAnimationFrame(() => el.classList.add('visible')); - setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 4000); -} - const GEAR_SVG = ``; export interface UnifiedSettingsConfig { @@ -219,6 +210,8 @@ export class UnifiedSettings { isDesktopApp: this.config.isDesktopApp, onMapProviderChange: this.config.onMapProviderChange, isSignedIn: !this.config.isDesktopApp && (getAuthState().user !== null), + isSignedIn: !this.config.isDesktopApp && (getAuthState().user !== null), + onSettingSaved: () => showToast(t('modals.settingsWindow.saved')), }); this.overlay.innerHTML = ` diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index 995d0cde53..d8cab66260 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -36,6 +36,9 @@ export interface PreferencesHost { isDesktopApp: boolean; onMapProviderChange?: (provider: MapProvider) => void; isSignedIn?: boolean; + onSettingSaved?: () => void; + isSignedIn?: boolean; + onSettingSaved?: () => void; } export interface PreferencesResult { @@ -89,6 +92,88 @@ function updateAiStatus(container: HTMLElement): void { } } +function handleSettingsImport(target: HTMLInputElement, container: HTMLElement): boolean { + if (target.id !== 'usImportInput') return false; + + const file = target.files?.[0]; + if (!file) return true; + + importSettings(file).then((result: ImportResult) => { + showToast(container, t('components.settings.importSuccess', { count: String(result.keysImported) }), true); + }).catch(() => { + showToast(container, t('components.settings.importFailed'), false); + }); + target.value = ''; + return true; +} + +function handlePreferenceChange(target: HTMLInputElement, container: HTMLElement, host: PreferencesHost): boolean { + if (target.id === 'us-stream-quality') { + setStreamQuality(target.value as StreamQuality); + return true; + } + if (target.id === 'us-globe-visual-preset') { + setGlobeVisualPreset(target.value as GlobeVisualPreset); + return true; + } + if (target.id === 'us-theme') { + setThemePreference(target.value as ThemePreference); + return true; + } + if (target.id === 'us-font-family') { + setFontFamily(target.value as FontFamily); + return true; + } + if (target.id === 'us-map-provider') { + const provider = target.value as MapProvider; + setMapProvider(provider); + renderMapThemeDropdown(container, provider); + host.onMapProviderChange?.(provider); + window.dispatchEvent(new CustomEvent('map-theme-changed')); + return true; + } + if (target.id === 'us-map-theme') { + const provider = getMapProvider(); + setMapTheme(provider, target.value); + window.dispatchEvent(new CustomEvent('map-theme-changed')); + return true; + } + if (target.id === 'us-live-streams-always-on') { + setLiveStreamsAlwaysOn(target.checked); + return true; + } + if (target.id === 'us-language') { + trackLanguageChange(target.value); + void changeLanguage(target.value); + return true; + } + if (target.id === 'us-cloud') { + setAiFlowSetting('cloudLlm', target.checked); + updateAiStatus(container); + return true; + } + if (target.id === 'us-browser') { + setAiFlowSetting('browserModel', target.checked); + const warn = container.querySelector('.ai-flow-toggle-warn') as HTMLElement; + if (warn) warn.style.display = target.checked ? 'block' : 'none'; + updateAiStatus(container); + return true; + } + if (target.id === 'us-map-flash') { + setAiFlowSetting('mapNewsFlash', target.checked); + return true; + } + if (target.id === 'us-headline-memory') { + setAiFlowSetting('headlineMemory', target.checked); + return true; + } + if (target.id === 'us-badge-anim') { + setAiFlowSetting('badgeAnimation', target.checked); + return true; + } + return false; +} + export function renderPreferences(host: PreferencesHost): PreferencesResult { const settings = getAiFlowSettings(); const currentLang = getCurrentLanguage(); @@ -371,72 +456,9 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { container.addEventListener('change', (e) => { const target = e.target as HTMLInputElement; - if (target.id === 'usImportInput') { - const file = target.files?.[0]; - if (!file) return; - importSettings(file).then((result: ImportResult) => { - showToast(container, t('components.settings.importSuccess', { count: String(result.keysImported) }), true); - }).catch(() => { - showToast(container, t('components.settings.importFailed'), false); - }); - target.value = ''; - return; - } - - if (target.id === 'us-stream-quality') { - setStreamQuality(target.value as StreamQuality); - return; - } - if (target.id === 'us-globe-visual-preset') { - setGlobeVisualPreset(target.value as GlobeVisualPreset); - return; - } - if (target.id === 'us-theme') { - setThemePreference(target.value as ThemePreference); - return; - } - if (target.id === 'us-font-family') { - setFontFamily(target.value as FontFamily); - return; - } - if (target.id === 'us-map-provider') { - const provider = target.value as MapProvider; - setMapProvider(provider); - renderMapThemeDropdown(container, provider); - host.onMapProviderChange?.(provider); - window.dispatchEvent(new CustomEvent('map-theme-changed')); - return; - } - if (target.id === 'us-map-theme') { - const provider = getMapProvider(); - setMapTheme(provider, target.value); - window.dispatchEvent(new CustomEvent('map-theme-changed')); - return; - } - if (target.id === 'us-live-streams-always-on') { - setLiveStreamsAlwaysOn(target.checked); - return; - } - if (target.id === 'us-language') { - trackLanguageChange(target.value); - void changeLanguage(target.value); - return; - } - if (target.id === 'us-cloud') { - setAiFlowSetting('cloudLlm', target.checked); - updateAiStatus(container); - } else if (target.id === 'us-browser') { - setAiFlowSetting('browserModel', target.checked); - const warn = container.querySelector('.ai-flow-toggle-warn') as HTMLElement; - if (warn) warn.style.display = target.checked ? 'block' : 'none'; - updateAiStatus(container); - } else if (target.id === 'us-map-flash') { - setAiFlowSetting('mapNewsFlash', target.checked); - } else if (target.id === 'us-headline-memory') { - setAiFlowSetting('headlineMemory', target.checked); - } else if (target.id === 'us-badge-anim') { - setAiFlowSetting('badgeAnimation', target.checked); - } + if (handleSettingsImport(target, container)) return; + if (!handlePreferenceChange(target, container, host)) return; + host.onSettingSaved?.(); }, { signal }); container.addEventListener('click', (e) => { diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 0000000000..ca7342f9a6 --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,15 @@ +export function showToast(message: string): void { + document.querySelector('.toast-notification')?.remove(); + + const toast = document.createElement('div'); + toast.className = 'toast-notification'; + toast.setAttribute('role', 'status'); + toast.textContent = message; + + document.body.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('visible')); + setTimeout(() => { + toast.classList.remove('visible'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} diff --git a/tests/settings-save-feedback.test.mjs b/tests/settings-save-feedback.test.mjs new file mode 100644 index 0000000000..a10f790840 --- /dev/null +++ b/tests/settings-save-feedback.test.mjs @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +function src(path) { + return readFileSync(join(root, path), 'utf-8'); +} + +describe('settings save feedback guardrails', () => { + const toastSrc = src('src/utils/toast.ts'); + const settingsSrc = src('src/components/UnifiedSettings.ts'); + const prefsSrc = src('src/services/preferences-content.ts'); + const handlersSrc = src('src/app/event-handlers.ts'); + const countryIntelSrc = src('src/app/country-intel.ts'); + + it('uses a shared body-level toast utility with role=status', () => { + assert.match(toastSrc, /export function showToast\(message: string\): void/); + assert.match(toastSrc, /toast\.setAttribute\('role', 'status'\)/); + assert.match(toastSrc, /document\.querySelector\('\.toast-notification'\)\?\.remove\(\)/); + }); + + it('shows saved feedback for Preferences through renderPreferences callback', () => { + assert.match(settingsSrc, /onSettingSaved:\s*\(\)\s*=>\s*showToast\(t\('modals\.settingsWindow\.saved'\)\)/); + assert.match(prefsSrc, /onSettingSaved\?: \(\) => void;/); + assert.match(prefsSrc, /host\.onSettingSaved\?\.\(\);/); + }); + + it('keeps Panels save on inline status only', () => { + const saveMatch = settingsSrc.match(/private savePanelChanges\(\): void \{([\s\S]*?)\n {2}\}/); + assert.ok(saveMatch, 'savePanelChanges() not found'); + assert.doesNotMatch(saveMatch[1], /showToast\(/); + }); + + it('removes duplicate global toast implementations from event handlers and country intel', () => { + assert.match(handlersSrc, /import \{ showToast \} from '@\/utils\/toast';/); + assert.match(countryIntelSrc, /import \{ showToast \} from '@\/utils\/toast';/); + assert.doesNotMatch(handlersSrc, /\n\s*showToast\(msg: string\): void \{/); + assert.doesNotMatch(countryIntelSrc, /\n\s*showToast\(msg: string\): void \{/); + }); +}); From 48356e6991a86c8951513f21e680780d83f1f2a9 Mon Sep 17 00:00:00 2001 From: lspassos1 Date: Wed, 25 Mar 2026 21:49:07 +0000 Subject: [PATCH 2/3] test(settings): make save feedback guard less brittle --- tests/settings-save-feedback.test.mjs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/settings-save-feedback.test.mjs b/tests/settings-save-feedback.test.mjs index a10f790840..081fe10a5c 100644 --- a/tests/settings-save-feedback.test.mjs +++ b/tests/settings-save-feedback.test.mjs @@ -11,6 +11,24 @@ function src(path) { return readFileSync(join(root, path), 'utf-8'); } +function extractMethodBody(source, methodName) { + const signature = `${methodName}(): void {`; + const start = source.indexOf(signature); + assert.notEqual(start, -1, `${methodName}() not found`); + + let depth = 1; + let i = start + signature.length; + while (i < source.length && depth > 0) { + const char = source[i]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + i += 1; + } + + assert.equal(depth, 0, `${methodName}() body did not terminate`); + return source.slice(start + signature.length, i - 1); +} + describe('settings save feedback guardrails', () => { const toastSrc = src('src/utils/toast.ts'); const settingsSrc = src('src/components/UnifiedSettings.ts'); @@ -31,9 +49,8 @@ describe('settings save feedback guardrails', () => { }); it('keeps Panels save on inline status only', () => { - const saveMatch = settingsSrc.match(/private savePanelChanges\(\): void \{([\s\S]*?)\n {2}\}/); - assert.ok(saveMatch, 'savePanelChanges() not found'); - assert.doesNotMatch(saveMatch[1], /showToast\(/); + const saveBody = extractMethodBody(settingsSrc, 'savePanelChanges'); + assert.doesNotMatch(saveBody, /showToast\(/); }); it('removes duplicate global toast implementations from event handlers and country intel', () => { From 8a3720e22a995a05a2ea0b469a00742490369b46 Mon Sep 17 00:00:00 2001 From: lspassos1 Date: Mon, 30 Mar 2026 22:35:34 +0100 Subject: [PATCH 3/3] fix(settings): preserve modal toast timing --- src/components/UnifiedSettings.ts | 5 ++--- src/services/preferences-content.ts | 2 -- src/utils/toast.ts | 4 ++-- tests/settings-save-feedback.test.mjs | 9 +++++++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/UnifiedSettings.ts b/src/components/UnifiedSettings.ts index 78fd6d8f9f..054198e45a 100644 --- a/src/components/UnifiedSettings.ts +++ b/src/components/UnifiedSettings.ts @@ -210,8 +210,7 @@ export class UnifiedSettings { isDesktopApp: this.config.isDesktopApp, onMapProviderChange: this.config.onMapProviderChange, isSignedIn: !this.config.isDesktopApp && (getAuthState().user !== null), - isSignedIn: !this.config.isDesktopApp && (getAuthState().user !== null), - onSettingSaved: () => showToast(t('modals.settingsWindow.saved')), + onSettingSaved: () => showToast(t('modals.settingsWindow.saved'), 4000), }); this.overlay.innerHTML = ` @@ -399,7 +398,7 @@ export class UnifiedSettings { if (!panel.enabled && !isProUser()) { const enabledCount = Object.entries(this.draftPanelSettings).filter(([k, p]) => p.enabled && !k.startsWith('cw-')).length; if (enabledCount >= FREE_MAX_PANELS) { - showToast(t('modals.settingsWindow.freePanelLimit', { max: String(FREE_MAX_PANELS) })); + showToast(t('modals.settingsWindow.freePanelLimit', { max: String(FREE_MAX_PANELS) }), 4000); return; } } diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index d8cab66260..32a89ff875 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -37,8 +37,6 @@ export interface PreferencesHost { onMapProviderChange?: (provider: MapProvider) => void; isSignedIn?: boolean; onSettingSaved?: () => void; - isSignedIn?: boolean; - onSettingSaved?: () => void; } export interface PreferencesResult { diff --git a/src/utils/toast.ts b/src/utils/toast.ts index ca7342f9a6..bb1643e921 100644 --- a/src/utils/toast.ts +++ b/src/utils/toast.ts @@ -1,4 +1,4 @@ -export function showToast(message: string): void { +export function showToast(message: string, durationMs = 3000): void { document.querySelector('.toast-notification')?.remove(); const toast = document.createElement('div'); @@ -11,5 +11,5 @@ export function showToast(message: string): void { setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 300); - }, 3000); + }, durationMs); } diff --git a/tests/settings-save-feedback.test.mjs b/tests/settings-save-feedback.test.mjs index 081fe10a5c..f112a92414 100644 --- a/tests/settings-save-feedback.test.mjs +++ b/tests/settings-save-feedback.test.mjs @@ -37,13 +37,14 @@ describe('settings save feedback guardrails', () => { const countryIntelSrc = src('src/app/country-intel.ts'); it('uses a shared body-level toast utility with role=status', () => { - assert.match(toastSrc, /export function showToast\(message: string\): void/); + assert.match(toastSrc, /export function showToast\(message: string, durationMs = 3000\): void/); assert.match(toastSrc, /toast\.setAttribute\('role', 'status'\)/); assert.match(toastSrc, /document\.querySelector\('\.toast-notification'\)\?\.remove\(\)/); + assert.match(toastSrc, /}, durationMs\);/); }); it('shows saved feedback for Preferences through renderPreferences callback', () => { - assert.match(settingsSrc, /onSettingSaved:\s*\(\)\s*=>\s*showToast\(t\('modals\.settingsWindow\.saved'\)\)/); + assert.match(settingsSrc, /onSettingSaved:\s*\(\)\s*=>\s*showToast\(t\('modals\.settingsWindow\.saved'\), 4000\)/); assert.match(prefsSrc, /onSettingSaved\?: \(\) => void;/); assert.match(prefsSrc, /host\.onSettingSaved\?\.\(\);/); }); @@ -59,4 +60,8 @@ describe('settings save feedback guardrails', () => { assert.doesNotMatch(handlersSrc, /\n\s*showToast\(msg: string\): void \{/); assert.doesNotMatch(countryIntelSrc, /\n\s*showToast\(msg: string\): void \{/); }); + + it('preserves UnifiedSettings toast duration for modal-originated feedback', () => { + assert.match(settingsSrc, /showToast\(t\('modals\.settingsWindow\.freePanelLimit', \{ max: String\(FREE_MAX_PANELS\) \}\), 4000\)/); + }); });