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
+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);
}