From 3c789ef40fd063969a4152d72453acd3af8bc6b4 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Fri, 10 Apr 2026 20:34:48 +0200 Subject: [PATCH 01/14] v6.0.12: Add background server CLI --- CHANGELOG.md | 9 + README.md | 28 ++ install.sh | 2 + server.js | 518 ++++++++++++++++++++++++++++++- tests/integration/server.test.ts | 197 +++++++++++- 5 files changed, 745 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4ded8..b5917a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Added +- **Background-CLI für `ttdash`** — `--background` startet den lokalen Server als losgelösten Hintergrundprozess; `ttdash stop` listet laufende Instanzen und beendet auf Wunsch gezielt die ausgewählte + +### Improved +- **CLI-Dokumentation** — README, Hilfeausgabe und Installer-Hinweise dokumentieren jetzt den Hintergrundmodus und den Stop-Befehl direkt im Startpfad +- **Race-safe Background-Registry** — parallele `--background`-Starts sperren die lokale Instanzdatei jetzt kurzzeitig, damit keine laufenden Server aus der Registry verloren gehen + ## [6.0.11] - 2026-04-10 ### Fixed diff --git a/README.md b/README.md index 402e38b..71c87da 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,18 @@ Start the app: ttdash ``` +Start it in the background: + +```bash +ttdash --background +``` + +Stop one of the running background instances again: + +```bash +ttdash stop +``` + Then either: 1. Click `Auto-Import` to load local `toktrack` data @@ -108,6 +120,8 @@ The auto-import path prefers: The server automatically picks the next free port if `3000` is occupied. +Background mode keeps the terminal free after launch. If multiple background instances are running, `ttdash stop` shows a selection prompt so you can choose which one to terminate. + ## Sample Data A synthetic sample dataset for local verification is included at `examples/sample-usage.json`. @@ -216,6 +230,20 @@ ls -la ~/.bun/bin/ttdash PORT=3010 ttdash ``` +### Run in the background + +```bash +ttdash --background +ttdash stop +``` + +Combine it with existing flags when needed: + +```bash +ttdash --background --port 3010 --auto-load +ttdash --background --no-open +``` + ### Auto-import cannot find `toktrack` Install `toktrack` locally or ensure `bunx` / `npx` can execute it. diff --git a/install.sh b/install.sh index f12fd65..609840a 100755 --- a/install.sh +++ b/install.sh @@ -181,6 +181,8 @@ printf "\n${GREEN}${BOLD}Fertig!${NC} Starte das Dashboard mit:\n" printf " ${BOLD}ttdash${NC}\n" printf "\n${BOLD}Nächste Schritte${NC}\n" printf " ${DIM}• App lokal starten:${NC} ttdash\n" +printf " ${DIM}• Im Hintergrund starten:${NC} ttdash --background\n" +printf " ${DIM}• Hintergrundinstanz beenden:${NC} ttdash stop\n" printf " ${DIM}• Anderen Port verwenden:${NC} PORT=3010 ttdash\n" printf " ${DIM}• Browser-Autostart deaktivieren:${NC} NO_OPEN_BROWSER=1 ttdash\n" printf " ${DIM}• Datenquelle im UI:${NC} Auto-Import oder JSON-Upload\n" diff --git a/server.js b/server.js index 838f7f5..b6af5d2 100755 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const readline = require('readline/promises'); const { spawn } = require('child_process'); const { parseArgs } = require('util'); const { normalizeIncomingData } = require('./usage-normalizer'); @@ -15,7 +16,9 @@ const STATIC_ROOT = path.join(ROOT, 'dist'); const APP_DIR_NAME = 'TTDash'; const APP_DIR_NAME_LINUX = 'ttdash'; const LEGACY_DATA_FILE = path.join(ROOT, 'data.json'); -const CLI_OPTIONS = parseCliArgs(process.argv.slice(2)); +const RAW_CLI_ARGS = process.argv.slice(2); +const NORMALIZED_CLI_ARGS = normalizeCliArgs(RAW_CLI_ARGS); +const CLI_OPTIONS = parseCliArgs(RAW_CLI_ARGS); const ENV_START_PORT = parseInt(process.env.PORT, 10); const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_START_PORT : 3000); const MAX_PORT = START_PORT + 100; @@ -32,6 +35,9 @@ const SECURITY_HEADERS = { 'Content-Security-Policy': "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }; const APP_LABEL = 'TTDash'; +const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1'; +const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1'; +const BACKGROUND_START_TIMEOUT_MS = 15000; const DEFAULT_SETTINGS = { language: 'de', theme: 'dark', @@ -49,6 +55,9 @@ function normalizeCliArgs(args) { if (arg === '-al') { return '--auto-load'; } + if (arg === '-bg') { + return '--background'; + } return arg; }); } @@ -58,17 +67,21 @@ function printHelp() { console.log(''); console.log('Verwendung:'); console.log(' ttdash [optionen]'); + console.log(' ttdash stop'); console.log(''); console.log('Optionen:'); console.log(' -p, --port Startport festlegen'); console.log(' -h, --help Diese Hilfe anzeigen'); console.log(' -no, --no-open Browser-Autostart deaktivieren'); console.log(' -al, --auto-load Führt direkt beim Start einen Auto-Import aus'); + console.log(' -b, --background Startet TTDash als Hintergrundprozess'); console.log(''); console.log('Beispiele:'); console.log(' ttdash --port 3010'); console.log(' ttdash -p 3010 -no'); console.log(' ttdash --auto-load'); + console.log(' ttdash --background'); + console.log(' ttdash stop'); console.log(''); console.log('Umgebungsvariablen:'); console.log(' PORT=3010 ttdash'); @@ -83,7 +96,7 @@ function parseCliArgs(rawArgs) { try { parsed = parseArgs({ args, - allowPositionals: false, + allowPositionals: true, strict: true, options: { port: { @@ -100,6 +113,10 @@ function parseCliArgs(rawArgs) { 'auto-load': { type: 'boolean', }, + background: { + type: 'boolean', + short: 'b', + }, }, }); } catch (error) { @@ -114,6 +131,25 @@ function parseCliArgs(rawArgs) { process.exit(0); } + let command = null; + if (parsed.positionals.length > 1) { + console.error(`Unbekannter Aufruf: ${parsed.positionals.join(' ')}`); + console.log(''); + printHelp(); + process.exit(1); + } + + if (parsed.positionals.length === 1) { + if (parsed.positionals[0] !== 'stop') { + console.error(`Unbekannter Befehl: ${parsed.positionals[0]}`); + console.log(''); + printHelp(); + process.exit(1); + } + + command = 'stop'; + } + let port; if (parsed.values.port !== undefined) { const parsedPort = Number.parseInt(parsed.values.port, 10); @@ -127,9 +163,11 @@ function parseCliArgs(rawArgs) { } return { + command, port, noOpen: Boolean(parsed.values['no-open']), autoLoad: Boolean(parsed.values['auto-load']), + background: Boolean(parsed.values.background), }; } @@ -165,6 +203,11 @@ const APP_PATHS = resolveAppPaths(); const DATA_FILE = path.join(APP_PATHS.dataDir, 'data.json'); const SETTINGS_FILE = path.join(APP_PATHS.configDir, 'settings.json'); const NPX_CACHE_DIR = path.join(APP_PATHS.cacheDir, 'npx-cache'); +const BACKGROUND_INSTANCES_FILE = path.join(APP_PATHS.configDir, 'background-instances.json'); +const BACKGROUND_LOG_DIR = path.join(APP_PATHS.cacheDir, 'background'); +const BACKGROUND_INSTANCES_LOCK_DIR = path.join(APP_PATHS.configDir, 'background-instances.lock'); +const BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS = 5000; +const BACKGROUND_INSTANCES_LOCK_STALE_MS = 10000; const MIME_TYPES = { '.html': 'text/html; charset=utf-8', @@ -190,6 +233,7 @@ function ensureAppDirs() { ensureDir(APP_PATHS.configDir); ensureDir(APP_PATHS.cacheDir); ensureDir(NPX_CACHE_DIR); + ensureDir(BACKGROUND_LOG_DIR); } function writeJsonAtomic(filePath, data) { @@ -199,6 +243,415 @@ function writeJsonAtomic(filePath, data) { fs.renameSync(tempPath, filePath); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function formatDateTime(value) { + return new Intl.DateTimeFormat('de-CH', { + dateStyle: 'short', + timeStyle: 'medium', + }).format(new Date(value)); +} + +function isProcessRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && error.code === 'EPERM'; + } +} + +function normalizeBackgroundInstance(value) { + if (!value || typeof value !== 'object') { + return null; + } + + const pid = Number.parseInt(value.pid, 10); + const port = Number.parseInt(value.port, 10); + const startedAt = normalizeIsoTimestamp(value.startedAt); + const id = typeof value.id === 'string' && value.id.trim() + ? value.id.trim() + : null; + const url = typeof value.url === 'string' && value.url.trim() + ? value.url.trim() + : null; + const host = typeof value.host === 'string' && value.host.trim() + ? value.host.trim() + : BIND_HOST; + + if (!id || !url || !startedAt || !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || port <= 0) { + return null; + } + + return { + id, + pid, + port, + url, + host, + startedAt, + logFile: typeof value.logFile === 'string' && value.logFile.trim() + ? value.logFile.trim() + : null, + }; +} + +function readBackgroundInstancesRaw() { + try { + const parsed = JSON.parse(fs.readFileSync(BACKGROUND_INSTANCES_FILE, 'utf-8')); + if (Array.isArray(parsed)) { + return parsed; + } + } catch {} + + return []; +} + +function writeBackgroundInstances(instances) { + writeJsonAtomic(BACKGROUND_INSTANCES_FILE, instances); +} + +function readBackgroundInstancesSnapshot() { + const normalized = readBackgroundInstancesRaw() + .map(normalizeBackgroundInstance) + .filter(Boolean); + + const alive = normalized.filter((instance) => isProcessRunning(instance.pid)); + const changed = normalized.length !== alive.length; + + alive.sort((left, right) => { + const byStartedAt = left.startedAt.localeCompare(right.startedAt); + if (byStartedAt !== 0) { + return byStartedAt; + } + return left.port - right.port; + }); + + return { + normalized, + alive, + changed, + }; +} + +function getBackgroundInstances() { + return readBackgroundInstancesSnapshot().alive; +} + +async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (true) { + try { + fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR); + break; + } catch (error) { + if (!error || error.code !== 'EEXIST') { + throw error; + } + + let lockIsStale = false; + try { + const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR); + lockIsStale = (Date.now() - stats.mtimeMs) > BACKGROUND_INSTANCES_LOCK_STALE_MS; + } catch {} + + if (lockIsStale) { + try { + fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true }); + continue; + } catch {} + } + + if (Date.now() - startedAt >= timeoutMs) { + throw new Error('Konnte Background-Registry nicht sperren.'); + } + + await sleep(50); + } + } + + try { + return await callback(); + } finally { + try { + fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true }); + } catch {} + } +} + +async function pruneBackgroundInstances() { + return withBackgroundInstancesLock(() => { + const snapshot = readBackgroundInstancesSnapshot(); + if (snapshot.changed) { + writeBackgroundInstances(snapshot.alive); + } + + return snapshot.alive; + }); +} + +async function registerBackgroundInstance(instance) { + return withBackgroundInstancesLock(() => { + const instances = readBackgroundInstancesSnapshot().alive; + const nextInstances = instances.filter((entry) => entry.pid !== instance.pid); + nextInstances.push(instance); + nextInstances.sort((left, right) => { + const byStartedAt = left.startedAt.localeCompare(right.startedAt); + if (byStartedAt !== 0) { + return byStartedAt; + } + return left.port - right.port; + }); + writeBackgroundInstances(nextInstances); + }); +} + +async function unregisterBackgroundInstance(pid) { + return withBackgroundInstancesLock(() => { + const instances = readBackgroundInstancesSnapshot().alive; + const nextInstances = instances.filter((entry) => entry.pid !== pid); + if (nextInstances.length !== instances.length) { + writeBackgroundInstances(nextInstances); + } + }); +} + +function createBackgroundInstance({ port, url }) { + return { + id: `${process.pid}-${Date.now()}`, + pid: process.pid, + port, + url, + host: BIND_HOST, + startedAt: new Date().toISOString(), + logFile: process.env.TTDASH_BACKGROUND_LOG_FILE || null, + }; +} + +function buildBackgroundLogFilePath() { + return path.join(BACKGROUND_LOG_DIR, `server-${Date.now()}.log`); +} + +async function waitForBackgroundInstance(pid, timeoutMs = BACKGROUND_START_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const instance = getBackgroundInstances().find((entry) => entry.pid === pid); + if (instance) { + return instance; + } + + if (!isProcessRunning(pid)) { + return null; + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + return null; +} + +async function waitForProcessExit(pid, timeoutMs = 5000) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (!isProcessRunning(pid)) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + return !isProcessRunning(pid); +} + +function formatBackgroundInstanceLabel(instance, index) { + const parts = [ + `${index + 1}. ${instance.url}`, + `PID ${instance.pid}`, + `Port ${instance.port}`, + `gestartet ${formatDateTime(instance.startedAt)}`, + ]; + + if (instance.logFile) { + parts.push(`Log ${instance.logFile}`); + } + + return parts.join(' | '); +} + +async function promptForBackgroundInstance(instances) { + if (instances.length === 1) { + return instances[0]; + } + + console.log('Mehrere TTDash-Background-Server laufen:'); + instances.forEach((instance, index) => { + console.log(` ${formatBackgroundInstanceLabel(instance, index)}`); + }); + console.log(''); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + while (true) { + const answer = (await rl.question(`Welche Instanz soll beendet werden? [1-${instances.length}, Enter=Abbrechen] `)).trim(); + + if (!answer) { + return null; + } + + const selection = Number.parseInt(answer, 10); + if (Number.isInteger(selection) && selection >= 1 && selection <= instances.length) { + return instances[selection - 1]; + } + + console.log(`Ungültige Auswahl: ${answer}`); + } + } finally { + rl.close(); + } +} + +async function stopBackgroundInstance(instance) { + if (!isProcessRunning(instance.pid)) { + await unregisterBackgroundInstance(instance.pid); + return { + status: 'already-stopped', + instance, + }; + } + + try { + process.kill(instance.pid, 'SIGTERM'); + } catch (error) { + if (error && error.code === 'ESRCH') { + await unregisterBackgroundInstance(instance.pid); + return { + status: 'already-stopped', + instance, + }; + } + + if (error && error.code === 'EPERM') { + return { + status: 'forbidden', + instance, + }; + } + + throw error; + } + + if (await waitForProcessExit(instance.pid)) { + await unregisterBackgroundInstance(instance.pid); + return { + status: 'stopped', + instance, + }; + } + + return { + status: 'timeout', + instance, + }; +} + +async function runStopCommand() { + ensureAppDirs(); + + const instances = await pruneBackgroundInstances(); + if (instances.length === 0) { + console.log('Keine laufenden TTDash-Background-Server gefunden.'); + return; + } + + const selectedInstance = await promptForBackgroundInstance(instances); + if (!selectedInstance) { + console.log('Abgebrochen.'); + return; + } + + const result = await stopBackgroundInstance(selectedInstance); + if (result.status === 'stopped') { + console.log(`TTDash-Background-Server beendet: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + return; + } + + if (result.status === 'already-stopped') { + console.log(`Instanz war bereits beendet und wurde aus der Registry entfernt: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + return; + } + + if (result.status === 'forbidden') { + console.error(`TTDash-Background-Server konnte nicht beendet werden (keine Berechtigung): ${selectedInstance.url} (PID ${selectedInstance.pid})`); + process.exitCode = 1; + return; + } + + console.error(`TTDash-Background-Server reagiert nicht auf SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + if (selectedInstance.logFile) { + console.error(`Log-Datei: ${selectedInstance.logFile}`); + } + process.exitCode = 1; +} + +function shouldBackgroundChildOpenBrowser() { + return !(CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1'); +} + +async function startInBackground() { + ensureAppDirs(); + + const logFile = buildBackgroundLogFilePath(); + const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background'); + const logFd = fs.openSync(logFile, 'a'); + + let child; + try { + child = spawn(process.execPath, [__filename, ...childArgs], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: { + ...process.env, + TTDASH_BACKGROUND_CHILD: '1', + TTDASH_BACKGROUND_LOG_FILE: logFile, + TTDASH_FORCE_OPEN_BROWSER: shouldBackgroundChildOpenBrowser() ? '1' : '0', + }, + }); + } finally { + fs.closeSync(logFd); + } + + child.unref(); + + const instance = await waitForBackgroundInstance(child.pid); + if (!instance) { + const logOutput = fs.existsSync(logFile) + ? fs.readFileSync(logFile, 'utf-8').trim() + : ''; + throw new Error(logOutput || `TTDash konnte nicht als Hintergrundprozess gestartet werden. Log: ${logFile}`); + } + + console.log('TTDash läuft im Hintergrund.'); + console.log(` URL: ${instance.url}`); + console.log(` PID: ${instance.pid}`); + console.log(` Log: ${logFile}`); + console.log(''); + console.log('Beenden mit:'); + console.log(' ttdash stop'); +} + function migrateLegacyDataFile() { if (!fs.existsSync(LEGACY_DATA_FILE) || fs.existsSync(DATA_FILE)) { return; @@ -320,7 +773,15 @@ function openBrowser(url) { } function shouldOpenBrowser() { - return !(CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1' || !process.stdout.isTTY); + if (CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1') { + return false; + } + + if (FORCE_OPEN_BROWSER) { + return true; + } + + return Boolean(process.stdout.isTTY); } function formatCurrency(value) { @@ -363,6 +824,9 @@ function printStartupSummary(url, port) { const autoLoadMode = CLI_OPTIONS.autoLoad ? 'aktiviert' : 'deaktiviert'; + const runtimeMode = IS_BACKGROUND_CHILD + ? 'Hintergrund' + : 'Vordergrund'; console.log(''); console.log(`${APP_LABEL} v${APP_VERSION} ist bereit`); @@ -370,9 +834,13 @@ function printStartupSummary(url, port) { console.log(` API: ${url}/api/usage`); console.log(` Port: ${port}`); console.log(` Host: ${BIND_HOST}`); + console.log(` Modus: ${runtimeMode}`); console.log(` Static Root: ${STATIC_ROOT}`); console.log(` Daten-Datei: ${DATA_FILE}`); console.log(` Settings-Datei: ${SETTINGS_FILE}`); + if (IS_BACKGROUND_CHILD && process.env.TTDASH_BACKGROUND_LOG_FILE) { + console.log(` Log-Datei: ${process.env.TTDASH_BACKGROUND_LOG_FILE}`); + } console.log(` Datenstatus: ${describeDataFile()}`); console.log(` Browser-Start: ${browserMode}`); console.log(` Auto-Load: ${autoLoadMode}`); @@ -384,6 +852,8 @@ function printStartupSummary(url, port) { console.log('Nützliche Kommandos:'); console.log(` ttdash --port ${port}`); console.log(` ttdash --port ${port} --no-open`); + console.log(' ttdash --background'); + console.log(' ttdash stop'); console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`); console.log(` curl ${url}/api/usage`); console.log(''); @@ -979,6 +1449,10 @@ async function start() { const browserHost = BIND_HOST === '0.0.0.0' ? 'localhost' : BIND_HOST; const url = `http://${browserHost}:${port}`; + if (IS_BACKGROUND_CHILD) { + await registerBackgroundInstance(createBackgroundInstance({ port, url })); + } + if (CLI_OPTIONS.autoLoad) { await runStartupAutoLoad({ source: 'cli-auto-load', @@ -989,20 +1463,48 @@ async function start() { openBrowser(url); } -start().catch((error) => { - console.error(error); - process.exit(1); +async function runCli() { + if (CLI_OPTIONS.command === 'stop') { + await runStopCommand(); + return; + } + + if (CLI_OPTIONS.background && !IS_BACKGROUND_CHILD) { + await startInBackground(); + return; + } + + await start(); +} + +runCli().catch((error) => { + Promise.resolve() + .then(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } + }) + .finally(() => { + console.error(error); + process.exit(1); + }); }); // Graceful shutdown on Ctrl+C / kill function shutdown(signal) { console.log(`\n${signal} empfangen, fahre Server herunter...`); - server.close(() => { + server.close(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } console.log('Server gestoppt.'); process.exit(0); }); // Force exit after 3s if connections don't close - setTimeout(() => { + setTimeout(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } console.log('Erzwinge Beendigung.'); process.exit(0); }, 3000); diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 4accb13..b1aa84f 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,6 +1,6 @@ import { createServer } from 'node:net' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -57,6 +57,111 @@ async function waitForServer(url: string) { throw new Error(`Timed out waiting for server startup:\n${output}`) } +async function waitForUrlAvailable(url: string) { + const startedAt = Date.now() + + while (Date.now() - startedAt < 15_000) { + try { + const response = await fetch(`${url}/api/usage`) + if (response.ok) { + return + } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + throw new Error(`Timed out waiting for server startup: ${url}`) +} + +async function waitForServerUnavailable(url: string) { + const startedAt = Date.now() + + while (Date.now() - startedAt < 15_000) { + try { + await fetch(`${url}/api/usage`) + } catch { + return + } + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + throw new Error(`Timed out waiting for server shutdown: ${url}`) +} + +function createCliEnv(root: string) { + return { + ...process.env, + HOME: root, + HOST: '127.0.0.1', + NO_OPEN_BROWSER: '1', + XDG_CACHE_HOME: path.join(root, 'cache'), + XDG_CONFIG_HOME: path.join(root, 'config'), + XDG_DATA_HOME: path.join(root, 'data'), + } +} + +function getCliConfigDir(root: string) { + if (process.platform === 'darwin') { + return path.join(root, 'Library', 'Application Support', 'TTDash') + } + + if (process.platform === 'win32') { + return path.join(root, 'AppData', 'Roaming', 'TTDash') + } + + return path.join(root, 'config', 'ttdash') +} + +function readBackgroundRegistry(root: string) { + const registryPath = path.join(getCliConfigDir(root), 'background-instances.json') + return JSON.parse(readFileSync(registryPath, 'utf-8')) as Array<{ url: string, port: number, pid: number }> +} + +async function runCli(args: string[], { env, input }: { env: NodeJS.ProcessEnv, input?: string }) { + return await new Promise<{ code: number | null, output: string }>((resolve, reject) => { + const cli = spawn(process.execPath, ['server.js', ...args], { + cwd: process.cwd(), + env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let cliOutput = '' + + cli.stdout.on('data', chunk => { + cliOutput += chunk.toString() + }) + + cli.stderr.on('data', chunk => { + cliOutput += chunk.toString() + }) + + cli.on('error', reject) + cli.on('close', code => { + resolve({ code, output: cliOutput }) + }) + + if (input) { + cli.stdin.write(input) + } + cli.stdin.end() + }) +} + +async function stopAllBackgroundServers(env: NodeJS.ProcessEnv) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const result = await runCli(['stop'], { + env, + input: '1\n', + }) + + if (result.output.includes('Keine laufenden TTDash-Background-Server gefunden.')) { + return + } + } +} + beforeAll(async () => { const port = await getFreePort() baseUrl = `http://127.0.0.1:${port}` @@ -198,4 +303,94 @@ describe('local server API', () => { lastLoadSource: null, }) }) + + it('starts background servers and stops the selected instance via the CLI', async () => { + const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-test-')) + const backgroundEnv = createCliEnv(backgroundRoot) + const firstPort = await getFreePort() + const secondPort = await getFreePort() + const firstUrl = `http://127.0.0.1:${firstPort}` + const secondUrl = `http://127.0.0.1:${secondPort}` + + try { + const firstStart = await runCli(['--background', '--no-open', '--port', String(firstPort)], { + env: backgroundEnv, + }) + + expect(firstStart.code).toBe(0) + expect(firstStart.output).toContain('TTDash läuft im Hintergrund.') + expect(firstStart.output).toContain(firstUrl) + await waitForUrlAvailable(firstUrl) + + const secondStart = await runCli(['--background', '--no-open', '--port', String(secondPort)], { + env: backgroundEnv, + }) + + expect(secondStart.code).toBe(0) + expect(secondStart.output).toContain('TTDash läuft im Hintergrund.') + expect(secondStart.output).toContain(secondUrl) + await waitForUrlAvailable(secondUrl) + + const stopSecond = await runCli(['stop'], { + env: backgroundEnv, + input: '2\n', + }) + + expect(stopSecond.code).toBe(0) + expect(stopSecond.output).toContain('Mehrere TTDash-Background-Server laufen:') + expect(stopSecond.output).toContain(firstUrl) + expect(stopSecond.output).toContain(secondUrl) + expect(stopSecond.output).toContain(`TTDash-Background-Server beendet: ${secondUrl}`) + + const firstUsageResponse = await fetch(`${firstUrl}/api/usage`) + expect(firstUsageResponse.status).toBe(200) + await waitForServerUnavailable(secondUrl) + + const stopFirst = await runCli(['stop'], { + env: backgroundEnv, + }) + + expect(stopFirst.code).toBe(0) + expect(stopFirst.output).toContain(`TTDash-Background-Server beendet: ${firstUrl}`) + await waitForServerUnavailable(firstUrl) + } finally { + await stopAllBackgroundServers(backgroundEnv) + rmSync(backgroundRoot, { recursive: true, force: true }) + } + }, 45_000) + + it('keeps both instances in the registry when background starts happen concurrently', async () => { + const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-parallel-test-')) + const backgroundEnv = createCliEnv(backgroundRoot) + const firstPort = await getFreePort() + const secondPort = await getFreePort() + const firstUrl = `http://127.0.0.1:${firstPort}` + const secondUrl = `http://127.0.0.1:${secondPort}` + + try { + const [firstStart, secondStart] = await Promise.all([ + runCli(['--background', '--no-open', '--port', String(firstPort)], { + env: backgroundEnv, + }), + runCli(['--background', '--no-open', '--port', String(secondPort)], { + env: backgroundEnv, + }), + ]) + + expect(firstStart.code).toBe(0) + expect(secondStart.code).toBe(0) + expect(firstStart.output).toContain('TTDash läuft im Hintergrund.') + expect(secondStart.output).toContain('TTDash läuft im Hintergrund.') + + await waitForUrlAvailable(firstUrl) + await waitForUrlAvailable(secondUrl) + + const registry = readBackgroundRegistry(backgroundRoot) + expect(registry).toHaveLength(2) + expect(registry.map(instance => instance.url).sort()).toEqual([firstUrl, secondUrl].sort()) + } finally { + await stopAllBackgroundServers(backgroundEnv) + rmSync(backgroundRoot, { recursive: true, force: true }) + } + }, 45_000) }) From 7e22933cc1daa2c53877c0e534f25747d8fb263d Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Fri, 10 Apr 2026 21:50:56 +0200 Subject: [PATCH 02/14] v6.0.12: Add settings backups and isolated E2E coverage --- CHANGELOG.md | 2 + README.md | 16 ++ scripts/start-test-server.js | 3 + server.js | 210 ++++++++++++++ src/components/Dashboard.tsx | 219 ++++++++++++++- src/components/EmptyState.tsx | 9 +- .../command-palette/CommandPalette.tsx | 6 +- .../features/limits/LimitsModal.tsx | 177 ------------ .../features/settings/SettingsModal.tsx | 264 ++++++++++++++++++ src/components/layout/Header.tsx | 6 +- src/lib/api.ts | 28 +- src/locales/de/common.json | 49 +++- src/locales/en/common.json | 49 +++- src/types/index.ts | 8 + tests/e2e/dashboard.spec.ts | 222 ++++++++++++++- tests/integration/server.test.ts | 100 +++++++ 16 files changed, 1154 insertions(+), 214 deletions(-) delete mode 100644 src/components/features/limits/LimitsModal.tsx create mode 100644 src/components/features/settings/SettingsModal.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b5917a9..626c590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,12 @@ ### Added - **Background-CLI für `ttdash`** — `--background` startet den lokalen Server als losgelösten Hintergrundprozess; `ttdash stop` listet laufende Instanzen und beendet auf Wunsch gezielt die ausgewählte +- **Settings-Backups im UI** — der bisherige Limits-Dialog ist jetzt ein generischer Settings-Dialog mit Import/Export für App-Settings und gespeicherte Nutzungsdaten ### Improved - **CLI-Dokumentation** — README, Hilfeausgabe und Installer-Hinweise dokumentieren jetzt den Hintergrundmodus und den Stop-Befehl direkt im Startpfad - **Race-safe Background-Registry** — parallele `--background`-Starts sperren die lokale Instanzdatei jetzt kurzzeitig, damit keine laufenden Server aus der Registry verloren gehen +- **Konservativer Datenimport** — Backup-Importe ergänzen fehlende Tage, überspringen identische Tage und behalten Konflikttage lokal statt sie still zu überschreiben ## [6.0.11] - 2026-04-10 diff --git a/README.md b/README.md index 71c87da..9137098 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Then either: 1. Click `Auto-Import` to load local `toktrack` data 2. Upload a `toktrack` JSON file manually 3. Upload a legacy `ccusage` export +4. Open `Settings` to export or import app backups The auto-import path prefers: @@ -122,6 +123,21 @@ The server automatically picks the next free port if `3000` is occupied. Background mode keeps the terminal free after launch. If multiple background instances are running, `ttdash stop` shows a selection prompt so you can choose which one to terminate. +## Backups + +The `Settings` dialog can export and import: + +- app settings backup +- stored usage data backup + +Data-backup import is conservative by design: + +- missing days are added +- identical days are skipped +- conflicting existing days stay local and are reported instead of being overwritten silently + +If you want to fully replace the current dataset with a fresh `toktrack` JSON, keep using the normal upload action in the header. + ## Sample Data A synthetic sample dataset for local verification is included at `examples/sample-usage.json`. diff --git a/scripts/start-test-server.js b/scripts/start-test-server.js index a2c02df..26c59fc 100644 --- a/scripts/start-test-server.js +++ b/scripts/start-test-server.js @@ -14,6 +14,9 @@ fs.mkdirSync(path.join(runtimeRoot, 'data'), { recursive: true }) process.env.NO_OPEN_BROWSER = '1' process.env.HOST = process.env.HOST || '127.0.0.1' process.env.PORT = process.env.PORT || '3015' +process.env.TTDASH_DATA_DIR = path.join(runtimeRoot, 'data') +process.env.TTDASH_CONFIG_DIR = path.join(runtimeRoot, 'config') +process.env.TTDASH_CACHE_DIR = path.join(runtimeRoot, 'cache') process.env.XDG_CACHE_HOME = path.join(runtimeRoot, 'cache') process.env.XDG_CONFIG_HOME = path.join(runtimeRoot, 'config') process.env.XDG_DATA_HOME = path.join(runtimeRoot, 'data') diff --git a/server.js b/server.js index b6af5d2..b464ef9 100755 --- a/server.js +++ b/server.js @@ -35,6 +35,9 @@ const SECURITY_HEADERS = { 'Content-Security-Policy': "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }; const APP_LABEL = 'TTDash'; +const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup'; +const USAGE_BACKUP_KIND = 'ttdash-usage-backup'; +const BACKUP_FORMAT_VERSION = 1; const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1'; const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1'; const BACKGROUND_START_TIMEOUT_MS = 15000; @@ -174,6 +177,20 @@ function parseCliArgs(rawArgs) { function resolveAppPaths() { const homeDir = os.homedir(); + const explicitPaths = { + dataDir: process.env.TTDASH_DATA_DIR, + configDir: process.env.TTDASH_CONFIG_DIR, + cacheDir: process.env.TTDASH_CACHE_DIR, + }; + + if (explicitPaths.dataDir || explicitPaths.configDir || explicitPaths.cacheDir) { + return { + dataDir: explicitPaths.dataDir || explicitPaths.configDir || explicitPaths.cacheDir, + configDir: explicitPaths.configDir || explicitPaths.dataDir || explicitPaths.cacheDir, + cacheDir: explicitPaths.cacheDir || explicitPaths.configDir || explicitPaths.dataDir, + }; + } + if (process.platform === 'darwin') { const appSupportDir = path.join(homeDir, 'Library', 'Application Support', APP_DIR_NAME); return { @@ -703,6 +720,166 @@ function sanitizeCurrency(value) { return Math.max(0, Number(value.toFixed(2))); } +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function computeUsageTotals(daily) { + return daily.reduce((totals, day) => ({ + inputTokens: totals.inputTokens + (day.inputTokens || 0), + outputTokens: totals.outputTokens + (day.outputTokens || 0), + cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0), + cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0), + thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0), + totalCost: totals.totalCost + (day.totalCost || 0), + totalTokens: totals.totalTokens + (day.totalTokens || 0), + requestCount: totals.requestCount + (day.requestCount || 0), + }), { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }); +} + +function sortStrings(values) { + return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === 'string' && value.trim()))] + .sort((left, right) => left.localeCompare(right)); +} + +function canonicalizeModelBreakdown(entry) { + return { + modelName: typeof entry?.modelName === 'string' ? entry.modelName : '', + inputTokens: Number(entry?.inputTokens) || 0, + outputTokens: Number(entry?.outputTokens) || 0, + cacheCreationTokens: Number(entry?.cacheCreationTokens) || 0, + cacheReadTokens: Number(entry?.cacheReadTokens) || 0, + thinkingTokens: Number(entry?.thinkingTokens) || 0, + cost: Number(entry?.cost) || 0, + requestCount: Number(entry?.requestCount) || 0, + }; +} + +function canonicalizeUsageDay(day) { + return { + date: typeof day?.date === 'string' ? day.date : '', + inputTokens: Number(day?.inputTokens) || 0, + outputTokens: Number(day?.outputTokens) || 0, + cacheCreationTokens: Number(day?.cacheCreationTokens) || 0, + cacheReadTokens: Number(day?.cacheReadTokens) || 0, + thinkingTokens: Number(day?.thinkingTokens) || 0, + totalTokens: Number(day?.totalTokens) || 0, + totalCost: Number(day?.totalCost) || 0, + requestCount: Number(day?.requestCount) || 0, + modelsUsed: sortStrings(day?.modelsUsed), + modelBreakdowns: (Array.isArray(day?.modelBreakdowns) ? day.modelBreakdowns : []) + .map(canonicalizeModelBreakdown) + .sort((left, right) => left.modelName.localeCompare(right.modelName)), + }; +} + +function areUsageDaysEquivalent(left, right) { + return JSON.stringify(canonicalizeUsageDay(left)) === JSON.stringify(canonicalizeUsageDay(right)); +} + +function extractSettingsImportPayload(payload) { + if (!isPlainObject(payload)) { + return payload; + } + + if (payload.kind === SETTINGS_BACKUP_KIND) { + if (!Object.prototype.hasOwnProperty.call(payload, 'settings')) { + throw new Error('Die Settings-Backup-Datei enthält keine Einstellungen.'); + } + return payload.settings; + } + + if (typeof payload.kind === 'string' && payload.kind === USAGE_BACKUP_KIND) { + throw new Error('Dies ist eine Daten-Backup-Datei und keine Settings-Datei.'); + } + + return payload; +} + +function extractUsageImportPayload(payload) { + if (!isPlainObject(payload)) { + return payload; + } + + if (payload.kind === USAGE_BACKUP_KIND) { + if (!Object.prototype.hasOwnProperty.call(payload, 'data')) { + throw new Error('Die Daten-Backup-Datei enthält keine Nutzungsdaten.'); + } + return payload.data; + } + + if (typeof payload.kind === 'string' && payload.kind === SETTINGS_BACKUP_KIND) { + throw new Error('Dies ist eine Settings-Backup-Datei und keine Daten-Datei.'); + } + + return payload; +} + +function mergeUsageData(currentData, importedData) { + const current = currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0 + ? normalizeIncomingData(currentData) + : null; + + if (!current) { + return { + data: importedData, + summary: { + importedDays: importedData.daily.length, + addedDays: importedData.daily.length, + unchangedDays: 0, + conflictingDays: 0, + totalDays: importedData.daily.length, + }, + }; + } + + const currentByDate = new Map(current.daily.map((day) => [day.date, day])); + let addedDays = 0; + let unchangedDays = 0; + let conflictingDays = 0; + + for (const importedDay of importedData.daily) { + const existingDay = currentByDate.get(importedDay.date); + if (!existingDay) { + currentByDate.set(importedDay.date, importedDay); + addedDays += 1; + continue; + } + + if (areUsageDaysEquivalent(existingDay, importedDay)) { + unchangedDays += 1; + continue; + } + + conflictingDays += 1; + } + + const mergedDaily = [...currentByDate.values()].sort((left, right) => left.date.localeCompare(right.date)); + + return { + data: { + daily: mergedDaily, + totals: computeUsageTotals(mergedDaily), + }, + summary: { + importedDays: importedData.daily.length, + addedDays, + unchangedDays, + conflictingDays, + totalDays: mergedDaily.length, + }, + }; +} + function normalizeProviderLimitConfig(value) { if (!value || typeof value !== 'object') { return { @@ -1284,6 +1461,21 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + if (apiPath === '/settings/import') { + if (req.method !== 'POST') { + return json(res, 405, { message: 'Method Not Allowed' }); + } + + try { + const body = await readBody(req); + const importedSettings = normalizeSettings(extractSettingsImportPayload(body)); + writeSettings(importedSettings); + return json(res, 200, toSettingsResponse(importedSettings)); + } catch (e) { + return json(res, 400, { message: e.message || 'Ungültige Settings-Datei' }); + } + } + if (apiPath === '/upload') { if (req.method === 'POST') { try { @@ -1305,6 +1497,24 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + if (apiPath === '/usage/import') { + if (req.method !== 'POST') { + return json(res, 405, { message: 'Method Not Allowed' }); + } + + try { + const body = await readBody(req); + const importedData = normalizeIncomingData(extractUsageImportPayload(body)); + const currentData = readData(); + const result = mergeUsageData(currentData, importedData); + writeData(result.data); + recordDataLoad('file'); + return json(res, 200, result.summary); + } catch (e) { + return json(res, 400, { message: e.message || 'Ungültige Daten-Datei' }); + } + } + if (apiPath === '/auto-import/stream') { if (req.method !== 'GET') { return json(res, 405, { message: 'Method Not Allowed' }); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 5085efd..cb9286c 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -47,17 +47,55 @@ import { useComputedMetrics } from '@/hooks/use-computed-metrics' import { useToast } from '@/components/ui/toast' import { applyTheme } from '@/lib/app-settings' import { downloadCSV } from '@/lib/csv-export' +import { VERSION } from '@/lib/constants' import { SECTION_HELP } from '@/lib/help-content' -import { generatePdfReport } from '@/lib/api' +import { generatePdfReport, importSettings, importUsageData } from '@/lib/api' import { formatCurrency, formatDateTimeCompact, formatDateTimeFull, formatTokens, formatPercent, periodUnit, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { getUniqueProviders } from '@/lib/model-utils' -import { LimitsModal } from './features/limits/LimitsModal' +import { SettingsModal } from './features/settings/SettingsModal' import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' import type { AppLanguage } from '@/types' const DrillDownModal = lazy(() => import('./features/drill-down/DrillDownModal').then(module => ({ default: module.DrillDownModal }))) const AutoImportModal = lazy(() => import('./features/auto-import/AutoImportModal').then(module => ({ default: module.AutoImportModal }))) +const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup' +const USAGE_BACKUP_KIND = 'ttdash-usage-backup' +const BACKUP_FORMAT_VERSION = 1 + +type JsonDownloadRecord = { + filename: string + mimeType: string + size: number + text: string +} + +type DashboardTestHooks = { + onJsonDownload?: (record: JsonDownloadRecord) => void + openSettings?: () => void +} + +function downloadJsonFile(filename: string, data: unknown) { + const text = JSON.stringify(data, null, 2) + const blob = new Blob([text], { type: 'application/json' }) + const globalWindow = window as Window & { + __TTDASH_TEST_HOOKS__?: DashboardTestHooks + } + globalWindow.__TTDASH_TEST_HOOKS__?.onJsonDownload?.({ + filename, + mimeType: blob.type, + size: blob.size, + text, + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) +} export function Dashboard() { const { t, i18n } = useTranslation() @@ -67,11 +105,15 @@ export function Dashboard() { const queryClient = useQueryClient() const { addToast } = useToast() const fileInputRef = useRef(null) + const settingsImportInputRef = useRef(null) + const dataImportInputRef = useRef(null) const [drillDownDate, setDrillDownDate] = useState(null) const [helpOpen, setHelpOpen] = useState(false) const [autoImportOpen, setAutoImportOpen] = useState(false) - const [limitsOpen, setLimitsOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const [reportGenerating, setReportGenerating] = useState(false) + const [settingsTransferBusy, setSettingsTransferBusy] = useState(false) + const [dataTransferBusy, setDataTransferBusy] = useState(false) const [dataSource, setDataSource] = useState<{ type: 'stored' | 'auto-import' | 'file'; label?: string; time?: string; title?: string } | null>(null) const [animationSeed, setAnimationSeed] = useState(0) @@ -84,6 +126,7 @@ export function Dashboard() { setTheme, setLanguage, setProviderLimits, + isSaving, } = useAppSettings(allProviders) const isDark = settings.theme === 'dark' @@ -97,6 +140,26 @@ export function Dashboard() { } }, [i18n, settings.language]) + useEffect(() => { + const globalWindow = window as Window & { + __TTDASH_TEST_HOOKS__?: DashboardTestHooks + } + + if (!globalWindow.__TTDASH_TEST_HOOKS__) { + return undefined + } + + globalWindow.__TTDASH_TEST_HOOKS__.openSettings = () => { + setSettingsOpen(true) + } + + return () => { + if (globalWindow.__TTDASH_TEST_HOOKS__?.openSettings) { + delete globalWindow.__TTDASH_TEST_HOOKS__.openSettings + } + } + }, []) + const persistedLoadedTime = useMemo( () => settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined, [settings.lastLoadedAt, i18n.resolvedLanguage], @@ -188,6 +251,10 @@ export function Dashboard() { fileInputRef.current?.click() }, []) + const handleOpenSettings = useCallback(() => { + setSettingsOpen(true) + }, []) + const handleToggleTheme = useCallback(() => { void setTheme(isDark ? 'light' : 'dark') }, [isDark, setTheme]) @@ -287,6 +354,100 @@ export function Dashboard() { addToast(t('toasts.dataImported'), 'success') }, [queryClient, addToast, t]) + const handleExportSettings = useCallback(() => { + downloadJsonFile(`ttdash-settings-backup-${localToday()}.json`, { + kind: SETTINGS_BACKUP_KIND, + version: BACKUP_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + appVersion: VERSION, + settings: { + language: settings.language, + theme: settings.theme, + providerLimits: settings.providerLimits, + lastLoadedAt: settings.lastLoadedAt, + lastLoadSource: settings.lastLoadSource, + }, + }) + addToast(t('toasts.settingsExported'), 'success') + }, [settings, addToast, t]) + + const handleExportData = useCallback(() => { + if (!usageData || usageData.daily.length === 0) { + addToast(t('toasts.noDataToExport'), 'info') + return + } + + downloadJsonFile(`ttdash-data-backup-${localToday()}.json`, { + kind: USAGE_BACKUP_KIND, + version: BACKUP_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + appVersion: VERSION, + data: usageData, + }) + addToast(t('toasts.dataExported'), 'success') + }, [usageData, addToast, t]) + + const handleImportSettings = useCallback(() => { + settingsImportInputRef.current?.click() + }, []) + + const handleImportData = useCallback(() => { + dataImportInputRef.current?.click() + }, []) + + const handleSettingsImportChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setSettingsTransferBusy(true) + try { + const parsed = JSON.parse(await file.text()) + const imported = await importSettings(parsed) + queryClient.setQueryData(['settings'], imported) + addToast(t('toasts.settingsImported', { name: file.name }), 'success') + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setSettingsTransferBusy(false) + e.target.value = '' + } + }, [queryClient, addToast, t]) + + const handleDataImportChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setDataTransferBusy(true) + try { + const parsed = JSON.parse(await file.text()) + const summary = await importUsageData(parsed) + await queryClient.invalidateQueries({ queryKey: ['usage'] }) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed(prev => prev + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) + setDataSource({ + type: 'file', + label: file.name, + time, + title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, + }) + + const toastType: 'info' | 'success' = summary.conflictingDays > 0 ? 'info' : 'success' + const toastKey = summary.conflictingDays > 0 ? 'toasts.dataBackupImportedWithConflicts' : 'toasts.dataBackupImported' + addToast(t(toastKey, { + added: summary.addedDays, + unchanged: summary.unchangedDays, + conflicts: summary.conflictingDays, + }), toastType) + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setDataTransferBusy(false) + e.target.value = '' + } + }, [queryClient, addToast, t]) + const handleScrollTo = useCallback((section: string) => { const el = document.getElementById(section) el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) @@ -299,18 +460,39 @@ export function Dashboard() { if (!hasData) { return ( <> - - + + + + {autoImportOpen && } + ) } return (
- + + +
setLimitsOpen(true)} - title="Provider Limits" + onClick={handleOpenSettings} + title={t('header.settings')} className="h-11 flex-col gap-1 px-0 text-[10px] sm:h-9 sm:flex-row sm:gap-2 sm:px-3 sm:text-sm" > - {t('header.limits')} + {t('header.settings')} )} pdfButton={( @@ -595,7 +777,7 @@ export function Dashboard() { onDelete={handleDelete} onUpload={handleUpload} onAutoImport={handleAutoImport} - onOpenLimits={() => setLimitsOpen(true)} + onOpenSettings={handleOpenSettings} onScrollTo={handleScrollTo} onViewModeChange={setViewMode} onApplyPreset={applyPreset} @@ -616,15 +798,22 @@ export function Dashboard() { {autoImportOpen && } -
) diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index 22384e1..52225a8 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,4 +1,4 @@ -import { Upload, ChartBar, Zap } from 'lucide-react' +import { Upload, ChartBar, Zap, SlidersHorizontal } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' @@ -8,9 +8,10 @@ import { VERSION } from '@/lib/constants' interface EmptyStateProps { onUpload: () => void onAutoImport: () => void + onOpenSettings: () => void } -export function EmptyState({ onUpload, onAutoImport }: EmptyStateProps) { +export function EmptyState({ onUpload, onAutoImport, onOpenSettings }: EmptyStateProps) { const { t } = useTranslation() return ( @@ -38,6 +39,10 @@ export function EmptyState({ onUpload, onAutoImport }: EmptyStateProps) { {t('emptyState.uploadFile')} + diff --git a/src/components/features/command-palette/CommandPalette.tsx b/src/components/features/command-palette/CommandPalette.tsx index b45458b..f6644f8 100644 --- a/src/components/features/command-palette/CommandPalette.tsx +++ b/src/components/features/command-palette/CommandPalette.tsx @@ -25,7 +25,7 @@ interface CommandPaletteProps { onDelete: () => void onUpload: () => void onAutoImport: () => void - onOpenLimits: () => void + onOpenSettings: () => void onScrollTo: (section: string) => void onViewModeChange: (mode: ViewMode) => void onApplyPreset: (preset: string) => void @@ -131,7 +131,7 @@ export function CommandPalette({ onDelete, onUpload, onAutoImport, - onOpenLimits, + onOpenSettings, onScrollTo, onViewModeChange, onApplyPreset, @@ -161,7 +161,7 @@ export function CommandPalette({ const baseCommands: CommandItem[] = [ { id: 'auto-import', label: t('commandPalette.commands.autoImport.label'), description: t('commandPalette.commands.autoImport.description'), keywords: ['toktrack', 'import', 'load', 'sync'], aliases: ['auto import', 'daten importieren'], icon: , action: onAutoImport, group: t('commandPalette.groups.actions') }, - { id: 'limits-open', label: t('commandPalette.commands.openLimits.label'), description: t('commandPalette.commands.openLimits.description'), keywords: ['limits', 'subscription', 'anbieter limit', 'budget'], aliases: ['limits dialog', 'subscriptions öffnen', 'provider limits'], icon: , action: onOpenLimits, group: t('commandPalette.groups.actions') }, + { id: 'settings-open', label: t('commandPalette.commands.openSettings.label'), description: t('commandPalette.commands.openSettings.description'), keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], icon: , action: onOpenSettings, group: t('commandPalette.groups.actions') }, { id: 'csv', label: t('commandPalette.commands.exportCsv.label'), description: t('commandPalette.commands.exportCsv.description'), keywords: ['download', 'export', 'csv'], aliases: ['csv download', 'daten exportieren'], shortcut: '⌘E', icon: , action: onExportCSV, group: t('commandPalette.groups.actions') }, { id: 'report', label: reportGenerating ? t('commandPalette.commands.generateReport.labelLoading') : t('commandPalette.commands.generateReport.label'), description: t('commandPalette.commands.generateReport.description'), keywords: ['pdf', 'report', 'bericht', 'export'], aliases: ['report export', 'pdf export', 'bericht generieren'], icon: , action: onGenerateReport, group: t('commandPalette.groups.actions') }, { id: 'upload', label: t('commandPalette.commands.upload.label'), description: t('commandPalette.commands.upload.description'), keywords: ['upload', 'file', 'json', 'import'], aliases: ['datei laden', 'json import'], shortcut: '⌘U', icon: , action: onUpload, group: t('commandPalette.groups.actions') }, diff --git a/src/components/features/limits/LimitsModal.tsx b/src/components/features/limits/LimitsModal.tsx deleted file mode 100644 index 1fd884b..0000000 --- a/src/components/features/limits/LimitsModal.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { InfoButton } from '@/components/features/help/InfoButton' -import { FEATURE_HELP } from '@/lib/help-content' -import { formatDateTimeFull } from '@/lib/formatters' -import { getProviderBadgeClasses } from '@/lib/model-utils' -import { syncProviderLimits } from '@/lib/provider-limits' -import { cn } from '@/lib/cn' -import type { DataLoadSource, ProviderLimits } from '@/types' - -interface LimitsModalProps { - open: boolean - onOpenChange: (open: boolean) => void - providers: string[] - limits: ProviderLimits - lastLoadedAt?: string | null - lastLoadSource?: DataLoadSource - cliAutoLoadActive?: boolean - onSave: (limits: ProviderLimits) => void -} - -function parseNumberInput(value: string): number { - const normalized = value.replace(',', '.').trim() - if (!normalized) return 0 - const parsed = Number.parseFloat(normalized) - if (!Number.isFinite(parsed)) return 0 - return Math.max(0, Number(parsed.toFixed(2))) -} - -export function LimitsModal({ open, onOpenChange, providers, limits, lastLoadedAt, lastLoadSource, cliAutoLoadActive = false, onSave }: LimitsModalProps) { - const { t } = useTranslation() - const [draft, setDraft] = useState(() => syncProviderLimits(providers, limits)) - - useEffect(() => { - if (!open) return - setDraft(syncProviderLimits(providers, limits)) - }, [open, providers, limits]) - - const updateProvider = (provider: string, patch: Partial) => { - setDraft(prev => ({ - ...prev, - [provider]: { - ...prev[provider], - ...patch, - }, - })) - } - - const handleSave = () => { - onSave(syncProviderLimits(providers, draft)) - onOpenChange(false) - } - - const loadSourceLabel = lastLoadSource - ? t(`limits.modal.sources.${lastLoadSource}`) - : t('limits.modal.sources.unknown') - - return ( - - - - - {t('limits.modal.title')} - - - - {t('limits.modal.description')} - - - -
-
- {t('limits.modal.dataStatus')} -
-
-
-
{t('limits.modal.lastLoaded')}
-
- {lastLoadedAt ? formatDateTimeFull(lastLoadedAt) : t('common.notAvailable')} -
-
-
-
{t('limits.modal.loadedVia')}
-
{loadSourceLabel}
-
-
-
{t('limits.modal.cliAutoLoad')}
-
- {cliAutoLoadActive ? t('common.enabled') : t('common.disabled')} -
-
-
-
- - {providers.length === 0 ? ( -
- {t('limits.modal.noProviders')} -
- ) : ( -
- {providers.map((provider) => { - const config = draft[provider] - - return ( -
-
-
-
- - {provider} - - -
-
- -
- - - -
-
-
- ) - })} -
- )} - -
- -
- - -
-
-
-
- ) -} diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx new file mode 100644 index 0000000..1a8203c --- /dev/null +++ b/src/components/features/settings/SettingsModal.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { InfoButton } from '@/components/features/help/InfoButton' +import { FEATURE_HELP } from '@/lib/help-content' +import { formatDateTimeFull } from '@/lib/formatters' +import { getProviderBadgeClasses } from '@/lib/model-utils' +import { syncProviderLimits } from '@/lib/provider-limits' +import { cn } from '@/lib/cn' +import { Database, Download, Settings2, Upload } from 'lucide-react' +import type { DataLoadSource, ProviderLimits } from '@/types' + +interface SettingsModalProps { + open: boolean + onOpenChange: (open: boolean) => void + providers: string[] + limits: ProviderLimits + lastLoadedAt?: string | null + lastLoadSource?: DataLoadSource + cliAutoLoadActive?: boolean + hasData: boolean + onSaveLimits: (limits: ProviderLimits) => void + onExportSettings: () => void + onImportSettings: () => void + onExportData: () => void + onImportData: () => void + settingsBusy?: boolean + dataBusy?: boolean +} + +function parseNumberInput(value: string): number { + const normalized = value.replace(',', '.').trim() + if (!normalized) return 0 + const parsed = Number.parseFloat(normalized) + if (!Number.isFinite(parsed)) return 0 + return Math.max(0, Number(parsed.toFixed(2))) +} + +export function SettingsModal({ + open, + onOpenChange, + providers, + limits, + lastLoadedAt, + lastLoadSource, + cliAutoLoadActive = false, + hasData, + onSaveLimits, + onExportSettings, + onImportSettings, + onExportData, + onImportData, + settingsBusy = false, + dataBusy = false, +}: SettingsModalProps) { + const { t } = useTranslation() + const [draft, setDraft] = useState(() => syncProviderLimits(providers, limits)) + + useEffect(() => { + if (!open) return + setDraft(syncProviderLimits(providers, limits)) + }, [open, providers, limits]) + + const updateProvider = (provider: string, patch: Partial) => { + setDraft(prev => ({ + ...prev, + [provider]: { + ...prev[provider], + ...patch, + }, + })) + } + + const handleSave = () => { + onSaveLimits(syncProviderLimits(providers, draft)) + onOpenChange(false) + } + + const loadSourceLabel = lastLoadSource + ? t(`settings.modal.sources.${lastLoadSource}`) + : t('settings.modal.sources.unknown') + + return ( + + + + + {t('settings.modal.title')} + + + + {t('settings.modal.description')} + + + +
+
+ {t('settings.modal.dataStatus')} +
+
+
+
{t('settings.modal.lastLoaded')}
+
+ {lastLoadedAt ? formatDateTimeFull(lastLoadedAt) : t('common.notAvailable')} +
+
+
+
{t('settings.modal.loadedVia')}
+
{loadSourceLabel}
+
+
+
{t('settings.modal.cliAutoLoad')}
+
+ {cliAutoLoadActive ? t('common.enabled') : t('common.disabled')} +
+
+
+
+ +
+
+
+ + + +
+
{t('settings.modal.settingsBackupTitle')}
+

{t('settings.modal.settingsBackupDescription')}

+
+
+
+ + +
+
+ +
+
+ + + +
+
{t('settings.modal.dataBackupTitle')}
+

{t('settings.modal.dataBackupDescription')}

+
+
+

+ {t('settings.modal.dataImportPolicy')} +

+

+ {t('settings.modal.dataImportReplaceHint')} +

+
+ + +
+
+
+ +
+
+
+ {t('settings.modal.providerLimitsTitle')} +
+

+ {t('settings.modal.providerLimitsDescription')} +

+
+ + {providers.length === 0 ? ( +
+ {t('settings.modal.noProviders')} +
+ ) : ( +
+ {providers.map((provider) => { + const config = draft[provider] + + return ( +
+
+
+
+ + {provider} + + +
+
+ +
+ + + +
+
+
+ ) + })} +
+ )} +
+ +
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 276628d..83ad34d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -33,7 +33,7 @@ interface HeaderProps { onDelete: () => void onUpload: () => void onAutoImport: () => void - limitsButton?: React.ReactNode + settingsButton?: React.ReactNode pdfButton?: React.ReactNode } @@ -81,7 +81,7 @@ function StartupAutoLoadBadge({ badge }: { badge: StartupAutoLoad }) { ) } -export function Header({ dateRange, isDark, currentLanguage, helpOpen, streak, dataSource, startupAutoLoad, onHelpOpenChange, onLanguageChange, onToggleTheme, onExportCSV, onDelete, onUpload, onAutoImport, limitsButton, pdfButton }: HeaderProps) { +export function Header({ dateRange, isDark, currentLanguage, helpOpen, streak, dataSource, startupAutoLoad, onHelpOpenChange, onLanguageChange, onToggleTheme, onExportCSV, onDelete, onUpload, onAutoImport, settingsButton, pdfButton }: HeaderProps) { const { t } = useTranslation() return ( @@ -157,7 +157,7 @@ export function Header({ dateRange, isDark, currentLanguage, helpOpen, streak, d
- {limitsButton} + {settingsButton}
{pdfButton} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9e45bba..abdab05 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { AppSettings, AppLanguage, AppTheme, ProviderLimits, UsageData, ViewMode } from '@/types' +import type { AppSettings, AppLanguage, AppTheme, ProviderLimits, UsageData, UsageImportSummary, ViewMode } from '@/types' import i18n from '@/lib/i18n' import { normalizeAppSettings } from '@/lib/app-settings' @@ -26,6 +26,19 @@ export async function deleteUsage(): Promise { if (!res.ok) throw new Error(i18n.t('api.deleteFailed')) } +export async function importUsageData(data: unknown): Promise { + const res = await fetch('/api/usage/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: i18n.t('api.importUsageFailed') })) + throw new Error(err.message) + } + return res.json() +} + export interface UpdateSettingsRequest { language?: AppLanguage theme?: AppTheme @@ -51,6 +64,19 @@ export async function updateSettings(patch: UpdateSettingsRequest): Promise { + const res = await fetch('/api/settings/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: i18n.t('api.importSettingsFailed') })) + throw new Error(err.message) + } + return normalizeAppSettings(await res.json()) +} + export interface PdfReportRequest { viewMode: ViewMode selectedMonth: string | null diff --git a/src/locales/de/common.json b/src/locales/de/common.json index d0dbb1e..cdf6583 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -11,6 +11,7 @@ "import": "Import", "upload": "Upload", "limits": "Limits", + "settings": "Einstellungen", "report": "Report", "csv": "CSV", "delete": "Löschen", @@ -24,6 +25,7 @@ "description": "Lade ein `toktrack`- oder Legacy-JSON hoch oder starte den lokalen Auto-Import mit lokalem `toktrack`, `bunx toktrack daily --json` oder `npx --yes toktrack daily --json`.", "autoImport": "Auto-Import", "uploadFile": "Datei hochladen", + "openSettings": "Einstellungen & Backups", "or": "oder" }, "viewModes": { @@ -680,9 +682,9 @@ "label": "Auto-Import starten", "description": "Lokalen toktrack Import ausführen" }, - "openLimits": { - "label": "Limits öffnen", - "description": "Provider Limits und Subscriptions konfigurieren" + "openSettings": { + "label": "Einstellungen öffnen", + "description": "Backups, App-Optionen und Provider Limits verwalten" }, "exportCsv": { "label": "CSV exportieren", @@ -831,6 +833,37 @@ } } }, + "settings": { + "modal": { + "title": "Einstellungen", + "description": "Verwalte App-Backups, gespeicherte Daten und Provider Limits an einem Ort.", + "dataStatus": "Datenstatus", + "lastLoaded": "Zuletzt geladen", + "loadedVia": "Geladen über", + "cliAutoLoad": "CLI Auto-Load", + "settingsBackupTitle": "Einstellungen sichern", + "settingsBackupDescription": "Exportiert und importiert Sprache, Theme, Limits und die gespeicherten Lade-Metadaten als versioniertes Backup.", + "dataBackupTitle": "Daten sichern", + "dataBackupDescription": "Exportiert den lokal gespeicherten Nutzungsstand als Backup und importiert Backups konservativ zurück.", + "dataImportPolicy": "Beim Datenimport werden nur fehlende Tage ergänzt. Bestehende Tage mit abweichenden Werten bleiben lokal erhalten und werden als Konflikt gemeldet.", + "dataImportReplaceHint": "Wenn du einen Datensatz vollständig ersetzen willst, nutze weiter den normalen JSON-Upload im Header.", + "providerLimitsTitle": "Provider Limits", + "providerLimitsDescription": "Definiere Subscription und Monatslimit pro Anbieter. Nur Anbieter aus dem aktuell geladenen Report sind editierbar.", + "noProviders": "Keine Anbieter im geladenen Report gefunden.", + "exportSettings": "Einstellungen exportieren", + "importSettings": "Einstellungen importieren", + "exportData": "Daten exportieren", + "importData": "Daten importieren", + "close": "Schliessen", + "save": "Speichern", + "sources": { + "file": "Datei-Upload", + "auto-import": "Auto-Import", + "cli-auto-load": "CLI Auto-Load", + "unknown": "Unbekannt" + } + } + }, "limits": { "sectionTitle": "Limits & Subscriptions", "sectionDescription": "Budget-Risiko getrennt von Subscription-Wirkung im aktuellen Filterkontext", @@ -911,6 +944,8 @@ "fetchUsageFailed": "Fehler beim Laden der Daten", "uploadFailed": "Upload fehlgeschlagen", "deleteFailed": "Löschen fehlgeschlagen", + "importUsageFailed": "Datenimport fehlgeschlagen", + "importSettingsFailed": "Einstellungs-Import fehlgeschlagen", "pdfFailed": "PDF-Generierung fehlgeschlagen" }, "toasts": { @@ -918,6 +953,12 @@ "fileReadFailed": "Datei konnte nicht gelesen werden", "dataDeleted": "Daten gelöscht", "csvExported": "CSV exportiert", - "dataImported": "Daten erfolgreich importiert" + "dataImported": "Daten erfolgreich importiert", + "settingsExported": "Einstellungs-Backup exportiert", + "dataExported": "Daten-Backup exportiert", + "noDataToExport": "Keine Daten zum Exportieren vorhanden", + "settingsImported": "Einstellungen aus {{name}} importiert", + "dataBackupImported": "Backup importiert: {{added}} neue Tage ergänzt, {{unchanged}} identische Tage übersprungen", + "dataBackupImportedWithConflicts": "Backup importiert: {{added}} neue Tage ergänzt, {{conflicts}} Konflikttage lokal beibehalten" } } diff --git a/src/locales/en/common.json b/src/locales/en/common.json index bf66e5d..10cf4f5 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -11,6 +11,7 @@ "import": "Import", "upload": "Upload", "limits": "Limits", + "settings": "Settings", "report": "Report", "csv": "CSV", "delete": "Delete", @@ -24,6 +25,7 @@ "description": "Upload a `toktrack` or legacy JSON file, or start local auto-import with local `toktrack`, `bunx toktrack daily --json`, or `npx --yes toktrack daily --json`.", "autoImport": "Auto import", "uploadFile": "Upload file", + "openSettings": "Settings & backups", "or": "or" }, "viewModes": { @@ -680,9 +682,9 @@ "label": "Start auto import", "description": "Run local toktrack import" }, - "openLimits": { - "label": "Open limits", - "description": "Configure provider limits and subscriptions" + "openSettings": { + "label": "Open settings", + "description": "Manage backups, app options, and provider limits" }, "exportCsv": { "label": "Export CSV", @@ -831,6 +833,37 @@ } } }, + "settings": { + "modal": { + "title": "Settings", + "description": "Manage app backups, stored data, and provider limits in one place.", + "dataStatus": "Data status", + "lastLoaded": "Last loaded", + "loadedVia": "Loaded via", + "cliAutoLoad": "CLI auto-load", + "settingsBackupTitle": "Back up settings", + "settingsBackupDescription": "Export and import language, theme, limits, and stored load metadata as a versioned backup.", + "dataBackupTitle": "Back up data", + "dataBackupDescription": "Export the locally stored usage state as a backup and import backups conservatively.", + "dataImportPolicy": "Data import only adds missing days. Existing days with different values stay local and are reported as conflicts.", + "dataImportReplaceHint": "If you want to fully replace the dataset, keep using the regular JSON upload in the header.", + "providerLimitsTitle": "Provider limits", + "providerLimitsDescription": "Define subscription and monthly limit per provider. Only providers from the currently loaded report can be edited.", + "noProviders": "No providers found in the loaded report.", + "exportSettings": "Export settings", + "importSettings": "Import settings", + "exportData": "Export data", + "importData": "Import data", + "close": "Close", + "save": "Save", + "sources": { + "file": "File upload", + "auto-import": "Auto import", + "cli-auto-load": "CLI auto-load", + "unknown": "Unknown" + } + } + }, "limits": { "sectionTitle": "Limits & Subscriptions", "sectionDescription": "Budget risk separated from subscription impact in the current filter context", @@ -911,6 +944,8 @@ "fetchUsageFailed": "Failed to load data", "uploadFailed": "Upload failed", "deleteFailed": "Delete failed", + "importUsageFailed": "Data import failed", + "importSettingsFailed": "Settings import failed", "pdfFailed": "PDF generation failed" }, "toasts": { @@ -918,6 +953,12 @@ "fileReadFailed": "Could not read file", "dataDeleted": "Data deleted", "csvExported": "CSV exported", - "dataImported": "Data imported successfully" + "dataImported": "Data imported successfully", + "settingsExported": "Settings backup exported", + "dataExported": "Data backup exported", + "noDataToExport": "No data available to export", + "settingsImported": "Imported settings from {{name}}", + "dataBackupImported": "Backup imported: added {{added}} new days, skipped {{unchanged}} identical days", + "dataBackupImportedWithConflicts": "Backup imported: added {{added}} new days, kept {{conflicts}} conflicting days local" } } diff --git a/src/types/index.ts b/src/types/index.ts index f1b7426..afc59e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,6 +39,14 @@ export interface UsageData { } } +export interface UsageImportSummary { + importedDays: number + addedDays: number + unchangedDays: number + conflictingDays: number + totalDays: number +} + export type AppLanguage = 'de' | 'en' export type AppTheme = 'dark' | 'light' diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 139e695..93b90fb 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -1,7 +1,23 @@ +import fs from 'node:fs' +import fsPromises from 'node:fs/promises' import path from 'node:path' -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' const sampleUsagePath = path.join(process.cwd(), 'examples', 'sample-usage.json') +const sampleUsage = JSON.parse(fs.readFileSync(sampleUsagePath, 'utf-8')) +const uploadToastPattern = /^(Datei sample-usage\.json erfolgreich geladen|File sample-usage\.json loaded successfully)$/ +const autoImportButtonPattern = /^(Auto-Import|Auto import)$/ +const uploadFileButtonPattern = /^(Datei hochladen|Upload file)$/ +const settingsButtonPattern = /^(Einstellungen|Settings)$/ +const settingsHeadingPattern = /^(Einstellungen|Settings)$/ +const exportSettingsButtonPattern = /^(Einstellungen exportieren|Export settings)$/ +const exportDataButtonPattern = /^(Daten exportieren|Export data)$/ +const dataImportToastPattern = /^(Backup importiert: 1 neue Tage ergänzt, 1 Konflikttage lokal beibehalten|Backup imported: added 1 new days, kept 1 conflicting days local)$/ + +async function uploadSampleUsage(page: Page) { + await page.locator('[data-testid="usage-upload-input"]').setInputFiles(sampleUsagePath) + await expect(page.getByText(uploadToastPattern)).toBeVisible() +} test('uploads sample usage data and renders the dashboard without browser errors', async ({ page }) => { const pageErrors: string[] = [] @@ -21,16 +37,212 @@ test('uploads sample usage data and renders the dashboard without browser errors await page.goto('/') await expect(page.getByRole('heading', { name: 'TTDash' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Auto-Import' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Datei hochladen' })).toBeVisible() + await expect(page.getByRole('button', { name: autoImportButtonPattern })).toBeVisible() + await expect(page.getByRole('button', { name: uploadFileButtonPattern })).toBeVisible() - await page.locator('input[type="file"]').setInputFiles(sampleUsagePath) + await uploadSampleUsage(page) await expect(page.getByRole('button', { name: 'Import' })).toBeVisible() await expect(page.getByRole('button', { name: 'Upload' })).toBeVisible() await expect(page.getByRole('button', { name: 'CSV' })).toBeVisible() - await expect(page.getByText(/^Datei sample-usage\.json erfolgreich geladen$/)).toBeVisible() await expect(page.locator('#token-analysis')).toBeVisible() expect(pageErrors, pageErrors.join('\n')).toEqual([]) }) + +test('manages settings and backup imports through the settings dialog using isolated test storage', async ({ page }, testInfo) => { + await page.request.delete('/api/usage') + await page.addInitScript(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + __TTDASH_TEST_HOOKS__?: { + onJsonDownload?: (record: { filename: string, mimeType: string, size: number, text: string }) => void + openSettings?: () => void + } + } + globalWindow.__TTDASH_DOWNLOAD_RECORDS__ = [] + globalWindow.__TTDASH_TEST_HOOKS__ = { + onJsonDownload: (record) => { + globalWindow.__TTDASH_DOWNLOAD_RECORDS__?.push(record) + }, + } + }) + await page.goto('/') + await uploadSampleUsage(page) + await expect(page.locator('#token-analysis')).toBeVisible() + + await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + await expect(page.getByRole('dialog')).toBeVisible() + + await page.getByRole('button', { name: exportSettingsButtonPattern }).click() + await expect.poll(async () => { + const records = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + }) + return records.length + }).toBe(1) + const exportedSettingsRecord = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + return records[0] + }) + expect(exportedSettingsRecord.filename).toMatch(/^ttdash-settings-backup-\d{4}-\d{2}-\d{2}\.json$/) + const exportedSettings = JSON.parse(exportedSettingsRecord.text) + expect(exportedSettings.kind).toBe('ttdash-settings-backup') + + await page.getByRole('button', { name: exportDataButtonPattern }).click() + await expect.poll(async () => { + const records = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + }) + return records.length + }).toBe(2) + const exportedDataRecord = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + return records[1] + }) + expect(exportedDataRecord.filename).toMatch(/^ttdash-data-backup-\d{4}-\d{2}-\d{2}\.json$/) + const exportedData = JSON.parse(exportedDataRecord.text) + expect(exportedData.kind).toBe('ttdash-usage-backup') + expect(exportedData.data.daily).toHaveLength(5) + + const importDataPath = testInfo.outputPath('usage-backup-import.json') + await fsPromises.writeFile(importDataPath, JSON.stringify({ + kind: 'ttdash-usage-backup', + version: 1, + data: { + daily: [ + sampleUsage.daily[0], + { + ...sampleUsage.daily[1], + totalCost: 999, + }, + { + ...sampleUsage.daily[0], + date: '2026-03-31', + }, + ], + }, + }, null, 2)) + + await page.locator('[data-testid="data-import-input"]').setInputFiles(importDataPath) + await expect(page.getByText(dataImportToastPattern)).toBeVisible() + + const mergedUsageResponse = await page.request.get('/api/usage') + expect(mergedUsageResponse.ok()).toBe(true) + const mergedUsage = await mergedUsageResponse.json() + expect(mergedUsage.daily).toHaveLength(6) + expect(mergedUsage.daily[0].date).toBe('2026-03-31') + expect(mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost).toBe(3.94) + + const importSettingsPath = testInfo.outputPath('settings-backup-import.json') + await fsPromises.writeFile(importSettingsPath, JSON.stringify({ + kind: 'ttdash-settings-backup', + version: 1, + settings: { + language: 'en', + theme: 'light', + providerLimits: { + OpenAI: { + hasSubscription: true, + subscriptionPrice: 20, + monthlyLimit: 400, + }, + }, + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', + }, + }, null, 2)) + + await page.locator('[data-testid="settings-import-input"]').setInputFiles(importSettingsPath) + await expect(page.getByRole('button', { name: 'Export settings' })).toBeVisible() + + const importedSettingsResponse = await page.request.get('/api/settings') + expect(importedSettingsResponse.ok()).toBe(true) + const importedSettings = await importedSettingsResponse.json() + expect(importedSettings.language).toBe('en') + expect(importedSettings.theme).toBe('light') + expect(importedSettings.providerLimits.OpenAI.monthlyLimit).toBe(400) +}) + +test('loads persisted settings on a fresh browser start and applies them immediately', async ({ browser, page }) => { + await page.request.delete('/api/usage') + + const patchSettingsResponse = await page.request.patch('/api/settings', { + data: { + language: 'en', + theme: 'light', + providerLimits: { + OpenAI: { + hasSubscription: true, + subscriptionPrice: 20, + monthlyLimit: 400, + }, + }, + }, + }) + expect(patchSettingsResponse.ok()).toBe(true) + + const uploadResponse = await page.request.post('/api/upload', { + data: sampleUsage, + }) + expect(uploadResponse.ok()).toBe(true) + + const context = await browser.newContext() + await context.addInitScript(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__ = {} + }) + + const freshPage = await context.newPage() + + try { + await freshPage.goto('/') + await expect(freshPage.locator('#token-analysis')).toBeVisible() + await expect.poll(async () => freshPage.evaluate(() => document.documentElement.classList.contains('dark'))).toBe(false) + await expect(freshPage.getByRole('button', { name: 'Settings' })).toBeVisible() + await expect(freshPage.getByText('Filter status')).toBeVisible() + await expect(freshPage.getByRole('button', { name: 'Delete' })).toBeVisible() + + await freshPage.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + + const dialog = freshPage.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Export settings' })).toBeVisible() + await expect(dialog.getByText('OpenAI')).toBeVisible() + const openAiCard = dialog.getByText('OpenAI', { exact: true }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]') + await expect(openAiCard.locator('input[type="number"]').nth(0)).toHaveValue('20') + await expect(openAiCard.locator('input[type="number"]').nth(1)).toHaveValue('400') + } finally { + await context.close() + } +}) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index b1aa84f..687c820 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -304,6 +304,106 @@ describe('local server API', () => { }) }) + it('imports settings backups and merges usage backups without overwriting conflicting local days', async () => { + const seedResponse = await fetch(`${baseUrl}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sampleUsage), + }) + expect(seedResponse.status).toBe(200) + + const settingsImportResponse = await fetch(`${baseUrl}/api/settings/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-settings-backup', + version: 1, + settings: { + language: 'de', + theme: 'light', + providerLimits: { + Anthropic: { + hasSubscription: true, + subscriptionPrice: 21.499, + monthlyLimit: 300.111, + }, + }, + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', + }, + }), + }) + + expect(settingsImportResponse.status).toBe(200) + expect(await settingsImportResponse.json()).toMatchObject({ + language: 'de', + theme: 'light', + providerLimits: { + Anthropic: { + hasSubscription: true, + subscriptionPrice: 21.5, + monthlyLimit: 300.11, + }, + }, + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', + cliAutoLoadActive: false, + }) + + const newImportedDay = { + ...sampleUsage.daily[0], + date: '2026-03-31', + } + + const usageImportResponse = await fetch(`${baseUrl}/api/usage/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-usage-backup', + version: 1, + data: { + daily: [ + sampleUsage.daily[0], + { + ...sampleUsage.daily[1], + totalCost: 999, + modelBreakdowns: sampleUsage.daily[1].modelBreakdowns.map((entry, index) => ( + index === 0 + ? { ...entry, cost: 997 } + : entry + )), + }, + newImportedDay, + ], + }, + }), + }) + + expect(usageImportResponse.status).toBe(200) + expect(await usageImportResponse.json()).toEqual({ + importedDays: 3, + addedDays: 1, + unchangedDays: 1, + conflictingDays: 1, + totalDays: 6, + }) + + const mergedUsageResponse = await fetch(`${baseUrl}/api/usage`) + expect(mergedUsageResponse.status).toBe(200) + const mergedUsage = await mergedUsageResponse.json() + expect(mergedUsage.daily).toHaveLength(6) + expect(mergedUsage.daily[0].date).toBe('2026-03-31') + expect(mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost).toBeCloseTo(3.94, 6) + + const mergedSettingsResponse = await fetch(`${baseUrl}/api/settings`) + expect(mergedSettingsResponse.status).toBe(200) + expect(await mergedSettingsResponse.json()).toMatchObject({ + theme: 'light', + language: 'de', + lastLoadSource: 'file', + }) + }) + it('starts background servers and stops the selected instance via the CLI', async () => { const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-test-')) const backgroundEnv = createCliEnv(backgroundRoot) From 9a984f9a423474ec30240576eb630904fd2d1ce9 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Fri, 10 Apr 2026 22:03:12 +0200 Subject: [PATCH 03/14] v6.0.12: Switch CLI output to English --- install.bat | 72 ++++++++-------- install.sh | 82 +++++++++--------- server.js | 138 +++++++++++++++---------------- tests/integration/server.test.ts | 16 ++-- 4 files changed, 155 insertions(+), 153 deletions(-) diff --git a/install.bat b/install.bat index 3124e3d..1ed6519 100644 --- a/install.bat +++ b/install.bat @@ -5,7 +5,7 @@ cd /d "%SCRIPT_DIR%" || exit /b 1 set "INSTALL_TOOL=npm" set "BUILD_TOOL=npm" set "GLOBAL_TOOL=npm" -set "APP_VERSION=unbekannt" +set "APP_VERSION=unknown" set "APP_NAME=ttdash" for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content -Raw 'package.json' | ConvertFrom-Json).version"`) do set "APP_VERSION=%%v" @@ -14,7 +14,7 @@ for /f "usebackq delims=" %%n in (`powershell -NoProfile -Command "(Get-Content echo. echo ttdash v%APP_VERSION% installer echo %cd% -echo Plattform: Windows ^| Benutzer: %USERNAME% +echo Platform: Windows ^| User: %USERNAME% echo. where bun >nul 2>&1 @@ -25,54 +25,54 @@ if %errorlevel% equ 0 ( ) echo Tooling: -echo - Installation: %INSTALL_TOOL% +echo - Install: %INSTALL_TOOL% echo - Global: %GLOBAL_TOOL% -echo - Build-Ziel: %cd%\dist +echo - Build target: %cd%\dist echo. :: 1 — Dependencies -echo [1/3] Installiere Abhangigkeiten... +echo [1/3] Installing dependencies... if /i "%INSTALL_TOOL%"=="bun" ( - echo - Fuehre bun install aus + echo - Running bun install call bun install >nul 2>&1 if !errorlevel! neq 0 ( - echo x bun install fehlgeschlagen + echo x bun install failed exit /b 1 ) - echo √ bun install abgeschlossen + echo √ bun install completed ) else ( - echo - Fuehre npm install --no-audit --no-fund aus + echo - Running npm install --no-audit --no-fund call npm install --no-audit --no-fund >nul 2>&1 if !errorlevel! neq 0 ( - echo x npm install fehlgeschlagen + echo x npm install failed exit /b 1 ) - echo √ npm install abgeschlossen + echo √ npm install completed ) :: 2 — Build echo. -echo [2/3] Baue Frontend... +echo [2/3] Building frontend... if /i "%BUILD_TOOL%"=="bun" ( - echo - Fuehre bun run build aus + echo - Running bun run build call bun run build >nul 2>&1 if !errorlevel! neq 0 ( - echo x Build fehlgeschlagen + echo x Build failed exit /b 1 ) ) else ( - echo - Fuehre npm run build aus + echo - Running npm run build call npm run build >nul 2>&1 if !errorlevel! neq 0 ( - echo x Build fehlgeschlagen + echo x Build failed exit /b 1 ) ) -echo √ Build abgeschlossen (dist/) +echo √ Build completed (dist/) :: 3 — Global install echo. -echo [3/3] Installiere global... +echo [3/3] Installing globally... if /i "%GLOBAL_TOOL%"=="bun" ( for /f "usebackq delims=" %%p in (`bun pm bin -g 2^>nul`) do set "BUN_GLOBAL_BIN=%%p" if defined BUN_GLOBAL_BIN ( @@ -83,43 +83,45 @@ if /i "%GLOBAL_TOOL%"=="bun" ( for /f "usebackq delims=" %%s in (`bun --eval "const fs = require('fs'); const file = process.env.BUN_GLOBAL_PACKAGE_JSON; const name = process.env.APP_NAME; if (!file || !fs.existsSync(file)) { console.log('clean'); process.exit(0); } const raw = fs.readFileSync(file, 'utf8'); const parsed = JSON.parse(raw); const deps = { ...(parsed.dependencies || {}) }; const hadEntry = Object.prototype.hasOwnProperty.call(deps, name); if (hadEntry) { delete deps[name]; } const next = { ...parsed }; if (Object.keys(deps).length > 0) { next.dependencies = deps; } else { delete next.dependencies; } const normalized = JSON.stringify(next, null, 2) + '\n'; const normalizedChanged = raw !== normalized; if (normalizedChanged || hadEntry) { fs.writeFileSync(file, normalized); } if (hadEntry) { console.log('removed'); } else if (normalizedChanged) { console.log('normalized'); } else { console.log('clean'); }" 2^>nul`) do set "BUN_CLEANUP_STATUS=%%s" if /i not "!BUN_CLEANUP_STATUS!"=="clean" ( if exist "%BUN_GLOBAL_LOCKFILE%" del /f /q "%BUN_GLOBAL_LOCKFILE%" >nul 2>&1 - echo - Vorhandenen Bun-Globaleintrag fuer %APP_NAME% bereinigt + echo - Cleaned up existing global Bun entry for %APP_NAME% ) ) - echo - Versuche bun add -g file:%cd% + echo - Trying bun add -g file:%cd% call bun add -g file:%cd% >nul 2>&1 if !errorlevel! neq 0 ( - echo ! Globale Bun-Installation fehlgeschlagen, wechsle auf npm-Fallback + echo ! Global Bun install failed, switching to npm fallback echo - Fallback: npm install -g . call npm install -g . >nul 2>&1 if !errorlevel! neq 0 ( - echo x Globale Installation fehlgeschlagen ^(Bun und npm^) + echo x Global install failed ^(Bun and npm^) exit /b 1 ) - echo √ Global via npm installiert + echo √ Installed globally via npm ) else ( - echo √ Global via Bun installiert + echo √ Installed globally via Bun ) ) else ( - echo - Fuehre npm install -g . aus + echo - Running npm install -g . call npm install -g . >nul 2>&1 if !errorlevel! neq 0 ( - echo x Globale Installation fehlgeschlagen (evtl. als Admin ausfuehren) + echo x Global install failed ^(try running as admin^) exit /b 1 ) - echo √ Global installiert + echo √ Installed globally ) echo. -echo Fertig! Starte das Dashboard mit: +echo Done! Start the dashboard with: echo ttdash echo. -echo Naechste Schritte: -echo - Anderen Port verwenden: set PORT=3010 ^&^& ttdash -echo - Browser-Autostart deaktivieren: set NO_OPEN_BROWSER=1 ^&^& ttdash -echo - Datenquelle in der App: Auto-Import oder JSON-Upload -echo - Installierte Version: %APP_VERSION% +echo Next steps: +echo - Start in the background: ttdash --background +echo - Stop a background instance: ttdash stop +echo - Use a different port: set PORT=3010 ^&^& ttdash +echo - Disable browser auto-open: set NO_OPEN_BROWSER=1 ^&^& ttdash +echo - Data source in the app: Auto-import or JSON upload +echo - Installed version: %APP_VERSION% echo. -echo Hinweis: Falls 'ttdash' nicht gefunden wird, oeffne ein neues Terminal -echo oder pruefe deinen globalen npm-/bun-Pfad. +echo Note: If 'ttdash' is not found, open a new terminal +echo or check your global npm/bun path. echo. diff --git a/install.sh b/install.sh index 609840a..678fee4 100755 --- a/install.sh +++ b/install.sh @@ -20,7 +20,7 @@ version="$(sed -n 's/.*"version": "\(.*\)".*/\1/p' "$manifest_file" | head -1)" package_name="$(sed -n 's/.*"name": "\(.*\)".*/\1/p' "$manifest_file" | head -1)" if [ -z "$version" ]; then - version="unbekannt" + version="unknown" fi if [ -z "$package_name" ]; then @@ -87,7 +87,7 @@ if (hadEntry) { console.log("clean"); }' )" || { - warn "Vorhandener Bun-Globaleintrag konnte nicht bereinigt werden" + warn "Could not clean up the existing global Bun entry" return 0 } @@ -95,7 +95,7 @@ if (hadEntry) { return 0 fi - note "Bereinige vorhandenen Bun-Globaleintrag für $package_name" + note "Cleaning up existing global Bun entry for $package_name" if [ -f "$bun_global_lock" ]; then rm -f "$bun_global_lock" fi @@ -105,87 +105,87 @@ cd "$script_dir" printf "${BOLD}ttdash v%s${NC} installer\n" "$version" printf "${DIM}%s${NC}\n" "$(pwd)" -printf "${DIM}Plattform: %s | Shell: %s${NC}\n" "$(uname -s)" "${SHELL:-unbekannt}" +printf "${DIM}Platform: %s | Shell: %s${NC}\n" "$(uname -s)" "${SHELL:-unknown}" if command -v bun >/dev/null 2>&1; then install_tool="bun" global_tool="bun" fi -note "Paketmanager für Installation: $install_tool" -note "Globaler Installer: $global_tool" -note "Build-Ziel: $(pwd)/dist" +note "Package manager for install: $install_tool" +note "Global installer: $global_tool" +note "Build target: $(pwd)/dist" # 1 — Dependencies -info "Installiere Abhängigkeiten..." +info "Installing dependencies..." if [ "$install_tool" = "bun" ]; then - note "Führe bun install aus" + note "Running bun install" if bun install 2>&1 | tail -1; then - ok "bun install abgeschlossen" + ok "bun install completed" else - fail "bun install fehlgeschlagen" + fail "bun install failed" fi else - note "Führe npm install --no-audit --no-fund aus" + note "Running npm install --no-audit --no-fund" if npm install --no-audit --no-fund 2>&1 | tail -1; then - ok "npm install abgeschlossen" + ok "npm install completed" else - fail "npm install fehlgeschlagen" + fail "npm install failed" fi fi # 2 — Build -info "Baue Frontend..." +info "Building frontend..." if [ "$install_tool" = "bun" ]; then - note "Führe bun run build aus" + note "Running bun run build" if bun run build 2>&1 | tail -1; then - ok "Build abgeschlossen (dist/)" + ok "Build completed (dist/)" else - fail "Build fehlgeschlagen" + fail "Build failed" fi else - note "Führe npm run build aus" + note "Running npm run build" if npm run build 2>&1 | tail -1; then - ok "Build abgeschlossen (dist/)" + ok "Build completed (dist/)" else - fail "Build fehlgeschlagen" + fail "Build failed" fi fi # 3 — Global install -info "Installiere global..." +info "Installing globally..." if [ "$global_tool" = "bun" ]; then prepare_bun_global_install - note "Versuche globale Installation mit bun add -g file:$(pwd)" + note "Trying global install with bun add -g file:$(pwd)" if bun add -g "file:$(pwd)" 2>&1 | tail -1; then - ok "Global via Bun installiert" + ok "Installed globally via Bun" else - warn "Globale Bun-Installation fehlgeschlagen, wechsle auf npm-Fallback" + warn "Global Bun install failed, switching to npm fallback" note "Fallback: npm install -g ." if npm install -g . 2>&1 | tail -1; then - ok "Global via npm installiert" + ok "Installed globally via npm" else - fail "Globale Installation fehlgeschlagen (Bun und npm)" + fail "Global install failed (Bun and npm)" fi fi else - note "Führe npm install -g . aus" + note "Running npm install -g ." if npm install -g . 2>&1 | tail -1; then - ok "Global installiert" + ok "Installed globally" else - fail "Globale Installation fehlgeschlagen (evtl. sudo nötig)" + fail "Global install failed (sudo may be required)" fi fi -printf "\n${GREEN}${BOLD}Fertig!${NC} Starte das Dashboard mit:\n" +printf "\n${GREEN}${BOLD}Done!${NC} Start the dashboard with:\n" printf " ${BOLD}ttdash${NC}\n" -printf "\n${BOLD}Nächste Schritte${NC}\n" -printf " ${DIM}• App lokal starten:${NC} ttdash\n" -printf " ${DIM}• Im Hintergrund starten:${NC} ttdash --background\n" -printf " ${DIM}• Hintergrundinstanz beenden:${NC} ttdash stop\n" -printf " ${DIM}• Anderen Port verwenden:${NC} PORT=3010 ttdash\n" -printf " ${DIM}• Browser-Autostart deaktivieren:${NC} NO_OPEN_BROWSER=1 ttdash\n" -printf " ${DIM}• Datenquelle im UI:${NC} Auto-Import oder JSON-Upload\n" -printf " ${DIM}• Installierte Version:${NC} %s\n" "$version" -printf "\n${YELLOW}Hinweis:${NC} Falls 'ttdash' nicht gefunden wird, starte ein neues Terminal\n" -printf "oder prüfe deinen globalen Paketpfad.\n" +printf "\n${BOLD}Next steps${NC}\n" +printf " ${DIM}• Start locally:${NC} ttdash\n" +printf " ${DIM}• Start in the background:${NC} ttdash --background\n" +printf " ${DIM}• Stop a background instance:${NC} ttdash stop\n" +printf " ${DIM}• Use a different port:${NC} PORT=3010 ttdash\n" +printf " ${DIM}• Disable browser auto-open:${NC} NO_OPEN_BROWSER=1 ttdash\n" +printf " ${DIM}• Data source in the UI:${NC} Auto-import or JSON upload\n" +printf " ${DIM}• Installed version:${NC} %s\n" "$version" +printf "\n${YELLOW}Note:${NC} If 'ttdash' is not found, open a new terminal\n" +printf "or check your global package path.\n" diff --git a/server.js b/server.js index b464ef9..3f81be8 100755 --- a/server.js +++ b/server.js @@ -68,25 +68,25 @@ function normalizeCliArgs(args) { function printHelp() { console.log(`TTDash v${APP_VERSION}`); console.log(''); - console.log('Verwendung:'); - console.log(' ttdash [optionen]'); + console.log('Usage:'); + console.log(' ttdash [options]'); console.log(' ttdash stop'); console.log(''); - console.log('Optionen:'); - console.log(' -p, --port Startport festlegen'); - console.log(' -h, --help Diese Hilfe anzeigen'); - console.log(' -no, --no-open Browser-Autostart deaktivieren'); - console.log(' -al, --auto-load Führt direkt beim Start einen Auto-Import aus'); - console.log(' -b, --background Startet TTDash als Hintergrundprozess'); + console.log('Options:'); + console.log(' -p, --port Set the start port'); + console.log(' -h, --help Show this help'); + console.log(' -no, --no-open Disable browser auto-open'); + console.log(' -al, --auto-load Run auto-import immediately on startup'); + console.log(' -b, --background Start TTDash as a background process'); console.log(''); - console.log('Beispiele:'); + console.log('Examples:'); console.log(' ttdash --port 3010'); console.log(' ttdash -p 3010 -no'); console.log(' ttdash --auto-load'); console.log(' ttdash --background'); console.log(' ttdash stop'); console.log(''); - console.log('Umgebungsvariablen:'); + console.log('Environment variables:'); console.log(' PORT=3010 ttdash'); console.log(' NO_OPEN_BROWSER=1 ttdash'); console.log(' HOST=127.0.0.1 ttdash'); @@ -136,7 +136,7 @@ function parseCliArgs(rawArgs) { let command = null; if (parsed.positionals.length > 1) { - console.error(`Unbekannter Aufruf: ${parsed.positionals.join(' ')}`); + console.error(`Unknown invocation: ${parsed.positionals.join(' ')}`); console.log(''); printHelp(); process.exit(1); @@ -144,7 +144,7 @@ function parseCliArgs(rawArgs) { if (parsed.positionals.length === 1) { if (parsed.positionals[0] !== 'stop') { - console.error(`Unbekannter Befehl: ${parsed.positionals[0]}`); + console.error(`Unknown command: ${parsed.positionals[0]}`); console.log(''); printHelp(); process.exit(1); @@ -157,7 +157,7 @@ function parseCliArgs(rawArgs) { if (parsed.values.port !== undefined) { const parsedPort = Number.parseInt(parsed.values.port, 10); if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) { - console.error(`Ungültiger Port: ${parsed.values.port}`); + console.error(`Invalid port: ${parsed.values.port}`); console.log(''); printHelp(); process.exit(1); @@ -387,7 +387,7 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST } if (Date.now() - startedAt >= timeoutMs) { - throw new Error('Konnte Background-Registry nicht sperren.'); + throw new Error('Could not acquire background registry lock.'); } await sleep(50); @@ -494,11 +494,11 @@ function formatBackgroundInstanceLabel(instance, index) { `${index + 1}. ${instance.url}`, `PID ${instance.pid}`, `Port ${instance.port}`, - `gestartet ${formatDateTime(instance.startedAt)}`, + `started ${formatDateTime(instance.startedAt)}`, ]; if (instance.logFile) { - parts.push(`Log ${instance.logFile}`); + parts.push(`log ${instance.logFile}`); } return parts.join(' | '); @@ -509,7 +509,7 @@ async function promptForBackgroundInstance(instances) { return instances[0]; } - console.log('Mehrere TTDash-Background-Server laufen:'); + console.log('Multiple TTDash background servers are running:'); instances.forEach((instance, index) => { console.log(` ${formatBackgroundInstanceLabel(instance, index)}`); }); @@ -522,7 +522,7 @@ async function promptForBackgroundInstance(instances) { try { while (true) { - const answer = (await rl.question(`Welche Instanz soll beendet werden? [1-${instances.length}, Enter=Abbrechen] `)).trim(); + const answer = (await rl.question(`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `)).trim(); if (!answer) { return null; @@ -533,7 +533,7 @@ async function promptForBackgroundInstance(instances) { return instances[selection - 1]; } - console.log(`Ungültige Auswahl: ${answer}`); + console.log(`Invalid selection: ${answer}`); } } finally { rl.close(); @@ -589,36 +589,36 @@ async function runStopCommand() { const instances = await pruneBackgroundInstances(); if (instances.length === 0) { - console.log('Keine laufenden TTDash-Background-Server gefunden.'); + console.log('No running TTDash background servers found.'); return; } const selectedInstance = await promptForBackgroundInstance(instances); if (!selectedInstance) { - console.log('Abgebrochen.'); + console.log('Canceled.'); return; } const result = await stopBackgroundInstance(selectedInstance); if (result.status === 'stopped') { - console.log(`TTDash-Background-Server beendet: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.log(`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`); return; } if (result.status === 'already-stopped') { - console.log(`Instanz war bereits beendet und wurde aus der Registry entfernt: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.log(`Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`); return; } if (result.status === 'forbidden') { - console.error(`TTDash-Background-Server konnte nicht beendet werden (keine Berechtigung): ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.error(`Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`); process.exitCode = 1; return; } - console.error(`TTDash-Background-Server reagiert nicht auf SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + console.error(`TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`); if (selectedInstance.logFile) { - console.error(`Log-Datei: ${selectedInstance.logFile}`); + console.error(`Log file: ${selectedInstance.logFile}`); } process.exitCode = 1; } @@ -657,15 +657,15 @@ async function startInBackground() { const logOutput = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf-8').trim() : ''; - throw new Error(logOutput || `TTDash konnte nicht als Hintergrundprozess gestartet werden. Log: ${logFile}`); + throw new Error(logOutput || `Could not start TTDash as a background process. Log: ${logFile}`); } - console.log('TTDash läuft im Hintergrund.'); + console.log('TTDash is running in the background.'); console.log(` URL: ${instance.url}`); console.log(` PID: ${instance.pid}`); console.log(` Log: ${logFile}`); console.log(''); - console.log('Beenden mit:'); + console.log('Stop it with:'); console.log(' ttdash stop'); } @@ -678,13 +678,13 @@ function migrateLegacyDataFile() { try { fs.renameSync(LEGACY_DATA_FILE, DATA_FILE); - console.log(`Migriere bestehende Daten nach ${DATA_FILE}`); + console.log(`Migrating existing data to ${DATA_FILE}`); } catch { fs.copyFileSync(LEGACY_DATA_FILE, DATA_FILE); try { fs.unlinkSync(LEGACY_DATA_FILE); } catch {} - console.log(`Kopiere bestehende Daten nach ${DATA_FILE}`); + console.log(`Copying existing data to ${DATA_FILE}`); } } @@ -976,57 +976,57 @@ function formatInteger(value) { function describeDataFile() { if (!fs.existsSync(DATA_FILE)) { - return 'keine lokale Datei gefunden'; + return 'no local file found'; } try { const normalized = readData(); if (!normalized) { - return 'vorhanden, aber nicht lesbar'; + return 'present, but unreadable'; } const totalCost = formatCurrency(normalized.totals?.totalCost || 0); const totalTokens = formatInteger(normalized.totals?.totalTokens || 0); const dailyCount = formatInteger(normalized.daily?.length || 0); - return `${dailyCount} Tage, ${totalCost}, ${totalTokens} Tokens`; + return `${dailyCount} days, ${totalCost}, ${totalTokens} tokens`; } catch { - return 'vorhanden, aber nicht lesbar'; + return 'present, but unreadable'; } } function printStartupSummary(url, port) { const browserMode = shouldOpenBrowser() - ? 'aktiviert' - : 'deaktiviert'; + ? 'enabled' + : 'disabled'; const autoLoadMode = CLI_OPTIONS.autoLoad - ? 'aktiviert' - : 'deaktiviert'; + ? 'enabled' + : 'disabled'; const runtimeMode = IS_BACKGROUND_CHILD - ? 'Hintergrund' - : 'Vordergrund'; + ? 'background' + : 'foreground'; console.log(''); - console.log(`${APP_LABEL} v${APP_VERSION} ist bereit`); + console.log(`${APP_LABEL} v${APP_VERSION} is ready`); console.log(` URL: ${url}`); console.log(` API: ${url}/api/usage`); console.log(` Port: ${port}`); console.log(` Host: ${BIND_HOST}`); - console.log(` Modus: ${runtimeMode}`); + console.log(` Mode: ${runtimeMode}`); console.log(` Static Root: ${STATIC_ROOT}`); - console.log(` Daten-Datei: ${DATA_FILE}`); - console.log(` Settings-Datei: ${SETTINGS_FILE}`); + console.log(` Data File: ${DATA_FILE}`); + console.log(` Settings File: ${SETTINGS_FILE}`); if (IS_BACKGROUND_CHILD && process.env.TTDASH_BACKGROUND_LOG_FILE) { - console.log(` Log-Datei: ${process.env.TTDASH_BACKGROUND_LOG_FILE}`); + console.log(` Log File: ${process.env.TTDASH_BACKGROUND_LOG_FILE}`); } - console.log(` Datenstatus: ${describeDataFile()}`); - console.log(` Browser-Start: ${browserMode}`); + console.log(` Data Status: ${describeDataFile()}`); + console.log(` Browser Open: ${browserMode}`); console.log(` Auto-Load: ${autoLoadMode}`); console.log(''); - console.log('Verfügbare Wege für Daten:'); - console.log(' 1. Auto-Import aus der App starten'); - console.log(' 2. toktrack JSON per Upload importieren'); + console.log('Available ways to load data:'); + console.log(' 1. Start auto-import from the app'); + console.log(' 2. Import toktrack JSON via upload'); console.log(''); - console.log('Nützliche Kommandos:'); + console.log('Useful commands:'); console.log(` ttdash --port ${port}`); console.log(` ttdash --port ${port} --no-open`); console.log(' ttdash --background'); @@ -1253,8 +1253,8 @@ async function resolveToktrackRunner() { command: TOKTRACK_LOCAL_BIN, prefixArgs: [], env: process.env, - method: 'lokal', - label: 'lokales toktrack', + method: 'local', + label: 'local toktrack', displayCommand: 'node_modules/.bin/toktrack daily --json', }; } @@ -1319,7 +1319,7 @@ function runToktrack(runner, args, { streamStderr = false, onStderr, signalOnClo resolve(stdout.trimEnd()); return; } - reject(new Error(stderr.trim() || `${runner.label} konnte nicht gestartet werden.`)); + reject(new Error(stderr.trim() || `Could not start ${runner.label}.`)); }); }); } @@ -1332,24 +1332,24 @@ async function performAutoImport({ signalOnClose, } = {}) { if (autoImportRunning) { - throw new Error('Ein Auto-Import läuft bereits. Bitte warten.'); + throw new Error('An auto-import is already running. Please wait.'); } autoImportRunning = true; let progressSeconds = 0; const progressInterval = setInterval(() => { progressSeconds += 5; - onOutput(`Verarbeite Nutzungsdaten... (${progressSeconds}s)`); + onOutput(`Processing usage data... (${progressSeconds}s)`); }, 5000); try { onCheck({ tool: 'toktrack', status: 'checking' }); - onProgress({ message: 'Starte lokalen toktrack-Import...' }); + onProgress({ message: 'Starting local toktrack import...' }); const runner = await resolveToktrackRunner(); if (!runner) { onCheck({ tool: 'toktrack', status: 'not_found' }); - throw new Error('Kein lokales toktrack, Bun oder npm exec gefunden.'); + throw new Error('No local toktrack, Bun, or npm exec installation found.'); } const versionResult = await runToktrack(runner, ['--version']); @@ -1359,7 +1359,7 @@ async function performAutoImport({ method: runner.label, version: String(versionResult).replace(/^toktrack\s+/, ''), }); - onProgress({ message: `Lade Nutzungsdaten via ${runner.displayCommand}...` }); + onProgress({ message: `Loading usage data via ${runner.displayCommand}...` }); const rawJson = await runToktrack(runner, ['daily', '--json'], { streamStderr: true, @@ -1384,14 +1384,14 @@ async function performAutoImport({ } async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { - console.log('Auto-Load aktiviert, starte Import...'); + console.log('Auto-load enabled, starting import...'); try { const result = await performAutoImport({ source, onCheck: (event) => { if (event.status === 'found') { - console.log(`toktrack gefunden (${event.method}, v${event.version})`); + console.log(`toktrack found (${event.method}, v${event.version})`); } }, onProgress: (event) => { @@ -1403,10 +1403,10 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { }); startupAutoLoadCompleted = true; - console.log(`Auto-Load abgeschlossen: ${result.days} Tage importiert, ${formatCurrency(result.totalCost)}.`); + console.log(`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`); } catch (error) { - console.error(`Auto-Load fehlgeschlagen: ${error.message}`); - console.error('Dashboard startet ohne neu importierte Daten.'); + console.error(`Auto-load failed: ${error.message}`); + console.error('Dashboard will start without newly imported data.'); } } @@ -1561,7 +1561,7 @@ const server = http.createServer(async (req, res) => { res.end(); } catch (err) { if (aborted) { return; } - sendSSE(res, 'error', { message: `Fehler: ${err.message}` }); + sendSSE(res, 'error', { message: `Error: ${err.message}` }); sendSSE(res, 'done', {}); res.end(); } @@ -1702,12 +1702,12 @@ runCli().catch((error) => { // Graceful shutdown on Ctrl+C / kill function shutdown(signal) { - console.log(`\n${signal} empfangen, fahre Server herunter...`); + console.log(`\n${signal} received, shutting down server...`); server.close(async () => { if (IS_BACKGROUND_CHILD) { await unregisterBackgroundInstance(process.pid); } - console.log('Server gestoppt.'); + console.log('Server stopped.'); process.exit(0); }); // Force exit after 3s if connections don't close @@ -1715,7 +1715,7 @@ function shutdown(signal) { if (IS_BACKGROUND_CHILD) { await unregisterBackgroundInstance(process.pid); } - console.log('Erzwinge Beendigung.'); + console.log('Forcing shutdown.'); process.exit(0); }, 3000); } diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 687c820..3d8d903 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -156,7 +156,7 @@ async function stopAllBackgroundServers(env: NodeJS.ProcessEnv) { input: '1\n', }) - if (result.output.includes('Keine laufenden TTDash-Background-Server gefunden.')) { + if (result.output.includes('No running TTDash background servers found.')) { return } } @@ -418,7 +418,7 @@ describe('local server API', () => { }) expect(firstStart.code).toBe(0) - expect(firstStart.output).toContain('TTDash läuft im Hintergrund.') + expect(firstStart.output).toContain('TTDash is running in the background.') expect(firstStart.output).toContain(firstUrl) await waitForUrlAvailable(firstUrl) @@ -427,7 +427,7 @@ describe('local server API', () => { }) expect(secondStart.code).toBe(0) - expect(secondStart.output).toContain('TTDash läuft im Hintergrund.') + expect(secondStart.output).toContain('TTDash is running in the background.') expect(secondStart.output).toContain(secondUrl) await waitForUrlAvailable(secondUrl) @@ -437,10 +437,10 @@ describe('local server API', () => { }) expect(stopSecond.code).toBe(0) - expect(stopSecond.output).toContain('Mehrere TTDash-Background-Server laufen:') + expect(stopSecond.output).toContain('Multiple TTDash background servers are running:') expect(stopSecond.output).toContain(firstUrl) expect(stopSecond.output).toContain(secondUrl) - expect(stopSecond.output).toContain(`TTDash-Background-Server beendet: ${secondUrl}`) + expect(stopSecond.output).toContain(`Stopped TTDash background server: ${secondUrl}`) const firstUsageResponse = await fetch(`${firstUrl}/api/usage`) expect(firstUsageResponse.status).toBe(200) @@ -451,7 +451,7 @@ describe('local server API', () => { }) expect(stopFirst.code).toBe(0) - expect(stopFirst.output).toContain(`TTDash-Background-Server beendet: ${firstUrl}`) + expect(stopFirst.output).toContain(`Stopped TTDash background server: ${firstUrl}`) await waitForServerUnavailable(firstUrl) } finally { await stopAllBackgroundServers(backgroundEnv) @@ -479,8 +479,8 @@ describe('local server API', () => { expect(firstStart.code).toBe(0) expect(secondStart.code).toBe(0) - expect(firstStart.output).toContain('TTDash läuft im Hintergrund.') - expect(secondStart.output).toContain('TTDash läuft im Hintergrund.') + expect(firstStart.output).toContain('TTDash is running in the background.') + expect(secondStart.output).toContain('TTDash is running in the background.') await waitForUrlAvailable(firstUrl) await waitForUrlAvailable(secondUrl) From 5ee6a67acffee478d1f0cb04f472ba18e2eef185 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Fri, 10 Apr 2026 23:21:56 +0200 Subject: [PATCH 04/14] v6.0.12: Add dashboard layout preferences --- server.js | 84 +++ src/components/Dashboard.tsx | 496 ++++++++------- .../command-palette/CommandPalette.tsx | 30 +- .../features/settings/SettingsModal.tsx | 564 +++++++++++++++--- src/hooks/use-app-settings.ts | 18 +- src/hooks/use-dashboard-filters.ts | 224 ++++--- src/lib/api.ts | 16 +- src/lib/app-settings.ts | 14 + src/lib/dashboard-preferences.ts | 105 ++++ src/locales/de/common.json | 28 + src/locales/en/common.json | 28 + src/types/index.ts | 28 + tests/e2e/dashboard.spec.ts | 110 +++- tests/frontend/use-dashboard-filters.test.tsx | 68 +++ tests/integration/server.test.ts | 120 +++- 15 files changed, 1547 insertions(+), 386 deletions(-) create mode 100644 src/lib/dashboard-preferences.ts diff --git a/server.js b/server.js index 3f81be8..9259e8e 100755 --- a/server.js +++ b/server.js @@ -41,10 +41,34 @@ const BACKUP_FORMAT_VERSION = 1; const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1'; const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1'; const BACKGROUND_START_TIMEOUT_MS = 15000; +const DASHBOARD_DATE_PRESETS = ['all', '7d', '30d', 'month', 'year']; +const DASHBOARD_SECTION_IDS = [ + 'insights', + 'metrics', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', +]; const DEFAULT_SETTINGS = { language: 'de', theme: 'dark', providerLimits: {}, + defaultFilters: { + viewMode: 'daily', + datePreset: 'all', + providers: [], + models: [], + }, + sectionVisibility: Object.fromEntries(DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true])), + sectionOrder: DASHBOARD_SECTION_IDS, lastLoadedAt: null, lastLoadSource: null, }; @@ -696,6 +720,14 @@ function normalizeTheme(value) { return value === 'light' ? 'light' : 'dark'; } +function normalizeViewMode(value) { + return value === 'monthly' || value === 'yearly' ? value : 'daily'; +} + +function normalizeDashboardDatePreset(value) { + return DASHBOARD_DATE_PRESETS.includes(value) ? value : 'all'; +} + function normalizeLastLoadSource(value) { return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value @@ -908,12 +940,64 @@ function normalizeProviderLimits(value) { return next; } +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value + .filter((entry) => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean))]; +} + +function normalizeDefaultFilters(value) { + const source = value && typeof value === 'object' ? value : {}; + + return { + viewMode: normalizeViewMode(source.viewMode), + datePreset: normalizeDashboardDatePreset(source.datePreset), + providers: normalizeStringList(source.providers), + models: normalizeStringList(source.models), + }; +} + +function normalizeSectionVisibility(value) { + const source = value && typeof value === 'object' ? value : {}; + const next = {}; + + for (const sectionId of DASHBOARD_SECTION_IDS) { + next[sectionId] = typeof source[sectionId] === 'boolean' + ? source[sectionId] + : true; + } + + return next; +} + +function normalizeSectionOrder(value) { + if (!Array.isArray(value)) { + return [...DASHBOARD_SECTION_IDS]; + } + + const incoming = value.filter((sectionId) => ( + typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId) + )); + const uniqueIncoming = [...new Set(incoming)]; + const missing = DASHBOARD_SECTION_IDS.filter((sectionId) => !uniqueIncoming.includes(sectionId)); + + return [...uniqueIncoming, ...missing]; +} + function normalizeSettings(value) { const source = value && typeof value === 'object' ? value : {}; return { language: normalizeLanguage(source.language), theme: normalizeTheme(source.theme), providerLimits: normalizeProviderLimits(source.providerLimits), + defaultFilters: normalizeDefaultFilters(source.defaultFilters), + sectionVisibility: normalizeSectionVisibility(source.sectionVisibility), + sectionOrder: normalizeSectionOrder(source.sectionOrder), lastLoadedAt: normalizeIsoTimestamp(source.lastLoadedAt), lastLoadSource: normalizeLastLoadSource(source.lastLoadSource), }; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index cb9286c..52290f5 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useRef, useState, useCallback, useMemo } from 'react' +import { Fragment, lazy, Suspense, useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useQueryClient } from '@tanstack/react-query' import { SlidersHorizontal } from 'lucide-react' @@ -52,10 +52,10 @@ import { SECTION_HELP } from '@/lib/help-content' import { generatePdfReport, importSettings, importUsageData } from '@/lib/api' import { formatCurrency, formatDateTimeCompact, formatDateTimeFull, formatTokens, formatPercent, periodUnit, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' -import { getUniqueProviders } from '@/lib/model-utils' +import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' import { SettingsModal } from './features/settings/SettingsModal' import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' -import type { AppLanguage } from '@/types' +import type { AppLanguage, DashboardDefaultFilters, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ProviderLimits } from '@/types' const DrillDownModal = lazy(() => import('./features/drill-down/DrillDownModal').then(module => ({ default: module.DrillDownModal }))) const AutoImportModal = lazy(() => import('./features/auto-import/AutoImportModal').then(module => ({ default: module.AutoImportModal }))) @@ -120,12 +120,13 @@ export function Dashboard() { const daily = usageData?.daily ?? [] const hasData = daily.length > 0 const allProviders = useMemo(() => getUniqueProviders(daily.map(d => d.modelsUsed)), [daily]) + const allModelsFromData = useMemo(() => getUniqueModels(daily.map(d => d.modelsUsed)), [daily]) const { settings, providerLimits, setTheme, setLanguage, - setProviderLimits, + saveSettings, isSaving, } = useAppSettings(allProviders) const isDark = settings.theme === 'dark' @@ -198,6 +199,7 @@ export function Dashboard() { startDate, setStartDate, endDate, setEndDate, resetAll, + applyDefaultFilters, applyPreset, filteredDailyData, filteredData, @@ -205,7 +207,7 @@ export function Dashboard() { availableProviders, availableModels, dateRange, - } = useDashboardFilters(daily) + } = useDashboardFilters(daily, settings.defaultFilters) const { metrics, modelCosts, providerMetrics, costChartData, modelCostChartData, @@ -229,6 +231,16 @@ export function Dashboard() { const visibleLimitProviders = useMemo(() => ( selectedProviders.length > 0 ? selectedProviders : allProviders ), [selectedProviders, allProviders]) + const settingsProviderOptions = useMemo( + () => [...new Set([...allProviders, ...settings.defaultFilters.providers])].sort((left, right) => left.localeCompare(right)), + [allProviders, settings.defaultFilters.providers], + ) + const settingsModelOptions = useMemo( + () => [...new Set([...allModelsFromData, ...settings.defaultFilters.models])].sort((left, right) => left.localeCompare(right)), + [allModelsFromData, settings.defaultFilters.models], + ) + const sectionVisibility = settings.sectionVisibility + const sectionOrder = settings.sectionOrder // Compute active streak (consecutive days from today backwards) const streak = useMemo(() => { @@ -259,6 +271,17 @@ export function Dashboard() { void setTheme(isDark ? 'light' : 'dark') }, [isDark, setTheme]) + const handleSaveSettings = useCallback(async (nextSettings: { + providerLimits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder + }) => { + const updatedSettings = await saveSettings(nextSettings) + applyDefaultFilters(updatedSettings.defaultFilters) + addToast(t('toasts.settingsSaved'), 'success') + }, [saveSettings, applyDefaultFilters, addToast, t]) + const handleLanguageChange = useCallback((language: AppLanguage) => { if (settings.language !== language) { void setLanguage(language) @@ -364,6 +387,9 @@ export function Dashboard() { language: settings.language, theme: settings.theme, providerLimits: settings.providerLimits, + defaultFilters: settings.defaultFilters, + sectionVisibility: settings.sectionVisibility, + sectionOrder: settings.sectionOrder, lastLoadedAt: settings.lastLoadedAt, lastLoadSource: settings.lastLoadSource, }, @@ -404,6 +430,7 @@ export function Dashboard() { const parsed = JSON.parse(await file.text()) const imported = await importSettings(parsed) queryClient.setQueryData(['settings'], imported) + applyDefaultFilters(imported.defaultFilters) addToast(t('toasts.settingsImported', { name: file.name }), 'success') } catch (error) { addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') @@ -411,7 +438,7 @@ export function Dashboard() { setSettingsTransferBusy(false) e.target.value = '' } - }, [queryClient, addToast, t]) + }, [queryClient, applyDefaultFilters, addToast, t]) const handleDataImportChange = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -453,6 +480,240 @@ export function Dashboard() { el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, []) + const renderSection = useCallback((sectionId: DashboardSectionId) => { + switch (sectionId) { + case 'insights': + return sectionVisibility.insights ? ( +
+ +
+ ) : null + case 'metrics': + return sectionVisibility.metrics ? ( +
+ + + + + +
+ d.totalCost)} viewMode={viewMode} /> +
+
+
+ ) : null + case 'today': + return sectionVisibility.today && todayData ? ( +
+ +
+ ) : null + case 'currentMonth': + return sectionVisibility.currentMonth && hasCurrentMonthData ? ( +
+ +
+ ) : null + case 'activity': + return sectionVisibility.activity ? ( +
+ + +
+ + + +
+
+
+ ) : null + case 'forecastCache': + return sectionVisibility.forecastCache ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'limits': + return sectionVisibility.limits ? ( +
+ + + +
+ ) : null + case 'costAnalysis': + return sectionVisibility.costAnalysis ? ( +
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ ) : null + case 'tokenAnalysis': + return sectionVisibility.tokenAnalysis ? ( +
+ + +
+ + +
+
+
+ ) : null + case 'requestAnalysis': + return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + case 'advancedAnalysis': + return sectionVisibility.advancedAnalysis ? ( +
+ + +
+ + +
+
+ +
+ +
+
+
+ ) : null + case 'comparisons': + return sectionVisibility.comparisons ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'tables': + return sectionVisibility.tables ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + default: + return null + } + }, [ + allModels, + comparisonData, + costChartData, + filteredDailyData, + filteredData, + hasCurrentMonthData, + metrics, + modelCostChartData, + modelCosts, + modelPieData, + providerLimits, + providerMetrics, + requestChartData, + sectionVisibility, + selectedMonth, + t, + todayData, + tokenChartData, + tokenPieData, + totalCalendarDays, + viewMode, + visibleLimitProviders, + weekdayData, + ]) + if (isLoading) { return } @@ -470,13 +731,18 @@ export function Dashboard() {
-
-
- -
- - {/* Primary Metrics */} -
- - - - - -
- d.totalCost)} viewMode={viewMode} /> -
-
-
- - {/* Today's KPIs */} - {todayData && ( -
- -
- )} - - {/* Current Month KPIs */} - {hasCurrentMonthData && ( -
- -
- )} - - {/* Heatmap Calendar */} -
- - -
- - - -
-
-
- - {/* Cost Forecast + Cache ROI */} -
- - -
- - - - - - -
-
-
- - - - - - {/* Charts */} -
- - -
-
- -
- -
-
- - -
- -
-
- - -
- - -
-
- - -
- - -
-
-
- - {/* Token Analysis */} -
- - -
- - -
-
-
- - {metrics.hasRequestData && ( -
- - - - - -
- -
-
- -
- -
-
-
- )} - -
- - -
- - -
-
- -
- -
-
-
- - {/* Period Comparison + Anomaly Detection */} -
- - -
- - - - - - -
-
-
- - {/* Tables */} -
- - - - - -
- -
-
- -
- -
-
-
+
+ {sectionOrder.map((sectionId) => ( + + {renderSection(sectionId)} + + ))}
{/* Drill-Down Modal */} @@ -770,6 +849,8 @@ export function Dashboard() { selectedModels={selectedModels} hasTodaySection={Boolean(todayData)} hasMonthSection={hasCurrentMonthData} + hasRequestSection={metrics.hasRequestData} + sectionVisibility={sectionVisibility} reportGenerating={reportGenerating} onToggleTheme={handleToggleTheme} onExportCSV={handleExportCSV} @@ -801,13 +882,18 @@ export function Dashboard() { void onExportCSV: () => void @@ -124,6 +126,8 @@ export function CommandPalette({ selectedModels, hasTodaySection, hasMonthSection, + hasRequestSection, + sectionVisibility, reportGenerating, onToggleTheme, onExportCSV, @@ -183,18 +187,18 @@ export function CommandPalette({ { id: 'top', label: t('commandPalette.commands.scrollTop.label'), description: t('commandPalette.commands.scrollTop.description'), keywords: ['top', 'start', 'anfang'], shortcut: '⌘↑', icon: , action: () => window.scrollTo({ top: 0, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, { id: 'bottom', label: t('commandPalette.commands.scrollBottom.label'), description: t('commandPalette.commands.scrollBottom.description'), keywords: ['bottom', 'ende'], icon: , action: () => window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, { id: 'filters', label: t('commandPalette.commands.filters.label'), description: t('commandPalette.commands.filters.description'), keywords: ['filterbar', 'filter'], icon: , action: () => onScrollTo('filters'), group: t('commandPalette.groups.navigation') }, - { id: 'insights', label: t('commandPalette.commands.insights.label'), description: t('commandPalette.commands.insights.description'), keywords: ['summary', 'insight'], icon: , action: () => onScrollTo('insights'), group: t('commandPalette.groups.navigation') }, - { id: 'metrics', label: t('commandPalette.commands.metrics.label'), description: t('commandPalette.commands.metrics.description'), keywords: ['kpi', 'zahlen'], icon: , action: () => onScrollTo('metrics'), group: t('commandPalette.groups.navigation') }, - ...(hasTodaySection ? [{ id: 'today', label: t('commandPalette.commands.today.label'), description: t('commandPalette.commands.today.description'), keywords: ['today', 'heute'], icon: , action: () => onScrollTo('today'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(hasMonthSection ? [{ id: 'month', label: t('commandPalette.commands.month.label'), description: t('commandPalette.commands.month.description'), keywords: ['monat', 'current month'], icon: , action: () => onScrollTo('current-month'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - { id: 'activity', label: t('commandPalette.commands.activity.label'), description: t('commandPalette.commands.activity.description'), keywords: ['heatmap', 'aktivität'], icon: , action: () => onScrollTo('activity'), group: t('commandPalette.groups.navigation') }, - { id: 'forecast-cache', label: t('commandPalette.commands.forecastCache.label'), description: t('commandPalette.commands.forecastCache.description'), keywords: ['forecast', 'cache', 'roi'], icon: , action: () => onScrollTo('forecast-cache'), group: t('commandPalette.groups.navigation') }, - { id: 'limits', label: t('commandPalette.commands.limits.label'), description: t('commandPalette.commands.limits.description'), keywords: ['limits', 'subscriptions', 'budget', 'anbieter limits'], aliases: ['limits sektion', 'subscriptions sektion'], icon: , action: () => onScrollTo('limits'), group: t('commandPalette.groups.navigation') }, - { id: 'charts', label: t('commandPalette.commands.charts.label'), description: t('commandPalette.commands.charts.description'), keywords: ['charts', 'kostenanalyse'], icon: , action: () => onScrollTo('charts'), group: t('commandPalette.groups.navigation') }, - { id: 'token-analysis', label: t('commandPalette.commands.tokenAnalysis.label'), description: t('commandPalette.commands.tokenAnalysis.description'), keywords: ['tokens', 'token analyse'], aliases: ['token chart'], icon: , action: () => onScrollTo('token-analysis'), group: t('commandPalette.groups.navigation') }, - { id: 'request-analysis', label: t('commandPalette.commands.requestAnalysis.label'), description: t('commandPalette.commands.requestAnalysis.description'), keywords: ['requests', 'request analyse', 'anfragen'], aliases: ['request chart', 'request donut'], icon: , action: () => onScrollTo('request-analysis'), group: t('commandPalette.groups.navigation') }, - { id: 'comparisons', label: t('commandPalette.commands.comparisons.label'), description: t('commandPalette.commands.comparisons.description'), keywords: ['anomalie', 'vergleich'], icon: , action: () => onScrollTo('comparisons'), group: t('commandPalette.groups.navigation') }, - { id: 'tables', label: t('commandPalette.commands.tables.label'), description: t('commandPalette.commands.tables.description'), keywords: ['table', 'details'], icon: , action: () => onScrollTo('tables'), group: t('commandPalette.groups.navigation') }, + ...(sectionVisibility.insights ? [{ id: 'insights', label: t('commandPalette.commands.insights.label'), description: t('commandPalette.commands.insights.description'), keywords: ['summary', 'insight'], icon: , action: () => onScrollTo('insights'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.metrics ? [{ id: 'metrics', label: t('commandPalette.commands.metrics.label'), description: t('commandPalette.commands.metrics.description'), keywords: ['kpi', 'zahlen'], icon: , action: () => onScrollTo('metrics'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(hasTodaySection && sectionVisibility.today ? [{ id: 'today', label: t('commandPalette.commands.today.label'), description: t('commandPalette.commands.today.description'), keywords: ['today', 'heute'], icon: , action: () => onScrollTo('today'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(hasMonthSection && sectionVisibility.currentMonth ? [{ id: 'month', label: t('commandPalette.commands.month.label'), description: t('commandPalette.commands.month.description'), keywords: ['monat', 'current month'], icon: , action: () => onScrollTo('current-month'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.activity ? [{ id: 'activity', label: t('commandPalette.commands.activity.label'), description: t('commandPalette.commands.activity.description'), keywords: ['heatmap', 'aktivität'], icon: , action: () => onScrollTo('activity'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.forecastCache ? [{ id: 'forecast-cache', label: t('commandPalette.commands.forecastCache.label'), description: t('commandPalette.commands.forecastCache.description'), keywords: ['forecast', 'cache', 'roi'], icon: , action: () => onScrollTo('forecast-cache'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.limits ? [{ id: 'limits', label: t('commandPalette.commands.limits.label'), description: t('commandPalette.commands.limits.description'), keywords: ['limits', 'subscriptions', 'budget', 'anbieter limits'], aliases: ['limits sektion', 'subscriptions sektion'], icon: , action: () => onScrollTo('limits'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.costAnalysis ? [{ id: 'charts', label: t('commandPalette.commands.charts.label'), description: t('commandPalette.commands.charts.description'), keywords: ['charts', 'kostenanalyse'], icon: , action: () => onScrollTo('charts'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.tokenAnalysis ? [{ id: 'token-analysis', label: t('commandPalette.commands.tokenAnalysis.label'), description: t('commandPalette.commands.tokenAnalysis.description'), keywords: ['tokens', 'token analyse'], aliases: ['token chart'], icon: , action: () => onScrollTo('token-analysis'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(hasRequestSection && sectionVisibility.requestAnalysis ? [{ id: 'request-analysis', label: t('commandPalette.commands.requestAnalysis.label'), description: t('commandPalette.commands.requestAnalysis.description'), keywords: ['requests', 'request analyse', 'anfragen'], aliases: ['request chart', 'request donut'], icon: , action: () => onScrollTo('request-analysis'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.comparisons ? [{ id: 'comparisons', label: t('commandPalette.commands.comparisons.label'), description: t('commandPalette.commands.comparisons.description'), keywords: ['anomalie', 'vergleich'], icon: , action: () => onScrollTo('comparisons'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...(sectionVisibility.tables ? [{ id: 'tables', label: t('commandPalette.commands.tables.label'), description: t('commandPalette.commands.tables.description'), keywords: ['table', 'details'], icon:
, action: () => onScrollTo('tables'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), { id: 'theme', label: isDark ? t('commandPalette.commands.themeLight.label') : t('commandPalette.commands.themeDark.label'), description: t('commandPalette.commands.themeDark.description'), keywords: ['theme', 'dark', 'light'], shortcut: '⌘D', icon: isDark ? : , action: onToggleTheme, group: t('commandPalette.groups.view') }, { id: 'language-de', label: t('commandPalette.commands.languageGerman.label'), description: t('commandPalette.commands.languageGerman.description'), keywords: ['language', 'sprache', 'deutsch', 'german', 'locale'], aliases: ['switch german', 'auf deutsch', 'sprache deutsch'], icon: , action: () => onLanguageChange('de'), group: t('commandPalette.groups.language') }, diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx index 1a8203c..f18831e 100644 --- a/src/components/features/settings/SettingsModal.tsx +++ b/src/components/features/settings/SettingsModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -7,20 +7,45 @@ import { FEATURE_HELP } from '@/lib/help-content' import { formatDateTimeFull } from '@/lib/formatters' import { getProviderBadgeClasses } from '@/lib/model-utils' import { syncProviderLimits } from '@/lib/provider-limits' +import { + DASHBOARD_SECTION_DEFINITION_MAP, + DASHBOARD_DATE_PRESETS, + DASHBOARD_VIEW_MODES, + DEFAULT_DASHBOARD_FILTERS, + getDefaultDashboardSectionOrder, + getDefaultDashboardSectionVisibility, +} from '@/lib/dashboard-preferences' import { cn } from '@/lib/cn' -import { Database, Download, Settings2, Upload } from 'lucide-react' -import type { DataLoadSource, ProviderLimits } from '@/types' +import { ArrowDown, ArrowUp, Database, Download, Eye, Filter, GripVertical, LayoutPanelTop, Settings2, Upload } from 'lucide-react' +import type { + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + DataLoadSource, + ProviderLimits, + ViewMode, +} from '@/types' interface SettingsModalProps { open: boolean onOpenChange: (open: boolean) => void - providers: string[] + limitProviders: string[] + filterProviders: string[] + models: string[] limits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder lastLoadedAt?: string | null lastLoadSource?: DataLoadSource cliAutoLoadActive?: boolean hasData: boolean - onSaveLimits: (limits: ProviderLimits) => void + onSaveSettings: (settings: { + providerLimits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder + }) => Promise | unknown onExportSettings: () => void onImportSettings: () => void onExportData: () => void @@ -37,16 +62,61 @@ function parseNumberInput(value: string): number { return Math.max(0, Number(parsed.toFixed(2))) } +function toggleSelection(values: string[], value: string) { + return values.includes(value) + ? values.filter(entry => entry !== value) + : [...values, value] +} + +function normalizeSelection(values: string[]) { + return [...new Set(values.map(value => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)) +} + +function moveSection(order: DashboardSectionOrder, sectionId: DashboardSectionOrder[number], direction: -1 | 1) { + const currentIndex = order.indexOf(sectionId) + const targetIndex = currentIndex + direction + + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= order.length) { + return order + } + + const next = [...order] + const [moved] = next.splice(currentIndex, 1) + next.splice(targetIndex, 0, moved) + return next +} + +function reorderSections(order: DashboardSectionOrder, sourceId: DashboardSectionOrder[number], targetId: DashboardSectionOrder[number]) { + if (sourceId === targetId) return order + + const sourceIndex = order.indexOf(sourceId) + const targetIndex = order.indexOf(targetId) + + if (sourceIndex < 0 || targetIndex < 0) { + return order + } + + const next = [...order] + const [moved] = next.splice(sourceIndex, 1) + next.splice(targetIndex, 0, moved) + return next +} + export function SettingsModal({ open, onOpenChange, - providers, + limitProviders, + filterProviders, + models, limits, + defaultFilters, + sectionVisibility, + sectionOrder, lastLoadedAt, lastLoadSource, cliAutoLoadActive = false, hasData, - onSaveLimits, + onSaveSettings, onExportSettings, onImportSettings, onExportData, @@ -55,15 +125,35 @@ export function SettingsModal({ dataBusy = false, }: SettingsModalProps) { const { t } = useTranslation() - const [draft, setDraft] = useState(() => syncProviderLimits(providers, limits)) + const [limitDraft, setLimitDraft] = useState(() => syncProviderLimits(limitProviders, limits)) + const [defaultFilterDraft, setDefaultFilterDraft] = useState(defaultFilters) + const [sectionVisibilityDraft, setSectionVisibilityDraft] = useState(sectionVisibility) + const [sectionOrderDraft, setSectionOrderDraft] = useState(sectionOrder) + const [draggedSectionId, setDraggedSectionId] = useState(null) + const [dragOverSectionId, setDragOverSectionId] = useState(null) useEffect(() => { if (!open) return - setDraft(syncProviderLimits(providers, limits)) - }, [open, providers, limits]) + + setLimitDraft(syncProviderLimits(limitProviders, limits)) + setDefaultFilterDraft(defaultFilters) + setSectionVisibilityDraft(sectionVisibility) + setSectionOrderDraft(sectionOrder) + setDraggedSectionId(null) + setDragOverSectionId(null) + }, [open, limitProviders, limits, defaultFilters, sectionVisibility, sectionOrder]) + + const providerOptions = useMemo( + () => normalizeSelection([...filterProviders, ...defaultFilterDraft.providers]), + [filterProviders, defaultFilterDraft.providers], + ) + const modelOptions = useMemo( + () => normalizeSelection([...models, ...defaultFilterDraft.models]), + [models, defaultFilterDraft.models], + ) const updateProvider = (provider: string, patch: Partial) => { - setDraft(prev => ({ + setLimitDraft(prev => ({ ...prev, [provider]: { ...prev[provider], @@ -72,18 +162,55 @@ export function SettingsModal({ })) } - const handleSave = () => { - onSaveLimits(syncProviderLimits(providers, draft)) + const handleSave = async () => { + const nextProviderLimits = { ...limits } + for (const provider of limitProviders) { + nextProviderLimits[provider] = limitDraft[provider] + } + + await onSaveSettings({ + providerLimits: nextProviderLimits, + defaultFilters: { + ...defaultFilterDraft, + providers: normalizeSelection(defaultFilterDraft.providers), + models: normalizeSelection(defaultFilterDraft.models), + }, + sectionVisibility: sectionVisibilityDraft, + sectionOrder: sectionOrderDraft, + }) onOpenChange(false) } + const handleResetDrafts = () => { + setLimitDraft(syncProviderLimits(limitProviders, {})) + setDefaultFilterDraft(DEFAULT_DASHBOARD_FILTERS) + setSectionVisibilityDraft(getDefaultDashboardSectionVisibility()) + } + + const handleResetDefaultFilters = () => { + setDefaultFilterDraft(DEFAULT_DASHBOARD_FILTERS) + } + + const handleResetSectionVisibility = () => { + setSectionVisibilityDraft(getDefaultDashboardSectionVisibility()) + setSectionOrderDraft(getDefaultDashboardSectionOrder()) + } + + const handleResetProviderLimits = () => { + setLimitDraft(syncProviderLimits(limitProviders, {})) + } + const loadSourceLabel = lastLoadSource ? t(`settings.modal.sources.${lastLoadSource}`) : t('settings.modal.sources.unknown') + const orderedSections = useMemo( + () => sectionOrderDraft.map((sectionId) => DASHBOARD_SECTION_DEFINITION_MAP[sectionId]), + [sectionOrderDraft], + ) return ( - + {t('settings.modal.title')} @@ -118,7 +245,260 @@ export function SettingsModal({ -
+
+
+
+
+ + + +
+
{t('settings.modal.defaultFiltersTitle')}
+

{t('settings.modal.defaultFiltersDescription')}

+
+
+ +
+ +
+
+
{t('settings.modal.defaultViewMode')}
+
+ {DASHBOARD_VIEW_MODES.map((mode) => ( + + ))} +
+
+ +
+
{t('settings.modal.defaultDateRange')}
+
+ {DASHBOARD_DATE_PRESETS.map((preset) => ( + + ))} +
+
+ +
+
{t('settings.modal.filterProviders')}
+ {providerOptions.length === 0 ? ( +
+ {t('settings.modal.noProviders')} +
+ ) : ( +
+ {providerOptions.map((provider) => { + const selected = defaultFilterDraft.providers.includes(provider) + return ( + + ) + })} +
+ )} +
+ +
+
{t('settings.modal.filterModels')}
+ {modelOptions.length === 0 ? ( +
+ {t('settings.modal.noModels')} +
+ ) : ( +
+ {modelOptions.map((model) => { + const selected = defaultFilterDraft.models.includes(model) + return ( + + ) + })} +
+ )} +
+
+
+ +
+
+
+ + + +
+
{t('settings.modal.sectionVisibilityTitle')}
+

{t('settings.modal.sectionVisibilityDescription')}

+
+
+ +
+ +
+ {t('settings.modal.sectionOrderHint')} +
+
+ {orderedSections.map((section, index) => { + const visible = sectionVisibilityDraft[section.id] + return ( +
{ + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', section.id) + setDraggedSectionId(section.id) + setDragOverSectionId(section.id) + }} + onDragOver={(event) => { + event.preventDefault() + if (dragOverSectionId !== section.id) { + setDragOverSectionId(section.id) + } + }} + onDragLeave={() => { + if (dragOverSectionId === section.id) { + setDragOverSectionId(null) + } + }} + onDrop={(event) => { + event.preventDefault() + const sourceId = event.dataTransfer.getData('text/plain') as DashboardSectionOrder[number] || draggedSectionId + if (!sourceId) return + setSectionOrderDraft((prev) => reorderSections(prev, sourceId, section.id)) + setDraggedSectionId(null) + setDragOverSectionId(null) + }} + onDragEnd={() => { + setDraggedSectionId(null) + setDragOverSectionId(null) + }} + className={cn( + 'flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors', + dragOverSectionId === section.id + ? 'border-primary/40 bg-primary/10' + : 'border-border/70 bg-muted/10', + draggedSectionId === section.id && 'opacity-70', + )} + > + + + +
+
{t(section.labelKey)}
+
+ {t('settings.modal.positionLabel', { position: index + 1, total: orderedSections.length })} +
+
+
+ + + +
+
+ ) + })} +
+
+
+ +
@@ -170,92 +550,108 @@ export function SettingsModal({
-
-
-
- {t('settings.modal.providerLimitsTitle')} +
+
+
+ + + +
+
{t('settings.modal.providerLimitsTitle')}
+

{t('settings.modal.providerLimitsDescription')}

+
-

- {t('settings.modal.providerLimitsDescription')} -

+
- {providers.length === 0 ? ( -
- {t('settings.modal.noProviders')} -
- ) : ( -
- {providers.map((provider) => { - const config = draft[provider] +
+ {limitProviders.length === 0 ? ( +
+ {t('settings.modal.noProviders')} +
+ ) : ( +
+ {limitProviders.map((provider) => { + const config = limitDraft[provider] - return ( -
-
-
-
- - {provider} - - + return ( +
+
+
+
+ + {provider} + + +
-
-
- - - +
+ + + +
-
- ) - })} -
- )} + ) + })} +
+ )} +
- - + +
diff --git a/src/hooks/use-app-settings.ts b/src/hooks/use-app-settings.ts index fe4313e..11487c2 100644 --- a/src/hooks/use-app-settings.ts +++ b/src/hooks/use-app-settings.ts @@ -1,6 +1,14 @@ import { useCallback, useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { AppLanguage, AppSettings, AppTheme, ProviderLimits } from '@/types' +import type { + AppLanguage, + AppSettings, + AppTheme, + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + ProviderLimits, +} from '@/types' import { fetchSettings, updateSettings, type UpdateSettingsRequest } from '@/lib/api' import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from '@/lib/app-settings' import { syncProviderLimits } from '@/lib/provider-limits' @@ -49,6 +57,10 @@ export function useAppSettings(availableProviders: string[]) { const setTheme = useCallback((theme: AppTheme) => mutation.mutateAsync({ theme }), [mutation]) const setLanguage = useCallback((language: AppLanguage) => mutation.mutateAsync({ language }), [mutation]) const setProviderLimits = useCallback((limits: ProviderLimits) => mutation.mutateAsync({ providerLimits: limits }), [mutation]) + const setDefaultFilters = useCallback((defaultFilters: DashboardDefaultFilters) => mutation.mutateAsync({ defaultFilters }), [mutation]) + const setSectionVisibility = useCallback((sectionVisibility: DashboardSectionVisibility) => mutation.mutateAsync({ sectionVisibility }), [mutation]) + const setSectionOrder = useCallback((sectionOrder: DashboardSectionOrder) => mutation.mutateAsync({ sectionOrder }), [mutation]) + const saveSettings = useCallback((patch: UpdateSettingsRequest) => mutation.mutateAsync(patch), [mutation]) return { settings, @@ -56,6 +68,10 @@ export function useAppSettings(availableProviders: string[]) { setTheme, setLanguage, setProviderLimits, + setDefaultFilters, + setSectionVisibility, + setSectionOrder, + saveSettings, isLoading: query.isLoading, isSaving: mutation.isPending, } diff --git a/src/hooks/use-dashboard-filters.ts b/src/hooks/use-dashboard-filters.ts index 332f293..9d9773d 100644 --- a/src/hooks/use-dashboard-filters.ts +++ b/src/hooks/use-dashboard-filters.ts @@ -1,111 +1,184 @@ -import { useState, useCallback, useMemo } from 'react' -import type { DailyUsage, ViewMode } from '@/types' +import { useState, useCallback, useMemo, useEffect, useRef } from 'react' +import type { DailyUsage, DashboardDefaultFilters, DashboardDatePreset, ViewMode } from '@/types' +import { DEFAULT_DASHBOARD_FILTERS } from '@/lib/dashboard-preferences' import { filterByDateRange, filterByModels, filterByMonth, sortByDate, getAvailableMonths, getDateRange, aggregateToDailyFormat, filterByProviders } from '@/lib/data-transforms' import { toLocalDateStr } from '@/lib/formatters' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' -export function useDashboardFilters(data: DailyUsage[]) { - const [viewMode, setViewMode] = useState('daily') - const [selectedMonth, setSelectedMonth] = useState(null) - const [selectedProviders, setSelectedProviders] = useState([]) - const [selectedModels, setSelectedModels] = useState([]) - const [startDate, setStartDate] = useState(undefined) - const [endDate, setEndDate] = useState(undefined) +function resolvePresetRange(preset: DashboardDatePreset) { + const today = new Date() + today.setHours(0, 0, 0, 0) + const fmt = toLocalDateStr + + switch (preset) { + case '7d': { + const start = new Date(today) + start.setDate(today.getDate() - 6) + return { startDate: fmt(start), endDate: fmt(today) } + } + case '30d': { + const start = new Date(today) + start.setDate(today.getDate() - 29) + return { startDate: fmt(start), endDate: fmt(today) } + } + case 'month': { + const start = new Date(today.getFullYear(), today.getMonth(), 1) + return { startDate: fmt(start), endDate: fmt(today) } + } + case 'year': { + const start = new Date(today.getFullYear(), 0, 1) + return { startDate: fmt(start), endDate: fmt(today) } + } + case 'all': + default: + return { startDate: undefined, endDate: undefined } + } +} + +function sanitizeDefaultFilters(data: DailyUsage[], defaultFilters: DashboardDefaultFilters) { + const providers = new Set(getUniqueProviders(data.map(entry => entry.modelsUsed))) + const models = new Set(getUniqueModels(data.map(entry => entry.modelsUsed))) + + return { + viewMode: defaultFilters.viewMode, + datePreset: defaultFilters.datePreset, + providers: defaultFilters.providers.filter(provider => providers.has(provider)), + models: defaultFilters.models.filter(model => models.has(model)), + } +} + +export function useDashboardFilters(data: DailyUsage[], defaultFilters: DashboardDefaultFilters = DEFAULT_DASHBOARD_FILTERS) { + const resolvedDefaults = useMemo( + () => sanitizeDefaultFilters(data, defaultFilters), + [data, defaultFilters], + ) + const defaultRange = useMemo( + () => resolvePresetRange(resolvedDefaults.datePreset), + [resolvedDefaults.datePreset], + ) + const defaultFiltersKey = useMemo( + () => JSON.stringify(resolvedDefaults), + [resolvedDefaults], + ) + + const [viewModeState, setViewModeState] = useState(resolvedDefaults.viewMode) + const [selectedMonthState, setSelectedMonthState] = useState(null) + const [selectedProvidersState, setSelectedProvidersState] = useState(resolvedDefaults.providers) + const [selectedModelsState, setSelectedModelsState] = useState(resolvedDefaults.models) + const [startDateState, setStartDateState] = useState(defaultRange.startDate) + const [endDateState, setEndDateState] = useState(defaultRange.endDate) + const userModifiedRef = useRef(false) + const appliedDefaultsKeyRef = useRef(defaultFiltersKey) + + const applyDefaultFilters = useCallback((nextDefaultFilters: DashboardDefaultFilters = defaultFilters) => { + const sanitizedDefaults = sanitizeDefaultFilters(data, nextDefaultFilters) + const nextRange = resolvePresetRange(sanitizedDefaults.datePreset) + userModifiedRef.current = false + appliedDefaultsKeyRef.current = JSON.stringify(sanitizedDefaults) + setViewModeState(sanitizedDefaults.viewMode) + setSelectedMonthState(null) + setSelectedProvidersState(sanitizedDefaults.providers) + setSelectedModelsState(sanitizedDefaults.models) + setStartDateState(nextRange.startDate) + setEndDateState(nextRange.endDate) + }, [data, defaultFilters]) + + useEffect(() => { + if (appliedDefaultsKeyRef.current === defaultFiltersKey || userModifiedRef.current) { + return + } + + appliedDefaultsKeyRef.current = defaultFiltersKey + setViewModeState(resolvedDefaults.viewMode) + setSelectedMonthState(null) + setSelectedProvidersState(resolvedDefaults.providers) + setSelectedModelsState(resolvedDefaults.models) + setStartDateState(defaultRange.startDate) + setEndDateState(defaultRange.endDate) + }, [defaultFiltersKey, resolvedDefaults, defaultRange]) + + const setViewMode = useCallback((mode: ViewMode) => { + userModifiedRef.current = true + setViewModeState(mode) + }, []) + + const setSelectedMonth = useCallback((month: string | null) => { + userModifiedRef.current = true + setSelectedMonthState(month) + }, []) + + const setStartDate = useCallback((date: string | undefined) => { + userModifiedRef.current = true + setStartDateState(date) + }, []) + + const setEndDate = useCallback((date: string | undefined) => { + userModifiedRef.current = true + setEndDateState(date) + }, []) const toggleProvider = useCallback((provider: string) => { - setSelectedProviders(prev => + userModifiedRef.current = true + setSelectedProvidersState(prev => prev.includes(provider) ? prev.filter(p => p !== provider) : [...prev, provider] ) - setSelectedModels([]) + setSelectedModelsState([]) }, []) const clearProviders = useCallback(() => { - setSelectedProviders([]) - setSelectedModels([]) + userModifiedRef.current = true + setSelectedProvidersState([]) + setSelectedModelsState([]) }, []) const toggleModel = useCallback((model: string) => { - setSelectedModels(prev => + userModifiedRef.current = true + setSelectedModelsState(prev => prev.includes(model) ? prev.filter(m => m !== model) : [...prev, model] ) }, []) - const clearModels = useCallback(() => setSelectedModels([]), []) + const clearModels = useCallback(() => { + userModifiedRef.current = true + setSelectedModelsState([]) + }, []) const resetAll = useCallback(() => { - setViewMode('daily') - setSelectedMonth(null) - setSelectedProviders([]) - setSelectedModels([]) - setStartDate(undefined) - setEndDate(undefined) - }, []) + applyDefaultFilters() + }, [applyDefaultFilters]) const applyPreset = useCallback((preset: string) => { - setSelectedMonth(null) - const today = new Date() - today.setHours(0, 0, 0, 0) - const fmt = toLocalDateStr - - switch (preset) { - case '7d': { - const start = new Date(today) - start.setDate(today.getDate() - 6) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case '30d': { - const start = new Date(today) - start.setDate(today.getDate() - 29) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case 'month': { - const start = new Date(today.getFullYear(), today.getMonth(), 1) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case 'year': { - const start = new Date(today.getFullYear(), 0, 1) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case 'all': - default: - setStartDate(undefined) - setEndDate(undefined) - break - } + userModifiedRef.current = true + setSelectedMonthState(null) + const nextRange = resolvePresetRange(preset as DashboardDatePreset) + setStartDateState(nextRange.startDate) + setEndDateState(nextRange.endDate) }, []) const preProviderFilteredData = useMemo(() => { let result = sortByDate(data) - result = filterByDateRange(result, startDate, endDate) - result = filterByMonth(result, selectedMonth) + result = filterByDateRange(result, startDateState, endDateState) + result = filterByMonth(result, selectedMonthState) return result - }, [data, startDate, endDate, selectedMonth]) + }, [data, startDateState, endDateState, selectedMonthState]) const preModelFilteredData = useMemo(() => { let result = preProviderFilteredData - result = filterByProviders(result, selectedProviders) + result = filterByProviders(result, selectedProvidersState) return result - }, [preProviderFilteredData, selectedProviders]) + }, [preProviderFilteredData, selectedProvidersState]) const filteredDailyData = useMemo(() => { let result = preModelFilteredData - result = filterByModels(result, selectedModels) + result = filterByModels(result, selectedModelsState) return result - }, [preModelFilteredData, selectedModels]) + }, [preModelFilteredData, selectedModelsState]) const filteredData = useMemo(() => { let result = filteredDailyData - result = aggregateToDailyFormat(result, viewMode) + result = aggregateToDailyFormat(result, viewModeState) return result - }, [filteredDailyData, viewMode]) + }, [filteredDailyData, viewModeState]) const availableMonths = useMemo(() => getAvailableMonths(data), [data]) const availableProviders = useMemo(() => getUniqueProviders(preProviderFilteredData.map(d => d.modelsUsed)), [preProviderFilteredData]) @@ -113,13 +186,14 @@ export function useDashboardFilters(data: DailyUsage[]) { const dateRange = useMemo(() => getDateRange(filteredDailyData), [filteredDailyData]) return { - viewMode, setViewMode, - selectedMonth, setSelectedMonth, - selectedProviders, toggleProvider, clearProviders, - selectedModels, toggleModel, clearModels, - startDate, setStartDate, - endDate, setEndDate, + viewMode: viewModeState, setViewMode, + selectedMonth: selectedMonthState, setSelectedMonth, + selectedProviders: selectedProvidersState, toggleProvider, clearProviders, + selectedModels: selectedModelsState, toggleModel, clearModels, + startDate: startDateState, setStartDate, + endDate: endDateState, setEndDate, resetAll, + applyDefaultFilters, applyPreset, filteredDailyData, filteredData, diff --git a/src/lib/api.ts b/src/lib/api.ts index abdab05..ddd6ff4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,15 @@ -import type { AppSettings, AppLanguage, AppTheme, ProviderLimits, UsageData, UsageImportSummary, ViewMode } from '@/types' +import type { + AppSettings, + AppLanguage, + AppTheme, + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + ProviderLimits, + UsageData, + UsageImportSummary, + ViewMode, +} from '@/types' import i18n from '@/lib/i18n' import { normalizeAppSettings } from '@/lib/app-settings' @@ -43,6 +54,9 @@ export interface UpdateSettingsRequest { language?: AppLanguage theme?: AppTheme providerLimits?: ProviderLimits + defaultFilters?: DashboardDefaultFilters + sectionVisibility?: DashboardSectionVisibility + sectionOrder?: DashboardSectionOrder } export async function fetchSettings(): Promise { diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 61eca49..6899179 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -1,10 +1,21 @@ import type { AppLanguage, AppSettings, AppTheme, DataLoadSource, ProviderLimits } from '@/types' +import { + DEFAULT_DASHBOARD_FILTERS, + getDefaultDashboardSectionOrder, + getDefaultDashboardSectionVisibility, + normalizeDashboardDefaultFilters, + normalizeDashboardSectionOrder, + normalizeDashboardSectionVisibility, +} from '@/lib/dashboard-preferences' import { normalizeProviderLimitConfig } from '@/lib/provider-limits' export const DEFAULT_APP_SETTINGS: AppSettings = { language: 'de', theme: 'dark', providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + sectionVisibility: getDefaultDashboardSectionVisibility(), + sectionOrder: getDefaultDashboardSectionOrder(), lastLoadedAt: null, lastLoadSource: null, cliAutoLoadActive: false, @@ -51,6 +62,9 @@ export function normalizeAppSettings(value: unknown): AppSettings { language: normalizeAppLanguage(source.language), theme: normalizeAppTheme(source.theme), providerLimits: normalizeStoredProviderLimits(source.providerLimits), + defaultFilters: normalizeDashboardDefaultFilters(source.defaultFilters), + sectionVisibility: normalizeDashboardSectionVisibility(source.sectionVisibility), + sectionOrder: normalizeDashboardSectionOrder(source.sectionOrder), lastLoadedAt: normalizeStoredTimestamp(source.lastLoadedAt), lastLoadSource: normalizeDataLoadSource(source.lastLoadSource), cliAutoLoadActive: Boolean(source.cliAutoLoadActive), diff --git a/src/lib/dashboard-preferences.ts b/src/lib/dashboard-preferences.ts new file mode 100644 index 0000000..0652f55 --- /dev/null +++ b/src/lib/dashboard-preferences.ts @@ -0,0 +1,105 @@ +import type { + DashboardDatePreset, + DashboardDefaultFilters, + DashboardSectionId, + DashboardSectionOrder, + DashboardSectionVisibility, + ViewMode, +} from '@/types' + +export const DASHBOARD_DATE_PRESETS: DashboardDatePreset[] = ['all', '7d', '30d', 'month', 'year'] +export const DASHBOARD_VIEW_MODES: ViewMode[] = ['daily', 'monthly', 'yearly'] +export const DASHBOARD_SECTION_DEFINITIONS: Array<{ id: DashboardSectionId; domId: string; labelKey: string }> = [ + { id: 'insights', domId: 'insights', labelKey: 'helpPanel.sectionLabels.insights' }, + { id: 'metrics', domId: 'metrics', labelKey: 'helpPanel.sectionLabels.metrics' }, + { id: 'today', domId: 'today', labelKey: 'helpPanel.sectionLabels.today' }, + { id: 'currentMonth', domId: 'current-month', labelKey: 'helpPanel.sectionLabels.currentMonth' }, + { id: 'activity', domId: 'activity', labelKey: 'helpPanel.sectionLabels.activity' }, + { id: 'forecastCache', domId: 'forecast-cache', labelKey: 'helpPanel.sectionLabels.forecastCache' }, + { id: 'limits', domId: 'limits', labelKey: 'helpPanel.sectionLabels.limits' }, + { id: 'costAnalysis', domId: 'charts', labelKey: 'helpPanel.sectionLabels.costAnalysis' }, + { id: 'tokenAnalysis', domId: 'token-analysis', labelKey: 'helpPanel.sectionLabels.tokenAnalysis' }, + { id: 'requestAnalysis', domId: 'request-analysis', labelKey: 'helpPanel.sectionLabels.requestAnalysis' }, + { id: 'advancedAnalysis', domId: 'advanced-analysis', labelKey: 'helpPanel.sectionLabels.advancedAnalysis' }, + { id: 'comparisons', domId: 'comparisons', labelKey: 'helpPanel.sectionLabels.comparisons' }, + { id: 'tables', domId: 'tables', labelKey: 'helpPanel.sectionLabels.tables' }, +] +export const DASHBOARD_SECTION_DEFINITION_MAP = Object.fromEntries( + DASHBOARD_SECTION_DEFINITIONS.map((section) => [section.id, section]), +) as Record + +export const DEFAULT_DASHBOARD_FILTERS: DashboardDefaultFilters = { + viewMode: 'daily', + datePreset: 'all', + providers: [], + models: [], +} + +export function getDefaultDashboardSectionVisibility(): DashboardSectionVisibility { + return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ + ...visibility, + [section.id]: true, + }), {} as DashboardSectionVisibility) +} + +export function getDefaultDashboardSectionOrder(): DashboardSectionOrder { + return DASHBOARD_SECTION_DEFINITIONS.map((section) => section.id) +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + return [...new Set(value + .filter((entry): entry is string => typeof entry === 'string') + .map(entry => entry.trim()) + .filter(Boolean))] +} + +export function normalizeDashboardDatePreset(value: unknown): DashboardDatePreset { + return DASHBOARD_DATE_PRESETS.includes(value as DashboardDatePreset) + ? value as DashboardDatePreset + : 'all' +} + +export function normalizeDashboardViewMode(value: unknown): ViewMode { + return DASHBOARD_VIEW_MODES.includes(value as ViewMode) + ? value as ViewMode + : 'daily' +} + +export function normalizeDashboardDefaultFilters(value: unknown): DashboardDefaultFilters { + const source = value && typeof value === 'object' ? value as Partial : {} + + return { + viewMode: normalizeDashboardViewMode(source.viewMode), + datePreset: normalizeDashboardDatePreset(source.datePreset), + providers: normalizeStringList(source.providers), + models: normalizeStringList(source.models), + } +} + +export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility { + const source = value && typeof value === 'object' ? value as Partial : {} + const defaults = getDefaultDashboardSectionVisibility() + + return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ + ...visibility, + [section.id]: typeof source[section.id] === 'boolean' ? Boolean(source[section.id]) : defaults[section.id], + }), {} as DashboardSectionVisibility) +} + +export function normalizeDashboardSectionOrder(value: unknown): DashboardSectionOrder { + const defaults = getDefaultDashboardSectionOrder() + + if (!Array.isArray(value)) { + return defaults + } + + const incoming = value.filter((sectionId): sectionId is DashboardSectionId => ( + typeof sectionId === 'string' && defaults.includes(sectionId as DashboardSectionId) + )) + const uniqueIncoming = [...new Set(incoming)] + const missing = defaults.filter((sectionId) => !uniqueIncoming.includes(sectionId)) + + return [...uniqueIncoming, ...missing] +} diff --git a/src/locales/de/common.json b/src/locales/de/common.json index cdf6583..3b01ba0 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -68,6 +68,8 @@ "no": "Nein", "enabled": "Aktiv", "disabled": "Inaktiv", + "visible": "Sichtbar", + "hidden": "Versteckt", "open": "Öffnen", "close": "Schliessen", "startDate": "Startdatum", @@ -841,6 +843,19 @@ "lastLoaded": "Zuletzt geladen", "loadedVia": "Geladen über", "cliAutoLoad": "CLI Auto-Load", + "defaultFiltersTitle": "Standardfilter fürs Dashboard", + "defaultFiltersDescription": "Lege den Filterzustand fest, der beim Öffnen des Dashboards und beim Zurücksetzen verwendet wird.", + "defaultViewMode": "Standardansicht", + "defaultDateRange": "Standardzeitraum", + "filterProviders": "Standard-Anbieterfilter", + "filterModels": "Standard-Modellfilter", + "noModels": "Keine Modelle im geladenen Report gefunden.", + "sectionVisibilityTitle": "Sichtbare Dashboard-Sektionen", + "sectionVisibilityDescription": "Steuere, welche Sektionen im Dashboard gerendert werden und in welcher Reihenfolge sie erscheinen.", + "sectionOrderHint": "Ziehe die Sektionen per Drag and Drop in die gewünschte Reihenfolge. Die aktuelle Reihenfolge ist das Standardlayout.", + "positionLabel": "Position {{position}} von {{total}}", + "moveSectionUp": "{{section}} nach oben verschieben", + "moveSectionDown": "{{section}} nach unten verschieben", "settingsBackupTitle": "Einstellungen sichern", "settingsBackupDescription": "Exportiert und importiert Sprache, Theme, Limits und die gespeicherten Lade-Metadaten als versioniertes Backup.", "dataBackupTitle": "Daten sichern", @@ -856,6 +871,18 @@ "importData": "Daten importieren", "close": "Schliessen", "save": "Speichern", + "viewModes": { + "daily": "Täglich", + "monthly": "Monatlich", + "yearly": "Jährlich" + }, + "datePresets": { + "all": "Alle Daten", + "7d": "Letzte 7 Tage", + "30d": "Letzte 30 Tage", + "month": "Aktueller Monat", + "year": "Aktuelles Jahr" + }, "sources": { "file": "Datei-Upload", "auto-import": "Auto-Import", @@ -958,6 +985,7 @@ "dataExported": "Daten-Backup exportiert", "noDataToExport": "Keine Daten zum Exportieren vorhanden", "settingsImported": "Einstellungen aus {{name}} importiert", + "settingsSaved": "Einstellungen gespeichert", "dataBackupImported": "Backup importiert: {{added}} neue Tage ergänzt, {{unchanged}} identische Tage übersprungen", "dataBackupImportedWithConflicts": "Backup importiert: {{added}} neue Tage ergänzt, {{conflicts}} Konflikttage lokal beibehalten" } diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 10cf4f5..c7d71af 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -68,6 +68,8 @@ "no": "No", "enabled": "Enabled", "disabled": "Disabled", + "visible": "Visible", + "hidden": "Hidden", "open": "Open", "close": "Close", "startDate": "Start date", @@ -841,6 +843,19 @@ "lastLoaded": "Last loaded", "loadedVia": "Loaded via", "cliAutoLoad": "CLI auto-load", + "defaultFiltersTitle": "Default dashboard filters", + "defaultFiltersDescription": "Choose the filter state that should be applied when the dashboard opens or when filters are reset.", + "defaultViewMode": "Default view mode", + "defaultDateRange": "Default date range", + "filterProviders": "Default provider filter", + "filterModels": "Default model filter", + "noModels": "No models found in the loaded report.", + "sectionVisibilityTitle": "Visible dashboard sections", + "sectionVisibilityDescription": "Control which sections are rendered in the dashboard and adjust their order.", + "sectionOrderHint": "Drag sections to reorder them. The current order is the default dashboard layout.", + "positionLabel": "Position {{position}} of {{total}}", + "moveSectionUp": "Move {{section}} up", + "moveSectionDown": "Move {{section}} down", "settingsBackupTitle": "Back up settings", "settingsBackupDescription": "Export and import language, theme, limits, and stored load metadata as a versioned backup.", "dataBackupTitle": "Back up data", @@ -856,6 +871,18 @@ "importData": "Import data", "close": "Close", "save": "Save", + "viewModes": { + "daily": "Daily", + "monthly": "Monthly", + "yearly": "Yearly" + }, + "datePresets": { + "all": "All data", + "7d": "Last 7 days", + "30d": "Last 30 days", + "month": "Current month", + "year": "Current year" + }, "sources": { "file": "File upload", "auto-import": "Auto import", @@ -958,6 +985,7 @@ "dataExported": "Data backup exported", "noDataToExport": "No data available to export", "settingsImported": "Imported settings from {{name}}", + "settingsSaved": "Settings saved", "dataBackupImported": "Backup imported: added {{added}} new days, skipped {{unchanged}} identical days", "dataBackupImportedWithConflicts": "Backup imported: added {{added}} new days, kept {{conflicts}} conflicting days local" } diff --git a/src/types/index.ts b/src/types/index.ts index afc59e8..152cce3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,31 @@ export type AppLanguage = 'de' | 'en' export type AppTheme = 'dark' | 'light' export type ViewMode = 'daily' | 'monthly' | 'yearly' +export type DashboardDatePreset = 'all' | '7d' | '30d' | 'month' | 'year' +export type DashboardSectionId = + | 'insights' + | 'metrics' + | 'today' + | 'currentMonth' + | 'activity' + | 'forecastCache' + | 'limits' + | 'costAnalysis' + | 'tokenAnalysis' + | 'requestAnalysis' + | 'advancedAnalysis' + | 'comparisons' + | 'tables' + +export interface DashboardDefaultFilters { + viewMode: ViewMode + datePreset: DashboardDatePreset + providers: string[] + models: string[] +} + +export type DashboardSectionVisibility = Record +export type DashboardSectionOrder = DashboardSectionId[] export interface DateRange { start: string @@ -177,6 +202,9 @@ export interface AppSettings { language: AppLanguage theme: AppTheme providerLimits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder lastLoadedAt: string | null lastLoadSource: DataLoadSource cliAutoLoadActive: boolean diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 93b90fb..d86e4a2 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -13,6 +13,10 @@ const settingsHeadingPattern = /^(Einstellungen|Settings)$/ const exportSettingsButtonPattern = /^(Einstellungen exportieren|Export settings)$/ const exportDataButtonPattern = /^(Daten exportieren|Export data)$/ const dataImportToastPattern = /^(Backup importiert: 1 neue Tage ergänzt, 1 Konflikttage lokal beibehalten|Backup imported: added 1 new days, kept 1 conflicting days local)$/ +const saveSettingsButtonPattern = /^(Speichern|Save)$/ +const monthlySettingsPattern = /^(Monatlich|Monthly)$/ +const monthlyViewPattern = /^(Monatsansicht|Monthly view)$/ +const last30DaysPattern = /^(Letzte 30 Tage|Last 30 days)$/ async function uploadSampleUsage(page: Page) { await page.locator('[data-testid="usage-upload-input"]').setInputFiles(sampleUsagePath) @@ -79,7 +83,41 @@ test('manages settings and backup imports through the settings dialog using isol } globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() }) - await expect(page.getByRole('dialog')).toBeVisible() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.locator('[data-section-id="insights"]')).toContainText('Insights') + + await dialog.getByRole('button', { name: monthlySettingsPattern }).click() + await dialog.getByRole('button', { name: last30DaysPattern }).click() + await dialog.locator('[data-section-id="tokenAnalysis"]').click() + await dialog.getByTestId('reset-default-filters').click() + await expect(dialog.getByRole('button', { name: /^(Täglich|Daily)$/ })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.getByRole('button', { name: /^(Alle Daten|All data)$/ })).toHaveAttribute('aria-pressed', 'true') + await dialog.getByRole('button', { name: monthlySettingsPattern }).click() + await dialog.getByRole('button', { name: last30DaysPattern }).click() + await dialog.getByTestId('reset-section-visibility').click() + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText(/Sichtbar|Visible/) + await dialog.getByTestId('move-section-up-tokenAnalysis').click() + await dialog.getByTestId('toggle-section-visibility-tokenAnalysis').click() + await dialog.getByRole('button', { name: saveSettingsButtonPattern }).click() + + await expect(dialog).toBeHidden() + await expect(page.locator('#token-analysis')).toHaveCount(0) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(monthlyViewPattern) + + await page.reload() + await expect(page.locator('#token-analysis')).toHaveCount(0) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(monthlyViewPattern) + + await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + await expect(dialog).toBeVisible() await page.getByRole('button', { name: exportSettingsButtonPattern }).click() await expect.poll(async () => { @@ -101,6 +139,10 @@ test('manages settings and backup imports through the settings dialog using isol expect(exportedSettingsRecord.filename).toMatch(/^ttdash-settings-backup-\d{4}-\d{2}-\d{2}\.json$/) const exportedSettings = JSON.parse(exportedSettingsRecord.text) expect(exportedSettings.kind).toBe('ttdash-settings-backup') + expect(exportedSettings.settings.defaultFilters.viewMode).toBe('monthly') + expect(exportedSettings.settings.defaultFilters.datePreset).toBe('30d') + expect(exportedSettings.settings.sectionVisibility.tokenAnalysis).toBe(false) + expect(exportedSettings.settings.sectionOrder.indexOf('tokenAnalysis')).toBeLessThan(exportedSettings.settings.sectionOrder.indexOf('costAnalysis')) await page.getByRole('button', { name: exportDataButtonPattern }).click() await expect.poll(async () => { @@ -167,6 +209,17 @@ test('manages settings and backup imports through the settings dialog using isol monthlyLimit: 400, }, }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['tables', 'metrics', 'insights'], lastLoadedAt: '2026-04-01T12:30:00.000Z', lastLoadSource: 'file', }, @@ -181,6 +234,15 @@ test('manages settings and backup imports through the settings dialog using isol expect(importedSettings.language).toBe('en') expect(importedSettings.theme).toBe('light') expect(importedSettings.providerLimits.OpenAI.monthlyLimit).toBe(400) + expect(importedSettings.defaultFilters).toEqual({ + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }) + expect(importedSettings.sectionVisibility.tokenAnalysis).toBe(false) + expect(importedSettings.sectionVisibility.comparisons).toBe(false) + expect(importedSettings.sectionOrder.slice(0, 3)).toEqual(['tables', 'metrics', 'insights']) }) test('loads persisted settings on a fresh browser start and applies them immediately', async ({ browser, page }) => { @@ -197,6 +259,17 @@ test('loads persisted settings on a fresh browser start and applies them immedia monthlyLimit: 400, }, }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['tables', 'metrics', 'insights'], }, }) expect(patchSettingsResponse.ok()).toBe(true) @@ -220,11 +293,29 @@ test('loads persisted settings on a fresh browser start and applies them immedia try { await freshPage.goto('/') - await expect(freshPage.locator('#token-analysis')).toBeVisible() + await expect(freshPage.locator('#token-analysis')).toHaveCount(0) + await expect(freshPage.locator('#comparisons')).toHaveCount(0) await expect.poll(async () => freshPage.evaluate(() => document.documentElement.classList.contains('dark'))).toBe(false) await expect(freshPage.getByRole('button', { name: 'Settings' })).toBeVisible() - await expect(freshPage.getByText('Filter status')).toBeVisible() + await expect(freshPage.locator('#filters').getByText('Filter status')).toBeVisible() + await expect(freshPage.locator('#filters').getByText('1 providers active')).toBeVisible() + await expect(freshPage.locator('#filters').getByText('1 models active')).toBeVisible() + await expect(freshPage.locator('#filters').getByRole('combobox').first()).toContainText('Monthly view') await expect(freshPage.getByRole('button', { name: 'Delete' })).toBeVisible() + await expect.poll(async () => freshPage.evaluate(() => { + const tables = document.getElementById('tables') + const metrics = document.getElementById('metrics') + const insights = document.getElementById('insights') + + if (!tables || !metrics || !insights) { + return false + } + + const tablesBeforeMetrics = Boolean(tables.compareDocumentPosition(metrics) & Node.DOCUMENT_POSITION_FOLLOWING) + const metricsBeforeInsights = Boolean(metrics.compareDocumentPosition(insights) & Node.DOCUMENT_POSITION_FOLLOWING) + + return tablesBeforeMetrics && metricsBeforeInsights + })).toBe(true) await freshPage.evaluate(() => { const globalWindow = window as typeof window & { @@ -238,10 +329,19 @@ test('loads persisted settings on a fresh browser start and applies them immedia const dialog = freshPage.getByRole('dialog') await expect(dialog).toBeVisible() await expect(dialog.getByRole('button', { name: 'Export settings' })).toBeVisible() - await expect(dialog.getByText('OpenAI')).toBeVisible() - const openAiCard = dialog.getByText('OpenAI', { exact: true }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]') + await expect(dialog.getByRole('button', { name: 'OpenAI', exact: true })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Monthly' })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.getByRole('button', { name: 'Last 30 days' })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.locator('[data-section-id="insights"]')).toContainText('Insights') + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText('Hidden') + const orderedSectionIds = await dialog.locator('[data-section-id]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id'))) + expect(orderedSectionIds.slice(0, 3)).toEqual(['tables', 'metrics', 'insights']) + const openAiCard = dialog.locator('[data-provider-id="OpenAI"]') await expect(openAiCard.locator('input[type="number"]').nth(0)).toHaveValue('20') await expect(openAiCard.locator('input[type="number"]').nth(1)).toHaveValue('400') + await dialog.getByTestId('reset-provider-limits').click() + await expect(openAiCard.locator('input[type="number"]').nth(0)).toHaveValue('0') + await expect(openAiCard.locator('input[type="number"]').nth(1)).toHaveValue('0') } finally { await context.close() } diff --git a/tests/frontend/use-dashboard-filters.test.tsx b/tests/frontend/use-dashboard-filters.test.tsx index da2d040..918a164 100644 --- a/tests/frontend/use-dashboard-filters.test.tsx +++ b/tests/frontend/use-dashboard-filters.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useDashboardFilters } from '@/hooks/use-dashboard-filters' +import type { DashboardDefaultFilters } from '@/types' import { dashboardFixture } from '../fixtures/usage-data' describe('useDashboardFilters', () => { @@ -66,4 +67,71 @@ describe('useDashboardFilters', () => { expect(result.current.startDate).toBe('2026-03-31') expect(result.current.endDate).toBe('2026-04-06') }) + + it('hydrates from external default filters and restores them on reset', () => { + const defaults: DashboardDefaultFilters = { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + } + + const { result } = renderHook(({ filters }) => useDashboardFilters(dashboardFixture, filters), { + initialProps: { filters: defaults }, + }) + + expect(result.current.viewMode).toBe('monthly') + expect(result.current.selectedProviders).toEqual(['OpenAI']) + expect(result.current.selectedModels).toEqual(['GPT-5.4']) + expect(result.current.startDate).toBe('2026-03-08') + expect(result.current.endDate).toBe('2026-04-06') + + act(() => { + result.current.toggleProvider('Anthropic') + result.current.applyPreset('7d') + }) + + expect(result.current.selectedProviders).toEqual(['OpenAI', 'Anthropic']) + expect(result.current.startDate).toBe('2026-03-31') + + act(() => { + result.current.resetAll() + }) + + expect(result.current.viewMode).toBe('monthly') + expect(result.current.selectedProviders).toEqual(['OpenAI']) + expect(result.current.selectedModels).toEqual(['GPT-5.4']) + expect(result.current.startDate).toBe('2026-03-08') + expect(result.current.endDate).toBe('2026-04-06') + }) + + it('applies persisted defaults when matching data becomes available later', () => { + const defaults: DashboardDefaultFilters = { + viewMode: 'daily', + datePreset: 'all', + providers: ['OpenAI'], + models: ['GPT-5.4'], + } + + const { result, rerender } = renderHook( + ({ data, filters }) => useDashboardFilters(data, filters), + { + initialProps: { + data: [], + filters: defaults, + }, + }, + ) + + expect(result.current.selectedProviders).toEqual([]) + expect(result.current.selectedModels).toEqual([]) + + rerender({ + data: dashboardFixture, + filters: defaults, + }) + + expect(result.current.selectedProviders).toEqual(['OpenAI']) + expect(result.current.selectedModels).toEqual(['GPT-5.4']) + }) }) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 3d8d903..9b60512 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import sampleUsage from '../../examples/sample-usage.json' +import { DEFAULT_DASHBOARD_FILTERS, getDefaultDashboardSectionOrder } from '@/lib/dashboard-preferences' let child: ChildProcessWithoutNullStreams | null = null let baseUrl = '' @@ -227,6 +228,23 @@ describe('local server API', () => { language: 'de', theme: 'dark', providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + sectionVisibility: { + insights: true, + metrics: true, + today: true, + currentMonth: true, + activity: true, + forecastCache: true, + limits: true, + costAnalysis: true, + tokenAnalysis: true, + requestAnalysis: true, + advancedAnalysis: true, + comparisons: true, + tables: true, + }, + sectionOrder: getDefaultDashboardSectionOrder(), lastLoadedAt: null, lastLoadSource: null, cliAutoLoadActive: false, @@ -267,6 +285,17 @@ describe('local server API', () => { monthlyLimit: 500.555, }, }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['metrics', 'insights', 'today'], }), }) @@ -281,6 +310,32 @@ describe('local server API', () => { monthlyLimit: 500.56, }, }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + insights: true, + }, + sectionOrder: [ + 'metrics', + 'insights', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', + ], cliAutoLoadActive: false, }) @@ -296,12 +351,24 @@ describe('local server API', () => { expect(finalUsage.totals.totalCost).toBe(0) const finalSettingsResponse = await fetch(`${baseUrl}/api/settings`) - expect(await finalSettingsResponse.json()).toMatchObject({ + const finalSettings = await finalSettingsResponse.json() + expect(finalSettings).toMatchObject({ language: 'en', theme: 'light', + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, lastLoadedAt: null, lastLoadSource: null, }) + expect(finalSettings.sectionOrder.slice(0, 3)).toEqual(['metrics', 'insights', 'today']) }) it('imports settings backups and merges usage backups without overwriting conflicting local days', async () => { @@ -328,6 +395,17 @@ describe('local server API', () => { monthlyLimit: 300.111, }, }, + defaultFilters: { + viewMode: 'yearly', + datePreset: 'year', + providers: ['Anthropic'], + models: ['Claude Sonnet 4.5'], + }, + sectionVisibility: { + tables: false, + advancedAnalysis: false, + }, + sectionOrder: ['tables', 'metrics', 'insights'], lastLoadedAt: '2026-04-01T12:30:00.000Z', lastLoadSource: 'file', }, @@ -345,6 +423,32 @@ describe('local server API', () => { monthlyLimit: 300.11, }, }, + defaultFilters: { + viewMode: 'yearly', + datePreset: 'year', + providers: ['Anthropic'], + models: ['Claude Sonnet 4.5'], + }, + sectionVisibility: { + tables: false, + advancedAnalysis: false, + insights: true, + }, + sectionOrder: [ + 'tables', + 'metrics', + 'insights', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + ], lastLoadedAt: '2026-04-01T12:30:00.000Z', lastLoadSource: 'file', cliAutoLoadActive: false, @@ -397,11 +501,23 @@ describe('local server API', () => { const mergedSettingsResponse = await fetch(`${baseUrl}/api/settings`) expect(mergedSettingsResponse.status).toBe(200) - expect(await mergedSettingsResponse.json()).toMatchObject({ + const mergedSettings = await mergedSettingsResponse.json() + expect(mergedSettings).toMatchObject({ theme: 'light', language: 'de', + defaultFilters: { + viewMode: 'yearly', + datePreset: 'year', + providers: ['Anthropic'], + models: ['Claude Sonnet 4.5'], + }, + sectionVisibility: { + tables: false, + advancedAnalysis: false, + }, lastLoadSource: 'file', }) + expect(mergedSettings.sectionOrder.slice(0, 3)).toEqual(['tables', 'metrics', 'insights']) }) it('starts background servers and stops the selected instance via the CLI', async () => { From c774166ce3273b2fa575a0cf4140fc28a95ebada Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Sat, 11 Apr 2026 00:06:51 +0200 Subject: [PATCH 05/14] v6.0.12: Fix dashboard metrics and preferences flows --- server/report/utils.js | 11 +- src/components/Dashboard.tsx | 4 +- .../command-palette/CommandPalette.tsx | 127 +++++++++++++++--- .../features/insights/UsageInsights.tsx | 2 +- .../features/settings/SettingsModal.tsx | 4 + src/components/layout/FilterBar.tsx | 64 +++++++-- src/lib/calculations.ts | 20 ++- src/lib/dashboard-preferences.ts | 10 +- src/locales/de/common.json | 4 + src/locales/en/common.json | 4 + src/types/index.ts | 2 +- tests/e2e/dashboard.spec.ts | 93 +++++++++++-- tests/frontend/filter-bar.test.tsx | 127 ++++++++++++++++++ tests/integration/server.test.ts | 37 +++++ tests/unit/analytics.test.ts | 80 ++++++++++- tests/unit/report-utils.test.ts | 16 +++ 16 files changed, 550 insertions(+), 55 deletions(-) create mode 100644 tests/frontend/filter-bar.test.tsx create mode 100644 tests/unit/report-utils.test.ts diff --git a/server/report/utils.js b/server/report/utils.js index 7249493..3cae4b3 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -252,6 +252,7 @@ function stdDev(values) { } function computeWeekOverWeekChange(data) { + if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null; if (data.length < 14) return null; const sorted = sortByDate(data); const last7 = sorted.slice(-7); @@ -450,15 +451,21 @@ function computeProviderRows(data) { tokens: 0, requests: 0, days: 0, + _dates: new Set(), }; current.cost += breakdown.cost; current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens; current.requests += breakdown.requestCount; - current.days += entryDays; + if (!current._dates.has(day.date)) { + current._dates.add(day.date); + current.days += entryDays; + } rows.set(provider, current); } } - return Array.from(rows.values()).sort((a, b) => b.cost - a.cost); + return Array.from(rows.values()) + .map(({ _dates, ...entry }) => entry) + .sort((a, b) => b.cost - a.cost); } function getDateRange(data) { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 52290f5..cde1342 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -357,7 +357,7 @@ export function Dashboard() { } finally { setReportGenerating(false) } - }, [reportGenerating, viewMode, selectedMonth, selectedProviders, selectedModels, startDate, endDate, addToast]) + }, [reportGenerating, viewMode, selectedMonth, selectedProviders, selectedModels, startDate, endDate, addToast, i18n.language, t]) const handleAutoImport = useCallback(() => { setAutoImportOpen(true) @@ -842,7 +842,6 @@ export function Dashboard() { {/* Command Palette */} void onExportCSV: () => void @@ -51,6 +52,45 @@ interface CommandItem { icon: React.ReactNode action: () => void group: string + testId?: string +} + +const SECTION_COMMAND_ICON_MAP: Record = { + insights: , + metrics: , + today: , + currentMonth: , + activity: , + forecastCache: , + limits: , + costAnalysis: , + tokenAnalysis: , + requestAnalysis: , + advancedAnalysis: , + comparisons: , + tables:
, +} + +const SECTION_COMMAND_KEYWORDS: Record = { + insights: ['summary', 'insight'], + metrics: ['kpi', 'zahlen'], + today: ['today', 'heute'], + currentMonth: ['monat', 'current month'], + activity: ['heatmap', 'aktivität'], + forecastCache: ['forecast', 'cache', 'roi'], + limits: ['limits', 'subscriptions', 'budget', 'anbieter limits'], + costAnalysis: ['charts', 'kostenanalyse'], + tokenAnalysis: ['tokens', 'token analyse'], + requestAnalysis: ['requests', 'request analyse', 'anfragen'], + advancedAnalysis: ['advanced analysis', 'distributions', 'risk', 'verteilungen'], + comparisons: ['anomalie', 'vergleich'], + tables: ['table', 'details'], +} + +const SECTION_COMMAND_ALIASES: Partial> = { + limits: ['limits sektion', 'subscriptions sektion'], + tokenAnalysis: ['token chart'], + requestAnalysis: ['request chart', 'request donut'], } function normalizeSearchValue(value: string) { @@ -119,7 +159,6 @@ function getCommandSearchScore(cmd: CommandItem, query: string) { export function CommandPalette({ isDark, - currentLanguage, availableProviders, selectedProviders, availableModels, @@ -128,6 +167,7 @@ export function CommandPalette({ hasMonthSection, hasRequestSection, sectionVisibility, + sectionOrder, reportGenerating, onToggleTheme, onExportCSV, @@ -152,6 +192,22 @@ export function CommandPalette({ const [open, setOpen] = useState(false) const [search, setSearch] = useState('') + const sectionAvailability = useMemo>(() => ({ + insights: true, + metrics: true, + today: hasTodaySection, + currentMonth: hasMonthSection, + activity: true, + forecastCache: true, + limits: true, + costAnalysis: true, + tokenAnalysis: true, + requestAnalysis: hasRequestSection, + advancedAnalysis: true, + comparisons: true, + tables: true, + }), [hasMonthSection, hasRequestSection, hasTodaySection]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { @@ -163,7 +219,31 @@ export function CommandPalette({ return () => document.removeEventListener('keydown', handleKeyDown) }, []) - const baseCommands: CommandItem[] = [ + const sectionNavigationCommands = useMemo(() => ( + sectionOrder.flatMap((sectionId) => { + const section = DASHBOARD_SECTION_DEFINITION_MAP[sectionId] + + if (!sectionVisibility[sectionId] || !sectionAvailability[sectionId]) { + return [] + } + + const sectionLabel = t(section.labelKey) + + return [{ + id: `section-${section.id}`, + label: t('commandPalette.commands.goToSection.label', { section: sectionLabel }), + description: t('commandPalette.commands.goToSection.description', { section: sectionLabel }), + keywords: [sectionLabel, section.domId, ...SECTION_COMMAND_KEYWORDS[section.id]], + aliases: SECTION_COMMAND_ALIASES[section.id], + icon: SECTION_COMMAND_ICON_MAP[section.id], + action: () => onScrollTo(section.domId), + group: t('commandPalette.groups.navigation'), + testId: `command-section-${section.id}`, + }] + }) + ), [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t]) + + const baseCommands = useMemo(() => [ { id: 'auto-import', label: t('commandPalette.commands.autoImport.label'), description: t('commandPalette.commands.autoImport.description'), keywords: ['toktrack', 'import', 'load', 'sync'], aliases: ['auto import', 'daten importieren'], icon: , action: onAutoImport, group: t('commandPalette.groups.actions') }, { id: 'settings-open', label: t('commandPalette.commands.openSettings.label'), description: t('commandPalette.commands.openSettings.description'), keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], icon: , action: onOpenSettings, group: t('commandPalette.groups.actions') }, { id: 'csv', label: t('commandPalette.commands.exportCsv.label'), description: t('commandPalette.commands.exportCsv.description'), keywords: ['download', 'export', 'csv'], aliases: ['csv download', 'daten exportieren'], shortcut: '⌘E', icon: , action: onExportCSV, group: t('commandPalette.groups.actions') }, @@ -187,24 +267,34 @@ export function CommandPalette({ { id: 'top', label: t('commandPalette.commands.scrollTop.label'), description: t('commandPalette.commands.scrollTop.description'), keywords: ['top', 'start', 'anfang'], shortcut: '⌘↑', icon: , action: () => window.scrollTo({ top: 0, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, { id: 'bottom', label: t('commandPalette.commands.scrollBottom.label'), description: t('commandPalette.commands.scrollBottom.description'), keywords: ['bottom', 'ende'], icon: , action: () => window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, { id: 'filters', label: t('commandPalette.commands.filters.label'), description: t('commandPalette.commands.filters.description'), keywords: ['filterbar', 'filter'], icon: , action: () => onScrollTo('filters'), group: t('commandPalette.groups.navigation') }, - ...(sectionVisibility.insights ? [{ id: 'insights', label: t('commandPalette.commands.insights.label'), description: t('commandPalette.commands.insights.description'), keywords: ['summary', 'insight'], icon: , action: () => onScrollTo('insights'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.metrics ? [{ id: 'metrics', label: t('commandPalette.commands.metrics.label'), description: t('commandPalette.commands.metrics.description'), keywords: ['kpi', 'zahlen'], icon: , action: () => onScrollTo('metrics'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(hasTodaySection && sectionVisibility.today ? [{ id: 'today', label: t('commandPalette.commands.today.label'), description: t('commandPalette.commands.today.description'), keywords: ['today', 'heute'], icon: , action: () => onScrollTo('today'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(hasMonthSection && sectionVisibility.currentMonth ? [{ id: 'month', label: t('commandPalette.commands.month.label'), description: t('commandPalette.commands.month.description'), keywords: ['monat', 'current month'], icon: , action: () => onScrollTo('current-month'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.activity ? [{ id: 'activity', label: t('commandPalette.commands.activity.label'), description: t('commandPalette.commands.activity.description'), keywords: ['heatmap', 'aktivität'], icon: , action: () => onScrollTo('activity'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.forecastCache ? [{ id: 'forecast-cache', label: t('commandPalette.commands.forecastCache.label'), description: t('commandPalette.commands.forecastCache.description'), keywords: ['forecast', 'cache', 'roi'], icon: , action: () => onScrollTo('forecast-cache'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.limits ? [{ id: 'limits', label: t('commandPalette.commands.limits.label'), description: t('commandPalette.commands.limits.description'), keywords: ['limits', 'subscriptions', 'budget', 'anbieter limits'], aliases: ['limits sektion', 'subscriptions sektion'], icon: , action: () => onScrollTo('limits'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.costAnalysis ? [{ id: 'charts', label: t('commandPalette.commands.charts.label'), description: t('commandPalette.commands.charts.description'), keywords: ['charts', 'kostenanalyse'], icon: , action: () => onScrollTo('charts'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.tokenAnalysis ? [{ id: 'token-analysis', label: t('commandPalette.commands.tokenAnalysis.label'), description: t('commandPalette.commands.tokenAnalysis.description'), keywords: ['tokens', 'token analyse'], aliases: ['token chart'], icon: , action: () => onScrollTo('token-analysis'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(hasRequestSection && sectionVisibility.requestAnalysis ? [{ id: 'request-analysis', label: t('commandPalette.commands.requestAnalysis.label'), description: t('commandPalette.commands.requestAnalysis.description'), keywords: ['requests', 'request analyse', 'anfragen'], aliases: ['request chart', 'request donut'], icon: , action: () => onScrollTo('request-analysis'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.comparisons ? [{ id: 'comparisons', label: t('commandPalette.commands.comparisons.label'), description: t('commandPalette.commands.comparisons.description'), keywords: ['anomalie', 'vergleich'], icon: , action: () => onScrollTo('comparisons'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(sectionVisibility.tables ? [{ id: 'tables', label: t('commandPalette.commands.tables.label'), description: t('commandPalette.commands.tables.description'), keywords: ['table', 'details'], icon:
, action: () => onScrollTo('tables'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), + ...sectionNavigationCommands, { id: 'theme', label: isDark ? t('commandPalette.commands.themeLight.label') : t('commandPalette.commands.themeDark.label'), description: t('commandPalette.commands.themeDark.description'), keywords: ['theme', 'dark', 'light'], shortcut: '⌘D', icon: isDark ? : , action: onToggleTheme, group: t('commandPalette.groups.view') }, { id: 'language-de', label: t('commandPalette.commands.languageGerman.label'), description: t('commandPalette.commands.languageGerman.description'), keywords: ['language', 'sprache', 'deutsch', 'german', 'locale'], aliases: ['switch german', 'auf deutsch', 'sprache deutsch'], icon: , action: () => onLanguageChange('de'), group: t('commandPalette.groups.language') }, { id: 'language-en', label: t('commandPalette.commands.languageEnglish.label'), description: t('commandPalette.commands.languageEnglish.description'), keywords: ['language', 'sprache', 'english', 'englisch', 'locale'], aliases: ['switch english', 'auf englisch', 'sprache english'], icon: , action: () => onLanguageChange('en'), group: t('commandPalette.groups.language') }, { id: 'help', label: t('commandPalette.commands.help.label'), description: t('commandPalette.commands.help.description'), keywords: ['shortcut', 'hilfe'], shortcut: '?', icon: , action: onHelp, group: t('commandPalette.groups.help') }, - ] + ], [ + isDark, + onAutoImport, + onOpenSettings, + onExportCSV, + onGenerateReport, + onUpload, + onDelete, + onViewModeChange, + onApplyPreset, + onClearProviders, + onClearModels, + onClearDateRange, + onResetAll, + onScrollTo, + sectionNavigationCommands, + reportGenerating, + onToggleTheme, + onLanguageChange, + onHelp, + t, + ]) const providerCommands = useMemo(() => ( availableProviders.map(provider => { @@ -242,7 +332,7 @@ export function CommandPalette({ ...baseCommands, ...providerCommands, ...modelCommands, - ], [baseCommands, providerCommands, modelCommands, currentLanguage]) + ], [baseCommands, providerCommands, modelCommands]) const filteredCommands = useMemo(() => ( commands @@ -316,6 +406,7 @@ export function CommandPalette({ runCommand(cmd)} className="flex items-center gap-2 rounded-md px-2 py-2 text-sm cursor-pointer aria-selected:bg-accent" > diff --git a/src/components/features/insights/UsageInsights.tsx b/src/components/features/insights/UsageInsights.tsx index d7c3f57..2a4ebc2 100644 --- a/src/components/features/insights/UsageInsights.tsx +++ b/src/components/features/insights/UsageInsights.tsx @@ -118,7 +118,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns ? t('insights.usagePatterns.summaryWithCoverage', { activeDays: metrics.activeDays, totalDays: totalCalendarDays, volatility: formatNumber(Math.round(metrics.requestVolatility)) }) : t('insights.usagePatterns.summaryWithoutCoverage', { activeDays: metrics.activeDays, unit: usageUnit })} details={[ - { label: t('insights.usagePatterns.avgModels'), value: metrics.avgModelsPerDay.toFixed(1) }, + { label: t('insights.usagePatterns.avgModels'), value: metrics.avgModelsPerEntry.toFixed(1) }, { label: t('insights.usagePatterns.providersActive'), value: formatNumber(metrics.providerCount) }, { label: t('insights.usagePatterns.weekendShare'), value: metrics.weekendCostShare !== null ? formatPercent(metrics.weekendCostShare, 0) : '–' }, { label: t('insights.usagePatterns.thinkingShare'), value: metrics.totalTokens > 0 ? formatPercent((metrics.totalThinking / metrics.totalTokens) * 100, 1) : '–' }, diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx index f18831e..e773fd1 100644 --- a/src/components/features/settings/SettingsModal.tsx +++ b/src/components/features/settings/SettingsModal.tsx @@ -185,6 +185,9 @@ export function SettingsModal({ setLimitDraft(syncProviderLimits(limitProviders, {})) setDefaultFilterDraft(DEFAULT_DASHBOARD_FILTERS) setSectionVisibilityDraft(getDefaultDashboardSectionVisibility()) + setSectionOrderDraft(getDefaultDashboardSectionOrder()) + setDraggedSectionId(null) + setDragOverSectionId(null) } const handleResetDefaultFilters = () => { @@ -646,6 +649,7 @@ export function SettingsModal({ variant="ghost" onClick={handleResetDrafts} disabled={settingsBusy} + data-testid="reset-all-settings-drafts" > {t('common.reset')} diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index 395295e..7a82c3c 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -7,7 +7,7 @@ import { getModelColor, getProviderBadgeClasses, getProviderBadgeStyle } from '@ import { formatDate, formatMonthYear, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react' -import type { ViewMode } from '@/types' +import type { DashboardDatePreset, ViewMode } from '@/types' interface FilterBarProps { viewMode: ViewMode @@ -53,6 +53,50 @@ function buildCalendarDays(displayMonth: Date) { return cells } +function resolveActivePreset(selectedMonth: string | null, startDate?: string, endDate?: string): DashboardDatePreset | null { + if (selectedMonth) return null + if (!startDate && !endDate) return 'all' + if (!startDate || !endDate) return null + + const today = new Date() + today.setHours(0, 0, 0, 0) + const fmt = toLocalDateStr + + const matchesPreset = (preset: DashboardDatePreset) => { + switch (preset) { + case '7d': { + const start = new Date(today) + start.setDate(today.getDate() - 6) + return startDate === fmt(start) && endDate === fmt(today) + } + case '30d': { + const start = new Date(today) + start.setDate(today.getDate() - 29) + return startDate === fmt(start) && endDate === fmt(today) + } + case 'month': { + const start = new Date(today.getFullYear(), today.getMonth(), 1) + return startDate === fmt(start) && endDate === fmt(today) + } + case 'year': { + const start = new Date(today.getFullYear(), 0, 1) + return startDate === fmt(start) && endDate === fmt(today) + } + case 'all': + default: + return false + } + } + + for (const preset of ['7d', '30d', 'month', 'year'] as DashboardDatePreset[]) { + if (matchesPreset(preset)) { + return preset + } + } + + return null +} + interface DatePickerFieldProps { label: string value?: string @@ -270,10 +314,10 @@ export function FilterBar({ onResetAll, }: FilterBarProps) { const { t } = useTranslation() - const [activePreset, setActivePreset] = useState(null) - - // Reset active preset when month or viewMode changes externally - useEffect(() => { setActivePreset(null) }, [selectedMonth, viewMode]) + const activePreset = useMemo( + () => resolveActivePreset(selectedMonth, startDate, endDate), + [selectedMonth, startDate, endDate], + ) const hasCustomFilters = selectedMonth !== null || selectedProviders.length > 0 || selectedModels.length > 0 || Boolean(startDate || endDate) || viewMode !== 'daily' @@ -287,10 +331,7 @@ export function FilterBar({ {(startDate || endDate) && {t('filterBar.dateFilterActive')}}