- 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';
+ applyStaticI18n();
+}
+
// --- 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,7 +694,18 @@
function escHtml(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); }
// --- Init ---
-loadData();
+// 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();
+})();