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
+10
View File
@@ -0,0 +1,10 @@
node_modules/
release/
*.log
# Nutzerspezifische Claude-Code-Sitzungseinstellungen, nicht projektweit.
.claude/settings.local.json
# Laufzeitzustand liegt im Benutzerprofil, nicht im Projekt —
# ältere Fassungen legten diese Dateien noch hier ab.
state.json
+229
View File
@@ -0,0 +1,229 @@
# Claude Live Dashboard
Ein rahmenloses Always-on-Top-Widget für Windows, das die Auslastung des
Claude-Plans live anzeigt: 5-Stunden-Fenster, Wochenverbrauch, Reset-Countdown,
Tokenverbrauch je Modell und die Hochrechnung auf das Fensterende.
Die Daten stammen aus den Transkripten, die Claude Code ohnehin unter
`~/.claude/projects/**/*.jsonl` schreibt. Es wird nichts installiert, nichts
mitgeschnitten und standardmäßig keine Netzwerkverbindung aufgebaut.
## Start
Node.js wird benötigt. Ist es nicht im `PATH` (etwa bei nvm-windows ohne
aktive Version), genügt der Versionspfad:
```powershell
$env:PATH = "$env:APPDATA\nvm\v22.22.1;$env:PATH"
npm install
npm start
```
Das Tray-Symbol landet unter Windows zunächst im Überlaufbereich hinter dem
`^` in der Taskleiste; per Drag lässt es sich dauerhaft sichtbar machen.
## Bedienung
| Aktion | Wirkung |
|---|---|
| Kopfzeile ziehen | Fenster verschieben (Position wird gemerkt) |
| ⚙ | Einstellungen öffnen |
| „Details“ | Modell-Aufschlüsselung, Tempo und Datengrundlage aufklappen |
| `` | Ausblenden — Rückkehr über das Tray-Symbol |
| Tray-Klick | Ein-/Ausblenden |
| Tray-Menü | Deckkraft, Klick-Durchlässigkeit, Autostart, Einstellungen, Beenden |
### Einstellungen
Das Zahnrad in der Kopfzeile öffnet einen Dialog für alle Felder der
`config.json`: Deckkraft, Klick-Durchlässigkeit, Autostart, Serverabfrage,
Bezugsgrößen, Gewichtung und Wochenfenster.
Gespeicherte Änderungen greifen **sofort** — ohne Neustart. Der Dialog liest
und schreibt dieselbe Datei, die sich auch von Hand bearbeiten lässt:
Kommentarfelder (`_hinweis` und Verwandte) und unbekannte Einträge bleiben
dabei unangetastet, weil die vorhandene Datei nur ergänzt und nicht ersetzt
wird. `Strg`+`S` speichert, `Esc` schließt.
Autostart ist kein Feld der Datei, sondern ein Systemzustand — er wird deshalb
sofort beim Umschalten übernommen, unabhängig vom Speichern.
„Klicks durchlassen“ macht das Widget für Mausereignisse transparent — es liegt
dann über der Arbeit, ohne sie zu behindern. Zurückschalten geht über das
Tray-Menü.
## Was angezeigt wird
**Laufende Sitzung** — Auslastung des aktuellen 5-Stunden-Fensters, dazu
verbrauchte Tokens und Tempo.
**Wochenkontingent** — das übergeordnete Limit über alle Modelle, mit exaktem
Reset-Zeitpunkt.
**Nutzungsguthaben** — der Budget-Zustand jenseits des Plan-Kontingents
(verbraucht von Monatslimit). Erscheint nur, wenn der Server ihn meldet.
Darunter die eigentliche Kernaussage: *wie lange das Kontingent bei diesem
Tempo noch reicht* — gerechnet in **aktiver Arbeitszeit**, nicht in
Kalenderzeit. Eine Hochrechnung der Burn-Rate auf fünf Kalendertage wäre
Unsinn, weil niemand durchgehend arbeitet.
## Woher die Werte kommen
Standardmäßig fragt das Dashboard denselben Endpunkt ab, den auch `/usage`
nutzt (`GET /api/oauth/usage` bei `api.anthropic.com`). Damit sind die
Prozentwerte, Schweregrade und Reset-Zeitpunkte **exakt** — sekundengenau und
ohne Schätzung.
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
gespeichert, nie protokolliert und nur an diesen Host gesendet. Es gibt bewusst
keinen eigenen Refresh-Flow: Läuft das Token ab, pausiert die Abfrage, bis
Claude Code es erneuert hat. Nach drei Fehlversuchen schaltet sie sich für die
Sitzung ab.
Abschalten über `config.json`:
```json
{ "oauth": { "enabled": false } }
```
**Wichtig:** Der Endpunkt ist nicht dokumentiert und kann durch ein Update
brechen. Deshalb rechnet das Dashboard *immer* auch lokal weiter — fällt die
Abfrage aus, läuft es ohne Unterbrechung auf den eigenen Zahlen weiter, nur
eben mit Näherungswerten. Die Anzeige sagt jederzeit, welcher Fall vorliegt:
| Anzeige | Bedeutung |
|---|---|
| **exakt (Server)** | Wert vom Server, entspricht `/usage` — nur mit Stufe 2 |
| **eigener Wert** | `limits` aus `config.json` |
| **kalibriert** | aus mindestens 5 eigenen abgeschlossenen Fenstern abgeleitet |
| **geschätzt** | noch zu wenige Messungen, grober Anhaltspunkt |
Solange die Serverabfrage läuft, kalibriert sich das Dashboard **selbst**: Der
Server nennt den exakten Anteil, lokal ist der gewichtete Verbrauch im selben
Fenster bekannt — daraus folgt die Bezugsgröße unmittelbar. Auch das
Wochenfenster wird aus dem gemeldeten Reset-Zeitpunkt abgeleitet, nicht
geraten.
Die folgenden Abschnitte betreffen nur den Fall, dass die Abfrage **nicht**
verfügbar ist.
### Kalibrieren (Rückfallebene)
Ohne Serverabfrage nimmt das Dashboard das höchste je beobachtete
Fenstervolumen als untere Schranke des Limits. Das ist grob. Besser: den
Prozentwert in **Einstellungen ▸ Nutzung** ablesen und unmittelbar danach:
```powershell
npm run calibrate -- --week 14 # nur rechnen
npm run calibrate -- --week 14 --write # in config.json übernehmen
npm run calibrate -- --block 37 --write # 5-Stunden-Fenster
```
Das Werkzeug teilt den aktuellen Verbrauch durch den angezeigten Anteil und
erhält so die Bezugsgröße exakt. Danach das Dashboard neu starten.
Steht in der Nutzungsanzeige ein fester Reset-Zeitpunkt („Zurücksetzung Mo.,
21:59“), gehört er in `config.json` — sonst passt das Fenster nicht zum
kalibrierten Wert:
```json
{ "weekResetWeekday": 1, "weekResetHour": 21, "weekResetMinute": 59 }
```
(0 = Sonntag, 1 = Montag …) Ohne diese Angabe rechnet das Dashboard mit einem
rollierenden 7-Tage-Fenster und zeigt bewusst keinen Countdown, weil ein
rollierendes Fenster keinen Reset-Zeitpunkt hat.
### Gewichtung
Tokens zählen nicht gleich: Ausgabe wiegt stärker als ein Cache-Treffer, Opus
stärker als Sonnet oder Haiku. Die Faktoren stehen in `config.json`.
Weichen zwei Kalibrierungen mit **unterschiedlichem Modellmix** deutlich
voneinander ab, stimmen diese Gewichte nicht — dann dort nachziehen, statt das
Limit hin- und herzuschieben. Ein einzelner Kalibrierpunkt legt nur den
Maßstab fest, nicht die Gewichte.
## Aufbau
```
src/main/index.js Fenster, Tray, IPC, Autostart
src/main/collector.js Watch-Schleife, Zustandsaufbau
src/main/jsonl.js inkrementeller Reader, Parser, Dedup
src/main/blocks.js 5h-Blöcke, Wochenfenster, Burn-Rate
src/main/limits.js Plan-Erkennung, Kalibrierung, Gewichtung
src/main/oauth-usage.js Serverabfrage, gekapselt und ausfallsicher
src/main/calibrate.js Kalibrierung gegen die echte Anzeige
src/main/store.js state.json, config.json
src/main/paths.js Speicherorte für Konfiguration und Zustand
src/preload.js contextBridge fürs Widget
src/settings-preload.js contextBridge für den Einstellungsdialog
src/renderer/ Widget-Oberfläche
src/settings/ Einstellungsdialog
```
Alle Dateizugriffe und Rechnungen passieren im Main-Prozess; der Renderer
bekommt fertige Zahlen und lässt nur den Countdown lokal weiterlaufen.
Zwei Details, die nicht offensichtlich sind:
- **Datei-Offsets werden nicht persistiert.** Innerhalb einer Sitzung sorgen sie
dafür, dass nur das Delta gelesen wird. Über einen Neustart hinweg wären sie
schädlich, weil der Aggregator leer startet und die Historie sonst
übersprungen würde. Ein Vollscan über 15 MB kostet rund 70 ms.
- **Dedup ist nicht optional.** Claude Code schreibt dieselbe Nachricht bei
Retries und beim Kompaktieren mehrfach ins Transkript; ohne Dedup über
`message.id` wären alle Zahlen zu hoch.
## Release bauen
```powershell
npm run dist # Installer + portable Fassung nach release/
npm run pack # nur entpackt nach release/win-unpacked (schneller Test)
```
Ergebnis in `release/`:
| Datei | Zweck |
|---|---|
| `Claude Live Dashboard-1.0.0-x64.exe` | NSIS-Installer, ~97 MB |
| `Claude Live Dashboard-1.0.0-portable.exe` | läuft ohne Installation |
Der Installer läuft **ohne Administratorrechte** (`perMachine: false`),
installiert also ins Benutzerprofil. Zielverzeichnis ist wählbar, Desktop- und
Startmenü-Verknüpfung werden angelegt. Bei der Deinstallation bleiben
Konfiguration und Kalibrierung erhalten (`deleteAppDataOnUninstall: false`).
**Nicht signiert.** Ohne Code-Signing-Zertifikat zeigt Windows beim ersten
Start einen SmartScreen-Hinweis („Der Computer wurde durch Windows geschützt“)
— über *Weitere Informationen ▸ Trotzdem ausführen* zu bestätigen. Für eine
Weitergabe an andere wäre ein Zertifikat nötig; für den Eigenbedarf ist es
verzichtbar.
### Konfiguration und Zustand
Beides liegt im Benutzerprofil, nicht im Programmverzeichnis — dort wäre es
nach der Installation im asar-Archiv und schreibgeschützt:
```
%APPDATA%\Claude Live Dashboard\config.json
%APPDATA%\Claude Live Dashboard\state.json
```
Beim ersten Start wird `config.json` aus der mitgelieferten Vorlage angelegt.
Erreichbar über das Tray-Menü (*Konfiguration öffnen* / *Datenordner öffnen*).
> Die mitgelieferte Vorlage enthält unter `limits` einen auf **diesem** System
> kalibrierten Wert als Rückfallebene. Für eine Weitergabe an andere sollte er
> entfernt werden — bei aktiver Serverabfrage wird er ohnehin nicht benutzt.
## Gegenprobe
```powershell
npm run dump # Modell-Summen und alle 5h-Blöcke auf der Konsole
npx ccusage blocks # unabhängige Referenz für Blockgrenzen und Summen
```
`npm run dump` gibt außerdem die Dauer des zweiten Polls aus — ohne neue Daten
muss er 0 Bytes lesen und im einstelligen Millisekundenbereich liegen.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

