Initial commit: Claude Live Dashboard

Always-on-Top Electron-Widget für die Claude-Plan-Auslastung unter Windows.
Liest Transkripte lokal, fragt optional den /usage-Endpunkt ab, bietet einen
Einstellungsdialog und lässt sich als NSIS-Installer oder portable Fassung
bauen.
This commit is contained in:
Winkler, Stefan
2026-07-22 12:59:54 +02:00
commit 868f31fa9a
28 changed files with 8971 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
'use strict';
/**
* Aggregation des Tokenverbrauchs zu rollierenden 5-Stunden-Blöcken,
* Wochenfenster, Burn-Rate und Projektion.
*
* Die Rohdaten werden zu Minuten-Buckets je Modell verdichtet. Für alle
* Anzeigen des Dashboards reicht Minutenauflösung, und der Speicherbedarf
* bleibt auch bei intensiver Nutzung über eine Woche im niedrigen
* einstelligen Megabyte-Bereich.
*/
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const FIVE_HOURS_MS = 5 * HOUR_MS;
const DAY_MS = 24 * HOUR_MS;
/** Aufbewahrung: 8 Tage, damit das 7-Tage-Fenster immer vollständig ist. */
const RETENTION_MS = 8 * DAY_MS;
/**
* Relatives Gewicht der Token-Arten. Die Plan-Limits sind nicht
* token-linear — Output wiegt deutlich schwerer als ein Cache-Treffer.
* Die Werte orientieren sich an den Preisrelationen der API und sind über
* config.json überschreibbar.
*/
const DEFAULT_TOKEN_WEIGHTS = {
input: 1,
cacheCreate: 1.25,
cacheRead: 0.1,
output: 5,
};
/**
* Relatives Gewicht der Modellfamilien. Opus zehrt spürbar stärker am
* Kontingent als Sonnet. Die exakten Faktoren sind nicht öffentlich —
* Startwerte als Annahme, per config.json justierbar.
*/
const DEFAULT_MODEL_WEIGHTS = {
opus: 5,
sonnet: 1,
fable: 1,
haiku: 0.3,
unknown: 1,
};
/** Leeres Token-Tupel. */
function emptyTotals() {
return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, count: 0 };
}
function addTotals(target, src) {
target.input += src.input;
target.output += src.output;
target.cacheRead += src.cacheRead;
target.cacheCreate += src.cacheCreate;
target.count += src.count || 0;
return target;
}
/** Rohe Tokensumme ohne jede Gewichtung — für die Detailanzeige. */
function rawTotal(t) {
return t.input + t.output + t.cacheRead + t.cacheCreate;
}
class UsageAggregator {
/**
* @param {{tokenWeights?: Object, modelWeights?: Object,
* sessionMs?: number, weekMs?: number}} options
*/
constructor(options = {}) {
this.tokenWeights = { ...DEFAULT_TOKEN_WEIGHTS, ...(options.tokenWeights || {}) };
this.modelWeights = { ...DEFAULT_MODEL_WEIGHTS, ...(options.modelWeights || {}) };
this.sessionMs = options.sessionMs || FIVE_HOURS_MS;
this.weekMs = options.weekMs || 7 * DAY_MS;
/**
* Minuten-Buckets: Key `${minuteIndex}|${model}` → Totals + Metadaten.
* @type {Map<string, {minute: number, model: string, family: string} & ReturnType<emptyTotals>}
*/
this.buckets = new Map();
}
/** Gewichteter Beitrag eines Token-Tupels für eine Modellfamilie. */
weigh(totals, family) {
const w = this.tokenWeights;
const base =
totals.input * w.input +
totals.output * w.output +
totals.cacheRead * w.cacheRead +
totals.cacheCreate * w.cacheCreate;
const mw = this.modelWeights[family] ?? this.modelWeights.unknown ?? 1;
return base * mw;
}
/** @param {Array} entries Einträge aus jsonl.js (beliebige Reihenfolge) */
add(entries) {
for (const e of entries) {
const minute = Math.floor(e.ts / MINUTE_MS);
const key = `${minute}|${e.model}`;
let b = this.buckets.get(key);
if (!b) {
b = { minute, model: e.model, family: e.family, ...emptyTotals() };
this.buckets.set(key, b);
}
b.input += e.input;
b.output += e.output;
b.cacheRead += e.cacheRead;
b.cacheCreate += e.cacheCreate;
b.count += 1;
}
}
/** Entfernt Buckets außerhalb des Aufbewahrungsfensters. */
prune(now = Date.now()) {
const cutoff = Math.floor((now - RETENTION_MS) / MINUTE_MS);
for (const [key, b] of this.buckets) {
if (b.minute < cutoff) this.buckets.delete(key);
}
}
/** Buckets aufsteigend nach Zeit. */
sortedBuckets() {
return [...this.buckets.values()].sort((a, b) => a.minute - b.minute);
}
/**
* Bildet rollierende 5h-Blöcke.
*
* Ein Block startet zur vollen Stunde des ersten Eintrags. Ein neuer Block
* beginnt, sobald seit Blockstart mehr als die Sessiondauer vergangen ist
* oder eine Lücke von mindestens der Sessiondauer zwischen zwei Einträgen
* liegt — dasselbe Verfahren, das auch ccusage anwendet.
*/
buildBlocks(now = Date.now()) {
const buckets = this.sortedBuckets();
const blocks = [];
let current = null;
let lastTs = null;
for (const b of buckets) {
const ts = b.minute * MINUTE_MS;
const startNew =
!current ||
ts >= current.start + this.sessionMs ||
(lastTs !== null && ts - lastTs >= this.sessionMs);
if (startNew) {
current = {
start: Math.floor(ts / HOUR_MS) * HOUR_MS,
totals: emptyTotals(),
byModel: new Map(),
weighted: 0,
lastActivity: ts,
};
current.end = current.start + this.sessionMs;
blocks.push(current);
}
addTotals(current.totals, b);
current.weighted += this.weigh(b, b.family);
current.lastActivity = ts;
let m = current.byModel.get(b.model);
if (!m) {
m = { model: b.model, family: b.family, ...emptyTotals() };
current.byModel.set(b.model, m);
}
addTotals(m, b);
lastTs = ts;
}
for (const block of blocks) {
block.isActive = now >= block.start && now < block.end;
block.models = [...block.byModel.values()]
.map((m) => ({ ...m, raw: rawTotal(m), weighted: this.weigh(m, m.family) }))
.sort((a, b) => b.weighted - a.weighted);
delete block.byModel;
block.raw = rawTotal(block.totals);
}
return blocks;
}
/** Summiert alle Buckets in einem Zeitfenster. */
windowTotals(fromTs, toTs) {
const fromMin = Math.floor(fromTs / MINUTE_MS);
const toMin = Math.floor(toTs / MINUTE_MS);
const totals = emptyTotals();
const byModel = new Map();
let weighted = 0;
for (const b of this.buckets.values()) {
if (b.minute < fromMin || b.minute > toMin) continue;
addTotals(totals, b);
weighted += this.weigh(b, b.family);
let m = byModel.get(b.model);
if (!m) {
m = { model: b.model, family: b.family, ...emptyTotals() };
byModel.set(b.model, m);
}
addTotals(m, b);
}
return {
from: fromTs,
to: toTs,
totals,
raw: rawTotal(totals),
weighted,
models: [...byModel.values()]
.map((m) => ({ ...m, raw: rawTotal(m), weighted: this.weigh(m, m.family) }))
.sort((a, b) => b.weighted - a.weighted),
};
}
/**
* Gewichteter Verbrauch pro Minute über die letzten `windowMs`.
* Basis für Projektion und Restzeit-Schätzung.
*/
burnRate(now = Date.now(), windowMs = 30 * MINUTE_MS) {
const w = this.windowTotals(now - windowMs, now);
return w.weighted / (windowMs / MINUTE_MS);
}
/**
* Vollständiger Zustand für die UI.
* @param {number} now
* @param {{weekAnchor?: number}} options weekAnchor = Beginn des Wochenfensters
*/
snapshot(now = Date.now(), options = {}) {
const blocks = this.buildBlocks(now);
const active = blocks.find((b) => b.isActive) || null;
const weekFrom = options.weekAnchor ?? now - this.weekMs;
const week = this.windowTotals(weekFrom, now);
week.resetAt = weekFrom + this.weekMs;
const rate = this.burnRate(now);
let block = null;
if (active) {
const remainingMs = active.end - now;
block = {
...active,
remainingMs,
// Hochrechnung auf das Blockende bei gleichbleibendem Tempo.
projectedWeighted: active.weighted + rate * (remainingMs / MINUTE_MS),
};
} else {
// Kein aktiver Block: Das nächste Fenster beginnt mit der nächsten Anfrage.
block = {
start: null,
end: null,
totals: emptyTotals(),
raw: 0,
weighted: 0,
models: [],
isActive: false,
remainingMs: 0,
projectedWeighted: 0,
};
}
return {
now,
block,
week,
burnRatePerMinute: rate,
blockHistory: blocks.slice(-12).map((b) => ({
start: b.start,
end: b.end,
weighted: b.weighted,
raw: b.raw,
})),
};
}
}
module.exports = {
UsageAggregator,
DEFAULT_TOKEN_WEIGHTS,
DEFAULT_MODEL_WEIGHTS,
FIVE_HOURS_MS,
MINUTE_MS,
HOUR_MS,
DAY_MS,
rawTotal,
};
+160
View File
@@ -0,0 +1,160 @@
'use strict';
/**
* Kalibrierung gegen die echte Nutzungsanzeige.
*
* Die Plan-Limits sind nicht als Token-Zahlen veröffentlicht. Wer den echten
* Prozentwert kennt — Einstellungen ▸ Nutzung, oder `/usage` in Claude Code —
* kann daraus die Bezugsgröße exakt bestimmen: Sie ist der aktuelle Verbrauch
* geteilt durch den angezeigten Anteil.
*
* npm run calibrate -- --week 14
* npm run calibrate -- --block 37
* npm run calibrate -- --week 14 --block 37 --write
*
* Ohne --write wird nur gerechnet und nichts verändert.
*
* Der Abgleich lohnt sich nach jeder größeren Änderung des Nutzungsverhaltens.
* Weichen zwei Kalibrierungen mit unterschiedlichem Modellmix stark
* voneinander ab, stimmen die Modellgewichte in config.json nicht — dann dort
* nachziehen statt das Limit hin- und herzuschieben.
*/
const fs = require('node:fs');
const { TranscriptReader } = require('./jsonl');
const { UsageAggregator, MINUTE_MS } = require('./blocks');
const { weekAnchor } = require('./limits');
const { loadConfig } = require('./store');
const { CONFIG_FILE, ensureUserConfig } = require('./paths');
function parseArgs(argv) {
const args = { write: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--write') args.write = true;
else if (a === '--week' || a === '--block') {
const value = Number(String(argv[++i]).replace(',', '.').replace('%', ''));
if (!Number.isFinite(value) || value <= 0 || value > 100) {
throw new Error(`${a} erwartet einen Prozentwert zwischen 0 und 100`);
}
args[a.slice(2)] = value;
} else if (a === '--help' || a === '-h') {
args.help = true;
} else {
throw new Error(`Unbekannte Option: ${a}`);
}
}
return args;
}
const fmt = (n) => Math.round(n).toLocaleString('de-DE');
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || (args.week == null && args.block == null)) {
console.log(`Kalibrierung gegen die echte Nutzungsanzeige.
npm run calibrate -- --week <prozent> Wochenfenster kalibrieren
npm run calibrate -- --block <prozent> 5-Stunden-Fenster kalibrieren
npm run calibrate -- --week 14 --write Ergebnis in config.json schreiben
Den Prozentwert aus Einstellungen > Nutzung bzw. /usage ablesen und
moeglichst unmittelbar danach kalibrieren — der Verbrauch laeuft weiter.`);
return;
}
ensureUserConfig();
const { config, error } = loadConfig(CONFIG_FILE);
if (error) {
console.error(`Achtung: ${error}`);
console.error('Es wird mit Voreinstellungen gerechnet.\n');
}
const { entries } = await new TranscriptReader().poll();
const agg = new UsageAggregator({
tokenWeights: config.tokenWeights,
modelWeights: config.modelWeights,
sessionMs: config.sessionHours ? config.sessionHours * 60 * MINUTE_MS : undefined,
});
agg.add(entries);
const now = Date.now();
const results = {};
if (args.week != null) {
const anchor = weekAnchor(now, config);
const w = agg.windowTotals(anchor, now);
const limit = (w.weighted / args.week) * 100;
results.week = limit;
const rolling = config.weekResetWeekday == null;
console.log('── Wochenfenster ──────────────────────────────────────────');
console.log(` Fenster ab: ${new Date(anchor).toLocaleString('de-DE')}${rolling ? ' (rollierend)' : ''}`);
console.log(` Verbrauch roh: ${fmt(w.raw)}`);
console.log(` Verbrauch gew.: ${fmt(w.weighted)}`);
console.log(` Angezeigt: ${args.week} %`);
console.log(` ⇒ Limit gew.: ${fmt(limit)}`);
if (rolling) {
console.log(
'\n Hinweis: Ohne weekResetWeekday rechnet das Dashboard rollierend.\n' +
' Steht in der Nutzungsanzeige ein fester Reset-Zeitpunkt, trage ihn\n' +
' ein — sonst passt das Fenster nicht zum kalibrierten Wert.',
);
}
console.log();
}
if (args.block != null) {
const blocks = agg.buildBlocks(now);
const active = blocks.find((b) => b.isActive);
if (!active) {
console.error('Kein aktives 5-Stunden-Fenster — es liegt gerade kein Verbrauch an.');
console.error('Kalibriere waehrend oder direkt nach einer Sitzung.\n');
} else {
const limit = (active.weighted / args.block) * 100;
results.block = limit;
console.log('── 5-Stunden-Fenster ──────────────────────────────────────');
console.log(` Fenster: ${new Date(active.start).toLocaleString('de-DE')} ${new Date(active.end).toLocaleTimeString('de-DE')}`);
console.log(` Verbrauch roh: ${fmt(active.raw)}`);
console.log(` Verbrauch gew.: ${fmt(active.weighted)}`);
console.log(` Angezeigt: ${args.block} %`);
console.log(` ⇒ Limit gew.: ${fmt(limit)}`);
console.log();
}
}
if (!args.write) {
console.log('Nichts geschrieben. Mit --write in config.json uebernehmen.');
return;
}
// Bestehende Datei zeichengenau einlesen, damit Kommentarfelder und
// Formatierung des Nutzers erhalten bleiben.
let raw;
try {
raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch (err) {
console.error(`config.json ist nicht lesbar (${err.message}) — nichts geschrieben.`);
process.exitCode = 1;
return;
}
raw.limits = { ...(raw.limits || {}) };
for (const [key, value] of Object.entries(results)) {
raw.limits[key] = Math.round(value);
}
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
console.log(`In ${CONFIG_FILE} geschrieben:`);
for (const [key, value] of Object.entries(results)) {
console.log(` limits.${key} = ${fmt(value)}`);
}
console.log('\nDashboard neu starten, damit die Werte greifen.');
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
+339
View File
@@ -0,0 +1,339 @@
'use strict';
/**
* Orchestrierung: überwacht die Transkripte, hält den Aggregator aktuell und
* stellt den fertig gerechneten Zustand für die Oberfläche bereit.
*
* Der Renderer bekommt ausschließlich fertige Zahlen — alle Dateizugriffe und
* jede Rechnung passieren hier im Main-Prozess.
*/
const fs = require('node:fs');
const { EventEmitter } = require('node:events');
const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
const { UsageAggregator, MINUTE_MS } = require('./blocks');
const { LimitModel, weekAnchor, severity } = require('./limits');
const { OAuthUsage } = require('./oauth-usage');
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
const DEBOUNCE_MS = 300;
/** Sicherheitsnetz, falls der Watcher ein Ereignis verschluckt. */
const SAFETY_POLL_MS = 15000;
/** Taktung der Neuberechnung, damit Countdown und Burn-Rate nicht einfrieren. */
const RECOMPUTE_MS = 5000;
/** Übersetzt den Schweregrad des Servers in die eigenen Stufen. */
function serverSeverity(value) {
switch (value) {
case 'normal':
return 'ok';
case 'warning':
case 'warn':
return 'warn';
case 'critical':
case 'serious':
return 'critical';
default:
return null;
}
}
class Collector extends EventEmitter {
/**
* @param {{config: Object, store: import('./store').Store, configError?: string|null}} deps
*/
constructor({ config, store, configError = null }) {
super();
this.config = config || {};
this.store = store;
this.configError = configError;
// Bewusst ohne persistierte Offsets: Die Offsets beschleunigen das Lesen
// *innerhalb* einer Sitzung. Über einen Neustart hinweg wären sie schädlich
// — der Aggregator startet leer, und mit fortgeschrittenen Offsets bliebe
// die gesamte Historie ungelesen. Ein Vollscan kostet rund 70 ms.
this.reader = new TranscriptReader();
this.aggregator = new UsageAggregator({
tokenWeights: this.config.tokenWeights,
modelWeights: this.config.modelWeights,
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
});
this.oauth = new OAuthUsage(this.config.oauth || {}, store);
this.subscription = { subscriptionType: null, rateLimitTier: null };
this.limits = null;
this.state = null;
this.errors = [];
if (configError) this.errors.push(configError);
this._watcher = null;
this._debounce = null;
this._timers = [];
this._polling = false;
this._pollAgain = false;
this._lastPollMs = 0;
}
async start() {
this.subscription = await readSubscription();
this.limits = new LimitModel(this.config, this.store.data.calibration, this.subscription);
await this._poll();
this._startWatcher();
this._timers.push(setInterval(() => this._poll(), SAFETY_POLL_MS));
this._timers.push(setInterval(() => this._recompute(), RECOMPUTE_MS));
}
/**
* Übernimmt eine geänderte Konfiguration im laufenden Betrieb.
*
* Die gesammelten Messwerte bleiben erhalten — geändert werden nur die
* Rechenparameter. Die Gewichte greifen sofort, weil sie bei jeder
* Auswertung neu angewandt und nicht in den Buckets vorberechnet werden.
*/
applyConfig(config) {
this.config = config || {};
this.aggregator.tokenWeights = {
...this.aggregator.tokenWeights,
...(this.config.tokenWeights || {}),
};
this.aggregator.modelWeights = {
...this.aggregator.modelWeights,
...(this.config.modelWeights || {}),
};
if (this.config.sessionHours > 0) {
this.aggregator.sessionMs = this.config.sessionHours * 60 * MINUTE_MS;
}
// Serverabfrage neu aufsetzen: An- und Abschalten sowie ein geänderter
// Endpunkt müssen sofort wirken, und ein Fehlerzähler von vorher darf eine
// frisch aktivierte Abfrage nicht blockieren.
this.oauth = new OAuthUsage(this.config.oauth || {}, this.store);
this.limits = new LimitModel(this.config, this.store.data.calibration, this.subscription);
this.errors = [];
this._recompute().catch((err) => this._note(`Neuberechnung fehlgeschlagen: ${err.message}`));
}
stop() {
for (const t of this._timers) clearInterval(t);
this._timers = [];
if (this._debounce) clearTimeout(this._debounce);
if (this._watcher) {
this._watcher.close();
this._watcher = null;
}
this.store.saveSync();
}
_startWatcher() {
try {
// Node unterstützt rekursives Beobachten auf Windows nativ — damit
// braucht es keine zusätzliche Watcher-Bibliothek.
this._watcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_event, filename) => {
if (filename && !String(filename).endsWith('.jsonl')) return;
if (this._debounce) clearTimeout(this._debounce);
this._debounce = setTimeout(() => this._poll(), DEBOUNCE_MS);
});
this._watcher.on('error', (err) => {
this._note(`Dateiüberwachung gestört: ${err.message} — es wird weiter regelmäßig abgefragt.`);
});
} catch (err) {
// Ohne Watcher greift das Sicherheitsnetz alle 15 s.
this._note(`Dateiüberwachung nicht verfügbar: ${err.message}`);
}
}
_note(message) {
if (!this.errors.includes(message)) {
this.errors.push(message);
if (this.errors.length > 5) this.errors.shift();
}
}
async _poll() {
if (this._polling) {
this._pollAgain = true;
return;
}
this._polling = true;
const t0 = Date.now();
try {
const { entries } = await this.reader.poll();
if (entries.length) this.aggregator.add(entries);
this.aggregator.prune();
this._lastPollMs = Date.now() - t0;
await this._recompute();
} catch (err) {
this._note(`Lesen fehlgeschlagen: ${err.message}`);
} finally {
this._polling = false;
if (this._pollAgain) {
this._pollAgain = false;
setImmediate(() => this._poll());
}
}
}
/**
* Merkt sich eine aus der Serverantwort abgeleitete Bezugsgröße, damit die
* Anzeige bei einem Ausfall der Abfrage nicht auf Schätzwerte zurückfällt.
*
* Nur ab 5 % Auslastung: Der Server rundet auf ganze Prozent, und bei einem
* kleinen Nenner wird der abgeleitete Wert dadurch beliebig ungenau.
*/
_rememberServerLimit(kind, limit, percent) {
if (!(percent >= 5) || !(limit > 0)) return;
const store = this.store.data.calibration;
if (!store.serverLimits) store.serverLimits = {};
const previous = store.serverLimits[kind];
// Leichte Glättung gegen das Rundungsrauschen aufeinanderfolgender Werte.
const next = previous > 0 ? previous * 0.7 + limit * 0.3 : limit;
if (!previous || Math.abs(next - previous) / previous > 0.005) {
store.serverLimits[kind] = Math.round(next);
this.store.save();
}
}
/** Baut den Zustand für die Oberfläche und meldet ihn. */
async _recompute() {
const now = Date.now();
// Zuerst der Server: Sein Reset-Zeitpunkt legt das Wochenfenster fest.
// Damit stimmt das lokale Fenster ohne jede Konfiguration mit dem echten
// überein — Voraussetzung dafür, dass die Live-Kalibrierung unten trägt.
const server = await this.oauth.fetch(now);
const anchor = server?.weekResetAt
? server.weekResetAt - this.aggregator.weekMs
: weekAnchor(now, this.config);
const snapshot = this.aggregator.snapshot(now, { weekAnchor: anchor });
const blocks = this.aggregator.buildBlocks(now);
if (this.limits.observe(snapshot, blocks)) {
this.store.data.calibration = this.limits.calibration;
this.store.save();
}
const block = this.limits.evaluate('block', snapshot.block.weighted, server?.blockLimit);
const week = this.limits.evaluate('week', snapshot.week.weighted, server?.weekLimit);
// Ein exakter Prozentwert vom Server sticht die lokale Rechnung.
if (server?.blockPercent != null) {
block.percent = server.blockPercent <= 1 ? server.blockPercent * 100 : server.blockPercent;
block.confidence = 'exact';
}
if (server?.weekPercent != null) {
week.percent = server.weekPercent <= 1 ? server.weekPercent * 100 : server.weekPercent;
week.confidence = 'exact';
}
const blockResetAt = server?.blockResetAt ?? snapshot.block.end;
// Ein rollierendes 7-Tage-Fenster hat keinen Reset-Zeitpunkt — es wandert
// kontinuierlich mit. Einen Countdown darauf zu zeigen wäre irreführend.
const rollingWeek = this.config.weekResetWeekday == null;
const weekResetAt = server?.weekResetAt ?? (rollingWeek ? null : snapshot.week.resetAt);
// Der Balken erscheint, sobald eine belastbare Bezugsgröße vorliegt —
// vom Server, aus der Konfiguration oder aus einer früheren Serverantwort.
// Bloß geschätzte Werte rechtfertigen keinen Auslastungsbalken.
const blockHasLimit =
server?.blockPercent != null || ['configured', 'calibrated', 'exact'].includes(block.confidence);
// Live-Kalibrierung: Der Server nennt den exakten Anteil, lokal ist der
// gewichtete Verbrauch im selben Fenster bekannt — daraus folgt die
// Bezugsgröße direkt, ohne Zutun des Nutzers und ohne Schätzwerte.
let effectiveWeekLimit = week.limit;
if (server?.weekPercent > 0 && snapshot.week.weighted > 0) {
effectiveWeekLimit = snapshot.week.weighted / (server.weekPercent / 100);
this._rememberServerLimit('week', effectiveWeekLimit, server.weekPercent);
}
let effectiveBlockLimit = block.limit;
if (server?.blockPercent > 0 && snapshot.block.weighted > 0) {
effectiveBlockLimit = snapshot.block.weighted / (server.blockPercent / 100);
this._rememberServerLimit('block', effectiveBlockLimit, server.blockPercent);
}
// Verbleibende *aktive* Arbeitszeit bis zum Wochenlimit. Bewusst nicht als
// Kalenderzeit hochgerechnet: Niemand arbeitet fünf Tage durch, eine
// Hochrechnung der Burn-Rate auf die Kalenderwoche wäre Unsinn.
const remainingWeek = Math.max(0, effectiveWeekLimit - snapshot.week.weighted);
const activeTimeLeftMs =
snapshot.burnRatePerMinute > 0
? (remainingWeek / snapshot.burnRatePerMinute) * MINUTE_MS
: null;
// Kurzfristprojektion: Wo steht das Wochenkontingent, wenn eine weitere
// Stunde in diesem Tempo gearbeitet wird?
const weekInOneHour = snapshot.week.weighted + snapshot.burnRatePerMinute * 60;
const weekProjectedPercent =
effectiveWeekLimit > 0 ? (weekInOneHour / effectiveWeekLimit) * 100 : 0;
this.state = {
updatedAt: now,
subscription: this.subscription,
block: {
...block,
hasLimit: blockHasLimit,
severity: serverSeverity(server?.blockSeverity) || severity(block.percent),
resetAt: blockResetAt,
remainingMs: blockResetAt ? Math.max(0, blockResetAt - now) : 0,
active: snapshot.block.isActive,
raw: snapshot.block.raw,
messages: snapshot.block.totals.count,
models: snapshot.block.models.map((m) => ({
model: m.model,
family: m.family,
raw: m.raw,
weighted: m.weighted,
input: m.input,
output: m.output,
cacheRead: m.cacheRead,
cacheCreate: m.cacheCreate,
})),
},
week: {
...week,
severity: serverSeverity(server?.weekSeverity) || severity(week.percent),
resetAt: weekResetAt,
rolling: rollingWeek && server?.weekResetAt == null,
remainingMs: weekResetAt ? Math.max(0, weekResetAt - now) : 0,
raw: snapshot.week.raw,
messages: snapshot.week.totals.count,
models: snapshot.week.models.slice(0, 6).map((m) => ({
model: m.model,
family: m.family,
raw: m.raw,
weighted: m.weighted,
})),
},
burn: {
perMinute: snapshot.burnRatePerMinute,
projectedWeighted: snapshot.block.projectedWeighted,
projectedPercent:
effectiveBlockLimit > 0
? (snapshot.block.projectedWeighted / effectiveBlockLimit) * 100
: 0,
weekProjectedPercent,
activeTimeLeftMs,
},
// Nutzungsguthaben — nur vorhanden, wenn der Server es liefert.
spend: server?.spend || null,
extras: server?.extras || [],
history: snapshot.blockHistory,
oauth: this.oauth.status(),
errors: this.errors.slice(),
stats: { lastPollMs: this._lastPollMs },
};
this.emit('update', this.state);
return this.state;
}
}
module.exports = { Collector };
+86
View File
@@ -0,0 +1,86 @@
'use strict';
/**
* CLI-Gegenprobe für die Datenschicht: liest alle Transkripte einmal
* vollständig und gibt Modell-Summen sowie die 5h-Blöcke aus.
*
* npm run dump
*
* Die Modell-Summen lassen sich gegen ~/.claude/stats-cache.json (modelUsage)
* abgleichen, die Blöcke gegen `npx ccusage blocks`.
*/
const { TranscriptReader, readSubscription } = require('./jsonl');
const { UsageAggregator } = require('./blocks');
const fmt = (n) => Math.round(n).toLocaleString('de-DE');
const fmtTime = (ts) =>
ts == null ? '—' : new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
async function main() {
const t0 = Date.now();
const reader = new TranscriptReader();
const { entries, bytesRead, files } = await reader.poll();
const readMs = Date.now() - t0;
const agg = new UsageAggregator();
agg.add(entries);
const sub = await readSubscription();
console.log('=== Einlesen ===');
console.log(`Dateien: ${files}`);
console.log(`Gelesen: ${(bytesRead / 1024 / 1024).toFixed(2)} MB in ${readMs} ms`);
console.log(`Einträge: ${fmt(entries.length)}`);
console.log(`Abo: ${sub.subscriptionType || '?'} (${sub.rateLimitTier || '?'})`);
console.log('\n=== Summen je Modell (gesamter Zeitraum) ===');
console.log(
['Modell'.padEnd(30), 'Input'.padStart(12), 'Output'.padStart(12), 'CacheRead'.padStart(14), 'CacheCreate'.padStart(13)].join(''),
);
const byModel = new Map();
for (const e of entries) {
let m = byModel.get(e.model);
if (!m) byModel.set(e.model, (m = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, n: 0 }));
m.input += e.input;
m.output += e.output;
m.cacheRead += e.cacheRead;
m.cacheCreate += e.cacheCreate;
m.n += 1;
}
for (const [model, m] of [...byModel].sort((a, b) => b[1].output - a[1].output)) {
console.log(
[model.padEnd(30), fmt(m.input).padStart(12), fmt(m.output).padStart(12), fmt(m.cacheRead).padStart(14), fmt(m.cacheCreate).padStart(13)].join(''),
);
}
console.log('\n=== 5h-Blöcke ===');
const blocks = agg.buildBlocks();
for (const b of blocks) {
const flag = b.isActive ? ' ← aktiv' : '';
console.log(
`${fmtTime(b.start)}${fmtTime(b.end)} roh ${fmt(b.raw).padStart(12)} gewichtet ${fmt(b.weighted).padStart(12)} (${b.totals.count} Nachrichten)${flag}`,
);
for (const m of b.models) {
console.log(` ${m.model.padEnd(30)} roh ${fmt(m.raw).padStart(12)}`);
}
}
const snap = agg.snapshot();
console.log('\n=== Snapshot ===');
console.log(`Aktiver Block: ${snap.block.isActive ? `${fmt(snap.block.weighted)} gewichtet, Reset ${fmtTime(snap.block.end)}` : 'keiner'}`);
console.log(`Woche (7 Tage): ${fmt(snap.week.weighted)} gewichtet, ${fmt(snap.week.raw)} roh`);
console.log(`Burn-Rate: ${fmt(snap.burnRatePerMinute)} gewichtet/min`);
// Zweiter Poll: muss ohne neue Daten praktisch nichts lesen.
const t1 = Date.now();
const second = await reader.poll();
console.log(
`\nZweiter Poll: ${second.entries.length} neue Einträge, ${second.bytesRead} Bytes, ${Date.now() - t1} ms`,
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+373
View File
@@ -0,0 +1,373 @@
'use strict';
/**
* Einstiegspunkt: rahmenloses Always-on-Top-Fenster, Tray-Symbol und die
* Verdrahtung zum Collector.
*/
const fs = require('node:fs');
const path = require('node:path');
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell } = require('electron');
const { Store, loadConfig } = require('./store');
const { Collector } = require('./collector');
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
const COMPACT_HEIGHT = 352;
const EXPANDED_HEIGHT = 640;
const WIDTH = 320;
const MARGIN = 16;
const ROOT = path.join(__dirname, '..', '..');
const SETTINGS_WIDTH = 520;
const SETTINGS_HEIGHT = 660;
let win = null;
let settingsWin = null;
let tray = null;
let collector = null;
let store = null;
let config = {};
/** Verhindert, dass mehrere Instanzen dieselben Zustandsdateien beschreiben. */
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => showWindow());
}
/**
* Stellt die gespeicherte Position wieder her — aber nur, wenn sie auf einem
* aktuell vorhandenen Bildschirm liegt. Nach einem Monitorwechsel läge das
* Fenster sonst unsichtbar außerhalb.
*/
function resolvePosition(width, height) {
const saved = store.data.window;
if (saved.x != null && saved.y != null) {
const displays = screen.getAllDisplays();
const visible = displays.some((d) => {
const b = d.workArea;
return (
saved.x + width > b.x + 40 &&
saved.x < b.x + b.width - 40 &&
saved.y + 40 > b.y &&
saved.y < b.y + b.height - 40
);
});
if (visible) return { x: saved.x, y: saved.y };
}
// Voreinstellung: rechts oben auf dem Hauptbildschirm
const area = screen.getPrimaryDisplay().workArea;
return { x: area.x + area.width - width - MARGIN, y: area.y + MARGIN };
}
function createWindow() {
const expanded = Boolean(store.data.window.expanded);
const height = expanded ? EXPANDED_HEIGHT : COMPACT_HEIGHT;
const { x, y } = resolvePosition(WIDTH, height);
win = new BrowserWindow({
width: WIDTH,
height,
x,
y,
frame: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
show: false,
backgroundColor: '#00000000',
icon: path.join(ROOT, 'assets', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// 'screen-saver' legt das Fenster auch über Vollbildanwendungen.
win.setAlwaysOnTop(true, 'screen-saver');
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
win.once('ready-to-show', () => win.show());
// Position sichern, sobald der Nutzer das Fenster losgelassen hat.
win.on('moved', () => {
const [nx, ny] = win.getPosition();
store.data.window.x = nx;
store.data.window.y = ny;
store.save();
});
// Externe Links nie im Widget selbst öffnen.
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
win.on('closed', () => {
win = null;
});
}
function showWindow() {
if (!win) return createWindow();
win.show();
win.setAlwaysOnTop(true, 'screen-saver');
}
function toggleWindow() {
if (!win) return createWindow();
if (win.isVisible()) win.hide();
else showWindow();
}
function setOpacity(value) {
config.opacity = value;
if (win) win.setOpacity(value);
buildTrayMenu();
}
function setClickThrough(enabled) {
config.clickThrough = enabled;
// forward: true lässt Hover-Effekte weiterlaufen, ohne Klicks abzufangen.
if (win) win.setIgnoreMouseEvents(enabled, { forward: true });
buildTrayMenu();
}
function setAutostart(enabled) {
app.setLoginItemSettings({ openAtLogin: enabled, args: [] });
buildTrayMenu();
}
function buildTrayMenu() {
if (!tray) return;
const autostart = app.getLoginItemSettings().openAtLogin;
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Dashboard anzeigen', click: showWindow },
{ label: 'Ausblenden', click: () => win && win.hide() },
{ type: 'separator' },
{
label: 'Deckkraft',
submenu: [1, 0.9, 0.75, 0.6].map((v) => ({
label: `${Math.round(v * 100)} %`,
type: 'radio',
checked: (config.opacity ?? 1) === v,
click: () => setOpacity(v),
})),
},
{
label: 'Klicks durchlassen',
type: 'checkbox',
checked: Boolean(config.clickThrough),
click: (item) => setClickThrough(item.checked),
},
{
label: 'Mit Windows starten',
type: 'checkbox',
checked: autostart,
click: (item) => setAutostart(item.checked),
},
{ type: 'separator' },
{ label: 'Einstellungen …', click: openSettings },
{ label: 'Datenordner öffnen', click: () => shell.openPath(USER_DATA_DIR) },
{ type: 'separator' },
{ label: 'Beenden', click: () => app.quit() },
]),
);
}
function createTray() {
const icon = nativeImage.createFromPath(path.join(ROOT, 'assets', 'tray.png'));
tray = new Tray(icon);
tray.setToolTip('Claude Live Dashboard');
tray.on('click', toggleWindow);
buildTrayMenu();
}
/** Höhe an den Aufklappzustand anpassen, Position beibehalten. */
function applyExpanded(expanded) {
store.data.window.expanded = expanded;
store.save();
if (!win) return;
const [x, y] = win.getPosition();
win.setBounds({ x, y, width: WIDTH, height: expanded ? EXPANDED_HEIGHT : COMPACT_HEIGHT }, true);
}
/** Einstellungsdialog — ein Fenster, mehrfaches Öffnen holt es nur nach vorn. */
function openSettings() {
if (settingsWin && !settingsWin.isDestroyed()) {
settingsWin.show();
settingsWin.focus();
return;
}
// Auf dem Bildschirm zentrieren, auf dem das Widget steht — bei mehreren
// Monitoren landet der Dialog sonst irgendwo abseits.
const target = win && !win.isDestroyed()
? screen.getDisplayMatching(win.getBounds())
: screen.getPrimaryDisplay();
const area = target.workArea;
settingsWin = new BrowserWindow({
width: SETTINGS_WIDTH,
height: SETTINGS_HEIGHT,
x: Math.round(area.x + (area.width - SETTINGS_WIDTH) / 2),
y: Math.round(area.y + Math.max(0, (area.height - SETTINGS_HEIGHT) / 2)),
minWidth: 440,
minHeight: 420,
frame: false,
resizable: true,
skipTaskbar: false,
// Über dem Widget, das selbst always-on-top ist — sonst verschwindet der
// Dialog dahinter.
alwaysOnTop: true,
show: false,
backgroundColor: '#1e1e22',
icon: path.join(ROOT, 'assets', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, '..', 'settings-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
settingsWin.loadFile(path.join(__dirname, '..', 'settings', 'index.html'));
settingsWin.once('ready-to-show', () => settingsWin.show());
settingsWin.on('closed', () => {
settingsWin = null;
});
}
/**
* Übernimmt geänderte Einstellungen ohne Neustart und schreibt sie in die
* Datei. Unbekannte Felder und die `_`-Kommentare bleiben erhalten, weil die
* vorhandene Datei gelesen und nur ergänzt wird.
*/
function applySettings(patch) {
let raw = {};
try {
raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(`config.json nicht lesbar: ${err.message}`);
}
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete raw[key];
else if (value && typeof value === 'object' && !Array.isArray(value)) {
const merged = { ...(raw[key] || {}), ...value };
// Leere Werte entfernen, damit "automatisch" auch wirklich automatisch ist.
for (const [k, v] of Object.entries(value)) {
if (v === undefined || v === '' || v === null) delete merged[k];
}
raw[key] = merged;
} else raw[key] = value;
}
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
config = raw;
if (win && !win.isDestroyed()) {
win.setOpacity(config.opacity ?? 1);
win.setIgnoreMouseEvents(Boolean(config.clickThrough), { forward: true });
}
if (collector) collector.applyConfig(config);
buildTrayMenu();
}
function registerIpc() {
ipcMain.handle('get-state', () => (collector ? collector.state : null));
ipcMain.handle('get-ui-state', () => ({ expanded: Boolean(store.data.window.expanded) }));
ipcMain.on('set-expanded', (_event, expanded) => applyExpanded(Boolean(expanded)));
ipcMain.on('hide-window', () => win && win.hide());
ipcMain.on('open-settings', openSettings);
ipcMain.handle('settings:load', () => ({
config,
meta: {
configPath: CONFIG_FILE,
autostart: app.getLoginItemSettings().openAtLogin,
},
}));
ipcMain.handle('settings:save', (_event, patch) => {
try {
applySettings(patch || {});
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('settings:autostart', (_event, enabled) => {
setAutostart(Boolean(enabled));
return { ok: true };
});
ipcMain.on('settings:open-file', () => shell.openPath(CONFIG_FILE));
ipcMain.on('settings:close', () => settingsWin && settingsWin.close());
}
async function main() {
await app.whenReady();
// Beim ersten Start die mitgelieferte Vorlage ins Benutzerprofil kopieren —
// im Anwendungsverzeichnis wäre sie nach der Installation nicht beschreibbar.
ensureUserConfig();
const { config: loaded, error } = loadConfig(CONFIG_FILE);
config = loaded;
store = new Store(STATE_FILE);
store.load();
registerIpc();
createWindow();
createTray();
if (config.opacity != null) win.setOpacity(config.opacity);
if (config.clickThrough) win.setIgnoreMouseEvents(true, { forward: true });
collector = new Collector({ config, store, configError: error });
collector.on('update', (state) => {
if (win && !win.isDestroyed()) win.webContents.send('state', state);
if (tray) {
const parts = [`Claude · Woche ${Math.round(state.week.percent)} %`];
if (state.block.hasLimit) parts.push(`5h ${Math.round(state.block.percent)} %`);
if (state.burn.activeTimeLeftMs != null) {
const h = Math.floor(state.burn.activeTimeLeftMs / 3600000);
parts.push(h > 0 ? `noch ~${h} Std. Arbeit` : 'unter 1 Std. Arbeit');
}
tray.setToolTip(parts.join(' · '));
}
});
try {
await collector.start();
} catch (err) {
console.error('[collector] Start fehlgeschlagen:', err);
}
}
// Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist.
app.on('window-all-closed', () => {});
app.on('before-quit', () => {
if (collector) collector.stop();
});
main().catch((err) => {
console.error(err);
app.quit();
});
+245
View File
@@ -0,0 +1,245 @@
'use strict';
/**
* Inkrementeller Reader für die Claude-Code-Transkripte.
*
* Die Transkripte unter ~/.claude/projects/**\/*.jsonl umfassen zusammen
* zweistellige Megabyte und wachsen im Sekundentakt. Sie bei jedem Poll
* vollständig zu parsen wäre nicht tragbar, deshalb merkt sich der Reader je
* Datei einen Byte-Offset und liest nur das Delta.
*/
const fsp = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const CLAUDE_HOME =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
const CREDENTIALS_FILE = path.join(CLAUDE_HOME, '.credentials.json');
/** Obergrenze je Datei und Poll, damit ein Erstlauf den Speicher nicht sprengt. */
const MAX_CHUNK_BYTES = 32 * 1024 * 1024;
/** Modelle ohne echten Verbrauch, die Claude Code trotzdem protokolliert. */
const SYNTHETIC_MODELS = new Set(['<synthetic>', 'synthetic', '<none>']);
/**
* Ordnet eine Modell-ID einer Familie zu — die Plan-Limits gewichten nach
* Familie, nicht nach exakter Version.
* @param {string} model z.B. "claude-haiku-4-5-20251001"
* @returns {string} "opus" | "sonnet" | "haiku" | "unknown"
*/
function modelFamily(model) {
if (!model) return 'unknown';
const m = model.toLowerCase();
if (m.includes('opus')) return 'opus';
if (m.includes('sonnet')) return 'sonnet';
if (m.includes('haiku')) return 'haiku';
if (m.includes('fable')) return 'fable';
return 'unknown';
}
/**
* Findet alle Transkript-Dateien.
* @returns {Promise<string[]>} absolute Pfade
*/
async function findTranscripts(dir = PROJECTS_DIR) {
let dirents;
try {
dirents = await fsp.readdir(dir, { withFileTypes: true, recursive: true });
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
return dirents
.filter((d) => d.isFile() && d.name.endsWith('.jsonl'))
.map((d) => path.join(d.parentPath || d.path, d.name));
}
/**
* Liest die seit `fromOffset` hinzugekommenen *vollständigen* Zeilen.
*
* Da die Datei währenddessen weitergeschrieben wird, endet der gelesene Block
* fast immer mitten in einer Zeile. Deshalb wird nur bis zum letzten Newline
* ausgewertet und der Offset genau dorthin gesetzt — der angefangene Rest wird
* beim nächsten Poll erneut gelesen.
*
* @returns {Promise<{lines: string[], newOffset: number, size: number}>}
*/
async function readNewLines(filePath, fromOffset) {
const fh = await fsp.open(filePath, 'r');
try {
const { size } = await fh.stat();
// Datei geschrumpft (Rewrite/Kompaktierung) → von vorn lesen.
let start = fromOffset;
if (size < start) start = 0;
if (size <= start) return { lines: [], newOffset: start, size };
const len = Math.min(size - start, MAX_CHUNK_BYTES);
const buf = Buffer.allocUnsafe(len);
await fh.read(buf, 0, len, start);
const lastNl = buf.lastIndexOf(0x0a);
if (lastNl === -1) {
// Keine vollständige Zeile im Block — beim nächsten Poll erneut versuchen.
return { lines: [], newOffset: start, size };
}
const text = buf.toString('utf8', 0, lastNl + 1);
const lines = text.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
return { lines, newOffset: start + lastNl + 1, size };
} finally {
await fh.close();
}
}
/**
* Extrahiert einen Verbrauchseintrag aus einer Transkript-Zeile.
* @returns {null | {id: string, ts: number, model: string, family: string,
* input: number, output: number, cacheRead: number, cacheCreate: number}}
*/
function parseLine(line) {
if (!line || line.charCodeAt(0) !== 0x7b /* '{' */) return null;
// Billiger Vorfilter: über 90 % der Zeilen sind User-Nachrichten und
// Tool-Ergebnisse ohne Usage-Block.
if (!line.includes('"usage"')) return null;
let rec;
try {
rec = JSON.parse(line);
} catch {
return null;
}
if (rec.type !== 'assistant') return null;
const msg = rec.message;
if (!msg || !msg.usage) return null;
const model = msg.model;
if (!model || SYNTHETIC_MODELS.has(model)) return null;
const ts = Date.parse(rec.timestamp);
if (!Number.isFinite(ts)) return null;
const u = msg.usage;
return {
// Dedup-Schlüssel aus Nachrichten- UND Anfrage-ID. Die Nachrichten-ID
// allein greift zu scharf: Setzt ein Retry dieselbe Nachricht unter neuer
// Anfrage-ID fort, ist das echter zusätzlicher Verbrauch. Entspricht dem
// Verfahren von ccusage.
id: `${msg.id || rec.uuid || ''}:${rec.requestId || ''}:${msg.id ? '' : ts}`,
ts,
model,
family: modelFamily(model),
input: u.input_tokens || 0,
output: u.output_tokens || 0,
cacheRead: u.cache_read_input_tokens || 0,
cacheCreate: u.cache_creation_input_tokens || 0,
};
}
/**
* Liest die Abo-Informationen aus den Claude-Code-Credentials.
*
* Es wird ausschließlich der Plan-Typ gelesen; die Tokens selbst rührt diese
* Funktion nicht an (siehe oauth-usage.js für die optionale Stufe 2).
*
* @returns {Promise<{subscriptionType: string|null, rateLimitTier: string|null}>}
*/
async function readSubscription() {
try {
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
const oauth = JSON.parse(raw).claudeAiOauth || {};
return {
subscriptionType: oauth.subscriptionType || null,
rateLimitTier: oauth.rateLimitTier || null,
};
} catch {
return { subscriptionType: null, rateLimitTier: null };
}
}
/**
* Hält die Offsets und die Dedup-Menge über mehrere Polls hinweg.
*
* Dedup ist nicht optional: Claude Code schreibt dieselbe Assistant-Nachricht
* bei Retries und beim Kompaktieren einer Session mehrfach ins Transkript.
*/
class TranscriptReader {
/** @param {{offsets?: Object}} persisted zuvor gespeicherter Zustand */
constructor(persisted = {}) {
/** @type {Record<string, {offset: number, size: number}>} */
this.offsets = persisted.offsets || {};
/** @type {Set<string>} */
this.seen = new Set();
this.maxSeen = 60000;
this.trimTo = 50000;
}
/** Begrenzt die Dedup-Menge; Sets behalten die Einfügereihenfolge. */
_trimSeen() {
if (this.seen.size <= this.maxSeen) return;
const drop = this.seen.size - this.trimTo;
let i = 0;
for (const key of this.seen) {
if (i++ >= drop) break;
this.seen.delete(key);
}
}
/**
* Liest alle seit dem letzten Aufruf hinzugekommenen Einträge.
* @returns {Promise<{entries: Array, bytesRead: number, files: number}>}
*/
async poll() {
const files = await findTranscripts();
const entries = [];
let bytesRead = 0;
for (const file of files) {
const prev = this.offsets[file] || { offset: 0, size: 0 };
let result;
try {
result = await readNewLines(file, prev.offset);
} catch (err) {
// Eine gesperrte oder gelöschte Datei darf den Poll nicht abbrechen.
if (err.code === 'ENOENT' || err.code === 'EBUSY') continue;
throw err;
}
bytesRead += result.newOffset - prev.offset;
this.offsets[file] = { offset: result.newOffset, size: result.size };
for (const line of result.lines) {
const entry = parseLine(line);
if (!entry || this.seen.has(entry.id)) continue;
this.seen.add(entry.id);
entries.push(entry);
}
}
this._trimSeen();
entries.sort((a, b) => a.ts - b.ts);
return { entries, bytesRead, files: files.length };
}
/** Serialisierbarer Zustand für store.js. */
serialize() {
return { offsets: this.offsets };
}
}
module.exports = {
CLAUDE_HOME,
PROJECTS_DIR,
CREDENTIALS_FILE,
TranscriptReader,
findTranscripts,
readNewLines,
parseLine,
modelFamily,
readSubscription,
};
+159
View File
@@ -0,0 +1,159 @@
'use strict';
/**
* Plan-Erkennung, Limit-Referenzwerte und Auto-Kalibrierung.
*
* Anthropic veröffentlicht die Plan-Limits nicht als Token-Zahlen, und es gibt
* keine dokumentierte lokale Schnittstelle dafür. Der Referenzwert für "100 %"
* wird deshalb kalibriert: Das höchste je beobachtete Blockvolumen gilt als
* untere Schranke des Limits und wird ausschließlich nach oben korrigiert.
*
* Damit die Anzeige ehrlich bleibt, liefert jede Auswertung eine
* Vertrauensstufe mit:
* exact — vom Server gemeldet (OAuth-Stufe aktiv)
* configured — vom Nutzer in config.json gesetzt
* calibrated — aus genügend eigenen Messungen abgeleitet
* estimate — zu wenig Daten, Wert ist ein grober Anhaltspunkt
*/
const { DAY_MS } = require('./blocks');
/** Ab so vielen beobachteten Blöcken gilt die Kalibrierung als belastbar. */
const CALIBRATION_MIN_SAMPLES = 5;
/**
* Anhaltswerte je Abo für den allerersten Start, solange keine eigenen
* Messungen vorliegen. Bewusst grob — sie werden von der Kalibrierung
* überschrieben, sobald reale Blöcke vorliegen.
*/
const PLAN_SEED = {
// pro.week ist gemessen: 34,3 Mio. gewichtete Tokens entsprachen laut
// Einstellungen ▸ Nutzung 14 % (Juli 2026). pro.block ist dagegen ungeprüft
// — für das 5-Stunden-Fenster lag kein Referenzwert vor.
pro: { block: 12_000_000, week: 250_000_000 },
max: { block: 60_000_000, week: 300_000_000 },
max_5x: { block: 60_000_000, week: 300_000_000 },
max_20x: { block: 240_000_000, week: 1_200_000_000 },
default: { block: 12_000_000, week: 60_000_000 },
};
/**
* Leitet den Beginn des Wochenfensters ab.
*
* Ohne Serverauskunft ist die ehrlichste Annahme ein rollierendes 7-Tage-
* Fenster. Wer den echten Reset-Wochentag kennt, trägt ihn als
* `weekResetWeekday` (0 = Sonntag) und `weekResetHour` in config.json ein.
*/
function weekAnchor(now, config = {}) {
const weekday = config.weekResetWeekday;
if (weekday == null) return now - 7 * DAY_MS; // rollierend
const hour = config.weekResetHour ?? 0;
const minute = config.weekResetMinute ?? 0;
const anchor = new Date(now);
anchor.setHours(hour, minute, 0, 0);
const diff = (anchor.getDay() - weekday + 7) % 7;
anchor.setDate(anchor.getDate() - diff);
if (anchor.getTime() > now) anchor.setDate(anchor.getDate() - 7);
return anchor.getTime();
}
class LimitModel {
/**
* @param {Object} config Nutzer-Konfiguration
* @param {Object} calibration persistierter Kalibrierungszustand aus store.js
* @param {{subscriptionType: string|null}} subscription
*/
constructor(config, calibration, subscription) {
this.config = config || {};
this.calibration = calibration;
this.subscription = subscription || {};
this.seed = PLAN_SEED[this.subscription.subscriptionType] || PLAN_SEED.default;
}
/**
* Aktualisiert die Kalibrierung anhand abgeschlossener Blöcke.
* Nur abgeschlossene Blöcke zählen — ein noch laufender Block sagt nichts
* über das Maximum aus.
* @returns {boolean} true, wenn sich etwas geändert hat
*/
observe(snapshot, blocks) {
let changed = false;
const finished = blocks.filter((b) => !b.isActive);
// `samples` misst gesammelte Erfahrung, nicht den aktuellen Fensterinhalt.
// Der Aggregator hält nur 8 Tage vor — würde hier direkt zugewiesen, fiele
// die Kalibrierungsgüte nach einer Pause wieder auf null zurück, obwohl
// blockMax erhalten bleibt. Das Maximum wächst monoton und zählt bei
// Neustarts dieselben Blöcke nicht doppelt.
if (finished.length > this.calibration.samples) {
this.calibration.samples = finished.length;
changed = true;
}
for (const b of finished) {
if (b.weighted > this.calibration.blockMax) {
this.calibration.blockMax = b.weighted;
changed = true;
}
}
// Auch der laufende Block darf die untere Schranke anheben, sobald er das
// bisherige Maximum überschreitet — das Limit ist dann nachweislich höher.
if (snapshot.block.weighted > this.calibration.blockMax) {
this.calibration.blockMax = snapshot.block.weighted;
changed = true;
}
if (snapshot.week.weighted > this.calibration.weekMax) {
this.calibration.weekMax = snapshot.week.weighted;
changed = true;
}
return changed;
}
/**
* Referenzwert und Vertrauensstufe für ein Fenster.
* @param {'block'|'week'} kind
* @param {number|null} serverLimit exakter Wert aus der OAuth-Stufe
*/
limitFor(kind, serverLimit = null) {
if (serverLimit != null && serverLimit > 0) {
return { limit: serverLimit, confidence: 'exact' };
}
const configured = (this.config.limits || {})[kind];
if (configured != null && configured > 0) {
return { limit: configured, confidence: 'configured' };
}
// Aus einer früheren Serverantwort abgeleitet — deutlich belastbarer als
// das bloß beobachtete Maximum und überbrückt Ausfälle der Abfrage.
const fromServer = (this.calibration.serverLimits || {})[kind];
if (fromServer > 0) {
return { limit: fromServer, confidence: 'calibrated' };
}
const observed = kind === 'block' ? this.calibration.blockMax : this.calibration.weekMax;
const seed = this.seed[kind];
if (this.calibration.samples >= CALIBRATION_MIN_SAMPLES && observed > 0) {
// Das beobachtete Maximum ist eine untere Schranke des echten Limits;
// der Puffer verhindert, dass die Anzeige dauerhaft bei 100 % klebt.
return { limit: Math.max(observed * 1.15, seed * 0.5), confidence: 'calibrated' };
}
return { limit: Math.max(seed, observed * 1.15), confidence: 'estimate' };
}
/**
* Rechnet einen gewichteten Verbrauch in einen Anzeigewert um.
* @returns {{used: number, limit: number, percent: number, confidence: string}}
*/
evaluate(kind, weighted, serverLimit = null) {
const { limit, confidence } = this.limitFor(kind, serverLimit);
const percent = limit > 0 ? Math.min(999, (weighted / limit) * 100) : 0;
return { used: weighted, limit, percent, confidence };
}
}
/** Schwellen für die Farbcodierung der UI. */
function severity(percent) {
if (percent >= 85) return 'critical';
if (percent >= 60) return 'warn';
return 'ok';
}
module.exports = { LimitModel, weekAnchor, severity, PLAN_SEED, CALIBRATION_MIN_SAMPLES };
+262
View File
@@ -0,0 +1,262 @@
'use strict';
/**
* Stufe 2: exakter Limit-Status vom Server.
*
* Claude Code zeigt unter `/usage` Werte an, die es von einem Endpunkt bezieht,
* der weder dokumentiert noch stabil ist. Dieses Modul kann ihn abfragen, ist
* aber vollständig gekapselt: Fällt es aus, läuft das Dashboard unverändert auf
* der lokalen Berechnung weiter.
*
* ── Endpunkt ermitteln ──────────────────────────────────────────────────────
* Der Endpunkt wird NICHT geraten. Vorgehen:
* 1. `claude --debug api` starten
* 2. in der Sitzung `/usage` aufrufen
* 3. die Request-URL aus der Debug-Ausgabe ablesen
* 4. sie in config.json unter `oauth.endpoint` eintragen und
* `oauth.enabled: true` setzen
*
* Ohne diesen Eintrag bleibt das Modul inaktiv.
*
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe.
*/
const fsp = require('node:fs/promises');
const { CREDENTIALS_FILE } = require('./jsonl');
/** Standard-Endpunkt, ermittelt aus der Claude-Code-Installation. */
const DEFAULT_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
/**
* Abstand zwischen zwei Abfragen. Die Werte ändern sich nur langsam, und der
* Endpunkt ist ratenbegrenzt — häufiger zu fragen bringt nichts und riskiert
* ein 429.
*/
const MIN_INTERVAL_MS = 3 * 60 * 1000;
/** Nach so vielen Fehlversuchen wird pausiert — nicht dauerhaft abgeschaltet. */
const MAX_FAILURES = 3;
/** Länge dieser Pause; danach wird wieder versucht. */
const COOLDOWN_MS = 15 * 60 * 1000;
/** Pause nach einem 429, falls der Server kein Retry-After mitschickt. */
const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 10000;
/** Nur diese Hosts dürfen angesprochen werden. */
const ALLOWED_HOSTS = new Set(['api.anthropic.com', 'console.anthropic.com', 'claude.ai']);
class OAuthUsage {
/**
* @param {{enabled?: boolean, endpoint?: string, betaHeader?: string}} config
* @param {import('./store').Store} store
*/
constructor(config = {}, store = null) {
this.config = config;
this.store = store;
// Standardmäßig aktiv: Die Serverwerte sind exakt, alles andere ist
// Näherung. Abschaltbar über "oauth": { "enabled": false }.
this.wanted = config.enabled !== false;
this.endpoint = config.endpoint || DEFAULT_ENDPOINT;
this.enabled = this.wanted;
this.failures = 0;
// Abfragezeitpunkt und Pause überdauern Neustarts. Ohne das führt jeder
// Start sofort eine Abfrage aus — bei mehreren Starts kurz nacheinander
// reicht das, um in die Ratenbegrenzung zu laufen.
const persisted = (store && store.data && store.data.oauth) || {};
this.lastFetch = persisted.lastFetch || 0;
this.pausedUntil = persisted.pausedUntil || 0;
this.lastResult = null;
this.lastError = null;
if (this.enabled && !this._hostAllowed(this.endpoint)) {
this.enabled = false;
this.lastError = 'oauth.endpoint zeigt nicht auf einen Anthropic-Host';
}
}
/** Sichert Abfragezeitpunkt und Pause über Neustarts hinweg. */
_persist() {
if (!this.store) return;
this.store.data.oauth = {
...(this.store.data.oauth || {}),
lastFetch: this.lastFetch,
pausedUntil: this.pausedUntil,
};
this.store.save();
}
_hostAllowed(endpoint) {
try {
const url = new URL(endpoint);
return url.protocol === 'https:' && ALLOWED_HOSTS.has(url.hostname);
} catch {
return false;
}
}
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
async _token() {
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
const oauth = JSON.parse(raw).claudeAiOauth;
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
throw new Error('Zugriffstoken abgelaufen — Claude Code erneuert es beim nächsten Start');
}
return oauth.accessToken;
}
/**
* Fragt den Endpunkt ab, höchstens einmal pro Minute.
* @returns {Promise<{blockLimit: number|null, weekLimit: number|null,
* blockPercent: number|null, weekPercent: number|null,
* blockResetAt: number|null, weekResetAt: number|null} | null>}
*/
async fetch(now = Date.now()) {
if (!this.enabled) return null;
// Pausiert nach Fehlern oder Ratenbegrenzung — der zuletzt bekannte Stand
// bleibt gültig, bis die Pause abgelaufen ist.
if (now < this.pausedUntil) return this.lastResult;
if (now - this.lastFetch < MIN_INTERVAL_MS) return this.lastResult;
this.lastFetch = now;
this._persist();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const token = await this._token();
const res = await fetch(this.endpoint, {
method: 'GET',
signal: controller.signal,
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': this.config.betaHeader || 'oauth-2025-04-20',
Accept: 'application/json',
},
});
if (res.status === 429) {
// Ratenbegrenzung ist vorübergehend und kein Grund, die Abfrage
// aufzugeben — es wird nur länger gewartet.
const retryAfter = Number(res.headers.get('retry-after'));
const wait = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: RATE_LIMIT_COOLDOWN_MS;
this.pausedUntil = now + wait;
this._persist();
this.lastError = `zu viele Anfragen — Pause bis ${new Date(this.pausedUntil).toLocaleTimeString('de-DE')}`;
return this.lastResult;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
this.failures = 0;
this.pausedUntil = 0;
this.lastError = null;
this.lastResult = this._normalize(json);
this._persist();
return this.lastResult;
} catch (err) {
this.failures += 1;
// Nur die Fehlerart, nie Header oder Token protokollieren.
this.lastError = err.name === 'AbortError' ? 'Zeitüberschreitung' : err.message;
if (this.failures >= MAX_FAILURES) {
this.failures = 0;
this.pausedUntil = now + COOLDOWN_MS;
this._persist();
this.lastError += ` — Pause bis ${new Date(this.pausedUntil).toLocaleTimeString('de-DE')}`;
}
return this.lastResult;
} finally {
clearTimeout(timer);
}
}
/**
* Bringt die Antwort in die interne Form.
*
* Die Antwort führt dieselben Werte doppelt: einmal als benannte Felder
* (`five_hour`, `seven_day`) und einmal im Array `limits` mit `kind`,
* `percent`, `severity` und `resets_at`. Das Array ist die reichere Quelle
* und hat Vorrang; die benannten Felder dienen als Rückfallebene.
*
* Bewusst tolerant: Das Schema ist nicht zugesichert, und die Antwort
* enthält etliche Felder, die je nach Abo `null` sind.
*/
_normalize(json) {
const toMs = (v) => {
if (v == null) return null;
if (typeof v === 'number') return v > 1e12 ? v : v * 1000;
const parsed = Date.parse(v);
return Number.isFinite(parsed) ? parsed : null;
};
const pct = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const byKind = new Map();
for (const entry of Array.isArray(json.limits) ? json.limits : []) {
if (entry && entry.kind) byKind.set(entry.kind, entry);
}
const session = byKind.get('session') || json.five_hour || {};
const week = byKind.get('weekly_all') || json.seven_day || {};
// Zusätzliche Wochenkontingente, die nur manche Abos führen.
const extras = [];
for (const [kind, label] of [
['weekly_opus', 'Opus'],
['weekly_sonnet', 'Sonnet'],
]) {
const e = byKind.get(kind) || json[`seven_day_${label.toLowerCase()}`];
if (e && pct(e.percent ?? e.utilization) != null) {
extras.push({
label,
percent: pct(e.percent ?? e.utilization),
resetAt: toMs(e.resets_at),
severity: e.severity || null,
});
}
}
// Nutzungsguthaben: Beträge kommen als kleinste Währungseinheit.
let spend = null;
const s = json.spend;
if (s && s.used && s.limit) {
const toMajor = (m) => m.amount_minor / 10 ** (m.exponent ?? 2);
spend = {
used: toMajor(s.used),
limit: toMajor(s.limit),
currency: s.limit.currency || s.used.currency || 'EUR',
percent: pct(s.percent),
severity: s.severity || null,
enabled: s.enabled !== false,
};
}
return {
raw: json,
blockPercent: pct(session.percent ?? session.utilization),
weekPercent: pct(week.percent ?? week.utilization),
blockResetAt: toMs(session.resets_at),
weekResetAt: toMs(week.resets_at),
blockSeverity: session.severity || null,
weekSeverity: week.severity || null,
extras,
spend,
// Der Endpunkt nennt keine Token-Obergrenzen, nur Prozentwerte.
blockLimit: null,
weekLimit: null,
};
}
/** Statuszeile für die Detailansicht. */
status() {
if (!this.wanted) return { active: false, text: 'abgeschaltet' };
if (!this.enabled) return { active: false, text: this.lastError || 'nicht konfiguriert' };
if (this.lastError) return { active: false, text: this.lastError };
return { active: Boolean(this.lastResult), text: this.lastResult ? 'aktiv' : 'wartet' };
}
}
module.exports = { OAuthUsage };
+62
View File
@@ -0,0 +1,62 @@
'use strict';
/**
* Zentrale Pfadauflösung — die einzige Stelle, die weiß, wo Konfiguration und
* Zustand liegen.
*
* Wichtig für die installierte Fassung: Das Anwendungsverzeichnis steckt dann
* in einem asar-Archiv und ist schreibgeschützt. Konfiguration und Zustand
* gehören deshalb ins Benutzerprofil. `calibrate.js` läuft außerhalb von
* Electron und kann `app.getPath()` nicht nutzen — darum wird der Pfad hier
* ohne Electron-Abhängigkeit hergeleitet und von beiden Seiten verwendet.
*/
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
/** Muss mit `productName` in package.json übereinstimmen — Electron leitet
* seinen userData-Pfad daraus ab. */
const APP_NAME = 'Claude Live Dashboard';
function userDataDir() {
if (process.platform === 'win32') {
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return path.join(base, APP_NAME);
}
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support', APP_NAME);
}
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), APP_NAME);
}
const USER_DATA_DIR = userDataDir();
const CONFIG_FILE = path.join(USER_DATA_DIR, 'config.json');
const STATE_FILE = path.join(USER_DATA_DIR, 'state.json');
/** Mitgelieferte Vorlage im Anwendungsverzeichnis (nur lesend). */
const CONFIG_TEMPLATE = path.join(__dirname, '..', '..', 'config.json');
/**
* Legt beim ersten Start eine Konfiguration im Benutzerprofil an.
* @returns {boolean} true, wenn eine Datei neu angelegt wurde
*/
function ensureUserConfig() {
if (fs.existsSync(CONFIG_FILE)) return false;
try {
fs.mkdirSync(USER_DATA_DIR, { recursive: true });
if (fs.existsSync(CONFIG_TEMPLATE)) {
fs.copyFileSync(CONFIG_TEMPLATE, CONFIG_FILE);
} else {
fs.writeFileSync(CONFIG_FILE, '{}\n', 'utf8');
}
return true;
} catch (err) {
// Ohne eigene Konfiguration laufen die Voreinstellungen — kein Grund,
// den Start abzubrechen.
console.warn(`[paths] Konfiguration konnte nicht angelegt werden: ${err.message}`);
return false;
}
}
module.exports = { APP_NAME, USER_DATA_DIR, CONFIG_FILE, STATE_FILE, CONFIG_TEMPLATE, ensureUserConfig };
+118
View File
@@ -0,0 +1,118 @@
'use strict';
/**
* Persistenz für Datei-Offsets, Kalibrierungswerte und Fensterposition.
*
* Bewusst ohne Electron-Abhängigkeit, damit die Datenschicht auch per CLI
* testbar bleibt — der Speicherpfad wird hereingereicht.
*/
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
/**
* Datei-Offsets stehen bewusst NICHT hier: Sie gelten nur innerhalb einer
* Sitzung. Persistiert würden sie beim nächsten Start die gesamte Historie
* überspringen, weil der Aggregator leer beginnt.
*/
const DEFAULT_STATE = {
version: 1,
// serverLimits: aus früheren Serverantworten abgeleitete Bezugsgrößen. Sie
// überbrücken Ausfälle der Abfrage, ohne dass die Anzeige einbricht.
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
window: { x: null, y: null, expanded: false },
oauth: { lastFetch: 0, pausedUntil: 0 },
};
class Store {
/** @param {string} filePath Pfad zu state.json */
constructor(filePath) {
this.filePath = filePath;
this.data = structuredClone(DEFAULT_STATE);
this._writeTimer = null;
this._writing = false;
this._dirty = false;
}
load() {
try {
const raw = fs.readFileSync(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
// Flach mergen, damit neue Felder späterer Versionen Defaults bekommen.
this.data = {
...DEFAULT_STATE,
...parsed,
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
};
} catch (err) {
if (err.code !== 'ENOENT') {
// Beschädigter Zustand darf den Start nicht verhindern.
console.warn(`[store] ${this.filePath} nicht lesbar (${err.message}) — Defaults werden verwendet.`);
}
this.data = structuredClone(DEFAULT_STATE);
}
return this.data;
}
/** Markiert den Zustand als änderungsbedürftig; geschrieben wird gebündelt. */
save({ immediate = false } = {}) {
this._dirty = true;
if (immediate) return this._flush();
if (this._writeTimer) return Promise.resolve();
this._writeTimer = setTimeout(() => {
this._writeTimer = null;
this._flush().catch((err) => console.warn(`[store] Schreiben fehlgeschlagen: ${err.message}`));
}, 2000);
if (this._writeTimer.unref) this._writeTimer.unref();
return Promise.resolve();
}
async _flush() {
if (this._writing || !this._dirty) return;
this._writing = true;
this._dirty = false;
const tmp = `${this.filePath}.tmp`;
try {
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
await fsp.writeFile(tmp, JSON.stringify(this.data, null, 2), 'utf8');
await fsp.rename(tmp, this.filePath); // atomar, damit kein halber Zustand entsteht
} finally {
this._writing = false;
}
}
/** Schreibt synchron — für den Shutdown-Pfad. */
saveSync() {
if (!this._dirty) return;
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
fs.renameSync(tmp, this.filePath);
this._dirty = false;
} catch (err) {
console.warn(`[store] Schreiben beim Beenden fehlgeschlagen: ${err.message}`);
}
}
}
/**
* Lädt die Nutzer-Konfiguration. Fehlt oder bricht sie, laufen die Defaults —
* ein Tippfehler in config.json darf das Dashboard nicht lahmlegen.
*
* @returns {{config: Object, error: string|null}}
*/
function loadConfig(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return { config: JSON.parse(raw), error: null };
} catch (err) {
if (err.code === 'ENOENT') return { config: {}, error: null };
return { config: {}, error: `config.json: ${err.message}` };
}
}
module.exports = { Store, loadConfig, DEFAULT_STATE };
+25
View File
@@ -0,0 +1,25 @@
'use strict';
/**
* Schmale, explizite Brücke zwischen Main-Prozess und Oberfläche.
* Der Renderer bekommt keinen Node-Zugriff — nur diese Funktionen.
*/
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('dashboard', {
/** Registriert den Empfänger für Zustandsaktualisierungen. */
onState(callback) {
ipcRenderer.on('state', (_event, state) => callback(state));
},
/** Fordert den aktuellen Zustand an (für den ersten Aufbau). */
getState: () => ipcRenderer.invoke('get-state'),
/** Liefert den gespeicherten Ansichtszustand, damit die Oberfläche zur Fenstergröße passt. */
getUiState: () => ipcRenderer.invoke('get-ui-state'),
/** Klappt die Detailansicht auf oder zu. */
setExpanded: (expanded) => ipcRenderer.send('set-expanded', expanded),
/** Blendet das Fenster aus (bleibt über das Tray-Symbol erreichbar). */
hide: () => ipcRenderer.send('hide-window'),
/** Öffnet den Einstellungsdialog. */
openSettings: () => ipcRenderer.send('open-settings'),
});
+470
View File
@@ -0,0 +1,470 @@
'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'),
errorBadge: $('error-badge'),
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.`;
}
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.map((msg) => {
const li = document.createElement('li');
li.textContent = msg;
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);
// Meldungen stehen in der Detailansicht — in der Kopfzeile muss aber
// sichtbar sein, dass es überhaupt eine gibt.
el.errorBadge.hidden = !(s.errors && s.errors.length);
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());
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);
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self';" />
<title>Claude Live Dashboard</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="widget" id="widget">
<!-- Kopfzeile: einziger Ziehbereich des rahmenlosen Fensters -->
<header class="titlebar">
<span class="dot-brand" aria-hidden="true"></span>
<span class="title" id="plan-label">Claude</span>
<span class="spacer"></span>
<span class="badge warn" id="error-badge" hidden title="Details öffnen für die Meldung">Hinweis</span>
<span class="badge warn" id="stale-badge" hidden>veraltet</span>
<button class="icon-btn" id="btn-settings" title="Einstellungen" aria-label="Einstellungen">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M8 5.2a2.8 2.8 0 1 0 0 5.6 2.8 2.8 0 0 0 0-5.6Zm0 1.4a1.4 1.4 0 1 1 0 2.8 1.4 1.4 0 0 1 0-2.8Z"
/>
<path
fill="currentColor"
d="M6.9.9a.7.7 0 0 0-.69.58l-.2 1.16a5.6 5.6 0 0 0-1.02.59l-1.1-.42a.7.7 0 0 0-.85.31l-1.1 1.9a.7.7 0 0 0 .16.89l.9.74a5.65 5.65 0 0 0 0 1.18l-.9.74a.7.7 0 0 0-.16.89l1.1 1.9a.7.7 0 0 0 .85.31l1.1-.42c.32.24.66.44 1.02.59l.2 1.16a.7.7 0 0 0 .69.58h2.2a.7.7 0 0 0 .69-.58l.2-1.16c.36-.15.7-.35 1.02-.59l1.1.42a.7.7 0 0 0 .85-.31l1.1-1.9a.7.7 0 0 0-.16-.89l-.9-.74a5.65 5.65 0 0 0 0-1.18l.9-.74a.7.7 0 0 0 .16-.89l-1.1-1.9a.7.7 0 0 0-.85-.31l-1.1.42a5.6 5.6 0 0 0-1.02-.59l-.2-1.16A.7.7 0 0 0 9.1.9H6.9Zm.59 1.4h1.02l.17.99a.7.7 0 0 0 .5.55c.46.14.89.39 1.24.72a.7.7 0 0 0 .73.14l.94-.36.51.88-.77.63a.7.7 0 0 0-.25.7 4.25 4.25 0 0 1 0 1.44.7.7 0 0 0 .25.7l.77.63-.51.88-.94-.36a.7.7 0 0 0-.73.14c-.35.33-.78.58-1.24.72a.7.7 0 0 0-.5.55l-.17.99H7.49l-.17-.99a.7.7 0 0 0-.5-.55 4.2 4.2 0 0 1-1.24-.72.7.7 0 0 0-.73-.14l-.94.36-.51-.88.77-.63a.7.7 0 0 0 .25-.7 4.25 4.25 0 0 1 0-1.44.7.7 0 0 0-.25-.7l-.77-.63.51-.88.94.36a.7.7 0 0 0 .73-.14c.35-.33.78-.58 1.24-.72a.7.7 0 0 0 .5-.55l.17-.99Z"
/>
</svg>
</button>
<button class="icon-btn" id="btn-hide" title="Ausblenden (über das Tray-Symbol zurückholen)"></button>
</header>
<main class="meters">
<!-- Laufende Sitzung: das kurzfristig bindende Limit -->
<section class="meter" id="meter-block">
<div class="meter-head">
<span class="meter-label">Laufende Sitzung</span>
<span class="meter-value" id="block-percent"></span>
</div>
<div class="track" role="img" aria-labelledby="block-a11y">
<div class="fill" id="block-fill"></div>
<div class="projection" id="block-projection" hidden></div>
</div>
<div class="meter-foot">
<span class="status" id="block-status"><span class="status-icon" aria-hidden="true"></span><span class="status-text"></span></span>
<span class="reset" id="block-reset"></span>
</div>
<div class="session-figures">
<div class="figure">
<span class="figure-value" id="session-tokens"></span>
<span class="figure-label">Tokens</span>
</div>
<div class="figure">
<span class="figure-value" id="session-rate"></span>
<span class="figure-label">pro Minute</span>
</div>
</div>
<span class="sr-only" id="block-a11y"></span>
</section>
<!-- Wochenkontingent -->
<section class="meter" id="meter-week">
<div class="meter-head">
<span class="meter-label">Wochenkontingent</span>
<span class="meter-value" id="week-percent"></span>
</div>
<div class="track" role="img" aria-labelledby="week-a11y">
<div class="fill" id="week-fill"></div>
<div class="projection" id="week-projection" hidden></div>
</div>
<div class="meter-foot">
<span class="status" id="week-status"><span class="status-icon" aria-hidden="true"></span><span class="status-text"></span></span>
<span class="reset" id="week-reset"></span>
</div>
<span class="sr-only" id="week-a11y"></span>
</section>
<!-- Nutzungsguthaben — nur wenn der Server es meldet -->
<section class="meter" id="meter-spend" hidden>
<div class="meter-head">
<span class="meter-label">Nutzungsguthaben</span>
<span class="meter-value" id="spend-value"></span>
</div>
<div class="track" role="img" aria-labelledby="spend-a11y">
<div class="fill" id="spend-fill"></div>
</div>
<div class="meter-foot">
<span class="status" id="spend-status"><span class="status-icon" aria-hidden="true"></span><span class="status-text"></span></span>
<span class="reset" id="spend-limit"></span>
</div>
<span class="sr-only" id="spend-a11y"></span>
</section>
<p class="runway" id="session-runway"></p>
</main>
<button class="expander" id="btn-expand" aria-expanded="false" aria-controls="details">
<span id="expander-label">Details</span>
<span class="chevron" aria-hidden="true"></span>
</button>
<!-- Detailansicht -->
<div class="details" id="details" hidden>
<section class="detail-block">
<h2>Tokenverbrauch im laufenden Fenster</h2>
<div class="stacked" id="model-bar" role="img" aria-labelledby="model-a11y"></div>
<ul class="legend" id="model-legend"></ul>
<span class="sr-only" id="model-a11y"></span>
<table class="model-table" id="model-table">
<thead>
<tr>
<th scope="col">Modell</th>
<th scope="col" class="num">Ausgabe</th>
<th scope="col" class="num">Cache</th>
<th scope="col" class="num">Gesamt</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<section class="detail-block">
<h2>Tempo</h2>
<dl class="facts">
<dt>Burn-Rate</dt>
<dd id="fact-burn"></dd>
<dt>Woche in 1 Std.</dt>
<dd id="fact-projection"></dd>
<dt>Kontingent reicht noch</dt>
<dd id="fact-ttl"></dd>
<dt>Nachrichten in der Sitzung</dt>
<dd id="fact-messages"></dd>
</dl>
</section>
<section class="detail-block">
<h2>Datengrundlage</h2>
<dl class="facts">
<dt id="fact-confidence-label" hidden>Genauigkeit 5 Std.</dt>
<dd id="fact-confidence" hidden></dd>
<dt>Genauigkeit Woche</dt>
<dd id="fact-confidence-week"></dd>
<dt>Serverabfrage</dt>
<dd id="fact-oauth"></dd>
<dt>Aktualisiert</dt>
<dd id="fact-updated"></dd>
</dl>
<p class="hint" id="confidence-hint"></p>
<ul class="errors" id="errors" hidden></ul>
</section>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+504
View File
@@ -0,0 +1,504 @@
/*
* Das Widget ist ein transluzentes Overlay und committet sich bewusst auf
* einen dunklen Look — ein heller Modus würde über beliebigen Fenstern
* schlechter lesbar sein. Die Farbwerte stammen aus der validierten Palette
* und wurden gegen die Oberfläche #1e1e22 geprüft.
*/
:root {
color-scheme: dark;
/*
* Nahezu deckend: Ein transparentes Electron-Fenster hat keinen Backdrop,
* den CSS blurren könnte — durchscheinender Text darunter macht das Widget
* unlesbar. Die Transparenz steuert stattdessen bewusst das Tray-Menü über
* die Fenster-Deckkraft.
*/
--surface: rgba(24, 24, 27, 0.985);
--surface-solid: #1e1e22;
--surface-raised: rgba(255, 255, 255, 0.05);
--track: rgba(255, 255, 255, 0.1);
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--hairline: rgba(255, 255, 255, 0.1);
/* Statusfarben — fest, nie durch Serienfarben ersetzt */
--status-good: #0ca30c;
--status-warning: #fab219;
--status-critical: #d03b3b;
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-other: #898781;
--radius: 10px;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
/*
* Das hidden-Attribut muss jede display-Regel schlagen. Ohne !important
* gewinnt z. B. `.meter-head { display: flex }` gegen das UA-Stylesheet, und
* ein ausgeblendeter Bereich bleibt sichtbar — mit leeren Platzhaltern.
*/
[hidden] {
display: none !important;
}
html,
body {
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
font-family: var(--font);
color: var(--text-primary);
user-select: none;
cursor: default;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.widget {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
overflow: hidden;
}
/* ── Kopfzeile ───────────────────────────────────────────────────────── */
.titlebar {
-webkit-app-region: drag; /* einziger Ziehbereich des rahmenlosen Fensters */
display: flex;
align-items: center;
gap: 7px;
padding: 7px 8px 7px 10px;
border-bottom: 1px solid var(--hairline);
flex: 0 0 auto;
}
.dot-brand {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--series-2);
flex: 0 0 auto;
}
.title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.badge {
font-size: 10px;
border: 1px solid currentColor;
border-radius: 4px;
padding: 0 4px;
white-space: nowrap;
}
.badge.warn {
color: var(--status-warning);
}
.icon-btn {
-webkit-app-region: no-drag;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 18px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-raised);
color: var(--text-primary);
}
/* ── Meter ───────────────────────────────────────────────────────────── */
.meters {
padding: 10px 12px 4px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 0 0 auto;
}
.meter-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 5px;
}
.meter-label {
font-size: 11px;
color: var(--text-secondary);
}
.meter-value {
font-size: 17px;
font-weight: 650;
line-height: 1;
letter-spacing: -0.01em;
}
/* Balken: 4px runde Datenenden, an der Grundlinie verankert */
.track {
position: relative;
height: 8px;
border-radius: 4px;
background: var(--track);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: 4px;
background: var(--status-good);
transition: width 0.4s ease, background-color 0.3s ease;
}
/* Hochrechnung als schraffierte Verlängerung — deutlich vom Ist getrennt */
.projection {
position: absolute;
top: 0;
bottom: 0;
border-radius: 0 4px 4px 0;
background-image: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.28) 0 2px,
transparent 2px 5px
);
border-left: 2px solid var(--surface-solid); /* 2px Abstand zum Ist-Wert */
transition: left 0.4s ease, width 0.4s ease;
}
.meter-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 5px;
font-size: 10.5px;
color: var(--text-muted);
}
/* Status trägt immer Symbol + Text, nie Farbe allein */
.status {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.status-icon {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--status-good);
flex: 0 0 auto;
}
.status[data-severity="warn"] .status-icon {
background: var(--status-warning);
border-radius: 1px;
transform: rotate(45deg); /* zweite Unterscheidung neben der Farbe */
}
.status[data-severity="critical"] .status-icon {
background: var(--status-critical);
border-radius: 1px;
}
.status[data-severity="warn"] .status-text {
color: var(--status-warning);
}
.status[data-severity="critical"] .status-text {
color: var(--status-critical);
}
/* Eigene Schriftgröße: .reset steht auch außerhalb von .meter-foot. */
.reset {
/* Bleibt rechtsbündig, auch wenn der Status daneben entfällt. */
margin-left: auto;
font-size: 10.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ── Laufende Sitzung ────────────────────────────────────────────────── */
.session-figures {
display: flex;
gap: 18px;
margin-top: 7px;
}
.figure {
display: flex;
flex-direction: column;
gap: 1px;
}
.figure-value {
font-size: 14px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
}
.figure-label {
font-size: 9.5px;
color: var(--text-muted);
}
.runway {
margin: 2px 0 0;
padding-top: 9px;
border-top: 1px solid var(--hairline);
font-size: 10.5px;
line-height: 1.4;
color: var(--text-secondary);
}
/* ── Aufklapper ──────────────────────────────────────────────────────── */
.expander {
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
padding: 6px;
border: 0;
border-top: 1px solid var(--hairline);
background: transparent;
color: var(--text-muted);
font-family: inherit;
font-size: 10.5px;
cursor: pointer;
flex: 0 0 auto;
}
.expander:hover {
background: var(--surface-raised);
color: var(--text-secondary);
}
.chevron {
transition: transform 0.2s ease;
display: inline-block;
}
.expander[aria-expanded="true"] .chevron {
transform: rotate(180deg);
}
/* ── Detailansicht ───────────────────────────────────────────────────── */
.details {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 12px 12px;
border-top: 1px solid var(--hairline);
}
.details::-webkit-scrollbar {
width: 6px;
}
.details::-webkit-scrollbar-thumb {
background: var(--track);
border-radius: 3px;
}
.detail-block {
padding-top: 10px;
}
.detail-block + .detail-block {
margin-top: 4px;
border-top: 1px solid var(--hairline);
}
.detail-block h2 {
margin: 0 0 7px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
/* Gestapelter Balken mit 2px Oberflächenlücke zwischen den Segmenten */
.stacked {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
background: var(--track);
gap: 2px;
}
.stacked .segment {
height: 100%;
min-width: 2px;
}
.stacked .segment:first-child {
border-radius: 4px 0 0 4px;
}
.stacked .segment:last-child {
border-radius: 0 4px 4px 0;
}
.stacked .segment:only-child {
border-radius: 4px;
}
.legend {
list-style: none;
margin: 7px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 10.5px;
color: var(--text-secondary);
}
.legend li {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend .swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex: 0 0 auto;
}
/* Tabellenansicht — zweiter Zugang zu denselben Zahlen */
.model-table {
width: 100%;
margin-top: 9px;
border-collapse: collapse;
font-size: 10.5px;
}
.model-table th,
.model-table td {
padding: 3px 0;
text-align: left;
color: var(--text-secondary);
font-weight: 400;
white-space: nowrap;
}
.model-table th {
color: var(--text-muted);
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--hairline);
}
.model-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.model-table td:first-child {
max-width: 118px;
overflow: hidden;
text-overflow: ellipsis;
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
font-size: 10.5px;
}
.facts dt {
color: var(--text-muted);
white-space: nowrap;
}
.facts dd {
margin: 0;
text-align: right;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.hint {
margin: 8px 0 0;
font-size: 10px;
line-height: 1.45;
color: var(--text-muted);
}
.errors {
list-style: none;
margin: 8px 0 0;
padding: 0;
font-size: 10px;
line-height: 1.4;
color: var(--status-warning);
}
.errors li + li {
margin-top: 4px;
}
+15
View File
@@ -0,0 +1,15 @@
'use strict';
/** Brücke für den Einstellungsdialog. Bewusst getrennt vom Widget-Preload. */
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('settings', {
/** @returns {Promise<{config: Object, meta: {configPath: string, autostart: boolean}}>} */
load: () => ipcRenderer.invoke('settings:load'),
/** @returns {Promise<{ok: boolean, error?: string}>} */
save: (patch) => ipcRenderer.invoke('settings:save', patch),
setAutostart: (enabled) => ipcRenderer.invoke('settings:autostart', enabled),
openFile: () => ipcRenderer.send('settings:open-file'),
close: () => ipcRenderer.send('settings:close'),
});
+224
View File
@@ -0,0 +1,224 @@
'use strict';
/**
* Einstellungsdialog. Liest und schreibt dieselbe config.json, die auch von
* Hand bearbeitet werden kann — unbekannte Felder und die `_`-Kommentare
* bleiben dabei erhalten.
*/
const $ = (id) => document.getElementById(id);
const el = {
path: $('config-path'),
close: $('btn-close'),
save: $('btn-save'),
revert: $('btn-revert'),
openFile: $('btn-open-file'),
feedback: $('feedback'),
opacity: $('opacity'),
opacityOut: $('opacity-out'),
clickThrough: $('clickThrough'),
autostart: $('autostart'),
oauthEnabled: $('oauthEnabled'),
oauthEndpoint: $('oauthEndpoint'),
limitWeek: $('limitWeek'),
limitBlock: $('limitBlock'),
wInput: $('wInput'),
wOutput: $('wOutput'),
wCacheCreate: $('wCacheCreate'),
wCacheRead: $('wCacheRead'),
mOpus: $('mOpus'),
mSonnet: $('mSonnet'),
mHaiku: $('mHaiku'),
weekday: $('weekday'),
resetHour: $('resetHour'),
resetMinute: $('resetMinute'),
};
/** Voreinstellungen, die auch der Main-Prozess verwendet. */
const DEFAULTS = {
opacity: 1,
tokenWeights: { input: 1, output: 5, cacheCreate: 1.25, cacheRead: 0.1 },
modelWeights: { opus: 5, sonnet: 1, haiku: 0.3 },
};
let feedbackTimer = null;
function say(text, kind = 'ok') {
el.feedback.hidden = false;
el.feedback.textContent = text;
el.feedback.dataset.kind = kind;
clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
el.feedback.hidden = true;
}, 4000);
}
/** Zahl aus einem Feld; leer ergibt null. */
function num(input) {
const raw = input.value.trim();
if (raw === '') return null;
const value = Number(raw.replace(',', '.'));
return Number.isFinite(value) ? value : NaN;
}
function setNum(input, value, fallback = '') {
input.value = value == null ? fallback : String(value);
}
/** Füllt die Oberfläche aus der Konfiguration. */
function fill(config, meta) {
el.path.textContent = meta.configPath;
const opacity = Math.round((config.opacity ?? DEFAULTS.opacity) * 100);
el.opacity.value = String(opacity);
el.opacityOut.textContent = `${opacity} %`;
el.clickThrough.checked = Boolean(config.clickThrough);
el.autostart.checked = Boolean(meta.autostart);
const oauth = config.oauth || {};
el.oauthEnabled.checked = oauth.enabled !== false;
el.oauthEndpoint.value = oauth.endpoint || '';
const limits = config.limits || {};
setNum(el.limitWeek, limits.week);
setNum(el.limitBlock, limits.block);
const tw = { ...DEFAULTS.tokenWeights, ...(config.tokenWeights || {}) };
setNum(el.wInput, tw.input);
setNum(el.wOutput, tw.output);
setNum(el.wCacheCreate, tw.cacheCreate);
setNum(el.wCacheRead, tw.cacheRead);
const mw = { ...DEFAULTS.modelWeights, ...(config.modelWeights || {}) };
setNum(el.mOpus, mw.opus);
setNum(el.mSonnet, mw.sonnet);
setNum(el.mHaiku, mw.haiku);
el.weekday.value = config.weekResetWeekday == null ? '' : String(config.weekResetWeekday);
setNum(el.resetHour, config.weekResetHour, '0');
setNum(el.resetMinute, config.weekResetMinute, '0');
updateWeekFields();
}
/** Reset-Zeit ist ohne gewählten Wochentag bedeutungslos. */
function updateWeekFields() {
const off = el.weekday.value === '';
el.resetHour.disabled = off;
el.resetMinute.disabled = off;
}
/**
* Baut das Änderungsobjekt. Nur gesetzte Werte werden übernommen — so bleiben
* Felder, die der Dialog nicht kennt, in der Datei unangetastet.
* @returns {{patch: Object, errors: string[]}}
*/
function collect() {
const errors = [];
const patch = {};
const check = (input, label, { min = 0, max = Infinity, required = false } = {}) => {
const value = num(input);
const invalid =
Number.isNaN(value) || (value != null && (value < min || value > max)) || (required && value == null);
input.setAttribute('aria-invalid', invalid ? 'true' : 'false');
if (invalid) errors.push(label);
return invalid ? undefined : value;
};
patch.opacity = Number(el.opacity.value) / 100;
patch.clickThrough = el.clickThrough.checked;
patch.oauth = { enabled: el.oauthEnabled.checked };
const endpoint = el.oauthEndpoint.value.trim();
if (endpoint) {
if (!/^https:\/\/[^\s]+$/.test(endpoint)) errors.push('Endpunkt');
else patch.oauth.endpoint = endpoint;
} else {
patch.oauth.endpoint = '';
}
const week = check(el.limitWeek, 'Bezugsgröße Woche');
const block = check(el.limitBlock, 'Bezugsgröße 5-Stunden-Fenster');
patch.limits = {};
if (week) patch.limits.week = week;
if (block) patch.limits.block = block;
patch.tokenWeights = {
input: check(el.wInput, 'Gewicht Eingabe', { required: true }),
output: check(el.wOutput, 'Gewicht Ausgabe', { required: true }),
cacheCreate: check(el.wCacheCreate, 'Gewicht Cache anlegen', { required: true }),
cacheRead: check(el.wCacheRead, 'Gewicht Cache lesen', { required: true }),
};
patch.modelWeights = {
opus: check(el.mOpus, 'Gewicht Opus', { required: true }),
sonnet: check(el.mSonnet, 'Gewicht Sonnet', { required: true }),
haiku: check(el.mHaiku, 'Gewicht Haiku', { required: true }),
};
if (el.weekday.value === '') {
patch.weekResetWeekday = null;
patch.weekResetHour = null;
patch.weekResetMinute = null;
} else {
patch.weekResetWeekday = Number(el.weekday.value);
patch.weekResetHour = check(el.resetHour, 'Reset-Stunde', { max: 23, required: true });
patch.weekResetMinute = check(el.resetMinute, 'Reset-Minute', { max: 59, required: true });
}
return { patch, errors };
}
async function load() {
const { config, meta } = await window.settings.load();
fill(config, meta);
}
el.opacity.addEventListener('input', () => {
el.opacityOut.textContent = `${el.opacity.value} %`;
});
el.weekday.addEventListener('change', updateWeekFields);
// Autostart ist keine Einstellung der Datei, sondern ein Systemzustand —
// deshalb sofort wirksam statt beim Speichern.
el.autostart.addEventListener('change', async () => {
await window.settings.setAutostart(el.autostart.checked);
say(el.autostart.checked ? 'Autostart aktiviert.' : 'Autostart deaktiviert.');
});
el.save.addEventListener('click', async () => {
const { patch, errors } = collect();
if (errors.length) {
say(`Bitte prüfen: ${errors.join(', ')}`, 'error');
return;
}
el.save.disabled = true;
const result = await window.settings.save(patch);
el.save.disabled = false;
say(result.ok ? 'Gespeichert und übernommen.' : `Fehler: ${result.error}`, result.ok ? 'ok' : 'error');
});
el.revert.addEventListener('click', async () => {
await load();
say('Änderungen verworfen.');
});
el.openFile.addEventListener('click', () => window.settings.openFile());
el.close.addEventListener('click', () => window.settings.close());
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') window.settings.close();
if (event.key === 's' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
el.save.click();
}
});
load();
+157
View File
@@ -0,0 +1,157 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self';" />
<title>Einstellungen</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="titlebar">
<span class="title">Einstellungen</span>
<span class="spacer"></span>
<button class="icon-btn" id="btn-close" title="Schließen" aria-label="Schließen"></button>
</header>
<main class="content">
<p class="path" id="config-path"></p>
<section class="group">
<h2>Anzeige</h2>
<div class="row">
<label for="opacity">Deckkraft</label>
<div class="control">
<input type="range" id="opacity" min="30" max="100" step="5" />
<output id="opacity-out">100 %</output>
</div>
</div>
<div class="row">
<label for="clickThrough">Klicks durchlassen</label>
<div class="control">
<input type="checkbox" id="clickThrough" />
<span class="hint">Das Widget fängt keine Mausklicks mehr ab.</span>
</div>
</div>
<div class="row">
<label for="autostart">Mit Windows starten</label>
<div class="control">
<input type="checkbox" id="autostart" />
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
</div>
</div>
</section>
<section class="group">
<h2>Datenquelle</h2>
<div class="row">
<label for="oauthEnabled">Serverabfrage</label>
<div class="control">
<input type="checkbox" id="oauthEnabled" />
<span class="hint">
Liefert exakte Prozentwerte und Reset-Zeiten. Abgeschaltet rechnet
das Dashboard nur lokal — dann sind alle Werte Näherungen.
</span>
</div>
</div>
<div class="row">
<label for="oauthEndpoint">Endpunkt</label>
<div class="control">
<input type="text" id="oauthEndpoint" placeholder="Standard verwenden" spellcheck="false" />
<span class="hint">Nur ausfüllen, um den Standard zu überschreiben.</span>
</div>
</div>
</section>
<section class="group">
<h2>Bezugsgrößen <span class="tag">Rückfallebene</span></h2>
<p class="note">
Werden nur benutzt, wenn die Serverabfrage ausfällt. Ermitteln mit
<code>npm run calibrate</code>. Leer lassen für Auto-Kalibrierung.
</p>
<div class="row">
<label for="limitWeek">Woche</label>
<div class="control">
<input type="number" id="limitWeek" min="0" step="1000000" placeholder="automatisch" />
<span class="hint">gewichtete Tokens für 100 %</span>
</div>
</div>
<div class="row">
<label for="limitBlock">5-Stunden-Fenster</label>
<div class="control">
<input type="number" id="limitBlock" min="0" step="1000000" placeholder="automatisch" />
<span class="hint">gewichtete Tokens für 100 %</span>
</div>
</div>
</section>
<section class="group">
<h2>Gewichtung</h2>
<p class="note">
Wie stark Token-Arten und Modelle ins Kontingent zählen. Beeinflusst
die lokale Rechnung und die Hochrechnung „reicht noch …“, nicht die
Serverwerte.
</p>
<div class="grid" id="weights">
<label for="wInput">Eingabe</label><input type="number" id="wInput" min="0" step="0.05" />
<label for="wOutput">Ausgabe</label><input type="number" id="wOutput" min="0" step="0.05" />
<label for="wCacheCreate">Cache anlegen</label><input type="number" id="wCacheCreate" min="0" step="0.05" />
<label for="wCacheRead">Cache lesen</label><input type="number" id="wCacheRead" min="0" step="0.05" />
<label for="mOpus">Opus</label><input type="number" id="mOpus" min="0" step="0.1" />
<label for="mSonnet">Sonnet</label><input type="number" id="mSonnet" min="0" step="0.1" />
<label for="mHaiku">Haiku</label><input type="number" id="mHaiku" min="0" step="0.1" />
</div>
</section>
<section class="group">
<h2>Wochenfenster <span class="tag">Rückfallebene</span></h2>
<p class="note">
Bei aktiver Serverabfrage wird der echte Reset-Zeitpunkt verwendet und
diese Angaben bleiben ungenutzt.
</p>
<div class="row">
<label for="weekday">Reset-Tag</label>
<div class="control">
<select id="weekday">
<option value="">rollierende 7 Tage</option>
<option value="0">Sonntag</option>
<option value="1">Montag</option>
<option value="2">Dienstag</option>
<option value="3">Mittwoch</option>
<option value="4">Donnerstag</option>
<option value="5">Freitag</option>
<option value="6">Samstag</option>
</select>
</div>
</div>
<div class="row">
<label for="resetHour">Uhrzeit</label>
<div class="control inline">
<input type="number" id="resetHour" min="0" max="23" step="1" />
<span class="colon">:</span>
<input type="number" id="resetMinute" min="0" max="59" step="1" />
</div>
</div>
</section>
</main>
<footer class="actions">
<button class="btn ghost" id="btn-open-file">Datei öffnen</button>
<span class="spacer"></span>
<span class="feedback" id="feedback" hidden></span>
<button class="btn ghost" id="btn-revert">Verwerfen</button>
<button class="btn primary" id="btn-save">Speichern</button>
</footer>
<script src="app.js"></script>
</body>
</html>
+315
View File
@@ -0,0 +1,315 @@
/* Einstellungsfenster — gleiche Farbwelt wie das Widget, aber auf Lesbarkeit
in einem größeren Fenster ausgelegt. */
:root {
color-scheme: dark;
--surface: #1e1e22;
--surface-raised: rgba(255, 255, 255, 0.05);
--surface-input: rgba(255, 255, 255, 0.06);
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--hairline: rgba(255, 255, 255, 0.1);
--accent: #3987e5;
--good: #0ca30c;
--warning: #fab219;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--surface);
color: var(--text-primary);
font-family: var(--font);
font-size: 13px;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
/* ── Titelleiste ─────────────────────────────────────────────────────── */
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px 9px 14px;
border-bottom: 1px solid var(--hairline);
flex: 0 0 auto;
}
.title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.spacer {
flex: 1;
}
.icon-btn {
-webkit-app-region: no-drag;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 22px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-raised);
color: var(--text-primary);
}
/* ── Inhalt ──────────────────────────────────────────────────────────── */
.content {
flex: 1 1 auto;
overflow-y: auto;
padding: 14px 16px 18px;
}
.content::-webkit-scrollbar {
width: 8px;
}
.content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.path {
margin: 0 0 16px;
font-size: 11px;
color: var(--text-muted);
word-break: break-all;
}
.group + .group {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--hairline);
}
.group h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.tag {
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.03em;
text-transform: none;
color: var(--warning);
border: 1px solid currentColor;
border-radius: 3px;
padding: 0 4px;
}
.note {
margin: 0 0 12px;
font-size: 11px;
line-height: 1.5;
color: var(--text-muted);
}
.note code {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 10.5px;
background: var(--surface-input);
border-radius: 3px;
padding: 1px 4px;
}
/* ── Zeilen ──────────────────────────────────────────────────────────── */
.row {
display: grid;
grid-template-columns: 148px 1fr;
gap: 10px;
align-items: start;
padding: 7px 0;
}
.row > label {
padding-top: 5px;
color: var(--text-secondary);
}
.control {
display: flex;
flex-direction: column;
gap: 5px;
}
.control.inline {
flex-direction: row;
align-items: center;
gap: 6px;
}
.control .hint {
font-size: 10.5px;
line-height: 1.45;
color: var(--text-muted);
}
.colon {
color: var(--text-muted);
}
.grid {
display: grid;
grid-template-columns: 148px 1fr;
gap: 8px 10px;
align-items: center;
}
.grid label {
color: var(--text-secondary);
}
/* ── Eingabefelder ───────────────────────────────────────────────────── */
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--hairline);
border-radius: 5px;
background: var(--surface-input);
color: var(--text-primary);
font-family: inherit;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
input[type="number"] {
max-width: 170px;
}
.control.inline input[type="number"] {
max-width: 72px;
}
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
input[type="checkbox"] {
width: 15px;
height: 15px;
margin: 4px 0 0;
accent-color: var(--accent);
cursor: pointer;
}
input[type="range"] {
width: 190px;
accent-color: var(--accent);
cursor: pointer;
}
.control:has(> input[type="range"]) {
flex-direction: row;
align-items: center;
gap: 10px;
}
output {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
min-width: 42px;
}
/* Ungültige Eingabe deutlich, aber nicht allein über die Farbe */
input[aria-invalid="true"] {
border-color: var(--warning);
background: rgba(250, 178, 25, 0.08);
}
/* ── Fußzeile ────────────────────────────────────────────────────────── */
.actions {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
border-top: 1px solid var(--hairline);
flex: 0 0 auto;
}
.btn {
padding: 6px 14px;
border: 1px solid var(--hairline);
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.btn:hover {
background: var(--surface-raised);
color: var(--text-primary);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
font-weight: 500;
}
.btn.primary:hover {
filter: brightness(1.12);
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.feedback {
font-size: 11.5px;
color: var(--good);
}
.feedback[data-kind="error"] {
color: var(--warning);
}