Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions src/app/country-intel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -976,7 +977,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;
Expand All @@ -993,16 +994,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<string>();
Expand Down
15 changes: 3 additions & 12 deletions src/app/event-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1027,7 +1028,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;
}
}
Expand All @@ -1044,7 +1045,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;
}
}
Expand Down Expand Up @@ -1243,16 +1244,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();
}
Expand Down
12 changes: 2 additions & 10 deletions src/components/UnifiedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;

export interface UnifiedSettingsConfig {
Expand Down Expand Up @@ -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 = `
Expand Down
152 changes: 86 additions & 66 deletions src/services/preferences-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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 {
Expand Down Expand Up @@ -76,6 +77,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();
Expand Down Expand Up @@ -344,72 +427,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) => {
Expand Down
15 changes: 15 additions & 0 deletions src/utils/toast.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +12 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent timeout regression for UnifiedSettings toasts

The old local showToast in UnifiedSettings.ts used a 4 000 ms dismiss timeout; event-handlers.ts and country-intel.ts used 3 000 ms. The new shared helper standardises on 3 000 ms, which silently shortens the display time for two toasts in UnifiedSettings (the free-panel-limit toast fired from toggleDraftPanel, and the new "Saved" toast). This may be intentional, but it's worth confirming — users who currently see the panel-limit warning for 4 s will now only see it for 3 s.

If 3 000 ms is the desired standard, a brief comment here would make the intention explicit:

  }, 3000); // 3 s display — standardised across all global toasts

}
62 changes: 62 additions & 0 deletions tests/settings-save-feedback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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');
}

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');
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 saveBody = extractMethodBody(settingsSrc, 'savePanelChanges');
assert.doesNotMatch(saveBody, /showToast\(/);
});

it('removes duplicate global toast implementations from event handlers and country intel', () => {
assert.match(handlersSrc, /import \{ showToast \} from '@\/utils\/toast';/);
Comment on lines +54 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile closing-brace regex in guardrail test

The pattern /private savePanelChanges\(\): void \{([\s\S]*?)\n {2}\}/ assumes the closing } of savePanelChanges is indented with exactly 2 spaces. Any formatting change (e.g., reformatter runs, indentation style shift) will silently break the assertion — saveMatch will be null, the assert.ok will fail with a confusing message, and the real assertion about showToast will never be reached.

Consider anchoring on the method name itself rather than its closing brace, or reading the entire file and checking the region between known method boundaries:

// alternative: just assert the method body does not contain showToast anywhere nearby
const methodStart = settingsSrc.indexOf('private savePanelChanges()');
const methodEnd = settingsSrc.indexOf('\n  private ', methodStart + 1);
const methodBody = settingsSrc.slice(methodStart, methodEnd === -1 ? undefined : methodEnd);
assert.ok(methodBody.length > 0, 'savePanelChanges() not found');
assert.doesNotMatch(methodBody, /showToast\(/);

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 \{/);
});
});
Loading