+30
View File
@@ -0,0 +1,30 @@
{
"_hinweis": "Alle Felder sind optional. Fehlt die Datei oder ist sie fehlerhaft, laufen die Voreinstellungen.",
"opacity": 1,
"clickThrough": false,
"_limits": "Rückfallebene, falls die Serverabfrage ausfällt. Bezugsgröße für 100 % in gewichteten Tokens; per `npm run calibrate` ermittelbar.",
"limits": {
"week": 249013747
},
"_tokenWeights": "Wie stark die Token-Arten ins Kontingent zählen.",
"tokenWeights": {
"input": 1,
"cacheCreate": 1.25,
"cacheRead": 0.1,
"output": 5
},
"_modelWeights": "Wie stark die Modellfamilien ins Kontingent zählen.",
"modelWeights": {
"opus": 5,
"sonnet": 1,
"haiku": 0.3
},
"_weekReset": "Echter Reset-Zeitpunkt laut Einstellungen > Nutzung (0 = Sonntag). Weglassen = rollierendes 7-Tage-Fenster.",
"weekResetWeekday": 1,
"weekResetHour": 21,
"weekResetMinute": 59,
"_oauth": "Serverabfrage für exakte Werte. Standardmäßig aktiv; mit enabled:false abschalten. endpoint nur setzen, um den Standard zu überschreiben.",
"oauth": {
"enabled": true
}
}
+4522
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
{
"name": "claude-live-dashboard",
"productName": "Claude Live Dashboard",
"version": "1.0.0",
"private": true,
"description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows",
"main": "src/main/index.js",
"scripts": {
"start": "electron .",
"dump": "node src/main/dump.js",
"calibrate": "node src/main/calibrate.js",
"icons": "node tools/make-icon.js",
"dist": "electron-builder --win",
"pack": "electron-builder --win --dir"
},
"author": "",
"license": "MIT",
"devDependencies": {
"electron": "^43.2.0",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "de.local.claude-live-dashboard",
"productName": "Claude Live Dashboard",
"copyright": "Copyright © 2026",
"directories": {
"output": "release",
"buildResources": "assets"
},
"files": [
"src/**/*",
"assets/**/*",
"config.json",
"!**/*.map"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
}
],
"icon": "assets/icon.png",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Claude Live Dashboard",
"installerLanguages": [
"de_DE"
],
"language": "1031",
"deleteAppDataOnUninstall": false
},
"portable": {
"artifactName": "${productName}-${version}-portable.${ext}"
}
}
}
+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);
}
+24
View File
@@ -0,0 +1,24 @@
@echo off
REM Startet das Dashboard, ohne dass Node im PATH liegen muss.
REM Auf diesem System zeigt der nvm-Symlink "C:\Program Files\nodejs"
REM ins Leere, deshalb wird die Node-Version hier direkt angesteuert.
setlocal
set "NODE_DIR=%APPDATA%\nvm\v22.22.1"
if not exist "%NODE_DIR%\node.exe" (
echo Node wurde unter "%NODE_DIR%" nicht gefunden.
echo Passe NODE_DIR in dieser Datei an oder fuehre "nvm use 22.22.1" aus.
exit /b 1
)
set "PATH=%NODE_DIR%;%PATH%"
cd /d "%~dp0"
if not exist "node_modules\electron" (
echo Abhaengigkeiten werden installiert...
call npm install || exit /b 1
)
start "" "node_modules\.bin\electron.cmd" .
endlocal
+125
View File
@@ -0,0 +1,125 @@
'use strict';
/**
* Erzeugt die Tray-Symbole als PNG — ohne Bildbibliothek, nur mit zlib aus der
* Standardbibliothek. Einmalig aufzurufen:
*
* node tools/make-icon.js
*
* Motiv: ein Ring mit einem Sektor, der den Füllstand andeutet.
*/
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const OUT_DIR = path.join(__dirname, '..', 'assets');
/** CRC32 nach PNG-Spezifikation. */
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
return table;
})();
function crc32(buf) {
let c = -1;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ -1) >>> 0;
}
function chunk(type, data) {
const out = Buffer.alloc(data.length + 12);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, 'ascii');
data.copy(out, 8);
const crcInput = Buffer.concat([Buffer.from(type, 'ascii'), data]);
out.writeUInt32BE(crc32(crcInput), data.length + 8);
return out;
}
/**
* @param {number} size Kantenlänge
* @param {(x: number, y: number) => [number, number, number, number]} shade RGBA je Pixel
*/
function makePng(size, shade) {
// Scanlines mit Filterbyte 0 je Zeile
const raw = Buffer.alloc(size * (size * 4 + 1));
let p = 0;
for (let y = 0; y < size; y++) {
raw[p++] = 0;
for (let x = 0; x < size; x++) {
const [r, g, b, a] = shade(x, y);
raw[p++] = r;
raw[p++] = g;
raw[p++] = b;
raw[p++] = a;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // Bittiefe
ihdr[9] = 6; // Farbtyp RGBA
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
/** Ring mit Füllsektor; kantengeglättet über 4×4-Supersampling. */
function ringShader(size, color, fillFraction = 0.68) {
const c = (size - 1) / 2;
const rOuter = size * 0.46;
const rInner = size * 0.28;
const SS = 4;
return (x, y) => {
let hits = 0;
for (let sy = 0; sy < SS; sy++) {
for (let sx = 0; sx < SS; sx++) {
const px = x + (sx + 0.5) / SS - 0.5;
const py = y + (sy + 0.5) / SS - 0.5;
const dx = px - c;
const dy = py - c;
const dist = Math.hypot(dx, dy);
if (dist > rOuter || dist < rInner) continue;
// Winkel ab 12 Uhr im Uhrzeigersinn
let ang = Math.atan2(dx, -dy);
if (ang < 0) ang += Math.PI * 2;
if (ang <= fillFraction * Math.PI * 2) hits++;
}
}
const a = Math.round((hits / (SS * SS)) * 255);
return [color[0], color[1], color[2], a];
};
}
function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const orange = [217, 89, 38]; // Slot 2 der Palette
for (const size of [16, 32]) {
const file = path.join(OUT_DIR, `tray${size === 16 ? '' : `@${size}`}.png`);
fs.writeFileSync(file, makePng(size, ringShader(size, orange)));
console.log(`geschrieben: ${file}`);
}
// Fenster- und Anwendungssymbol
const file = path.join(OUT_DIR, 'icon.png');
fs.writeFileSync(file, makePng(256, ringShader(256, orange)));
console.log(`geschrieben: ${file}`);
}
main();