From e2132ae45c6da7bb6142098b87b01a3b7ed30fba Mon Sep 17 00:00:00 2001 From: lspassos1 Date: Wed, 25 Mar 2026 21:37:12 +0000 Subject: [PATCH 1/2] feat(settings): add save feedback for preferences changes --- src/app/country-intel.ts | 13 +-- src/app/event-handlers.ts | 15 +-- src/components/UnifiedSettings.ts | 12 +- src/services/preferences-content.ts | 152 +++++++++++++++----------- src/utils/toast.ts | 15 +++ tests/settings-save-feedback.test.mjs | 45 ++++++++ 6 files changed, 153 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 e3e0b7fca..fef74b7ff 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -40,6 +40,7 @@ import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel'; import type { NewsItem } from '@/types'; import { getNearbyInfrastructure } from '@/services/related-assets'; import { toFlagEmoji } from '@/utils/country-flag'; +import { showToast } from '@/utils/toast'; type IntlDisplayNamesCtor = new ( locales: string | string[], @@ -875,7 +876,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; @@ -892,16 +893,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 decab04e0..2ccebc7bd 100644 --- a/src/app/event-handlers.ts +++ b/src/app/event-handlers.ts @@ -62,6 +62,7 @@ import { mlWorker } from '@/services/ml-worker'; import { UnifiedSettings } from '@/components/UnifiedSettings'; import { t } from '@/services/i18n'; import { TvModeController } from '@/services/tv-mode'; +import { showToast } from '@/utils/toast'; export interface EventHandlerCallbacks { updateSearchIndex: () => void; @@ -985,7 +986,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; } } @@ -1002,7 +1003,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; } } @@ -1178,16 +1179,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 8fde52310..b24c55ede 100644 --- a/src/components/UnifiedSettings.ts +++ b/src/components/UnifiedSettings.ts @@ -5,20 +5,11 @@ 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 { 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 { @@ -217,6 +208,7 @@ export class UnifiedSettings { const prefs = renderPreferences({ isDesktopApp: this.config.isDesktopApp, onMapProviderChange: this.config.onMapProviderChange, + onSettingSaved: () => showToast(t('modals.settingsWindow.saved')), }); this.overlay.innerHTML = ` diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index 422daca45..6dbe5ec3f 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -15,6 +15,7 @@ const DESKTOP_RELEASES_URL = 'https://github.com/koala73/worldmonitor/releases'; export interface PreferencesHost { isDesktopApp: boolean; onMapProviderChange?: (provider: MapProvider) => void; + onSettingSaved?: () => void; } export interface PreferencesResult { @@ -68,6 +69,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(); @@ -265,72 +348,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 000000000..ca7342f9a --- /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 000000000..a10f79084 --- /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 69312e62a8199e3b0280d42e40047d325389b35d Mon Sep 17 00:00:00 2001 From: lspassos1 Date: Wed, 25 Mar 2026 21:49:07 +0000 Subject: [PATCH 2/2] 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 a10f79084..081fe10a5 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', () => {