Skip to content

feat(i18n): translate dynamic content by active language#2260

Open
lspassos1 wants to merge 2 commits intokoala73:mainfrom
lspassos1:feat/translate-content-language
Open

feat(i18n): translate dynamic content by active language#2260
lspassos1 wants to merge 2 commits intokoala73:mainfrom
lspassos1:feat/translate-content-language

Conversation

@lspassos1
Copy link
Copy Markdown
Collaborator

Summary

Closes #644 by translating dynamic content into the active UI language across the main news and briefing surfaces, while reusing the translation path already present in the app.

Root cause

The interface could switch languages, but dynamic content still remained in its source language or was forced to English in some paths. The biggest gaps were news headlines, country brief fallback copy, and the Daily Market Brief generation/cache path.

Changes

  • add a shared frontend translation layer with caching and in-flight request deduplication
  • auto-translate news headlines in the News panel and country brief news cards when the active UI language is non-English
  • make Daily Market Brief language-aware end-to-end, including summary generation, cache keys, rule-based fallback copy, and panel labels
  • translate remaining English-only fallback lines in the country brief flow instead of leaving mixed-language output
  • add focused tests for translation caching and language-aware daily brief generation

Validation

  • npx tsx --test tests/content-translation.test.mts tests/daily-market-brief.test.mts
  • npm run typecheck

Risk

Low to medium. The behavior change is intentionally broad across content surfaces, but it stays within the existing translation architecture and falls back to original text when translation is unavailable.

Architecture note

@koala73 This patch intentionally stays on the existing translation path already used in the app. It does not introduce a new translation vendor, browser-only API, or offline translation runtime.

If we want broader translation coverage later, the follow-up decision is whether to keep the current path or move to a dedicated/offline provider. That choice affects cost, latency, cache strategy, and offline/runtime support, but it is not changed in this PR.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

@lspassos1 is attempting to deploy a commit to the Elie Team on Vercel.

A member of the Team first needs to authorize it.

@lspassos1
Copy link
Copy Markdown
Collaborator Author

@koala73 One architecture note to make explicit: this PR closes #644 using the translation path already present in the app, so it does not introduce a new translation vendor or browser-only runtime dependency. If we later want broader or cheaper translation coverage, that should be a separate product/infra decision because it changes cost, latency, cache strategy, and offline/runtime support.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR introduces end-to-end language-awareness for the three main dynamic-content surfaces — News panels, Country Brief, and Daily Market Brief — by adding a shared content-translation service with two-level caching (in-memory + localStorage) and in-flight request deduplication, then wiring it into each surface consistently.

Key changes:

  • src/services/content-translation.ts (new): FNV-1a-keyed localStorage cache backed by an in-memory Map; shouldTranslateContent guard prevents no-op calls; translateContentText deduplicates concurrent requests for the same text via an inFlight map.
  • src/services/daily-market-brief.ts: Cache keys are now language-qualified (premium:daily-market-brief:v1:{tz}:{lang}); rule-based fallback copy (summary, action plan, risk watch, item notes) is translated via translateBriefCopy when lang !== 'en'; the lang field is stored in the cached brief.
  • src/components/DailyMarketBriefPanel.ts: Renders already-translated brief content; uses getBriefCopy for static labels with an async [data-brief-copy] deferred pass via scheduleBriefCopyTranslation.
  • src/components/NewsPanel.ts / CountryBriefPage.ts: Both implement a request-ID–guarded autoTranslate*Titles pattern to progressively translate visible headlines after the DOM settles, with cached pre-translation for already-seen items.
  • src/app/country-intel.ts: Fallback brief lines that were previously English-only are now passed through translateEnglishLine.
  • src/app/data-loader.ts: getCachedDailyMarketBrief and buildDailyMarketBrief receive the current lang value so the daily brief is generated and cached per-language.

Notable issues found:

  • stanceLabel in DailyMarketBriefPanel returns hard-coded English strings ('Bullish', 'Defensive', 'Neutral') and is never localized, creating a visible mixed-language result in the panel even when all surrounding copy is translated.
  • lang variable is scoped inside the try block in data-loader.ts, so the catch handler re-calls getCurrentLanguage() rather than using the captured snapshot; if the user switches languages during an async brief build, the error-path cache lookup can target the wrong language's cache.
  • localStorage translation cache has no TTL or eviction policy; on a high-frequency news dashboard this will grow unboundedly.
  • Fallback brief lines in country-intel.ts are translated sequentially via individual await calls rather than a single Promise.all, adding avoidable latency when multiple signals are active simultaneously.

