From aae15618093a88273cc012fb11464ddc1a42eb7f Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 17:46:15 +0900 Subject: [PATCH 1/7] feat(i18n): add translation infrastructure (no UI change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundation for dashboard internationalization without altering any rendered output: - MESSAGES catalog keyed by locale, with English (the source of truth) pre-populated for every user-facing string in the dashboard. - LOCALES registry that drives the language picker (added in a follow-up commit). Single entry for now: 'en'. - tr(key, vars) helper with {placeholder} interpolation and English fallback + console.warn for missing keys. - applyTranslations() walks elements with data-i18n / data-i18n-html / data-i18n-title attributes — the migration of HTML and JS render paths to use these attributes is split across follow-up commits to keep diffs readable. - Language resolution order: ?lang= URL param > localStorage > matching prefix of navigator.languages > 'en'. setLang() persists the choice to both URL and localStorage. No call sites use tr() yet, so the dashboard renders identically to before. Subsequent commits add the picker UI and migrate text. --- dashboard.py | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/dashboard.py b/dashboard.py index ebf8d5f..94e3623 100644 --- a/dashboard.py +++ b/dashboard.py @@ -359,6 +359,224 @@ 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', + }, +}; + +// 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', +}; + +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(); +} + +currentLang = getInitialLang(); + // ── State ────────────────────────────────────────────────────────────────── let rawData = null; let selectedModels = new Set(); From b8b463d54f2ebd77e184a12d032fb633ded1cd3a Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 17:49:57 +0900 Subject: [PATCH 2/7] feat(i18n): mark static HTML with data-i18n attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates every static label in the rendered — header, filter bar, range buttons, chart card titles, peak-hour legend, TZ buttons, table section titles, table headers, CSV export buttons, and footer — to use data-i18n / data-i18n-html / data-i18n-title attributes that applyTranslations() resolves on load. Sortable cells wrap their label in so the adjacent sort-icon is preserved (textContent on the would otherwise wipe the icon when re-translating). The footer disclaimer is migrated as data-i18n-html so the inline and tags inside the message survive translation. applyTranslations() is invoked once before loadData() at startup. With only English in the catalog the rendered output is identical to before; adding more locales is now purely a data change. --- dashboard.py | 117 ++++++++++++++++++++++++++------------------------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/dashboard.py b/dashboard.py index 94e3623..7ac8952 100644 --- a/dashboard.py +++ b/dashboard.py @@ -222,26 +222,26 @@ def get_dashboard_data(db_path=DB_PATH):
-

Claude Code Usage Dashboard

-
Loading...
- +

Claude Code Usage Dashboard

