From e431a5f0f5c74576379a3287d13e3420ce090907 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 21:15:44 +0200 Subject: [PATCH 01/12] v6.1.9: Fix Windows child process spawning --- .github/workflows/ci.yml | 23 +++++++++++++++++++++ package-lock.json | 7 +------ package.json | 1 + server.js | 11 +++++------ tests/unit/server-helpers.test.ts | 33 +++++++++++++++++++++++++++++-- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd77260..63b90d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,26 @@ jobs: coverage/ playwright-report/ test-results/ + + windows-smoke: + runs-on: windows-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Run Windows server helper smoke tests + run: npx vitest run tests/unit/server-helpers.test.ts + + - name: Build production bundle + run: npm run build:app diff --git a/package-lock.json b/package-lock.json index a13930d..1074d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "6.1.8", "license": "MIT", "dependencies": { + "cross-spawn": "^7.0.6", "i18next": "^26.0.3", "react-i18next": "^17.0.2", "react-is": "^19.2.4" @@ -3337,7 +3338,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4263,7 +4263,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4988,7 +4987,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5475,7 +5473,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5488,7 +5485,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6128,7 +6124,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index bebae0a..db46d4d 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "vitest": "^4.1.3" }, "dependencies": { + "cross-spawn": "^7.0.6", "i18next": "^26.0.3", "react-i18next": "^17.0.2", "react-is": "^19.2.4" diff --git a/server.js b/server.js index 8757179..b636a0f 100755 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ const os = require('os'); const path = require('path'); const readline = require('readline/promises'); const { spawn } = require('child_process'); +const spawnCrossPlatform = require('cross-spawn'); const { parseArgs } = require('util'); const { normalizeIncomingData } = require('./usage-normalizer'); const { generatePdfReport } = require('./server/report'); @@ -1406,10 +1407,6 @@ function sendSSE(res, event, data) { let autoImportRunning = false; -function shouldUseShell(command) { - return IS_WINDOWS && /\.(cmd|bat)$/i.test(command); -} - function getExecutableName(baseName, isWindows = IS_WINDOWS) { if (!isWindows) { return baseName; @@ -1427,9 +1424,10 @@ function getExecutableName(baseName, isWindows = IS_WINDOWS) { } function spawnCommand(command, args, options = {}) { - return spawn(command, args, { + // cross-spawn resolves Windows command shims without relying on shell=true, + // which avoids the DEP0190 warning from Node's child_process APIs. + return spawnCrossPlatform(command, args, { ...options, - shell: options.shell ?? shouldUseShell(command), windowsHide: options.windowsHide ?? true, }); } @@ -1979,6 +1977,7 @@ module.exports = { bootstrapCli, runCli, __test__: { + commandExists, getExecutableName, listenOnAvailablePort, }, diff --git a/tests/unit/server-helpers.test.ts b/tests/unit/server-helpers.test.ts index 0de76a7..c07ce46 100644 --- a/tests/unit/server-helpers.test.ts +++ b/tests/unit/server-helpers.test.ts @@ -1,12 +1,13 @@ import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' const require = createRequire(import.meta.url) const { - __test__: { getExecutableName, listenOnAvailablePort }, + __test__: { commandExists, getExecutableName, listenOnAvailablePort }, } = require('../../server.js') as { __test__: { + commandExists: (command: string, args?: string[]) => Promise getExecutableName: (baseName: string, isWindows?: boolean) => string listenOnAvailablePort: ( serverInstance: { @@ -23,6 +24,10 @@ const { } } +afterEach(() => { + vi.restoreAllMocks() +}) + function createFakeServer( onListen: (port: number, bindHost: string, emitter: EventEmitter) => void, ) { @@ -54,6 +59,30 @@ describe('server helper utilities', () => { expect(getExecutableName('npx', false)).toBe('npx') }) + it.runIf(process.platform === 'win32')( + 'checks npx on Windows without emitting DEP0190 warnings', + async () => { + const warningMessages: string[] = [] + const emitWarning = vi.spyOn(process, 'emitWarning').mockImplementation((( + warning: string | Error, + ) => { + warningMessages.push(typeof warning === 'string' ? warning : warning.message) + return process + }) as typeof process.emitWarning) + + expect(await commandExists(getExecutableName('npx'))).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(emitWarning).not.toHaveBeenCalledWith( + expect.stringContaining('DEP0190'), + expect.anything(), + expect.anything(), + ) + expect(warningMessages.join('\n')).not.toContain('DEP0190') + }, + ) + it('retries iteratively on EADDRINUSE and logs each skipped port', async () => { const attempts: number[] = [] const logs: string[] = [] From e2aa24d7bb23f23733c644ccaf751b6d40691ecc Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 21:39:46 +0200 Subject: [PATCH 02/12] v6.1.9: Harden server input and auto-import --- server.js | 269 ++++++++++++++++++++++++++----- src/lib/auto-import.ts | 93 ++++++----- tests/integration/server.test.ts | 177 +++++++++++++++++++- tests/unit/auto-import.test.ts | 62 +++++++ 4 files changed, 523 insertions(+), 78 deletions(-) create mode 100644 tests/unit/auto-import.test.ts diff --git a/server.js b/server.js index b636a0f..fad56a2 100755 --- a/server.js +++ b/server.js @@ -847,6 +847,46 @@ function normalizeIsoTimestamp(value) { return new Date(timestamp).toISOString(); } +function createPersistedStateError(kind, filePath, cause) { + const label = kind === 'settings' ? 'Settings file' : 'Usage data file'; + const error = new Error(`${label} is unreadable or corrupted.`); + error.code = 'PERSISTED_STATE_INVALID'; + error.kind = kind; + error.filePath = filePath; + error.cause = cause; + return error; +} + +function isPersistedStateError(error, kind) { + return ( + Boolean(error) && + error.code === 'PERSISTED_STATE_INVALID' && + (kind ? error.kind === kind : true) + ); +} + +function isPayloadTooLargeError(error) { + return Boolean(error) && error.code === 'PAYLOAD_TOO_LARGE'; +} + +function readJsonFile(filePath, kind) { + try { + return { + status: 'ok', + value: JSON.parse(fs.readFileSync(filePath, 'utf-8')), + }; + } catch (error) { + if (error && error.code === 'ENOENT') { + return { + status: 'missing', + value: null, + }; + } + + throw createPersistedStateError(kind, filePath, error); + } +} + function sanitizeCurrency(value) { if (typeof value !== 'number' || !Number.isFinite(value)) return 0; return Math.max(0, Number(value.toFixed(2))); @@ -856,6 +896,49 @@ function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function createAutoImportMessageEvent(key, vars = {}) { + return { + key, + vars, + }; +} + +function createAutoImportError(message, key, vars = {}) { + const error = new Error(message); + error.messageKey = key; + error.messageVars = vars; + return error; +} + +function toAutoImportErrorEvent(error) { + if (error && typeof error.messageKey === 'string') { + return createAutoImportMessageEvent(error.messageKey, error.messageVars || {}); + } + + return createAutoImportMessageEvent('errorPrefix', { + message: error && error.message ? error.message : 'Unknown error', + }); +} + +function formatAutoImportMessageEvent(event) { + switch (event?.key) { + case 'startingLocalImport': + return 'Starting local toktrack import...'; + case 'loadingUsageData': + return `Loading usage data via ${event.vars?.command || 'unknown command'}...`; + case 'processingUsageData': + return `Processing usage data... (${event.vars?.seconds || 0}s)`; + case 'autoImportRunning': + return 'An auto-import is already running. Please wait.'; + case 'noRunnerFound': + return 'No local toktrack, Bun, or npm exec installation found.'; + case 'errorPrefix': + return `Error: ${event.vars?.message || 'Unknown error'}`; + default: + return 'Auto-import update'; + } +} + function computeUsageTotals(daily) { return daily.reduce( (totals, day) => ({ @@ -1272,11 +1355,16 @@ function serveFile(res, reqPath) { // --- API helpers --- function readData() { - try { - return normalizeIncomingData(JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'))); - } catch { + const file = readJsonFile(DATA_FILE, 'usage'); + if (file.status === 'missing') { return null; } + + try { + return normalizeIncomingData(file.value); + } catch (error) { + throw createPersistedStateError('usage', DATA_FILE, error); + } } function writeData(data) { @@ -1284,14 +1372,30 @@ function writeData(data) { } function readSettings() { - try { - return toSettingsResponse(JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8'))); - } catch { + const file = readJsonFile(SETTINGS_FILE, 'settings'); + if (file.status === 'missing') { return toSettingsResponse({ ...DEFAULT_SETTINGS, providerLimits: {}, }); } + + return toSettingsResponse(file.value); +} + +function readSettingsForWrite() { + try { + return readSettings(); + } catch (error) { + if (isPersistedStateError(error, 'settings')) { + return toSettingsResponse({ + ...DEFAULT_SETTINGS, + providerLimits: {}, + }); + } + + throw error; + } } function writeSettings(settings) { @@ -1299,7 +1403,7 @@ function writeSettings(settings) { } function updateSettings(patch) { - const current = readSettings(); + const current = readSettingsForWrite(); const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}), @@ -1319,7 +1423,7 @@ function updateSettings(patch) { } function recordDataLoad(source) { - const current = readSettings(); + const current = readSettingsForWrite(); const next = { ...current, lastLoadedAt: new Date().toISOString(), @@ -1331,7 +1435,7 @@ function recordDataLoad(source) { } function clearDataLoadState() { - const current = readSettings(); + const current = readSettingsForWrite(); const next = { ...current, lastLoadedAt: null, @@ -1346,23 +1450,62 @@ function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let totalSize = 0; - req.on('data', (c) => { + let settled = false; + + const cleanup = () => { + req.off('data', onData); + req.off('end', onEnd); + req.off('error', onError); + }; + + const rejectOnce = (error) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + const resolveOnce = (value) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + }; + + const onData = (c) => { totalSize += c.length; if (totalSize > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error('Payload too large')); + const error = new Error('Payload too large'); + error.code = 'PAYLOAD_TOO_LARGE'; + rejectOnce(error); + req.resume(); return; } chunks.push(c); - }); - req.on('end', () => { + }; + + const onEnd = () => { try { - resolve(JSON.parse(Buffer.concat(chunks).toString())); + resolveOnce(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { - reject(e); + rejectOnce(e); } - }); - req.on('error', reject); + }; + + const onError = (error) => { + if (settled && error && error.code === 'ECONNRESET') { + return; + } + rejectOnce(error); + }; + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); }); } @@ -1525,24 +1668,30 @@ async function performAutoImport({ signalOnClose, } = {}) { if (autoImportRunning) { - throw new Error('An auto-import is already running. Please wait.'); + throw createAutoImportError( + 'An auto-import is already running. Please wait.', + 'autoImportRunning', + ); } autoImportRunning = true; let progressSeconds = 0; const progressInterval = setInterval(() => { progressSeconds += 5; - onOutput(`Processing usage data... (${progressSeconds}s)`); + onProgress(createAutoImportMessageEvent('processingUsageData', { seconds: progressSeconds })); }, 5000); try { onCheck({ tool: 'toktrack', status: 'checking' }); - onProgress({ message: 'Starting local toktrack import...' }); + onProgress(createAutoImportMessageEvent('startingLocalImport')); const runner = await resolveToktrackRunner(); if (!runner) { onCheck({ tool: 'toktrack', status: 'not_found' }); - throw new Error('No local toktrack, Bun, or npm exec installation found.'); + throw createAutoImportError( + 'No local toktrack, Bun, or npm exec installation found.', + 'noRunnerFound', + ); } const versionResult = await runToktrack(runner, ['--version']); @@ -1552,7 +1701,11 @@ async function performAutoImport({ method: runner.label, version: String(versionResult).replace(/^toktrack\s+/, ''), }); - onProgress({ message: `Loading usage data via ${runner.displayCommand}...` }); + onProgress( + createAutoImportMessageEvent('loadingUsageData', { + command: runner.displayCommand, + }), + ); const rawJson = await runToktrack(runner, ['daily', '--json'], { streamStderr: true, @@ -1588,7 +1741,7 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { } }, onProgress: (event) => { - console.log(event.message); + console.log(formatAutoImportMessageEvent(event)); }, onOutput: (line) => { console.log(line); @@ -1608,15 +1761,30 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { // --- Server --- const server = http.createServer(async (req, res) => { - const url = new URL(req.url, 'http://localhost'); - const pathname = decodeURIComponent(url.pathname); + let url; + let pathname; + + try { + url = new URL(req.url, 'http://localhost'); + pathname = decodeURIComponent(url.pathname); + } catch { + return json(res, 400, { message: 'Invalid request path' }); + } // API routing const apiPath = resolveApiPath(pathname); if (apiPath === '/usage') { if (req.method === 'GET') { - const data = readData(); + let data; + try { + data = readData(); + } catch (error) { + if (isPersistedStateError(error, 'usage')) { + return json(res, 500, { message: error.message }); + } + throw error; + } return json( res, 200, @@ -1664,7 +1832,14 @@ const server = http.createServer(async (req, res) => { if (apiPath === '/settings') { if (req.method === 'GET') { - return json(res, 200, readSettings()); + try { + return json(res, 200, readSettings()); + } catch (error) { + if (isPersistedStateError(error, 'settings')) { + return json(res, 500, { message: error.message }); + } + throw error; + } } if (req.method === 'DELETE') { @@ -1681,6 +1856,9 @@ const server = http.createServer(async (req, res) => { const body = await readBody(req); return json(res, 200, updateSettings(body)); } catch (e) { + if (isPayloadTooLargeError(e)) { + return json(res, 413, { message: 'Settings request too large' }); + } return json(res, 400, { message: e.message || 'Invalid settings request' }); } } @@ -1699,6 +1877,9 @@ const server = http.createServer(async (req, res) => { writeSettings(importedSettings); return json(res, 200, toSettingsResponse(importedSettings)); } catch (e) { + if (isPayloadTooLargeError(e)) { + return json(res, 413, { message: 'Settings file too large' }); + } return json(res, 400, { message: e.message || 'Invalid settings file' }); } } @@ -1714,11 +1895,10 @@ const server = http.createServer(async (req, res) => { const totalCost = normalized.totals.totalCost; return json(res, 200, { days, totalCost }); } catch (e) { - const status = e.message === 'Payload too large' ? 413 : 400; - const message = - e.message === 'Payload too large' - ? 'File too large (max. 10 MB)' - : e.message || 'Invalid JSON'; + const status = isPayloadTooLargeError(e) ? 413 : 400; + const message = isPayloadTooLargeError(e) + ? 'File too large (max. 10 MB)' + : e.message || 'Invalid JSON'; return json(res, status, { message }); } } @@ -1739,6 +1919,12 @@ const server = http.createServer(async (req, res) => { recordDataLoad('file'); return json(res, 200, result.summary); } catch (e) { + if (isPayloadTooLargeError(e)) { + return json(res, 413, { message: 'Usage backup file too large' }); + } + if (isPersistedStateError(e, 'usage')) { + return json(res, 500, { message: e.message }); + } return json(res, 400, { message: e.message || 'Invalid usage backup file' }); } } @@ -1795,7 +1981,7 @@ const server = http.createServer(async (req, res) => { if (aborted) { return; } - sendSSE(res, 'error', { message: `Error: ${err.message}` }); + sendSSE(res, 'error', toAutoImportErrorEvent(err)); sendSSE(res, 'done', {}); res.end(); } @@ -1807,7 +1993,15 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } - const data = readData(); + let data; + try { + data = readData(); + } catch (error) { + if (isPersistedStateError(error, 'usage')) { + return json(res, 500, { message: error.message }); + } + throw error; + } if (!data || !Array.isArray(data.daily) || data.daily.length === 0) { return json(res, 400, { message: 'No data available for the report.' }); } @@ -1816,10 +2010,9 @@ const server = http.createServer(async (req, res) => { try { body = await readBody(req); } catch (e) { - const status = e.message === 'Payload too large' ? 413 : 400; + const status = isPayloadTooLargeError(e) ? 413 : 400; return json(res, status, { - message: - e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request', + message: isPayloadTooLargeError(e) ? 'Report request too large' : 'Invalid report request', }); } diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 5ff00e1..79a9ec2 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -4,7 +4,25 @@ export interface CheckEvent { method?: string version?: string } +type AutoImportMessageKey = + | 'startingLocalImport' + | 'loadingUsageData' + | 'processingUsageData' + | 'serverConnectionLost' + | 'autoImportRunning' + | 'noRunnerFound' + | 'errorPrefix' + +type AutoImportMessageEvent = { + key: AutoImportMessageKey + vars?: Record +} + export interface ProgressEvent { + key: AutoImportMessageKey + vars?: Record +} +export interface ProgressMessage { message: string } export interface StderrEvent { @@ -15,6 +33,10 @@ export interface SuccessEvent { totalCost: number } export interface ErrorEvent { + key: AutoImportMessageKey + vars?: Record +} +export interface ErrorMessage { message: string } @@ -38,48 +60,40 @@ export function parseEventData(event: Event): T | null { } } -function translateAutoImportMessage(message: string, t: AutoImportTranslator) { - if (message === 'Starte lokalen toktrack-Import...') { - return t('autoImportModal.startingLocalImport') - } - - if (message.startsWith('Lade Nutzungsdaten via ')) { - return t('autoImportModal.loadingUsageData', { - command: message.replace('Lade Nutzungsdaten via ', '').replace(/\.\.\.$/, ''), - }) - } - - const processingMatch = message.match(/^Verarbeite Nutzungsdaten\.\.\. \((\d+)s\)$/) - if (processingMatch) { - return t('autoImportModal.processingUsageData', { seconds: processingMatch[1] ?? '0' }) +export function translateAutoImportEvent(event: AutoImportMessageEvent, t: AutoImportTranslator) { + switch (event.key) { + case 'startingLocalImport': + return t('autoImportModal.startingLocalImport') + case 'loadingUsageData': + return t('autoImportModal.loadingUsageData', { + command: String(event.vars?.['command'] ?? ''), + }) + case 'processingUsageData': + return t('autoImportModal.processingUsageData', { + seconds: String(event.vars?.['seconds'] ?? '0'), + }) + case 'serverConnectionLost': + return t('autoImportModal.serverConnectionLost') + case 'autoImportRunning': + return t('autoImportModal.autoImportRunning') + case 'noRunnerFound': + return t('autoImportModal.noRunnerFound') + case 'errorPrefix': + return t('autoImportModal.errorPrefix', { + message: String(event.vars?.['message'] ?? ''), + }) + default: + return event.key } - - if (message === 'Verbindung zum Server verloren.') { - return t('autoImportModal.serverConnectionLost') - } - - if (message === 'Ein Auto-Import läuft bereits. Bitte warten.') { - return t('autoImportModal.autoImportRunning') - } - - if (message === 'Kein lokales toktrack, Bun oder npm exec gefunden.') { - return t('autoImportModal.noRunnerFound') - } - - if (message.startsWith('Fehler: ')) { - return t('autoImportModal.errorPrefix', { message: message.replace(/^Fehler: /, '') }) - } - - return message } export function startAutoImport( callbacks: { onCheck: (data: CheckEvent) => void - onProgress: (data: ProgressEvent) => void + onProgress: (data: ProgressMessage) => void onStderr: (data: StderrEvent) => void onSuccess: (data: SuccessEvent) => void - onError: (data: ErrorEvent) => void + onError: (data: ErrorMessage) => void onDone: () => void }, t: AutoImportTranslator = (key) => key, @@ -95,13 +109,16 @@ export function startAutoImport( es.addEventListener('progress', (event) => { const data = parseEventData(event) if (data) { - callbacks.onProgress({ ...data, message: translateAutoImportMessage(data.message, t) }) + callbacks.onProgress({ + ...data, + message: translateAutoImportEvent(data, t), + }) } }) es.addEventListener('stderr', (event) => { const data = parseEventData(event) if (data) { - callbacks.onStderr({ ...data, line: translateAutoImportMessage(data.line, t) }) + callbacks.onStderr(data) } }) es.addEventListener('success', (event) => { @@ -114,7 +131,9 @@ export function startAutoImport( // SSE 'error' can be both our custom event and a connection error const data = parseEventData(event) if (data) { - callbacks.onError({ ...data, message: translateAutoImportMessage(data.message, t) }) + callbacks.onError({ + message: translateAutoImportEvent(data, t), + }) } else { callbacks.onError({ message: t('autoImportModal.serverConnectionLost') }) es.close() diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 5a1657f..e0a4e28 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,4 +1,4 @@ -import { createServer } from 'node:net' +import { createConnection, createServer } from 'node:net' import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -103,6 +103,7 @@ async function waitForProcessServer( currentChild: ChildProcessWithoutNullStreams, url: string, getOutput: () => string, + readinessPath = '/api/usage', ) { const startedAt = Date.now() @@ -112,7 +113,7 @@ async function waitForProcessServer( } try { - const response = await fetch(`${url}/api/usage`) + const response = await fetch(`${url}${readinessPath}`) if (response.ok) { return } @@ -152,10 +153,12 @@ async function startStandaloneServer({ root, args = [], envOverrides = {}, + readinessPath = '/api/usage', }: { root: string args?: string[] envOverrides?: NodeJS.ProcessEnv + readinessPath?: string }) { const port = Number(envOverrides.PORT) || (await getFreePort()) const url = `http://127.0.0.1:${port}` @@ -179,7 +182,7 @@ async function startStandaloneServer({ serverOutput += chunk.toString() }) - await waitForProcessServer(currentChild, url, () => serverOutput) + await waitForProcessServer(currentChild, url, () => serverOutput, readinessPath) return { child: currentChild, @@ -201,6 +204,36 @@ function getCliConfigDir(root: string) { return path.join(root, 'config', 'ttdash') } +function getCliDataDir(root: string) { + if (process.platform === 'darwin') { + return path.join(root, 'Library', 'Application Support', 'TTDash') + } + + if (process.platform === 'win32') { + return path.join(root, 'AppData', 'Local', 'TTDash') + } + + return path.join(root, 'data', 'ttdash') +} + +async function sendRawHttpRequest(port: number, request: string) { + return await new Promise((resolve, reject) => { + const socket = createConnection(port, '127.0.0.1') + let response = '' + + socket.on('connect', () => { + socket.write(request) + }) + socket.on('data', (chunk) => { + response += chunk.toString() + }) + socket.on('end', () => { + resolve(response) + }) + socket.on('error', reject) + }) +} + function readBackgroundRegistry(root: string) { const registryPath = path.join(getCliConfigDir(root), 'background-instances.json') return JSON.parse(readFileSync(registryPath, 'utf-8')) as Array<{ @@ -853,6 +886,62 @@ describe('local server API', () => { }) }) + it('returns 400 for malformed request paths without crashing the server', async () => { + const port = Number(new URL(baseUrl).port) + const rawResponse = await sendRawHttpRequest( + port, + 'GET /%E0%A4%A HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n', + ) + + expect(rawResponse.startsWith('HTTP/1.1 400 Bad Request')).toBe(true) + expect(rawResponse).toContain('{"message":"Invalid request path"}') + + const usageResponse = await fetch(`${baseUrl}/api/usage`) + expect(usageResponse.status).toBe(200) + }) + + it('returns 413 for oversized upload payloads instead of resetting the connection', async () => { + const oversizedPayload = `"${'a'.repeat(11 * 1024 * 1024)}"` + + const response = await fetch(`${baseUrl}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: oversizedPayload, + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + message: 'File too large (max. 10 MB)', + }) + + const usageResponse = await fetch(`${baseUrl}/api/usage`) + expect(usageResponse.status).toBe(200) + }) + + it('returns 413 for oversized report payloads instead of resetting the connection', 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 oversizedPayload = `"${'a'.repeat(11 * 1024 * 1024)}"` + const response = await fetch(`${baseUrl}/api/report/pdf`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: oversizedPayload, + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + message: 'Report request too large', + }) + + const usageResponse = await fetch(`${baseUrl}/api/usage`) + expect(usageResponse.status).toBe(200) + }) + 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) @@ -1081,4 +1170,86 @@ describe('local server API', () => { rmSync(cliRoot, { recursive: true, force: true }) } }, 20_000) + + it('returns 500 for corrupt persisted usage data and recovers after deletion', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-corrupt-usage-test-')) + const dataFile = path.join(getCliDataDir(runtimeRoot), 'data.json') + mkdirSync(path.dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, '{not-json') + + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + readinessPath: '/api/runtime', + }) + + const corruptResponse = await fetch(`${standaloneServer.url}/api/usage`) + expect(corruptResponse.status).toBe(500) + expect(await corruptResponse.json()).toEqual({ + message: 'Usage data file is unreadable or corrupted.', + }) + + const deleteResponse = await fetch(`${standaloneServer.url}/api/usage`, { + method: 'DELETE', + }) + expect(deleteResponse.status).toBe(200) + + const recoveredResponse = await fetch(`${standaloneServer.url}/api/usage`) + expect(recoveredResponse.status).toBe(200) + expect(await recoveredResponse.json()).toMatchObject({ + daily: [], + totals: { + totalCost: 0, + totalTokens: 0, + }, + }) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + + it('returns 500 for corrupt persisted settings and recovers after deletion', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-corrupt-settings-test-')) + const settingsFile = path.join(getCliConfigDir(runtimeRoot), 'settings.json') + mkdirSync(path.dirname(settingsFile), { recursive: true }) + writeFileSync(settingsFile, '{not-json') + + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + }) + + const corruptResponse = await fetch(`${standaloneServer.url}/api/settings`) + expect(corruptResponse.status).toBe(500) + expect(await corruptResponse.json()).toEqual({ + message: 'Settings file is unreadable or corrupted.', + }) + + const deleteResponse = await fetch(`${standaloneServer.url}/api/settings`, { + method: 'DELETE', + }) + expect(deleteResponse.status).toBe(200) + + const recoveredResponse = await fetch(`${standaloneServer.url}/api/settings`) + expect(recoveredResponse.status).toBe(200) + expect(await recoveredResponse.json()).toMatchObject({ + language: 'de', + theme: 'dark', + providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + }) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) }) diff --git a/tests/unit/auto-import.test.ts b/tests/unit/auto-import.test.ts new file mode 100644 index 0000000..3564851 --- /dev/null +++ b/tests/unit/auto-import.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { translateAutoImportEvent } from '@/lib/auto-import' + +const translations = { + 'autoImportModal.startingLocalImport': 'Starte lokalen toktrack-Import...', + 'autoImportModal.loadingUsageData': 'Lade Nutzungsdaten via {{command}}...', + 'autoImportModal.processingUsageData': 'Verarbeite Nutzungsdaten... ({{seconds}}s)', + 'autoImportModal.autoImportRunning': 'Ein Auto-Import läuft bereits. Bitte warten.', + 'autoImportModal.noRunnerFound': 'Kein lokales toktrack, Bun oder npm exec gefunden.', + 'autoImportModal.errorPrefix': 'Fehler: {{message}}', +} as const + +function translate(key: string, vars?: Record) { + let template = translations[key as keyof typeof translations] ?? key + + for (const [name, value] of Object.entries(vars ?? {})) { + template = template.replace(`{{${name}}}`, String(value)) + } + + return template +} + +describe('translateAutoImportEvent', () => { + it('renders structured progress events via translation keys instead of source-text matching', () => { + expect(translateAutoImportEvent({ key: 'startingLocalImport' }, translate)).toBe( + 'Starte lokalen toktrack-Import...', + ) + expect( + translateAutoImportEvent( + { + key: 'loadingUsageData', + vars: { command: 'npx --yes toktrack daily --json' }, + }, + translate, + ), + ).toBe('Lade Nutzungsdaten via npx --yes toktrack daily --json...') + expect( + translateAutoImportEvent( + { + key: 'processingUsageData', + vars: { seconds: 10 }, + }, + translate, + ), + ).toBe('Verarbeite Nutzungsdaten... (10s)') + }) + + it('renders localized error events from structured payloads', () => { + expect(translateAutoImportEvent({ key: 'noRunnerFound' }, translate)).toBe( + 'Kein lokales toktrack, Bun oder npm exec gefunden.', + ) + expect( + translateAutoImportEvent( + { + key: 'errorPrefix', + vars: { message: 'toktrack failed' }, + }, + translate, + ), + ).toBe('Fehler: toktrack failed') + }) +}) From bf1b4a3d021c6e7c2fc7eb4b73df47510cd07fdb Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 21:56:28 +0200 Subject: [PATCH 03/12] v6.1.9: Improve UI accessibility and i18n --- src/components/Dashboard.tsx | 2 + src/components/charts/ChartCard.tsx | 4 +- .../features/drill-down/DrillDownModal.tsx | 114 +++++++----------- .../features/settings/SettingsModal.tsx | 43 ++++++- src/components/layout/FilterBar.tsx | 29 ++--- src/components/ui/dialog.tsx | 45 ++++--- src/locales/de/common.json | 26 ++++ src/locales/en/common.json | 26 ++++ tests/frontend/filter-bar.test.tsx | 35 ++++++ tests/frontend/header-links.test.tsx | 8 ++ tests/frontend/phase4-correctness.test.tsx | 66 +++++++++- tests/frontend/settings-modal.test.tsx | 82 +++++++++++++ 12 files changed, 373 insertions(+), 107 deletions(-) create mode 100644 tests/frontend/settings-modal.test.tsx diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 23113cf..4c4c85d 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -981,6 +981,7 @@ export function Dashboard() { {title} - Expanded chart view with metric summary and optional CSV export. + {t('chartCard.expandedDescription')}
@@ -231,7 +231,7 @@ export function ChartCard({ onClick={handleExport} className="text-xs px-3 py-1.5 rounded-lg border border-border hover:bg-accent transition-all duration-200 text-muted-foreground hover:text-foreground" > - CSV Export + {t('chartCard.exportCsv')} )}
diff --git a/src/components/features/drill-down/DrillDownModal.tsx b/src/components/features/drill-down/DrillDownModal.tsx index 40e3b81..9a71d4b 100644 --- a/src/components/features/drill-down/DrillDownModal.tsx +++ b/src/components/features/drill-down/DrillDownModal.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, @@ -27,6 +28,7 @@ interface DrillDownModalProps { } export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDownModalProps) { + const { t } = useTranslation() const modelData = useMemo(() => { if (!day) return [] const map = new Map< @@ -119,6 +121,21 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo ) const formatTokenShare = (value: number) => hasTokens ? formatPercent((value / tokensTotal) * 100) : '–' + const tokenSegments = [ + { + value: day.cacheReadTokens, + color: 'hsl(160, 50%, 42%)', + label: t('drillDown.tokenSegments.cacheRead'), + }, + { + value: day.cacheCreationTokens, + color: 'hsl(262, 60%, 55%)', + label: t('drillDown.tokenSegments.cacheWrite'), + }, + { value: day.inputTokens, color: 'hsl(340, 55%, 52%)', label: t('common.input') }, + { value: day.outputTokens, color: 'hsl(35, 80%, 52%)', label: t('common.output') }, + { value: day.thinkingTokens, color: 'hsl(12, 78%, 56%)', label: t('common.thinking') }, + ] as const return ( !o && onClose()}> @@ -127,15 +144,12 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo {formatDate(day.date, 'long')} — {formatCurrency(day.totalCost)} - - Detaillierte Tagesansicht mit Token-Verteilung, Modellanteilen, Requests und Thinking - Tokens. - + {t('drillDown.description')}
-
Tokens
+
{t('common.tokens')}
@@ -151,45 +165,45 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
-
Cache-Rate
+
{t('drillDown.cacheRate')}
-
Modelle
+
{t('common.models')}
{modelData.length}
-
Requests
+
{t('common.requests')}
-
Thinking
+
{t('common.thinking')}
-
Tokens / Req
+
{t('drillDown.tokensPerRequest')}
-
Kosten / Req
+
{t('drillDown.costPerRequest')}
-
Kosten-Rang
+
{t('drillDown.costRank')}
{costRanking > 0 ? `#${costRanking}` : '–'}
-
Request-Rang
+
{t('drillDown.requestRank')}
{requestRanking > 0 ? `#${requestRanking}` : '–'}
@@ -198,11 +212,11 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
-
Dominant nach Requests
+
{t('drillDown.topRequestModel')}
{topRequestModel?.name ?? '–'}
-
Kosten vs. 7T-Ø
+
{t('drillDown.costVsAverage7d')}
{avgCost7 !== null ? `${day.totalCost >= avgCost7 ? '↑' : '↓'} ${formatCurrency(Math.abs(day.totalCost - avgCost7))}` @@ -210,7 +224,7 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
-
Requests vs. 7T-Ø
+
{t('drillDown.requestsVsAverage7d')}
{avgRequests7 !== null ? `${day.requestCount >= avgRequests7 ? '↑' : '↓'} ${Math.abs(day.requestCount - avgRequests7).toFixed(0)}` @@ -221,22 +235,12 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo {/* Token type stacked bar */}
-
Token-Verteilung
+
+ {t('drillDown.tokenDistribution')} +
{hasTokens && - ( - [ - { value: day.cacheReadTokens, color: 'hsl(160, 50%, 42%)', label: 'Cache Read' }, - { - value: day.cacheCreationTokens, - color: 'hsl(262, 60%, 55%)', - label: 'Cache Write', - }, - { value: day.inputTokens, color: 'hsl(340, 55%, 52%)', label: 'Input' }, - { value: day.outputTokens, color: 'hsl(35, 80%, 52%)', label: 'Output' }, - { value: day.thinkingTokens, color: 'hsl(12, 78%, 56%)', label: 'Thinking' }, - ] as const - ).map((seg) => ( + tokenSegments.map((seg) => (
- - - Cache Read {formatTokenShare(day.cacheReadTokens)} - - - - Cache Write {formatTokenShare(day.cacheCreationTokens)} - - - - Input {formatTokenShare(day.inputTokens)} - - - - Output {formatTokenShare(day.outputTokens)} - - - - Thinking {formatTokenShare(day.thinkingTokens)} - + {tokenSegments.map((segment) => ( + + + {segment.label} {formatTokenShare(segment.value)} + + ))}
@@ -344,13 +319,16 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo - {model.requests} Req + {t('drillDown.requestCountShort', { count: model.requests })}
{model.requests > 0 - ? `${formatCurrency(model.cost / model.requests)}/Req · ${formatTokens(model.tokens / model.requests)}/Req` - : 'Keine Requests'} + ? t('drillDown.modelRequestSummary', { + costPerRequest: formatCurrency(model.cost / model.requests), + tokensPerRequest: formatTokens(model.tokens / model.requests), + }) + : t('drillDown.noRequests')}
diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx index 6629994..648788e 100644 --- a/src/components/features/settings/SettingsModal.tsx +++ b/src/components/features/settings/SettingsModal.tsx @@ -31,20 +31,24 @@ import { Filter, GripVertical, LayoutPanelTop, + Languages, Settings2, Upload, } from 'lucide-react' import type { + AppLanguage, DashboardDefaultFilters, DashboardSectionOrder, DashboardSectionVisibility, DataLoadSource, ProviderLimits, } from '@/types' +import { DEFAULT_APP_SETTINGS } from '@/lib/app-settings' interface SettingsModalProps { open: boolean onOpenChange: (open: boolean) => void + language: AppLanguage limitProviders: string[] filterProviders: string[] models: string[] @@ -57,6 +61,7 @@ interface SettingsModalProps { cliAutoLoadActive?: boolean hasData: boolean onSaveSettings: (settings: { + language: AppLanguage providerLimits: ProviderLimits defaultFilters: DashboardDefaultFilters sectionVisibility: DashboardSectionVisibility @@ -145,6 +150,7 @@ export function reorderSections( export function SettingsModal({ open, onOpenChange, + language, limitProviders, filterProviders, models, @@ -165,6 +171,7 @@ export function SettingsModal({ dataBusy = false, }: SettingsModalProps) { const { t } = useTranslation() + const [languageDraft, setLanguageDraft] = useState(language) const [limitDraft, setLimitDraft] = useState(() => syncProviderLimits(limitProviders, limits), ) @@ -183,13 +190,14 @@ export function SettingsModal({ useEffect(() => { if (!open) return + setLanguageDraft(language) setLimitDraft(syncProviderLimits(limitProviders, limits)) setDefaultFilterDraft(defaultFilters) setSectionVisibilityDraft(sectionVisibility) setSectionOrderDraft(sectionOrder) setDraggedSectionId(null) setDragOverSectionId(null) - }, [open, limitProviders, limits, defaultFilters, sectionVisibility, sectionOrder]) + }, [open, language, limitProviders, limits, defaultFilters, sectionVisibility, sectionOrder]) const providerOptions = useMemo( () => normalizeSelection([...filterProviders, ...defaultFilterDraft.providers]), @@ -212,6 +220,7 @@ export function SettingsModal({ const handleSave = async () => { await onSaveSettings({ + language: languageDraft, providerLimits: buildProviderLimitsState(limitProviders, limitDraft), defaultFilters: { ...defaultFilterDraft, @@ -225,6 +234,7 @@ export function SettingsModal({ } const handleResetDrafts = () => { + setLanguageDraft(DEFAULT_APP_SETTINGS.language) setLimitDraft(syncProviderLimits(limitProviders, {})) setDefaultFilterDraft(DEFAULT_DASHBOARD_FILTERS) setSectionVisibilityDraft(getDefaultDashboardSectionVisibility()) @@ -299,6 +309,37 @@ export function SettingsModal({
+
+
+ + + +
+
+ {t('settings.modal.languageTitle')} +
+

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

+
+
+ +
+ {(['de', 'en'] as const).map((nextLanguage) => ( + + ))} +
+
+
diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index c1396b4..cf62311 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -204,28 +204,23 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { ref={triggerRef} type="button" onClick={() => setOpen((prev) => !prev)} - className="flex h-10 w-full items-center justify-between gap-3 rounded-md border border-border bg-background px-3 text-sm text-left transition-colors hover:bg-accent/40" + className="flex h-10 w-full items-center justify-between gap-3 rounded-md border border-border bg-background px-3 pr-14 text-left text-sm transition-colors hover:bg-accent/40" > {value ? formatDate(value, 'long') : label} - - {value && ( - { - event.stopPropagation() - onChange(undefined) - }} - className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" - > - - - )} - - + {value && ( + + )} + {open && typeof document !== 'undefined' && diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index ac05f3b..edb16e4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' +import { useTranslation } from 'react-i18next' import { X } from 'lucide-react' import { cn } from '@/lib/cn' @@ -25,24 +26,32 @@ DialogOverlay.displayName = 'DialogOverlay' const DialogContent = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - - - -)) +>(({ className, children, ...props }, ref) => { + const { t } = useTranslation() + + return ( + + + + {children} + + + + + + ) +}) DialogContent.displayName = 'DialogContent' const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 22ba43e..3d99a21 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -101,6 +101,7 @@ "providersActive": "{{count}} Anbieter aktiv", "modelsActive": "{{count}} Modelle aktiv", "dateFilterActive": "Datumsfilter aktiv", + "clearDate": "{{label}} zurücksetzen", "presets": { "7d": "7T", "30d": "30T", @@ -858,6 +859,8 @@ "modal": { "title": "Einstellungen", "description": "Verwalte App-Backups, gespeicherte Daten und Provider Limits an einem Ort.", + "languageTitle": "Dashboard-Sprache", + "languageDescription": "Lege fest, welche Sprache im Dashboard, in Dialogen und in Reports verwendet wird.", "dataStatus": "Datenstatus", "lastLoaded": "Zuletzt geladen", "loadedVia": "Geladen über", @@ -910,6 +913,29 @@ } } }, + "drillDown": { + "description": "Detaillierte Tagesansicht mit Token-Verteilung, Modellanteilen, Requests und Thinking Tokens.", + "cacheRate": "Cache-Rate", + "tokensPerRequest": "Tokens / Req", + "costPerRequest": "Kosten / Req", + "costRank": "Kosten-Rang", + "requestRank": "Request-Rang", + "topRequestModel": "Dominant nach Requests", + "costVsAverage7d": "Kosten vs. 7T-Ø", + "requestsVsAverage7d": "Requests vs. 7T-Ø", + "tokenDistribution": "Token-Verteilung", + "requestCountShort": "{{count}} Req", + "modelRequestSummary": "{{costPerRequest}}/Req · {{tokensPerRequest}}/Req", + "noRequests": "Keine Requests", + "tokenSegments": { + "cacheRead": "Cache Read", + "cacheWrite": "Cache Write" + } + }, + "chartCard": { + "expandedDescription": "Erweiterte Chart-Ansicht mit Kennzahlenübersicht und optionalem CSV-Export.", + "exportCsv": "CSV exportieren" + }, "limits": { "sectionTitle": "Limits & Subscriptions", "sectionDescription": "Budget-Risiko getrennt von Subscription-Wirkung im aktuellen Filterkontext", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 2b65ddc..6893652 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -101,6 +101,7 @@ "providersActive": "{{count}} providers active", "modelsActive": "{{count}} models active", "dateFilterActive": "Date filter active", + "clearDate": "Clear {{label}}", "presets": { "7d": "7D", "30d": "30D", @@ -858,6 +859,8 @@ "modal": { "title": "Settings", "description": "Manage app backups, stored data, and provider limits in one place.", + "languageTitle": "Dashboard language", + "languageDescription": "Choose the language used in the dashboard UI, dialogs, and reports.", "dataStatus": "Data status", "lastLoaded": "Last loaded", "loadedVia": "Loaded via", @@ -910,6 +913,29 @@ } } }, + "drillDown": { + "description": "Detailed daily view with token distribution, model shares, requests, and thinking tokens.", + "cacheRate": "Cache rate", + "tokensPerRequest": "Tokens / Req", + "costPerRequest": "Cost / Req", + "costRank": "Cost rank", + "requestRank": "Request rank", + "topRequestModel": "Top by requests", + "costVsAverage7d": "Cost vs. 7D avg", + "requestsVsAverage7d": "Requests vs. 7D avg", + "tokenDistribution": "Token distribution", + "requestCountShort": "{{count}} Req", + "modelRequestSummary": "{{costPerRequest}}/Req · {{tokensPerRequest}}/Req", + "noRequests": "No requests", + "tokenSegments": { + "cacheRead": "Cache Read", + "cacheWrite": "Cache Write" + } + }, + "chartCard": { + "expandedDescription": "Expanded chart view with metric summary and optional CSV export.", + "exportCsv": "Export CSV" + }, "limits": { "sectionTitle": "Limits & Subscriptions", "sectionDescription": "Budget risk separated from subscription impact in the current filter context", diff --git a/tests/frontend/filter-bar.test.tsx b/tests/frontend/filter-bar.test.tsx index 4c2f43a..67d1667 100644 --- a/tests/frontend/filter-bar.test.tsx +++ b/tests/frontend/filter-bar.test.tsx @@ -156,4 +156,39 @@ describe('FilterBar', () => { expect(screen.getByRole('button', { name: 'Previous month' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Next month' })).toBeInTheDocument() }) + + it('renders a separate clear button for populated date fields and clears the value', () => { + const onStartDateChange = vi.fn() + const noop = vi.fn() + + render( + , + ) + + const clearButton = screen.getByRole('button', { name: 'Clear Start date' }) + + expect(clearButton).toBeInTheDocument() + fireEvent.click(clearButton) + expect(onStartDateChange).toHaveBeenCalledWith(undefined) + }) }) diff --git a/tests/frontend/header-links.test.tsx b/tests/frontend/header-links.test.tsx index 9e87aa2..a528c15 100644 --- a/tests/frontend/header-links.test.tsx +++ b/tests/frontend/header-links.test.tsx @@ -57,4 +57,12 @@ describe('Header external links', () => { GITHUB_ISSUES_URL, ) }) + + it('labels the shared dialog close button accessibly', () => { + render() + + fireEvent.click(screen.getAllByRole('button', { name: 'Help & shortcuts' })[0]) + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + }) }) diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx index ed2a8a0..233baf6 100644 --- a/tests/frontend/phase4-correctness.test.tsx +++ b/tests/frontend/phase4-correctness.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TodayMetrics } from '@/components/cards/TodayMetrics' +import { ChartCard } from '@/components/charts/ChartCard' import { DrillDownModal } from '@/components/features/drill-down/DrillDownModal' import { TooltipProvider } from '@/components/ui/tooltip' import { initI18n } from '@/lib/i18n' @@ -154,4 +155,67 @@ describe('phase 4 UI correctness', () => { expect(screen.getByText(/\$50\.0k/)).toBeInTheDocument() expect(screen.getByText('Cache Read 10.0%')).toBeInTheDocument() }) + + it('localizes drill-down labels in English', async () => { + await initI18n('en') + + const day: DailyUsage = { + date: '2026-04-07', + inputTokens: 60, + outputTokens: 20, + cacheCreationTokens: 10, + cacheReadTokens: 10, + thinkingTokens: 0, + totalTokens: 100, + totalCost: 5, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 60, + outputTokens: 20, + cacheCreationTokens: 10, + cacheReadTokens: 10, + thinkingTokens: 0, + cost: 5, + requestCount: 2, + }, + ], + } + + render( + + {}} /> + , + ) + + expect( + screen.getByText( + 'Detailed daily view with token distribution, model shares, requests, and thinking tokens.', + ), + ).toBeInTheDocument() + expect(screen.getByText('Token distribution')).toBeInTheDocument() + expect(screen.getByText('Cost rank')).toBeInTheDocument() + }) + + it('localizes expanded chart actions in German', async () => { + await initI18n('de') + + render( + + +
Chart
+
+
, + ) + + fireEvent.click(screen.getByRole('button', { name: /vergrössern/i })) + + expect(screen.getByRole('button', { name: 'CSV exportieren' })).toBeInTheDocument() + }) }) diff --git a/tests/frontend/settings-modal.test.tsx b/tests/frontend/settings-modal.test.tsx new file mode 100644 index 0000000..693e818 --- /dev/null +++ b/tests/frontend/settings-modal.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SettingsModal } from '@/components/features/settings/SettingsModal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +describe('SettingsModal', () => { + beforeEach(async () => { + await initI18n('en') + }) + + it('exposes language controls and saves the selected language', async () => { + const onSaveSettings = vi.fn().mockResolvedValue(undefined) + + render( + + + , + ) + + expect(screen.getByText('Dashboard language')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('settings-language-en')) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(onSaveSettings).toHaveBeenCalledWith( + expect.objectContaining({ + language: 'en', + }), + ) + }) +}) From d69e7482e1ccd2241ca196383bbc51d71c4aaf52 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 22:34:40 +0200 Subject: [PATCH 04/12] v6.1.9: Improve UI information accessibility --- src/components/cards/PrimaryMetrics.tsx | 15 ++- src/components/cards/TodayMetrics.tsx | 10 +- src/components/charts/ChartCard.tsx | 3 +- .../features/heatmap/HeatmapCalendar.tsx | 38 +++++++- src/components/layout/Header.tsx | 2 +- src/components/tables/RecentDays.tsx | 10 +- src/components/ui/expandable-card.tsx | 6 +- src/components/ui/formatted-value.tsx | 30 +++++- src/locales/de/common.json | 37 +++++--- src/locales/en/common.json | 13 ++- tests/e2e/dashboard.spec.ts | 2 +- tests/frontend/chart-card.test.tsx | 12 +++ tests/frontend/expandable-card.test.tsx | 31 +++++++ tests/frontend/formatted-value.test.tsx | 62 +++++++++++++ tests/frontend/heatmap-calendar.test.tsx | 58 ++++++++++++ tests/frontend/phase4-correctness.test.tsx | 93 +++++++++++++++++++ 16 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 tests/frontend/expandable-card.test.tsx create mode 100644 tests/frontend/formatted-value.test.tsx create mode 100644 tests/frontend/heatmap-calendar.test.tsx diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index 82cfd04..ed07c59 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -75,7 +75,11 @@ export function PrimaryMetrics({ })} /> } - subtitle={`Ø ${formatCurrency(metrics.avgDailyCost)}/${periodUnit(viewMode)} · ${formatCurrency(metrics.avgCostPerRequest)}/Req`} + subtitle={t('metricCards.primary.totalCostSubtitle', { + average: formatCurrency(metrics.avgDailyCost), + unit: periodUnit(viewMode), + costPerRequest: formatCurrency(metrics.avgCostPerRequest), + })} icon={} trend={metrics.weekOverWeekChange !== null ? { value: metrics.weekOverWeekChange } : null} info={METRIC_HELP.totalCost} @@ -94,8 +98,13 @@ export function PrimaryMetrics({ } subtitle={ ioRatio - ? `I/O ${ioRatio}:1 · ${formatTokens(metrics.avgTokensPerRequest)} / Request` - : `${formatTokens(metrics.avgTokensPerRequest)} / Request` + ? t('metricCards.primary.totalTokensSubtitleWithRatio', { + ratio: ioRatio, + tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), + }) + : t('metricCards.primary.totalTokensSubtitle', { + tokensPerRequest: formatTokens(metrics.avgTokensPerRequest), + }) } icon={} info={METRIC_HELP.totalTokens} diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index 7a831be..bb76207 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -102,7 +102,11 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { value={today.totalTokens} type="tokens" label={t('metricCards.today.tokensToday')} - insight={`${formatTokens(today.requestCount > 0 ? today.totalTokens / today.requestCount : 0)} / Request`} + insight={t('metricCards.today.tokensInsight', { + value: formatTokens( + today.requestCount > 0 ? today.totalTokens / today.requestCount : 0, + ), + })} /> } icon={} @@ -143,7 +147,9 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { value={today.requestCount} type="number" label={t('metricCards.today.requestsToday')} - insight={`${formatCurrency(today.totalCost / today.requestCount)} / Request`} + insight={t('metricCards.today.requestsInsight', { + value: formatCurrency(today.totalCost / today.requestCount), + })} /> ) : ( t('common.notAvailable') diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index 93d787b..1a71459 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -200,8 +200,9 @@ export function ChartCard({ {renderChildren(false)} {expandable && (
diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index 32b9a2d..6e2baed 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -210,10 +210,10 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
- +
- +
{viewMode === 'daily' && benchmarkMap.get(day.date)?.prevCostDelta !== undefined && ( @@ -229,19 +229,19 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{t('common.input')}
- +
{t('common.output')}
- +
$/1M
- +
diff --git a/src/components/ui/expandable-card.tsx b/src/components/ui/expandable-card.tsx index 89ff069..381833b 100644 --- a/src/components/ui/expandable-card.tsx +++ b/src/components/ui/expandable-card.tsx @@ -27,9 +27,11 @@ export function ExpandableCard({
{children} @@ -44,7 +46,7 @@ export function ExpandableCard({ > {title ?? t('common.expand')} - Expanded card view with additional metrics and full content. + {t('common.expandedCardDescription')}
{stats && stats.length > 0 && ( diff --git a/src/components/ui/formatted-value.tsx b/src/components/ui/formatted-value.tsx index 98df64c..2d5104a 100644 --- a/src/components/ui/formatted-value.tsx +++ b/src/components/ui/formatted-value.tsx @@ -18,6 +18,7 @@ interface FormattedValueProps { decimals?: number // for percent type label?: string insight?: string + interactive?: boolean } // Maps type to abbreviated formatter @@ -43,6 +44,7 @@ export function FormattedValue({ decimals, label, insight, + interactive = true, }: FormattedValueProps) { const abbreviated = FORMATTERS[type](value, decimals) const exact = EXACT_FORMATTERS[type](value) @@ -54,17 +56,39 @@ export function FormattedValue({ return {abbreviated} } + const accessibleLabel = label ? `${label}: ${exact}` : exact + + if (!interactive) { + return ( + + + + {accessibleLabel} + {insight ? `. ${insight}` : ''} + + + ) + } + return ( - {abbreviated} - +
diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 3d99a21..0e413a4 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -20,7 +20,8 @@ "loadedAt": "Zuletzt geladen: {{time}}", "autoLoadActive": "Auto-Load beim Start", "autoLoadAt": "Beim Start automatisch geladen: {{time}}", - "versionLinkTitle": "TTDash v{{version}} auf npm öffnen" + "versionLinkTitle": "TTDash v{{version}} auf npm öffnen", + "streak": "{{count}} Tage in Folge" }, "emptyState": { "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`.", @@ -86,6 +87,7 @@ "focusMonth": "Fokusmonat", "showInfo": "Info anzeigen", "expand": "Vergrössern", + "expandedCardDescription": "Erweiterte Kartenansicht mit zusätzlichen Kennzahlen und vollständigem Inhalt.", "input": "Input", "output": "Output", "cacheWrite": "Cache Write", @@ -118,10 +120,10 @@ }, "dashboard": { "insights": { - "title": "Insights", + "title": "Einblicke", "badge": "Verdichtete Signale", "description": "Konzentrierte Aussagen aus Kosten-, Modell- und Request-Daten", - "quickRead": "Quick Read" + "quickRead": "Kurzfazit" }, "metrics": { "title": "Metriken", @@ -196,12 +198,15 @@ "coverageOfDays": "{{coverage}} Abdeckung von {{days}} Tagen", "providersActive": "{{count}} Anbieter aktiv", "share": "{{value}} Anteil", - "requestLead": "Req-Lead: {{value}}", + "requestLead": "Top bei Requests: {{value}}", "allTokensViaCacheRead": "{{value}} aller Tokens via Cache Read", "requestCountersMissing": "Keine Request-Zähler im Datensatz", "thinkingShareOfVolume": "{{value}} des gesamten Tokenvolumens", - "requestsSubtitle": "Ø {{requests}} / {{unit}} · {{cost}} / Req · σ {{volatility}}", - "thinkingSubtitle": "{{share}} Anteil · {{tokens}} / Request" + "totalCostSubtitle": "Ø {{average}}/{{unit}} · {{costPerRequest}} pro Request", + "totalTokensSubtitle": "{{tokensPerRequest}} pro Request", + "totalTokensSubtitleWithRatio": "Input/Output-Verhältnis {{ratio}}:1 · {{tokensPerRequest}} pro Request", + "requestsSubtitle": "Ø {{requests}} / {{unit}} · {{cost}} pro Request · σ {{volatility}}", + "thinkingSubtitle": "{{share}} Anteil · {{tokens}} pro Request" }, "secondary": { "mostExpensiveDay": "Teuerster Tag", @@ -233,9 +238,11 @@ "cacheShare": "{{value}} Cache-Anteil", "requestsToday": "Requests heute", "avgPerDay": "Ø {{value}}/Tag", - "ioRatio": "I/O Ratio: {{value}}:1", + "ioRatio": "Input/Output-Verhältnis: {{value}}:1", "topModel": "Top: {{value}}", - "requestsSubtitle": "{{value}} / Modell · {{cost}}/Req", + "requestsSubtitle": "{{value}} / Modell · {{cost}} pro Request", + "tokensInsight": "{{value}} pro Request", + "requestsInsight": "{{value}} pro Request", "requestCountersMissing": "Keine Request-Zähler", "thinkingSubtitle": "{{value}} Anteil" }, @@ -255,11 +262,11 @@ "coverage": "{{value}} Abdeckung", "requestsInMonth": "Requests im Monat", "avgPerDay": "Ø {{value}}/Tag", - "ioRatio": "I/O Ratio: {{value}}:1", + "ioRatio": "Input/Output-Verhältnis: {{value}}:1", "topModel": "Top: {{value}}", "cacheMix": "In: {{input}} / Out: {{output}}", - "costPerRequest": "{{value}} / Req", - "requestsSubtitle": "Ø {{value}}/Tag · {{cost}}/Req", + "costPerRequest": "{{value}} pro Request", + "requestsSubtitle": "Ø {{value}}/Tag · {{cost}} pro Request", "requestCountersMissing": "Keine Request-Zähler", "thinkingSubtitle": "{{value}} Anteil" } @@ -382,6 +389,7 @@ "costTitle": "Kosten-Heatmap", "requestsTitle": "Request-Heatmap", "tokensTitle": "Token-Heatmap", + "cellLabel": "{{date}}: {{value}}", "costEmpty": "Kosten-Heatmap nur in der Tagesansicht verfügbar", "requestsEmpty": "Request-Heatmap nur in der Tagesansicht verfügbar", "tokensEmpty": "Token-Heatmap nur in der Tagesansicht verfügbar", @@ -633,10 +641,11 @@ "forecast": "Prognose", "cacheROI": "Cache-ROI", "periodComparison": "Periodenvergleich", - "anomalyDetection": "Anomalie-Erkennung" + "anomalyDetection": "Anomalie-Erkennung", + "cellLabel": "{{date}}: {{value}}" }, "sectionLabels": { - "insights": "Insights", + "insights": "Einblicke", "metrics": "Metriken", "today": "Heute", "currentMonth": "Monat", @@ -786,7 +795,7 @@ "description": "Springt zur Sektion {{section}}" }, "insights": { - "label": "Zu Insights", + "label": "Zu Einblicke", "description": "Springt zur Executive Summary" }, "metrics": { diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 6893652..74c9443 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -20,7 +20,8 @@ "loadedAt": "Last loaded: {{time}}", "autoLoadActive": "Auto-load on start", "autoLoadAt": "Automatically loaded on start: {{time}}", - "versionLinkTitle": "Open TTDash v{{version}} on npm" + "versionLinkTitle": "Open TTDash v{{version}} on npm", + "streak": "{{count}}D streak" }, "emptyState": { "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`.", @@ -86,6 +87,7 @@ "focusMonth": "Focus month", "showInfo": "Show info", "expand": "Expand", + "expandedCardDescription": "Expanded card view with additional metrics and full content.", "input": "Input", "output": "Output", "cacheWrite": "Cache write", @@ -200,6 +202,9 @@ "allTokensViaCacheRead": "{{value}} of all tokens via cache read", "requestCountersMissing": "No request counters in the dataset", "thinkingShareOfVolume": "{{value}} of total token volume", + "totalCostSubtitle": "Avg {{average}}/{{unit}} · {{costPerRequest}} per request", + "totalTokensSubtitle": "{{tokensPerRequest}} per request", + "totalTokensSubtitleWithRatio": "Input/Output {{ratio}}:1 · {{tokensPerRequest}} per request", "requestsSubtitle": "Avg {{requests}} / {{unit}} · {{cost}} / req · σ {{volatility}}", "thinkingSubtitle": "{{share}} share · {{tokens}} / request" }, @@ -236,6 +241,8 @@ "ioRatio": "I/O ratio: {{value}}:1", "topModel": "Top: {{value}}", "requestsSubtitle": "{{value}} / model · {{cost}}/req", + "tokensInsight": "{{value}} per request", + "requestsInsight": "{{value}} per request", "requestCountersMissing": "No request counters", "thinkingSubtitle": "{{value}} share" }, @@ -382,6 +389,7 @@ "costTitle": "Cost heatmap", "requestsTitle": "Request heatmap", "tokensTitle": "Token heatmap", + "cellLabel": "{{date}}: {{value}}", "costEmpty": "Cost heatmap is only available in daily view", "requestsEmpty": "Request heatmap is only available in daily view", "tokensEmpty": "Token heatmap is only available in daily view", @@ -633,7 +641,8 @@ "forecast": "Forecast", "cacheROI": "Cache ROI", "periodComparison": "Period comparison", - "anomalyDetection": "Anomaly detection" + "anomalyDetection": "Anomaly detection", + "cellLabel": "{{date}}: {{value}}" }, "sectionLabels": { "insights": "Insights", diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 3cfd054..e9bbc86 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -104,7 +104,7 @@ test('manages settings and backup imports through the settings dialog using isol }) const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() - await expect(dialog.locator('[data-section-id="insights"]')).toContainText('Insights') + await expect(dialog.locator('[data-section-id="insights"]')).toContainText(/Insights|Einblicke/) await dialog.getByRole('button', { name: monthlySettingsPattern }).click() await dialog.getByRole('button', { name: last30DaysPattern }).click() diff --git a/tests/frontend/chart-card.test.tsx b/tests/frontend/chart-card.test.tsx index 297f3ed..e422a76 100644 --- a/tests/frontend/chart-card.test.tsx +++ b/tests/frontend/chart-card.test.tsx @@ -37,4 +37,16 @@ describe('ChartCard', () => { expect(screen.getByText('Total')).toBeInTheDocument() expect(screen.getByText('Data points')).toBeInTheDocument() }) + + it('reveals the expand control for keyboard focus on desktop', () => { + render( + +
Content
+
, + ) + + const button = screen.getByRole('button', { name: /demo chart expand/i }) + expect(button.className).toContain('md:group-focus-within:opacity-100') + expect(button.className).toContain('focus-visible:opacity-100') + }) }) diff --git a/tests/frontend/expandable-card.test.tsx b/tests/frontend/expandable-card.test.tsx new file mode 100644 index 0000000..0c11de1 --- /dev/null +++ b/tests/frontend/expandable-card.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { ExpandableCard } from '@/components/ui/expandable-card' +import { initI18n } from '@/lib/i18n' + +describe('ExpandableCard', () => { + beforeEach(async () => { + await initI18n('de') + }) + + it('uses a focus-revealed expand control and localized dialog description', () => { + render( + +
Inhalt
+
, + ) + + const button = screen.getByRole('button', { name: /forecast vergrössern/i }) + expect(button.className).toContain('group-focus-within:opacity-100') + + fireEvent.click(button) + + expect( + screen.getByText( + 'Erweiterte Kartenansicht mit zusätzlichen Kennzahlen und vollständigem Inhalt.', + ), + ).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/formatted-value.test.tsx b/tests/frontend/formatted-value.test.tsx new file mode 100644 index 0000000..8bad8a5 --- /dev/null +++ b/tests/frontend/formatted-value.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { FormattedValue } from '@/components/ui/formatted-value' +import { TooltipProvider } from '@/components/ui/tooltip' +import { formatCurrencyExact } from '@/lib/formatters' +import { initI18n } from '@/lib/i18n' + +describe('FormattedValue', () => { + beforeEach(async () => { + await initI18n('en') + }) + + it('renders abbreviated values as focusable tooltip triggers with the exact value', async () => { + render( + + + , + ) + + const trigger = screen.getByRole('button', { + name: `Total cost: ${formatCurrencyExact(5046.25)}`, + }) + + fireEvent.focus(trigger) + + expect(await screen.findAllByText(formatCurrencyExact(5046.25))).toHaveLength(2) + expect(screen.getAllByText('Average spend per active day')).toHaveLength(2) + }) + + it('keeps non-abbreviated values static', () => { + render( + + + , + ) + + expect(screen.getByText('5')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders a non-interactive exact-value fallback inside clickable containers', () => { + render( + , + ) + + const value = screen.getByText('$5.0k') + const wrapper = value.closest('[title]') + + expect(value.tagName).toBe('SPAN') + expect(value.querySelector('button')).toBeNull() + expect(wrapper).toHaveAttribute('title', '$5,046.25') + }) +}) diff --git a/tests/frontend/heatmap-calendar.test.tsx b/tests/frontend/heatmap-calendar.test.tsx new file mode 100644 index 0000000..1aa1f30 --- /dev/null +++ b/tests/frontend/heatmap-calendar.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { HeatmapCalendar } from '@/components/features/heatmap/HeatmapCalendar' +import { TooltipProvider } from '@/components/ui/tooltip' +import { formatCurrency } from '@/lib/formatters' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage } from '@/types' + +describe('HeatmapCalendar', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + + await initI18n('en') + }) + + it('exposes daily cells with keyboard-accessible labels and focus details', () => { + const day: DailyUsage = { + date: '2026-04-07', + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 15, + totalCost: 5, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + } + + const dateLabel = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date('2026-04-07T00:00:00')) + + render( + + + , + ) + + const cell = screen.getByRole('img', { name: `${dateLabel}: ${formatCurrency(5)}` }) + + expect(cell).toHaveAttribute('tabindex', '0') + fireEvent.focus(cell) + expect(screen.getByText(formatCurrency(5))).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx index 233baf6..9539f14 100644 --- a/tests/frontend/phase4-correctness.test.tsx +++ b/tests/frontend/phase4-correctness.test.tsx @@ -3,8 +3,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TodayMetrics } from '@/components/cards/TodayMetrics' +import { PrimaryMetrics } from '@/components/cards/PrimaryMetrics' import { ChartCard } from '@/components/charts/ChartCard' import { DrillDownModal } from '@/components/features/drill-down/DrillDownModal' +import { UsageInsights } from '@/components/features/insights/UsageInsights' +import { Header } from '@/components/layout/Header' import { TooltipProvider } from '@/components/ui/tooltip' import { initI18n } from '@/lib/i18n' import type { DailyUsage, DashboardMetrics } from '@/types' @@ -218,4 +221,94 @@ describe('phase 4 UI correctness', () => { expect(screen.getByRole('button', { name: 'CSV exportieren' })).toBeInTheDocument() }) + + it('uses consistent German terminology on primary information paths', async () => { + await initI18n('de') + + render( + +
+
{}} + onLanguageChange={() => {}} + onToggleTheme={() => {}} + onExportCSV={() => {}} + onDelete={() => {}} + onUpload={() => {}} + onAutoImport={() => {}} + /> + + +
+
, + ) + + expect(screen.getByText('22 Tage in Folge')).toBeInTheDocument() + expect(screen.getByText('Einblicke')).toBeInTheDocument() + expect(screen.getByText('Kurzfazit')).toBeInTheDocument() + expect(document.body.textContent).toContain('Input/Output-Verhältnis') + expect(document.body.textContent).toContain('pro Request') + expect(document.body.textContent).not.toContain('Req-Lead') + expect(document.body.textContent).not.toContain('Quick Read') + expect(document.body.textContent).not.toContain('Streak') + }) }) From c058bd85c91129ba3b4ab775018442955f877c6d Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Mon, 13 Apr 2026 23:15:38 +0200 Subject: [PATCH 05/12] Fix accessibility and localization regressions --- src/components/charts/CorrelationAnalysis.tsx | 20 +- src/components/charts/CustomTooltip.tsx | 17 +- .../charts/DistributionAnalysis.tsx | 20 +- .../features/anomaly/AnomalyDetection.tsx | 22 +- .../features/cache-roi/CacheROI.tsx | 13 +- .../features/comparison/PeriodComparison.tsx | 20 +- .../features/heatmap/HeatmapCalendar.tsx | 20 +- src/components/features/help/HelpPanel.tsx | 64 ++++- src/components/features/help/InfoHeading.tsx | 18 ++ .../request-quality/RequestQuality.tsx | 11 +- .../features/risk/ConcentrationRisk.tsx | 11 +- .../features/settings/SettingsModal.tsx | 9 +- src/components/layout/FilterBar.tsx | 4 +- src/components/tables/ModelEfficiency.tsx | 23 +- src/components/tables/ProviderEfficiency.tsx | 19 +- src/components/tables/RecentDays.tsx | 11 +- src/components/ui/section-header.tsx | 7 +- src/lib/help-content.ts | 32 +-- src/locales/de/common.json | 272 +++++++++--------- src/locales/en/common.json | 16 +- tests/e2e/dashboard.spec.ts | 25 +- tests/frontend/custom-tooltip.test.tsx | 54 ++++ .../frontend/de-analysis-terminology.test.tsx | 81 ++++++ tests/frontend/filter-bar.test.tsx | 31 ++ tests/frontend/help-panel.test.tsx | 45 +++ tests/frontend/info-heading.test.tsx | 77 +++++ tests/frontend/phase4-correctness.test.tsx | 2 +- tests/unit/help-content.test.ts | 23 +- 28 files changed, 697 insertions(+), 270 deletions(-) create mode 100644 src/components/features/help/InfoHeading.tsx create mode 100644 tests/frontend/custom-tooltip.test.tsx create mode 100644 tests/frontend/de-analysis-terminology.test.tsx create mode 100644 tests/frontend/help-panel.test.tsx create mode 100644 tests/frontend/info-heading.test.tsx diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 997d7c5..dc0ee12 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -12,7 +12,7 @@ import { ZAxis, } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { @@ -267,10 +267,11 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { return ( - - {t('charts.correlation.title')} - - + + + {t('charts.correlation.title')} + +
@@ -284,10 +285,11 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { return ( - - {t('charts.correlation.title')} - - + + + {t('charts.correlation.title')} + + @@ -93,9 +98,9 @@ export function CustomTooltip({
- Gesamt: + {totalLabel}: - {formatter ? formatter(total, 'Gesamt') : total} + {formatter ? formatter(total, totalLabel) : total} 100%
@@ -141,20 +146,20 @@ export function CustomTooltip({ {deltaVsPrevious !== null && (
- vs. vorher: + {t('customTooltip.vsPrevious')}: {deltaVsPrevious >= 0 ? '+' : ''} - {formatter ? formatter(deltaVsPrevious, 'Delta') : deltaVsPrevious} + {formatter ? formatter(deltaVsPrevious, deltaLabel) : deltaVsPrevious}
)} {deltaVsAverage !== null && (
- vs. Ø: + {t('customTooltip.vsAverage')}: {deltaVsAverage >= 0 ? '+' : ''} - {formatter ? formatter(deltaVsAverage, 'Delta') : deltaVsAverage} + {formatter ? formatter(deltaVsAverage, deltaLabel) : deltaVsAverage}
)} diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index 7f4f5ee..d3ef43b 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -12,7 +12,7 @@ import { Cell, } from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatNumber, formatTokens, periodLabel } from '@/lib/formatters' @@ -123,10 +123,11 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA return ( - - {t('charts.distribution.title')} - - + + + {t('charts.distribution.title')} + +
@@ -140,10 +141,11 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA return ( - - {t('charts.distribution.title')} - - + + + {t('charts.distribution.title')} + + {distributions.map((distribution, index) => ( diff --git a/src/components/features/anomaly/AnomalyDetection.tsx b/src/components/features/anomaly/AnomalyDetection.tsx index 4d4a8dc..9b55d3b 100644 --- a/src/components/features/anomaly/AnomalyDetection.tsx +++ b/src/components/features/anomaly/AnomalyDetection.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { formatCurrency, formatDate } from '@/lib/formatters' import { computeAnomalies } from '@/lib/calculations' import { CHART_HELP } from '@/lib/help-content' @@ -29,10 +29,11 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma return ( - - {t('anomaly.title', { period: periodLabel(viewMode, true) })} - - + + + {t('anomaly.title', { period: periodLabel(viewMode, true) })} + +
@@ -47,11 +48,12 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma return ( - - - {t('anomaly.title', { period: periodLabel(viewMode, true) })} ({anomalies.length}) - - + + + + {t('anomaly.title', { period: periodLabel(viewMode, true) })} ({anomalies.length}) + +

diff --git a/src/components/features/cache-roi/CacheROI.tsx b/src/components/features/cache-roi/CacheROI.tsx index 2fee727..f57bfea 100644 --- a/src/components/features/cache-roi/CacheROI.tsx +++ b/src/components/features/cache-roi/CacheROI.tsx @@ -6,7 +6,7 @@ import { normalizeModelName } from '@/lib/model-utils' import { MODEL_PRICES } from '@/lib/constants' import { Zap } from 'lucide-react' import { FormattedValue } from '@/components/ui/formatted-value' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_HELP } from '@/lib/help-content' import { periodUnit } from '@/lib/formatters' import type { DailyUsage, ViewMode } from '@/types' @@ -79,11 +79,12 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) { return ( - - - {t('cacheRoi.title')} - - + + + + {t('cacheRoi.title')} + + {heuristicModels.length > 0 && ( diff --git a/src/components/features/comparison/PeriodComparison.tsx b/src/components/features/comparison/PeriodComparison.tsx index 8c412e4..399d2c7 100644 --- a/src/components/features/comparison/PeriodComparison.tsx +++ b/src/components/features/comparison/PeriodComparison.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { InfoButton } from '@/components/features/help/InfoButton' +import { InfoHeading } from '@/components/features/help/InfoHeading' import { CHART_HELP } from '@/lib/help-content' import { formatCurrency, formatTokens, formatPercent } from '@/lib/formatters' import { computeMetrics } from '@/lib/calculations' @@ -105,10 +105,11 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { return ( - - {t('comparison.title')} - - + + + {t('comparison.title')} + +

@@ -168,10 +169,11 @@ export function PeriodComparison({ data }: PeriodComparisonProps) {
- - {t('comparison.title')} - - + + + {t('comparison.title')} + +
+ ))} +
+ + +
+ ) +} diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index 1a71459..0ee400b 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -14,8 +14,11 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/compone import { Maximize2 } from 'lucide-react' import { InfoButton } from '@/components/features/help/InfoButton' import { cn } from '@/lib/cn' +import { buildCsvLine, stringifyCsvCell } from '@/lib/csv' import { formatCurrency } from '@/lib/formatters' +export { stringifyCsvCell } from '@/lib/csv' + interface ChartCardProps { title: string subtitle?: string @@ -30,28 +33,6 @@ interface ChartCardProps { expandedExtra?: ReactNode } -export function stringifyCsvCell(value: unknown): string { - let stringValue = '' - - if (value == null) return '' - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'bigint' - ) { - stringValue = String(value) - } else { - try { - stringValue = JSON.stringify(value) ?? '' - } catch { - stringValue = '' - } - } - - return `"${stringValue.replace(/"/g, '""')}"` -} - export function buildChartCsv(chartData: Record[]): string { if (chartData.length === 0) return '' @@ -60,7 +41,7 @@ export function buildChartCsv(chartData: Record[]): string { const keys = Object.keys(firstRow) return [ - keys.map((key) => stringifyCsvCell(key)).join(','), + buildCsvLine(keys), ...chartData.map((row) => keys.map((key) => stringifyCsvCell(row[key])).join(',')), ].join('\n') } diff --git a/src/hooks/use-app-settings.ts b/src/hooks/use-app-settings.ts index 8c502fd..2cc4f30 100644 --- a/src/hooks/use-app-settings.ts +++ b/src/hooks/use-app-settings.ts @@ -92,5 +92,8 @@ export function useAppSettings(availableProviders: string[]) { saveSettings, isLoading: query.isLoading, isSaving: mutation.isPending, + error: query.error, + isError: query.isError, + hasFetchedAfterMount: query.isFetchedAfterMount, } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 9426abb..55ace58 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -35,7 +35,9 @@ async function readErrorMessage(response: Response, fallback: string): Promise { const res = await fetch('/api/usage') - if (!res.ok) throw new Error(i18n.t('api.fetchUsageFailed')) + if (!res.ok) { + throw new Error(await readErrorMessage(res, i18n.t('api.fetchUsageFailed'))) + } return parseResponseJson(res) } @@ -79,7 +81,9 @@ export interface UpdateSettingsRequest { export async function fetchSettings(): Promise { const res = await fetch('/api/settings') - if (!res.ok) throw new Error('Failed to load settings') + if (!res.ok) { + throw new Error(await readErrorMessage(res, i18n.t('api.fetchSettingsFailed'))) + } return normalizeAppSettings(await parseResponseJson(res)) } @@ -90,11 +94,21 @@ export async function updateSettings(patch: UpdateSettingsRequest): Promise(res)) } +export async function deleteSettings(): Promise { + const res = await fetch('/api/settings', { method: 'DELETE' }) + if (!res.ok) { + throw new Error(await readErrorMessage(res, i18n.t('api.deleteSettingsFailed'))) + } + + const payload = await parseResponseJson<{ settings?: unknown }>(res) + return normalizeAppSettings(payload.settings) +} + export async function importSettings(data: unknown): Promise { const res = await fetch('/api/settings/import', { method: 'POST', diff --git a/src/lib/csv-export.ts b/src/lib/csv-export.ts index daea2b0..d98f44b 100644 --- a/src/lib/csv-export.ts +++ b/src/lib/csv-export.ts @@ -1,16 +1,38 @@ import type { DailyUsage } from '@/types' import { normalizeModelName } from './model-utils' import { localToday } from './formatters' +import { buildCsvLine } from './csv' export function generateCSV(data: DailyUsage[]): string { - const header = - 'date,totalCost,totalTokens,inputTokens,outputTokens,cacheCreationTokens,cacheReadTokens,thinkingTokens,requestCount,models' + const header = buildCsvLine([ + 'date', + 'totalCost', + 'totalTokens', + 'inputTokens', + 'outputTokens', + 'cacheCreationTokens', + 'cacheReadTokens', + 'thinkingTokens', + 'requestCount', + 'models', + ]) const rows = data.map((d) => { const models = d.modelBreakdowns .map((mb) => normalizeModelName(mb.modelName)) .filter((v, i, a) => a.indexOf(v) === i) .join('; ') - return `${d.date},${d.totalCost.toFixed(2)},${d.totalTokens},${d.inputTokens},${d.outputTokens},${d.cacheCreationTokens},${d.cacheReadTokens},${d.thinkingTokens},${d.requestCount},"${models}"` + return buildCsvLine([ + d.date, + d.totalCost.toFixed(2), + d.totalTokens, + d.inputTokens, + d.outputTokens, + d.cacheCreationTokens, + d.cacheReadTokens, + d.thinkingTokens, + d.requestCount, + models, + ]) }) return [header, ...rows].join('\n') } diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..fc1c1b9 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,26 @@ +export function stringifyCsvCell(value: unknown): string { + let stringValue = '' + + if (value == null) return '""' + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + stringValue = String(value) + } else { + try { + stringValue = JSON.stringify(value) ?? '' + } catch { + stringValue = '' + } + } + + return `"${stringValue.replace(/"/g, '""')}"` +} + +export function buildCsvLine(values: unknown[]): string { + return values.map((value) => stringifyCsvCell(value)).join(',') +} diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 6ed394b..c821acf 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -30,6 +30,18 @@ "openSettings": "Einstellungen & Backups", "or": "oder" }, + "loadError": { + "title": "Lokaler App-Zustand konnte nicht geladen werden", + "settingsDescription": "Die lokalen Einstellungen konnten nicht geladen werden. Setze sie zurück oder lade den Zustand erneut.", + "usageDescription": "Die gespeicherten Nutzungsdaten konnten nicht geladen werden. Entferne die defekten Daten oder lade den Zustand erneut.", + "multipleDescription": "TTDash hat Probleme sowohl in den lokalen Einstellungen als auch in den gespeicherten Nutzungsdaten gefunden. Setze die betroffenen Daten zurück und lade den Zustand erneut.", + "details": "Details", + "retry": "Erneut laden", + "resetSettings": "Einstellungen zurücksetzen", + "deleteData": "Gespeicherte Daten löschen", + "settingsCorrupted": "Die lokale Einstellungsdatei ist unlesbar oder beschädigt.", + "usageCorrupted": "Die lokale Nutzungsdatei ist unlesbar oder beschädigt." + }, "viewModes": { "daily": "Tagesansicht", "monthly": "Monatsansicht", @@ -1107,10 +1119,13 @@ }, "api": { "fetchUsageFailed": "Fehler beim Laden der Daten", + "fetchSettingsFailed": "Fehler beim Laden der Einstellungen", "uploadFailed": "Upload fehlgeschlagen", "deleteFailed": "Löschen fehlgeschlagen", + "deleteSettingsFailed": "Einstellungen konnten nicht zurückgesetzt werden", "importUsageFailed": "Datenimport fehlgeschlagen", "importSettingsFailed": "Einstellungs-Import fehlgeschlagen", + "saveSettingsFailed": "Einstellungen konnten nicht gespeichert werden", "pdfFailed": "PDF-Generierung fehlgeschlagen" }, "toasts": { @@ -1123,6 +1138,7 @@ "dataExported": "Daten-Backup exportiert", "noDataToExport": "Keine Daten zum Exportieren vorhanden", "settingsImported": "Einstellungen aus {{name}} importiert", + "settingsReset": "Einstellungen zurückgesetzt", "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 7d1082d..933fde7 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -30,6 +30,18 @@ "openSettings": "Settings & backups", "or": "or" }, + "loadError": { + "title": "Could not load local app state", + "settingsDescription": "The local settings could not be loaded. Reset them or retry the load.", + "usageDescription": "The stored usage data could not be loaded. Remove the broken data or retry the load.", + "multipleDescription": "TTDash found problems in both local settings and stored usage data. Reset the affected state and retry the load.", + "details": "Details", + "retry": "Retry load", + "resetSettings": "Reset settings", + "deleteData": "Delete stored data", + "settingsCorrupted": "The local settings file is unreadable or corrupted.", + "usageCorrupted": "The local usage data file is unreadable or corrupted." + }, "viewModes": { "daily": "Daily view", "monthly": "Monthly view", @@ -1107,10 +1119,13 @@ }, "api": { "fetchUsageFailed": "Failed to load data", + "fetchSettingsFailed": "Failed to load settings", "uploadFailed": "Upload failed", "deleteFailed": "Delete failed", + "deleteSettingsFailed": "Failed to reset settings", "importUsageFailed": "Data import failed", "importSettingsFailed": "Settings import failed", + "saveSettingsFailed": "Failed to save settings", "pdfFailed": "PDF generation failed" }, "toasts": { @@ -1123,6 +1138,7 @@ "dataExported": "Data backup exported", "noDataToExport": "No data available to export", "settingsImported": "Imported settings from {{name}}", + "settingsReset": "Settings reset", "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/main.tsx b/src/main.tsx index e34d04d..ad0996b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,26 +3,54 @@ import { createRoot } from 'react-dom/client' import { App } from './App' import { DEFAULT_APP_SETTINGS, applyTheme, normalizeAppSettings } from './lib/app-settings' import { initI18n } from './lib/i18n' +import type { AppSettings } from './types' import './index.css' +interface InitialSettingsLoadResult { + settings: AppSettings + errorMessage: string | null +} + +async function readErrorMessage(response: Response): Promise { + try { + const payload = (await response.json()) as { message?: string } + return typeof payload.message === 'string' && payload.message.trim() ? payload.message : null + } catch { + return null + } +} + async function loadInitialSettings() { try { const res = await fetch('/api/settings') - if (!res.ok) return DEFAULT_APP_SETTINGS - return normalizeAppSettings(await res.json()) + if (!res.ok) { + return { + settings: DEFAULT_APP_SETTINGS, + errorMessage: await readErrorMessage(res), + } satisfies InitialSettingsLoadResult + } + + return { + settings: normalizeAppSettings(await res.json()), + errorMessage: null, + } satisfies InitialSettingsLoadResult } catch { - return DEFAULT_APP_SETTINGS + return { + settings: DEFAULT_APP_SETTINGS, + errorMessage: null, + } satisfies InitialSettingsLoadResult } } async function bootstrap() { - const initialSettings = await loadInitialSettings() + const { settings: initialSettings, errorMessage: initialSettingsError } = + await loadInitialSettings() applyTheme(initialSettings.theme) await initI18n(initialSettings.language) createRoot(document.getElementById('root')!).render( - + , ) } diff --git a/tests/frontend/dashboard-error-state.test.tsx b/tests/frontend/dashboard-error-state.test.tsx new file mode 100644 index 0000000..263b030 --- /dev/null +++ b/tests/frontend/dashboard-error-state.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom + +import type { ReactNode } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Dashboard } from '@/components/Dashboard' +import { ToastProvider } from '@/components/ui/toast' +import { DEFAULT_APP_SETTINGS } from '@/lib/app-settings' +import { initI18n } from '@/lib/i18n' + +const usageHookMocks = vi.hoisted(() => ({ + useUsageData: vi.fn(), + useUploadData: vi.fn(), + useDeleteData: vi.fn(), +})) + +const settingsHookMocks = vi.hoisted(() => ({ + useAppSettings: vi.fn(), +})) + +const apiMocks = vi.hoisted(() => ({ + deleteSettings: vi.fn(), + generatePdfReport: vi.fn(), + importSettings: vi.fn(), + importUsageData: vi.fn(), +})) + +vi.mock('@/hooks/use-usage-data', () => usageHookMocks) +vi.mock('@/hooks/use-app-settings', () => settingsHookMocks) +vi.mock('@/lib/api', () => apiMocks) + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } +} + +describe('Dashboard fatal load state', () => { + beforeEach(async () => { + await initI18n('en') + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + + usageHookMocks.useUploadData.mockReturnValue({ + mutateAsync: vi.fn(), + }) + usageHookMocks.useDeleteData.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + }) + settingsHookMocks.useAppSettings.mockReturnValue({ + settings: { + ...DEFAULT_APP_SETTINGS, + language: 'en', + }, + providerLimits: {}, + setTheme: vi.fn(), + setLanguage: vi.fn(), + saveSettings: vi.fn(), + isSaving: false, + isLoading: false, + error: null, + isError: false, + hasFetchedAfterMount: false, + }) + apiMocks.deleteSettings.mockResolvedValue(DEFAULT_APP_SETTINGS) + apiMocks.generatePdfReport.mockResolvedValue(new Blob()) + apiMocks.importSettings.mockResolvedValue(DEFAULT_APP_SETTINGS) + apiMocks.importUsageData.mockResolvedValue({ + importedDays: 0, + addedDays: 0, + unchangedDays: 0, + conflictingDays: 0, + totalDays: 0, + }) + }) + + it('renders a fatal settings error state instead of the normal empty state and resets settings', async () => { + usageHookMocks.useUsageData.mockReturnValue({ + data: { + daily: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + }, + isLoading: false, + error: null, + }) + + render(, { + wrapper: createWrapper(), + }) + + expect( + screen.getByRole('heading', { name: 'Could not load local app state' }), + ).toBeInTheDocument() + expect( + screen.getByText('The local settings file is unreadable or corrupted.'), + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Upload file' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Reset settings' })) + + await waitFor(() => expect(apiMocks.deleteSettings).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(screen.getByText('Settings reset')).toBeInTheDocument()) + }) + + it('renders a fatal usage error state with a delete action for corrupted stored data', async () => { + const mutateAsync = vi.fn().mockResolvedValue(undefined) + + usageHookMocks.useUsageData.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Usage data file is unreadable or corrupted.'), + }) + usageHookMocks.useDeleteData.mockReturnValue({ + mutateAsync, + }) + + render(, { + wrapper: createWrapper(), + }) + + expect( + screen.getByText('The local usage data file is unreadable or corrupted.'), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Delete stored data' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Settings & backups' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Delete stored data' })) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/tests/frontend/help-panel.test.tsx b/tests/frontend/help-panel.test.tsx index ad2f5b4..d413e89 100644 --- a/tests/frontend/help-panel.test.tsx +++ b/tests/frontend/help-panel.test.tsx @@ -25,7 +25,7 @@ describe('HelpPanel', () => { expect(screen.queryByText('providerLimitProgress')).not.toBeInTheDocument() expect(screen.queryByText('providerSubscriptionMix')).not.toBeInTheDocument() expect(screen.queryByText('providerLimitTimeline')).not.toBeInTheDocument() - }) + }, 10_000) it('uses consistent German terminology for request and limit surfaces', async () => { await initI18n('de') diff --git a/tests/frontend/settings-modal.test.tsx b/tests/frontend/settings-modal.test.tsx index 693e818..9bab5a3 100644 --- a/tests/frontend/settings-modal.test.tsx +++ b/tests/frontend/settings-modal.test.tsx @@ -78,5 +78,5 @@ describe('SettingsModal', () => { language: 'en', }), ) - }) + }, 10_000) }) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index e0a4e28..bc30354 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1031,6 +1031,7 @@ describe('local server API', () => { (entries) => entries.length === 2 && [firstUrl, secondUrl].every((url) => entries.some((entry) => entry.url === url)), + 30_000, ) expect(registry).toHaveLength(2) @@ -1039,7 +1040,7 @@ describe('local server API', () => { await stopAllBackgroundServers(backgroundEnv, backgroundRoot) rmSync(backgroundRoot, { recursive: true, force: true }) } - }, 45_000) + }, 60_000) it('prunes stale background entries that point to a live non-matching process', async () => { const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-stale-test-')) @@ -1252,4 +1253,33 @@ describe('local server API', () => { rmSync(runtimeRoot, { recursive: true, force: true }) } }) + + it('warns clearly when binding the server on a non-loopback host', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-remote-bind-test-')) + + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + envOverrides: { + HOST: '0.0.0.0', + NO_OPEN_BROWSER: '1', + }, + }) + + expect(standaloneServer.getOutput()).toContain('Host: 0.0.0.0') + expect(standaloneServer.getOutput()).toContain( + 'Exposure: network-accessible via 0.0.0.0', + ) + expect(standaloneServer.getOutput()).toContain( + 'Security warning: this bind host can expose local data and destructive API routes.', + ) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) }) diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts new file mode 100644 index 0000000..2e0725a --- /dev/null +++ b/tests/unit/api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchSettings, updateSettings } from '@/lib/api' +import { initI18n } from '@/lib/i18n' + +describe('api error handling', () => { + beforeEach(async () => { + await initI18n('en') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the localized fallback when settings fetch fails without a server message', async () => { + await initI18n('de') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{}', { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(fetchSettings()).rejects.toThrow('Fehler beim Laden der Einstellungen') + }) + + it('uses the localized fallback when saving settings fails without a server message', async () => { + await initI18n('en') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{}', { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(updateSettings({ theme: 'light' })).rejects.toThrow('Failed to save settings') + }) + + it('prefers the server-provided settings error message over the localized fallback', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Settings file is unreadable or corrupted.' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(fetchSettings()).rejects.toThrow('Settings file is unreadable or corrupted.') + }) +}) diff --git a/tests/unit/csv-export.test.ts b/tests/unit/csv-export.test.ts new file mode 100644 index 0000000..cbd7bfe --- /dev/null +++ b/tests/unit/csv-export.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { generateCSV } from '@/lib/csv-export' +import { buildCsvLine, stringifyCsvCell } from '@/lib/csv' +import type { DailyUsage } from '@/types' + +function createDay(modelName: string): DailyUsage { + return { + date: '2026-04-13', + inputTokens: 10, + outputTokens: 20, + cacheCreationTokens: 5, + cacheReadTokens: 15, + thinkingTokens: 0, + totalTokens: 50, + totalCost: 1.23, + requestCount: 2, + modelsUsed: [modelName], + modelBreakdowns: [ + { + modelName, + inputTokens: 10, + outputTokens: 20, + cacheCreationTokens: 5, + cacheReadTokens: 15, + thinkingTokens: 0, + cost: 1.23, + requestCount: 2, + }, + ], + } +} + +describe('csv export helpers', () => { + it('escapes quotes, commas, and newlines in CSV cells', () => { + expect(stringifyCsvCell('alpha "beta",gamma')).toBe('"alpha ""beta"",gamma"') + expect(stringifyCsvCell('first line\nsecond line')).toBe('"first line\nsecond line"') + }) + + it('builds quoted CSV lines consistently', () => { + expect(buildCsvLine(['model', 'cost', 1.23])).toBe('"model","cost","1.23"') + }) + + it('escapes normalized model names in the dashboard CSV export', () => { + const csv = generateCSV([createDay('gpt-4 "test"')]) + + expect(csv).toContain('"models"') + expect(csv).toContain('"GPT-4 ""test"""') + }) +}) From 6e60354c516c03c2bcdf3ed36e48b06397b605cf Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 00:07:43 +0200 Subject: [PATCH 07/12] Harden local API security boundaries --- README.md | 19 ++- SECURITY.md | 12 ++ server.js | 139 +++++++++++++++- src/lib/auto-import.ts | 196 +++++++++++++++++------ tests/frontend/heatmap-calendar.test.tsx | 4 +- tests/integration/server.test.ts | 151 ++++++++++++++++- tests/unit/auto-import.test.ts | 116 +++++++++++++- 7 files changed, 573 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 080326d..a141527 100644 --- a/README.md +++ b/README.md @@ -177,13 +177,20 @@ Commands: Environment variables: -| Variable | Description | -| ------------------- | --------------------------------------------------------- | -| `PORT` | Override the start port | -| `NO_OPEN_BROWSER=1` | Disable browser auto-open | -| `HOST` | Override the bind host, for example `HOST=0.0.0.0 ttdash` | +| Variable | Description | +| ----------------------- | --------------------------------------------------------- | +| `PORT` | Override the start port | +| `NO_OPEN_BROWSER=1` | Disable browser auto-open | +| `HOST` | Override the bind host, for example `HOST=0.0.0.0 ttdash` | +| `TTDASH_ALLOW_REMOTE=1` | Explicitly allow binding to a non-loopback host | -Binding to a non-loopback host such as `0.0.0.0` exposes the local dashboard API to your network, including destructive routes for local data and settings resets. Only use this on trusted networks. +Binding to a non-loopback host such as `0.0.0.0` exposes the local dashboard API to your network, including destructive routes for local data and settings resets. TTDash now refuses that bind unless you also set `TTDASH_ALLOW_REMOTE=1`. Only use this on trusted networks. + +Example: + +```bash +TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash +``` ## Features diff --git a/SECURITY.md b/SECURITY.md index 30c1a8f..92f86fe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,3 +26,15 @@ Please include: ## Response Expectations This project is maintained on a best-effort basis by a single maintainer. Reports will be reviewed as quickly as practical, but no fixed response SLA is promised. + +## Deployment Notes + +`TTDash` is intended to run as a local-first app on loopback by default. Binding it to a non-loopback host exposes local API routes for uploads, imports, resets, and report generation to your network. + +Non-loopback binding therefore requires an explicit opt-in: + +```bash +TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash +``` + +Only use that mode on trusted networks. diff --git a/server.js b/server.js index df8ca41..646111a 100755 --- a/server.js +++ b/server.js @@ -24,9 +24,12 @@ 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 = Math.min(START_PORT + 100, 65535); const BIND_HOST = process.env.HOST || '127.0.0.1'; +const ALLOW_REMOTE_BIND = process.env.TTDASH_ALLOW_REMOTE === '1'; const API_PREFIX = '/port/5000/api'; const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB const IS_WINDOWS = process.platform === 'win32'; +const SECURE_DIR_MODE = 0o700; +const SECURE_FILE_MODE = 0o600; const TOKTRACK_LOCAL_BIN = path.join( ROOT, 'node_modules', @@ -130,6 +133,7 @@ function printHelp() { console.log(' PORT=3010 ttdash'); console.log(' NO_OPEN_BROWSER=1 ttdash'); console.log(' HOST=127.0.0.1 ttdash'); + console.log(' TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash'); } function parseCliArgs(rawArgs) { @@ -291,7 +295,11 @@ const MIME_TYPES = { }; function ensureDir(dirPath) { - fs.mkdirSync(dirPath, { recursive: true }); + const existed = fs.existsSync(dirPath); + fs.mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE }); + if (!IS_WINDOWS && !existed) { + fs.chmodSync(dirPath, SECURE_DIR_MODE); + } } function ensureAppDirs() { @@ -305,10 +313,31 @@ function ensureAppDirs() { function writeJsonAtomic(filePath, data) { ensureDir(path.dirname(filePath)); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { + mode: SECURE_FILE_MODE, + }); + if (!IS_WINDOWS) { + fs.chmodSync(tempPath, SECURE_FILE_MODE); + } fs.renameSync(tempPath, filePath); } +function isLoopbackHost(host) { + return host === '127.0.0.1' || host === 'localhost' || host === '::1'; +} + +function ensureBindHostAllowed() { + if (isLoopbackHost(BIND_HOST) || ALLOW_REMOTE_BIND) { + return; + } + + const error = new Error( + `Refusing to bind TTDash to non-loopback host "${BIND_HOST}" without TTDASH_ALLOW_REMOTE=1.`, + ); + error.code = 'REMOTE_BIND_REQUIRES_OPT_IN'; + throw error; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -474,7 +503,7 @@ async function withBackgroundInstancesLock( while (true) { try { - fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR); + fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR, { mode: SECURE_DIR_MODE }); break; } catch (error) { if (!error || error.code !== 'EEXIST') { @@ -754,11 +783,12 @@ function shouldBackgroundChildOpenBrowser() { } async function startInBackground() { + ensureBindHostAllowed(); ensureAppDirs(); const logFile = buildBackgroundLogFilePath(); const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background'); - const logFd = fs.openSync(logFile, 'a'); + const logFd = fs.openSync(logFile, 'a', SECURE_FILE_MODE); let child; try { @@ -1275,7 +1305,7 @@ function printStartupSummary(url, port) { const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled'; const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled'; const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground'; - const remoteBind = BIND_HOST !== '127.0.0.1' && BIND_HOST !== 'localhost' && BIND_HOST !== '::1'; + const remoteBind = !isLoopbackHost(BIND_HOST); console.log(''); console.log(`${APP_LABEL} v${APP_VERSION} is ready`); @@ -1314,6 +1344,7 @@ function printStartupSummary(url, port) { console.log(' ttdash --background'); console.log(' ttdash stop'); console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`); + console.log(` TTDASH_ALLOW_REMOTE=1 HOST=${BIND_HOST} PORT=${port} node server.js`); console.log(` curl ${url}/api/usage`); console.log(''); } @@ -1553,6 +1584,64 @@ function resolveApiPath(pathname) { return null; } +function getHeaderValue(req, name) { + const value = req.headers[name]; + if (Array.isArray(value)) { + return value[0] || ''; + } + return typeof value === 'string' ? value : ''; +} + +function hasJsonContentType(req) { + const contentType = getHeaderValue(req, 'content-type'); + if (!contentType) { + return false; + } + + return contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json'; +} + +function hasTrustedOrigin(req) { + const originHeader = getHeaderValue(req, 'origin').trim(); + if (!originHeader) { + return true; + } + + const hostHeader = getHeaderValue(req, 'host').trim(); + if (!hostHeader || originHeader === 'null') { + return false; + } + + try { + const origin = new URL(originHeader); + return origin.host === hostHeader; + } catch { + return false; + } +} + +function isCrossSiteFetch(req) { + return getHeaderValue(req, 'sec-fetch-site').trim().toLowerCase() === 'cross-site'; +} + +function validateMutationRequest(req, { requiresJsonContentType = false } = {}) { + if (isCrossSiteFetch(req) || !hasTrustedOrigin(req)) { + return { + status: 403, + message: 'Cross-site requests are not allowed', + }; + } + + if (requiresJsonContentType && !hasJsonContentType(req)) { + return { + status: 415, + message: 'Content-Type must be application/json', + }; + } + + return null; +} + // --- SSE helpers --- function sendSSE(res, event, data) { @@ -1815,6 +1904,10 @@ const server = http.createServer(async (req, res) => { ); } if (req.method === 'DELETE') { + const validationError = validateMutationRequest(req); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } try { fs.unlinkSync(DATA_FILE); } catch { @@ -1854,6 +1947,10 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'DELETE') { + const validationError = validateMutationRequest(req); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } try { fs.unlinkSync(SETTINGS_FILE); } catch { @@ -1863,6 +1960,10 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'PATCH') { + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } try { const body = await readBody(req); return json(res, 200, updateSettings(body)); @@ -1882,6 +1983,11 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + try { const body = await readBody(req); const importedSettings = normalizeSettings(extractSettingsImportPayload(body)); @@ -1897,6 +2003,11 @@ const server = http.createServer(async (req, res) => { if (apiPath === '/upload') { if (req.method === 'POST') { + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + try { const body = await readBody(req); const normalized = normalizeIncomingData(body); @@ -1921,6 +2032,11 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + try { const body = await readBody(req); const importedData = normalizeIncomingData(extractUsageImportPayload(body)); @@ -1941,10 +2057,15 @@ const server = http.createServer(async (req, res) => { } if (apiPath === '/auto-import/stream') { - if (req.method !== 'GET') { + if (req.method !== 'POST') { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -2004,6 +2125,11 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + const validationError = validateMutationRequest(req, { requiresJsonContentType: true }); + if (validationError) { + return json(res, validationError.status, { message: validationError.message }); + } + let data; try { data = readData(); @@ -2118,6 +2244,7 @@ function tryListen(port) { } async function start() { + ensureBindHostAllowed(); ensureAppDirs(); migrateLegacyDataFile(); diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 79a9ec2..1011a53 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -42,24 +42,29 @@ export interface ErrorMessage { type AutoImportTranslationVars = Record type AutoImportTranslator = (key: string, vars?: AutoImportTranslationVars) => string +type StreamEventType = 'check' | 'progress' | 'stderr' | 'success' | 'error' | 'done' function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export function parseEventData(event: Event): T | null { - if (!(event instanceof MessageEvent) || typeof event.data !== 'string') { - return null - } - +function parseJsonRecord(value: string): T | null { try { - const data: unknown = JSON.parse(event.data) + const data: unknown = JSON.parse(value) return isPlainObject(data) ? (data as T) : null } catch { return null } } +export function parseEventData(event: Event): T | null { + if (!(event instanceof MessageEvent) || typeof event.data !== 'string') { + return null + } + + return parseJsonRecord(event.data) +} + export function translateAutoImportEvent(event: AutoImportMessageEvent, t: AutoImportTranslator) { switch (event.key) { case 'startingLocalImport': @@ -98,56 +103,155 @@ export function startAutoImport( }, t: AutoImportTranslator = (key) => key, ): { close: () => void } { - const es = new EventSource('/api/auto-import/stream') + const controller = new AbortController() + const decoder = new TextDecoder() + let done = false - es.addEventListener('check', (event) => { - const data = parseEventData(event) - if (data) { - callbacks.onCheck(data) - } - }) - es.addEventListener('progress', (event) => { - const data = parseEventData(event) - if (data) { - callbacks.onProgress({ - ...data, - message: translateAutoImportEvent(data, t), - }) + const finish = () => { + if (done) return + done = true + callbacks.onDone() + } + + const dispatchEvent = (type: StreamEventType, dataText: string) => { + switch (type) { + case 'check': { + const data = parseJsonRecord(dataText) + if (data) callbacks.onCheck(data) + return + } + case 'progress': { + const data = parseJsonRecord(dataText) + if (data) { + callbacks.onProgress({ + ...data, + message: translateAutoImportEvent(data, t), + }) + } + return + } + case 'stderr': { + const data = parseJsonRecord(dataText) + if (data) callbacks.onStderr(data) + return + } + case 'success': { + const data = parseJsonRecord(dataText) + if (data) callbacks.onSuccess(data) + return + } + case 'error': { + const data = parseJsonRecord(dataText) + if (data) { + callbacks.onError({ + message: translateAutoImportEvent(data, t), + }) + } else { + callbacks.onError({ message: t('autoImportModal.serverConnectionLost') }) + } + return + } + case 'done': + finish() } - }) - es.addEventListener('stderr', (event) => { - const data = parseEventData(event) - if (data) { - callbacks.onStderr(data) + } + + const flushEvent = (type: string, dataLines: string[]) => { + if (dataLines.length === 0) { + return } - }) - es.addEventListener('success', (event) => { - const data = parseEventData(event) - if (data) { - callbacks.onSuccess(data) + + const normalizedType = (type || 'message') as StreamEventType + dispatchEvent(normalizedType, dataLines.join('\n')) + } + + const readStream = async () => { + const response = await fetch('/api/auto-import/stream', { + method: 'POST', + signal: controller.signal, + }) + + if (!response.ok) { + let message = t('autoImportModal.serverConnectionLost') + + try { + const payload: unknown = await response.json() + if ( + isPlainObject(payload) && + typeof payload['message'] === 'string' && + payload['message'].trim() + ) { + message = payload['message'] + } + } catch {} + + callbacks.onError({ message }) + finish() + return } - }) - es.addEventListener('error', (event) => { - // SSE 'error' can be both our custom event and a connection error - const data = parseEventData(event) - if (data) { - callbacks.onError({ - message: translateAutoImportEvent(data, t), - }) - } else { + + if (!response.body) { callbacks.onError({ message: t('autoImportModal.serverConnectionLost') }) - es.close() - callbacks.onDone() + finish() + return } - }) - es.addEventListener('done', () => { - es.close() - callbacks.onDone() + + const reader = response.body.getReader() + let buffer = '' + let currentEvent = '' + let dataLines: string[] = [] + + while (true) { + const { value, done: streamDone } = await reader.read() + if (streamDone) { + flushEvent(currentEvent, dataLines) + finish() + return + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line) { + flushEvent(currentEvent, dataLines) + currentEvent = '' + dataLines = [] + continue + } + + if (line.startsWith('event:')) { + currentEvent = line.slice('event:'.length).trim() + continue + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()) + } + } + } + } + + void readStream().catch((error) => { + if (controller.signal.aborted) { + finish() + return + } + + callbacks.onError({ + message: + error instanceof Error && error.message + ? error.message + : t('autoImportModal.serverConnectionLost'), + }) + finish() }) return { close: () => { - es.close() + controller.abort() + finish() }, } } diff --git a/tests/frontend/heatmap-calendar.test.tsx b/tests/frontend/heatmap-calendar.test.tsx index 1aa1f30..e610ab9 100644 --- a/tests/frontend/heatmap-calendar.test.tsx +++ b/tests/frontend/heatmap-calendar.test.tsx @@ -22,7 +22,7 @@ describe('HeatmapCalendar', () => { await initI18n('en') }) - it('exposes daily cells with keyboard-accessible labels and focus details', () => { + it('exposes daily cells with keyboard-accessible labels and focus details', async () => { const day: DailyUsage = { date: '2026-04-07', inputTokens: 10, @@ -53,6 +53,6 @@ describe('HeatmapCalendar', () => { expect(cell).toHaveAttribute('tabindex', '0') fireEvent.focus(cell) - expect(screen.getByText(formatCurrency(5))).toBeInTheDocument() + expect(await screen.findByText(formatCurrency(5))).toBeInTheDocument() }) }) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index bc30354..16ae237 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,6 +1,14 @@ import { createConnection, createServer } from 'node:net' import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -19,6 +27,11 @@ const hasTypst = (() => { return !result.error && result.status === 0 })() const itIfTypst = hasTypst ? it : it.skip +const itIfPosix = process.platform === 'win32' ? it.skip : it + +function permissionBits(targetPath: string) { + return statSync(targetPath).mode & 0o777 +} async function getFreePort() { return new Promise((resolve, reject) => { @@ -562,6 +575,119 @@ describe('local server API', () => { expect(finalSettings.sectionOrder.slice(0, 3)).toEqual(['metrics', 'insights', 'today']) }) + it('rejects cross-site mutation requests, enforces JSON bodies, and blocks auto-import GET requests', async () => { + const wrongContentTypeResponse = await fetch(`${baseUrl}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: JSON.stringify(sampleUsage), + }) + expect(wrongContentTypeResponse.status).toBe(415) + expect(await wrongContentTypeResponse.json()).toEqual({ + message: 'Content-Type must be application/json', + }) + + const crossSiteUploadResponse = await fetch(`${baseUrl}/api/upload`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://evil.example', + }, + body: JSON.stringify(sampleUsage), + }) + expect(crossSiteUploadResponse.status).toBe(403) + expect(await crossSiteUploadResponse.json()).toEqual({ + message: 'Cross-site requests are not allowed', + }) + + const crossSiteDeleteResponse = await fetch(`${baseUrl}/api/usage`, { + method: 'DELETE', + headers: { + Origin: 'https://evil.example', + }, + }) + expect(crossSiteDeleteResponse.status).toBe(403) + expect(await crossSiteDeleteResponse.json()).toEqual({ + message: 'Cross-site requests are not allowed', + }) + + const autoImportGetResponse = await fetch(`${baseUrl}/api/auto-import/stream`) + expect(autoImportGetResponse.status).toBe(405) + expect(await autoImportGetResponse.json()).toEqual({ + message: 'Method Not Allowed', + }) + }) + + it('streams auto-import events over POST instead of mutating via GET', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-auto-import-post-test-')) + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + envOverrides: { + PATH: '', + }, + }) + + const streamResponse = await fetch(`${standaloneServer.url}/api/auto-import/stream`, { + method: 'POST', + }) + + expect(streamResponse.status).toBe(200) + expect(streamResponse.headers.get('content-type')).toContain('text/event-stream') + + const streamBody = await streamResponse.text() + expect(streamBody).toContain('event: check') + expect(streamBody).toContain('event: progress') + expect(streamBody).toContain('event: error') + expect(streamBody).toContain('event: done') + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + + itIfPosix('writes persisted data and settings with restrictive local permissions', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-permissions-test-')) + const dataFile = path.join(getCliDataDir(runtimeRoot), 'data.json') + const settingsFile = path.join(getCliConfigDir(runtimeRoot), 'settings.json') + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + }) + + const uploadResponse = await fetch(`${standaloneServer.url}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sampleUsage), + }) + expect(uploadResponse.status).toBe(200) + + const settingsResponse = await fetch(`${standaloneServer.url}/api/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + language: 'en', + }), + }) + expect(settingsResponse.status).toBe(200) + + expect(permissionBits(path.dirname(dataFile))).toBe(0o700) + expect(permissionBits(path.dirname(settingsFile))).toBe(0o700) + expect(permissionBits(dataFile)).toBe(0o600) + expect(permissionBits(settingsFile)).toBe(0o600) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + it('imports settings backups and merges usage backups without overwriting conflicting local days', async () => { const seedResponse = await fetch(`${baseUrl}/api/upload`, { method: 'POST', @@ -1254,8 +1380,28 @@ describe('local server API', () => { } }) - it('warns clearly when binding the server on a non-loopback host', async () => { + it('refuses non-loopback binding unless remote access is explicitly allowed', async () => { const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-remote-bind-test-')) + try { + const result = await runCli([], { + env: { + ...createCliEnv(runtimeRoot), + HOST: '0.0.0.0', + NO_OPEN_BROWSER: '1', + }, + }) + + expect(result.code).toBe(1) + expect(result.output).toContain( + 'Refusing to bind TTDash to non-loopback host "0.0.0.0" without TTDASH_ALLOW_REMOTE=1.', + ) + } finally { + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + + it('warns clearly when binding the server on a non-loopback host with explicit opt-in', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-remote-bind-allowed-test-')) let standaloneServer: Awaited> | null = null @@ -1265,6 +1411,7 @@ describe('local server API', () => { envOverrides: { HOST: '0.0.0.0', NO_OPEN_BROWSER: '1', + TTDASH_ALLOW_REMOTE: '1', }, }) diff --git a/tests/unit/auto-import.test.ts b/tests/unit/auto-import.test.ts index 3564851..707376e 100644 --- a/tests/unit/auto-import.test.ts +++ b/tests/unit/auto-import.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { translateAutoImportEvent } from '@/lib/auto-import' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { startAutoImport, translateAutoImportEvent } from '@/lib/auto-import' const translations = { 'autoImportModal.startingLocalImport': 'Starte lokalen toktrack-Import...', @@ -60,3 +60,115 @@ describe('translateAutoImportEvent', () => { ).toBe('Fehler: toktrack failed') }) }) + +describe('startAutoImport', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('starts auto-import via POST and dispatches streamed events', async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + [ + 'event: check', + 'data: {"tool":"toktrack","status":"checking"}', + '', + 'event: progress', + 'data: {"key":"startingLocalImport","vars":{}}', + '', + 'event: stderr', + 'data: {"line":"runner output"}', + '', + 'event: success', + 'data: {"days":3,"totalCost":4.5}', + '', + 'event: done', + 'data: {}', + '', + ].join('\n'), + ), + ) + controller.close() + }, + }) + + const fetchMock = vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const callbacks = { + onCheck: vi.fn(), + onProgress: vi.fn(), + onStderr: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + onDone: vi.fn(), + } + + startAutoImport(callbacks, translate) + + await vi.waitFor(() => { + expect(callbacks.onDone).toHaveBeenCalledTimes(1) + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/auto-import/stream', + expect.objectContaining({ + method: 'POST', + }), + ) + expect(callbacks.onCheck).toHaveBeenCalledWith({ + tool: 'toktrack', + status: 'checking', + }) + expect(callbacks.onProgress).toHaveBeenCalledWith({ + key: 'startingLocalImport', + vars: {}, + message: 'Starte lokalen toktrack-Import...', + }) + expect(callbacks.onStderr).toHaveBeenCalledWith({ line: 'runner output' }) + expect(callbacks.onSuccess).toHaveBeenCalledWith({ days: 3, totalCost: 4.5 }) + expect(callbacks.onError).not.toHaveBeenCalled() + }) + + it('surfaces structured server errors when the POST request is rejected', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Cross-site requests are not allowed' }), { + status: 403, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const callbacks = { + onCheck: vi.fn(), + onProgress: vi.fn(), + onStderr: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + onDone: vi.fn(), + } + + startAutoImport(callbacks, translate) + + await vi.waitFor(() => { + expect(callbacks.onDone).toHaveBeenCalledTimes(1) + }) + + expect(callbacks.onError).toHaveBeenCalledWith({ + message: 'Cross-site requests are not allowed', + }) + expect(callbacks.onSuccess).not.toHaveBeenCalled() + }) +}) From cc425cd9e1fb54a421f10b8d7bdf98f5d154ddff Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 00:35:23 +0200 Subject: [PATCH 08/12] Refactor dashboard and server architecture --- docs/architecture.md | 67 + package.json | 1 + server.js | 254 +--- server/http-utils.js | 163 +++ server/report/utils.js | 469 +------ server/runtime.js | 72 + shared/dashboard-domain.d.ts | 19 + shared/dashboard-domain.js | 603 +++++++++ shared/dashboard-preferences.json | 43 + src/components/Dashboard.tsx | 1161 ++--------------- .../dashboard/DashboardSections.tsx | 439 +++++++ src/hooks/use-dashboard-controller.ts | 717 ++++++++++ src/lib/api.ts | 30 +- src/lib/calculations.ts | 280 +--- src/lib/dashboard-preferences.ts | 38 +- src/lib/data-transforms.ts | 121 +- src/lib/model-utils.ts | 178 +-- src/main.tsx | 42 +- tests/unit/api.test.ts | 21 +- vitest.config.ts | 7 +- 20 files changed, 2375 insertions(+), 2350 deletions(-) create mode 100644 docs/architecture.md create mode 100644 server/http-utils.js create mode 100644 server/runtime.js create mode 100644 shared/dashboard-domain.d.ts create mode 100644 shared/dashboard-domain.js create mode 100644 shared/dashboard-preferences.json create mode 100644 src/components/dashboard/DashboardSections.tsx create mode 100644 src/hooks/use-dashboard-controller.ts diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..79afc96 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,67 @@ +# Architecture Overview + +## System Shape + +TTDash is a local-first product with two runtime parts: + +- a Node-based CLI and HTTP server exposed through `server.js` +- a Vite/React frontend bundled into `dist/` + +The server owns local persistence, background instance management, auto-import execution and the HTTP API. The frontend owns interaction, filtering, visualization and user-driven import/export flows. + +## Architectural Boundaries + +### Shared Domain + +`shared/dashboard-domain.js` is the common source of truth for model normalization, provider resolution, filter application, aggregation and core dashboard metrics. It is used by: + +- frontend data transforms and calculations in `src/lib/*` +- server-side PDF report generation in `server/report/*` + +This boundary exists to keep dashboard and report output aligned for the same underlying data. + +### Frontend Page Composition + +The dashboard page is split into: + +- `src/hooks/use-dashboard-controller.ts` for query orchestration, local UI state and user actions +- `src/components/dashboard/DashboardSections.tsx` for section rendering and layout composition +- `src/components/Dashboard.tsx` as the thin page shell + +The shell is responsible for state branching only: + +- loading +- fatal local-state error +- empty state +- main dashboard + +### Settings Contract + +Dashboard preferences are driven by `shared/dashboard-preferences.json`, which is consumed by both: + +- `src/lib/dashboard-preferences.ts` +- `server.js` + +Frontend settings normalization lives in `src/lib/app-settings.ts`. Bootstrap loading is centralized in `src/lib/api.ts` through `loadBootstrapSettings()`. + +## Current Server Structure + +`server.js` is still the public entrypoint and still owns several runtime responsibilities. The current refactor reduces contract drift and shared-domain duplication first, while keeping the published CLI interface stable. Further modularization of the server runtime should continue from the current seams: + +- runtime/bootstrap +- persistence/settings +- background instance lifecycle +- HTTP helpers and route handlers +- auto-import execution + +## Release and Packaging + +The npm package ships: + +- `server.js` +- `server/` +- `shared/` +- `dist/` +- `src/locales/` + +`shared/` is published because the report layer depends on the same domain logic as the frontend bundle. diff --git a/package.json b/package.json index db46d4d..ca1a7f9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "server.js", "usage-normalizer.js", "server/", + "shared/", "src/locales/", "dist/" ], diff --git a/server.js b/server.js index 646111a..f5c9460 100755 --- a/server.js +++ b/server.js @@ -11,6 +11,13 @@ const { parseArgs } = require('util'); const { normalizeIncomingData } = require('./usage-normalizer'); const { generatePdfReport } = require('./server/report'); const { version: APP_VERSION } = require('./package.json'); +const dashboardPreferences = require('./shared/dashboard-preferences.json'); +const { createHttpUtils } = require('./server/http-utils'); +const { + ensureBindHostAllowed, + isLoopbackHost, + listenOnAvailablePort, +} = require('./server/runtime'); const ROOT = __dirname; const STATIC_ROOT = path.join(ROOT, 'dist'); @@ -50,22 +57,8 @@ const USAGE_BACKUP_KIND = 'ttdash-usage-backup'; 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 DASHBOARD_DATE_PRESETS = dashboardPreferences.datePresets; +const DASHBOARD_SECTION_IDS = dashboardPreferences.sectionDefinitions.map((section) => section.id); const DEFAULT_SETTINGS = { language: 'de', theme: 'dark', @@ -322,22 +315,6 @@ function writeJsonAtomic(filePath, data) { fs.renameSync(tempPath, filePath); } -function isLoopbackHost(host) { - return host === '127.0.0.1' || host === 'localhost' || host === '::1'; -} - -function ensureBindHostAllowed() { - if (isLoopbackHost(BIND_HOST) || ALLOW_REMOTE_BIND) { - return; - } - - const error = new Error( - `Refusing to bind TTDash to non-loopback host "${BIND_HOST}" without TTDASH_ALLOW_REMOTE=1.`, - ); - error.code = 'REMOTE_BIND_REQUIRES_OPT_IN'; - throw error; -} - function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -783,7 +760,7 @@ function shouldBackgroundChildOpenBrowser() { } async function startInBackground() { - ensureBindHostAllowed(); + ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND); ensureAppDirs(); const logFile = buildBackgroundLogFilePath(); @@ -1487,160 +1464,11 @@ function clearDataLoadState() { writeSettings(next); return toSettingsResponse(next); } - -function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - let totalSize = 0; - let settled = false; - - const cleanup = () => { - req.off('data', onData); - req.off('end', onEnd); - req.off('error', onError); - }; - - const rejectOnce = (error) => { - if (settled) { - return; - } - settled = true; - cleanup(); - reject(error); - }; - - const resolveOnce = (value) => { - if (settled) { - return; - } - settled = true; - cleanup(); - resolve(value); - }; - - const onData = (c) => { - totalSize += c.length; - if (totalSize > MAX_BODY_SIZE) { - const error = new Error('Payload too large'); - error.code = 'PAYLOAD_TOO_LARGE'; - rejectOnce(error); - req.resume(); - return; - } - chunks.push(c); - }; - - const onEnd = () => { - try { - resolveOnce(JSON.parse(Buffer.concat(chunks).toString())); - } catch (e) { - rejectOnce(e); - } - }; - - const onError = (error) => { - if (settled && error && error.code === 'ECONNRESET') { - return; - } - rejectOnce(error); - }; - - req.on('data', onData); - req.on('end', onEnd); - req.on('error', onError); - }); -} - -function json(res, status, data) { - res.writeHead(status, { - 'Content-Type': 'application/json; charset=utf-8', - ...SECURITY_HEADERS, - }); - res.end(JSON.stringify(data)); -} - -function sendBuffer(res, status, headers, buffer) { - res.writeHead(status, { - 'Content-Length': buffer.length, - ...headers, - ...SECURITY_HEADERS, - }); - res.end(buffer); -} - -function resolveApiPath(pathname) { - if (pathname.startsWith(API_PREFIX + '/')) { - return pathname.slice(API_PREFIX.length); - } - if (pathname === API_PREFIX) { - return '/'; - } - if (pathname.startsWith('/api/')) { - return pathname.slice(4); - } - if (pathname === '/api') { - return '/'; - } - return null; -} - -function getHeaderValue(req, name) { - const value = req.headers[name]; - if (Array.isArray(value)) { - return value[0] || ''; - } - return typeof value === 'string' ? value : ''; -} - -function hasJsonContentType(req) { - const contentType = getHeaderValue(req, 'content-type'); - if (!contentType) { - return false; - } - - return contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json'; -} - -function hasTrustedOrigin(req) { - const originHeader = getHeaderValue(req, 'origin').trim(); - if (!originHeader) { - return true; - } - - const hostHeader = getHeaderValue(req, 'host').trim(); - if (!hostHeader || originHeader === 'null') { - return false; - } - - try { - const origin = new URL(originHeader); - return origin.host === hostHeader; - } catch { - return false; - } -} - -function isCrossSiteFetch(req) { - return getHeaderValue(req, 'sec-fetch-site').trim().toLowerCase() === 'cross-site'; -} - -function validateMutationRequest(req, { requiresJsonContentType = false } = {}) { - if (isCrossSiteFetch(req) || !hasTrustedOrigin(req)) { - return { - status: 403, - message: 'Cross-site requests are not allowed', - }; - } - - if (requiresJsonContentType && !hasJsonContentType(req)) { - return { - status: 415, - message: 'Content-Type must be application/json', - }; - } - - return null; -} +const { json, readBody, resolveApiPath, sendBuffer, validateMutationRequest } = createHttpUtils({ + apiPrefix: API_PREFIX, + maxBodySize: MAX_BODY_SIZE, + securityHeaders: SECURITY_HEADERS, +}); // --- SSE helpers --- @@ -2189,62 +2017,12 @@ const server = http.createServer(async (req, res) => { serveFile(res, filePath); }); -function createNoFreePortError(rangeStartPort, maxPort) { - return new Error(`No free port found (${rangeStartPort}-${maxPort})`); -} - -async function listenOnAvailablePort( - serverInstance, - port, - maxPort, - bindHost, - log = console.log, - rangeStartPort = port, -) { - if (port > maxPort) { - throw createNoFreePortError(rangeStartPort, maxPort); - } - - for (let currentPort = port; currentPort <= maxPort; currentPort += 1) { - try { - await new Promise((resolve, reject) => { - const onError = (err) => { - serverInstance.off('listening', onListening); - reject(err); - }; - - const onListening = () => { - serverInstance.off('error', onError); - resolve(); - }; - - serverInstance.once('error', onError); - serverInstance.once('listening', onListening); - serverInstance.listen(currentPort, bindHost); - }); - - return currentPort; - } catch (err) { - if (err && err.code === 'EADDRINUSE') { - if (currentPort >= maxPort) { - throw createNoFreePortError(rangeStartPort, maxPort); - } - log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`); - continue; - } - throw err; - } - } - - throw createNoFreePortError(rangeStartPort, maxPort); -} - function tryListen(port) { return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT); } async function start() { - ensureBindHostAllowed(); + ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND); ensureAppDirs(); migrateLegacyDataFile(); diff --git a/server/http-utils.js b/server/http-utils.js new file mode 100644 index 0000000..cf0241e --- /dev/null +++ b/server/http-utils.js @@ -0,0 +1,163 @@ +function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) { + function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let totalSize = 0; + let settled = false; + + const cleanup = () => { + req.off('data', onData); + req.off('end', onEnd); + req.off('error', onError); + }; + + const rejectOnce = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + const resolveOnce = (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + + const onData = (chunk) => { + totalSize += chunk.length; + if (totalSize > maxBodySize) { + const error = new Error('Payload too large'); + error.code = 'PAYLOAD_TOO_LARGE'; + rejectOnce(error); + req.resume(); + return; + } + chunks.push(chunk); + }; + + const onEnd = () => { + try { + resolveOnce(JSON.parse(Buffer.concat(chunks).toString())); + } catch (error) { + rejectOnce(error); + } + }; + + const onError = (error) => { + if (settled && error && error.code === 'ECONNRESET') { + return; + } + rejectOnce(error); + }; + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + }); + } + + function json(res, status, data) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + ...securityHeaders, + }); + res.end(JSON.stringify(data)); + } + + function sendBuffer(res, status, headers, buffer) { + res.writeHead(status, { + 'Content-Length': buffer.length, + ...headers, + ...securityHeaders, + }); + res.end(buffer); + } + + function resolveApiPath(pathname) { + if (pathname.startsWith(apiPrefix + '/')) { + return pathname.slice(apiPrefix.length); + } + if (pathname === apiPrefix) { + return '/'; + } + if (pathname.startsWith('/api/')) { + return pathname.slice(4); + } + if (pathname === '/api') { + return '/'; + } + return null; + } + + function getHeaderValue(req, name) { + const value = req.headers[name]; + if (Array.isArray(value)) { + return value[0] || ''; + } + return typeof value === 'string' ? value : ''; + } + + function hasJsonContentType(req) { + const contentType = getHeaderValue(req, 'content-type'); + if (!contentType) { + return false; + } + + return contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json'; + } + + function hasTrustedOrigin(req) { + const originHeader = getHeaderValue(req, 'origin').trim(); + if (!originHeader) { + return true; + } + + const hostHeader = getHeaderValue(req, 'host').trim(); + if (!hostHeader || originHeader === 'null') { + return false; + } + + try { + const origin = new URL(originHeader); + return origin.host === hostHeader; + } catch { + return false; + } + } + + function isCrossSiteFetch(req) { + return getHeaderValue(req, 'sec-fetch-site').trim().toLowerCase() === 'cross-site'; + } + + function validateMutationRequest(req, { requiresJsonContentType = false } = {}) { + if (isCrossSiteFetch(req) || !hasTrustedOrigin(req)) { + return { + status: 403, + message: 'Cross-site requests are not allowed', + }; + } + + if (requiresJsonContentType && !hasJsonContentType(req)) { + return { + status: 415, + message: 'Content-Type must be application/json', + }; + } + + return null; + } + + return { + readBody, + json, + sendBuffer, + resolveApiPath, + validateMutationRequest, + }; +} + +module.exports = { + createHttpUtils, +}; diff --git a/server/report/utils.js b/server/report/utils.js index 4aaa303..14c545c 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -1,15 +1,17 @@ const { version: APP_VERSION } = require('../../package.json'); const { getLanguage, getLocale, translate } = require('./i18n'); -const modelNormalizationSpec = require('../model-normalization.json'); - -const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ - ...alias, - matcher: new RegExp(alias.pattern, 'i'), -})); -const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ - ...matcher, - matcher: new RegExp(matcher.pattern, 'i'), -})); +const { + aggregateToDailyFormat, + computeMetrics, + computeMovingAverage, + filterByDateRange, + filterByModels, + filterByMonth, + filterByProviders, + getModelProvider, + normalizeModelName, + sortByDate, +} = require('../../shared/dashboard-domain'); const MODEL_COLORS = { 'Opus 4.6': 'rgb(175, 92, 224)', @@ -26,300 +28,10 @@ const MODEL_COLORS = { const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; -function titleCaseSegment(segment) { - if (!segment) return segment; - if (/^\d+([.-]\d+)*$/.test(segment)) return segment.replace(/-/g, '.'); - if (/^[a-z]{1,4}\d+$/i.test(segment)) return segment.toUpperCase(); - return segment.charAt(0).toUpperCase() + segment.slice(1); -} - -function capitalize(segment) { - if (!segment) return ''; - return segment.charAt(0).toUpperCase() + segment.slice(1); -} - -function formatVersion(version) { - return version.replace(/-/g, '.'); -} - -function canonicalizeModelName(raw) { - const normalized = String(raw || '') - .trim() - .toLowerCase() - .replace(/^model[:/ -]*/i, '') - .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') - .replace(/\./g, '-') - .replace(/[_/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-|-$/g, ''); - - const suffixStart = normalized.lastIndexOf('-'); - if (suffixStart > 0) { - const suffix = normalized.slice(suffixStart + 1); - if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { - return normalized.slice(0, suffixStart); - } - } - - return normalized; -} - -function parseClaudeName(rest) { - const parts = rest.split('-', 2); - if (parts.length < 2) { - return `Claude ${capitalize(rest)}`; - } - - return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim(); -} - -function parseGptName(rest) { - const parts = rest.split('-'); - const variant = parts[0] || ''; - const minor = parts[1] || ''; - - if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { - const version = `${variant}.${minor}`; - if (parts.length > 2) { - const suffix = parts.slice(2).map(capitalize).join(' '); - return `GPT-${version}${suffix ? ` ${suffix}` : ''}`; - } - return `GPT-${version}`; - } - - if (parts.length > 1) { - const suffix = parts.slice(1).map(capitalize).join(' '); - return `GPT-${variant}${suffix ? ` ${suffix}` : ''}`; - } - - return `GPT-${rest}`; -} - -function parseGeminiName(rest) { - const parts = rest.split('-'); - if (parts.length < 2) { - return `Gemini ${rest}`; - } - - const versionParts = []; - const tierParts = []; - - for (const part of parts) { - if (/^\d+$/.test(part) && tierParts.length === 0) { - versionParts.push(part); - } else { - tierParts.push(capitalize(part)); - } - } - - const version = versionParts.join('.'); - const tier = tierParts.join(' '); - - return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}`; -} - -function parseCodexName(rest) { - const normalized = rest.replace(/-latest$/i, ''); - if (!normalized) { - return 'Codex'; - } - return `Codex ${normalized.split('-').map(capitalize).join(' ')}`; -} - -function parseOSeries(name) { - const separatorIndex = name.indexOf('-'); - if (separatorIndex === -1) { - return name; - } - return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`; -} - -function normalizeModelName(raw) { - const canonical = canonicalizeModelName(raw); - - for (const alias of DISPLAY_ALIASES) { - if (alias.matcher.test(canonical)) return alias.name; - } - - if (canonical.startsWith('claude-')) { - return parseClaudeName(canonical.slice('claude-'.length)); - } - - if (canonical.startsWith('gpt-')) { - return parseGptName(canonical.slice('gpt-'.length)); - } - - if (canonical.startsWith('gemini-')) { - return parseGeminiName(canonical.slice('gemini-'.length)); - } - - if (canonical.startsWith('codex-')) { - return parseCodexName(canonical.slice('codex-'.length)); - } - - if (/^o\d/i.test(canonical)) { - return parseOSeries(canonical); - } - - const familyMatch = canonical.match( - /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, - ); - if (familyMatch) { - const family = familyMatch[1]; - if (/^codex$/i.test(family)) { - return parseCodexName(familyMatch[2] || ''); - } - if (/^(o\d)$/i.test(family)) return parseOSeries(canonical); - - const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : ''; - if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`; - return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim(); - } - - return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || ''); -} - -function getModelProvider(raw) { - const canonical = canonicalizeModelName(raw); - for (const matcher of PROVIDER_MATCHERS) { - if (matcher.matcher.test(canonical)) return matcher.provider; - } - return 'Other'; -} - function getModelColor(name) { return MODEL_COLORS[name] || 'rgb(113, 128, 150)'; } -function sortByDate(data) { - return [...data].sort((a, b) => a.date.localeCompare(b.date)); -} - -function filterByDateRange(data, start, end) { - return data.filter((day) => { - if (start && day.date < start) return false; - if (end && day.date > end) return false; - return true; - }); -} - -function filterByMonth(data, month) { - if (!month) return data; - return data.filter((day) => day.date.startsWith(month)); -} - -function recalculateDayFromBreakdowns(day, modelBreakdowns) { - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let thinkingTokens = 0; - let totalCost = 0; - let requestCount = 0; - - for (const breakdown of modelBreakdowns) { - inputTokens += breakdown.inputTokens; - outputTokens += breakdown.outputTokens; - cacheCreationTokens += breakdown.cacheCreationTokens; - cacheReadTokens += breakdown.cacheReadTokens; - thinkingTokens += breakdown.thinkingTokens; - totalCost += breakdown.cost; - requestCount += breakdown.requestCount; - } - - return { - ...day, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - thinkingTokens, - totalCost, - requestCount, - totalTokens: - inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, - modelsUsed: modelBreakdowns.map((item) => item.modelName), - modelBreakdowns, - }; -} - -function filterByProviders(data, selectedProviders) { - if (!selectedProviders || selectedProviders.length === 0) return data; - const selected = new Set(selectedProviders); - return data - .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => - selected.has(getModelProvider(entry.modelName)), - ); - return filteredBreakdowns.length > 0 - ? recalculateDayFromBreakdowns(day, filteredBreakdowns) - : null; - }) - .filter(Boolean); -} - -function filterByModels(data, selectedModels) { - if (!selectedModels || selectedModels.length === 0) return data; - const selected = new Set(selectedModels); - return data - .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => - selected.has(normalizeModelName(entry.modelName)), - ); - return filteredBreakdowns.length > 0 - ? recalculateDayFromBreakdowns(day, filteredBreakdowns) - : null; - }) - .filter(Boolean); -} - -function aggregateToDailyFormat(data, viewMode) { - if (viewMode === 'daily') return data; - const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4); - const groups = new Map(); - - for (const day of data) { - const key = groupKey(day.date); - const existing = groups.get(key); - const days = day._aggregatedDays || 1; - - if (!existing) { - groups.set(key, { - ...day, - date: key, - _aggregatedDays: days, - }); - continue; - } - - existing.totalCost += day.totalCost; - existing.totalTokens += day.totalTokens; - existing.inputTokens += day.inputTokens; - existing.outputTokens += day.outputTokens; - existing.cacheCreationTokens += day.cacheCreationTokens; - existing.cacheReadTokens += day.cacheReadTokens; - existing.thinkingTokens += day.thinkingTokens; - existing.requestCount += day.requestCount; - existing._aggregatedDays += days; - existing.modelBreakdowns = existing.modelBreakdowns.concat(day.modelBreakdowns); - existing.modelsUsed = Array.from(new Set(existing.modelsUsed.concat(day.modelsUsed))); - } - - return Array.from(groups.values()).sort((a, b) => a.date.localeCompare(b.date)); -} - -function computeMovingAverage(values, window = 7) { - const result = new Array(values.length); - let sum = 0; - for (let index = 0; index < values.length; index += 1) { - sum += values[index]; - if (index >= window) sum -= values[index - window]; - result[index] = index < window - 1 ? null : sum / window; - } - return result; -} - function toCostChartData(data) { const sorted = sortByDate(data); const ma7 = computeMovingAverage(sorted.map((day) => day.totalCost)); @@ -362,163 +74,6 @@ function toWeekdayData(data) { }); } -function stdDev(values) { - if (!values.length) return 0; - const mean = values.reduce((sum, value) => sum + value, 0) / values.length; - const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length; - return Math.sqrt(variance); -} - -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); - const prev7 = sorted.slice(-14, -7); - const lastSum = last7.reduce((sum, day) => sum + day.totalCost, 0); - const prevSum = prev7.reduce((sum, day) => sum + day.totalCost, 0); - if (prevSum === 0) return null; - return ((lastSum - prevSum) / prevSum) * 100; -} - -function computeBusiestWeek(data) { - const sorted = sortByDate(data).filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)); - if (sorted.length < 3) return null; - let best = null; - for (let start = 0; start < sorted.length; start += 1) { - const startDate = new Date(`${sorted[start].date}T00:00:00`); - const endLimit = new Date(startDate); - endLimit.setDate(endLimit.getDate() + 6); - let cost = 0; - let end = start; - while (end < sorted.length && new Date(`${sorted[end].date}T00:00:00`) <= endLimit) { - cost += sorted[end].totalCost; - end += 1; - } - if (!best || cost > best.cost) { - best = { start: sorted[start].date, end: sorted[end - 1].date, cost }; - } - } - return best; -} - -function computeMetrics(data) { - if (data.length === 0) { - return { - totalCost: 0, - totalTokens: 0, - activeDays: 0, - totalRequests: 0, - hasRequestData: false, - avgDailyCost: 0, - avgRequestsPerDay: 0, - avgTokensPerRequest: 0, - avgCostPerRequest: 0, - cacheHitRate: 0, - costPerMillion: 0, - topModel: null, - topModelShare: 0, - topProvider: null, - topDay: null, - cheapestDay: null, - busiestWeek: null, - weekendCostShare: null, - weekOverWeekChange: null, - requestVolatility: 0, - providerCount: 0, - }; - } - - const modelCosts = new Map(); - const providerCosts = new Map(); - let totalCost = 0; - let totalTokens = 0; - let totalRequests = 0; - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheCreate = 0; - let totalThinking = 0; - let activeDays = 0; - let hasRequestData = false; - let weekendCost = 0; - let weekendEligible = 0; - let topDay = { date: data[0].date, cost: data[0].totalCost }; - let cheapestDay = { date: data[0].date, cost: data[0].totalCost }; - - for (const day of data) { - totalCost += day.totalCost; - totalTokens += day.totalTokens; - totalRequests += day.requestCount; - totalInput += day.inputTokens; - totalOutput += day.outputTokens; - totalCacheRead += day.cacheReadTokens; - totalCacheCreate += day.cacheCreationTokens; - totalThinking += day.thinkingTokens; - activeDays += day._aggregatedDays || 1; - if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) - hasRequestData = true; - if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost }; - if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost }; - - if (/^\d{4}-\d{2}-\d{2}$/.test(day.date)) { - const weekday = new Date(`${day.date}T00:00:00`).getDay(); - if (weekday === 0 || weekday === 6) weekendCost += day.totalCost; - weekendEligible += day.totalCost; - } - - for (const breakdown of day.modelBreakdowns) { - const model = normalizeModelName(breakdown.modelName); - const provider = getModelProvider(breakdown.modelName); - modelCosts.set(model, (modelCosts.get(model) || 0) + breakdown.cost); - providerCosts.set(provider, (providerCosts.get(provider) || 0) + breakdown.cost); - } - } - - let topModel = null; - for (const [name, cost] of modelCosts) { - if (!topModel || cost > topModel.cost) topModel = { name, cost }; - } - - let topProvider = null; - for (const [name, cost] of providerCosts) { - if (!topProvider || cost > topProvider.cost) { - topProvider = { name, cost, share: totalCost > 0 ? (cost / totalCost) * 100 : 0 }; - } - } - - const cacheBase = totalCacheRead + totalCacheCreate + totalInput + totalOutput + totalThinking; - - return { - totalCost, - totalTokens, - activeDays, - totalRequests, - totalInput, - totalOutput, - totalCacheRead, - totalCacheCreate, - totalThinking, - hasRequestData, - avgDailyCost: activeDays > 0 ? totalCost / activeDays : 0, - avgRequestsPerDay: hasRequestData && activeDays > 0 ? totalRequests / activeDays : 0, - avgTokensPerRequest: hasRequestData && totalRequests > 0 ? totalTokens / totalRequests : 0, - avgCostPerRequest: hasRequestData && totalRequests > 0 ? totalCost / totalRequests : 0, - cacheHitRate: cacheBase > 0 ? (totalCacheRead / cacheBase) * 100 : 0, - costPerMillion: totalTokens > 0 ? totalCost / (totalTokens / 1000000) : 0, - topModel, - topModelShare: topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0, - topProvider, - topDay, - cheapestDay, - busiestWeek: computeBusiestWeek(data), - weekendCostShare: weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null, - weekOverWeekChange: computeWeekOverWeekChange(data), - requestVolatility: stdDev(data.map((item) => item.requestCount)), - providerCount: providerCosts.size, - }; -} - function computeModelRows(data) { const rows = new Map(); for (const day of data) { diff --git a/server/runtime.js b/server/runtime.js new file mode 100644 index 0000000..f39e71c --- /dev/null +++ b/server/runtime.js @@ -0,0 +1,72 @@ +function isLoopbackHost(host) { + return host === '127.0.0.1' || host === 'localhost' || host === '::1'; +} + +function ensureBindHostAllowed(bindHost, allowRemoteBind) { + if (isLoopbackHost(bindHost) || allowRemoteBind) { + return; + } + + const error = new Error( + `Refusing to bind TTDash to non-loopback host "${bindHost}" without TTDASH_ALLOW_REMOTE=1.`, + ); + error.code = 'REMOTE_BIND_REQUIRES_OPT_IN'; + throw error; +} + +function createNoFreePortError(rangeStartPort, maxPort) { + return new Error(`No free port found (${rangeStartPort}-${maxPort})`); +} + +async function listenOnAvailablePort( + serverInstance, + port, + maxPort, + bindHost, + log = console.log, + rangeStartPort = port, +) { + if (port > maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); + } + + for (let currentPort = port; currentPort <= maxPort; currentPort += 1) { + try { + await new Promise((resolve, reject) => { + const onError = (error) => { + serverInstance.off('listening', onListening); + reject(error); + }; + + const onListening = () => { + serverInstance.off('error', onError); + resolve(); + }; + + serverInstance.once('error', onError); + serverInstance.once('listening', onListening); + serverInstance.listen(currentPort, bindHost); + }); + + return currentPort; + } catch (error) { + if (error && error.code === 'EADDRINUSE') { + if (currentPort >= maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); + } + log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`); + continue; + } + throw error; + } + } + + throw createNoFreePortError(rangeStartPort, maxPort); +} + +module.exports = { + createNoFreePortError, + ensureBindHostAllowed, + isLoopbackHost, + listenOnAvailablePort, +}; diff --git a/shared/dashboard-domain.d.ts b/shared/dashboard-domain.d.ts new file mode 100644 index 0000000..c575003 --- /dev/null +++ b/shared/dashboard-domain.d.ts @@ -0,0 +1,19 @@ +import type { DailyUsage, DashboardMetrics, ViewMode } from '../src/types' + +export function aggregateToDailyFormat(data: DailyUsage[], viewMode: ViewMode): DailyUsage[] +export function computeBusiestWeek( + data: DailyUsage[], +): { start: string; end: string; cost: number } | null +export function computeMetrics(data: DailyUsage[]): DashboardMetrics +export function computeMovingAverage( + values: Array, + window?: number, +): Array +export function computeWeekOverWeekChange(data: DailyUsage[]): number | null +export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[] +export function filterByModels(data: DailyUsage[], selectedModels: string[]): DailyUsage[] +export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[] +export function filterByProviders(data: DailyUsage[], selectedProviders: string[]): DailyUsage[] +export function getModelProvider(raw: string): string +export function normalizeModelName(raw: string): string +export function sortByDate(data: DailyUsage[]): DailyUsage[] diff --git a/shared/dashboard-domain.js b/shared/dashboard-domain.js new file mode 100644 index 0000000..03cff88 --- /dev/null +++ b/shared/dashboard-domain.js @@ -0,0 +1,603 @@ +const modelNormalizationSpec = require('../server/model-normalization.json') + +const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ + ...alias, + matcher: new RegExp(alias.pattern, 'i'), +})) + +const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ + ...matcher, + matcher: new RegExp(matcher.pattern, 'i'), +})) + +function titleCaseSegment(segment) { + if (!segment) return segment + if (/^\d+([.-]\d+)*$/.test(segment)) return segment.replace(/-/g, '.') + if (/^[a-z]{1,4}\d+$/i.test(segment)) return segment.toUpperCase() + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +function capitalize(segment) { + if (!segment) return '' + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +function formatVersion(version) { + return version.replace(/-/g, '.') +} + +function canonicalizeModelName(raw) { + const normalized = String(raw || '') + .trim() + .toLowerCase() + .replace(/^model[:/ -]*/i, '') + .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') + .replace(/\./g, '-') + .replace(/[_/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, '') + + const suffixStart = normalized.lastIndexOf('-') + if (suffixStart > 0) { + const suffix = normalized.slice(suffixStart + 1) + if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { + return normalized.slice(0, suffixStart) + } + } + + return normalized +} + +function parseClaudeName(rest) { + const parts = rest.split('-', 2) + if (parts.length < 2) { + return `Claude ${capitalize(rest)}` + } + + return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim() +} + +function parseGptName(rest) { + const parts = rest.split('-') + const variant = parts[0] || '' + const minor = parts[1] || '' + + if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { + const version = `${variant}.${minor}` + if (parts.length > 2) { + const suffix = parts.slice(2).map(capitalize).join(' ') + return `GPT-${version}${suffix ? ` ${suffix}` : ''}` + } + return `GPT-${version}` + } + + if (parts.length > 1) { + const suffix = parts.slice(1).map(capitalize).join(' ') + return `GPT-${variant}${suffix ? ` ${suffix}` : ''}` + } + + return `GPT-${rest}` +} + +function parseGeminiName(rest) { + const parts = rest.split('-') + if (parts.length < 2) { + return `Gemini ${rest}` + } + + const versionParts = [] + const tierParts = [] + + for (const part of parts) { + if (/^\d+$/.test(part) && tierParts.length === 0) { + versionParts.push(part) + } else { + tierParts.push(capitalize(part)) + } + } + + const version = versionParts.join('.') + const tier = tierParts.join(' ') + + return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}` +} + +function parseCodexName(rest) { + const normalized = rest.replace(/-latest$/i, '') + if (!normalized) { + return 'Codex' + } + + return `Codex ${normalized.split('-').map(capitalize).join(' ')}` +} + +function parseOSeries(name) { + const separatorIndex = name.indexOf('-') + if (separatorIndex === -1) { + return name + } + + return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}` +} + +function normalizeModelName(raw) { + const canonical = canonicalizeModelName(raw) + + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) return alias.name + } + + if (canonical.startsWith('claude-')) { + return parseClaudeName(canonical.slice('claude-'.length)) + } + + if (canonical.startsWith('gpt-')) { + return parseGptName(canonical.slice('gpt-'.length)) + } + + if (canonical.startsWith('gemini-')) { + return parseGeminiName(canonical.slice('gemini-'.length)) + } + + if (canonical.startsWith('codex-')) { + return parseCodexName(canonical.slice('codex-'.length)) + } + + if (/^o\d/i.test(canonical)) { + return parseOSeries(canonical) + } + + const familyMatch = canonical.match( + /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, + ) + if (familyMatch) { + const family = familyMatch[1] + if (/^codex$/i.test(family)) { + return parseCodexName(familyMatch[2] || '') + } + + if (/^(o\d)$/i.test(family)) return parseOSeries(canonical) + + const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '' + if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}` + return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim() + } + + return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '') +} + +function getModelProvider(raw) { + const canonical = canonicalizeModelName(raw) + for (const matcher of PROVIDER_MATCHERS) { + if (matcher.matcher.test(canonical)) return matcher.provider + } + return 'Other' +} + +function recalculateDayFromBreakdowns(day, filteredBreakdowns) { + let totalCost = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheCreationTokens = 0 + let cacheReadTokens = 0 + let thinkingTokens = 0 + let requestCount = 0 + + for (const breakdown of filteredBreakdowns) { + totalCost += breakdown.cost + inputTokens += breakdown.inputTokens + outputTokens += breakdown.outputTokens + cacheCreationTokens += breakdown.cacheCreationTokens + cacheReadTokens += breakdown.cacheReadTokens + thinkingTokens += breakdown.thinkingTokens + requestCount += breakdown.requestCount + } + + return { + ...day, + totalCost, + totalTokens: + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + thinkingTokens, + requestCount, + modelBreakdowns: filteredBreakdowns, + modelsUsed: [ + ...new Set(filteredBreakdowns.map((breakdown) => normalizeModelName(breakdown.modelName))), + ], + } +} + +function filterByDateRange(data, start, end) { + return data.filter((entry) => { + if (start && entry.date < start) return false + if (end && entry.date > end) return false + return true + }) +} + +function filterByModels(data, selectedModels) { + if (!selectedModels || selectedModels.length === 0) return data + const selected = new Set(selectedModels) + + return data + .map((entry) => { + const filteredBreakdowns = entry.modelBreakdowns.filter((breakdown) => + selected.has(normalizeModelName(breakdown.modelName)), + ) + + if (filteredBreakdowns.length === 0) return null + return recalculateDayFromBreakdowns(entry, filteredBreakdowns) + }) + .filter(Boolean) +} + +function filterByProviders(data, selectedProviders) { + if (!selectedProviders || selectedProviders.length === 0) return data + const selected = new Set(selectedProviders) + + return data + .map((entry) => { + const filteredBreakdowns = entry.modelBreakdowns.filter((breakdown) => + selected.has(getModelProvider(breakdown.modelName)), + ) + + if (filteredBreakdowns.length === 0) return null + return recalculateDayFromBreakdowns(entry, filteredBreakdowns) + }) + .filter(Boolean) +} + +function filterByMonth(data, month) { + if (!month) return data + return data.filter((entry) => entry.date.startsWith(month)) +} + +function sortByDate(data) { + return [...data].sort((left, right) => left.date.localeCompare(right.date)) +} + +function aggregateToDailyFormat(data, viewMode) { + if (viewMode === 'daily') return data + + const getGroupKey = + viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4) + const groups = new Map() + + for (const day of data) { + const key = getGroupKey(day.date) + const existing = groups.get(key) + const aggregatedDays = day._aggregatedDays || 1 + + if (!existing) { + groups.set(key, { + ...day, + date: key, + _aggregatedDays: aggregatedDays, + }) + continue + } + + existing.totalCost += day.totalCost + existing.totalTokens += day.totalTokens + existing.inputTokens += day.inputTokens + existing.outputTokens += day.outputTokens + existing.cacheCreationTokens += day.cacheCreationTokens + existing.cacheReadTokens += day.cacheReadTokens + existing.thinkingTokens += day.thinkingTokens + existing.requestCount += day.requestCount + existing._aggregatedDays += aggregatedDays + existing.modelBreakdowns = existing.modelBreakdowns.concat(day.modelBreakdowns) + existing.modelsUsed = Array.from(new Set(existing.modelsUsed.concat(day.modelsUsed))) + } + + return Array.from(groups.values()).sort((left, right) => left.date.localeCompare(right.date)) +} + +function computeMovingAverage(values, window = 7) { + const result = Array(values.length) + let sum = 0 + + for (let index = 0; index < values.length; index += 1) { + const currentValue = values[index] + if (currentValue === undefined) { + result[index] = undefined + continue + } + + sum += currentValue + + if (index >= window) { + const outgoingValue = values[index - window] + if (outgoingValue !== undefined) { + sum -= outgoingValue + } + } + + result[index] = index < window - 1 ? undefined : sum / window + } + + return result +} + +function stdDev(values) { + if (!values.length) return 0 + const mean = values.reduce((sum, value) => sum + value, 0) / values.length + const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length + return Math.sqrt(variance) +} + +function computeBusiestWeek(data) { + const sorted = data + .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) + .sort((left, right) => left.date.localeCompare(right.date)) + + if (sorted.length < 3) return null + + let bestWindow = null + + for (let start = 0; start < sorted.length; start += 1) { + const startEntry = sorted[start] + if (!startEntry) continue + + const startDate = new Date(`${startEntry.date}T00:00:00`) + const endLimit = new Date(startDate) + endLimit.setDate(endLimit.getDate() + 6) + let windowCost = 0 + let end = start + + while (end < sorted.length) { + const endEntry = sorted[end] + if (!endEntry) break + if (new Date(`${endEntry.date}T00:00:00`) > endLimit) break + windowCost += endEntry.totalCost + end += 1 + } + + const finalEntry = sorted[end - 1] + if (finalEntry && (!bestWindow || windowCost > bestWindow.cost)) { + bestWindow = { + start: startEntry.date, + end: finalEntry.date, + cost: windowCost, + } + } + } + + return bestWindow +} + +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) + const prev7 = sorted.slice(-14, -7) + const lastSum = last7.reduce((sum, day) => sum + day.totalCost, 0) + const prevSum = prev7.reduce((sum, day) => sum + day.totalCost, 0) + if (prevSum === 0) return null + return ((lastSum - prevSum) / prevSum) * 100 +} + +function computeMetrics(data) { + if (data.length === 0) { + return { + totalCost: 0, + totalTokens: 0, + activeDays: 0, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: null, + providerCount: 0, + hasRequestData: false, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + avgModelsPerEntry: 0, + avgDailyCost: 0, + avgRequestsPerDay: 0, + topDay: null, + cheapestDay: null, + busiestWeek: null, + weekendCostShare: null, + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 0, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, + } + } + + const firstDay = data[0] + let topDay = { date: firstDay.date, cost: firstDay.totalCost } + let cheapestDay = { date: firstDay.date, cost: firstDay.totalCost } + let totalCost = 0 + let totalTokens = 0 + let totalInput = 0 + let totalOutput = 0 + let totalCacheRead = 0 + let totalCacheCreate = 0 + let totalThinking = 0 + let totalRequests = 0 + let activeDays = 0 + let hasRequestData = false + let totalModelsUsed = 0 + let weekendCost = 0 + let weekendEligible = 0 + const modelCosts = new Map() + const modelTokens = new Map() + const modelRequests = new Map() + const providerCosts = new Map() + + for (const day of data) { + totalCost += day.totalCost + totalTokens += day.totalTokens + totalInput += day.inputTokens + totalOutput += day.outputTokens + totalCacheRead += day.cacheReadTokens + totalCacheCreate += day.cacheCreationTokens + totalThinking += day.thinkingTokens + totalRequests += day.requestCount + if ( + day.requestCount > 0 || + day.modelBreakdowns.some((breakdown) => breakdown.requestCount > 0) + ) { + hasRequestData = true + } + activeDays += day._aggregatedDays || 1 + totalModelsUsed += day.modelsUsed.length + + if (/^\d{4}-\d{2}-\d{2}$/.test(day.date)) { + const weekday = new Date(`${day.date}T00:00:00`).getDay() + if (weekday === 0 || weekday === 6) weekendCost += day.totalCost + weekendEligible += day.totalCost + } + + if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost } + if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost } + + for (const breakdown of day.modelBreakdowns) { + const normalizedName = normalizeModelName(breakdown.modelName) + const totalBreakdownTokens = + breakdown.inputTokens + + breakdown.outputTokens + + breakdown.cacheCreationTokens + + breakdown.cacheReadTokens + + breakdown.thinkingTokens + + modelCosts.set(normalizedName, (modelCosts.get(normalizedName) || 0) + breakdown.cost) + modelTokens.set(normalizedName, (modelTokens.get(normalizedName) || 0) + totalBreakdownTokens) + modelRequests.set( + normalizedName, + (modelRequests.get(normalizedName) || 0) + breakdown.requestCount, + ) + + const provider = getModelProvider(breakdown.modelName) + providerCosts.set(provider, (providerCosts.get(provider) || 0) + breakdown.cost) + } + } + + const avgDailyCost = totalCost / activeDays + const avgRequestsPerDay = hasRequestData && activeDays > 0 ? totalRequests / activeDays : 0 + const costPerMillion = totalTokens > 0 ? totalCost / (totalTokens / 1_000_000) : 0 + const avgTokensPerRequest = hasRequestData && totalRequests > 0 ? totalTokens / totalRequests : 0 + const avgCostPerRequest = hasRequestData && totalRequests > 0 ? totalCost / totalRequests : 0 + const avgModelsPerEntry = data.length > 0 ? totalModelsUsed / data.length : 0 + const cacheBase = totalCacheRead + totalCacheCreate + totalInput + totalOutput + totalThinking + const cacheHitRate = cacheBase > 0 ? (totalCacheRead / cacheBase) * 100 : 0 + + let topModel = null + for (const [name, cost] of modelCosts) { + if (!topModel || cost > topModel.cost) topModel = { name, cost } + } + + let topRequestModel = null + for (const [name, requests] of modelRequests) { + if (!topRequestModel || requests > topRequestModel.requests) { + topRequestModel = { name, requests } + } + } + + let topTokenModel = null + for (const [name, tokens] of modelTokens) { + if (!topTokenModel || tokens > topTokenModel.tokens) topTokenModel = { name, tokens } + } + + const topModelShare = topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0 + const topThreeModelsShare = + totalCost > 0 + ? ([...modelCosts.values()] + .sort((left, right) => right - left) + .slice(0, 3) + .reduce((sum, value) => sum + value, 0) / + totalCost) * + 100 + : 0 + + let topProvider = null + for (const [name, cost] of providerCosts) { + if (!topProvider || cost > topProvider.cost) { + topProvider = { name, cost, share: totalCost > 0 ? (cost / totalCost) * 100 : 0 } + } + } + + const requestValues = data.map((entry) => entry.requestCount) + const requestVolatility = stdDev(requestValues) + const modelConcentrationIndex = + totalCost > 0 + ? [...modelCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 + const providerConcentrationIndex = + totalCost > 0 + ? [...providerCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 + + return { + totalCost, + totalTokens, + activeDays, + topModel, + topRequestModel, + topTokenModel, + topModelShare, + topThreeModelsShare, + topProvider, + providerCount: providerCosts.size, + hasRequestData, + cacheHitRate, + costPerMillion, + avgTokensPerRequest, + avgCostPerRequest, + avgModelsPerEntry, + avgDailyCost, + avgRequestsPerDay, + topDay, + cheapestDay, + busiestWeek: computeBusiestWeek(data), + weekendCostShare: weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null, + totalInput, + totalOutput, + totalCacheRead, + totalCacheCreate, + totalThinking, + totalRequests, + weekOverWeekChange: computeWeekOverWeekChange(data), + requestVolatility, + modelConcentrationIndex, + providerConcentrationIndex, + } +} + +module.exports = { + aggregateToDailyFormat, + computeBusiestWeek, + computeMetrics, + computeMovingAverage, + computeWeekOverWeekChange, + filterByDateRange, + filterByModels, + filterByMonth, + filterByProviders, + getModelProvider, + normalizeModelName, + sortByDate, +} diff --git a/shared/dashboard-preferences.json b/shared/dashboard-preferences.json new file mode 100644 index 0000000..9361efb --- /dev/null +++ b/shared/dashboard-preferences.json @@ -0,0 +1,43 @@ +{ + "datePresets": ["all", "7d", "30d", "month", "year"], + "viewModes": ["daily", "monthly", "yearly"], + "sectionDefinitions": [ + { "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" } + ] +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index daa909f..12b2a4b 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,85 +1,17 @@ -import { Fragment, lazy, Suspense, useEffect, useRef, useState, useCallback, useMemo } from 'react' +import { lazy, Suspense } from 'react' import { useTranslation } from 'react-i18next' -import { useQueryClient } from '@tanstack/react-query' import { SlidersHorizontal } from 'lucide-react' import { Header } from './layout/Header' import { FilterBar } from './layout/FilterBar' -import { PrimaryMetrics } from './cards/PrimaryMetrics' -import { SecondaryMetrics } from './cards/SecondaryMetrics' -import { TodayMetrics } from './cards/TodayMetrics' -import { MonthMetrics } from './cards/MonthMetrics' -import { CostOverTime } from './charts/CostOverTime' -import { CostByModel } from './charts/CostByModel' -import { CostByModelOverTime } from './charts/CostByModelOverTime' -import { CumulativeCost } from './charts/CumulativeCost' -import { TokensOverTime } from './charts/TokensOverTime' -import { RequestsOverTime } from './charts/RequestsOverTime' -import { RequestCacheHitRateByModel } from './charts/RequestCacheHitRateByModel' -import { TokenTypes } from './charts/TokenTypes' -import { CostByWeekday } from './charts/CostByWeekday' -import { TokenEfficiency } from './charts/TokenEfficiency' -import { ModelMix } from './charts/ModelMix' -import { DistributionAnalysis } from './charts/DistributionAnalysis' -import { CorrelationAnalysis } from './charts/CorrelationAnalysis' -import { ModelEfficiency } from './tables/ModelEfficiency' -import { ProviderEfficiency } from './tables/ProviderEfficiency' -import { RecentDays } from './tables/RecentDays' import { EmptyState } from './EmptyState' import { LoadErrorState } from './LoadErrorState' -import { HeatmapCalendar } from './features/heatmap/HeatmapCalendar' -import { CostForecast } from './features/forecast/CostForecast' -import { CacheROI } from './features/cache-roi/CacheROI' -import { PeriodComparison } from './features/comparison/PeriodComparison' -import { AnomalyDetection } from './features/anomaly/AnomalyDetection' -import { UsageInsights } from './features/insights/UsageInsights' -import { ConcentrationRisk } from './features/risk/ConcentrationRisk' -import { RequestQuality } from './features/request-quality/RequestQuality' -import { PDFReportButton } from './features/pdf-report/PDFReport' import { CommandPalette } from './features/command-palette/CommandPalette' -import { FadeIn } from './features/animations/FadeIn' -import { SectionHeader } from './ui/section-header' -import { ExpandableCard } from './ui/expandable-card' +import { SettingsModal } from './features/settings/SettingsModal' +import { PDFReportButton } from './features/pdf-report/PDFReport' +import { DashboardSections } from './dashboard/DashboardSections' import { DashboardSkeleton } from './ui/skeleton' import { Button } from './ui/button' -import { useUsageData, useUploadData, useDeleteData } from '@/hooks/use-usage-data' -import { useAppSettings } from '@/hooks/use-app-settings' -import { useDashboardFilters } from '@/hooks/use-dashboard-filters' -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 { - deleteSettings, - generatePdfReport, - importSettings, - importUsageData, - type PdfReportRequest, -} from '@/lib/api' -import { - formatCurrency, - formatDateTimeCompact, - formatDateTimeFull, - formatTokens, - formatPercent, - periodUnit, - localToday, - toLocalDateStr, -} from '@/lib/formatters' -import { getCurrentLocale } from '@/lib/i18n' -import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' -import { SettingsModal } from './features/settings/SettingsModal' -import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' -import type { - AppLanguage, - AppSettings, - DashboardDefaultFilters, - DashboardSectionId, - DashboardSectionOrder, - DashboardSectionVisibility, - ProviderLimits, -} from '@/types' +import { useDashboardController } from '@/hooks/use-dashboard-controller' const DrillDownModal = lazy(() => import('./features/drill-down/DrillDownModal').then((module) => ({ @@ -91,176 +23,44 @@ const AutoImportModal = lazy(() => 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 -} interface DashboardProps { initialSettingsError?: string | null } -const CORRUPT_SETTINGS_MESSAGE = 'Settings file is unreadable or corrupted.' -const CORRUPT_USAGE_MESSAGE = 'Usage data file is unreadable or corrupted.' - -function normalizeErrorMessage(error: unknown): string | null { - return error instanceof Error && error.message.trim() ? error.message : null -} - -function describeLoadError(message: string, fallback: string): string { - if (message === CORRUPT_SETTINGS_MESSAGE) return fallback - if (message === CORRUPT_USAGE_MESSAGE) return fallback - return message -} - -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({ initialSettingsError = null }: DashboardProps) { - const { t, i18n } = useTranslation() - const { data: usageData, isLoading, error: usageError } = useUsageData() - const uploadMutation = useUploadData() - const deleteMutation = useDeleteData() - 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 [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) - const [bootstrapSettingsError, setBootstrapSettingsError] = useState(initialSettingsError) - - const daily = useMemo(() => usageData?.daily ?? [], [usageData]) - 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 { t } = useTranslation() + const controller = useDashboardController(initialSettingsError) const { + fileInputRef, + settingsImportInputRef, + dataImportInputRef, settings, providerLimits, - setTheme, - setLanguage, - saveSettings, + isLoading, + settingsLoading, isSaving, - isLoading: settingsLoading, - error: settingsError, - hasFetchedAfterMount: hasFetchedSettingsAfterMount, - } = useAppSettings(allProviders) - const isDark = settings.theme === 'dark' - - useEffect(() => { - if (bootstrapSettingsError && hasFetchedSettingsAfterMount && !settingsError) { - setBootstrapSettingsError(null) - } - }, [bootstrapSettingsError, hasFetchedSettingsAfterMount, settingsError]) - - useEffect(() => { - applyTheme(settings.theme) - }, [settings.theme]) - - useEffect(() => { - if (i18n.resolvedLanguage !== settings.language) { - void i18n.changeLanguage(settings.language) - } - }, [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], - ) - const persistedLoadedTitle = useMemo( - () => - settings.lastLoadedAt - ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) - : undefined, - [settings.lastLoadedAt, t], - ) - const persistedDataSource = useMemo(() => { - if (!hasData) return null - - return { - type: 'stored' as const, - ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), - ...(persistedLoadedTitle ? { title: persistedLoadedTitle } : {}), - } - }, [hasData, persistedLoadedTime, persistedLoadedTitle]) - const headerDataSource = dataSource ?? persistedDataSource - const startupAutoLoadBadge = useMemo( - () => - settings.cliAutoLoadActive - ? { - active: true, - ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), - title: settings.lastLoadedAt - ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) - : t('header.autoLoadActive'), - } - : null, - [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, t], - ) - - const { + isDark, + hasData, + helpOpen, + setHelpOpen, + autoImportOpen, + setAutoImportOpen, + settingsOpen, + setSettingsOpen, + drillDownDate, + setDrillDownDate, + drillDownDay, + reportGenerating, + settingsTransferBusy, + dataTransferBusy, + headerDataSource, + startupAutoLoadBadge, + animationSeed, + daily, + allProviders, + settingsProviderOptions, + settingsModelOptions, viewMode, setViewMode, selectedMonth, @@ -276,7 +76,6 @@ export function Dashboard({ initialSettingsError = null }: DashboardProps) { endDate, setEndDate, resetAll, - applyDefaultFilters, applyPreset, filteredDailyData, filteredData, @@ -284,9 +83,6 @@ export function Dashboard({ initialSettingsError = null }: DashboardProps) { availableProviders, availableModels, dateRange, - } = useDashboardFilters(daily, settings.defaultFilters) - - const { metrics, modelCosts, providerMetrics, @@ -298,729 +94,36 @@ export function Dashboard({ initialSettingsError = null }: DashboardProps) { allModels, modelPieData, tokenPieData, - } = useComputedMetrics(filteredData) - - // Full dataset with only model filter applied (no date/month filter) for PeriodComparison - const comparisonData = filteredDailyData - - // Calculate total calendar days from the date range (only meaningful for daily view) - const totalCalendarDays = useMemo(() => { - if (!dateRange || viewMode !== 'daily') return 0 - const start = new Date(dateRange.start + 'T00:00:00') - const end = new Date(dateRange.end + 'T00:00:00') - return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1 - }, [dateRange, viewMode]) - - const todayStr = localToday() - const todayData = useMemo( - () => filteredDailyData.find((d) => d.date === todayStr) ?? null, - [filteredDailyData, todayStr], - ) - const hasCurrentMonthData = useMemo( - () => filteredDailyData.some((d) => d.date.startsWith(todayStr.slice(0, 7))), - [filteredDailyData, todayStr], - ) - 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(() => { - const dates = new Set(filteredDailyData.map((d) => d.date)) - let count = 0 - const d = new Date(todayStr + 'T00:00:00') - while (dates.has(toLocalDateStr(d))) { - count++ - d.setDate(d.getDate() - 1) - } - return count - }, [filteredDailyData, todayStr]) - - const drillDownDay = useMemo(() => { - if (!drillDownDate) return null - return filteredData.find((d) => d.date === drillDownDate) ?? null - }, [drillDownDate, filteredData]) - - const handleUpload = useCallback(() => { - fileInputRef.current?.click() - }, []) - - const handleOpenSettings = useCallback(() => { - setSettingsOpen(true) - }, []) - - const handleRetryLoad = useCallback(async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['settings'] }), - queryClient.invalidateQueries({ queryKey: ['usage'] }), - ]) - }, [queryClient]) - - const handleResetSettings = useCallback(async () => { - try { - const nextSettings = await deleteSettings() - queryClient.setQueryData(['settings'], nextSettings) - setBootstrapSettingsError(null) - await queryClient.invalidateQueries({ queryKey: ['settings'] }) - addToast(t('toasts.settingsReset'), 'success') - } catch (error) { - addToast(error instanceof Error ? error.message : t('api.deleteSettingsFailed'), 'error') - } - }, [queryClient, addToast, t]) - - const handleToggleTheme = useCallback(() => { - 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) - } - if (i18n.resolvedLanguage !== language) { - void i18n.changeLanguage(language) - } - }, - [i18n, setLanguage, settings.language], - ) - - const handleFileChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - try { - const text = await file.text() - const json: unknown = JSON.parse(text) - await uploadMutation.mutateAsync(json) - void 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()) })}`, - }) - addToast(t('toasts.fileLoaded', { name: file.name }), 'success') - } catch { - addToast(t('toasts.fileReadFailed'), 'error') - } - e.target.value = '' - }, - [uploadMutation, queryClient, addToast, t], - ) - - const handleDelete = useCallback(async () => { - await deleteMutation.mutateAsync() - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed((prev) => prev + 1) - setDataSource(null) - addToast(t('toasts.dataDeleted'), 'info') - }, [deleteMutation, queryClient, addToast, t]) - - const settingsErrorMessage = - bootstrapSettingsError ?? normalizeErrorMessage(settingsError) ?? null - const usageErrorMessage = normalizeErrorMessage(usageError) - const fatalLoadState = useMemo(() => { - const details: string[] = [] - const hasSettingsError = Boolean(settingsErrorMessage) - const hasUsageError = Boolean(usageErrorMessage) - - if (settingsErrorMessage) { - details.push(describeLoadError(settingsErrorMessage, t('loadError.settingsCorrupted'))) - } - - if (usageErrorMessage) { - details.push(describeLoadError(usageErrorMessage, t('loadError.usageCorrupted'))) - } - - if (!hasSettingsError && !hasUsageError) { - return null - } - - return { - title: t('loadError.title'), - description: - hasSettingsError && hasUsageError - ? t('loadError.multipleDescription') - : hasSettingsError - ? t('loadError.settingsDescription') - : t('loadError.usageDescription'), - details, - canResetSettings: hasSettingsError, - canResetUsage: hasUsageError, - } - }, [settingsErrorMessage, usageErrorMessage, t]) - - const handleExportCSV = useCallback(() => { - downloadCSV(filteredData) - addToast(t('toasts.csvExported'), 'success') - }, [filteredData, addToast, t]) - - const handleGenerateReport = useCallback(async () => { - if (reportGenerating) return - setReportGenerating(true) - - try { - const requestLanguage: PdfReportRequest['language'] = i18n.language === 'en' ? 'en' : 'de' - const request: PdfReportRequest = { - viewMode, - selectedMonth, - selectedProviders, - selectedModels, - language: requestLanguage, - ...(startDate ? { startDate } : {}), - ...(endDate ? { endDate } : {}), - } - const blob = await generatePdfReport(request) - const objectUrl = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = objectUrl - a.download = `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf` - document.body.appendChild(a) - a.click() - a.remove() - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000) - addToast(t('commandPalette.commands.generateReport.label'), 'success') - } catch (error) { - console.error('PDF generation failed:', error) - addToast( - `${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'error', - ) - } finally { - setReportGenerating(false) - } - }, [ - reportGenerating, - viewMode, - selectedMonth, - selectedProviders, - selectedModels, - startDate, - endDate, - addToast, - i18n.language, - t, - ]) - - const handleAutoImport = useCallback(() => { - setAutoImportOpen(true) - }, []) - - const handleAutoImportSuccess = useCallback(() => { - void queryClient.invalidateQueries({ queryKey: ['usage'] }) - void 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: 'auto-import', - ...(time ? { time } : {}), - title: t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) }), - }) - 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, - defaultFilters: settings.defaultFilters, - sectionVisibility: settings.sectionVisibility, - sectionOrder: settings.sectionOrder, - 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: unknown = 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') - } finally { - setSettingsTransferBusy(false) - e.target.value = '' - } - }, - [queryClient, applyDefaultFilters, addToast, t], - ) - - const handleDataImportChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - - setDataTransferBusy(true) - try { - const parsed: unknown = 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 ? { 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' }) - }, []) - - 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, - ], - ) + comparisonData, + totalCalendarDays, + todayData, + hasCurrentMonthData, + visibleLimitProviders, + sectionVisibility, + sectionOrder, + streak, + fatalLoadState, + handleUpload, + handleOpenSettings, + handleRetryLoad, + handleResetSettings, + handleToggleTheme, + handleSaveSettings, + handleLanguageChange, + handleFileChange, + handleDelete, + handleExportCSV, + handleGenerateReport, + handleAutoImport, + handleAutoImportSuccess, + handleExportSettings, + handleExportData, + handleImportSettings, + handleImportData, + handleSettingsImportChange, + handleDataImportChange, + handleScrollTo, + } = controller const fileInputs = ( <> @@ -1051,6 +154,44 @@ export function Dashboard({ initialSettingsError = null }: DashboardProps) { ) + const autoImportDialog = ( + + {autoImportOpen && ( + + )} + + ) + + const settingsDialog = ( + + ) + if (!fatalLoadState && (isLoading || settingsLoading)) { return } @@ -1093,38 +234,8 @@ export function Dashboard({ initialSettingsError = null }: DashboardProps) { onOpenSettings={handleOpenSettings} /> {fileInputs} - - {autoImportOpen && ( - - )} - - + {autoImportDialog} + {settingsDialog} ) } @@ -1193,12 +304,34 @@ export function Dashboard({ initialSettingsError = null }: DashboardProps) { key={`${animationSeed}-${daily.length}-${daily[daily.length - 1]?.date ?? 'empty'}-${Math.round(metrics.totalCost)}`} className="mt-4 space-y-4" > - {sectionOrder.map((sectionId) => ( - {renderSection(sectionId)} - ))} +
- {/* Drill-Down Modal */} {drillDownDate !== null && ( - {/* Command Palette */} - - {autoImportOpen && ( - - )} - - - + {autoImportDialog} + {settingsDialog}
) } diff --git a/src/components/dashboard/DashboardSections.tsx b/src/components/dashboard/DashboardSections.tsx new file mode 100644 index 0000000..0ca5729 --- /dev/null +++ b/src/components/dashboard/DashboardSections.tsx @@ -0,0 +1,439 @@ +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' +import { PrimaryMetrics } from '../cards/PrimaryMetrics' +import { SecondaryMetrics } from '../cards/SecondaryMetrics' +import { TodayMetrics } from '../cards/TodayMetrics' +import { MonthMetrics } from '../cards/MonthMetrics' +import { CostOverTime } from '../charts/CostOverTime' +import { CostByModel } from '../charts/CostByModel' +import { CostByModelOverTime } from '../charts/CostByModelOverTime' +import { CumulativeCost } from '../charts/CumulativeCost' +import { TokensOverTime } from '../charts/TokensOverTime' +import { RequestsOverTime } from '../charts/RequestsOverTime' +import { RequestCacheHitRateByModel } from '../charts/RequestCacheHitRateByModel' +import { TokenTypes } from '../charts/TokenTypes' +import { CostByWeekday } from '../charts/CostByWeekday' +import { TokenEfficiency } from '../charts/TokenEfficiency' +import { ModelMix } from '../charts/ModelMix' +import { DistributionAnalysis } from '../charts/DistributionAnalysis' +import { CorrelationAnalysis } from '../charts/CorrelationAnalysis' +import { ModelEfficiency } from '../tables/ModelEfficiency' +import { ProviderEfficiency } from '../tables/ProviderEfficiency' +import { RecentDays } from '../tables/RecentDays' +import { HeatmapCalendar } from '../features/heatmap/HeatmapCalendar' +import { CostForecast } from '../features/forecast/CostForecast' +import { CacheROI } from '../features/cache-roi/CacheROI' +import { PeriodComparison } from '../features/comparison/PeriodComparison' +import { AnomalyDetection } from '../features/anomaly/AnomalyDetection' +import { UsageInsights } from '../features/insights/UsageInsights' +import { ConcentrationRisk } from '../features/risk/ConcentrationRisk' +import { RequestQuality } from '../features/request-quality/RequestQuality' +import { FadeIn } from '../features/animations/FadeIn' +import { ProviderLimitsSection } from '../features/limits/ProviderLimitsSection' +import { SectionHeader } from '../ui/section-header' +import { ExpandableCard } from '../ui/expandable-card' +import { SECTION_HELP } from '@/lib/help-content' +import { formatCurrency, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' +import type { + AggregateMetrics, + ChartDataPoint, + DailyUsage, + DashboardMetrics, + DashboardSectionId, + ProviderLimits, + RequestChartDataPoint, + TokenChartDataPoint, + ViewMode, + WeekdayData, +} from '@/types' + +interface DashboardSectionsProps { + sectionOrder: DashboardSectionId[] + sectionVisibility: Record + metrics: DashboardMetrics + viewMode: ViewMode + totalCalendarDays: number + filteredData: DailyUsage[] + filteredDailyData: DailyUsage[] + todayData: DailyUsage | null + hasCurrentMonthData: boolean + visibleLimitProviders: string[] + providerLimits: ProviderLimits + selectedMonth: string | null + allModels: string[] + costChartData: ChartDataPoint[] + modelPieData: Array<{ name: string; value: number }> + modelCostChartData: Array> + weekdayData: WeekdayData[] + tokenChartData: TokenChartDataPoint[] + tokenPieData: Array<{ name: string; value: number }> + requestChartData: RequestChartDataPoint[] + comparisonData: DailyUsage[] + modelCosts: Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + days: number + } + > + providerMetrics: Map + onDrillDownDateChange: (date: string | null) => void +} + +export function DashboardSections({ + sectionOrder, + sectionVisibility, + metrics, + viewMode, + totalCalendarDays, + filteredData, + filteredDailyData, + todayData, + hasCurrentMonthData, + visibleLimitProviders, + providerLimits, + selectedMonth, + allModels, + costChartData, + modelPieData, + modelCostChartData, + weekdayData, + tokenChartData, + tokenPieData, + requestChartData, + comparisonData, + modelCosts, + providerMetrics, + onDrillDownDateChange, +}: DashboardSectionsProps) { + const { t } = useTranslation() + + const renderSection = (sectionId: DashboardSectionId) => { + switch (sectionId) { + case 'insights': + return sectionVisibility.insights ? ( +
+ +
+ ) : null + case 'metrics': + return sectionVisibility.metrics ? ( +
+ + + + + +
+ entry.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 + } + } + + return ( + <> + {sectionOrder.map((sectionId) => ( + {renderSection(sectionId)} + ))} + + ) +} diff --git a/src/hooks/use-dashboard-controller.ts b/src/hooks/use-dashboard-controller.ts new file mode 100644 index 0000000..20c69ff --- /dev/null +++ b/src/hooks/use-dashboard-controller.ts @@ -0,0 +1,717 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { useQueryClient } from '@tanstack/react-query' +import { useUsageData, useUploadData, useDeleteData } from '@/hooks/use-usage-data' +import { useAppSettings } from '@/hooks/use-app-settings' +import { useDashboardFilters } from '@/hooks/use-dashboard-filters' +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 { + deleteSettings, + generatePdfReport, + importSettings, + importUsageData, + type PdfReportRequest, +} from '@/lib/api' +import { + formatDateTimeCompact, + formatDateTimeFull, + localToday, + toLocalDateStr, +} from '@/lib/formatters' +import { getCurrentLocale } from '@/lib/i18n' +import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' +import type { + AppLanguage, + AppSettings, + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + ProviderLimits, +} from '@/types' + +const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup' +const USAGE_BACKUP_KIND = 'ttdash-usage-backup' +const BACKUP_FORMAT_VERSION = 1 +const CORRUPT_SETTINGS_MESSAGE = 'Settings file is unreadable or corrupted.' +const CORRUPT_USAGE_MESSAGE = 'Usage data file is unreadable or corrupted.' + +export type JsonDownloadRecord = { + filename: string + mimeType: string + size: number + text: string +} + +export type DashboardTestHooks = { + onJsonDownload?: (record: JsonDownloadRecord) => void + openSettings?: () => void +} + +function normalizeErrorMessage(error: unknown): string | null { + return error instanceof Error && error.message.trim() ? error.message : null +} + +function describeLoadError(message: string, fallback: string): string { + if (message === CORRUPT_SETTINGS_MESSAGE) return fallback + if (message === CORRUPT_USAGE_MESSAGE) return fallback + return message +} + +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 useDashboardController(initialSettingsError: string | null = null) { + const { t, i18n } = useTranslation() + const { data: usageData, isLoading, error: usageError } = useUsageData() + const uploadMutation = useUploadData() + const deleteMutation = useDeleteData() + 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 [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) + const [bootstrapSettingsError, setBootstrapSettingsError] = useState(initialSettingsError) + + const daily = useMemo(() => usageData?.daily ?? [], [usageData]) + const hasData = daily.length > 0 + const allProviders = useMemo( + () => getUniqueProviders(daily.map((entry) => entry.modelsUsed)), + [daily], + ) + const allModelsFromData = useMemo( + () => getUniqueModels(daily.map((entry) => entry.modelsUsed)), + [daily], + ) + const { + settings, + providerLimits, + setTheme, + setLanguage, + saveSettings, + isSaving, + isLoading: settingsLoading, + error: settingsError, + hasFetchedAfterMount, + } = useAppSettings(allProviders) + const isDark = settings.theme === 'dark' + + useEffect(() => { + if (bootstrapSettingsError && hasFetchedAfterMount && !settingsError) { + setBootstrapSettingsError(null) + } + }, [bootstrapSettingsError, hasFetchedAfterMount, settingsError]) + + useEffect(() => { + applyTheme(settings.theme) + }, [settings.theme]) + + useEffect(() => { + if (i18n.resolvedLanguage !== settings.language) { + void i18n.changeLanguage(settings.language) + } + }, [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], + ) + const persistedLoadedTitle = useMemo( + () => + settings.lastLoadedAt + ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) + : undefined, + [settings.lastLoadedAt, t], + ) + const persistedDataSource = useMemo(() => { + if (!hasData) return null + + return { + type: 'stored' as const, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), + ...(persistedLoadedTitle ? { title: persistedLoadedTitle } : {}), + } + }, [hasData, persistedLoadedTime, persistedLoadedTitle]) + const headerDataSource = dataSource ?? persistedDataSource + const startupAutoLoadBadge = useMemo( + () => + settings.cliAutoLoadActive + ? { + active: true, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), + title: settings.lastLoadedAt + ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) + : t('header.autoLoadActive'), + } + : null, + [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, t], + ) + + const filters = useDashboardFilters(daily, settings.defaultFilters) + const { + viewMode, + setViewMode, + selectedMonth, + setSelectedMonth, + selectedProviders, + toggleProvider, + clearProviders, + selectedModels, + toggleModel, + clearModels, + startDate, + setStartDate, + endDate, + setEndDate, + resetAll, + applyDefaultFilters, + applyPreset, + filteredDailyData, + filteredData, + availableMonths, + availableProviders, + availableModels, + dateRange, + } = filters + + const computed = useComputedMetrics(filteredData) + const { + metrics, + modelCosts, + providerMetrics, + costChartData, + modelCostChartData, + tokenChartData, + requestChartData, + weekdayData, + allModels, + modelPieData, + tokenPieData, + } = computed + + const comparisonData = filteredDailyData + const totalCalendarDays = useMemo(() => { + if (!dateRange || viewMode !== 'daily') return 0 + const start = new Date(dateRange.start + 'T00:00:00') + const end = new Date(dateRange.end + 'T00:00:00') + return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1 + }, [dateRange, viewMode]) + + const todayStr = localToday() + const todayData = useMemo( + () => filteredDailyData.find((entry) => entry.date === todayStr) ?? null, + [filteredDailyData, todayStr], + ) + const hasCurrentMonthData = useMemo( + () => filteredDailyData.some((entry) => entry.date.startsWith(todayStr.slice(0, 7))), + [filteredDailyData, todayStr], + ) + 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 + + const streak = useMemo(() => { + const dates = new Set(filteredDailyData.map((entry) => entry.date)) + let count = 0 + const date = new Date(todayStr + 'T00:00:00') + while (dates.has(toLocalDateStr(date))) { + count += 1 + date.setDate(date.getDate() - 1) + } + return count + }, [filteredDailyData, todayStr]) + + const drillDownDay = useMemo(() => { + if (!drillDownDate) return null + return filteredData.find((entry) => entry.date === drillDownDate) ?? null + }, [drillDownDate, filteredData]) + + const handleUpload = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const handleOpenSettings = useCallback(() => { + setSettingsOpen(true) + }, []) + + const handleRetryLoad = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings'] }), + queryClient.invalidateQueries({ queryKey: ['usage'] }), + ]) + }, [queryClient]) + + const handleResetSettings = useCallback(async () => { + try { + const nextSettings = await deleteSettings() + queryClient.setQueryData(['settings'], nextSettings) + setBootstrapSettingsError(null) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + addToast(t('toasts.settingsReset'), 'success') + } catch (error) { + addToast(error instanceof Error ? error.message : t('api.deleteSettingsFailed'), 'error') + } + }, [queryClient, addToast, t]) + + const handleToggleTheme = useCallback(() => { + 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) + } + if (i18n.resolvedLanguage !== language) { + void i18n.changeLanguage(language) + } + }, + [i18n, setLanguage, settings.language], + ) + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + try { + const parsed: unknown = JSON.parse(await file.text()) + await uploadMutation.mutateAsync(parsed) + void queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((previous) => previous + 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()) })}`, + }) + addToast(t('toasts.fileLoaded', { name: file.name }), 'success') + } catch { + addToast(t('toasts.fileReadFailed'), 'error') + } + + event.target.value = '' + }, + [uploadMutation, queryClient, addToast, t], + ) + + const handleDelete = useCallback(async () => { + await deleteMutation.mutateAsync() + void queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((previous) => previous + 1) + setDataSource(null) + addToast(t('toasts.dataDeleted'), 'info') + }, [deleteMutation, queryClient, addToast, t]) + + const settingsErrorMessage = + bootstrapSettingsError ?? normalizeErrorMessage(settingsError) ?? null + const usageErrorMessage = normalizeErrorMessage(usageError) + const fatalLoadState = useMemo(() => { + const details: string[] = [] + const hasSettingsError = Boolean(settingsErrorMessage) + const hasUsageError = Boolean(usageErrorMessage) + + if (settingsErrorMessage) { + details.push(describeLoadError(settingsErrorMessage, t('loadError.settingsCorrupted'))) + } + + if (usageErrorMessage) { + details.push(describeLoadError(usageErrorMessage, t('loadError.usageCorrupted'))) + } + + if (!hasSettingsError && !hasUsageError) { + return null + } + + return { + title: t('loadError.title'), + description: + hasSettingsError && hasUsageError + ? t('loadError.multipleDescription') + : hasSettingsError + ? t('loadError.settingsDescription') + : t('loadError.usageDescription'), + details, + canResetSettings: hasSettingsError, + canResetUsage: hasUsageError, + } + }, [settingsErrorMessage, usageErrorMessage, t]) + + const handleExportCSV = useCallback(() => { + downloadCSV(filteredData) + addToast(t('toasts.csvExported'), 'success') + }, [filteredData, addToast, t]) + + const handleGenerateReport = useCallback(async () => { + if (reportGenerating) return + setReportGenerating(true) + + try { + const requestLanguage: PdfReportRequest['language'] = i18n.language === 'en' ? 'en' : 'de' + const request: PdfReportRequest = { + viewMode, + selectedMonth, + selectedProviders, + selectedModels, + language: requestLanguage, + ...(startDate ? { startDate } : {}), + ...(endDate ? { endDate } : {}), + } + const blob = await generatePdfReport(request) + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = `ttdash-report-${new Date().toISOString().slice(0, 10)}.pdf` + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000) + addToast(t('commandPalette.commands.generateReport.label'), 'success') + } catch (error) { + console.error('PDF generation failed:', error) + addToast( + `${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error', + ) + } finally { + setReportGenerating(false) + } + }, [ + reportGenerating, + viewMode, + selectedMonth, + selectedProviders, + selectedModels, + startDate, + endDate, + addToast, + i18n.language, + t, + ]) + + const handleAutoImport = useCallback(() => { + setAutoImportOpen(true) + }, []) + + const handleAutoImportSuccess = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ['usage'] }) + void queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((previous) => previous + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) + setDataSource({ + type: 'auto-import', + ...(time ? { time } : {}), + title: t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) }), + }) + 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, + defaultFilters: settings.defaultFilters, + sectionVisibility: settings.sectionVisibility, + sectionOrder: settings.sectionOrder, + 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 (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + setSettingsTransferBusy(true) + try { + const parsed: unknown = 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') + } finally { + setSettingsTransferBusy(false) + event.target.value = '' + } + }, + [queryClient, applyDefaultFilters, addToast, t], + ) + + const handleDataImportChange = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + setDataTransferBusy(true) + try { + const parsed: unknown = JSON.parse(await file.text()) + const summary = await importUsageData(parsed) + await queryClient.invalidateQueries({ queryKey: ['usage'] }) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((previous) => previous + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { + hour: '2-digit', + minute: '2-digit', + }) + setDataSource({ + type: 'file', + label: file.name, + ...(time ? { 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) + event.target.value = '' + } + }, + [queryClient, addToast, t], + ) + + const handleScrollTo = useCallback((section: string) => { + const element = document.getElementById(section) + element?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, []) + + return { + fileInputRef, + settingsImportInputRef, + dataImportInputRef, + settings, + providerLimits, + isLoading, + settingsLoading, + isSaving, + isDark, + hasData, + helpOpen, + setHelpOpen, + autoImportOpen, + setAutoImportOpen, + settingsOpen, + setSettingsOpen, + drillDownDate, + setDrillDownDate, + drillDownDay, + reportGenerating, + settingsTransferBusy, + dataTransferBusy, + dataSource, + headerDataSource, + startupAutoLoadBadge, + animationSeed, + daily, + usageData, + allProviders, + allModelsFromData, + settingsProviderOptions, + settingsModelOptions, + viewMode, + setViewMode, + selectedMonth, + setSelectedMonth, + selectedProviders, + toggleProvider, + clearProviders, + selectedModels, + toggleModel, + clearModels, + startDate, + setStartDate, + endDate, + setEndDate, + resetAll, + applyPreset, + filteredDailyData, + filteredData, + availableMonths, + availableProviders, + availableModels, + dateRange, + metrics, + modelCosts, + providerMetrics, + costChartData, + modelCostChartData, + tokenChartData, + requestChartData, + weekdayData, + allModels, + modelPieData, + tokenPieData, + comparisonData, + totalCalendarDays, + todayData, + hasCurrentMonthData, + visibleLimitProviders, + sectionVisibility, + sectionOrder, + streak, + fatalLoadState, + handleUpload, + handleOpenSettings, + handleRetryLoad, + handleResetSettings, + handleToggleTheme, + handleSaveSettings, + handleLanguageChange, + handleFileChange, + handleDelete, + handleExportCSV, + handleGenerateReport, + handleAutoImport, + handleAutoImportSuccess, + handleExportSettings, + handleExportData, + handleImportSettings, + handleImportData, + handleSettingsImportChange, + handleDataImportChange, + handleScrollTo, + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 55ace58..f432005 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,7 +11,7 @@ import type { ViewMode, } from '@/types' import i18n from '@/lib/i18n' -import { normalizeAppSettings } from '@/lib/app-settings' +import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from '@/lib/app-settings' interface ApiErrorPayload { message?: string @@ -33,6 +33,11 @@ async function readErrorMessage(response: Response, fallback: string): Promise { const res = await fetch('/api/usage') if (!res.ok) { @@ -87,6 +92,29 @@ export async function fetchSettings(): Promise { return normalizeAppSettings(await parseResponseJson(res)) } +export async function loadBootstrapSettings(): Promise { + try { + const response = await fetch('/api/settings') + + if (!response.ok) { + return { + settings: DEFAULT_APP_SETTINGS, + errorMessage: await readErrorMessage(response, i18n.t('api.fetchSettingsFailed')), + } + } + + return { + settings: normalizeAppSettings(await parseResponseJson(response)), + errorMessage: null, + } + } catch { + return { + settings: DEFAULT_APP_SETTINGS, + errorMessage: null, + } + } +} + export async function updateSettings(patch: UpdateSettingsRequest): Promise { const res = await fetch('/api/settings', { method: 'PATCH', diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index c598f83..0854348 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -4,287 +4,23 @@ import type { DailyUsage, DashboardMetrics, } from '@/types' +import { + computeMetrics as computeSharedMetrics, + computeMovingAverage as computeSharedMovingAverage, + computeWeekOverWeekChange as computeSharedWeekOverWeekChange, +} from '../../shared/dashboard-domain.js' import { getModelProvider, normalizeModelName } from './model-utils' export function computeMetrics(data: DailyUsage[]): DashboardMetrics { - if (data.length === 0) { - return { - totalCost: 0, - totalTokens: 0, - activeDays: 0, - topModel: null, - topRequestModel: null, - topTokenModel: null, - topModelShare: 0, - topThreeModelsShare: 0, - topProvider: null, - providerCount: 0, - hasRequestData: false, - cacheHitRate: 0, - costPerMillion: 0, - avgTokensPerRequest: 0, - avgCostPerRequest: 0, - avgModelsPerEntry: 0, - avgDailyCost: 0, - avgRequestsPerDay: 0, - topDay: null, - cheapestDay: null, - busiestWeek: null, - weekendCostShare: null, - totalInput: 0, - totalOutput: 0, - totalCacheRead: 0, - totalCacheCreate: 0, - totalThinking: 0, - totalRequests: 0, - weekOverWeekChange: null, - requestVolatility: 0, - modelConcentrationIndex: 0, - providerConcentrationIndex: 0, - } - } - - const firstDay = data[0] - if (!firstDay) { - throw new Error('computeMetrics received empty data unexpectedly') - } - - let topDay = { date: firstDay.date, cost: firstDay.totalCost } - let cheapestDay = { date: firstDay.date, cost: firstDay.totalCost } - let totalCost = 0 - let totalTokens = 0 - let totalInput = 0 - let totalOutput = 0 - let totalCacheRead = 0 - let totalCacheCreate = 0 - let totalThinking = 0 - let totalRequests = 0 - let activeDays = 0 - let hasRequestData = false - const modelCosts = new Map() - const modelTokens = new Map() - const modelRequests = new Map() - const providerCosts = new Map() - let totalModelsUsed = 0 - let weekendCost = 0 - let weekendEligible = 0 - - for (const d of data) { - totalCost += d.totalCost - totalTokens += d.totalTokens - totalInput += d.inputTokens - totalOutput += d.outputTokens - totalCacheRead += d.cacheReadTokens - totalCacheCreate += d.cacheCreationTokens - totalThinking += d.thinkingTokens - totalRequests += d.requestCount - if (d.requestCount > 0 || d.modelBreakdowns.some((mb) => mb.requestCount > 0)) - hasRequestData = true - activeDays += d._aggregatedDays ?? 1 - totalModelsUsed += d.modelsUsed.length - - if (/^\d{4}-\d{2}-\d{2}$/.test(d.date)) { - const weekday = new Date(`${d.date}T00:00:00`).getDay() - if (weekday === 0 || weekday === 6) weekendCost += d.totalCost - weekendEligible += d.totalCost - } - - if (d.totalCost > topDay.cost) topDay = { date: d.date, cost: d.totalCost } - if (d.totalCost < cheapestDay.cost) cheapestDay = { date: d.date, cost: d.totalCost } - for (const mb of d.modelBreakdowns) { - const name = normalizeModelName(mb.modelName) - modelCosts.set(name, (modelCosts.get(name) ?? 0) + mb.cost) - modelTokens.set( - name, - (modelTokens.get(name) ?? 0) + - mb.inputTokens + - mb.outputTokens + - mb.cacheCreationTokens + - mb.cacheReadTokens + - mb.thinkingTokens, - ) - modelRequests.set(name, (modelRequests.get(name) ?? 0) + mb.requestCount) - const provider = getModelProvider(mb.modelName) - providerCosts.set(provider, (providerCosts.get(provider) ?? 0) + mb.cost) - } - } - - const avgDailyCost = totalCost / activeDays - const avgRequestsPerDay = hasRequestData && activeDays > 0 ? totalRequests / activeDays : 0 - const costPerMillion = totalTokens > 0 ? totalCost / (totalTokens / 1_000_000) : 0 - const avgTokensPerRequest = hasRequestData && totalRequests > 0 ? totalTokens / totalRequests : 0 - const avgCostPerRequest = hasRequestData && totalRequests > 0 ? totalCost / totalRequests : 0 - const avgModelsPerEntry = data.length > 0 ? totalModelsUsed / data.length : 0 - const cacheBase = totalCacheRead + totalCacheCreate + totalInput + totalOutput + totalThinking - const cacheHitRate = cacheBase > 0 ? (totalCacheRead / cacheBase) * 100 : 0 - - let topModel: { name: string; cost: number } | null = null - for (const [name, cost] of modelCosts) { - if (!topModel || cost > topModel.cost) topModel = { name, cost } - } - let topRequestModel: { name: string; requests: number } | null = null - for (const [name, requests] of modelRequests) { - if (!topRequestModel || requests > topRequestModel.requests) - topRequestModel = { name, requests } - } - let topTokenModel: { name: string; tokens: number } | null = null - for (const [name, tokens] of modelTokens) { - if (!topTokenModel || tokens > topTokenModel.tokens) topTokenModel = { name, tokens } - } - const topModelShare = topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0 - const topThreeModelsShare = - totalCost > 0 - ? ([...modelCosts.values()] - .sort((a, b) => b - a) - .slice(0, 3) - .reduce((sum, value) => sum + value, 0) / - totalCost) * - 100 - : 0 - - let topProvider: { name: string; cost: number; share: number } | null = null - for (const [name, cost] of providerCosts) { - if (!topProvider || cost > topProvider.cost) { - topProvider = { name, cost, share: totalCost > 0 ? (cost / totalCost) * 100 : 0 } - } - } - - const busiestWeek = computeBusiestWeek(data) - const weekendCostShare = weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null - const requestValues = data.map((entry) => entry.requestCount) - const requestVolatility = stdDev(requestValues) - const modelConcentrationIndex = - totalCost > 0 - ? [...modelCosts.values()].reduce((sum, cost) => { - const share = cost / totalCost - return sum + share * share - }, 0) - : 0 - const providerConcentrationIndex = - totalCost > 0 - ? [...providerCosts.values()].reduce((sum, cost) => { - const share = cost / totalCost - return sum + share * share - }, 0) - : 0 - - // Week-over-week change - const weekOverWeekChange = computeWeekOverWeekChange(data) - - return { - totalCost, - totalTokens, - activeDays, - topModel, - topRequestModel, - topTokenModel, - topModelShare, - topThreeModelsShare, - topProvider, - providerCount: providerCosts.size, - hasRequestData, - cacheHitRate, - costPerMillion, - avgTokensPerRequest, - avgCostPerRequest, - avgModelsPerEntry, - avgDailyCost, - avgRequestsPerDay, - topDay, - cheapestDay, - busiestWeek, - weekendCostShare, - totalInput, - totalOutput, - totalCacheRead, - totalCacheCreate, - totalThinking, - totalRequests, - weekOverWeekChange, - requestVolatility, - modelConcentrationIndex, - providerConcentrationIndex, - } -} - -function computeBusiestWeek( - data: DailyUsage[], -): { start: string; end: string; cost: number } | null { - const sorted = data - .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) - .sort((a, b) => a.date.localeCompare(b.date)) - - if (sorted.length < 3) return null - - let bestWindow: { start: string; end: string; cost: number } | null = null - - for (let start = 0; start < sorted.length; start++) { - const startEntry = sorted[start] - if (!startEntry) continue - - const startDate = new Date(`${startEntry.date}T00:00:00`) - const endLimit = new Date(startDate) - endLimit.setDate(endLimit.getDate() + 6) - let windowCost = 0 - let end = start - - while (end < sorted.length) { - const endEntry = sorted[end] - if (!endEntry) break - if (new Date(`${endEntry.date}T00:00:00`) > endLimit) break - windowCost += endEntry.totalCost - end++ - } - - const finalEntry = sorted[end - 1] - if (finalEntry && (!bestWindow || windowCost > bestWindow.cost)) { - bestWindow = { - start: startEntry.date, - end: finalEntry.date, - cost: windowCost, - } - } - } - - return bestWindow + return computeSharedMetrics(data) } export function computeWeekOverWeekChange(data: DailyUsage[]): number | null { - if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null - if (data.length < 14) return null - const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)) - const last7 = sorted.slice(-7) - const prev7 = sorted.slice(-14, -7) - const lastSum = last7.reduce((s, d) => s + d.totalCost, 0) - const prevSum = prev7.reduce((s, d) => s + d.totalCost, 0) - if (prevSum === 0) return null - return ((lastSum - prevSum) / prevSum) * 100 + return computeSharedWeekOverWeekChange(data) } export function computeMovingAverage(values: number[], window = 7): (number | undefined)[] { - const result = Array(values.length) - let sum = 0 - - for (let i = 0; i < values.length; i++) { - const currentValue = values[i] - if (currentValue === undefined) { - result[i] = undefined - continue - } - - sum += currentValue - - if (i >= window) { - const outgoingValue = values[i - window] - if (outgoingValue !== undefined) { - sum -= outgoingValue - } - } - - result[i] = i < window - 1 ? undefined : sum / window - } - - return result + return computeSharedMovingAverage(values, window) } export function computeModelCosts(data: DailyUsage[]): Map< diff --git a/src/lib/dashboard-preferences.ts b/src/lib/dashboard-preferences.ts index 3267f93..15a12d6 100644 --- a/src/lib/dashboard-preferences.ts +++ b/src/lib/dashboard-preferences.ts @@ -6,6 +6,7 @@ import type { DashboardSectionVisibility, ViewMode, } from '@/types' +import dashboardPreferences from '../../shared/dashboard-preferences.json' export interface DashboardSectionDefinition { id: DashboardSectionId @@ -13,39 +14,10 @@ export interface DashboardSectionDefinition { labelKey: string } -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: DashboardSectionDefinition[] = [ - { 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_DATE_PRESETS = dashboardPreferences.datePresets as DashboardDatePreset[] +export const DASHBOARD_VIEW_MODES = dashboardPreferences.viewModes as ViewMode[] +export const DASHBOARD_SECTION_DEFINITIONS = + dashboardPreferences.sectionDefinitions as DashboardSectionDefinition[] export const DASHBOARD_SECTION_DEFINITION_MAP = Object.fromEntries( DASHBOARD_SECTION_DEFINITIONS.map((section) => [section.id, section]), ) as Record diff --git a/src/lib/data-transforms.ts b/src/lib/data-transforms.ts index 1b6a490..571eba6 100644 --- a/src/lib/data-transforms.ts +++ b/src/lib/data-transforms.ts @@ -7,94 +7,35 @@ import type { ViewMode, } from '@/types' import { computeMovingAverage } from './calculations' -import { getModelProvider, normalizeModelName } from './model-utils' +import { + aggregateToDailyFormat as aggregateSharedToDailyFormat, + filterByDateRange as filterBySharedDateRange, + filterByModels as filterBySharedModels, + filterByMonth as filterBySharedMonth, + filterByProviders as filterBySharedProviders, + sortByDate as sortSharedByDate, +} from '../../shared/dashboard-domain.js' +import { normalizeModelName } from './model-utils' import { getCurrentLocale } from './i18n' -function recalculateDayFromBreakdowns( - day: DailyUsage, - filteredBreakdowns: DailyUsage['modelBreakdowns'], -): DailyUsage { - let totalCost = 0 - let inputTokens = 0 - let outputTokens = 0 - let cacheCreationTokens = 0 - let cacheReadTokens = 0 - let thinkingTokens = 0 - let requestCount = 0 - - for (const mb of filteredBreakdowns) { - totalCost += mb.cost - inputTokens += mb.inputTokens - outputTokens += mb.outputTokens - cacheCreationTokens += mb.cacheCreationTokens - cacheReadTokens += mb.cacheReadTokens - thinkingTokens += mb.thinkingTokens - requestCount += mb.requestCount - } - - return { - ...day, - totalCost, - totalTokens: - inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - thinkingTokens, - requestCount, - modelBreakdowns: filteredBreakdowns, - modelsUsed: [...new Set(filteredBreakdowns.map((mb) => normalizeModelName(mb.modelName)))], - } -} - export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[] { - return data.filter((d) => { - if (start && d.date < start) return false - if (end && d.date > end) return false - return true - }) + return filterBySharedDateRange(data, start, end) } export function filterByModels(data: DailyUsage[], selectedModels: string[]): DailyUsage[] { - if (selectedModels.length === 0) return data - const selected = new Set(selectedModels) - - return data - .map((d) => { - const filteredBreakdowns = d.modelBreakdowns.filter((mb) => - selected.has(normalizeModelName(mb.modelName)), - ) - - if (filteredBreakdowns.length === 0) return null - return recalculateDayFromBreakdowns(d, filteredBreakdowns) - }) - .filter((d): d is DailyUsage => d !== null) + return filterBySharedModels(data, selectedModels) } export function filterByProviders(data: DailyUsage[], selectedProviders: string[]): DailyUsage[] { - if (selectedProviders.length === 0) return data - const selected = new Set(selectedProviders) - - return data - .map((d) => { - const filteredBreakdowns = d.modelBreakdowns.filter((mb) => - selected.has(getModelProvider(mb.modelName)), - ) - - if (filteredBreakdowns.length === 0) return null - return recalculateDayFromBreakdowns(d, filteredBreakdowns) - }) - .filter((d): d is DailyUsage => d !== null) + return filterBySharedProviders(data, selectedProviders) } export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[] { - if (!month) return data - return data.filter((d) => d.date.startsWith(month)) + return filterBySharedMonth(data, month) } export function sortByDate(data: DailyUsage[]): DailyUsage[] { - return [...data].sort((a, b) => a.date.localeCompare(b.date)) + return sortSharedByDate(data) } export function getAvailableMonths(data: DailyUsage[]): string[] { @@ -323,39 +264,7 @@ export function toWeekdayData(data: DailyUsage[]): WeekdayData[] { } export function aggregateToDailyFormat(data: DailyUsage[], mode: ViewMode): DailyUsage[] { - if (mode === 'daily') return data - - const groupKey = - mode === 'monthly' ? (date: string) => date.slice(0, 7) : (date: string) => date.slice(0, 4) - - const map = new Map() - - for (const d of data) { - const key = groupKey(d.date) - const existing = map.get(key) - const days = d._aggregatedDays ?? 1 - - if (!existing) { - map.set(key, { ...d, date: key, _aggregatedDays: days }) - } else { - existing.totalCost += d.totalCost - existing.totalTokens += d.totalTokens - existing.inputTokens += d.inputTokens - existing.outputTokens += d.outputTokens - existing.cacheCreationTokens += d.cacheCreationTokens - existing.cacheReadTokens += d.cacheReadTokens - existing.thinkingTokens += d.thinkingTokens - existing.requestCount += d.requestCount - existing._aggregatedDays = (existing._aggregatedDays ?? 1) + days - // Merge model breakdowns - existing.modelBreakdowns = [...existing.modelBreakdowns, ...d.modelBreakdowns] - // Merge modelsUsed (unique) - const allModels = new Set([...existing.modelsUsed, ...d.modelsUsed]) - existing.modelsUsed = Array.from(allModels) - } - } - - return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date)) + return aggregateSharedToDailyFormat(data, mode) } export function aggregateByMonth(data: DailyUsage[]): { diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts index 40c3aef..2a5d469 100644 --- a/src/lib/model-utils.ts +++ b/src/lib/model-utils.ts @@ -1,123 +1,10 @@ import { MODEL_COLORS, MODEL_COLOR_DEFAULT } from './constants' -import modelNormalizationSpec from '../../server/model-normalization.json' +import { + getModelProvider as getSharedModelProvider, + normalizeModelName as normalizeSharedModelName, +} from '../../shared/dashboard-domain.js' const DYNAMIC_COLOR_CACHE = new Map() -const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ - ...alias, - matcher: new RegExp(alias.pattern, 'i'), -})) -const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ - ...matcher, - matcher: new RegExp(matcher.pattern, 'i'), -})) - -function titleCaseSegment(segment: string): string { - if (!segment) return segment - if (/^\d+([.-]\d+)*$/.test(segment)) return segment.replace(/-/g, '.') - if (/^[a-z]{1,4}\d+$/i.test(segment)) return segment.toUpperCase() - return segment.charAt(0).toUpperCase() + segment.slice(1) -} - -function capitalize(segment: string): string { - if (!segment) return '' - return segment.charAt(0).toUpperCase() + segment.slice(1) -} - -function formatVersion(version: string): string { - return version.replace(/-/g, '.') -} - -function canonicalizeModelName(raw: string): string { - const normalized = String(raw || '') - .trim() - .toLowerCase() - .replace(/^model[:/ -]*/i, '') - .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') - .replace(/\./g, '-') - .replace(/[_/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-|-$/g, '') - - const suffixStart = normalized.lastIndexOf('-') - if (suffixStart > 0) { - const suffix = normalized.slice(suffixStart + 1) - if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { - return normalized.slice(0, suffixStart) - } - } - - return normalized -} - -function parseClaudeName(rest: string): string { - const parts = rest.split('-', 2) - if (parts.length < 2) { - return `Claude ${capitalize(rest)}` - } - return `${capitalize(parts[0] ?? '')} ${formatVersion(parts[1] ?? '')}`.trim() -} - -function parseGptName(rest: string): string { - const parts = rest.split('-') - const variant = parts[0] ?? '' - const minor = parts[1] ?? '' - - if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { - const version = `${variant}.${minor}` - if (parts.length > 2) { - const suffix = parts.slice(2).map(capitalize).join(' ') - return `GPT-${version}${suffix ? ` ${suffix}` : ''}` - } - return `GPT-${version}` - } - - if (parts.length > 1) { - const suffix = parts.slice(1).map(capitalize).join(' ') - return `GPT-${variant}${suffix ? ` ${suffix}` : ''}` - } - - return `GPT-${rest}` -} - -function parseGeminiName(rest: string): string { - const parts = rest.split('-') - if (parts.length < 2) { - return `Gemini ${rest}` - } - - const versionParts: string[] = [] - const tierParts: string[] = [] - - for (const part of parts) { - if (/^\d+$/.test(part) && tierParts.length === 0) { - versionParts.push(part) - } else { - tierParts.push(capitalize(part)) - } - } - - const version = versionParts.join('.') - const tier = tierParts.join(' ') - - return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}` -} - -function parseCodexName(rest: string): string { - const normalized = rest.replace(/-latest$/i, '') - if (!normalized) { - return 'Codex' - } - return `Codex ${normalized.split('-').map(capitalize).join(' ')}` -} - -function parseOSeries(name: string): string { - const separatorIndex = name.indexOf('-') - if (separatorIndex === -1) { - return name - } - return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}` -} function dynamicColor(name: string): string { const cached = DYNAMIC_COLOR_CACHE.get(name) @@ -137,64 +24,11 @@ function dynamicColor(name: string): string { } export function normalizeModelName(raw: string): string { - const canonical = canonicalizeModelName(raw) - for (const alias of DISPLAY_ALIASES) { - if (alias.matcher.test(canonical)) { - return alias.name - } - } - - if (canonical.startsWith('claude-')) { - return parseClaudeName(canonical.slice('claude-'.length)) - } - - if (canonical.startsWith('gpt-')) { - return parseGptName(canonical.slice('gpt-'.length)) - } - - if (canonical.startsWith('gemini-')) { - return parseGeminiName(canonical.slice('gemini-'.length)) - } - - if (canonical.startsWith('codex-')) { - return parseCodexName(canonical.slice('codex-'.length)) - } - - if (/^o\d/i.test(canonical)) { - return parseOSeries(canonical) - } - - const familyMatch = canonical.match( - /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, - ) - if (familyMatch) { - const family = familyMatch[1] - if (!family) return canonical - - if (/^codex$/i.test(family)) { - return parseCodexName(familyMatch[2] ?? '') - } - - if (/^(o\d)$/i.test(family)) { - return parseOSeries(canonical) - } - - const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '' - if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}` - return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim() - } - - return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || raw + return normalizeSharedModelName(raw) } export function getModelProvider(raw: string): string { - const canonical = canonicalizeModelName(raw) - for (const matcher of PROVIDER_MATCHERS) { - if (matcher.matcher.test(canonical)) { - return matcher.provider - } - } - return 'Other' + return getSharedModelProvider(raw) } export function getProviderBadgeClasses(provider: string): string { diff --git a/src/main.tsx b/src/main.tsx index ad0996b..139d64e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,50 +1,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' -import { DEFAULT_APP_SETTINGS, applyTheme, normalizeAppSettings } from './lib/app-settings' +import { applyTheme } from './lib/app-settings' +import { loadBootstrapSettings } from './lib/api' import { initI18n } from './lib/i18n' -import type { AppSettings } from './types' import './index.css' -interface InitialSettingsLoadResult { - settings: AppSettings - errorMessage: string | null -} - -async function readErrorMessage(response: Response): Promise { - try { - const payload = (await response.json()) as { message?: string } - return typeof payload.message === 'string' && payload.message.trim() ? payload.message : null - } catch { - return null - } -} - -async function loadInitialSettings() { - try { - const res = await fetch('/api/settings') - if (!res.ok) { - return { - settings: DEFAULT_APP_SETTINGS, - errorMessage: await readErrorMessage(res), - } satisfies InitialSettingsLoadResult - } - - return { - settings: normalizeAppSettings(await res.json()), - errorMessage: null, - } satisfies InitialSettingsLoadResult - } catch { - return { - settings: DEFAULT_APP_SETTINGS, - errorMessage: null, - } satisfies InitialSettingsLoadResult - } -} - async function bootstrap() { const { settings: initialSettings, errorMessage: initialSettingsError } = - await loadInitialSettings() + await loadBootstrapSettings() applyTheme(initialSettings.theme) await initI18n(initialSettings.language) diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index 2e0725a..a129c28 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchSettings, updateSettings } from '@/lib/api' +import { fetchSettings, loadBootstrapSettings, updateSettings } from '@/lib/api' +import { DEFAULT_APP_SETTINGS } from '@/lib/app-settings' import { initI18n } from '@/lib/i18n' describe('api error handling', () => { @@ -54,4 +55,22 @@ describe('api error handling', () => { await expect(fetchSettings()).rejects.toThrow('Settings file is unreadable or corrupted.') }) + + it('returns bootstrap defaults together with the localized error message', async () => { + await initI18n('en') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{}', { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect(loadBootstrapSettings()).resolves.toEqual({ + settings: DEFAULT_APP_SETTINGS, + errorMessage: 'Failed to load settings', + }) + }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 4d86682..a1e7486 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,12 @@ export default defineConfig(async () => { provider: 'v8', reporter: ['text', 'html', 'lcov'], reportsDirectory: './coverage', - include: ['src/hooks/**/*.ts', 'src/lib/**/*.ts', 'usage-normalizer.js'], + include: [ + 'src/hooks/**/*.ts', + 'src/lib/**/*.ts', + 'src/components/Dashboard.tsx', + 'usage-normalizer.js', + ], exclude: [ 'src/lib/i18n.ts', 'src/lib/constants.ts', From 89cbca4fca3a9ec9488ac392baac343141bc22e9 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 00:36:34 +0200 Subject: [PATCH 09/12] v6.1.9: Update changelog --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a8f20..4fd93c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [6.1.9] - 2026-04-14 + +### Added + +- **Klare Recovery-Flows für beschädigte lokale Daten** — die App zeigt korrupte Settings- oder Usage-Dateien jetzt als expliziten Fehlerzustand mit direkten Reset- und Löschaktionen statt als irreführenden Leerzustand +- **Architekturdokumentation für die aktuelle Systemstruktur** — eine neue Architekturübersicht beschreibt die Grenzen zwischen lokalem Server, Frontend, Shared-Domainlogik und Packaging für die weitere Wartung + +### Improved + +- **Barrierefreiheit und Informationsqualität in zentralen Dashboard-Flächen** — Top-Level-Filter haben jetzt stabile zugängliche Namen, Info-Buttons sind semantisch sauber von Headings getrennt, und das Help-Panel zeigt vollständig benannte und fachlich besser gruppierte Inhalte +- **Lokalisierung und Terminologiekonsistenz in Analyse- und Tooltip-Flächen** — gemischte deutsche und englische UI-Begriffe wurden bereinigt, Tooltip-Texte lokalisiert und die verbleibenden Accessibility-/i18n-Regressionen durch zusätzliche Tests abgesichert +- **Robustere lokale API-Grenzen und Auto-Import-Sicherheit** — mutierende Endpunkte akzeptieren nur noch erlaubte Request-Formen, Cross-Site-Zugriffe werden abgewehrt, Auto-Import verwendet keine mutierende `GET`-Route mehr, und non-loopback Binding erfordert jetzt ein explizites Remote-Opt-in +- **Sicherere lokale Persistenz und Exportpfade** — Daten- und Settings-Dateien werden restriktiver geschrieben, CSV-Exporte escapen Sonderzeichen korrekt, und serverseitige Fatal-Load-Fehler werden bis in die UI transparent durchgereicht +- **Nachhaltigere Architektur für Dashboard, Report und Server-Runtime** — gemeinsame Dashboard-/Report-Domainlogik, ein entschlackter Dashboard-Controller und erste Server-Module reduzieren Drift, verbessern Testbarkeit und schaffen klarere Verantwortungsgrenzen + +### Fixed + +- **Windows-Kompatibilität beim Auto-Import und Child-Process-Start** — die Runner-Ausführung funktioniert auf Windows jetzt zuverlässig ohne die zuvor fehleranfällige Prozessinitialisierung + ## [6.1.8] ### Added From cc0b5e4eae5c67ee0a5feb6ad3b29039adbc4e78 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 01:09:34 +0200 Subject: [PATCH 10/12] Fix CodeRabbit review findings --- docs/architecture.md | 2 +- server.js | 9 +- server/http-utils.js | 18 +-- server/runtime.js | 11 +- shared/dashboard-domain.d.ts | 2 +- shared/dashboard-domain.js | 23 +++- shared/dashboard-types.d.ts | 62 ++++++++++ src/components/LoadErrorState.tsx | 4 +- src/components/cards/MonthMetrics.tsx | 9 +- src/components/cards/PrimaryMetrics.tsx | 7 +- src/components/charts/ChartCard.tsx | 7 +- .../features/drill-down/DrillDownModal.tsx | 22 +++- .../features/heatmap/HeatmapCalendar.tsx | 13 +- .../features/settings/SettingsModal.tsx | 3 +- src/components/ui/expandable-card.tsx | 2 +- src/hooks/use-dashboard-controller.ts | 21 +++- src/lib/auto-import.ts | 52 ++++---- src/lib/dashboard-preferences.ts | 101 ++++++++++++++- src/lib/model-utils.ts | 4 +- src/locales/de/common.json | 1 + src/locales/en/common.json | 1 + tests/e2e/dashboard.spec.ts | 2 +- tests/frontend/chart-card.test.tsx | 4 +- tests/frontend/dashboard-error-state.test.tsx | 101 +++++++++++++++ .../frontend/de-analysis-terminology.test.tsx | 6 + tests/frontend/expandable-card.test.tsx | 2 +- tests/frontend/heatmap-calendar.test.tsx | 41 +++++- tests/frontend/metric-ratio-locale.test.tsx | 117 ++++++++++++++++++ tests/frontend/phase4-correctness.test.tsx | 2 - tests/integration/server.test.ts | 54 ++++++++ tests/unit/api.test.ts | 2 - tests/unit/auto-import.test.ts | 41 ++++++ tests/unit/dashboard-preferences.test.ts | 25 ++++ tests/unit/http-utils.test.ts | 67 ++++++++++ tests/unit/model-normalization.test.ts | 8 +- tests/unit/report-utils.test.ts | 14 ++- tests/unit/server-helpers.test.ts | 12 ++ 37 files changed, 784 insertions(+), 88 deletions(-) create mode 100644 shared/dashboard-types.d.ts create mode 100644 tests/frontend/metric-ratio-locale.test.tsx create mode 100644 tests/unit/dashboard-preferences.test.ts create mode 100644 tests/unit/http-utils.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 79afc96..ccec346 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ Frontend settings normalization lives in `src/lib/app-settings.ts`. Bootstrap lo ## Current Server Structure -`server.js` is still the public entrypoint and still owns several runtime responsibilities. The current refactor reduces contract drift and shared-domain duplication first, while keeping the published CLI interface stable. Further modularization of the server runtime should continue from the current seams: +`server.js` is still the public entrypoint and still owns several runtime responsibilities. The current refactor reduces contract drift and shared-domain duplication first, while keeping the published CLI stable. Further modularization of the server runtime should continue from the current seams: - runtime/bootstrap - persistence/settings diff --git a/server.js b/server.js index f5c9460..a4e4a51 100755 --- a/server.js +++ b/server.js @@ -32,7 +32,7 @@ const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_ST const MAX_PORT = Math.min(START_PORT + 100, 65535); const BIND_HOST = process.env.HOST || '127.0.0.1'; const ALLOW_REMOTE_BIND = process.env.TTDASH_ALLOW_REMOTE === '1'; -const API_PREFIX = '/port/5000/api'; +const API_PREFIX = process.env.API_PREFIX || '/api'; const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB const IS_WINDOWS = process.platform === 'win32'; const SECURE_DIR_MODE = 0o700; @@ -288,9 +288,8 @@ const MIME_TYPES = { }; function ensureDir(dirPath) { - const existed = fs.existsSync(dirPath); fs.mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE }); - if (!IS_WINDOWS && !existed) { + if (!IS_WINDOWS) { fs.chmodSync(dirPath, SECURE_DIR_MODE); } } @@ -1702,6 +1701,10 @@ const server = http.createServer(async (req, res) => { // API routing const apiPath = resolveApiPath(pathname); + if (apiPath === null && (pathname === '/api' || pathname.startsWith('/api/'))) { + return json(res, 404, { message: 'Not Found' }); + } + if (apiPath === '/usage') { if (req.method === 'GET') { let data; diff --git a/server/http-utils.js b/server/http-utils.js index cf0241e..9879f52 100644 --- a/server/http-utils.js +++ b/server/http-utils.js @@ -9,6 +9,7 @@ function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) { req.off('data', onData); req.off('end', onEnd); req.off('error', onError); + req.off('close', onClose); }; const rejectOnce = (error) => { @@ -52,9 +53,16 @@ function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) { rejectOnce(error); }; + const onClose = () => { + if (!req.readableEnded) { + rejectOnce(new Error('Request body stream closed before the payload finished')); + } + }; + req.on('data', onData); req.on('end', onEnd); req.on('error', onError); + req.on('close', onClose); }); } @@ -76,17 +84,11 @@ function createHttpUtils({ apiPrefix, maxBodySize, securityHeaders }) { } function resolveApiPath(pathname) { - if (pathname.startsWith(apiPrefix + '/')) { - return pathname.slice(apiPrefix.length); - } if (pathname === apiPrefix) { return '/'; } - if (pathname.startsWith('/api/')) { - return pathname.slice(4); - } - if (pathname === '/api') { - return '/'; + if (pathname.startsWith(apiPrefix + '/')) { + return pathname.slice(apiPrefix.length); } return null; } diff --git a/server/runtime.js b/server/runtime.js index f39e71c..42e27af 100644 --- a/server/runtime.js +++ b/server/runtime.js @@ -1,5 +1,14 @@ function isLoopbackHost(host) { - return host === '127.0.0.1' || host === 'localhost' || host === '::1'; + const normalized = String(host || '') + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); + return ( + normalized === '127.0.0.1' || + normalized === 'localhost' || + normalized === '::1' || + normalized === '::ffff:127.0.0.1' + ); } function ensureBindHostAllowed(bindHost, allowRemoteBind) { diff --git a/shared/dashboard-domain.d.ts b/shared/dashboard-domain.d.ts index c575003..a2a17db 100644 --- a/shared/dashboard-domain.d.ts +++ b/shared/dashboard-domain.d.ts @@ -1,4 +1,4 @@ -import type { DailyUsage, DashboardMetrics, ViewMode } from '../src/types' +import type { DailyUsage, DashboardMetrics, ViewMode } from './dashboard-types' export function aggregateToDailyFormat(data: DailyUsage[], viewMode: ViewMode): DailyUsage[] export function computeBusiestWeek( diff --git a/shared/dashboard-domain.js b/shared/dashboard-domain.js index 03cff88..72c6c3c 100644 --- a/shared/dashboard-domain.js +++ b/shared/dashboard-domain.js @@ -50,12 +50,23 @@ function canonicalizeModelName(raw) { } function parseClaudeName(rest) { - const parts = rest.split('-', 2) + const parts = rest.split('-') if (parts.length < 2) { return `Claude ${capitalize(rest)}` } - return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim() + const family = capitalize(parts[0] || '') + const secondPart = parts[1] || '' + + if (/^\d+$/.test(secondPart)) { + const version = formatVersion(parts.slice(1).join('-')) + return ['Claude', family, version].filter(Boolean).join(' ').trim() + } + + const model = capitalize(secondPart) + const version = formatVersion(parts.slice(2).join('-')) + + return ['Claude', family, model, version].filter(Boolean).join(' ').trim() } function parseGptName(rest) { @@ -124,14 +135,14 @@ function parseOSeries(name) { function normalizeModelName(raw) { const canonical = canonicalizeModelName(raw) - for (const alias of DISPLAY_ALIASES) { - if (alias.matcher.test(canonical)) return alias.name - } - if (canonical.startsWith('claude-')) { return parseClaudeName(canonical.slice('claude-'.length)) } + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) return alias.name + } + if (canonical.startsWith('gpt-')) { return parseGptName(canonical.slice('gpt-'.length)) } diff --git a/shared/dashboard-types.d.ts b/shared/dashboard-types.d.ts new file mode 100644 index 0000000..1b0c462 --- /dev/null +++ b/shared/dashboard-types.d.ts @@ -0,0 +1,62 @@ +export interface ModelBreakdown { + modelName: string + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + cost: number + requestCount: number +} + +export interface DailyUsage { + date: string + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + totalTokens: number + totalCost: number + requestCount: number + modelsUsed: string[] + modelBreakdowns: ModelBreakdown[] + _aggregatedDays?: number +} + +export type ViewMode = 'daily' | 'monthly' | 'yearly' + +export interface DashboardMetrics { + totalCost: number + totalTokens: number + activeDays: number + topModel: { name: string; cost: number } | null + topRequestModel: { name: string; requests: number } | null + topTokenModel: { name: string; tokens: number } | null + topModelShare: number + topThreeModelsShare: number + topProvider: { name: string; cost: number; share: number } | null + providerCount: number + hasRequestData: boolean + cacheHitRate: number + costPerMillion: number + avgTokensPerRequest: number + avgCostPerRequest: number + avgModelsPerEntry: number + avgDailyCost: number + avgRequestsPerDay: number + topDay: { date: string; cost: number } | null + cheapestDay: { date: string; cost: number } | null + busiestWeek: { start: string; end: string; cost: number } | null + weekendCostShare: number | null + totalInput: number + totalOutput: number + totalCacheRead: number + totalCacheCreate: number + totalThinking: number + totalRequests: number + weekOverWeekChange: number | null + requestVolatility: number + modelConcentrationIndex: number + providerConcentrationIndex: number +} diff --git a/src/components/LoadErrorState.tsx b/src/components/LoadErrorState.tsx index da5b3be..e144127 100644 --- a/src/components/LoadErrorState.tsx +++ b/src/components/LoadErrorState.tsx @@ -44,8 +44,8 @@ export function LoadErrorState({ {detailLabel}
    - {details.map((detail) => ( -
  • {detail}
  • + {details.map((detail, index) => ( +
  • {detail}
  • ))}
diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index 7fd542c..ee633dd 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -16,6 +16,7 @@ import { SectionHeader } from '@/components/ui/section-header' import { FadeIn } from '@/components/features/animations/FadeIn' import { SECTION_HELP } from '@/lib/help-content' import { formatCurrency, formatMonthYear, localMonth } from '@/lib/formatters' +import { getCurrentLocale } from '@/lib/i18n' import { normalizeModelName } from '@/lib/model-utils' import type { DailyUsage, DashboardMetrics } from '@/types' @@ -26,6 +27,7 @@ interface MonthMetricsProps { export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const { t } = useTranslation() + const locale = getCurrentLocale() const currentMonth = localMonth() const monthData = useMemo( @@ -101,7 +103,12 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const ioTotal = agg.inputTokens + agg.outputTokens const tokensSubtitle = agg.inputTokens > 0 && agg.outputTokens > 0 - ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) + ? t('metricCards.month.ioRatio', { + value: new Intl.NumberFormat(locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(agg.inputTokens / agg.outputTokens), + }) : null const modelsSubtitle = agg.topModel ? t('metricCards.month.topModel', { value: agg.topModel.name }) diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index ed07c59..c022000 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -13,6 +13,7 @@ import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { formatCurrency, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' import { METRIC_HELP } from '@/lib/help-content' +import { getCurrentLocale } from '@/lib/i18n' import type { DashboardMetrics, ViewMode } from '@/types' interface PrimaryMetricsProps { @@ -27,10 +28,14 @@ export function PrimaryMetrics({ viewMode = 'daily', }: PrimaryMetricsProps) { const { t } = useTranslation() + const locale = getCurrentLocale() // Calculate input/output ratio const ioRatio = metrics.totalInput > 0 && metrics.totalOutput > 0 - ? (metrics.totalInput / metrics.totalOutput).toFixed(1) + ? new Intl.NumberFormat(locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(metrics.totalInput / metrics.totalOutput) : null const coverageRate = diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index 0ee400b..14fcc6b 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/compone import { Maximize2 } from 'lucide-react' import { InfoButton } from '@/components/features/help/InfoButton' import { cn } from '@/lib/cn' -import { buildCsvLine, stringifyCsvCell } from '@/lib/csv' +import { buildCsvLine } from '@/lib/csv' import { formatCurrency } from '@/lib/formatters' export { stringifyCsvCell } from '@/lib/csv' @@ -42,7 +42,7 @@ export function buildChartCsv(chartData: Record[]): string { const keys = Object.keys(firstRow) return [ buildCsvLine(keys), - ...chartData.map((row) => keys.map((key) => stringifyCsvCell(row[key])).join(',')), + ...chartData.map((row) => buildCsvLine(keys.map((key) => row[key]))), ].join('\n') } @@ -185,7 +185,7 @@ export function ChartCard({ onClick={() => setExpanded(true)} className="absolute top-3 right-3 z-10 opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity duration-200 p-1.5 rounded-lg bg-background/80 backdrop-blur-sm border border-border/50 hover:bg-accent text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" title={t('common.expand')} - aria-label={`${title} ${t('common.expand').toLowerCase()}`} + aria-label={t('common.expandWithTitle', { title })} > @@ -210,6 +210,7 @@ export function ChartCard({
{chartData && chartData.length > 0 && ( diff --git a/src/hooks/use-dashboard-controller.ts b/src/hooks/use-dashboard-controller.ts index 20c69ff..747a007 100644 --- a/src/hooks/use-dashboard-controller.ts +++ b/src/hooks/use-dashboard-controller.ts @@ -363,7 +363,12 @@ export function useDashboardController(initialSettingsError: string | null = nul try { const parsed: unknown = JSON.parse(await file.text()) - await uploadMutation.mutateAsync(parsed) + try { + await uploadMutation.mutateAsync(parsed) + } catch (error) { + addToast(normalizeErrorMessage(error) ?? t('toasts.fileReadFailed'), 'error') + return + } void queryClient.invalidateQueries({ queryKey: ['settings'] }) setAnimationSeed((previous) => previous + 1) const now = new Date() @@ -388,11 +393,15 @@ export function useDashboardController(initialSettingsError: string | null = nul ) const handleDelete = useCallback(async () => { - await deleteMutation.mutateAsync() - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed((previous) => previous + 1) - setDataSource(null) - addToast(t('toasts.dataDeleted'), 'info') + try { + await deleteMutation.mutateAsync() + void queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((previous) => previous + 1) + setDataSource(null) + addToast(t('toasts.dataDeleted'), 'info') + } catch (error) { + addToast(normalizeErrorMessage(error) ?? t('toasts.deleteFailed'), 'error') + } }, [deleteMutation, queryClient, addToast, t]) const settingsErrorMessage = diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 1011a53..361e967 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -165,6 +165,26 @@ export function startAutoImport( dispatchEvent(normalizedType, dataLines.join('\n')) } + const processLines = (lines: string[], state: { currentEvent: string; dataLines: string[] }) => { + for (const line of lines) { + if (!line) { + flushEvent(state.currentEvent, state.dataLines) + state.currentEvent = '' + state.dataLines = [] + continue + } + + if (line.startsWith('event:')) { + state.currentEvent = line.slice('event:'.length).trim() + continue + } + + if (line.startsWith('data:')) { + state.dataLines.push(line.slice('data:'.length).trimStart()) + } + } + } + const readStream = async () => { const response = await fetch('/api/auto-import/stream', { method: 'POST', @@ -198,13 +218,20 @@ export function startAutoImport( const reader = response.body.getReader() let buffer = '' - let currentEvent = '' - let dataLines: string[] = [] + const state = { + currentEvent: '', + dataLines: [] as string[], + } while (true) { const { value, done: streamDone } = await reader.read() if (streamDone) { - flushEvent(currentEvent, dataLines) + buffer += decoder.decode() + if (buffer) { + processLines(buffer.split(/\r?\n/), state) + buffer = '' + } + flushEvent(state.currentEvent, state.dataLines) finish() return } @@ -212,24 +239,7 @@ export function startAutoImport( buffer += decoder.decode(value, { stream: true }) const lines = buffer.split(/\r?\n/) buffer = lines.pop() ?? '' - - for (const line of lines) { - if (!line) { - flushEvent(currentEvent, dataLines) - currentEvent = '' - dataLines = [] - continue - } - - if (line.startsWith('event:')) { - currentEvent = line.slice('event:'.length).trim() - continue - } - - if (line.startsWith('data:')) { - dataLines.push(line.slice('data:'.length).trimStart()) - } - } + processLines(lines, state) } } diff --git a/src/lib/dashboard-preferences.ts b/src/lib/dashboard-preferences.ts index 15a12d6..29095e7 100644 --- a/src/lib/dashboard-preferences.ts +++ b/src/lib/dashboard-preferences.ts @@ -14,10 +14,103 @@ export interface DashboardSectionDefinition { labelKey: string } -export const DASHBOARD_DATE_PRESETS = dashboardPreferences.datePresets as DashboardDatePreset[] -export const DASHBOARD_VIEW_MODES = dashboardPreferences.viewModes as ViewMode[] -export const DASHBOARD_SECTION_DEFINITIONS = - dashboardPreferences.sectionDefinitions as DashboardSectionDefinition[] +type DashboardPreferencesConfig = { + datePresets: DashboardDatePreset[] + viewModes: ViewMode[] + sectionDefinitions: DashboardSectionDefinition[] +} + +const VALID_DATE_PRESETS: DashboardDatePreset[] = ['all', '7d', '30d', 'month', 'year'] +const VALID_VIEW_MODES: ViewMode[] = ['daily', 'monthly', 'yearly'] +const VALID_SECTION_IDS: DashboardSectionId[] = [ + 'insights', + 'metrics', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', +] + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function validateStringArray( + value: unknown, + validValues: readonly T[], + fieldName: string, +): T[] { + if (!Array.isArray(value)) { + throw new Error(`Invalid dashboard preferences: "${fieldName}" must be an array.`) + } + + const entries: unknown[] = value + const invalidEntry = entries.find( + (entry) => typeof entry !== 'string' || !validValues.includes(entry as T), + ) + if (invalidEntry !== undefined) { + throw new Error(`Invalid dashboard preferences: "${fieldName}" contains unsupported values.`) + } + + return entries.map((entry) => entry as T) +} + +function validateSectionDefinitions(value: unknown): DashboardSectionDefinition[] { + if (!Array.isArray(value)) { + throw new Error('Invalid dashboard preferences: "sectionDefinitions" must be an array.') + } + + return value.map((entry) => { + if (!isPlainObject(entry)) { + throw new Error( + 'Invalid dashboard preferences: each "sectionDefinitions" entry must be an object.', + ) + } + + const { id, domId, labelKey } = entry + if (typeof id !== 'string' || !VALID_SECTION_IDS.includes(id as DashboardSectionId)) { + throw new Error('Invalid dashboard preferences: sectionDefinitions contain an unknown id.') + } + if (typeof domId !== 'string' || !domId.trim()) { + throw new Error('Invalid dashboard preferences: sectionDefinitions require a domId.') + } + if (typeof labelKey !== 'string' || !labelKey.trim()) { + throw new Error('Invalid dashboard preferences: sectionDefinitions require a labelKey.') + } + + return { + id: id as DashboardSectionId, + domId, + labelKey, + } + }) +} + +export function parseDashboardPreferencesConfig(value: unknown): DashboardPreferencesConfig { + if (!isPlainObject(value)) { + throw new Error('Invalid dashboard preferences: expected an object.') + } + + return { + datePresets: validateStringArray(value['datePresets'], VALID_DATE_PRESETS, 'datePresets'), + viewModes: validateStringArray(value['viewModes'], VALID_VIEW_MODES, 'viewModes'), + sectionDefinitions: validateSectionDefinitions(value['sectionDefinitions']), + } +} + +const rawDashboardPreferences: unknown = dashboardPreferences +const parsedDashboardPreferences = parseDashboardPreferencesConfig(rawDashboardPreferences) + +export const DASHBOARD_DATE_PRESETS = parsedDashboardPreferences.datePresets +export const DASHBOARD_VIEW_MODES = parsedDashboardPreferences.viewModes +export const DASHBOARD_SECTION_DEFINITIONS = parsedDashboardPreferences.sectionDefinitions export const DASHBOARD_SECTION_DEFINITION_MAP = Object.fromEntries( DASHBOARD_SECTION_DEFINITIONS.map((section) => [section.id, section]), ) as Record diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts index 2a5d469..4f049f8 100644 --- a/src/lib/model-utils.ts +++ b/src/lib/model-utils.ts @@ -1,4 +1,4 @@ -import { MODEL_COLORS, MODEL_COLOR_DEFAULT } from './constants' +import { MODEL_COLORS } from './constants' import { getModelProvider as getSharedModelProvider, normalizeModelName as normalizeSharedModelName, @@ -134,7 +134,7 @@ export function getProviderBadgeStyle(provider: string): { } export function getModelColor(name: string): string { - return MODEL_COLORS[name] ?? dynamicColor(name) ?? MODEL_COLOR_DEFAULT + return MODEL_COLORS[name] ?? dynamicColor(name) } export function getUniqueModels(modelsUsed: string[][]): string[] { diff --git a/src/locales/de/common.json b/src/locales/de/common.json index c821acf..02e2f4e 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -99,6 +99,7 @@ "focusMonth": "Fokusmonat", "showInfo": "Info anzeigen", "expand": "Vergrössern", + "expandWithTitle": "{{title}} vergrössern", "expandedCardDescription": "Erweiterte Kartenansicht mit zusätzlichen Kennzahlen und vollständigem Inhalt.", "input": "Input", "output": "Output", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 933fde7..3e2c827 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -99,6 +99,7 @@ "focusMonth": "Focus month", "showInfo": "Show info", "expand": "Expand", + "expandWithTitle": "{{title}} expand", "expandedCardDescription": "Expanded card view with additional metrics and full content.", "input": "Input", "output": "Output", diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index fb425cb..f46e6b1 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -442,7 +442,7 @@ test('loads persisted settings on a fresh browser start and applies them immedia await expect(freshPage.locator('#filters').getByText('1 models active')).toBeVisible() await expect( freshPage.locator('#filters').getByRole('combobox', { name: viewModeComboboxPattern }), - ).toContainText('Monthly view') + ).toContainText(monthlyViewPattern) await expect(freshPage.getByRole('button', { name: 'Delete' })).toBeVisible() await expect .poll(async () => diff --git a/tests/frontend/chart-card.test.tsx b/tests/frontend/chart-card.test.tsx index e422a76..4cc8c88 100644 --- a/tests/frontend/chart-card.test.tsx +++ b/tests/frontend/chart-card.test.tsx @@ -32,7 +32,7 @@ describe('ChartCard', () => { , ) - fireEvent.click(screen.getByRole('button', { name: /demo chart expand/i })) + fireEvent.click(screen.getByRole('button', { name: 'Demo chart expand' })) expect(screen.getByText('Total')).toBeInTheDocument() expect(screen.getByText('Data points')).toBeInTheDocument() @@ -45,7 +45,7 @@ describe('ChartCard', () => { , ) - const button = screen.getByRole('button', { name: /demo chart expand/i }) + const button = screen.getByRole('button', { name: 'Demo chart expand' }) expect(button.className).toContain('md:group-focus-within:opacity-100') expect(button.className).toContain('focus-visible:opacity-100') }) diff --git a/tests/frontend/dashboard-error-state.test.tsx b/tests/frontend/dashboard-error-state.test.tsx index 263b030..c11cca3 100644 --- a/tests/frontend/dashboard-error-state.test.tsx +++ b/tests/frontend/dashboard-error-state.test.tsx @@ -155,4 +155,105 @@ describe('Dashboard fatal load state', () => { await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) }) + + it('shows the backend upload error instead of masking it as a file-read failure', async () => { + const mutateAsync = vi.fn().mockRejectedValue(new Error('Usage payload is invalid')) + + usageHookMocks.useUsageData.mockReturnValue({ + data: { + daily: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + }, + isLoading: false, + error: null, + }) + usageHookMocks.useUploadData.mockReturnValue({ + mutateAsync, + }) + + render(, { + wrapper: createWrapper(), + }) + + const input = screen.getByTestId('usage-upload-input') as HTMLInputElement + const file = new File([JSON.stringify({ daily: [] })], 'usage.json', { + type: 'application/json', + }) + + fireEvent.change(input, { target: { files: [file] } }) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(screen.getByText('Usage payload is invalid')).toBeInTheDocument()) + expect(screen.queryByText('Could not read file')).not.toBeInTheDocument() + }) + + it('keeps the file-read toast for malformed JSON uploads', async () => { + const mutateAsync = vi.fn() + + usageHookMocks.useUsageData.mockReturnValue({ + data: { + daily: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + }, + isLoading: false, + error: null, + }) + usageHookMocks.useUploadData.mockReturnValue({ + mutateAsync, + }) + + render(, { + wrapper: createWrapper(), + }) + + const input = screen.getByTestId('usage-upload-input') as HTMLInputElement + const file = new File(['{"daily":'], 'broken.json', { + type: 'application/json', + }) + + fireEvent.change(input, { target: { files: [file] } }) + + await waitFor(() => expect(screen.getByText('Could not read file')).toBeInTheDocument()) + expect(mutateAsync).not.toHaveBeenCalled() + }) + + it('shows a failure toast when deleting corrupted stored data fails', async () => { + const mutateAsync = vi.fn().mockRejectedValue(new Error('Delete request failed')) + + usageHookMocks.useUsageData.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Usage data file is unreadable or corrupted.'), + }) + usageHookMocks.useDeleteData.mockReturnValue({ + mutateAsync, + }) + + render(, { + wrapper: createWrapper(), + }) + + fireEvent.click(screen.getByRole('button', { name: 'Delete stored data' })) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(screen.getByText('Delete request failed')).toBeInTheDocument()) + }) }) diff --git a/tests/frontend/de-analysis-terminology.test.tsx b/tests/frontend/de-analysis-terminology.test.tsx index 586986e..b5f1f7b 100644 --- a/tests/frontend/de-analysis-terminology.test.tsx +++ b/tests/frontend/de-analysis-terminology.test.tsx @@ -11,9 +11,15 @@ import type { AggregateMetrics, ViewMode } from '@/types' describe('German analysis terminology', () => { beforeEach(async () => { globalThis.IntersectionObserver = class IntersectionObserver { + readonly root: Element | Document | null = null + readonly rootMargin = '' + readonly thresholds: ReadonlyArray = [] observe() {} unobserve() {} disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return [] + } } as typeof IntersectionObserver await initI18n('de') }) diff --git a/tests/frontend/expandable-card.test.tsx b/tests/frontend/expandable-card.test.tsx index 0c11de1..c64d39e 100644 --- a/tests/frontend/expandable-card.test.tsx +++ b/tests/frontend/expandable-card.test.tsx @@ -17,7 +17,7 @@ describe('ExpandableCard', () => { , ) - const button = screen.getByRole('button', { name: /forecast vergrössern/i }) + const button = screen.getByRole('button', { name: 'Forecast vergrössern' }) expect(button.className).toContain('group-focus-within:opacity-100') fireEvent.click(button) diff --git a/tests/frontend/heatmap-calendar.test.tsx b/tests/frontend/heatmap-calendar.test.tsx index e610ab9..90eb516 100644 --- a/tests/frontend/heatmap-calendar.test.tsx +++ b/tests/frontend/heatmap-calendar.test.tsx @@ -1,11 +1,11 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { HeatmapCalendar } from '@/components/features/heatmap/HeatmapCalendar' import { TooltipProvider } from '@/components/ui/tooltip' import { formatCurrency } from '@/lib/formatters' -import { initI18n } from '@/lib/i18n' +import i18n, { initI18n } from '@/lib/i18n' import type { DailyUsage } from '@/types' describe('HeatmapCalendar', () => { @@ -55,4 +55,41 @@ describe('HeatmapCalendar', () => { fireEvent.focus(cell) expect(await screen.findByText(formatCurrency(5))).toBeInTheDocument() }) + + it('updates weekday and aria labels when the language changes at runtime', async () => { + const day: DailyUsage = { + date: '2026-04-07', + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 15, + totalCost: 5, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + } + + const { rerender } = render( + + + , + ) + + expect(screen.getByText('We')).toBeInTheDocument() + expect(screen.getByRole('img', { name: /April 7, 2026/ })).toBeInTheDocument() + + await i18n.changeLanguage('de') + rerender( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Mi')).toBeInTheDocument() + }) + expect(screen.getByRole('img', { name: /7\. April 2026/ })).toBeInTheDocument() + }) }) diff --git a/tests/frontend/metric-ratio-locale.test.tsx b/tests/frontend/metric-ratio-locale.test.tsx new file mode 100644 index 0000000..91761dd --- /dev/null +++ b/tests/frontend/metric-ratio-locale.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MonthMetrics } from '@/components/cards/MonthMetrics' +import { PrimaryMetrics } from '@/components/cards/PrimaryMetrics' +import { TooltipProvider } from '@/components/ui/tooltip' +import { getCurrentLocale, initI18n } from '@/lib/i18n' +import type { DailyUsage, DashboardMetrics } from '@/types' + +const metrics: DashboardMetrics = { + totalCost: 10, + totalTokens: 25, + activeDays: 2, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: null, + providerCount: 1, + hasRequestData: true, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 12.5, + avgCostPerRequest: 5, + avgModelsPerEntry: 1, + avgDailyCost: 5, + avgRequestsPerDay: 2, + topDay: null, + cheapestDay: null, + busiestWeek: null, + weekendCostShare: null, + totalInput: 15, + totalOutput: 10, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 4, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, +} + +describe('metric ratio localization', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('de') + }) + + it('formats the primary metrics I/O ratio with the active locale', () => { + const ratio = new Intl.NumberFormat(getCurrentLocale(), { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(1.5) + + render( + + + , + ) + + expect(document.body.textContent).toContain(`${ratio}:1`) + }) + + it('formats the month metrics I/O ratio with the active locale', () => { + const ratio = new Intl.NumberFormat(getCurrentLocale(), { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(1.5) + + const daily: DailyUsage[] = [ + { + date: '2026-04-02', + inputTokens: 10, + outputTokens: 6, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 16, + totalCost: 4, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + }, + { + date: '2026-04-04', + inputTokens: 5, + outputTokens: 4, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 9, + totalCost: 6, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + }, + ] + + render( + + + , + ) + + expect(document.body.textContent).toContain(`${ratio}:1`) + }) +}) diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx index 53955c0..ed08008 100644 --- a/tests/frontend/phase4-correctness.test.tsx +++ b/tests/frontend/phase4-correctness.test.tsx @@ -160,8 +160,6 @@ describe('phase 4 UI correctness', () => { }) it('localizes drill-down labels in English', async () => { - await initI18n('en') - const day: DailyUsage = { date: '2026-04-07', inputTokens: 60, diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 16ae237..58e2f7b 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,6 +1,7 @@ import { createConnection, createServer } from 'node:net' import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -649,6 +650,33 @@ describe('local server API', () => { } }) + it('serves the API only from the configured API prefix', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-api-prefix-test-')) + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + envOverrides: { + API_PREFIX: '/custom-api', + }, + readinessPath: '/custom-api/usage', + }) + + const customPrefixResponse = await fetch(`${standaloneServer.url}/custom-api/usage`) + expect(customPrefixResponse.status).toBe(200) + + const defaultPrefixResponse = await fetch(`${standaloneServer.url}/api/usage`) + expect(defaultPrefixResponse.status).toBe(404) + expect(await defaultPrefixResponse.json()).toEqual({ message: 'Not Found' }) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + itIfPosix('writes persisted data and settings with restrictive local permissions', async () => { const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-permissions-test-')) const dataFile = path.join(getCliDataDir(runtimeRoot), 'data.json') @@ -688,6 +716,32 @@ describe('local server API', () => { } }) + itIfPosix('tightens existing app directories to restrictive permissions on startup', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-existing-dir-permissions-test-')) + const dataDir = getCliDataDir(runtimeRoot) + const configDir = getCliConfigDir(runtimeRoot) + let standaloneServer: Awaited> | null = null + + try { + mkdirSync(dataDir, { recursive: true, mode: 0o755 }) + mkdirSync(configDir, { recursive: true, mode: 0o755 }) + chmodSync(dataDir, 0o755) + chmodSync(configDir, 0o755) + + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + }) + + expect(permissionBits(dataDir)).toBe(0o700) + expect(permissionBits(configDir)).toBe(0o700) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + it('imports settings backups and merges usage backups without overwriting conflicting local days', async () => { const seedResponse = await fetch(`${baseUrl}/api/upload`, { method: 'POST', diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index a129c28..bc323f1 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -28,7 +28,6 @@ describe('api error handling', () => { }) it('uses the localized fallback when saving settings fails without a server message', async () => { - await initI18n('en') vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( @@ -57,7 +56,6 @@ describe('api error handling', () => { }) it('returns bootstrap defaults together with the localized error message', async () => { - await initI18n('en') vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( diff --git a/tests/unit/auto-import.test.ts b/tests/unit/auto-import.test.ts index 707376e..654ed43 100644 --- a/tests/unit/auto-import.test.ts +++ b/tests/unit/auto-import.test.ts @@ -171,4 +171,45 @@ describe('startAutoImport', () => { }) expect(callbacks.onSuccess).not.toHaveBeenCalled() }) + + it('flushes the decoder tail so the final streamed event is not dropped', async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: success\ndata: {"days":7,')) + controller.enqueue(encoder.encode('"totalCost":12.5}')) + controller.close() + }, + }) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + }, + }), + ), + ) + + const callbacks = { + onCheck: vi.fn(), + onProgress: vi.fn(), + onStderr: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + onDone: vi.fn(), + } + + startAutoImport(callbacks, translate) + + await vi.waitFor(() => { + expect(callbacks.onDone).toHaveBeenCalledTimes(1) + }) + + expect(callbacks.onSuccess).toHaveBeenCalledWith({ days: 7, totalCost: 12.5 }) + expect(callbacks.onError).not.toHaveBeenCalled() + }) }) diff --git a/tests/unit/dashboard-preferences.test.ts b/tests/unit/dashboard-preferences.test.ts new file mode 100644 index 0000000..4404370 --- /dev/null +++ b/tests/unit/dashboard-preferences.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import dashboardPreferences from '../../shared/dashboard-preferences.json' +import { + DASHBOARD_SECTION_DEFINITIONS, + parseDashboardPreferencesConfig, +} from '@/lib/dashboard-preferences' + +describe('dashboard preferences config', () => { + it('parses the shared dashboard preferences JSON into a validated config', () => { + const parsed = parseDashboardPreferencesConfig(dashboardPreferences) + + expect(parsed.sectionDefinitions).toEqual(DASHBOARD_SECTION_DEFINITIONS) + expect(parsed.viewModes).toContain('monthly') + }) + + it('fails fast when the shared preferences JSON drifts from the expected shape', () => { + expect(() => + parseDashboardPreferencesConfig({ + datePresets: ['all'], + viewModes: ['daily', 'weekly'], + sectionDefinitions: [{ id: 'metrics', domId: 'metrics' }], + }), + ).toThrow('Invalid dashboard preferences') + }) +}) diff --git a/tests/unit/http-utils.test.ts b/tests/unit/http-utils.test.ts new file mode 100644 index 0000000..7d5ebd6 --- /dev/null +++ b/tests/unit/http-utils.test.ts @@ -0,0 +1,67 @@ +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { createHttpUtils } = require('../../server/http-utils.js') as { + createHttpUtils: (args: { + apiPrefix: string + maxBodySize: number + securityHeaders: Record + }) => { + readBody: (req: EventEmitter & { readableEnded?: boolean }) => Promise + resolveApiPath: (pathname: string) => string | null + } +} + +class MockRequest extends EventEmitter { + readableEnded = false +} + +describe('http utils', () => { + it('only resolves paths that match the configured API prefix', () => { + const utils = createHttpUtils({ + apiPrefix: '/custom-api', + maxBodySize: 1024, + securityHeaders: {}, + }) + + expect(utils.resolveApiPath('/custom-api')).toBe('/') + expect(utils.resolveApiPath('/custom-api/settings')).toBe('/settings') + expect(utils.resolveApiPath('/api/settings')).toBeNull() + }) + + it('rejects body reads when the request stream closes before completion', async () => { + const utils = createHttpUtils({ + apiPrefix: '/api', + maxBodySize: 1024, + securityHeaders: {}, + }) + const req = new MockRequest() + + const bodyPromise = utils.readBody(req) + req.emit('data', Buffer.from('{"broken":')) + req.emit('close') + + await expect(bodyPromise).rejects.toThrow( + 'Request body stream closed before the payload finished', + ) + }) + + it('parses JSON bodies normally when the request ends cleanly', async () => { + const utils = createHttpUtils({ + apiPrefix: '/api', + maxBodySize: 1024, + securityHeaders: {}, + }) + const req = new MockRequest() + + const bodyPromise = utils.readBody(req) + req.emit('data', Buffer.from('{"ok":true}')) + req.readableEnded = true + req.emit('end') + req.emit('close') + + await expect(bodyPromise).resolves.toEqual({ ok: true }) + }) +}) diff --git a/tests/unit/model-normalization.test.ts b/tests/unit/model-normalization.test.ts index 92ebe36..d5e7a8d 100644 --- a/tests/unit/model-normalization.test.ts +++ b/tests/unit/model-normalization.test.ts @@ -19,10 +19,10 @@ const { } const MODEL_CASES = [ - { raw: 'claude-opus-4.5', name: 'Opus 4.5', provider: 'Anthropic' }, - { raw: 'claude-opus-4-5-20251101', name: 'Opus 4.5', provider: 'Anthropic' }, - { raw: 'claude-sonnet-4-20250514', name: 'Sonnet 4', provider: 'Anthropic' }, - { raw: 'claude-haiku-4-5', name: 'Haiku 4.5', provider: 'Anthropic' }, + { raw: 'claude-opus-4.5', name: 'Claude Opus 4.5', provider: 'Anthropic' }, + { raw: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', provider: 'Anthropic' }, + { raw: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'Anthropic' }, + { raw: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'Anthropic' }, { raw: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI' }, { raw: 'gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' }, { raw: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' }, diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts index 288b534..f72b81b 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -28,7 +28,7 @@ describe('report utils', () => { 'OpenAI, Anthropic, Google +1 more', ) expect(report.meta.filterSummary.selectedModelsLabel).toBe( - 'GPT-5.4, Sonnet 4.5, Gemini 2.5 Pro +1 more', + 'GPT-5.4, Claude Sonnet 4.5, Gemini 2.5 Pro +1 more', ) expect(report.summaryCards[5].label).toBe('Peak period') expect(report.summaryCards[5].value).not.toMatch(/^\d{4}-\d{2}-\d{2}$/) @@ -163,4 +163,16 @@ describe('report utils', () => { 'GPT-5.3 Codex, Gemini 2.5 Flash, Codex Mini +1 more', ) }) + + it('keeps Claude family names and dotted versions intact in filter summaries', async () => { + const { buildReportData } = await import('../../server/report/utils.js') + + const report = buildReportData(dashboardFixture, { + viewMode: 'daily', + language: 'en', + selectedModels: ['claude-sonnet-4-5'], + }) + + expect(report.meta.filterSummary.selectedModelsLabel).toBe('Claude Sonnet 4.5') + }) }) diff --git a/tests/unit/server-helpers.test.ts b/tests/unit/server-helpers.test.ts index c07ce46..fd9bca2 100644 --- a/tests/unit/server-helpers.test.ts +++ b/tests/unit/server-helpers.test.ts @@ -23,6 +23,9 @@ const { ) => Promise } } +const { isLoopbackHost } = require('../../server/runtime.js') as { + isLoopbackHost: (host: string) => boolean +} afterEach(() => { vi.restoreAllMocks() @@ -59,6 +62,15 @@ describe('server helper utilities', () => { expect(getExecutableName('npx', false)).toBe('npx') }) + it('accepts common loopback host variants', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('localhost')).toBe(true) + expect(isLoopbackHost('::1')).toBe(true) + expect(isLoopbackHost('[::1]')).toBe(true) + expect(isLoopbackHost(' ::ffff:127.0.0.1 ')).toBe(true) + expect(isLoopbackHost('0.0.0.0')).toBe(false) + }) + it.runIf(process.platform === 'win32')( 'checks npx on Windows without emitting DEP0190 warnings', async () => { From ec8260830af095cd75e448d2517d80bad337134e Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 01:26:46 +0200 Subject: [PATCH 11/12] Address remaining CodeRabbit fixes --- server.js | 6 +- server/runtime.js | 9 +-- shared/dashboard-domain.js | 13 +-- src/components/cards/MonthMetrics.tsx | 27 +++++-- src/lib/auto-import.ts | 8 +- src/lib/calculations.ts | 5 +- src/lib/constants.ts | 2 - tests/frontend/dashboard-error-state.test.tsx | 58 +++++--------- tests/frontend/metric-ratio-locale.test.tsx | 80 ++++++++++++------- tests/integration/server.test.ts | 69 ++++++++++++++++ tests/unit/analytics.test.ts | 9 +++ tests/unit/auto-import.test.ts | 46 ++++++++++- tests/unit/dashboard-preferences.test.ts | 27 ++++++- tests/unit/server-helpers.test.ts | 3 + 14 files changed, 265 insertions(+), 97 deletions(-) diff --git a/server.js b/server.js index a4e4a51..4ac7078 100755 --- a/server.js +++ b/server.js @@ -343,11 +343,12 @@ async function fetchRuntimeIdentity(url, timeoutMs = 1000) { return null; } + const runtimePath = `${API_PREFIX.replace(/\/+$/, '')}/runtime`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(new URL('/api/runtime', `${url}/`), { + const response = await fetch(new URL(runtimePath, `${url}/`), { signal: controller.signal, }); @@ -765,6 +766,9 @@ async function startInBackground() { const logFile = buildBackgroundLogFilePath(); const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background'); const logFd = fs.openSync(logFile, 'a', SECURE_FILE_MODE); + if (!IS_WINDOWS) { + fs.fchmodSync(logFd, SECURE_FILE_MODE); + } let child; try { diff --git a/server/runtime.js b/server/runtime.js index 42e27af..d4d3445 100644 --- a/server/runtime.js +++ b/server/runtime.js @@ -3,12 +3,9 @@ function isLoopbackHost(host) { .trim() .toLowerCase() .replace(/^\[|\]$/g, ''); - return ( - normalized === '127.0.0.1' || - normalized === 'localhost' || - normalized === '::1' || - normalized === '::ffff:127.0.0.1' - ); + const ipv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized); + const ipv4MappedLoopback = /^::ffff:127(?:\.\d{1,3}){3}$/.test(normalized); + return ipv4Loopback || normalized === 'localhost' || normalized === '::1' || ipv4MappedLoopback; } function ensureBindHostAllowed(bindHost, allowRemoteBind) { diff --git a/shared/dashboard-domain.js b/shared/dashboard-domain.js index 72c6c3c..b097da2 100644 --- a/shared/dashboard-domain.js +++ b/shared/dashboard-domain.js @@ -312,24 +312,25 @@ function aggregateToDailyFormat(data, viewMode) { function computeMovingAverage(values, window = 7) { const result = Array(values.length) let sum = 0 + let definedCount = 0 for (let index = 0; index < values.length; index += 1) { const currentValue = values[index] - if (currentValue === undefined) { - result[index] = undefined - continue + if (currentValue !== undefined) { + sum += currentValue + definedCount += 1 } - sum += currentValue - if (index >= window) { const outgoingValue = values[index - window] if (outgoingValue !== undefined) { sum -= outgoingValue + definedCount -= 1 } } - result[index] = index < window - 1 ? undefined : sum / window + result[index] = + index < window - 1 ? undefined : definedCount > 0 ? sum / definedCount : undefined } return result diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index ee633dd..441a8e4 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -29,6 +29,20 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const { t } = useTranslation() const locale = getCurrentLocale() const currentMonth = localMonth() + const oneDecimalFormatter = new Intl.NumberFormat(locale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + const oneDecimalPercentFormatter = new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + const wholePercentFormatter = new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) const monthData = useMemo( () => daily.filter((d) => d.date.startsWith(currentMonth)), @@ -104,10 +118,7 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const tokensSubtitle = agg.inputTokens > 0 && agg.outputTokens > 0 ? t('metricCards.month.ioRatio', { - value: new Intl.NumberFormat(locale, { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format(agg.inputTokens / agg.outputTokens), + value: oneDecimalFormatter.format(agg.inputTokens / agg.outputTokens), }) : null const modelsSubtitle = agg.topModel @@ -120,7 +131,7 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const thinkingSubtitle = agg.totalTokens > 0 ? t('metricCards.month.thinkingSubtitle', { - value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%`, + value: oneDecimalPercentFormatter.format(agg.thinkingTokens / agg.totalTokens), }) : null @@ -157,7 +168,7 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { label={t('metricCards.month.activeDays')} value={`${agg.activeDays} / ${agg.dayOfMonth}`} subtitle={t('metricCards.month.coverage', { - value: `${((agg.activeDays / agg.dayOfMonth) * 100).toFixed(0)}%`, + value: wholePercentFormatter.format(agg.activeDays / agg.dayOfMonth), })} icon={} /> @@ -177,8 +188,8 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { label={t('metricCards.month.cacheHitRate')} value={} subtitle={t('metricCards.month.cacheMix', { - input: `${ioTotal > 0 ? ((agg.inputTokens / ioTotal) * 100).toFixed(0) : 0}%`, - output: `${ioTotal > 0 ? ((agg.outputTokens / ioTotal) * 100).toFixed(0) : 0}%`, + input: wholePercentFormatter.format(ioTotal > 0 ? agg.inputTokens / ioTotal : 0), + output: wholePercentFormatter.format(ioTotal > 0 ? agg.outputTokens / ioTotal : 0), })} icon={} /> diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 361e967..e9fb45d 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -106,6 +106,8 @@ export function startAutoImport( const controller = new AbortController() const decoder = new TextDecoder() let done = false + let receivedDoneFrame = false + let receivedErrorFrame = false const finish = () => { if (done) return @@ -141,6 +143,7 @@ export function startAutoImport( return } case 'error': { + receivedErrorFrame = true const data = parseJsonRecord(dataText) if (data) { callbacks.onError({ @@ -152,6 +155,7 @@ export function startAutoImport( return } case 'done': + receivedDoneFrame = true finish() } } @@ -229,9 +233,11 @@ export function startAutoImport( buffer += decoder.decode() if (buffer) { processLines(buffer.split(/\r?\n/), state) - buffer = '' } flushEvent(state.currentEvent, state.dataLines) + if (!receivedDoneFrame && !receivedErrorFrame) { + callbacks.onError({ message: t('autoImportModal.serverConnectionLost') }) + } finish() return } diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 0854348..7ae4b26 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -19,7 +19,10 @@ export function computeWeekOverWeekChange(data: DailyUsage[]): number | null { return computeSharedWeekOverWeekChange(data) } -export function computeMovingAverage(values: number[], window = 7): (number | undefined)[] { +export function computeMovingAverage( + values: Array, + window = 7, +): (number | undefined)[] { return computeSharedMovingAverage(values, window) } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4ebab4b..33ccf97 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -16,8 +16,6 @@ export const MODEL_COLORS: Record = { OpenCode: 'hsl(186, 58%, 48%)', } -export const MODEL_COLOR_DEFAULT = 'hsl(220, 8%, 56%)' - export const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] export const VIEW_MODE_LABELS = { diff --git a/tests/frontend/dashboard-error-state.test.tsx b/tests/frontend/dashboard-error-state.test.tsx index c11cca3..2682742 100644 --- a/tests/frontend/dashboard-error-state.test.tsx +++ b/tests/frontend/dashboard-error-state.test.tsx @@ -30,6 +30,22 @@ vi.mock('@/hooks/use-usage-data', () => usageHookMocks) vi.mock('@/hooks/use-app-settings', () => settingsHookMocks) vi.mock('@/lib/api', () => apiMocks) +function makeEmptyUsageData() { + return { + daily: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + } +} + function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -94,19 +110,7 @@ describe('Dashboard fatal load state', () => { it('renders a fatal settings error state instead of the normal empty state and resets settings', async () => { usageHookMocks.useUsageData.mockReturnValue({ - data: { - daily: [], - totals: { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, - }, - }, + data: makeEmptyUsageData(), isLoading: false, error: null, }) @@ -160,19 +164,7 @@ describe('Dashboard fatal load state', () => { const mutateAsync = vi.fn().mockRejectedValue(new Error('Usage payload is invalid')) usageHookMocks.useUsageData.mockReturnValue({ - data: { - daily: [], - totals: { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, - }, - }, + data: makeEmptyUsageData(), isLoading: false, error: null, }) @@ -200,19 +192,7 @@ describe('Dashboard fatal load state', () => { const mutateAsync = vi.fn() usageHookMocks.useUsageData.mockReturnValue({ - data: { - daily: [], - totals: { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, - }, - }, + data: makeEmptyUsageData(), isLoading: false, error: null, }) diff --git a/tests/frontend/metric-ratio-locale.test.tsx b/tests/frontend/metric-ratio-locale.test.tsx index 91761dd..c5b4983 100644 --- a/tests/frontend/metric-ratio-locale.test.tsx +++ b/tests/frontend/metric-ratio-locale.test.tsx @@ -44,6 +44,35 @@ const metrics: DashboardMetrics = { } describe('metric ratio localization', () => { + const daily: DailyUsage[] = [ + { + date: '2026-04-02', + inputTokens: 10, + outputTokens: 6, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 2, + totalTokens: 18, + totalCost: 4, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + }, + { + date: '2026-04-04', + inputTokens: 5, + outputTokens: 4, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 1, + totalTokens: 10, + totalCost: 6, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + }, + ] + beforeEach(async () => { vi.stubGlobal( 'IntersectionObserver', @@ -77,35 +106,6 @@ describe('metric ratio localization', () => { maximumFractionDigits: 1, }).format(1.5) - const daily: DailyUsage[] = [ - { - date: '2026-04-02', - inputTokens: 10, - outputTokens: 6, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalTokens: 16, - totalCost: 4, - requestCount: 2, - modelsUsed: ['gpt-5.4'], - modelBreakdowns: [], - }, - { - date: '2026-04-04', - inputTokens: 5, - outputTokens: 4, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalTokens: 9, - totalCost: 6, - requestCount: 2, - modelsUsed: ['gpt-5.4'], - modelBreakdowns: [], - }, - ] - render( @@ -114,4 +114,26 @@ describe('metric ratio localization', () => { expect(document.body.textContent).toContain(`${ratio}:1`) }) + + it('formats month percentage subtitles with the active locale', () => { + const thinkingShare = new Intl.NumberFormat(getCurrentLocale(), { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(3 / 28) + const coverage = new Intl.NumberFormat(getCurrentLocale(), { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(2 / new Date().getDate()) + + render( + + + , + ) + + expect(document.body.textContent).toContain(thinkingShare) + expect(document.body.textContent).toContain(coverage) + }) }) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 58e2f7b..044298d 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -254,6 +254,7 @@ function readBackgroundRegistry(root: string) { url: string port: number pid: number + logFile?: string | null }> } @@ -264,6 +265,7 @@ function tryReadBackgroundRegistry(root: string) { url: string port: number pid: number + logFile?: string | null }> } @@ -272,6 +274,7 @@ function tryReadBackgroundRegistry(root: string) { url: string port: number pid: number + logFile?: string | null }> } catch { return [] @@ -291,6 +294,7 @@ async function waitForBackgroundRegistry( url: string port: number pid: number + logFile?: string | null }>, ) => boolean, timeoutMs = 15_000, @@ -312,6 +316,23 @@ async function waitForBackgroundRegistry( ) } +async function waitForHttpOk(url: string, timeoutMs = 15_000) { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url) + if (response.ok) { + return + } + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + throw new Error(`Timed out waiting for server startup: ${url}`) +} + 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], { @@ -716,6 +737,54 @@ describe('local server API', () => { } }) + itIfPosix( + 'hardens background log files and stops background instances with a custom API prefix', + async () => { + const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-prefix-test-')) + const backgroundEnv = { + ...createCliEnv(backgroundRoot), + API_PREFIX: '/custom-api', + } + const backgroundPort = await getFreePort() + const backgroundUrl = `http://127.0.0.1:${backgroundPort}` + + try { + const startResult = await runCli( + ['--background', '--no-open', '--port', String(backgroundPort)], + { + env: backgroundEnv, + }, + ) + + expect(startResult.code).toBe(0) + expect(startResult.output).toContain('TTDash is running in the background.') + expect(startResult.output).toContain(backgroundUrl) + + await waitForHttpOk(`${backgroundUrl}/custom-api/usage`) + + const [instance] = await waitForBackgroundRegistry( + backgroundRoot, + (entries) => entries.length === 1, + ) + expect(instance).toBeDefined() + expect(instance?.logFile).toBeTruthy() + expect(permissionBits(instance!.logFile!)).toBe(0o600) + + const stopResult = await runCli(['stop'], { + env: backgroundEnv, + }) + + expect(stopResult.code).toBe(0) + expect(stopResult.output).toContain(`Stopped TTDash background server: ${backgroundUrl}`) + await waitForServerUnavailable(backgroundUrl) + } finally { + await stopAllBackgroundServers(backgroundEnv, backgroundRoot) + rmSync(backgroundRoot, { recursive: true, force: true }) + } + }, + 45_000, + ) + itIfPosix('tightens existing app directories to restrictive permissions on startup', async () => { const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-existing-dir-permissions-test-')) const dataDir = getCliDataDir(runtimeRoot) diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 0fc1470..459b5b3 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -133,4 +133,13 @@ describe('dashboard analytics', () => { it('computes moving averages with leading gaps instead of partial windows', () => { expect(computeMovingAverage([1, 2, 3, 4], 3)).toEqual([undefined, undefined, 2, 3]) }) + + it('computes moving averages from defined values only when windows contain gaps', () => { + expect(computeMovingAverage([1, undefined, 3, 5], 3)).toEqual([undefined, undefined, 2, 4]) + expect(computeMovingAverage([undefined, undefined, undefined], 2)).toEqual([ + undefined, + undefined, + undefined, + ]) + }) }) diff --git a/tests/unit/auto-import.test.ts b/tests/unit/auto-import.test.ts index 654ed43..898dc15 100644 --- a/tests/unit/auto-import.test.ts +++ b/tests/unit/auto-import.test.ts @@ -177,7 +177,7 @@ describe('startAutoImport', () => { const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode('event: success\ndata: {"days":7,')) - controller.enqueue(encoder.encode('"totalCost":12.5}')) + controller.enqueue(encoder.encode('"totalCost":12.5}\n\nevent: done\ndata: {}\n\n')) controller.close() }, }) @@ -212,4 +212,48 @@ describe('startAutoImport', () => { expect(callbacks.onSuccess).toHaveBeenCalledWith({ days: 7, totalCost: 12.5 }) expect(callbacks.onError).not.toHaveBeenCalled() }) + + it('treats an unexpected EOF without a terminal done frame as a lost server connection', async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(['event: success', 'data: {"days":7,"totalCost":12.5}', ''].join('\n')), + ) + controller.close() + }, + }) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + }, + }), + ), + ) + + const callbacks = { + onCheck: vi.fn(), + onProgress: vi.fn(), + onStderr: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + onDone: vi.fn(), + } + + startAutoImport(callbacks, translate) + + await vi.waitFor(() => { + expect(callbacks.onDone).toHaveBeenCalledTimes(1) + }) + + expect(callbacks.onSuccess).toHaveBeenCalledWith({ days: 7, totalCost: 12.5 }) + expect(callbacks.onError).toHaveBeenCalledWith({ + message: 'autoImportModal.serverConnectionLost', + }) + }) }) diff --git a/tests/unit/dashboard-preferences.test.ts b/tests/unit/dashboard-preferences.test.ts index 4404370..e7b82b6 100644 --- a/tests/unit/dashboard-preferences.test.ts +++ b/tests/unit/dashboard-preferences.test.ts @@ -10,14 +10,35 @@ describe('dashboard preferences config', () => { const parsed = parseDashboardPreferencesConfig(dashboardPreferences) expect(parsed.sectionDefinitions).toEqual(DASHBOARD_SECTION_DEFINITIONS) - expect(parsed.viewModes).toContain('monthly') + expect(parsed.viewModes).toEqual(dashboardPreferences.viewModes) + expect(parsed.datePresets).toEqual(dashboardPreferences.datePresets) }) - it('fails fast when the shared preferences JSON drifts from the expected shape', () => { + it('fails fast when datePresets contain unsupported values', () => { expect(() => parseDashboardPreferencesConfig({ - datePresets: ['all'], + datePresets: ['all', 'ever'], + viewModes: dashboardPreferences.viewModes, + sectionDefinitions: dashboardPreferences.sectionDefinitions, + }), + ).toThrow('Invalid dashboard preferences') + }) + + it('fails fast when viewModes contain unsupported values', () => { + expect(() => + parseDashboardPreferencesConfig({ + datePresets: dashboardPreferences.datePresets, viewModes: ['daily', 'weekly'], + sectionDefinitions: dashboardPreferences.sectionDefinitions, + }), + ).toThrow('Invalid dashboard preferences') + }) + + it('fails fast when sectionDefinitions entries are malformed', () => { + expect(() => + parseDashboardPreferencesConfig({ + datePresets: dashboardPreferences.datePresets, + viewModes: dashboardPreferences.viewModes, sectionDefinitions: [{ id: 'metrics', domId: 'metrics' }], }), ).toThrow('Invalid dashboard preferences') diff --git a/tests/unit/server-helpers.test.ts b/tests/unit/server-helpers.test.ts index fd9bca2..d498c24 100644 --- a/tests/unit/server-helpers.test.ts +++ b/tests/unit/server-helpers.test.ts @@ -64,10 +64,13 @@ describe('server helper utilities', () => { it('accepts common loopback host variants', () => { expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('127.0.0.2')).toBe(true) + expect(isLoopbackHost('127.255.255.255')).toBe(true) expect(isLoopbackHost('localhost')).toBe(true) expect(isLoopbackHost('::1')).toBe(true) expect(isLoopbackHost('[::1]')).toBe(true) expect(isLoopbackHost(' ::ffff:127.0.0.1 ')).toBe(true) + expect(isLoopbackHost('::ffff:127.0.0.2')).toBe(true) expect(isLoopbackHost('0.0.0.0')).toBe(false) }) From 5a9bb8f611b97583e14dd6a001e4d9f0a6693b65 Mon Sep 17 00:00:00 2001 From: tyl3r-ch Date: Tue, 14 Apr 2026 01:44:06 +0200 Subject: [PATCH 12/12] Fix auto-import progress message typing --- src/lib/auto-import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index e9fb45d..46218ab 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -22,7 +22,7 @@ export interface ProgressEvent { key: AutoImportMessageKey vars?: Record } -export interface ProgressMessage { +export interface ProgressMessage extends ProgressEvent { message: string } export interface StderrEvent {