From 581647f6d563d82d904e0848e16da8a104b395fd Mon Sep 17 00:00:00 2001 From: Its My Work Date: Sat, 12 Sep 2026 20:49:02 +0000 Subject: [PATCH 1/2] fix(dashboard): wire up i18n for the analytics dashboard public/dashboard.html shipped with zero i18n integration: every label, section title, empty-state message, gauge caption and toast was a hardcoded English literal, while index.html, kanban.html and schedule.html all support en/uk/ru/fr/he. A user who set the interface language to Russian saw the whole Dashboard tab still in English. Give the page the same TR/t()/applyI18nAttrs()/adoptServerLang() contract as kanban.html and schedule.html: read the persisted `lang`, adopt the server's /api/config lang like the sibling pages do, and route every user-facing string (nav, card titles, hero stats, gauge captions, empty states, tooltips) through t(). Month abbreviations and session dates now format via Intl/toLocaleDateString using the active locale instead of a hardcoded 'uk-UA'. Also extend test/i18n-completeness.test.js to include dashboard.html in the key-parity, placeholder-parity and hardcoded-string scans that already cover kanban.html and schedule.html, so this can't silently regress. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011E2bJQ2sL9LgEWosjvydTR --- public/dashboard.html | 204 +++++++++++++++++++++++++++------ test/i18n-completeness.test.js | 18 +-- 2 files changed, 178 insertions(+), 44 deletions(-) diff --git a/public/dashboard.html b/public/dashboard.html index 70bf321..a34e377 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -197,27 +197,27 @@
-
@@ -235,14 +235,14 @@
- Activity + Activity
- Less + Less
- More + More
@@ -251,14 +251,14 @@
- Tool Usage + Tool Usage
- Automation Index + Automation Index
@@ -269,21 +269,21 @@
- Models + Models
- Agent Modes + Agent Modes
- Peak Hours + Peak Hours
@@ -294,7 +294,7 @@
- Top Sessions + Top Sessions
@@ -323,6 +323,125 @@ const COLORS = ['#7c6aef','#3fb950','#58a6ff','#e5a435','#f85149','#a371f7','#f778ba','#79c0ff','#d2a8ff','#ffa657']; const MODEL_COLORS = { opus:'#f85149', sonnet:'#7c6aef', haiku:'#3fb950' }; +// ─── i18n ────────────────────────────────────────────────────────────────── +// Declarative i18n — same attribute contract as index.html/kanban.html/schedule.html: +// the markup carries the key, applyI18nAttrs() fills in text/attributes for the +// active language. Every user-facing literal must be routed through t(); see +// test/i18n-completeness.test.js. +let lang = localStorage.getItem('lang') || 'en'; +document.documentElement.lang = lang; +document.documentElement.dir = (lang === 'he') ? 'rtl' : 'ltr'; + +const LOCALE_MAP = { uk:'uk-UA', en:'en-US', ru:'ru-RU', fr:'fr-FR', he:'he-IL' }; +function locale(){ return LOCALE_MAP[lang] || 'en-US'; } + +const TR = { + uk: { + 'nav.aria':'Навігація між розділами','nav.chat':'Чат','nav.kanban':'Kanban дошка','nav.schedule':'Розклад','nav.dashboard':'Панель','hdr.refresh':'Оновити', + 'page.title':'Панель — Claude Code Studio', + 'card.activity':'Активність','card.tools':'Використання інструментів','card.automation':'Індекс автоматизації', + 'card.models':'Моделі','card.agentmodes':'Режими агента','card.peakhours':'Пікові години','card.topsessions':'Топ сесій', + 'heatmap.less':'Менше','heatmap.more':'Більше','heatmap.tip':'{date}: {n} повідомл.', + 'hero.sessions.label':'Сесії','hero.sessions.sub':'усього розмов', + 'hero.toolcalls.label':'Виклики інструментів','hero.messages.label':'Повідомлення', + 'hero.timesaved.label':'Заощаджений час','hero.timesaved.sub':'орієнтовна економія від автоматизації', + 'stat.avgPerSession':'{n} у середньому за сесію', + 'tools.empty':'Немає даних про інструменти','common.nodata':'Немає даних','sessions.empty':'Немає сесій','session.untitled':'Без назви', + 'gauge.toolautomation':'Автоматизація інструментів: {n} викликів', + 'gauge.multiagent':'Мультиагентні сесії: {n}% використання інструментів', + 'agent.single':'Один агент','agent.multi':'Мультиагент', + 'hourly.tip':'{time} — {n} повідомл.','error.loadfailed':'Не вдалося завантажити аналітику', + }, + en: { + 'nav.aria':'Navigation','nav.chat':'Chat','nav.kanban':'Kanban board','nav.schedule':'Schedule','nav.dashboard':'Dashboard','hdr.refresh':'Refresh', + 'page.title':'Dashboard — Claude Code Studio', + 'card.activity':'Activity','card.tools':'Tool Usage','card.automation':'Automation Index', + 'card.models':'Models','card.agentmodes':'Agent Modes','card.peakhours':'Peak Hours','card.topsessions':'Top Sessions', + 'heatmap.less':'Less','heatmap.more':'More','heatmap.tip':'{date}: {n} msgs', + 'hero.sessions.label':'Sessions','hero.sessions.sub':'conversations total', + 'hero.toolcalls.label':'Tool Calls','hero.messages.label':'Messages', + 'hero.timesaved.label':'Time Saved','hero.timesaved.sub':'estimated automation savings', + 'stat.avgPerSession':'{n} avg per session', + 'tools.empty':'No tool data','common.nodata':'No data','sessions.empty':'No sessions','session.untitled':'Untitled', + 'gauge.toolautomation':'Tool automation: {n} calls', + 'gauge.multiagent':'Multi-agent sessions: {n}% tool ratio', + 'agent.single':'Single Agent','agent.multi':'Multi-Agent', + 'hourly.tip':'{time} — {n} msgs','error.loadfailed':'Failed to load analytics', + }, + ru: { + 'nav.aria':'Навигация','nav.chat':'Чат','nav.kanban':'Kanban доска','nav.schedule':'Расписание','nav.dashboard':'Панель','hdr.refresh':'Обновить', + 'page.title':'Панель — Claude Code Studio', + 'card.activity':'Активность','card.tools':'Использование инструментов','card.automation':'Индекс автоматизации', + 'card.models':'Модели','card.agentmodes':'Режимы агента','card.peakhours':'Пиковые часы','card.topsessions':'Топ сессий', + 'heatmap.less':'Меньше','heatmap.more':'Больше','heatmap.tip':'{date}: {n} сообщ.', + 'hero.sessions.label':'Сессии','hero.sessions.sub':'всего разговоров', + 'hero.toolcalls.label':'Вызовы инструментов','hero.messages.label':'Сообщения', + 'hero.timesaved.label':'Сэкономленное время','hero.timesaved.sub':'ориентировочная экономия от автоматизации', + 'stat.avgPerSession':'{n} в среднем за сессию', + 'tools.empty':'Нет данных об инструментах','common.nodata':'Нет данных','sessions.empty':'Нет сессий','session.untitled':'Без названия', + 'gauge.toolautomation':'Автоматизация инструментов: {n} вызовов', + 'gauge.multiagent':'Мультиагентные сессии: {n}% использования инструментов', + 'agent.single':'Один агент','agent.multi':'Мультиагент', + 'hourly.tip':'{time} — {n} сообщ.','error.loadfailed':'Не удалось загрузить аналитику', + }, + fr: { + 'nav.aria':'Navigation','nav.chat':'Discussion','nav.kanban':'Tableau Kanban','nav.schedule':'Planning','nav.dashboard':'Tableau de bord','hdr.refresh':'Actualiser', + 'page.title':'Tableau de bord — Claude Code Studio', + 'card.activity':'Activité','card.tools':'Utilisation des outils','card.automation':'Indice d’automatisation', + 'card.models':'Modèles','card.agentmodes':'Modes agent','card.peakhours':'Heures de pointe','card.topsessions':'Meilleures sessions', + 'heatmap.less':'Moins','heatmap.more':'Plus','heatmap.tip':'{date} : {n} messages', + 'hero.sessions.label':'Sessions','hero.sessions.sub':'conversations au total', + 'hero.toolcalls.label':'Appels d’outils','hero.messages.label':'Messages', + 'hero.timesaved.label':'Temps économisé','hero.timesaved.sub':'économies estimées grâce à l’automatisation', + 'stat.avgPerSession':'{n} en moyenne par session', + 'tools.empty':'Aucune donnée sur les outils','common.nodata':'Aucune donnée','sessions.empty':'Aucune session','session.untitled':'Sans titre', + 'gauge.toolautomation':'Automatisation des outils : {n} appels', + 'gauge.multiagent':'Sessions multi-agents : {n} % de recours aux outils', + 'agent.single':'Agent unique','agent.multi':'Multi-agent', + 'hourly.tip':'{time} — {n} messages','error.loadfailed':'Échec du chargement des statistiques', + }, + he: { + 'nav.aria':'ניווט','nav.chat':'צ׳אט','nav.kanban':'לוח קנבן','nav.schedule':'לוח זמנים','nav.dashboard':'לוח מחוונים','hdr.refresh':'רענון', + 'page.title':'לוח מחוונים — Claude Code Studio', + 'card.activity':'פעילות','card.tools':'שימוש בכלים','card.automation':'מדד האוטומציה', + 'card.models':'מודלים','card.agentmodes':'מצבי סוכן','card.peakhours':'שעות שיא','card.topsessions':'הסשנים המובילים', + 'heatmap.less':'פחות','heatmap.more':'יותר','heatmap.tip':'{date}: {n} הודעות', + 'hero.sessions.label':'סשנים','hero.sessions.sub':'סה"כ שיחות', + 'hero.toolcalls.label':'קריאות לכלים','hero.messages.label':'הודעות', + 'hero.timesaved.label':'זמן שנחסך','hero.timesaved.sub':'חיסכון משוער מאוטומציה', + 'stat.avgPerSession':'{n} בממוצע לסשן', + 'tools.empty':'אין נתוני כלים','common.nodata':'אין נתונים','sessions.empty':'אין סשנים','session.untitled':'ללא כותרת', + 'gauge.toolautomation':'אוטומציית כלים: {n} קריאות', + 'gauge.multiagent':'סשנים רב-סוכניים: {n}% שימוש בכלים', + 'agent.single':'סוכן יחיד','agent.multi':'רב-סוכני', + 'hourly.tip':'{time} — {n} הודעות','error.loadfailed':'טעינת הנתונים נכשלה', + }, +}; +function t(k){ return (TR[lang]||TR.en)[k] || k; } + +function applyI18nAttrs(root){ + const r = root || document; + r.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); + for(const [attr, target] of [['data-i18n-ph','placeholder'], ['data-i18n-title','title'], + ['data-i18n-aria','aria-label'], ['data-i18n-tip','data-tip']]){ + r.querySelectorAll('[' + attr + ']').forEach(el => { el.setAttribute(target, t(el.getAttribute(attr))); }); + } +} +function applyStaticI18n(){ + document.title = t('page.title'); + applyI18nAttrs(); +} +// /api/config carries `lang` set from the main app; adopt it so this page +// matches whatever language the user picked there, not just localStorage. +function adoptServerLang(srvLang){ + if(!srvLang || srvLang === lang || !TR[srvLang]) return; + lang = srvLang; + localStorage.setItem('lang', lang); + document.documentElement.lang = lang; + document.documentElement.dir = (lang === 'he') ? 'rtl' : 'ltr'; + applyI18nAttrs(); +} + // --- Data loading --- async function loadData(){ const loader = $('#loader'); @@ -336,7 +455,7 @@ render(d); content.style.display = ''; } catch(e){ - content.innerHTML = `