+
Loading...
+
-
Models
+
Models
- - + +
-
Range
+
Range
- - - - - - - + + + + + + +
@@ -249,89 +249,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 +340,13 @@ def get_dashboard_data(db_path=DB_PATH):
@@ -1447,6 +1447,7 @@ def get_dashboard_data(db_path=DB_PATH): } } +applyTranslations(); loadData(); scheduleAutoRefresh(); From 114a442252a8d740e0670a2fca865ead7aed6464 Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 17:55:18 +0900 Subject: [PATCH 3/7] feat(i18n): route dynamic JS-rendered text through tr() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces hard-coded English strings in render paths and helpers with tr() lookups, so language changes propagate to every JS-driven surface: - RANGE_LABELS constant is replaced by rangeLabel(range) helper that reads from the catalog (tr('range_label.')) at call time. Chart titles, the stat-card 'sub' line, and any future range display now swap with the language picker without rebuilding state. - tzDisplayName() falls back to tr('chart.tz_local')/'chart.tz_utc'. - renderStats() looks up label, sub, and tooltip via tr(). The new tooltip is exposed as the stat-card's title attribute, giving every metric an explanatory hover (Output Tokens, Cache Read, etc.). - renderHourlyChart() day-count text uses singular/plural keys with {n}/{tz} placeholders. Tooltip dispatch switches from substring matching on the dataset label ('turns') to datasetIndex, which is stable across locales. - renderDailyChart(), renderModelChart(), renderProjectChart() use tr() for dataset labels, axis titles, and tooltip callbacks. - renderSessionsTable() reads the 'n/a' label and the duration minute suffix from the catalog. - triggerRescan() and the meta line in loadData() use tr() with {new}/{updated}/{date} placeholders. Adds rerenderAfterLangChange() which re-runs applyFilter() so the charts and tables pick up the new language. setLang() already calls it. CSV export headers are intentionally left in English: exports are data-extraction targets where stable, machine-friendly column names matter more than display localization. With only English in the catalog, output is byte-for-byte identical to before — no regression. Behavior changes are unlocked in later commits that add Korean strings and the picker UI. --- dashboard.py | 110 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/dashboard.py b/dashboard.py index 7ac8952..eb08f88 100644 --- a/dashboard.py +++ b/dashboard.py @@ -575,6 +575,13 @@ def get_dashboard_data(db_path=DB_PATH): 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() { + if (rawData) applyFilter(); +} + currentLang = getInitialLang(); // ── State ────────────────────────────────────────────────────────────────── @@ -627,11 +634,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'); } } @@ -699,9 +706,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; @@ -965,8 +975,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); @@ -984,19 +994,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)}
` : ''}
@@ -1031,9 +1041,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(); @@ -1043,13 +1056,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', @@ -1057,7 +1073,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)', @@ -1081,21 +1097,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 } } }, } } }); @@ -1109,10 +1127,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: { @@ -1120,8 +1138,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' } }, } } }); @@ -1141,7 +1159,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) }) } } } } }); @@ -1157,8 +1175,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: { @@ -1173,16 +1191,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)} @@ -1387,17 +1407,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 ─────────────────────────────────────────────────────────── @@ -1409,8 +1429,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; From 494d475a2703059f0d4a929e4ce0182c28fbd926 Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 18:01:35 +0900 Subject: [PATCH 4/7] feat(i18n): add language picker dropdown in header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 🌐 button next to Rescan that opens a small dropdown listing every locale registered in LOCALES. Selecting an entry calls setLang(), which persists the choice (URL + localStorage), re-applies static translations, and re-runs applyFilter() so JS-rendered surfaces (chart titles, datasets, stat cards, hourly day-count) also pick up the new language without a page reload. UI details: - Header right side wraps the picker and Rescan in a .header-actions flex container so the layout stays clean. - Menu is a positioned panel using existing dark-theme tokens (--card / --border / --accent), with a caret on the trigger and a check mark on the active locale. - Closes on outside click or Escape; aria-expanded / aria-current / role=menu attributes wired up for keyboard + screen-reader users. Only English is registered today; the next commit ships Korean and proves the picker works end-to-end. --- dashboard.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/dashboard.py b/dashboard.py index eb08f88..6fc4eb6 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; } @@ -224,7 +238,20 @@ def get_dashboard_data(db_path=DB_PATH):

Claude Code Usage Dashboard

Loading...
- +
+
+ + +
+ +
@@ -579,9 +606,59 @@ def get_dashboard_data(db_path=DB_PATH): // 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 ────────────────────────────────────────────────────────────────── @@ -1468,6 +1545,8 @@ def get_dashboard_data(db_path=DB_PATH): } applyTranslations(); +updateLangButton(); +buildLangMenu(); loadData(); scheduleAutoRefresh(); From efb7087620d1e3ed21d868e4c1164e95f21c63fa Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 18:06:49 +0900 Subject: [PATCH 5/7] feat(i18n): add Korean translations and register ko locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates all 115 catalog keys into Korean and registers 'ko' ('한국어') in LOCALES. The full set covers: - Header, meta line, rescan states (including the {new}/{updated} rescan summary). - Filter bar (Models, Range), every range pill (이번 주, 7일, 30일, …) and the longer chart-title forms (range_label.*). - All seven stat-card metrics with explanatory tooltips that explain what the user is actually looking at (예: '입력 토큰' / '캐시 읽기' / '캐시 생성' includes pricing context — 90% cheaper, 25% premium, etc). - Daily / hourly / model / project chart titles, dataset labels, axis titles, and tooltip callbacks (singular/plural day-count variants share an identical Korean form). - All four data tables: section titles, every column, the 'n/a' cell, the duration-minute suffix, and CSV-export tooltips. - Footer disclaimer (HTML, with embedded / preserved). - Language picker labels. Translation tone targets developers/analysts: technical loanwords kept as-is (토큰, 캐시, 세션, API), descriptive copy translated naturally. en and ko key sets are identical (115 keys each). --- dashboard.py | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/dashboard.py b/dashboard.py index 6fc4eb6..56d69b2 100644 --- a/dashboard.py +++ b/dashboard.py @@ -517,12 +517,139 @@ def get_dashboard_data(db_path=DB_PATH): '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'; From c8cff4eda7e5693689c0992f065687c5f1e400e7 Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 18:09:12 +0900 Subject: [PATCH 6/7] test(i18n): assert MESSAGES/LOCALES are complete and consistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four checks under TestI18n that catch the most common ways i18n breaks over time as keys are added or locales are appended: - English (the source of truth) must exist and be non-empty. - Every non-English locale must contain exactly the same key set as English — missing keys (regression: forgot to translate something) and unknown keys (regression: stale keys lingering after a rename) both fail the build with a precise diff. - The LOCALES picker registry must match the catalog: registering 'fr' in LOCALES without adding the catalog (or vice versa) fails here rather than at runtime in the user's browser. - No translation value may be the empty string. Tests parse the JS literals directly out of HTML_TEMPLATE so they need no JS runtime — they run on the same Python-stdlib-only stack as the rest of the suite. --- tests/test_dashboard.py | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) 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() From dc9986642000c20d488e7253c2d22f9676ab1089 Mon Sep 17 00:00:00 2001 From: laybacksound96 Date: Thu, 30 Apr 2026 18:09:55 +0900 Subject: [PATCH 7/7] docs(i18n): document language support and contribution flow README gains an Internationalization section that: - explains the picker, URL/localStorage persistence, and first-visit navigator.languages detection; - lists the bundled locales (en, ko); - describes the new hover tooltips on metrics, chart titles, and column headers; - gives a three-step recipe for adding a new locale, pointing contributors at the TestI18n suite as the gate that catches missing or stale keys before review. CHANGELOG entry summarizes the user-visible additions. --- CHANGELOG.md | 7 +++++++ README.md | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) 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)).