diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e71374..0eb6546 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 2026-04-30
+
+- Add internationalization (i18n) infrastructure with English (default) and Korean (νκ΅μ΄) bundled
+- Add language picker (π) in the header β choice persists via URL `?lang=` and `localStorage`; first-visit auto-detection from `navigator.languages`
+- Add hover tooltips with plain-language explanations for every stat card, chart title, and column header (what each metric means, including the cache-read discount and cache-creation premium)
+- Add tests that fail the build if any locale drifts from the English key set or the LOCALES picker registry
+
## 2026-04-09
- Fix token counts inflated ~2x by deduplicating streaming events that share the same message ID
diff --git a/README.md b/README.md
index cc3d0a8..7252122 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,29 @@ Claude Code writes one JSONL file per session to `~/.claude/projects/`. Each lin
---
+## Internationalization
+
+The dashboard ships English by default and can be switched to other languages from the π picker in the header. The chosen language is remembered in `localStorage` and reflected in the URL (`?lang=ko`); on first visit the app reads `navigator.languages` and uses the first match.
+
+Currently bundled locales:
+
+- `en` β English
+- `ko` β νκ΅μ΄ (Korean)
+
+Hover any metric, chart title, or column header to see an in-context explanation of what it represents (e.g. *Cache Read* explains the 90% discount over fresh input tokens).
+
+### Adding a new language
+
+All translations live in a single `MESSAGES` object inside `dashboard.py`. To add a locale:
+
+1. Copy the `en: { ... }` block, rename the key (e.g. `de: { ... }`), and translate each value. Keep the keys identical to English.
+2. Add a display name to the `LOCALES` registry: `de: 'Deutsch',`.
+3. Run `python -m unittest tests.test_dashboard.TestI18n` β it verifies key parity, registry/catalog consistency, and that no value is empty.
+
+CSV exports stay in English on purpose so spreadsheets and downstream pipelines see stable column names.
+
+---
+
## Cost estimates
Costs are calculated using **Anthropic API pricing as of April 2026** ([claude.com/pricing#api](https://claude.com/pricing#api)).
diff --git a/dashboard.py b/dashboard.py
index ebf8d5f..56d69b2 100644
--- a/dashboard.py
+++ b/dashboard.py
@@ -142,13 +142,27 @@ def get_dashboard_data(db_path=DB_PATH):
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; }
- header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
+ header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
header h1 { font-size: 18px; font-weight: 600; color: var(--accent); }
header .meta { color: var(--muted); font-size: 12px; }
+ .header-actions { display: flex; align-items: center; gap: 8px; }
#rescan-btn { background: var(--card); border: 1px solid var(--border); color: var(--muted); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; margin-top: 4px; }
#rescan-btn:hover { color: var(--text); border-color: var(--accent); }
#rescan-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+ .lang-picker { position: relative; margin-top: 4px; }
+ #lang-btn { background: var(--card); border: 1px solid var(--border); color: var(--muted); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; display: inline-flex; align-items: center; gap: 6px; }
+ #lang-btn:hover { color: var(--text); border-color: var(--accent); }
+ #lang-btn .caret { font-size: 9px; opacity: 0.7; }
+ .lang-menu { position: absolute; top: calc(100% + 6px); right: 0; min-width: 160px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 50; display: none; }
+ .lang-menu.open { display: block; }
+ .lang-menu .lang-menu-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 4px 10px 6px; }
+ .lang-menu button { display: flex; align-items: center; justify-content: space-between; width: 100%; background: transparent; border: none; color: var(--text); padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 13px; text-align: left; }
+ .lang-menu button:hover { background: rgba(255,255,255,0.04); }
+ .lang-menu button.active { color: var(--accent); }
+ .lang-menu button .check { color: var(--accent); opacity: 0; font-size: 12px; }
+ .lang-menu button.active .check { opacity: 1; }
+
#filter-bar { background: var(--card); border-bottom: 1px solid var(--border); padding: 10px 24px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.filter-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); white-space: nowrap; }
.filter-sep { width: 1px; height: 22px; background: var(--border); flex-shrink: 0; }
@@ -222,26 +236,39 @@ def get_dashboard_data(db_path=DB_PATH):
-
+
- | Project |
- Branch |
- Sessions |
- Turns |
- Input |
- Output |
- Est. Cost |
+ Project |
+ Branch |
+ Sessions |
+ Turns |
+ Input |
+ Output |
+ Est. Cost |
@@ -340,13 +367,13 @@ def get_dashboard_data(db_path=DB_PATH):
@@ -359,6 +386,408 @@ def get_dashboard_data(db_path=DB_PATH):
return d.innerHTML;
}
+// ββ i18n ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// English is the source of truth. To add a language, copy the `en` block,
+// translate each value, and add the new locale code to `LOCALES` below.
+// Keys missing in a locale fall back to English with a console warning.
+const MESSAGES = {
+ en: {
+ 'header.title': 'Claude Code Usage Dashboard',
+ 'header.meta_loading': 'Loading...',
+ 'header.meta_updated': 'Updated: {date}',
+ 'header.meta_refresh_note': ' Β· Auto-refresh in 30s',
+ 'header.rescan': 'β» Rescan',
+ 'header.rescan_tooltip': 'Rebuild the database from scratch by re-scanning all JSONL files. Use if data looks stale or costs seem wrong.',
+ 'header.rescan_scanning': 'β» Scanning...',
+ 'header.rescan_done': 'β» Rescan ({new} new, {updated} updated)',
+ 'header.rescan_error': 'β» Rescan (error)',
+
+ 'filter.models': 'Models',
+ 'filter.models_tooltip': 'Select Claude models to include in the aggregation. Only selected models appear in charts and tables.',
+ 'filter.all': 'All',
+ 'filter.all_tooltip': 'Select all models',
+ 'filter.none': 'None',
+ 'filter.none_tooltip': 'Clear all models',
+ 'filter.range': 'Range',
+ 'filter.range_tooltip': 'Select the aggregation period.',
+
+ 'range.week': 'This Week',
+ 'range.week_tooltip': 'This week (starting Monday)',
+ 'range.month': 'This Month',
+ 'range.month_tooltip': 'From the 1st of this month through today',
+ 'range.prev_month': 'Prev Month',
+ 'range.prev_month_tooltip': 'The full previous month',
+ 'range.7d': '7d',
+ 'range.7d_tooltip': 'Last 7 days',
+ 'range.30d': '30d',
+ 'range.30d_tooltip': 'Last 30 days',
+ 'range.90d': '90d',
+ 'range.90d_tooltip': 'Last 90 days',
+ 'range.all': 'All',
+ 'range.all_tooltip': 'All recorded history',
+
+ 'range_label.week': 'This Week',
+ 'range_label.month': 'This Month',
+ 'range_label.prev-month': 'Previous Month',
+ 'range_label.7d': 'Last 7 Days',
+ 'range_label.30d': 'Last 30 Days',
+ 'range_label.90d': 'Last 90 Days',
+ 'range_label.all': 'All Time',
+
+ 'stats.sessions.label': 'Sessions',
+ 'stats.sessions.tooltip': 'Number of distinct chat sessions in the selected period.',
+ 'stats.turns.label': 'Turns',
+ 'stats.turns.tooltip': 'Total assistant turns. Each tool-call cycle counts as one turn.',
+ 'stats.input_tokens.label': 'Input Tokens',
+ 'stats.input_tokens.tooltip': 'Raw prompt tokens you sent to the model. Usually a small portion of total cost.',
+ 'stats.output_tokens.label': 'Output Tokens',
+ 'stats.output_tokens.tooltip': 'Tokens generated by Claude. Typically the most expensive component of cost.',
+ 'stats.cache_read.label': 'Cache Read',
+ 'stats.cache_read.tooltip': 'Tokens served from prompt cache β about 90% cheaper than fresh input tokens.',
+ 'stats.cache_read.sub': 'from prompt cache',
+ 'stats.cache_creation.label': 'Cache Creation',
+ 'stats.cache_creation.tooltip': 'Tokens written to prompt cache β a 25% premium over input, but later cache reads are far cheaper.',
+ 'stats.cache_creation.sub': 'writes to prompt cache',
+ 'stats.est_cost.label': 'Est. Cost',
+ 'stats.est_cost.tooltip': 'Estimated API cost based on Anthropic API pricing as of April 2026. Max/Pro subscribers have a flat subscription cost instead.',
+ 'stats.est_cost.sub': 'API pricing, Apr 2026',
+
+ 'chart.daily_title': 'Daily Token Usage',
+ 'chart.daily_title_with_range': 'Daily Token Usage β {range}',
+ 'chart.daily_title_tooltip': 'Stacked daily token usage by category. Cache tokens use the left axis; raw input/output use the right axis.',
+ 'chart.hourly_title': 'Average Hourly Distribution',
+ 'chart.hourly_title_with_range': 'Average Hourly Distribution β {range}',
+ 'chart.hourly_title_tooltip': 'Average tokens and turns by hour of day across the selected range.',
+ 'chart.peak_legend': 'Peak hours (PT)',
+ 'chart.peak_legend_tooltip': 'MonβFri 05:00β11:00 PT β Anthropic peak-hour throttling window.',
+ 'chart.tz_local': 'Local',
+ 'chart.tz_utc': 'UTC',
+ 'chart.day_count_singular': '{n} day averaged Β· {tz}',
+ 'chart.day_count_plural': '{n} days averaged Β· {tz}',
+ 'chart.day_count_empty': 'No data Β· {tz}',
+ 'chart.peak_tooltip_suffix': ' Β· Peak β Anthropic US hours',
+ 'chart.avg_turns_label': 'Avg turns / hour',
+ 'chart.avg_output_label': 'Avg output tokens / hour',
+ 'chart.avg_turns_tooltip': ' Avg turns: {n}',
+ 'chart.avg_output_tooltip': ' Avg output: {n}',
+ 'chart.daily.input': 'Input',
+ 'chart.daily.output': 'Output',
+ 'chart.daily.cache_read': 'Cache Read',
+ 'chart.daily.cache_creation': 'Cache Creation',
+ 'chart.daily.y_left': 'Cache',
+ 'chart.daily.y_right': 'Input / Output',
+ 'chart.model_title': 'By Model',
+ 'chart.model_title_tooltip': 'Token share by model in the selected period.',
+ 'chart.model_tooltip_label': ' {model}: {tokens} tokens',
+ 'chart.project_title': 'Top Projects by Tokens',
+ 'chart.project_title_tooltip': 'Top 10 projects by total tokens in the selected period.',
+
+ 'table.cost_by_model': 'Cost by Model',
+ 'table.recent_sessions': 'Recent Sessions',
+ 'table.cost_by_project': 'Cost by Project',
+ 'table.cost_by_project_branch': 'Cost by Project & Branch',
+ 'table.csv_export': 'β€ CSV',
+ 'table.csv_export_sessions_tooltip': 'Export all filtered sessions to CSV',
+ 'table.csv_export_projects_tooltip': 'Export all projects to CSV',
+ 'table.csv_export_project_branch_tooltip': 'Export project + branch breakdown to CSV',
+
+ 'th.session': 'Session',
+ 'th.project': 'Project',
+ 'th.branch': 'Branch',
+ 'th.last_active': 'Last Active',
+ 'th.duration': 'Duration',
+ 'th.model': 'Model',
+ 'th.turns': 'Turns',
+ 'th.input': 'Input',
+ 'th.output': 'Output',
+ 'th.cache_read': 'Cache Read',
+ 'th.cache_creation': 'Cache Creation',
+ 'th.est_cost': 'Est. Cost',
+ 'th.sessions': 'Sessions',
+ 'th.cost_na': 'n/a',
+ 'th.duration_min_suffix': 'm',
+
+ 'footer.cost_disclaimer_html': 'Cost estimates based on Anthropic API pricing (
claude.com/pricing#api) as of April 2026. Only models containing
opus,
sonnet, or
haiku in the name are included in cost calculations. Actual costs for Max/Pro subscribers differ from API pricing.',
+ 'footer.github_label': 'GitHub:',
+ 'footer.created_by_label': 'Created by:',
+ 'footer.created_by_name': 'The Product Compass Newsletter',
+ 'footer.license_label': 'License:',
+ 'footer.license_value': 'MIT',
+
+ 'lang_picker.button_tooltip': 'Select language',
+ 'lang_picker.title': 'Language',
+ },
+ ko: {
+ 'header.title': 'Claude Code μ¬μ©λ λμ보λ',
+ 'header.meta_loading': 'λΆλ¬μ€λ μ€...',
+ 'header.meta_updated': 'κ°±μ : {date}',
+ 'header.meta_refresh_note': ' Β· 30μ΄λ§λ€ μλ μλ‘κ³ μΉ¨',
+ 'header.rescan': 'β» λ€μ μ€μΊ',
+ 'header.rescan_tooltip': 'λͺ¨λ JSONL λ‘κ·Έ νμΌμ λ€μ μ½μ΄ λ°μ΄ν°λ² μ΄μ€λ₯Ό μ²μλΆν° μ¬κ΅¬μ±ν©λλ€. λ°μ΄ν°κ° μ€λλκ±°λ λΉμ©μ΄ μ΄μν΄ λ³΄μΌ λ μ¬μ©νμΈμ.',
+ 'header.rescan_scanning': 'β» μ€μΊ μ€...',
+ 'header.rescan_done': 'β» λ€μ μ€μΊ (μ κ· {new}건, κ°±μ {updated}건)',
+ 'header.rescan_error': 'β» λ€μ μ€μΊ (μ€λ₯)',
+
+ 'filter.models': 'λͺ¨λΈ',
+ 'filter.models_tooltip': 'μ§κ³μ ν¬ν¨ν Claude λͺ¨λΈμ μ νν©λλ€. μ νλ λͺ¨λΈλ§ μ°¨νΈμ νμ λ°μλ©λλ€.',
+ 'filter.all': 'μ 체',
+ 'filter.all_tooltip': 'λͺ¨λ λͺ¨λΈ μ ν',
+ 'filter.none': 'μμ',
+ 'filter.none_tooltip': 'λͺ¨λ λͺ¨λΈ μ ν ν΄μ ',
+ 'filter.range': 'κΈ°κ°',
+ 'filter.range_tooltip': 'μ§κ³ κΈ°κ°μ μ νν©λλ€.',
+
+ 'range.week': 'μ΄λ² μ£Ό',
+ 'range.week_tooltip': 'μ΄λ² μ£Ό (μμμΌ μμ)',
+ 'range.month': 'μ΄λ² λ¬',
+ 'range.month_tooltip': 'μ΄λ² λ¬ 1μΌλΆν° μ€λκΉμ§',
+ 'range.prev_month': 'μ λ² λ¬',
+ 'range.prev_month_tooltip': 'μ λ² λ¬ μ 체',
+ 'range.7d': '7μΌ',
+ 'range.7d_tooltip': 'μ΅κ·Ό 7μΌ',
+ 'range.30d': '30μΌ',
+ 'range.30d_tooltip': 'μ΅κ·Ό 30μΌ',
+ 'range.90d': '90μΌ',
+ 'range.90d_tooltip': 'μ΅κ·Ό 90μΌ',
+ 'range.all': 'μ 체',
+ 'range.all_tooltip': 'κΈ°λ‘λ μ 체 κΈ°κ°',
+
+ 'range_label.week': 'μ΄λ² μ£Ό',
+ 'range_label.month': 'μ΄λ² λ¬',
+ 'range_label.prev-month': 'μ λ² λ¬',
+ 'range_label.7d': 'μ΅κ·Ό 7μΌ',
+ 'range_label.30d': 'μ΅κ·Ό 30μΌ',
+ 'range_label.90d': 'μ΅κ·Ό 90μΌ',
+ 'range_label.all': 'μ 체 κΈ°κ°',
+
+ 'stats.sessions.label': 'μΈμ
',
+ 'stats.sessions.tooltip': 'μ νν κΈ°κ°μ μ§νλ λ³κ° μΈμ
μ μμ
λλ€.',
+ 'stats.turns.label': 'ν΄',
+ 'stats.turns.tooltip': 'μ΄μμ€ν΄νΈμ μ΄ ν΄ μμ
λλ€. λꡬ νΈμΆ ν μ¬μ΄ν΄μ΄ ν ν΄μΌλ‘ κ³μ°λ©λλ€.',
+ 'stats.input_tokens.label': 'μ
λ ₯ ν ν°',
+ 'stats.input_tokens.tooltip': 'λͺ¨λΈμ λ³΄λΈ μμ ν둬ννΈ ν ν°μ
λλ€. λ³΄ν΅ μ 체 λΉμ©μμ μ°¨μ§νλ λΉμ€μ μμ΅λλ€.',
+ 'stats.output_tokens.label': 'μΆλ ₯ ν ν°',
+ 'stats.output_tokens.tooltip': 'Claudeκ° μμ±ν ν ν°μ
λλ€. μΌλ°μ μΌλ‘ κ°μ₯ λΉμΌ νλͺ©μ
λλ€.',
+ 'stats.cache_read.label': 'μΊμ μ½κΈ°',
+ 'stats.cache_read.tooltip': 'ν둬ννΈ μΊμμμ κ°μ Έμ¨ ν ν°μ
λλ€. μ κ· μ
λ ₯ λλΉ μ½ 90% μ λ ΄ν©λλ€.',
+ 'stats.cache_read.sub': 'ν둬ννΈ μΊμμμ',
+ 'stats.cache_creation.label': 'μΊμ μμ±',
+ 'stats.cache_creation.tooltip': 'ν둬ννΈ μΊμμ μ μ₯λ ν ν°μ
λλ€. μ
λ ₯λ³΄λ€ 25% λΉμΈμ§λ§ μ΄ν μΊμ μ½κΈ°λ ν¨μ¬ μ λ ΄ν΄μ§λλ€.',
+ 'stats.cache_creation.sub': 'ν둬ννΈ μΊμμ μ μ₯',
+ 'stats.est_cost.label': 'μμ λΉμ©',
+ 'stats.est_cost.tooltip': 'Anthropic API κ°κ²© κΈ°μ€ μμ λΉμ©μ
λλ€(2026λ
4μ κΈ°μ€). Max/Pro ꡬλ
μλ μ μ‘ κ΅¬λ
λ£κ° μ²κ΅¬λλ―λ‘ μ€μ κ²°μ μ‘κ³Ό λ€λ₯Ό μ μμ΅λλ€.',
+ 'stats.est_cost.sub': 'API κ°κ²©, 2026λ
4μ',
+
+ 'chart.daily_title': 'μΌλ³ ν ν° μ¬μ©λ',
+ 'chart.daily_title_with_range': 'μΌλ³ ν ν° μ¬μ©λ β {range}',
+ 'chart.daily_title_tooltip': 'μΌλ³ ν ν° μ¬μ©λμ νλͺ©λ³λ‘ λμ ν μ°¨νΈμ
λλ€. μΊμ ν ν°μ μ’μΈ‘ μΆ, μμ μ
λ ₯/μΆλ ₯μ μ°μΈ‘ μΆμ μ¬μ©ν©λλ€.',
+ 'chart.hourly_title': 'μκ°λλ³ νκ· λΆν¬',
+ 'chart.hourly_title_with_range': 'μκ°λλ³ νκ· λΆν¬ β {range}',
+ 'chart.hourly_title_tooltip': 'μ νν κΈ°κ° λ΄ μκ°λλ³ νκ· ν ν°κ³Ό ν΄ μμ
λλ€.',
+ 'chart.peak_legend': 'νΌν¬ μκ° (PT)',
+ 'chart.peak_legend_tooltip': 'μβκΈ PT 05:00β11:00 β Anthropic νΌν¬ μκ° μ€λ‘νλ§ κ΅¬κ°μ
λλ€.',
+ 'chart.tz_local': 'νμ§',
+ 'chart.tz_utc': 'UTC',
+ 'chart.day_count_singular': '{n}μΌ νκ· Β· {tz}',
+ 'chart.day_count_plural': '{n}μΌ νκ· Β· {tz}',
+ 'chart.day_count_empty': 'λ°μ΄ν° μμ Β· {tz}',
+ 'chart.peak_tooltip_suffix': ' Β· νΌν¬ β Anthropic λ―Έκ΅ μκ°',
+ 'chart.avg_turns_label': 'μκ°λΉ νκ· ν΄',
+ 'chart.avg_output_label': 'μκ°λΉ νκ· μΆλ ₯ ν ν°',
+ 'chart.avg_turns_tooltip': ' νκ· ν΄: {n}',
+ 'chart.avg_output_tooltip': ' νκ· μΆλ ₯: {n}',
+ 'chart.daily.input': 'μ
λ ₯',
+ 'chart.daily.output': 'μΆλ ₯',
+ 'chart.daily.cache_read': 'μΊμ μ½κΈ°',
+ 'chart.daily.cache_creation': 'μΊμ μμ±',
+ 'chart.daily.y_left': 'μΊμ',
+ 'chart.daily.y_right': 'μ
λ ₯ / μΆλ ₯',
+ 'chart.model_title': 'λͺ¨λΈλ³',
+ 'chart.model_title_tooltip': 'μ νν κΈ°κ°μ λͺ¨λΈλ³ ν ν° λΉμ€μ
λλ€.',
+ 'chart.model_tooltip_label': ' {model}: ν ν° {tokens}κ°',
+ 'chart.project_title': 'ν ν° κΈ°μ€ μμ νλ‘μ νΈ',
+ 'chart.project_title_tooltip': 'μ νν κΈ°κ°μ μ΄ ν ν° κΈ°μ€ μμ 10κ° νλ‘μ νΈμ
λλ€.',
+
+ 'table.cost_by_model': 'λͺ¨λΈλ³ λΉμ©',
+ 'table.recent_sessions': 'μ΅κ·Ό μΈμ
',
+ 'table.cost_by_project': 'νλ‘μ νΈλ³ λΉμ©',
+ 'table.cost_by_project_branch': 'νλ‘μ νΈ & λΈλμΉλ³ λΉμ©',
+ 'table.csv_export': 'β€ CSV',
+ 'table.csv_export_sessions_tooltip': 'νν°λ§λ λͺ¨λ μΈμ
μ CSVλ‘ λ΄λ³΄λ
λλ€',
+ 'table.csv_export_projects_tooltip': 'λͺ¨λ νλ‘μ νΈλ₯Ό CSVλ‘ λ΄λ³΄λ
λλ€',
+ 'table.csv_export_project_branch_tooltip': 'νλ‘μ νΈ+λΈλμΉ λΆμμ CSVλ‘ λ΄λ³΄λ
λλ€',
+
+ 'th.session': 'μΈμ
',
+ 'th.project': 'νλ‘μ νΈ',
+ 'th.branch': 'λΈλμΉ',
+ 'th.last_active': 'μ΅κ·Ό νλ',
+ 'th.duration': 'μ§μ μκ°',
+ 'th.model': 'λͺ¨λΈ',
+ 'th.turns': 'ν΄',
+ 'th.input': 'μ
λ ₯',
+ 'th.output': 'μΆλ ₯',
+ 'th.cache_read': 'μΊμ μ½κΈ°',
+ 'th.cache_creation': 'μΊμ μμ±',
+ 'th.est_cost': 'μμ λΉμ©',
+ 'th.sessions': 'μΈμ
',
+ 'th.cost_na': 'ν΄λΉ μμ',
+ 'th.duration_min_suffix': 'λΆ',
+
+ 'footer.cost_disclaimer_html': 'λΉμ© μΆμ μΉλ 2026λ
4μ κΈ°μ€ Anthropic API κ°κ²©(
claude.com/pricing#api)μ μ¬μ©ν©λλ€. λͺ¨λΈλͺ
μ
opus,
sonnet,
haikuκ° ν¬ν¨λ λͺ¨λΈλ§ λΉμ© κ³μ°μ λ°μλ©λλ€. Max/Pro ꡬλ
μμ μ€μ λΉμ©μ API κ°κ²©κ³Ό λ€λ¦
λλ€.',
+ 'footer.github_label': 'GitHub:',
+ 'footer.created_by_label': 'μ μ:',
+ 'footer.created_by_name': 'The Product Compass Newsletter',
+ 'footer.license_label': 'λΌμ΄μ μ€:',
+ 'footer.license_value': 'MIT',
+
+ 'lang_picker.button_tooltip': 'μΈμ΄ μ ν',
+ 'lang_picker.title': 'μΈμ΄',
+ },
+};
+
+// Display name shown in the language picker for each locale.
+// To add a new language, append a new entry here and a matching block in MESSAGES.
+const LOCALES = {
+ en: 'English',
+ ko: 'νκ΅μ΄',
+};
+
+const LANG_STORAGE_KEY = 'claudeUsageLang';
+const DEFAULT_LANG = 'en';
+let currentLang = DEFAULT_LANG;
+
+function getInitialLang() {
+ try {
+ const url = new URL(window.location.href);
+ const fromUrl = url.searchParams.get('lang');
+ if (fromUrl && LOCALES[fromUrl]) return fromUrl;
+ } catch(e) {}
+ try {
+ const stored = localStorage.getItem(LANG_STORAGE_KEY);
+ if (stored && LOCALES[stored]) return stored;
+ } catch(e) {}
+ try {
+ const navLangs = navigator.languages || [navigator.language];
+ for (const raw of navLangs) {
+ if (!raw) continue;
+ const short = String(raw).toLowerCase().split('-')[0];
+ if (LOCALES[short]) return short;
+ }
+ } catch(e) {}
+ return DEFAULT_LANG;
+}
+
+function tr(key, vars) {
+ const dict = MESSAGES[currentLang] || MESSAGES[DEFAULT_LANG];
+ let msg = dict[key];
+ if (msg === undefined) {
+ if (currentLang !== DEFAULT_LANG) {
+ console.warn('[i18n] missing key for ' + currentLang + ': ' + key);
+ }
+ msg = MESSAGES[DEFAULT_LANG][key];
+ }
+ if (msg === undefined) {
+ console.warn('[i18n] unknown key: ' + key);
+ return key;
+ }
+ if (vars) {
+ return msg.replace(/\{(\w+)\}/g, (_, name) =>
+ vars[name] !== undefined ? String(vars[name]) : '{' + name + '}'
+ );
+ }
+ return msg;
+}
+
+// Apply translations to all elements with data-i18n / data-i18n-title attributes.
+// data-i18n="key" β sets textContent
+// data-i18n-html="key" β sets innerHTML (use only for trusted message bundles)
+// data-i18n-title="key" β sets the title attribute (hover tooltip)
+function applyTranslations() {
+ document.documentElement.lang = currentLang;
+ document.title = tr('header.title');
+ document.querySelectorAll('[data-i18n]').forEach(el => {
+ el.textContent = tr(el.getAttribute('data-i18n'));
+ });
+ document.querySelectorAll('[data-i18n-html]').forEach(el => {
+ el.innerHTML = tr(el.getAttribute('data-i18n-html'));
+ });
+ document.querySelectorAll('[data-i18n-title]').forEach(el => {
+ el.setAttribute('title', tr(el.getAttribute('data-i18n-title')));
+ });
+}
+
+function setLang(lang) {
+ if (!LOCALES[lang]) return;
+ currentLang = lang;
+ try { localStorage.setItem(LANG_STORAGE_KEY, lang); } catch(e) {}
+ try {
+ const url = new URL(window.location.href);
+ url.searchParams.set('lang', lang);
+ history.replaceState(null, '', url.toString());
+ } catch(e) {}
+ applyTranslations();
+ if (typeof rerenderAfterLangChange === 'function') rerenderAfterLangChange();
+}
+
+// Re-render every JS-driven surface after a language change.
+// Static markup is handled by applyTranslations(); this covers the chart
+// titles, stat cards, chart datasets, hourly day-count text, etc.
+function rerenderAfterLangChange() {
+ updateLangButton();
+ buildLangMenu();
+ if (rawData) applyFilter();
+}
+
+// ββ Language picker UI ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function updateLangButton() {
+ const labelEl = document.getElementById('lang-btn-label');
+ if (labelEl) labelEl.textContent = LOCALES[currentLang] || currentLang;
+}
+
+function buildLangMenu() {
+ const container = document.getElementById('lang-menu-items');
+ if (!container) return;
+ const codes = Object.keys(LOCALES);
+ container.innerHTML = codes.map(code => {
+ const active = code === currentLang ? ' active' : '';
+ const aria = code === currentLang ? ' aria-current="true"' : '';
+ return '
';
+ }).join('');
+}
+
+function setLangMenuOpen(open) {
+ const menu = document.getElementById('lang-menu');
+ const btn = document.getElementById('lang-btn');
+ if (!menu || !btn) return;
+ menu.classList.toggle('open', open);
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+}
+
+function toggleLangMenu(e) {
+ if (e) { e.stopPropagation(); }
+ const menu = document.getElementById('lang-menu');
+ if (!menu) return;
+ setLangMenuOpen(!menu.classList.contains('open'));
+}
+
+function onLangSelect(code) {
+ setLangMenuOpen(false);
+ setLang(code);
+}
+
+document.addEventListener('click', (e) => {
+ const picker = e.target.closest('.lang-picker');
+ if (!picker) setLangMenuOpen(false);
+});
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') setLangMenuOpen(false);
+});
+
+currentLang = getInitialLang();
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let rawData = null;
let selectedModels = new Set();
@@ -409,11 +838,11 @@ def get_dashboard_data(db_path=DB_PATH):
}
function tzDisplayName(tzMode) {
- if (tzMode === 'utc') return 'UTC';
+ if (tzMode === 'utc') return tr('chart.tz_utc');
try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local';
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || tr('chart.tz_local');
} catch(e) {
- return 'Local';
+ return tr('chart.tz_local');
}
}
@@ -481,9 +910,12 @@ def get_dashboard_data(db_path=DB_PATH):
const MODEL_COLORS = ['#d97757','#4f8ef7','#4ade80','#a78bfa','#fbbf24','#f472b6','#34d399','#60a5fa'];
// ββ Time range βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-const RANGE_LABELS = { 'week': 'This Week', 'month': 'This Month', 'prev-month': 'Previous Month', '7d': 'Last 7 Days', '30d': 'Last 30 Days', '90d': 'Last 90 Days', 'all': 'All Time' };
+// Range identifiers. Display labels live in MESSAGES under 'range_label.*'
+// (e.g. tr('range_label.7d')); they are looked up at render time so the
+// language picker can swap them without re-creating the dashboard.
+const VALID_RANGES = ['week', 'month', 'prev-month', '7d', '30d', '90d', 'all'];
const RANGE_TICKS = { 'week': 7, 'month': 15, 'prev-month': 15, '7d': 7, '30d': 15, '90d': 13, 'all': 12 };
-const VALID_RANGES = Object.keys(RANGE_LABELS);
+function rangeLabel(range) { return tr('range_label.' + range); }
function rangeIncludesToday(range) {
if (range === 'all') return true;
@@ -747,8 +1179,8 @@ def get_dashboard_data(db_path=DB_PATH):
const hourlyAgg = aggregateHourly(hourlySrc, hourlyTZ);
// Update daily chart title
- document.getElementById('daily-chart-title').textContent = 'Daily Token Usage \u2014 ' + RANGE_LABELS[selectedRange];
- document.getElementById('hourly-chart-title').textContent = 'Average Hourly Distribution \u2014 ' + RANGE_LABELS[selectedRange];
+ document.getElementById('daily-chart-title').textContent = tr('chart.daily_title_with_range', { range: rangeLabel(selectedRange) });
+ document.getElementById('hourly-chart-title').textContent = tr('chart.hourly_title_with_range', { range: rangeLabel(selectedRange) });
renderStats(totals);
renderDailyChart(daily);
@@ -766,19 +1198,19 @@ def get_dashboard_data(db_path=DB_PATH):
// ββ Renderers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function renderStats(t) {
- const rangeLabel = RANGE_LABELS[selectedRange].toLowerCase();
+ const rangeSub = rangeLabel(selectedRange).toLowerCase();
const stats = [
- { label: 'Sessions', value: t.sessions.toLocaleString(), sub: rangeLabel },
- { label: 'Turns', value: fmt(t.turns), sub: rangeLabel },
- { label: 'Input Tokens', value: fmt(t.input), sub: rangeLabel },
- { label: 'Output Tokens', value: fmt(t.output), sub: rangeLabel },
- { label: 'Cache Read', value: fmt(t.cache_read), sub: 'from prompt cache' },
- { label: 'Cache Creation', value: fmt(t.cache_creation), sub: 'writes to prompt cache' },
- { label: 'Est. Cost', value: fmtCostBig(t.cost), sub: 'API pricing, Apr 2026', color: '#4ade80' },
+ { key: 'sessions', label: tr('stats.sessions.label'), tooltip: tr('stats.sessions.tooltip'), value: t.sessions.toLocaleString(), sub: rangeSub },
+ { key: 'turns', label: tr('stats.turns.label'), tooltip: tr('stats.turns.tooltip'), value: fmt(t.turns), sub: rangeSub },
+ { key: 'input_tokens', label: tr('stats.input_tokens.label'), tooltip: tr('stats.input_tokens.tooltip'), value: fmt(t.input), sub: rangeSub },
+ { key: 'output_tokens', label: tr('stats.output_tokens.label'), tooltip: tr('stats.output_tokens.tooltip'), value: fmt(t.output), sub: rangeSub },
+ { key: 'cache_read', label: tr('stats.cache_read.label'), tooltip: tr('stats.cache_read.tooltip'), value: fmt(t.cache_read), sub: tr('stats.cache_read.sub') },
+ { key: 'cache_creation', label: tr('stats.cache_creation.label'), tooltip: tr('stats.cache_creation.tooltip'), value: fmt(t.cache_creation), sub: tr('stats.cache_creation.sub') },
+ { key: 'est_cost', label: tr('stats.est_cost.label'), tooltip: tr('stats.est_cost.tooltip'), value: fmtCostBig(t.cost), sub: tr('stats.est_cost.sub'), color: '#4ade80' },
];
document.getElementById('stats-row').innerHTML = stats.map(s => `
-
-
${s.label}
+
+
${esc(s.label)}
${esc(s.value)}
${s.sub ? `
${esc(s.sub)}
` : ''}
@@ -813,9 +1245,12 @@ def get_dashboard_data(db_path=DB_PATH):
function renderHourlyChart(agg) {
const dayCountEl = document.getElementById('hourly-day-count');
- dayCountEl.textContent = agg.dayCount
- ? agg.dayCount + ' day' + (agg.dayCount === 1 ? '' : 's') + ' averaged Β· ' + tzDisplayName(hourlyTZ)
- : 'No data Β· ' + tzDisplayName(hourlyTZ);
+ if (!agg.dayCount) {
+ dayCountEl.textContent = tr('chart.day_count_empty', { tz: tzDisplayName(hourlyTZ) });
+ } else {
+ const key = agg.dayCount === 1 ? 'chart.day_count_singular' : 'chart.day_count_plural';
+ dayCountEl.textContent = tr(key, { n: agg.dayCount, tz: tzDisplayName(hourlyTZ) });
+ }
const ctx = document.getElementById('chart-hourly').getContext('2d');
if (charts.hourly) charts.hourly.destroy();
@@ -825,13 +1260,16 @@ def get_dashboard_data(db_path=DB_PATH):
const output = agg.hours.map(h => h.avgOutput);
const barColors = agg.hours.map(h => h.peak ? 'rgba(248,113,113,0.8)' : TOKEN_COLORS.input);
+ const turnsLabel = tr('chart.avg_turns_label');
+ const outputLabel = tr('chart.avg_output_label');
+
charts.hourly = new Chart(ctx, {
data: {
labels: labels,
datasets: [
{
type: 'bar',
- label: 'Avg turns / hour',
+ label: turnsLabel,
data: turns,
backgroundColor: barColors,
yAxisID: 'y',
@@ -839,7 +1277,7 @@ def get_dashboard_data(db_path=DB_PATH):
},
{
type: 'line',
- label: 'Avg output tokens / hour',
+ label: outputLabel,
data: output,
borderColor: TOKEN_COLORS.output,
backgroundColor: 'rgba(167,139,250,0.15)',
@@ -863,21 +1301,23 @@ def get_dashboard_data(db_path=DB_PATH):
const idx = items[0].dataIndex;
const h = agg.hours[idx];
const base = formatHourLabel(h.hour) + ' ' + tzDisplayName(hourlyTZ);
- return h.peak ? base + ' Β· Peak β Anthropic US hours' : base;
+ return h.peak ? base + tr('chart.peak_tooltip_suffix') : base;
},
+ // Dataset 0 is the turns bar, dataset 1 is the output line.
+ // Index-based dispatch keeps tooltip output stable across locales.
label: (item) => {
- if (item.dataset.label && item.dataset.label.indexOf('turns') !== -1) {
- return ' Avg turns: ' + item.parsed.y.toFixed(2);
+ if (item.datasetIndex === 0) {
+ return tr('chart.avg_turns_tooltip', { n: item.parsed.y.toFixed(2) });
}
- return ' Avg output: ' + fmt(item.parsed.y);
+ return tr('chart.avg_output_tooltip', { n: fmt(item.parsed.y) });
},
}
},
},
scales: {
x: { ticks: { color: '#8892a4', maxRotation: 0, autoSkip: false, font: { size: 10 } }, grid: { color: '#2a2d3a' } },
- y: { position: 'left', beginAtZero: true, ticks: { color: '#8892a4', callback: v => v.toFixed(1) }, grid: { color: '#2a2d3a' }, title: { display: true, text: 'Avg turns / hour', color: '#8892a4', font: { size: 11 } } },
- y1: { position: 'right', beginAtZero: true, ticks: { color: '#8892a4', callback: v => fmt(v) }, grid: { drawOnChartArea: false }, title: { display: true, text: 'Avg output tokens / hour', color: '#8892a4', font: { size: 11 } } },
+ y: { position: 'left', beginAtZero: true, ticks: { color: '#8892a4', callback: v => v.toFixed(1) }, grid: { color: '#2a2d3a' }, title: { display: true, text: turnsLabel, color: '#8892a4', font: { size: 11 } } },
+ y1: { position: 'right', beginAtZero: true, ticks: { color: '#8892a4', callback: v => fmt(v) }, grid: { drawOnChartArea: false }, title: { display: true, text: outputLabel, color: '#8892a4', font: { size: 11 } } },
}
}
});
@@ -891,10 +1331,10 @@ def get_dashboard_data(db_path=DB_PATH):
data: {
labels: daily.map(d => d.day),
datasets: [
- { label: 'Input', data: daily.map(d => d.input), backgroundColor: TOKEN_COLORS.input, stack: 'io', yAxisID: 'y1' },
- { label: 'Output', data: daily.map(d => d.output), backgroundColor: TOKEN_COLORS.output, stack: 'io', yAxisID: 'y1' },
- { label: 'Cache Read', data: daily.map(d => d.cache_read), backgroundColor: TOKEN_COLORS.cache_read, stack: 'cache', yAxisID: 'y' },
- { label: 'Cache Creation', data: daily.map(d => d.cache_creation), backgroundColor: TOKEN_COLORS.cache_creation, stack: 'cache', yAxisID: 'y' },
+ { label: tr('chart.daily.input'), data: daily.map(d => d.input), backgroundColor: TOKEN_COLORS.input, stack: 'io', yAxisID: 'y1' },
+ { label: tr('chart.daily.output'), data: daily.map(d => d.output), backgroundColor: TOKEN_COLORS.output, stack: 'io', yAxisID: 'y1' },
+ { label: tr('chart.daily.cache_read'), data: daily.map(d => d.cache_read), backgroundColor: TOKEN_COLORS.cache_read, stack: 'cache', yAxisID: 'y' },
+ { label: tr('chart.daily.cache_creation'), data: daily.map(d => d.cache_creation), backgroundColor: TOKEN_COLORS.cache_creation, stack: 'cache', yAxisID: 'y' },
]
},
options: {
@@ -902,8 +1342,8 @@ def get_dashboard_data(db_path=DB_PATH):
plugins: { legend: { labels: { color: '#8892a4', boxWidth: 12 } } },
scales: {
x: { ticks: { color: '#8892a4', maxTicksLimit: RANGE_TICKS[selectedRange] }, grid: { color: '#2a2d3a' } },
- y: { position: 'left', ticks: { color: '#74de80', callback: v => fmt(v) }, grid: { color: '#2a2d3a' }, title: { display: true, text: 'Cache', color: '#74de80' } },
- y1: { position: 'right', ticks: { color: '#4f8ef7', callback: v => fmt(v) }, grid: { drawOnChartArea: false }, title: { display: true, text: 'Input / Output', color: '#4f8ef7' } },
+ y: { position: 'left', ticks: { color: '#74de80', callback: v => fmt(v) }, grid: { color: '#2a2d3a' }, title: { display: true, text: tr('chart.daily.y_left'), color: '#74de80' } },
+ y1: { position: 'right', ticks: { color: '#4f8ef7', callback: v => fmt(v) }, grid: { drawOnChartArea: false }, title: { display: true, text: tr('chart.daily.y_right'), color: '#4f8ef7' } },
}
}
});
@@ -923,7 +1363,7 @@ def get_dashboard_data(db_path=DB_PATH):
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#8892a4', boxWidth: 12, font: { size: 11 } } },
- tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${fmt(ctx.raw)} tokens` } }
+ tooltip: { callbacks: { label: ctx => tr('chart.model_tooltip_label', { model: ctx.label, tokens: fmt(ctx.raw) }) } }
}
}
});
@@ -939,8 +1379,8 @@ def get_dashboard_data(db_path=DB_PATH):
data: {
labels: top.map(p => p.project.length > 22 ? '\u2026' + p.project.slice(-20) : p.project),
datasets: [
- { label: 'Input', data: top.map(p => p.input), backgroundColor: TOKEN_COLORS.input },
- { label: 'Output', data: top.map(p => p.output), backgroundColor: TOKEN_COLORS.output },
+ { label: tr('chart.daily.input'), data: top.map(p => p.input), backgroundColor: TOKEN_COLORS.input },
+ { label: tr('chart.daily.output'), data: top.map(p => p.output), backgroundColor: TOKEN_COLORS.output },
]
},
options: {
@@ -955,16 +1395,18 @@ def get_dashboard_data(db_path=DB_PATH):
}
function renderSessionsTable(sessions) {
+ const naLabel = tr('th.cost_na');
+ const minSuffix = tr('th.duration_min_suffix');
document.getElementById('sessions-body').innerHTML = sessions.map(s => {
const cost = calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation);
const costCell = isBillable(s.model)
? `
${fmtCost(cost)} | `
- : `
n/a | `;
+ : `
${esc(naLabel)} | `;
return `
| ${esc(s.session_id)}… |
${esc(s.project)} |
${esc(s.last)} |
- ${esc(s.duration_min)}m |
+ ${esc(s.duration_min)}${esc(minSuffix)} |
${esc(s.model)} |
${s.turns} |
${fmt(s.input)} |
@@ -1169,17 +1611,17 @@ def get_dashboard_data(db_path=DB_PATH):
async function triggerRescan() {
const btn = document.getElementById('rescan-btn');
btn.disabled = true;
- btn.textContent = '\u21bb Scanning...';
+ btn.textContent = tr('header.rescan_scanning');
try {
const resp = await fetch('/api/rescan', { method: 'POST' });
const d = await resp.json();
- btn.textContent = '\u21bb Rescan (' + d.new + ' new, ' + d.updated + ' updated)';
+ btn.textContent = tr('header.rescan_done', { new: d.new, updated: d.updated });
await loadData();
} catch(e) {
- btn.textContent = '\u21bb Rescan (error)';
+ btn.textContent = tr('header.rescan_error');
console.error(e);
}
- setTimeout(() => { btn.textContent = '\u21bb Rescan'; btn.disabled = false; }, 3000);
+ setTimeout(() => { btn.textContent = tr('header.rescan'); btn.disabled = false; }, 3000);
}
// ββ Data loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -1191,8 +1633,8 @@ def get_dashboard_data(db_path=DB_PATH):
document.body.innerHTML = '' + esc(d.error) + '
';
return;
}
- const refreshNote = rangeIncludesToday(selectedRange) ? ' \u00b7 Auto-refresh in 30s' : '';
- document.getElementById('meta').textContent = 'Updated: ' + d.generated_at + refreshNote;
+ const refreshNote = rangeIncludesToday(selectedRange) ? tr('header.meta_refresh_note') : '';
+ document.getElementById('meta').textContent = tr('header.meta_updated', { date: d.generated_at }) + refreshNote;
const isFirstLoad = rawData === null;
rawData = d;
@@ -1229,6 +1671,9 @@ def get_dashboard_data(db_path=DB_PATH):
}
}
+applyTranslations();
+updateLangButton();
+buildLangMenu();
loadData();
scheduleAutoRefresh();
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index 76287d1..7abff93 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -264,5 +264,83 @@ def test_prices_match(self):
)
+class TestI18n(unittest.TestCase):
+ """Sanity checks on the embedded MESSAGES catalog and LOCALES registry."""
+
+ @staticmethod
+ def _extract_locale_blocks():
+ """Return {locale_code: {key: value}} parsed out of HTML_TEMPLATE.
+
+ Parses the JS literal `const MESSAGES = { en: {...}, ko: {...}, };`.
+ Only handles single-quoted string keys/values, which is the
+ convention used in dashboard.py.
+ """
+ import re
+ msg_match = re.search(
+ r"const MESSAGES = \{(.*?)\n\};", HTML_TEMPLATE, re.DOTALL
+ )
+ if not msg_match:
+ raise AssertionError("MESSAGES literal not found in HTML_TEMPLATE")
+ body = msg_match.group(1)
+ locale_starts = [
+ (m.group(1), m.start())
+ for m in re.finditer(r"\n ([a-zA-Z][a-zA-Z0-9_-]*): \{", body)
+ ]
+ locales = {}
+ for i, (code, start) in enumerate(locale_starts):
+ end = locale_starts[i + 1][1] if i + 1 < len(locale_starts) else len(body)
+ block = body[start:end]
+ entries = dict(re.findall(r"'([^']+?)':\s*'((?:[^'\\]|\\.)*)'", block))
+ locales[code] = entries
+ return locales
+
+ @staticmethod
+ def _extract_locales_registry():
+ import re
+ m = re.search(r"const LOCALES = \{(.*?)\};", HTML_TEMPLATE, re.DOTALL)
+ if not m:
+ raise AssertionError("LOCALES literal not found in HTML_TEMPLATE")
+ return set(re.findall(r"\n ([a-zA-Z][a-zA-Z0-9_-]*):", m.group(1)))
+
+ def test_messages_contains_default_english(self):
+ locales = self._extract_locale_blocks()
+ self.assertIn("en", locales, "English (en) catalog missing")
+ self.assertGreater(len(locales["en"]), 0, "English catalog is empty")
+
+ def test_every_locale_has_same_keys_as_english(self):
+ locales = self._extract_locale_blocks()
+ en_keys = set(locales["en"].keys())
+ for code, entries in locales.items():
+ if code == "en":
+ continue
+ keys = set(entries.keys())
+ missing = en_keys - keys
+ extra = keys - en_keys
+ self.assertFalse(
+ missing,
+ f"Locale '{code}' is missing keys: {sorted(missing)}"
+ )
+ self.assertFalse(
+ extra,
+ f"Locale '{code}' has unknown keys: {sorted(extra)}"
+ )
+
+ def test_locales_registry_matches_messages(self):
+ registry = self._extract_locales_registry()
+ catalog = set(self._extract_locale_blocks().keys())
+ self.assertEqual(
+ registry, catalog,
+ "LOCALES picker entries and MESSAGES catalog must match exactly"
+ )
+
+ def test_no_translation_value_is_empty(self):
+ for code, entries in self._extract_locale_blocks().items():
+ for key, value in entries.items():
+ self.assertNotEqual(
+ value, "",
+ f"Locale '{code}' has empty value for key '{key}'"
+ )
+
+
if __name__ == "__main__":
unittest.main()