Failed to load analytics

${e.message}
`; + content.innerHTML = `

${t('error.loadfailed')}

${e.message}
`; content.style.display = ''; } finally { loader.style.display = 'none'; @@ -359,10 +478,10 @@ function renderHero(d){ const s = d.summary; const cards = [ - { label:'Sessions', value:s.total_sessions, sub:'conversations total', icon:'' }, - { label:'Tool Calls', value:fmtNum(s.total_tool_calls), sub:`${Math.round(s.total_tool_calls/Math.max(1,s.total_sessions))} avg per session`, icon:'' }, - { label:'Messages', value:fmtNum(s.total_messages), sub:`${d.sessionStats?.avg_messages_per_session || 0} avg per session`, icon:'' }, - { label:'Time Saved', value:`~${s.estimated_hours_saved}h`, sub:'estimated automation savings', icon:'' }, + { label:t('hero.sessions.label'), value:s.total_sessions, sub:t('hero.sessions.sub'), icon:'' }, + { label:t('hero.toolcalls.label'), value:fmtNum(s.total_tool_calls), sub:t('stat.avgPerSession').replace('{n}', Math.round(s.total_tool_calls/Math.max(1,s.total_sessions))), icon:'' }, + { label:t('hero.messages.label'), value:fmtNum(s.total_messages), sub:t('stat.avgPerSession').replace('{n}', d.sessionStats?.avg_messages_per_session || 0), icon:'' }, + { label:t('hero.timesaved.label'), value:`~${s.estimated_hours_saved}h`, sub:t('hero.timesaved.sub'), icon:'' }, ]; const row = $('#heroRow'); row.innerHTML = ''; @@ -404,13 +523,14 @@ const maxCount = Math.max(1, ...cells.map(c=>c.count)); cells.forEach(c => { const level = c.count === 0 ? 0 : Math.min(4, Math.ceil(c.count / maxCount * 4)); - const el = h('div',{class:`hm-cell hm-${level}`, 'data-tip':`${c.date}: ${c.count} msgs`}); + const el = h('div',{class:`hm-cell hm-${level}`, 'data-tip':t('heatmap.tip').replace('{date}', c.date).replace('{n}', c.count)}); hm.appendChild(el); }); // Month labels const weeks = Math.ceil((firstDow + cells.length) / 7); - const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const monthFmt = new Intl.DateTimeFormat(locale(), { month:'short' }); + const monthNames = [...Array(12).keys()].map(m => monthFmt.format(new Date(2024, m, 1))); let lastMonth = -1; const monthSpans = []; for(let w = 0; w < weeks; w++){ @@ -431,7 +551,7 @@ function renderTools(tools){ const wrap = $('#toolBars'); wrap.innerHTML = ''; - if(!tools.length){ wrap.innerHTML='
No tool data
'; return; } + if(!tools.length){ wrap.innerHTML=`
${t('tools.empty')}
`; return; } const max = tools[0].count; const colorClasses = ['c1','c2','c3','c4','c5','c6']; tools.slice(0,10).forEach((t,i) => { @@ -467,8 +587,8 @@
${score}
- Tool automation: ${summary.total_tool_calls} calls
- Multi-agent sessions: ${Math.round((summary.total_messages > 0 ? summary.total_tool_calls/summary.total_messages : 0)*100)}% tool ratio + ${t('gauge.toolautomation').replace('{n}', summary.total_tool_calls)}
+ ${t('gauge.multiagent').replace('{n}', Math.round((summary.total_messages > 0 ? summary.total_tool_calls/summary.total_messages : 0)*100))}
`; } @@ -476,7 +596,7 @@ function renderModelDonut(models){ const wrap = $('#modelDonut'); const total = models.reduce((a,m)=>a+m.count,0); - if(!total){ wrap.innerHTML='
No data
'; return; } + if(!total){ wrap.innerHTML=`
${t('common.nodata')}
`; return; } const r = 40, cx = 55, cy = 55, sw = 16; const circ = 2 * Math.PI * r; @@ -509,8 +629,8 @@ const wrap = $('#agentBars'); wrap.innerHTML = ''; const total = modes.reduce((a,m)=>a+m.count,0); - if(!total){ wrap.innerHTML='
No data
'; return; } - const labels = { single:'Single Agent', multi:'Multi-Agent' }; + if(!total){ wrap.innerHTML=`
${t('common.nodata')}
`; return; } + const labels = { single:t('agent.single'), multi:t('agent.multi') }; const colors = { single:'c3', multi:'c1' }; modes.forEach(m => { const pct = Math.max(5, m.count/total*100); @@ -539,7 +659,8 @@ byHour.forEach((count, hour) => { const pct = Math.max(2, count/max*100); - bars.innerHTML += `
`; + const tip = t('hourly.tip').replace('{time}', String(hour).padStart(2,'0')+':00').replace('{n}', count); + bars.innerHTML += `
`; if(hour % 3 === 0) labels.innerHTML += `${String(hour).padStart(2,'0')}`; else labels.innerHTML += ``; }); @@ -549,18 +670,20 @@ function renderTimeline(sessions){ const wrap = $('#timeline'); wrap.innerHTML = ''; - if(!sessions.length){ wrap.innerHTML='
No sessions
'; return; } + if(!sessions.length){ wrap.innerHTML=`
${t('sessions.empty')}
`; return; } + const modeLabels = { single:t('agent.single'), multi:t('agent.multi') }; sessions.forEach((s,i) => { const model = s.model || 'sonnet'; - const date = s.created_at ? new Date(s.created_at).toLocaleDateString('uk-UA',{day:'numeric',month:'short'}) : ''; - const title = s.title || 'Untitled'; + const date = s.created_at ? new Date(s.created_at).toLocaleDateString(locale(),{day:'numeric',month:'short'}) : ''; + const title = s.title || t('session.untitled'); const proj = s.workdir ? s.workdir.split('/').pop() : ''; + const mode = modeLabels[s.agent_mode] || escHtml(s.agent_mode || 'single'); wrap.innerHTML += `
${i+1}
${escHtml(title)}
-
${proj ? ''+escHtml(proj)+' · ' : ''}${date} · ${s.msg_count} msgs · ${s.tool_count} tools · ${escHtml(s.agent_mode||'single')}
+
${proj ? ''+escHtml(proj)+' · ' : ''}${date} · ${s.msg_count} msgs · ${s.tool_count} tools · ${mode}
${escHtml(model)}
`; @@ -571,6 +694,13 @@ function escHtml(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } // --- Init --- +applyStaticI18n(); +(async () => { + try { + const rc = await fetch('/api/config', { credentials:'include' }); + if(rc.ok){ const config = await rc.json(); adoptServerLang(config && config.lang); } + } catch {} +})(); loadData(); diff --git a/test/i18n-completeness.test.js b/test/i18n-completeness.test.js index 734fab8..5146b91 100644 --- a/test/i18n-completeness.test.js +++ b/test/i18n-completeness.test.js @@ -5,6 +5,7 @@ // 2. public/index.html — the SPA's inline TRANSLATIONS object // 3. public/kanban.html — the board's inline TR object // 4. public/schedule.html — the scheduler's inline TR object +// 5. public/dashboard.html — the analytics dashboard's inline TR object // and every key the UI actually asks for (`t('key')`, `data-i18n`, // `data-i18n-title`, `data-i18n-ph`, `data-i18n-aria`, `data-i18n-tip`, // `data-i18n-html`) must exist in ALL of them, or that language silently renders @@ -114,13 +115,15 @@ console.log('\nplaceholder parity against the base language:'); } } -// ── 5. Secondary page dictionaries (kanban / schedule) ────────────────────── -// Both pages ship their own inline `TR` object and their own `t()`. They used to -// carry uk/en/ru only and fall back to uk, so a French or Hebrew user got a -// Ukrainian board — the concrete complaint in issue #26. -console.log('\nsecondary page dictionaries (kanban / schedule):'); +// ── 5. Secondary page dictionaries (kanban / schedule / dashboard) ────────── +// These pages ship their own inline `TR` object and their own `t()`. Kanban and +// schedule used to carry uk/en/ru only and fall back to uk, so a French or +// Hebrew user got a Ukrainian board — the concrete complaint in issue #26. +// The dashboard shipped with no i18n at all until it was given the same +// contract as the other two. +console.log('\nsecondary page dictionaries (kanban / schedule / dashboard):'); const PAGE_DICTS = {}; -for (const file of ['kanban.html', 'schedule.html']) { +for (const file of ['kanban.html', 'schedule.html', 'dashboard.html']) { const src = fs.readFileSync(path.join(ROOT, 'public', file), 'utf8'); const start = src.indexOf('const TR = {'); assert.ok(start !== -1, `could not locate the TR object in public/${file}`); @@ -200,6 +203,7 @@ console.log('\nno hardcoded user-facing strings:'); 'Claude Code Studio — AI Chat & Agents', // , product name 'Kanban — Claude Code Studio', // <title>, product name 'Schedule — Claude Code Studio', // <title>, product name + 'Dashboard — Claude Code Studio', // <title>, product name 'CCS', // the logo mark, product initials 'GitHub', // company name 'Claude Desktop', // Anthropic product name @@ -314,7 +318,7 @@ console.log('\nno hardcoded user-facing strings:'); check('scanCalls ignores t() calls and non-user-facing calls', chits.length, 2); } - for (const file of ['index.html', 'kanban.html', 'schedule.html']) { + for (const file of ['index.html', 'kanban.html', 'schedule.html', 'dashboard.html']) { const src = PAGE_DICTS[file] ? PAGE_DICTS[file].src : fs.readFileSync(path.join(ROOT, 'public', file), 'utf8'); check(`${file}: no untranslated text or attribute in the markup`, scanMarkup(file, src), []); From a5e79f125d200b4689131bb8e19019c7e7d24bee Mon Sep 17 00:00:00 2001 From: Lexus2016 <lexus@cdzv.com> Date: Sun, 13 Sep 2026 15:11:09 +0200 Subject: [PATCH 2/2] fix(dashboard): adopt the server language before the first render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adoptServerLang() fetched /api/config concurrently with loadData(), so when localStorage.lang and the server's lang disagreed (another browser, cleared storage) the dynamic markup — hero cards, gauges, tooltips — rendered in one language and the static labels flipped to the other a moment later. kanban.html awaits the config before rendering; do the same here, and make adoptServerLang() refresh document.title along with the attributes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- public/dashboard.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/public/dashboard.html b/public/dashboard.html index a34e377..f494e43 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -439,7 +439,7 @@ localStorage.setItem('lang', lang); document.documentElement.lang = lang; document.documentElement.dir = (lang === 'he') ? 'rtl' : 'ltr'; - applyI18nAttrs(); + applyStaticI18n(); } // --- Data loading --- @@ -694,14 +694,18 @@ function escHtml(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); } // --- Init --- -applyStaticI18n(); +// Adopt the server's language BEFORE the first render, the way kanban.html does: +// loadData() bakes t() into the dynamic markup (hero cards, gauges, tooltips), so +// a language that lands after it leaves those in one language and the static +// labels in another. (async () => { try { const rc = await fetch('/api/config', { credentials:'include' }); if(rc.ok){ const config = await rc.json(); adoptServerLang(config && config.lang); } } catch {} + applyStaticI18n(); + loadData(); })(); -loadData(); </script> </body> </html>