export function formatTime(date: Date): string { const now = new Date(); const diff = Math.floor((now.getTime() - date.getTime()) / 1000); const lang = getCurrentLanguage(); // Safe fallback if Intl is not available (though it is in all modern browsers) try { const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }); if (diff < 60) return rtf.format(-Math.round(diff), 'second'); if (diff < 3600) return rtf.format(-Math.round(diff / 60), 'minute'); if (diff < 86400) return rtf.format(-Math.round(diff / 3600), 'hour'); return rtf.format(-Math.round(diff / 86400), 'day'); } catch (e) { if (diff < 60) return 'Just now'; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; } } // Live feeds occasionally omit numeric fields (undefined) rather than sending // null, and `null`/NaN/Infinity slip through call-site `!` assertions on // `number | null` fields. Shared guard so every formatter renders the // unavailable state instead of throwing or emitting misleading output // (WORLDMONITOR-SH). function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } export function formatPrice(price: number | null | undefined): string { if (!isFiniteNumber(price)) return '--'; if (price >= 1000) { return `$${price.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0, })}`; } return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; } export function formatChange(change: number | null | undefined): string { if (!isFiniteNumber(change)) return '--'; const sign = change >= 0 ? '+' : ''; return `${sign}${change.toFixed(2)}%`; } export function getChangeClass(change: number | null | undefined): string { if (!isFiniteNumber(change)) return ''; return change >= 0 ? 'up' : 'down'; } export function getHeatmapClass(change: number | null | undefined): string { if (!isFiniteNumber(change)) return ''; const abs = Math.abs(change); const direction = change >= 0 ? 'up' : 'down'; if (abs >= 2) return `${direction}-3`; if (abs >= 1) return `${direction}-2`; return `${direction}-1`; } export function debounce void>( fn: T, delay: number ): ((...args: Parameters) => void) & { cancel(): void } { let timeoutId: ReturnType; const debounced = (...args: Parameters) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; debounced.cancel = () => { clearTimeout(timeoutId); }; return debounced; } export function throttle void>( fn: T, limit: number ): (...args: Parameters) => void { // Time-based throttling for non-visual work where a fixed minimum interval is desired. let inThrottle = false; return (...args: Parameters) => { if (!inThrottle) { fn(...args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } }; } export function rafSchedule void>(fn: T): ((...args: Parameters) => void) & { cancel(): void } { // Frame-synchronized scheduling for visual updates; batches repeated calls into one render frame. let scheduled = false; let rafId = 0; let lastArgs: Parameters | null = null; const wrapped = (...args: Parameters) => { lastArgs = args; if (!scheduled) { scheduled = true; rafId = requestAnimationFrame(() => { scheduled = false; if (lastArgs) { fn(...lastArgs); lastArgs = null; } }); } }; wrapped.cancel = () => { cancelAnimationFrame(rafId); scheduled = false; lastArgs = null; }; return wrapped; } export function loadFromStorage(key: string, defaultValue: T): T { try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored) as T; // Merge with defaults for object types to handle new properties if (typeof defaultValue === 'object' && defaultValue !== null && !Array.isArray(defaultValue)) { return { ...defaultValue, ...parsed }; } return parsed; } } catch (e) { console.warn(`Failed to load ${key} from storage:`, e); } return defaultValue; } export function saveToStorage(key: string, value: T): void { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { if (isQuotaError(e)) { markStorageQuotaExceeded(); } else { console.warn(`Failed to save ${key} to storage:`, e); } } } export function generateId(): string { return `id-${crypto.randomUUID()}`; } /** Breakpoint (px): below this width the app uses the simplified mobile layout. Must match CSS @media (max-width: …). */ export const MOBILE_BREAKPOINT_PX = 768; /** True when viewport is below mobile breakpoint. Touch-capable notebooks keep desktop layout. */ export function isMobileDevice(): boolean { return window.innerWidth <= MOBILE_BREAKPOINT_PX; } export function chunkArray(items: T[], size: number): T[][] { const chunkSize = Math.max(1, size); const chunks: T[][] = []; for (let i = 0; i < items.length; i += chunkSize) { chunks.push(items.slice(i, i + chunkSize)); } return chunks; } export function toUniqueSorted(items: string[]): string[] { return Array.from(new Set(items)).sort(); } export function toUniqueSortedLowercase(items: string[]): string[] { return toUniqueSorted(items.map((item) => item.toLowerCase())); } export function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = a[i] as T; a[i] = a[j] as T; a[j] = tmp; } return a; } export { proxyUrl, fetchWithProxy, rssProxyUrl } from './proxy'; export { buildMapUrl, parseMapUrlState } from './urlState'; export { withTimeout, TimeoutError } from './with-timeout'; export type { ParsedMapUrlState } from './urlState'; export { CircuitBreaker, createCircuitBreaker, getCircuitBreakerStatus, getCircuitBreakerCooldownInfo } from './circuit-breaker'; export type { CircuitBreakerOptions } from './circuit-breaker'; export * from './analysis-constants'; export { getCSSColor, invalidateColorCache } from './theme-colors'; export { getStoredTheme, getCurrentTheme, setTheme, applyStoredTheme, getThemePreference, setThemePreference } from './theme-manager'; export type { Theme, ThemePreference } from './theme-manager'; export { toFlagEmoji } from './country-flag'; export { showToast } from './toast'; import { getCurrentLanguage } from '../services/i18n'; import { isQuotaError, markStorageQuotaExceeded } from './storage-quota'; export { isStorageQuotaExceeded, isQuotaError, markStorageQuotaExceeded } from './storage-quota';