Confidence Score: 3/5

  • Safe to merge with low-to-medium risk, but the untranslated stance labels in DailyMarketBriefPanel are a visible regression against the PR's own goals and should be addressed before shipping.
  • The architecture is sound and stays within the existing translation path. The new content-translation service is well-structured with correct deduplication and two-level caching. The daily brief language-keying, news headline auto-translation, and country brief fallback copy are all logically correct. Score is held at 3 rather than 4 due to: (1) the stanceLabel localization gap which produces a visibly mixed-language UI in the panel the PR is explicitly improving; (2) the lang variable scoping issue in the catch block of data-loader.ts which can cause the wrong language's cache to be returned on error; (3) the unbounded localStorage cache which is a production operational concern on a high-frequency news dashboard.
  • src/components/DailyMarketBriefPanel.ts (stance labels untranslated) and src/app/data-loader.ts (lang variable scoping in catch block) need the most attention before merge.

Important Files Changed

Filename Overview
src/services/content-translation.ts New shared translation layer — correct deduplication, in-memory + localStorage two-level cache, and shouldTranslateContent guard. No TTL or eviction on the localStorage layer.
src/services/daily-market-brief.ts Language-aware cache keys, title formatting, and rule-based copy translation all implemented correctly. Summarization is passed lang, and fallback copy is translated via translateBriefCopy when lang !== 'en'.
src/components/DailyMarketBriefPanel.ts Renders already-translated brief content correctly, but stanceLabel returns hard-coded English strings that are never localized — a clear gap relative to the PR's own goals.
src/app/data-loader.ts Language is correctly threaded into the cache lookup and brief build, but the catch block re-calls getCurrentLanguage() instead of reusing the already-captured lang, which can drift if the user switches language during an async build.
src/components/NewsPanel.ts Auto-translation of visible titles implemented with correct request-ID guard to cancel stale work. Both flat and clustered render paths are wired. Minor: empty string sourceLang for items with no lang field will always trigger translation even if the content is already in the target language.
src/components/CountryBriefPage.ts News card translation mirrors the NewsPanel pattern correctly; request-ID guards and shouldTranslateContent checks are properly applied.
src/app/country-intel.ts Fallback brief lines are individually awaited in sequence; multiple active signals cause serialized translation calls instead of a parallel batch, adding latency before the brief is shown.
tests/content-translation.test.mts Covers caching, deduplication, and localStorage persistence with correct FakeStorage stub and cache reset between tests.
tests/daily-market-brief.test.mts Validates schedule logic, English generation, non-English summarization pass-through, rules fallback, and full localization path — good coverage of the happy and error paths.

Sequence Diagram

sequenceDiagram
    participant UI as UI / Panel
    participant CT as content-translation.ts
    participant Mem as In-Memory Cache
    participant LS as localStorage Cache
    participant Trans as summarization.translateText

    UI->>CT: translateContentText(text, targetLang)
    CT->>CT: shouldTranslateContent(targetLang, sourceLang)?
    alt targetLang is 'en' or same as sourceLang
        CT-->>UI: return original text
    else needs translation
        CT->>Mem: getCachedContentTranslation(text, lang)
        alt memory hit
            Mem-->>CT: cached translation
            CT-->>UI: cached translation
        else memory miss
            CT->>LS: readStoredTranslation(text, lang)
            alt localStorage hit
                LS-->>CT: stored translation
                CT->>Mem: warm memory cache
                CT-->>UI: cached translation
            else cache miss
                CT->>CT: check inFlight Map (dedup)
                alt request already in-flight
                    CT-->>UI: await same Promise
                else new request
                    CT->>Trans: translateText(text, lang)
                    Trans-->>CT: translated string
                    CT->>Mem: memoryCache.set(key, result)
                    CT->>LS: persistTranslation(...)
                    CT-->>UI: translated string
                end
            end
        end
    end
Loading

