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):
-

Claude Code Usage Dashboard

-
Loading...
- +

Claude Code Usage Dashboard

+
Loading...
+
+
+ + +
+ +
-
Models
+
Models
- - + +
-
Range
+
Range
- - - - - - - + + + + + + +
@@ -249,89 +276,89 @@ def get_dashboard_data(db_path=DB_PATH):
-

Daily Token Usage

+

Daily Token Usage

-

Average Hourly Distribution

+

Average Hourly Distribution

- Peak hours (PT) + Peak hours (PT)
- - + +
-

By Model

+

By Model

-

Top Projects by Tokens

+

Top Projects by Tokens

-
Cost by Model
+
Cost by Model
- - - - - - - + + + + + + +
ModelTurns Input Output Cache Read Cache Creation Est. Cost ModelTurns Input Output Cache Read Cache Creation Est. Cost
-
Recent Sessions
+
Recent Sessions
- - - - - - - - - + + + + + + + + +
SessionProjectLast Active Duration ModelTurns Input Output Est. Cost SessionProjectLast Active Duration ModelTurns Input Output Est. Cost
-
Cost by Project
+
Cost by Project
- - - - - - + + + + + +
ProjectSessions Turns Input Output Est. Cost ProjectSessions Turns Input Output Est. Cost
-
Cost by Project & Branch
+
Cost by Project & Branch
- - - - - - - + + + + + + +
ProjectBranchSessions Turns Input Output Est. Cost ProjectBranchSessions 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()