diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 7f0e86adbc..ac30565e82 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -34,6 +34,7 @@ import { MILITARY_BASES } from '@/config'; import { mlWorker } from '@/services/ml-worker'; import { isHeadlineMemoryEnabled } from '@/services/ai-flow-settings'; import { t, getCurrentLanguage } from '@/services/i18n'; +import { translateContentText } from '@/services/content-translation'; import { trackCountrySelected, trackCountryBriefOpened } from '@/services/analytics'; import { toApiUrl } from '@/services/runtime'; import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel'; @@ -350,51 +351,64 @@ export class CountryIntelManager implements AppModule { this.ctx.countryBriefPage?.updateBrief({ brief: briefText, country, code }); } else { let fallbackBrief = ''; + const currentLang = getCurrentLanguage(); + const translateEnglishLine = async (line: string): Promise => { + const translated = await translateContentText(line, currentLang, { sourceLang: 'en' }); + return translated || line; + }; const sumModelId = BETA_MODE ? 'summarization-beta' : 'summarization'; if (briefHeadlines.length >= 2 && mlWorker.isAvailable && mlWorker.isModelLoaded(sumModelId)) { try { - const lang = getCurrentLanguage(); - const prompt = lang === 'fr' + const promptLang = currentLang === 'fr' ? 'fr' : 'en'; + const prompt = promptLang === 'fr' ? `Résumez la situation actuelle en ${country} à partir de ces titres : ${briefHeadlines.slice(0, 8).join('. ')}` : `Summarize the current situation in ${country} based on these headlines: ${briefHeadlines.slice(0, 8).join('. ')}`; const [summary] = await mlWorker.summarize([prompt], BETA_MODE ? 'summarization-beta' : undefined); - if (summary && summary.length > 20) fallbackBrief = summary; + if (summary && summary.length > 20) { + fallbackBrief = currentLang !== promptLang + ? await translateEnglishLine(summary) + : summary; + } } catch { /* T5 failed */ } } if (fallbackBrief) { this.ctx.countryBriefPage?.updateBrief({ brief: fallbackBrief, country, code, fallback: true }); } else { - const lines: string[] = []; - if (score) lines.push(t('countryBrief.fallback.instabilityIndex', { score: String(score.score), level: t(`countryBrief.levels.${score.level}`), trend: t(`countryBrief.trends.${score.trend}`) })); - if (signals.protests > 0) lines.push(t('countryBrief.fallback.protestsDetected', { count: String(signals.protests) })); - if (signals.militaryFlights > 0) lines.push(t('countryBrief.fallback.aircraftTracked', { count: String(signals.militaryFlights) })); - if (signals.militaryVessels > 0) lines.push(t('countryBrief.fallback.vesselsTracked', { count: String(signals.militaryVessels) })); - if (signals.activeStrikes > 0) lines.push(t('countryBrief.fallback.activeStrikes', { count: String(signals.activeStrikes) })); - if (signals.travelAdvisoryMaxLevel === 'do-not-travel') lines.push(`⚠️ Travel advisory: Do Not Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`); - else if (signals.travelAdvisoryMaxLevel === 'reconsider') lines.push(`⚠️ Travel advisory: Reconsider Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`); - if (signals.outages > 0) lines.push(t('countryBrief.fallback.internetOutages', { count: String(signals.outages) })); - if (signals.criticalNews > 0) lines.push(`🚨 Critical headlines in scope: ${signals.criticalNews}`); - if (signals.cyberThreats > 0) lines.push(`🛡️ Cyber threat indicators: ${signals.cyberThreats}`); - if (signals.aisDisruptions > 0) lines.push(`🚢 Maritime AIS disruptions: ${signals.aisDisruptions}`); - if (signals.satelliteFires > 0) lines.push(`🔥 Satellite fire detections: ${signals.satelliteFires}`); - if (signals.radiationAnomalies > 0) lines.push(`☢️ Radiation anomalies: ${signals.radiationAnomalies}`); - if (signals.temporalAnomalies > 0) lines.push(`⏱️ Temporal anomaly alerts: ${signals.temporalAnomalies}`); - if (signals.thermalEscalations > 0) lines.push(`🌡️ Thermal escalation clusters: ${signals.thermalEscalations}`); - if (signals.earthquakes > 0) lines.push(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) })); - if (signals.orefHistory24h > 0) lines.push(`🚨 Sirens in past 24h: ${signals.orefHistory24h}`); - if (context.stockIndex) lines.push(t('countryBrief.fallback.stockIndex', { value: context.stockIndex })); + const linePromises: Promise[] = []; + if (score) linePromises.push(Promise.resolve(t('countryBrief.fallback.instabilityIndex', { score: String(score.score), level: t(`countryBrief.levels.${score.level}`), trend: t(`countryBrief.trends.${score.trend}`) }))); + if (signals.protests > 0) linePromises.push(Promise.resolve(t('countryBrief.fallback.protestsDetected', { count: String(signals.protests) }))); + if (signals.militaryFlights > 0) linePromises.push(Promise.resolve(t('countryBrief.fallback.aircraftTracked', { count: String(signals.militaryFlights) }))); + if (signals.militaryVessels > 0) linePromises.push(Promise.resolve(t('countryBrief.fallback.vesselsTracked', { count: String(signals.militaryVessels) }))); + if (signals.activeStrikes > 0) linePromises.push(Promise.resolve(t('countryBrief.fallback.activeStrikes', { count: String(signals.activeStrikes) }))); + if (signals.travelAdvisoryMaxLevel === 'do-not-travel') { + linePromises.push(translateEnglishLine(`⚠️ Travel advisory: Do Not Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`)); + } else if (signals.travelAdvisoryMaxLevel === 'reconsider') { + linePromises.push(translateEnglishLine(`⚠️ Travel advisory: Reconsider Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`)); + } + if (signals.outages > 0) linePromises.push(Promise.resolve(t('countryBrief.fallback.internetOutages', { count: String(signals.outages) }))); + if (signals.criticalNews > 0) linePromises.push(translateEnglishLine(`🚨 Critical headlines in scope: ${signals.criticalNews}`)); + if (signals.cyberThreats > 0) linePromises.push(translateEnglishLine(`🛡️ Cyber threat indicators: ${signals.cyberThreats}`)); + if (signals.aisDisruptions > 0) linePromises.push(translateEnglishLine(`🚢 Maritime AIS disruptions: ${signals.aisDisruptions}`)); + if (signals.satelliteFires > 0) linePromises.push(translateEnglishLine(`🔥 Satellite fire detections: ${signals.satelliteFires}`)); + if (signals.radiationAnomalies > 0) linePromises.push(translateEnglishLine(`☢️ Radiation anomalies: ${signals.radiationAnomalies}`)); + if (signals.temporalAnomalies > 0) linePromises.push(translateEnglishLine(`⏱️ Temporal anomaly alerts: ${signals.temporalAnomalies}`)); + if (signals.thermalEscalations > 0) linePromises.push(translateEnglishLine(`🌡️ Thermal escalation clusters: ${signals.thermalEscalations}`)); + if (signals.earthquakes > 0) linePromises.push(Promise.resolve(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) }))); + if (signals.orefHistory24h > 0) linePromises.push(translateEnglishLine(`🚨 Sirens in past 24h: ${signals.orefHistory24h}`)); + if (context.stockIndex) linePromises.push(Promise.resolve(t('countryBrief.fallback.stockIndex', { value: context.stockIndex }))); + const lines = (await Promise.all(linePromises)).filter((line) => line.length > 0); if (lines.length > 0) { this.ctx.countryBriefPage?.updateBrief({ brief: lines.join('\n'), country, code, fallback: true }); } else { - this.ctx.countryBriefPage?.updateBrief({ brief: '', country, code, error: 'No AI service available. Configure GROQ_API_KEY in Settings for full briefs.' }); + this.ctx.countryBriefPage?.updateBrief({ brief: '', country, code, error: t('countryBrief.briefUnavailable') }); } } } } catch (err) { console.error('[CountryBrief] fetch error:', err); - this.ctx.countryBriefPage?.updateBrief({ brief: '', country, code, error: 'Failed to generate brief' }); + this.ctx.countryBriefPage?.updateBrief({ brief: '', country, code, error: t('countryBrief.assessmentUnavailable') }); } } diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 877791f426..7acb95c440 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -1459,9 +1459,10 @@ export class DataLoaderManager implements AppModule { this.dailyBriefGeneration++; const gen = this.dailyBriefGeneration; this.ctx.inFlight.add('dailyMarketBrief'); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const lang = getCurrentLanguage(); try { - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - const cached = await getCachedDailyMarketBrief(timezone); + const cached = await getCachedDailyMarketBrief(timezone, lang); if (cached?.available) { this.callPanel('daily-market-brief', 'renderBrief', cached, 'cached'); @@ -1487,6 +1488,7 @@ export class DataLoaderManager implements AppModule { const brief = await buildDailyMarketBrief({ markets: this.ctx.latestMarkets, newsByCategory: this.ctx.newsByCategory, + lang, timezone, regimeContext, yieldCurveContext, @@ -1510,8 +1512,7 @@ export class DataLoaderManager implements AppModule { this.callPanel('daily-market-brief', 'renderBrief', brief, 'live'); } catch (error) { console.warn('[DailyBrief] Failed to build daily market brief:', error); - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - const cached = await getCachedDailyMarketBrief(timezone).catch(() => null); + const cached = await getCachedDailyMarketBrief(timezone, lang).catch(() => null); if (cached?.available) { this.callPanel('daily-market-brief', 'renderBrief', cached, 'cached'); return; diff --git a/src/components/CountryBriefPage.ts b/src/components/CountryBriefPage.ts index e4349a3587..9fc907ef7e 100644 --- a/src/components/CountryBriefPage.ts +++ b/src/components/CountryBriefPage.ts @@ -1,6 +1,6 @@ import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; import { formatIntelBrief } from '@/utils/format-intel-brief'; -import { t } from '@/services/i18n'; +import { getCurrentLanguage, t } from '@/services/i18n'; import { getCSSColor } from '@/utils'; import type { CountryScore } from '@/services/country-instability'; import type { NewsItem } from '@/types'; @@ -15,8 +15,10 @@ import { exportCountryBriefJSON, exportCountryBriefCSV } from '@/utils/export'; import type { CountryBriefExport } from '@/utils/export'; import { ME_STRIKE_BOUNDS } from '@/services/country-geometry'; import { toFlagEmoji } from '@/utils/country-flag'; +import { getCachedContentTranslation, shouldTranslateContent, translateContentText } from '@/services/content-translation'; type BriefAssetType = AssetType | 'port'; +const COUNTRY_BRIEF_TRANSLATION_DELAY_MS = 80; export class CountryBriefPage implements CountryBriefPanel { private static BRIEF_BOUNDS: Record = { @@ -63,6 +65,8 @@ export class CountryBriefPage implements CountryBriefPanel { private onShareStory?: (code: string, name: string) => void; private onExportImage?: (code: string, name: string) => void; private abortController: AbortController = new AbortController(); + private newsTranslationTimer: ReturnType | null = null; + private newsTranslationRequestId = 0; constructor() { this.overlay = document.createElement('div'); @@ -281,6 +285,7 @@ export class CountryBriefPage implements CountryBriefPanel { } public showLoading(): void { + this.cancelPendingNewsTranslations(); this.currentCode = '__loading__'; this.overlay.innerHTML = `
@@ -306,6 +311,7 @@ export class CountryBriefPage implements CountryBriefPanel { } public showGeoError(onRetry: () => void): void { + this.cancelPendingNewsTranslations(); this.currentCode = '__error__'; this.overlay.textContent = ''; @@ -361,6 +367,7 @@ export class CountryBriefPage implements CountryBriefPanel { public show(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void { this.abortController.abort(); + this.cancelPendingNewsTranslations(); this.abortController = new AbortController(); this.currentCode = code; this.currentName = country; @@ -556,6 +563,7 @@ export class CountryBriefPage implements CountryBriefPanel { if (!section || !content || headlines.length === 0) return; const items = headlines.slice(0, 8); + const currentLang = getCurrentLanguage(); this.currentHeadlineCount = items.length; this.currentHeadlines = items; section.style.display = ''; @@ -567,10 +575,19 @@ export class CountryBriefPage implements CountryBriefPanel { : item.threat?.level === 'medium' ? getCSSColor('--threat-medium') : getCSSColor('--threat-info'); const timeAgo = this.timeAgo(item.pubDate); + const cachedTitle = shouldTranslateContent(currentLang, item.lang) + ? getCachedContentTranslation(item.title, currentLang) + : undefined; + const displayTitle = cachedTitle ?? item.title; + const wasTranslated = typeof cachedTitle === 'string' && cachedTitle !== item.title; const cardBody = `
-
${escapeHtml(item.title)}
+
${escapeHtml(displayTitle)}
${escapeHtml(item.source)} · ${timeAgo}
`; if (safeUrl) { @@ -578,6 +595,8 @@ export class CountryBriefPage implements CountryBriefPanel { } return `
${cardBody}
`; }).join(''); + + this.scheduleAutoTranslateNewsTitles(); } @@ -652,6 +671,47 @@ export class CountryBriefPage implements CountryBriefPanel { return t('modals.countryBrief.timeAgo.d', { count: Math.floor(hours / 24) }); } + private cancelPendingNewsTranslations(): void { + this.newsTranslationRequestId += 1; + if (this.newsTranslationTimer) { + clearTimeout(this.newsTranslationTimer); + this.newsTranslationTimer = null; + } + } + + private scheduleAutoTranslateNewsTitles(): void { + this.cancelPendingNewsTranslations(); + const targetLang = getCurrentLanguage(); + if (targetLang === 'en') return; + + const requestId = this.newsTranslationRequestId; + this.newsTranslationTimer = setTimeout(() => { + void this.autoTranslateNewsTitles(targetLang, requestId); + }, COUNTRY_BRIEF_TRANSLATION_DELAY_MS); + } + + private async autoTranslateNewsTitles(targetLang: string, requestId: number): Promise { + if (requestId !== this.newsTranslationRequestId || !this.overlay.isConnected) return; + + const titleEls = Array.from(this.overlay.querySelectorAll('.cb-news-title[data-original-title]')); + for (const titleEl of titleEls) { + if (requestId !== this.newsTranslationRequestId || !titleEl.isConnected) return; + + const originalTitle = titleEl.dataset.originalTitle || titleEl.textContent || ''; + const sourceLang = titleEl.dataset.sourceLang; + if (!originalTitle || !shouldTranslateContent(targetLang, sourceLang)) continue; + + const translated = await translateContentText(originalTitle, targetLang, { sourceLang }); + if (requestId !== this.newsTranslationRequestId || !titleEl.isConnected) return; + if (!translated || translated === originalTitle) continue; + + titleEl.textContent = translated; + titleEl.dataset.translatedTitle = translated; + titleEl.dataset.translationState = 'translated'; + titleEl.title = originalTitle; + } + } + private formatBrief(text: string, headlineCount = 0): string { return formatIntelBrief(text, headlineCount > 0 ? { count: headlineCount, hrefPrefix: '#cb-news-' } : undefined); } @@ -749,6 +809,7 @@ export class CountryBriefPage implements CountryBriefPanel { public hide(): void { this.abortController.abort(); + this.cancelPendingNewsTranslations(); this.overlay.classList.remove('active'); this.currentCode = null; this.currentName = null; diff --git a/src/components/DailyMarketBriefPanel.ts b/src/components/DailyMarketBriefPanel.ts index 5c9dd980c5..be488dda49 100644 --- a/src/components/DailyMarketBriefPanel.ts +++ b/src/components/DailyMarketBriefPanel.ts @@ -1,17 +1,20 @@ import { Panel } from './Panel'; -import { t } from '@/services/i18n'; +import { getCurrentLanguage, t } from '@/services/i18n'; import { hasPremiumAccess } from '@/services/panel-gating'; import { FrameworkSelector } from './FrameworkSelector'; import type { DailyMarketBrief } from '@/services/daily-market-brief'; import { describeFreshness } from '@/services/persistent-cache'; +import { getCachedContentTranslation, translateContentText } from '@/services/content-translation'; import { escapeHtml } from '@/utils/sanitize'; import { getChangeClass } from '@/utils'; type BriefSource = 'live' | 'cached'; +const BRIEF_COPY_TRANSLATION_DELAY_MS = 200; -function formatGeneratedTime(isoTimestamp: string, timezone: string): string { +function formatGeneratedTime(isoTimestamp: string, timezone: string, lang = 'en'): string { + const locale = lang === 'en' ? 'en-US' : lang; try { - return new Intl.DateTimeFormat('en-US', { + return new Intl.DateTimeFormat(locale, { timeZone: timezone, hour: 'numeric', minute: '2-digit', @@ -23,7 +26,11 @@ function formatGeneratedTime(isoTimestamp: string, timezone: string): string { } } -function stanceLabel(stance: DailyMarketBrief['items'][number]['stance']): string { +function getBriefCopy(text: string, lang: string): string { + return getCachedContentTranslation(text, lang) ?? text; +} + +function stanceCopySource(stance: DailyMarketBrief['items'][number]['stance']): string { if (stance === 'bullish') return 'Bullish'; if (stance === 'defensive') return 'Defensive'; return 'Neutral'; @@ -42,6 +49,7 @@ function formatChange(change: number | null): string { export class DailyMarketBriefPanel extends Panel { private fwSelector: FrameworkSelector; + private briefCopyRequestId = 0; constructor() { super({ id: 'daily-market-brief', title: 'Daily Market Brief', infoTooltip: t('components.dailyMarketBrief.infoTooltip'), premium: 'locked' }); @@ -55,33 +63,40 @@ export class DailyMarketBriefPanel extends Panel { } public renderBrief(brief: DailyMarketBrief, source: BriefSource = 'live'): void { + const lang = brief.lang || getCurrentLanguage(); const freshness = describeFreshness(new Date(brief.generatedAt).getTime()); this.setDataBadge(source, freshness); this.resetRetryBackoff(); + const actionPlanLabel = getBriefCopy('Action Plan', lang); + const riskWatchLabel = getBriefCopy('Risk Watch', lang); + const linkedHeadlineLabel = getBriefCopy('Linked headline', lang); const html = `
${escapeHtml(brief.title)}
-
${escapeHtml(formatGeneratedTime(brief.generatedAt, brief.timezone))}
+
${escapeHtml(formatGeneratedTime(brief.generatedAt, brief.timezone, lang))}
${escapeHtml(brief.summary)}
-
Action Plan
+
${escapeHtml(actionPlanLabel)}
${escapeHtml(brief.actionPlan)}
-
Risk Watch
+
${escapeHtml(riskWatchLabel)}
${escapeHtml(brief.riskWatch)}
- ${brief.items.map((item) => ` + ${brief.items.map((item) => { + const stanceSource = stanceCopySource(item.stance); + const stanceLabel = getBriefCopy(stanceSource, lang); + return `
@@ -94,21 +109,64 @@ export class DailyMarketBriefPanel extends Panel {
-
${escapeHtml(stanceLabel(item.stance))}
- ${item.relatedHeadline ? `
Linked headline
` : ''} +
${escapeHtml(stanceLabel)}
+ ${item.relatedHeadline ? `
${escapeHtml(linkedHeadlineLabel)}
` : ''}
${escapeHtml(item.note)}
- `).join('')} + `; + }).join('')}
`; this.setContent(html); + this.scheduleBriefCopyTranslation(lang); } public showUnavailable(message = 'The daily brief needs live market data before it can be generated.'): void { - this.showError(message); + const lang = getCurrentLanguage(); + const displayMessage = getBriefCopy(message, lang); + this.showError(displayMessage); + + const requestId = ++this.briefCopyRequestId; + if (lang === 'en' || displayMessage !== message) return; + setTimeout(() => { + void this.translateUnavailableMessage(message, lang, requestId); + }, BRIEF_COPY_TRANSLATION_DELAY_MS); + } + + private scheduleBriefCopyTranslation(targetLang: string): void { + const requestId = ++this.briefCopyRequestId; + if (targetLang === 'en') return; + setTimeout(() => { + void this.translateBriefCopy(targetLang, requestId); + }, BRIEF_COPY_TRANSLATION_DELAY_MS); + } + + private async translateBriefCopy(targetLang: string, requestId: number): Promise { + if (requestId !== this.briefCopyRequestId || !this.element.isConnected) return; + + const labels = Array.from(this.content.querySelectorAll('[data-brief-copy]')); + for (const label of labels) { + if (requestId !== this.briefCopyRequestId || !label.isConnected) return; + const source = label.dataset.briefCopy; + if (!source) continue; + + const translated = await translateContentText(source, targetLang, { sourceLang: 'en' }); + if (requestId !== this.briefCopyRequestId || !label.isConnected) return; + if (translated && translated !== source) { + label.textContent = translated; + } + } + } + + private async translateUnavailableMessage(message: string, targetLang: string, requestId: number): Promise { + const translated = await translateContentText(message, targetLang, { sourceLang: 'en' }); + if (requestId !== this.briefCopyRequestId || !this.element.isConnected) return; + if (translated && translated !== message) { + this.showError(translated); + } } } diff --git a/src/components/NewsPanel.ts b/src/components/NewsPanel.ts index 1070c80d70..a5d1278ba6 100644 --- a/src/components/NewsPanel.ts +++ b/src/components/NewsPanel.ts @@ -4,11 +4,12 @@ import type { NewsItem, ClusteredEvent, DeviationLevel, RelatedAsset, RelatedAss import { THREAT_PRIORITY } from '@/services/threat-classifier'; import { formatTime, getCSSColor } from '@/utils'; import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; -import { analysisWorker, enrichWithVelocityML, getClusterAssetContext, MAX_DISTANCE_KM, activityTracker, generateSummary, translateText } from '@/services'; +import { analysisWorker, enrichWithVelocityML, getClusterAssetContext, MAX_DISTANCE_KM, activityTracker, generateSummary } from '@/services'; import { getSourcePropagandaRisk, getSourceTier, getSourceType } from '@/config/feeds'; import { SITE_VARIANT } from '@/config'; import { t, getCurrentLanguage } from '@/services/i18n'; import { track } from '@/services/analytics'; +import { getCachedContentTranslation, shouldTranslateContent, translateContentText } from '@/services/content-translation'; type SortMode = 'relevance' | 'newest'; @@ -18,6 +19,9 @@ const VIRTUAL_SCROLL_THRESHOLD = 15; /** Summary cache TTL in milliseconds (10 minutes) */ const SUMMARY_CACHE_TTL = 10 * 60 * 1000; +/** Let Panel.setContent flush the DOM before scanning for titles to translate. */ +const AUTO_TRANSLATION_DELAY_MS = 200; + /** Prepared cluster data for rendering */ interface PreparedCluster { cluster: ClusteredEvent; @@ -39,6 +43,8 @@ export class NewsPanel extends Panel { private renderRequestId = 0; private boundScrollHandler: (() => void) | null = null; private boundClickHandler: (() => void) | null = null; + private titleTranslationTimer: ReturnType | null = null; + private titleTranslationRequestId = 0; // Sort mode toggle (#107) private sortMode!: SortMode; @@ -84,7 +90,10 @@ export class NewsPanel extends Panel { prepared.shouldHighlight, prepared.showNewTag ), - () => this.bindRelatedAssetEvents() + () => { + this.bindRelatedAssetEvents(); + this.scheduleAutoTranslateTitles(); + } ); } @@ -276,29 +285,50 @@ export class NewsPanel extends Panel { const titleEl = element.closest('.item')?.querySelector('.item-title') as HTMLElement; if (!titleEl) return; - const originalText = titleEl.textContent || ''; + const originalText = titleEl.dataset.originalTitle || titleEl.textContent || text; + const translatedText = titleEl.dataset.translatedTitle + ?? getCachedContentTranslation(originalText, currentLang); + + if (translatedText !== undefined) { + if (translatedText === originalText) return; + const showingTranslated = titleEl.dataset.translationState !== 'original'; + titleEl.textContent = showingTranslated ? originalText : translatedText; + titleEl.dataset.translationState = showingTranslated ? 'original' : 'translated'; + titleEl.title = showingTranslated ? '' : originalText; + element.innerHTML = showingTranslated ? '文' : '↺'; + element.title = showingTranslated ? 'Translate' : 'Show original'; + element.classList.toggle('translated', !showingTranslated); + return; + } // Visual feedback element.innerHTML = '...'; element.style.pointerEvents = 'none'; try { - const translated = await translateText(text, currentLang); + const translated = await translateContentText(text, currentLang, { + sourceLang: titleEl.dataset.sourceLang, + }); if (!this.element?.isConnected) return; - if (translated) { + if (translated && translated !== originalText) { titleEl.textContent = translated; titleEl.dataset.original = originalText; - element.innerHTML = '✓'; - element.title = 'Original: ' + originalText; + titleEl.dataset.originalTitle = originalText; + titleEl.dataset.translatedTitle = translated; + titleEl.dataset.translationState = 'translated'; + titleEl.title = originalText; + element.innerHTML = '↺'; + element.title = 'Show original'; element.classList.add('translated'); } else { element.innerHTML = '文'; - // Shake animation or error state could be added here + element.title = 'Translate'; } } catch (e) { if (!this.element?.isConnected) return; console.error('Translation failed', e); element.innerHTML = '文'; + element.title = 'Translate'; } finally { if (element.isConnected) { element.style.pointerEvents = 'auto'; @@ -306,6 +336,54 @@ export class NewsPanel extends Panel { } } + private cancelPendingTitleTranslations(): void { + this.titleTranslationRequestId += 1; + if (this.titleTranslationTimer) { + clearTimeout(this.titleTranslationTimer); + this.titleTranslationTimer = null; + } + } + + private scheduleAutoTranslateTitles(): void { + this.cancelPendingTitleTranslations(); + const targetLang = getCurrentLanguage(); + if (targetLang === 'en') return; + + const requestId = this.titleTranslationRequestId; + this.titleTranslationTimer = setTimeout(() => { + void this.autoTranslateVisibleTitles(targetLang, requestId); + }, AUTO_TRANSLATION_DELAY_MS); + } + + private async autoTranslateVisibleTitles(targetLang: string, requestId: number): Promise { + if (requestId !== this.titleTranslationRequestId || !this.element.isConnected) return; + + const titleEls = Array.from(this.content.querySelectorAll('.item-title[data-original-title]')); + for (const titleEl of titleEls) { + if (requestId !== this.titleTranslationRequestId || !this.element.isConnected || !titleEl.isConnected) return; + + const originalTitle = titleEl.dataset.originalTitle || titleEl.textContent || ''; + const sourceLang = titleEl.dataset.sourceLang; + if (!originalTitle || !shouldTranslateContent(targetLang, sourceLang)) continue; + + const translated = await translateContentText(originalTitle, targetLang, { sourceLang }); + if (requestId !== this.titleTranslationRequestId || !titleEl.isConnected) return; + if (!translated || translated === originalTitle) continue; + + titleEl.textContent = translated; + titleEl.dataset.translatedTitle = translated; + titleEl.dataset.translationState = 'translated'; + titleEl.title = originalTitle; + + const translateBtn = titleEl.closest('.item')?.querySelector('.item-translate-btn'); + if (translateBtn) { + translateBtn.innerHTML = '↺'; + translateBtn.title = 'Show original'; + translateBtn.classList.add('translated'); + } + } + } + private showSummary(summary: string): void { if (!this.summaryContainer || !this.element?.isConnected) return; this.summaryContainer.style.display = 'block'; @@ -379,6 +457,7 @@ export class NewsPanel extends Panel { } public override showError(message?: string, onRetry?: () => void, autoRetrySeconds?: number): void { + this.cancelPendingTitleTranslations(); this.lastRawClusters = null; this.lastRawItems = null; super.showError(message, onRetry, autoRetrySeconds); @@ -386,6 +465,7 @@ export class NewsPanel extends Panel { public renderNews(items: NewsItem[]): void { if (items.length === 0) { + this.cancelPendingTitleTranslations(); this.renderRequestId += 1; // Cancel in-flight clustering from previous renders. this.setDataBadge('unavailable'); this.showError(t('common.noNewsAvailable')); @@ -404,6 +484,7 @@ export class NewsPanel extends Panel { } public renderFilteredEmpty(message: string): void { + this.cancelPendingTitleTranslations(); this.renderRequestId += 1; // Cancel in-flight clustering from previous renders. this.lastRawClusters = null; this.lastRawItems = null; @@ -432,6 +513,7 @@ export class NewsPanel extends Panel { private renderFlat(items: NewsItem[]): void { this.lastRawItems = items; + const currentLang = getCurrentLanguage(); let sorted: NewsItem[]; if (this.sortMode === 'newest') { @@ -450,24 +532,35 @@ export class NewsPanel extends Panel { const html = sorted .map( - (item) => ` + (item) => { + const cachedTitle = shouldTranslateContent(currentLang, item.lang) + ? getCachedContentTranslation(item.title, currentLang) + : undefined; + const displayTitle = cachedTitle ?? item.title; + const wasTranslated = typeof cachedTitle === 'string' && cachedTitle !== item.title; + return `
${escapeHtml(item.source)} - ${item.lang && item.lang !== getCurrentLanguage() ? `${item.lang.toUpperCase()}` : ''} + ${item.lang && item.lang !== currentLang ? `${item.lang.toUpperCase()}` : ''} ${item.isAlert ? 'ALERT' : ''}
- ${escapeHtml(item.title)} + ${escapeHtml(displayTitle)}
${formatTime(item.pubDate)} - ${getCurrentLanguage() !== 'en' ? `` : ''} + ${shouldTranslateContent(currentLang, item.lang) ? `` : ''}
` - ) + }) .join(''); this.setContent(html); + this.scheduleAutoTranslateTitles(); } private renderClusters(clusters: ClusteredEvent[]): void { @@ -534,6 +627,7 @@ export class NewsPanel extends Panel { .map(p => this.renderClusterHtmlSafely(p.cluster, p.isNew, p.shouldHighlight, p.showNewTag)) .join(''); this.setContent(html); + this.scheduleAutoTranslateTitles(); } } @@ -566,6 +660,7 @@ export class NewsPanel extends Panel { shouldHighlight: boolean, showNewTag: boolean ): string { + const currentLang = getCurrentLanguage(); const sourceBadge = cluster.sourceCount > 1 ? `${t('components.newsPanel.sources', { count: String(cluster.sourceCount) })}` : ''; @@ -581,9 +676,14 @@ export class NewsPanel extends Panel { : ''; const newTag = showNewTag ? `${t('common.new')}` : ''; - const langBadge = cluster.lang && cluster.lang !== getCurrentLanguage() + const langBadge = cluster.lang && cluster.lang !== currentLang ? `${cluster.lang.toUpperCase()}` : ''; + const cachedTitle = shouldTranslateContent(currentLang, cluster.lang) + ? getCachedContentTranslation(cluster.primaryTitle, currentLang) + : undefined; + const displayTitle = cachedTitle ?? cluster.primaryTitle; + const wasTranslated = typeof cachedTitle === 'string' && cachedTitle !== cluster.primaryTitle; // Propaganda risk indicator for primary source const primaryPropRisk = getSourcePropagandaRisk(cluster.primarySource); @@ -684,11 +784,15 @@ export class NewsPanel extends Panel { ${categoryBadge} ${riskBadge}
- ${escapeHtml(cluster.primaryTitle)} + ${escapeHtml(displayTitle)}
${topSourcesHtml} ${formatTime(cluster.lastUpdated)} - ${getCurrentLanguage() !== 'en' ? `` : ''} + ${shouldTranslateContent(currentLang, cluster.lang) ? `` : ''}
${relatedAssetsHtml} diff --git a/src/services/content-translation.ts b/src/services/content-translation.ts new file mode 100644 index 0000000000..705ab84706 --- /dev/null +++ b/src/services/content-translation.ts @@ -0,0 +1,184 @@ +const CACHE_PREFIX = 'wm-content-translation:v1'; +const STORAGE_PREFIX = `${CACHE_PREFIX}:`; +const MAX_STORED_TRANSLATIONS = 500; + +const memoryCache = new Map(); +const inFlight = new Map>(); + +interface CachedTranslationEntry { + source: string; + translated: string; + savedAt?: number; +} + +function normalizeLanguage(language: string): string { + return String(language || '').trim().toLowerCase().split('-')[0] || ''; +} + +function normalizeText(text: string): string { + return String(text || '').trim(); +} + +function hashText(text: string): string { + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function memoryKey(text: string, targetLang: string): string { + return `${normalizeLanguage(targetLang)}\u0000${normalizeText(text)}`; +} + +function storageKey(text: string, targetLang: string): string { + const normalizedText = normalizeText(text); + const normalizedLang = normalizeLanguage(targetLang); + return `${STORAGE_PREFIX}${normalizedLang}:${hashText(normalizedText)}:${normalizedText.length}`; +} + +function removeStorageKey(key: string): void { + try { + localStorage.removeItem(key); + } catch { + // Ignore storage cleanup failures. + } +} + +function readStoredEntry(key: string): CachedTranslationEntry | undefined { + try { + const raw = localStorage.getItem(key); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as CachedTranslationEntry; + if (typeof parsed?.source !== 'string' || typeof parsed.translated !== 'string') { + removeStorageKey(key); + return undefined; + } + if (parsed.savedAt !== undefined && !Number.isFinite(parsed.savedAt)) { + removeStorageKey(key); + return undefined; + } + return parsed; + } catch { + removeStorageKey(key); + return undefined; + } +} + +function trimStoredTranslations(maxEntries = MAX_STORED_TRANSLATIONS): void { + try { + const entries: Array<{ key: string; savedAt: number }> = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(STORAGE_PREFIX)) continue; + const entry = readStoredEntry(key); + if (!entry) continue; + entries.push({ key, savedAt: Number.isFinite(entry.savedAt) ? entry.savedAt ?? 0 : 0 }); + } + + if (entries.length <= maxEntries) return; + + entries + .sort((a, b) => b.savedAt - a.savedAt || a.key.localeCompare(b.key)) + .slice(maxEntries) + .forEach((entry) => removeStorageKey(entry.key)); + } catch { + // Ignore localStorage iteration failures and keep in-memory cache hot. + } +} + +function readStoredTranslation(text: string, targetLang: string): string | undefined { + const key = storageKey(text, targetLang); + const normalizedText = normalizeText(text); + try { + const parsed = readStoredEntry(key); + if (!parsed) return undefined; + if (parsed.source !== normalizedText) { + removeStorageKey(key); + return undefined; + } + return parsed.translated; + } catch { + return undefined; + } +} + +function persistTranslation(text: string, targetLang: string, translated: string): void { + try { + const normalizedText = normalizeText(text); + localStorage.setItem(storageKey(normalizedText, targetLang), JSON.stringify({ + source: normalizedText, + translated, + savedAt: Date.now(), + } satisfies CachedTranslationEntry)); + trimStoredTranslations(); + } catch { + // Ignore storage failures and keep the in-memory cache hot. + } +} + +export function shouldTranslateContent(targetLang: string, sourceLang?: string): boolean { + const normalizedTarget = normalizeLanguage(targetLang); + if (!normalizedTarget || normalizedTarget === 'en') return false; + const normalizedSource = normalizeLanguage(sourceLang || ''); + return !normalizedSource || normalizedSource !== normalizedTarget; +} + +export function getCachedContentTranslation(text: string, targetLang: string): string | undefined { + const normalizedText = normalizeText(text); + const normalizedLang = normalizeLanguage(targetLang); + if (!normalizedText || !normalizedLang) return undefined; + + const key = memoryKey(normalizedText, normalizedLang); + const memoryHit = memoryCache.get(key); + if (memoryHit !== undefined) return memoryHit; + + const stored = readStoredTranslation(normalizedText, normalizedLang); + if (stored === undefined) return undefined; + + memoryCache.set(key, stored); + return stored; +} + +export async function translateContentText( + text: string, + targetLang: string, + options?: { + sourceLang?: string; + translator?: (input: string, lang: string) => Promise; + }, +): Promise { + const normalizedText = normalizeText(text); + const normalizedLang = normalizeLanguage(targetLang); + + if (!normalizedText) return null; + if (!shouldTranslateContent(normalizedLang, options?.sourceLang)) return normalizedText; + + const cached = getCachedContentTranslation(normalizedText, normalizedLang); + if (cached !== undefined) return cached; + + const key = memoryKey(normalizedText, normalizedLang); + const pending = inFlight.get(key); + if (pending) return pending; + + const translator = options?.translator ?? (await import('./summarization')).translateText; + const request = (async () => { + const translated = await translator(normalizedText, normalizedLang); + const normalizedTranslated = normalizeText(translated || ''); + if (!normalizedTranslated) return null; + memoryCache.set(key, normalizedTranslated); + persistTranslation(normalizedText, normalizedLang, normalizedTranslated); + return normalizedTranslated; + })().finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; +} + +export function resetContentTranslationCacheForTests(): void { + memoryCache.clear(); + inFlight.clear(); +} diff --git a/src/services/daily-market-brief.ts b/src/services/daily-market-brief.ts index 796f935bfd..6cc40835d4 100644 --- a/src/services/daily-market-brief.ts +++ b/src/services/daily-market-brief.ts @@ -1,6 +1,7 @@ import type { MarketData, NewsItem } from '@/types'; import type { MarketWatchlistEntry } from './market-watchlist'; import { getMarketWatchlistEntries } from './market-watchlist'; +import type { translateContentText } from './content-translation'; import type { SummarizationResult } from './summarization'; export interface DailyMarketBriefItem { @@ -19,6 +20,7 @@ export interface DailyMarketBrief { title: string; dateKey: string; timezone: string; + lang?: string; summary: string; actionPlan: string; riskWatch: string; @@ -63,6 +65,7 @@ export interface SectorBriefContext { export interface BuildDailyMarketBriefOptions { markets: MarketData[]; newsByCategory: Record; + lang?: string; timezone?: string; now?: Date; targets?: MarketWatchlistEntry[]; @@ -77,6 +80,7 @@ export interface BuildDailyMarketBriefOptions { geoContext?: string, lang?: string, ) => Promise; + translate?: typeof translateContentText; } async function getDefaultSummarizer(): Promise> { @@ -84,6 +88,11 @@ async function getDefaultSummarizer(): Promise> { + const { translateContentText } = await import('./content-translation'); + return translateContentText; +} + async function getPersistentCacheApi(): Promise<{ getPersistentCache: (key: string) => Promise<{ data: T } | null>; setPersistentCache: (key: string, data: T) => Promise; @@ -98,6 +107,10 @@ const DEFAULT_TARGET_COUNT = 4; const BRIEF_NEWS_CATEGORIES = ['markets', 'economic', 'crypto', 'finance']; const COMMON_NAME_TOKENS = new Set(['inc', 'corp', 'group', 'holdings', 'company', 'companies', 'class', 'common', 'plc', 'limited', 'ltd', 'adr']); +function normalizeLanguage(language?: string): string { + return String(language || 'en').trim().toLowerCase().split('-')[0] || 'en'; +} + function resolveTimeZone(timezone?: string): string { const candidate = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; try { @@ -136,8 +149,9 @@ function getLocalHour(date: Date, timezone: string): number { return Number.parseInt(getLocalDateParts(date, timezone).hour || '0', 10) || 0; } -function formatTitleDate(date: Date, timezone: string): string { - return new Intl.DateTimeFormat('en-US', { +function formatTitleDate(date: Date, timezone: string, lang = 'en'): string { + const locale = normalizeLanguage(lang) === 'en' ? 'en-US' : normalizeLanguage(lang); + return new Intl.DateTimeFormat(locale, { timeZone: resolveTimeZone(timezone), month: 'short', day: 'numeric', @@ -148,8 +162,20 @@ function sanitizeCacheKeyPart(value: string): string { return value.replace(/[^a-z0-9/_-]+/gi, '-').toLowerCase(); } -function getCacheKey(timezone: string): string { - return `${CACHE_PREFIX}:${sanitizeCacheKeyPart(resolveTimeZone(timezone))}`; +function getCacheKey(timezone: string, lang = 'en'): string { + return `${CACHE_PREFIX}:${sanitizeCacheKeyPart(resolveTimeZone(timezone))}:${sanitizeCacheKeyPart(normalizeLanguage(lang))}`; +} + +async function translateBriefCopy( + text: string, + targetLang: string, + translate: NonNullable, +): Promise { + if (!text) return text; + const normalizedLang = normalizeLanguage(targetLang); + if (normalizedLang === 'en') return text; + const translated = await translate(text, normalizedLang, { sourceLang: 'en' }); + return translated || text; } function isMeaningfulToken(token: string): boolean { @@ -363,25 +389,26 @@ export function shouldRefreshDailyBrief( return getLocalHour(now, resolvedTimezone) >= scheduleHour; } -export async function getCachedDailyMarketBrief(timezone?: string): Promise { +export async function getCachedDailyMarketBrief(timezone?: string, lang = 'en'): Promise { const resolvedTimezone = resolveTimeZone(timezone); const { getPersistentCache } = await getPersistentCacheApi(); - const envelope = await getPersistentCache(getCacheKey(resolvedTimezone)); + const envelope = await getPersistentCache(getCacheKey(resolvedTimezone, lang)); return envelope?.data ?? null; } export async function cacheDailyMarketBrief(brief: DailyMarketBrief): Promise { const { setPersistentCache } = await getPersistentCacheApi(); - await setPersistentCache(getCacheKey(brief.timezone), brief); + await setPersistentCache(getCacheKey(brief.timezone, brief.lang), brief); } export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOptions): Promise { const now = options.now || new Date(); const timezone = resolveTimeZone(options.timezone); + const lang = normalizeLanguage(options.lang); const trackedMarkets = resolveTargets(options.markets, options.targets).slice(0, DEFAULT_TARGET_COUNT); const relevantHeadlines = collectHeadlinePool(options.newsByCategory, options.newsCategories); - const items: DailyMarketBriefItem[] = trackedMarkets.map((market) => { + let items: DailyMarketBriefItem[] = trackedMarkets.map((market) => { const relatedHeadline = relevantHeadlines.find((headline) => matchesMarketHeadline(market, headline.title))?.title; return { symbol: market.symbol, @@ -396,12 +423,16 @@ export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOption }); if (items.length === 0) { + const translate = options.translate || await getDefaultTranslator(); + const titlePrefix = await translateBriefCopy('Daily Market Brief', lang, translate); + const unavailableSummary = await translateBriefCopy('Market data is not available yet for the daily brief.', lang, translate); return { available: false, - title: `Daily Market Brief • ${formatTitleDate(now, timezone)}`, + title: `${titlePrefix} • ${formatTitleDate(now, timezone, lang)}`, dateKey: getDateKey(now, timezone), timezone, - summary: 'Market data is not available yet for the daily brief.', + lang, + summary: unavailableSummary, actionPlan: '', riskWatch: '', items: [], @@ -419,6 +450,9 @@ export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOption extendedContext = `${extendedContext}\n\n---\nAnalytical Framework:\n${options.frameworkAppend}`; } let summary = buildRuleSummary(items, relevantHeadlines.length); + let titlePrefix = 'Daily Market Brief'; + let actionPlan = buildActionPlan(items, relevantHeadlines.length); + let riskWatch = buildRiskWatch(items, relevantHeadlines); let provider = 'rules'; let model = ''; let fallback = true; @@ -430,7 +464,7 @@ export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOption summaryHeadlines, undefined, extendedContext, - 'en', + lang, ); if (generated?.summary) { summary = generated.summary.trim(); @@ -443,14 +477,32 @@ export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOption } } + if (lang !== 'en') { + const translate = options.translate || await getDefaultTranslator(); + const [localizedTitlePrefix, localizedSummary, localizedActionPlan, localizedRiskWatch, localizedNotes] = await Promise.all([ + translateBriefCopy(titlePrefix, lang, translate), + fallback ? translateBriefCopy(summary, lang, translate) : Promise.resolve(summary), + translateBriefCopy(actionPlan, lang, translate), + translateBriefCopy(riskWatch, lang, translate), + Promise.all(items.map((item) => translateBriefCopy(item.note, lang, translate))), + ]); + + titlePrefix = localizedTitlePrefix; + summary = localizedSummary; + actionPlan = localizedActionPlan; + riskWatch = localizedRiskWatch; + items = items.map((item, index) => ({ ...item, note: localizedNotes[index] || item.note })); + } + return { available: true, - title: `Daily Market Brief • ${formatTitleDate(now, timezone)}`, + title: `${titlePrefix} • ${formatTitleDate(now, timezone, lang)}`, dateKey: getDateKey(now, timezone), timezone, + lang, summary, - actionPlan: buildActionPlan(items, relevantHeadlines.length), - riskWatch: buildRiskWatch(items, relevantHeadlines), + actionPlan, + riskWatch, items, provider, model, diff --git a/tests/content-translation.test.mts b/tests/content-translation.test.mts new file mode 100644 index 0000000000..e8d41ff8a1 --- /dev/null +++ b/tests/content-translation.test.mts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + getCachedContentTranslation, + resetContentTranslationCacheForTests, + shouldTranslateContent, + translateContentText, +} from '../src/services/content-translation.ts'; + +class FakeStorage { + private values = new Map(); + + get length(): number { + return this.values.size; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + getItem(key: string): string | null { + return this.values.has(key) ? this.values.get(key) ?? null : null; + } + + setItem(key: string, value: string): void { + this.values.set(key, String(value)); + } + + removeItem(key: string): void { + this.values.delete(key); + } + + clear(): void { + this.values.clear(); + } +} + +let fakeStorage: FakeStorage; + +const originalLocalStorage = { + exists: Object.prototype.hasOwnProperty.call(globalThis, 'localStorage'), + value: globalThis.localStorage, +}; + +beforeEach(() => { + fakeStorage = new FakeStorage(); + globalThis.localStorage = fakeStorage as unknown as Storage; + resetContentTranslationCacheForTests(); +}); + +afterEach(() => { + resetContentTranslationCacheForTests(); + if (originalLocalStorage.exists) { + globalThis.localStorage = originalLocalStorage.value; + return; + } + delete globalThis.localStorage; +}); + +describe('content translation helpers', () => { + it('only auto-translates when target language is non-English and differs from source', () => { + assert.equal(shouldTranslateContent('en', 'fr'), false); + assert.equal(shouldTranslateContent('fr', 'fr'), false); + assert.equal(shouldTranslateContent('fr-PT', 'fr-FR'), false); + assert.equal(shouldTranslateContent('pt', 'en'), true); + assert.equal(shouldTranslateContent('es', undefined), true); + }); + + it('caches successful translations and reuses them on subsequent calls', async () => { + let calls = 0; + const translator = async (input: string, lang: string): Promise => { + calls += 1; + return `${lang}:${input}`; + }; + + const first = await translateContentText('Market stress is rising', 'pt', { translator }); + const second = await translateContentText('Market stress is rising', 'pt', { translator }); + + assert.equal(first, 'pt:Market stress is rising'); + assert.equal(second, 'pt:Market stress is rising'); + assert.equal(calls, 1); + assert.equal( + getCachedContentTranslation('Market stress is rising', 'pt'), + 'pt:Market stress is rising', + ); + }); + + it('deduplicates concurrent translation requests for the same text', async () => { + let calls = 0; + const translator = async (input: string, lang: string): Promise => { + calls += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return `${lang}:${input}`; + }; + + const [first, second] = await Promise.all([ + translateContentText('Headline', 'de', { translator }), + translateContentText('Headline', 'de', { translator }), + ]); + + assert.equal(first, 'de:Headline'); + assert.equal(second, 'de:Headline'); + assert.equal(calls, 1); + }); + + it('hydrates cached translations back from localStorage after memory reset', async () => { + const translator = async (input: string, lang: string): Promise => `${lang}:${input}`; + + await translateContentText('Oil prices jump', 'es', { translator }); + resetContentTranslationCacheForTests(); + + assert.equal(getCachedContentTranslation('Oil prices jump', 'es'), 'es:Oil prices jump'); + }); + + it('reads legacy stored translations that predate savedAt metadata', async () => { + const source = 'Legacy headline'; + const translated = 'pt:Legacy headline'; + const translator = async (): Promise => translated; + + await translateContentText(source, 'pt', { translator }); + const key = fakeStorage.key(0); + assert.ok(key, 'expected persisted translation key'); + fakeStorage.setItem(key, JSON.stringify({ source, translated })); + resetContentTranslationCacheForTests(); + + assert.equal(getCachedContentTranslation(source, 'pt'), translated); + }); + + it('caps persisted translations to the newest 500 entries', async () => { + const translator = async (input: string, lang: string): Promise => `${lang}:${input}`; + + for (let i = 0; i < 501; i += 1) { + // Ensure each entry gets a strictly newer timestamp for deterministic eviction. + await new Promise((resolve) => setTimeout(resolve, 1)); + await translateContentText(`Headline ${i}`, 'fr', { translator }); + } + + assert.equal(fakeStorage.length, 500); + resetContentTranslationCacheForTests(); + assert.equal(getCachedContentTranslation('Headline 0', 'fr'), undefined); + assert.equal(getCachedContentTranslation('Headline 500', 'fr'), 'fr:Headline 500'); + }); +}); diff --git a/tests/daily-brief-i18n-guard.test.mjs b/tests/daily-brief-i18n-guard.test.mjs new file mode 100644 index 0000000000..6ba4262c84 --- /dev/null +++ b/tests/daily-brief-i18n-guard.test.mjs @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +describe('Daily brief i18n guardrails', () => { + const loaderSrc = readFileSync(join(root, 'src/app/data-loader.ts'), 'utf-8'); + const panelSrc = readFileSync(join(root, 'src/components/DailyMarketBriefPanel.ts'), 'utf-8'); + const countryIntelSrc = readFileSync(join(root, 'src/app/country-intel.ts'), 'utf-8'); + + it('reuses the original language snapshot when falling back to cached daily briefs', () => { + assert.match(loaderSrc, /const lang = getCurrentLanguage\(\);[\s\S]*?catch \(error\)[\s\S]*?getCachedDailyMarketBrief\(timezone, lang\)/); + assert.doesNotMatch(loaderSrc, /getCachedDailyMarketBrief\(timezone, getCurrentLanguage\(\)\)/); + }); + + it('waits for debounced daily brief panel content before translating copy', () => { + assert.match(panelSrc, /const BRIEF_COPY_TRANSLATION_DELAY_MS = 200;/); + assert.match(panelSrc, /translateUnavailableMessage\(message, lang, requestId\);\s*\}, BRIEF_COPY_TRANSLATION_DELAY_MS\)/); + assert.match(panelSrc, /translateBriefCopy\(targetLang, requestId\);\s*\}, BRIEF_COPY_TRANSLATION_DELAY_MS\)/); + }); + + it('treats stance labels as translatable brief copy', () => { + assert.match(panelSrc, /function stanceCopySource\(/); + assert.match(panelSrc, /data-brief-copy="\$\{escapeHtml\(stanceSource\)\}"/); + assert.match(panelSrc, /getBriefCopy\(stanceSource, lang\)/); + }); + + it('parallelizes translated fallback lines for country briefs', () => { + assert.match(countryIntelSrc, /const linePromises: Promise\[\] = \[];/); + assert.match(countryIntelSrc, /const lines = \(await Promise\.all\(linePromises\)\)\.filter\(\(line\) => line\.length > 0\)/); + }); +}); diff --git a/tests/daily-market-brief.test.mts b/tests/daily-market-brief.test.mts index abc0bb0e2b..dc9779e019 100644 --- a/tests/daily-market-brief.test.mts +++ b/tests/daily-market-brief.test.mts @@ -103,6 +103,39 @@ describe('buildDailyMarketBrief', () => { assert.match(brief.items[0]?.note || '', /Headline driver/i); }); + it('passes the requested language to summarization and localizes deterministic sections', async () => { + let receivedLang = ''; + + const brief = await buildDailyMarketBrief({ + markets, + newsByCategory: { + markets: [makeNewsItem('Apple extends gains after stronger iPhone cycle outlook')], + }, + lang: 'es', + timezone: 'UTC', + now: new Date('2026-03-08T10:30:00.000Z'), + targets: [{ symbol: 'AAPL', name: 'Apple', display: 'AAPL' }], + summarize: async (_headlines, _progress, _context, lang) => { + receivedLang = lang || ''; + return { + summary: 'Resumen de mercado con Apple liderando la sesión.', + provider: 'openrouter', + model: 'test-model', + cached: false, + }; + }, + translate: async (text, lang) => `[${lang}] ${text}`, + }); + + assert.equal(receivedLang, 'es'); + assert.equal(brief.lang, 'es'); + assert.equal(brief.summary, 'Resumen de mercado con Apple liderando la sesión.'); + assert.match(brief.actionPlan, /^\[es\]/); + assert.match(brief.riskWatch, /^\[es\]/); + assert.match(brief.items[0]?.note || '', /^\[es\]/); + assert.ok(brief.title.startsWith('[es] Daily Market Brief • ')); + }); + it('falls back to deterministic copy when summarization is unavailable', async () => { const brief = await buildDailyMarketBrief({ markets, @@ -120,4 +153,28 @@ describe('buildDailyMarketBrief', () => { assert.equal(brief.fallback, true); assert.match(brief.summary, /watchlist|breadth|headline flow/i); }); + + it('localizes rules-based copy when the brief is built in a non-English language', async () => { + const brief = await buildDailyMarketBrief({ + markets, + newsByCategory: { + markets: [makeNewsItem('NVIDIA holds gains as chip demand remains firm')], + }, + lang: 'pt', + timezone: 'UTC', + now: new Date('2026-03-08T10:30:00.000Z'), + targets: [{ symbol: 'NVDA', name: 'NVIDIA', display: 'NVDA' }], + summarize: async () => null, + translate: async (text, lang) => `[${lang}] ${text}`, + }); + + assert.equal(brief.lang, 'pt'); + assert.equal(brief.provider, 'rules'); + assert.equal(brief.fallback, true); + assert.match(brief.summary, /^\[pt\]/); + assert.match(brief.actionPlan, /^\[pt\]/); + assert.match(brief.riskWatch, /^\[pt\]/); + assert.match(brief.items[0]?.note || '', /^\[pt\]/); + assert.ok(brief.title.startsWith('[pt] Daily Market Brief • ')); + }); });