Comments Outside Diff (2)

  1. src/components/DailyMarketBriefPanel.ts, line 30-34 (link)

    P1 Stance labels are never translated

    stanceLabel returns hard-coded English strings ('Bullish', 'Defensive', 'Neutral') that are rendered directly with escapeHtml() on line 98. They don't carry a data-brief-copy attribute and are never passed through getBriefCopy, so the scheduleBriefCopyTranslation mechanism cannot pick them up. Every other piece of copy in the brief — notes, action plan, risk watch, labels — is translated at the service level or via the deferred [data-brief-copy] pass, but these three stance words will always stay in English regardless of the active UI language.

    The item note content (which mentions stance in prose) is correctly translated in buildDailyMarketBrief, making the visual result inconsistent: the note says e.g. "O ímpeto é construtivo…" while the badge above it still reads "Bullish".

    Then update the call site to stanceLabel(item.stance, lang) and add 'Bullish', 'Defensive', 'Neutral' to the data-brief-copy elements (or eagerly seed them in translateBriefCopy) so the async pass fills the cache for the next render.

  2. src/app/country-intel.ts, line 363-388 (link)

    P2 Sequential await in fallback brief generation may slow UI updates

    Each translateEnglishLine call is awaited individually inside a series of if branches. If several conditions are true simultaneously (e.g. travel advisory + critical news + cyber threats + satellite fires), the translation requests are serialized even though translateContentText already deduplicates in-flight requests for identical strings. Batching them with a single Promise.all would allow the in-flight deduplication to work across all of them and reduce total latency before updateBrief is called.

Reviews (1): Last reviewed commit: "feat(i18n): translate dynamic content by..." | Re-trigger Greptile

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, getCurrentLanguage()).catch(() => null);
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 lang re-read in catch block may differ from the value used in the try block

lang is declared with const inside the try block (line 1393), so it is not in scope here. The catch handler calls getCurrentLanguage() a second time. If the user switches languages during the await buildDailyMarketBrief(…) call (a real possibility given the async summarization step), the error-path cache lookup will use a different language than the generation that just failed, potentially surfacing the wrong language's cached brief in the error fallback path.

Capture lang before the try block so both paths share the same snapshot:

Suggested change
const cached = await getCachedDailyMarketBrief(timezone, getCurrentLanguage()).catch(() => null);
const cached = await getCachedDailyMarketBrief(timezone, lang).catch(() => null);

(This requires hoisting const lang = getCurrentLanguage(); to before the this.ctx.inFlight.add('dailyMarketBrief'); line so it's accessible in the catch.)

Comment on lines +53 to +63
function persistTranslation(text: string, targetLang: string, translated: string): void {
try {
const normalizedText = normalizeText(text);
localStorage.setItem(
storageKey(normalizedText, targetLang),
JSON.stringify({ source: normalizedText, translated } satisfies CachedTranslationEntry),
);
} catch {
// Ignore storage failures and keep the in-memory cache hot.
}
}
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 localStorage translation cache has no TTL or size limit

Translations are written to localStorage indefinitely. On a dashboard that aggregates 30+ live news feeds and refreshes frequently, the number of distinct translated headlines will grow quickly. Each entry stores both the source text and the translated text, which for lengthy news headlines could accumulate to tens of MB over weeks of use — especially in multilingual newsrooms.

Since the cache has no eviction, the catch { } on setItem is the only safety net; once localStorage is full, all subsequent translation calls fall back to the network every time (in-memory cache aside, which is reset on page reload).

Consider adding a simple epoch/version key and a max-item count guard, or at minimum documenting the unbounded growth characteristic so it can be addressed in a follow-up.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a47d0b6c43

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +128 to +129
setTimeout(() => {
void this.translateBriefCopy(targetLang, requestId);
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 Badge Defer brief-label translation until content render completes

renderBrief() schedules translateBriefCopy() with setTimeout(..., 0), but Panel.setContent() applies HTML with a 150ms debounce, so the translation pass runs against stale/empty DOM and usually finds no [data-brief-copy] nodes. In a non-English UI with uncached labels, "Action Plan"/"Risk Watch"/"Linked headline" remain English indefinitely because no second translation pass is triggered after the debounced render finishes.

Useful? React with 👍 / 👎.

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, getCurrentLanguage()).catch(() => null);
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 Badge Reuse the original language in daily-brief error fallback

The catch path re-reads getCurrentLanguage() before loading cached data. If the user switches language while a brief build is in flight and that build fails, recovery looks up a different language key than the one used earlier in the same request, misses an existing cache entry, and then overwrites the panel with an error despite valid cached content being available. Reusing the initially captured language avoids this race.

Useful? React with 👍 / 👎.

@koala73 koala73 added High Value Meaningful contribution to the project Ready to Merge PR is mergeable, passes checks, and adds value labels Mar 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

High Value Meaningful contribution to the project Ready to Merge PR is mergeable, passes checks, and adds value

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Add Translate Content based on langage

2 participants