'use strict'; /** * Darstellung. Rechnet nichts nach — die Zahlen kommen fertig aus dem * Main-Prozess. Nur der Countdown läuft hier lokal weiter, damit die Anzeige * zwischen zwei Aktualisierungen nicht stehenbleibt. */ /** * Feste Farbzuordnung je Modellfamilie. Die Farbe folgt der Entität, nie dem * Rang — verschiebt sich die Reihenfolge im Balken, bleibt Opus orange. */ const FAMILY_COLOR = { opus: 'var(--series-2)', sonnet: 'var(--series-1)', haiku: 'var(--series-3)', fable: 'var(--series-4)', unknown: 'var(--series-other)', }; const SEVERITY_TEXT = { ok: 'im Rahmen', warn: 'erhöht', critical: 'kritisch', }; const CONFIDENCE_TEXT = { exact: 'exakt (Server)', configured: 'eigener Wert', calibrated: 'kalibriert', estimate: 'geschätzt', }; const CONFIDENCE_HINT = { exact: 'Die Werte stammen direkt vom Server und entsprechen der Anzeige von /usage.', configured: 'Als Bezugsgröße dient der in config.json hinterlegte Wert.', calibrated: 'Bezugsgröße ist das höchste bisher beobachtete Volumen zuzüglich Puffer. Der Wert nähert sich dem echten Limit an, je öfter du nah herankommst.', estimate: 'Noch zu wenige eigene Messungen für eine belastbare Bezugsgröße. Der Prozentwert ist ein grober Anhaltspunkt — trage bei Bedarf ein Limit in config.json ein.', }; const $ = (id) => document.getElementById(id); const el = { widget: $('widget'), planLabel: $('plan-label'), staleBadge: $('stale-badge'), btnEndpointStatus: $('btn-endpoint-status'), btnUpdateAvailable: $('btn-update-available'), infoWrap: $('info-wrap'), btnInfo: $('btn-info'), infoMenu: $('info-menu'), infoMenuManual: $('info-menu-manual'), infoMenuDisclaimer: $('info-menu-disclaimer'), infoMenuLicense: $('info-menu-license'), btnHide: $('btn-hide'), btnSettings: $('btn-settings'), btnExpand: $('btn-expand'), expanderLabel: $('expander-label'), details: $('details'), meterBlock: $('meter-block'), blockPercent: $('block-percent'), blockFill: $('block-fill'), blockProjection: $('block-projection'), blockStatus: $('block-status'), blockReset: $('block-reset'), blockA11y: $('block-a11y'), sessionTokens: $('session-tokens'), sessionRate: $('session-rate'), sessionRunway: $('session-runway'), meterSpend: $('meter-spend'), spendValue: $('spend-value'), spendFill: $('spend-fill'), spendStatus: $('spend-status'), spendLimit: $('spend-limit'), spendA11y: $('spend-a11y'), weekPercent: $('week-percent'), weekFill: $('week-fill'), weekProjection: $('week-projection'), weekStatus: $('week-status'), weekReset: $('week-reset'), weekA11y: $('week-a11y'), modelBar: $('model-bar'), modelLegend: $('model-legend'), modelA11y: $('model-a11y'), modelTableBody: document.querySelector('#model-table tbody'), factBurn: $('fact-burn'), factProjection: $('fact-projection'), factTtl: $('fact-ttl'), factMessages: $('fact-messages'), factConfidence: $('fact-confidence'), factConfidenceLabel: $('fact-confidence-label'), factConfidenceWeek: $('fact-confidence-week'), factOauth: $('fact-oauth'), factUpdated: $('fact-updated'), confidenceHint: $('confidence-hint'), errors: $('errors'), }; let state = null; let expanded = false; // ── Formatierung ─────────────────────────────────────────────────────────── const nf = new Intl.NumberFormat('de-DE'); /** Kompakte Tokenzahl: 1.2 Mio., 43,5 Tsd. */ function fmtTokens(n) { if (!Number.isFinite(n)) return '—'; if (n >= 1e6) return `${(n / 1e6).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Mio.`; if (n >= 1e3) return `${(n / 1e3).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Tsd.`; return nf.format(Math.round(n)); } /** Restdauer als h:mm:ss bzw. mm:ss. */ function fmtDuration(ms) { if (ms == null || !Number.isFinite(ms) || ms <= 0) return '—'; const total = Math.floor(ms / 1000); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const pad = (v) => String(v).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } /** Restdauer minutengenau: "2 T. 3 Std. 40 Min." */ function fmtCoarse(ms) { if (ms == null || !Number.isFinite(ms) || ms <= 0) return '—'; const min = Math.floor(ms / 60000); if (min < 60) return `${min} Min.`; const h = Math.floor(min / 60); const restMin = min % 60; if (h < 24) return restMin ? `${h} Std. ${restMin} Min.` : `${h} Std.`; const d = Math.floor(h / 24); const restH = h % 24; const parts = [`${d} T.`]; if (restH) parts.push(`${restH} Std.`); if (restMin) parts.push(`${restMin} Min.`); return parts.join(' '); } /** * Vollständiger Zeitpunkt inkl. Datum, sobald er nicht mehr heute liegt. * Sekunden nur, wo sie eine Aussage haben — bei einem Termin in Tagen wären * sie Scheingenauigkeit. */ function fmtExact(ts, withSeconds = false) { if (!ts) return '—'; const d = new Date(ts); const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', ...(withSeconds ? { second: '2-digit' } : {}), }); if (d.toDateString() === new Date().toDateString()) return time; return `${d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })} ${time}`; } /** Absoluter Zeitpunkt — heute nur Uhrzeit, sonst mit Wochentag. */ function fmtWhen(ts) { if (!ts) return '—'; const d = new Date(ts); const now = new Date(); const sameDay = d.toDateString() === now.toDateString(); const time = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); if (sameDay) return time; return `${d.toLocaleDateString('de-DE', { weekday: 'short' })} ${time}`; } function fmtPercent(p) { if (!Number.isFinite(p)) return '—'; return `${p < 10 ? p.toLocaleString('de-DE', { maximumFractionDigits: 1 }) : Math.round(p)} %`; } function severityColor(sev) { return sev === 'critical' ? 'var(--status-critical)' : sev === 'warn' ? 'var(--status-warning)' : 'var(--status-good)'; } /** Schweregrad: Serverangabe hat Vorrang vor der eigenen Schwelle. */ function severityOf(percent, serverSeverity) { if (serverSeverity === 'warning' || serverSeverity === 'warn') return 'warn'; if (serverSeverity === 'critical' || serverSeverity === 'serious') return 'critical'; if (serverSeverity === 'normal') return 'ok'; return percent >= 85 ? 'critical' : percent >= 60 ? 'warn' : 'ok'; } // ── Rendering ────────────────────────────────────────────────────────────── function renderMeter(prefix, data) { const percent = Math.min(100, Math.max(0, data.percent)); el[`${prefix}Percent`].textContent = fmtPercent(data.percent); el[`${prefix}Fill`].style.width = `${percent}%`; el[`${prefix}Fill`].style.backgroundColor = severityColor(data.severity); const status = el[`${prefix}Status`]; status.dataset.severity = data.severity; const label = SEVERITY_TEXT[data.severity] || '—'; const confidence = CONFIDENCE_TEXT[data.confidence] || ''; status.querySelector('.status-text').textContent = data.confidence === 'exact' ? label : `${label} · ${confidence}`; } /** Zeichnet die schraffierte Hochrechnung hinter den Ist-Wert. */ function renderProjection(node, currentPercent, projectedPercent, sev) { const current = Math.min(100, Math.max(0, currentPercent)); const projected = Math.min(100, Math.max(0, projectedPercent)); if (!(projected > current + 1)) { node.hidden = true; return; } node.hidden = false; node.style.left = `${current}%`; node.style.width = `${projected - current}%`; // backgroundColor, nicht background: die Kurzschreibweise würde die // Schraffur aus dem Stylesheet mitlöschen. node.style.backgroundColor = severityColor(sev); node.style.opacity = '0.5'; } /** * Sitzungsfenster. Der Prozentbalken erscheint nur, wenn eine echte * Bezugsgröße vorliegt — vom Server oder selbst kalibriert. Verbrauch und * Tempo stehen unabhängig davon immer darunter. */ function renderBlock(block, burn) { // Ohne Bezugsgröße entfallen nur Prozentwert, Balken und Schweregrad. // Überschrift und Fensterende bleiben — sie gelten unabhängig davon, und // ohne sie stünden die Zahlen darunter ohne jeden Zusammenhang da. el.blockPercent.hidden = !block.hasLimit; el.meterBlock.querySelector('.track').hidden = !block.hasLimit; el.blockStatus.hidden = !block.hasLimit; if (block.hasLimit) { renderMeter('block', block); renderProjection(el.blockProjection, block.percent, burn.projectedPercent, block.severity); el.blockA11y.textContent = `Laufende Sitzung: ${fmtPercent(block.percent)} genutzt, ${CONFIDENCE_TEXT[block.confidence]}.`; } else { el.blockA11y.textContent = `Laufende Sitzung: ${fmtTokens(block.raw)} Tokens verbraucht.`; } el.sessionTokens.textContent = block.active ? fmtTokens(block.raw) : '—'; el.sessionRate.textContent = burn.perMinute > 0 ? fmtTokens(burn.perMinute) : '—'; if (!block.active) { el.sessionRunway.textContent = 'Keine laufende Sitzung.'; } else if (burn.activeTimeLeftMs != null) { el.sessionRunway.textContent = `Wochenkontingent reicht bei diesem Tempo noch ${fmtCoarse(burn.activeTimeLeftMs)} aktive Arbeit.`; } else { el.sessionRunway.textContent = 'Tempo zu gering für eine Hochrechnung.'; } } /** Nutzungsguthaben — der Budget-Zustand jenseits des Plan-Kontingents. */ function renderSpend(spend) { el.meterSpend.hidden = !spend; if (!spend) return; const money = new Intl.NumberFormat('de-DE', { style: 'currency', currency: spend.currency || 'EUR', }); const percent = spend.percent ?? (spend.limit > 0 ? (spend.used / spend.limit) * 100 : 0); el.spendValue.textContent = money.format(spend.used); el.spendFill.style.width = `${Math.min(100, Math.max(0, percent))}%`; el.spendFill.style.backgroundColor = severityColor(severityOf(percent, spend.severity)); el.spendLimit.textContent = `von ${money.format(spend.limit)}`; const sev = severityOf(percent, spend.severity); el.spendStatus.dataset.severity = sev; el.spendStatus.querySelector('.status-text').textContent = spend.enabled ? `${SEVERITY_TEXT[sev]} · ${fmtPercent(percent)}` : 'abgeschaltet'; el.spendA11y.textContent = `Nutzungsguthaben: ${money.format(spend.used)} von ${money.format(spend.limit)} verbraucht.`; } /** Endpunkt-Status-Icon im Titelbalken — Zustand der beiden Datenquellen. */ function renderEndpointStatus(s) { const ep = s.endpoints || { severity: 'ok', local: { ok: true }, oauth: { ok: true, text: '—' } }; el.btnEndpointStatus.dataset.severity = ep.severity; el.btnEndpointStatus.title = `Lokale Transkripte: ${ep.local.ok === false ? 'gestört' : 'aktiv'} · ` + `Serverabfrage: ${ep.oauth.text || '—'}`; } /** Titelleisten-Symbol für ein gefundenes neues Release. */ function renderUpdateAvailable(s) { const update = s.update || { available: false }; el.btnUpdateAvailable.hidden = !update.available; if (update.available) { el.btnUpdateAvailable.title = `Version ${update.tagName} verfügbar — Release-Seite öffnen`; } } function renderWeek(week, burn) { renderMeter('week', week); renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity); el.weekA11y.textContent = `Wochenkontingent: ${fmtPercent(week.percent)} genutzt, ${CONFIDENCE_TEXT[week.confidence]}.`; } function renderModels(block) { const models = block.models || []; const total = models.reduce((sum, m) => sum + m.raw, 0); el.modelBar.replaceChildren(); el.modelLegend.replaceChildren(); el.modelTableBody.replaceChildren(); if (!models.length || total <= 0) { el.modelA11y.textContent = 'Im laufenden Fenster wurden noch keine Tokens verbraucht.'; const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 4; cell.textContent = 'Noch kein Verbrauch im laufenden Fenster.'; row.append(cell); el.modelTableBody.append(row); return; } for (const m of models) { const color = FAMILY_COLOR[m.family] || FAMILY_COLOR.unknown; const share = (m.raw / total) * 100; const seg = document.createElement('div'); seg.className = 'segment'; seg.style.width = `${share}%`; seg.style.background = color; seg.title = `${m.model}: ${fmtTokens(m.raw)} Tokens (${fmtPercent(share)})`; el.modelBar.append(seg); // Legende: ab zwei Serien immer vorhanden, damit Identität nie allein // an der Farbe hängt. const li = document.createElement('li'); const swatch = document.createElement('span'); swatch.className = 'swatch'; swatch.style.background = color; const text = document.createElement('span'); text.textContent = `${m.model.replace(/^claude-/, '')} ${fmtPercent(share)}`; li.append(swatch, text); el.modelLegend.append(li); const row = document.createElement('tr'); for (const [value, isNum] of [ [m.model.replace(/^claude-/, ''), false], [fmtTokens(m.output), true], [fmtTokens(m.cacheRead + m.cacheCreate), true], [fmtTokens(m.raw), true], ]) { const td = document.createElement('td'); if (isNum) td.className = 'num'; td.textContent = value; row.append(td); } el.modelTableBody.append(row); } el.modelA11y.textContent = `Verbrauch je Modell: ${models .map((m) => `${m.model}: ${fmtTokens(m.raw)} Tokens`) .join(', ')}.`; } function renderDetails(s) { el.factBurn.textContent = s.burn.perMinute > 0 ? `${fmtTokens(s.burn.perMinute)} gew./Min.` : 'inaktiv'; el.factProjection.textContent = s.burn.perMinute > 0 ? fmtPercent(s.burn.weekProjectedPercent) : '—'; el.factTtl.textContent = s.burn.activeTimeLeftMs != null ? `${fmtCoarse(s.burn.activeTimeLeftMs)} Arbeit` : '—'; el.factMessages.textContent = nf.format(s.block.messages || 0); // Die Genauigkeit des Sitzungsfensters ist nur dann eine Aussage, wenn dafür // überhaupt ein Prozentwert angezeigt wird. el.factConfidence.hidden = !s.block.hasLimit; el.factConfidenceLabel.hidden = !s.block.hasLimit; el.factConfidence.textContent = CONFIDENCE_TEXT[s.block.confidence] || '—'; el.factConfidenceWeek.textContent = CONFIDENCE_TEXT[s.week.confidence] || '—'; el.factOauth.textContent = s.oauth.text || '—'; // Der Hinweis gilt dem schwächsten der gezeigten Werte. const rank = { exact: 3, configured: 2, calibrated: 1, estimate: 0 }; const shown = s.block.hasLimit ? [s.week.confidence, s.block.confidence] : [s.week.confidence]; const weakest = shown.reduce((a, b) => ((rank[b] ?? 0) < (rank[a] ?? 0) ? b : a)); el.confidenceHint.textContent = CONFIDENCE_HINT[weakest] || ''; if (s.errors && s.errors.length) { el.errors.hidden = false; el.errors.replaceChildren( ...s.errors.slice(-5).reverse().map((e) => { const li = document.createElement('li'); li.textContent = e.message; return li; }), ); } else { el.errors.hidden = true; } } function render(s) { state = s; const plan = s.subscription.subscriptionType; el.planLabel.textContent = plan ? `Claude · ${plan.replace(/_/g, ' ').toUpperCase()}` : 'Claude'; renderBlock(s.block, s.burn); renderWeek(s.week, s.burn); renderSpend(s.spend); renderEndpointStatus(s); renderUpdateAvailable(s); if (expanded) { renderModels(s.block); renderDetails(s); } tick(); } /** Läuft im Sekundentakt und hält nur die zeitabhängigen Anzeigen aktuell. */ function tick() { if (!state) return; const now = Date.now(); // „endet in“, nicht „Reset in“: Ohne eigenes Limit wird beim Fensterwechsel // nichts zurückgesetzt — das Fenster gruppiert nur den Verbrauch. const blockRemaining = state.block.resetAt ? state.block.resetAt - now : null; el.blockReset.textContent = state.block.resetAt ? `${fmtExact(state.block.resetAt, true)} · noch ${fmtDuration(blockRemaining)}` : 'inaktiv'; if (state.week.resetAt) { const weekRemaining = state.week.resetAt - now; // Untertägig auf die Sekunde genau, darüber minutengenau — mehr wäre // Scheingenauigkeit bei einem Countdown über Tage. const rest = weekRemaining < 24 * 3600e3 ? fmtDuration(weekRemaining) : fmtCoarse(weekRemaining); el.weekReset.textContent = `${fmtExact(state.week.resetAt)} · noch ${rest}`; } else if (state.week.rolling) { // Ohne bekannten Reset-Wochentag wandert das Fenster mit — ein Countdown // wäre hier eine erfundene Zahl. el.weekReset.textContent = 'rollierende 7 Tage'; } else { el.weekReset.textContent = '—'; } // Bleiben Aktualisierungen aus, wird das kenntlich gemacht statt still // veraltete Zahlen zu zeigen. const age = now - state.updatedAt; el.staleBadge.hidden = age < 60000; if (expanded) { el.factUpdated.textContent = age < 45000 ? 'gerade eben' : `vor ${fmtCoarse(age)}`; } } // ── Interaktion ──────────────────────────────────────────────────────────── /** Setzt die Ansicht; `persist` false beim Wiederherstellen nach dem Start. */ function applyExpanded(next, persist = true) { expanded = next; el.details.hidden = !expanded; el.btnExpand.setAttribute('aria-expanded', String(expanded)); el.expanderLabel.textContent = expanded ? 'Weniger' : 'Details'; if (persist) window.dashboard.setExpanded(expanded); if (expanded && state) { renderModels(state.block); renderDetails(state); tick(); } } el.btnExpand.addEventListener('click', () => applyExpanded(!expanded)); el.btnHide.addEventListener('click', () => window.dashboard.hide()); el.btnSettings.addEventListener('click', () => window.dashboard.openSettings()); el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog()); el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage()); el.btnInfo.addEventListener('click', () => { el.infoMenu.hidden = !el.infoMenu.hidden; }); document.addEventListener('click', (ev) => { if (!el.infoMenu.hidden && !el.infoWrap.contains(ev.target)) { el.infoMenu.hidden = true; } }); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !el.infoMenu.hidden) { el.infoMenu.hidden = true; } }); el.infoMenuManual.addEventListener('click', () => { el.infoMenu.hidden = true; window.dashboard.openManual(); }); el.infoMenuDisclaimer.addEventListener('click', () => { el.infoMenu.hidden = true; window.dashboard.openDisclaimer(); }); el.infoMenuLicense.addEventListener('click', () => { el.infoMenu.hidden = true; window.dashboard.openLicense(); }); window.dashboard.onState(render); // Reihenfolge: erst die Ansicht auf die wiederhergestellte Fenstergröße // bringen, dann die Daten einfüllen. window.dashboard.getUiState().then((ui) => { if (ui && ui.expanded) applyExpanded(true, false); return window.dashboard.getState(); }).then((s) => { if (s) render(s); }); setInterval(tick, 1000);