diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3c656..9016800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: npm - name: Install dependencies @@ -37,6 +37,9 @@ jobs: - name: Build production bundle run: npm run build + - name: Verify packed npm artifact + run: npm run verify:package + - name: Install Playwright browser run: npx playwright install --with-deps chromium diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c89fba..3da8f52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write concurrency: group: release-${{ github.ref_name }} @@ -28,11 +29,17 @@ jobs: git fetch origin main --depth=1 git branch -r --contains HEAD | grep -q 'origin/main' + - name: Verify tag matches package version + run: | + VERSION="$(node -p "require('./package.json').version")" + test "v${VERSION}" = "${GITHUB_REF_NAME}" + - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: npm + registry-url: https://registry.npmjs.org - name: Install dependencies run: npm ci --ignore-scripts @@ -43,12 +50,45 @@ jobs: - name: Build production bundle run: npm run build + - name: Verify packed npm artifact + run: npm run verify:package + - name: Install Playwright browser run: npx playwright install --with-deps chromium - name: Run end-to-end smoke tests run: npm run test:e2e:ci + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Publish package to npm + run: npm publish + + - name: Wait for npm registry propagation + run: | + VERSION="$(node -p "require('./package.json').version")" + for attempt in 1 2 3 4 5 6; do + if npm view "@roastcodes/ttdash@${VERSION}" version >/dev/null 2>&1; then + exit 0 + fi + sleep 10 + done + echo "Package was not visible on npm in time." + exit 1 + + - name: Verify npx install path + run: | + VERSION="$(node -p "require('./package.json').version")" + npx --yes "@roastcodes/ttdash@${VERSION}" --help + + - name: Verify bunx install path + run: | + VERSION="$(node -p "require('./package.json').version")" + bunx "@roastcodes/ttdash@${VERSION}" --help + - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index bec0c9b..8ca452a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ data.json .DS_Store .playwright-mcp/ .tmp-playwright/ +.tmp-smoke-*/ playwright-report/ test-results/ test-json/ diff --git a/AGENTS.md b/AGENTS.md index d770da7..c56d481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ During development, keep `npm run dev` and `node server.js` running in separate Frontend code is TypeScript + React. Follow the existing style: 2-space indentation, single quotes, trailing commas where the formatter leaves them, and no semicolons in `src/` files. Component, hook, and type filenames use PascalCase or descriptive kebab-free names such as `Dashboard.tsx`, `use-usage-data.ts`, and `formatters.ts`. Keep utilities small and colocate feature-specific UI under `src/components/features/`. In `server.js`, preserve the current CommonJS style and semicolon usage instead of rewriting it to match the frontend. ## Testing Guidelines -There is no committed automated test suite yet. Before opening a PR, run `npm run build` and manually verify the main flows: dashboard load, auto-import, JSON upload, filtering, and export actions. If you add tests, prefer colocated `*.test.ts` or `*.test.tsx` files and keep them focused on data transforms, hooks, or complex UI behavior. +Automated tests are part of the repo now. Before opening a PR, run `npm run build`, `npm run test:unit`, `npm run verify:package`, and `npm run test:e2e`. If local port `3015` is already in use, run Playwright with `PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e`. Continue to manually verify the main flows affected by the change: dashboard load, auto-import, JSON upload, filtering, and export actions. If you add tests, prefer focused `*.test.ts` or `*.test.tsx` coverage for data transforms, hooks, or complex UI behavior. ## Commit & Pull Request Guidelines Recent history favors short, imperative subjects, often with a version prefix, for example `v5.3.1: Fix timezone bug` or `Fix install.sh -e output`. Keep commits narrowly scoped. PRs should explain the user-visible change, note any manual verification performed, link related issues, and include screenshots or GIFs for UI changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4ded8..6eb22c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,41 +1,63 @@ # Changelog +## [Unreleased] + +## [6.1.0] - 2026-04-11 + +### Added +- **Background CLI mode** — `--background` starts the local server as a detached background process, and `ttdash stop` lists running instances so the selected one can be stopped directly +- **Settings backups and layout preferences** — the settings dialog now supports backup import/export, conservative usage-data restore, default dashboard filters, section visibility, and section ordering +- **Packaged CLI verification** — `npm run verify:package` now builds the real tarball and verifies that the packaged `ttdash` CLI can install, print help, and start outside the repo checkout +- **Scoped package release prep** — the package is now prepared for the first public scoped release as `@roastcodes/ttdash` + +### Improved +- **Dashboard settings model** — provider limits, persisted filters, section visibility, and section order now behave as first-class stored settings across fresh starts and backup restore flows +- **CLI and installer UX** — terminal output, help text, and installer guidance now use English-first release-facing messaging +- **Metrics and report correctness** — aggregated dashboard metrics, provider day counting, filter-preset behavior, and PDF language handling were corrected and aligned with the current view state +- **Release workflow** — tagged releases now verify the packed artifact, publish the scoped package, and smoke-check both `npx` and `bunx` after publish +- **Repository documentation** — README, contribution, release, security, and conduct docs were rewritten for a public, maintainer-led npm project + +### Fixed +- **Race-safe background registry** — parallel `--background` starts briefly lock the local instance registry so no running server gets dropped from the tracked list +- **Conservative data import** — backup imports add missing days, skip identical days, and keep conflicting local days instead of silently overwriting them +- **Playwright release validation** — the E2E configuration now supports an override port so local release verification does not fail when the default smoke-test port is already occupied + ## [6.0.11] - 2026-04-10 ### Fixed -- **Idempotenter Bun-Installer** — `install.sh` und `install.bat` bereinigen vor `bun add -g file:...` jetzt vorhandene `ttdash`-Einträge aus Bun’s globalem Manifest und löschen bei Bedarf das fehlerhafte globale `bun.lock`, damit wiederholte Upgrades keine doppelten `package.json`-Keys mehr erzeugen +- **Idempotent Bun installer** — `install.sh` and `install.bat` now clean existing `ttdash` entries from Bun’s global manifest before `bun add -g file:...` and remove the broken global `bun.lock` when needed, so repeated upgrades do not create duplicate `package.json` keys ## [6.0.10] - 2026-04-09 ### Added -- **GitHub Release-Workflow** — ein separater `release.yml` erstellt jetzt GitHub Releases automatisiert auf `v*`-Tags, prüft vorher Tests und Build und akzeptiert nur Tags auf `main` +- **GitHub release workflow** — a dedicated `release.yml` now creates GitHub releases automatically on `v*` tags, verifies tests and build first, and only accepts tags on `main` ### Improved -- **README-Projektkontext** — die Dokumentation verweist jetzt explizit auf `toktrack` als Basisdatenquelle und bedankt sich bei `mag123c` +- **README project context** — the documentation now points explicitly to `toktrack` as the primary data source and credits `mag123c` ## [6.0.9] - 2026-04-09 ### Added -- **Automatisierte Testpyramide** — Vitest deckt jetzt Datennormalisierung, Berechnungen, Hook-Logik und den lokalen Serverpfad ab; Playwright prüft den Upload-zu-Dashboard-Smoke-Flow mit echten Browser-Reports -- **CI-Testpipeline** — GitHub Actions führt Build, Coverage, Playwright-Smoke und Report-Artefakte jetzt automatisiert auf Pushes und Pull Requests aus +- **Automated test pyramid** — Vitest now covers data normalization, calculations, hook behavior, and the local server path; Playwright verifies the upload-to-dashboard smoke flow with real browser reports +- **CI test pipeline** — GitHub Actions now runs build, coverage, Playwright smoke tests, and report artifacts automatically on pushes and pull requests ### Improved -- **Öffentliche Repo-Bereitschaft** — Paket-Metadaten, Lizenz, Security-/Contribution-Dokumente und Publish-Surface wurden für ein späteres Public-Repo bereinigt -- **Test-Isolation** — der Playwright-Webserver nutzt eine eigene lokale App-Umgebung und überschreibt keine normalen Nutzungsdaten -- **Runtime-Härtung** — lokaler Server bindet standardmässig an `127.0.0.1`, liefert restriktivere Security-Header und vermeidet unnötige externe Runtime-Requests +- **Public repo readiness** — package metadata, license, security/contribution docs, and publish surface were cleaned up for a public repository +- **Test isolation** — the Playwright web server uses its own local app environment and does not overwrite normal user data +- **Runtime hardening** — the local server now binds to `127.0.0.1` by default, returns stricter security headers, and avoids unnecessary external runtime requests ### Fixed -- **Bun-/npm-Konsistenz** — Lockfiles und Publish-Inhalt sind jetzt auf denselben Dependency- und Runtime-Stand gebracht, sodass Build und Installation reproduzierbar bleiben +- **Bun/npm consistency** — lockfiles and published runtime contents now stay aligned so builds and installs remain reproducible ## [6.0.8] - 2026-04-08 ### Added -- **CLI-Flags für `ttdash`** — `--port` / `-p`, `--help` / `-h`, `--no-open` / `-no` und `--auto-load` / `-al` werden jetzt direkt vom globalen CLI-Befehl unterstützt -- **Persistente Lade-Metadaten** — App-Settings speichern jetzt, wann Daten zuletzt geladen wurden und über welchen Pfad (`Datei`, `Auto-Import`, `CLI Auto-Load`) -- **Sichtbare Lade-Hinweise im UI** — Header und Limits-Dialog zeigen jetzt den letzten Ladezeitpunkt; bei `-al` erscheint zusätzlich ein eigener `Auto-Load beim Start`-Badge +- **CLI flags for `ttdash`** — `--port` / `-p`, `--help` / `-h`, `--no-open` / `-no`, and `--auto-load` / `-al` are now supported directly by the global CLI command +- **Persistent load metadata** — app settings now store when data was last loaded and from which path (`file`, `auto-import`, `cli-auto-load`) +- **Visible load hints in the UI** — the header and limits dialog now show the last load time, and `-al` also adds a dedicated `Auto-load on start` badge ### Improved -- **Gemeinsamer Auto-Import-Pfad** — UI-Auto-Import und CLI-Auto-Load verwenden jetzt dieselbe Server-Logik, damit Laufzeitverhalten, Persistenz und Fehlerbehandlung konsistent bleiben +- **Shared auto-import path** — UI auto-import and CLI auto-load now use the same server logic so runtime behavior, persistence, and error handling stay consistent ## [6.0.7] - 2026-04-08 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..65a30d7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,31 @@ +# Code of Conduct + +## Our Standard + +Participation in this project should remain respectful, constructive, and focused on improving the software. + +Expected behavior includes: + +- being respectful in issues, pull requests, and reviews +- giving technical feedback directly and with context +- assuming good intent before escalating disagreement +- keeping discussions focused on code, behavior, and user impact + +Unacceptable behavior includes: + +- harassment, intimidation, or personal attacks +- discriminatory or derogatory language +- repeated bad-faith arguing or disruptive behavior +- publishing private information without permission + +## Scope + +This code of conduct applies to project spaces such as issues, pull requests, reviews, and other repository discussions. + +## Enforcement + +Project maintainers may remove comments, close discussions, reject participation, or block contributors whose behavior violates this code of conduct. + +## Reporting + +If you need to report a conduct issue, use GitHub private reporting if available or contact the maintainer through GitHub first. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfc5938..9a1e38f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,42 +1,70 @@ # Contributing -## Development Setup +Thanks for your interest in `TTDash`. -```bash -npm install -npm run dev -node server.js -``` +This project is currently maintained by a single maintainer. Contributions are welcome, but acceptance is selective so the codebase, scope, and release quality stay manageable. -Or with Bun: +## What Is Most Helpful -```bash -bun install -bun run dev -node server.js -``` +The easiest changes to review and merge are: -The frontend dev server runs on `http://localhost:5173` and the local API/static server runs on `http://localhost:3000`. +- reproducible bug reports +- documentation fixes and clarifications +- focused bugfix pull requests +- small UX or accessibility improvements +- tests that cover existing or clearly agreed behavior -## Before Opening a Pull Request +Please open an issue before spending time on larger changes such as: -Run the production build and automated checks: +- new features +- architectural refactors +- dependency swaps +- changes to import, persistence, or reporting behavior +- broad UI redesigns + +Large unsolicited pull requests may be declined even if they are technically correct, simply because they do not fit the current direction or available review time. + +## Before You Open an Issue + +Please include enough detail for the problem to be actionable: + +- what you expected +- what actually happened +- exact steps to reproduce +- sample input data if the bug depends on input shape +- screenshots or terminal output when helpful +- environment details when relevant: OS, Node version, install method, browser + +For feature requests, explain the user problem first. Suggestions that only describe an implementation without clarifying the problem are harder to evaluate. + +## Before You Open a Pull Request + +Make sure the change is small, focused, and aligned with the existing product direction. + +Run the main local checks: ```bash npm run build npm run test:unit +npm run verify:package npm run test:e2e ``` -The Playwright suite uses an isolated local app directory under `.tmp-playwright/` and should not reuse your normal local dashboard data. +If local port `3015` is already occupied, run Playwright on another isolated port: + +```bash +PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e +``` + +The Playwright suite uses an isolated local app directory under `.tmp-playwright/` and should not reuse your normal local dashboard data. `npm run verify:package` builds the real tarball and verifies that the packaged CLI can start outside the repo checkout. -Then verify the main flows manually: +Then manually verify the main user flows touched by your change: -- Dashboard load -- Auto-import +- dashboard load +- auto-import - JSON upload -- Filtering -- CSV/PDF export +- filtering +- CSV/PDF export when relevant If you change dependencies, update both lockfiles so npm and Bun installs stay reproducible: @@ -45,8 +73,49 @@ npm install bun install --lockfile-only ``` +## Pull Request Expectations + +Good pull requests are: + +- narrowly scoped +- easy to review commit-by-commit +- consistent with the existing code style +- explicit about user-visible behavior changes + +Please include: + +- a short summary of the change +- why the change is needed +- how you tested it +- screenshots or terminal output for UI/CLI changes when helpful + +## Development Setup + +```bash +npm install +npm run dev +node server.js +``` + +Or with Bun: + +```bash +bun install +bun run dev +node server.js +``` + +The frontend dev server runs on `http://localhost:5173` and the local API/static server runs on `http://localhost:3000`. + ## Style - Frontend: TypeScript + React, 2-space indentation, single quotes, no semicolons in `src/` - Server: CommonJS, keep existing semicolon style in `server.js` - Keep feature UI colocated under `src/components/features/` +- Prefer small, targeted changes over broad cleanup refactors + +## Related Docs + +- Release process: [`RELEASING.md`](RELEASING.md) +- Security reporting: [`SECURITY.md`](SECURITY.md) +- Conduct expectations: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) diff --git a/README.md b/README.md index 402e38b..8070465 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # TTDash -Local usage dashboard for `toktrack` data. `TTDash` runs entirely on your machine and turns raw usage exports into charts, KPIs, forecasts, provider/model breakdowns, and drill-down views. +[![CI](https://github.com/roastcodes/ttdash/actions/workflows/ci.yml/badge.svg)](https://github.com/roastcodes/ttdash/actions/workflows/ci.yml) +![GitHub Release](https://img.shields.io/github/v/release/roastcodes/ttdash) +[![npm](https://img.shields.io/npm/v/%40roastcodes%2Fttdash)](https://www.npmjs.com/package/@roastcodes/ttdash) + +`TTDash` is a local-first dashboard and CLI for `toktrack` usage data. It runs entirely on your machine, turns raw usage exports into charts and operational summaries, and keeps your stored data, settings, and imports on local disk instead of a hosted backend. `TTDash` is built around the usage data provided by [`toktrack`](https://github.com/mag123c/toktrack). Thanks to [mag123c](https://github.com/mag123c) for creating and maintaining the data source this dashboard builds on. @@ -8,85 +12,52 @@ Local usage dashboard for `toktrack` data. `TTDash` runs entirely on your machin ## Why TTDash -- Local-first: data stays on your device -- Fast setup: install once, run with `ttdash` -- Works with `toktrack` and legacy `ccusage` JSON -- Auto-import from local `toktrack` without manual export -- Built for day-to-day cost and token analysis, not just static reports - -## Features - -- Provider and model filtering for OpenAI, Anthropic, Google, and more -- KPI sections for overall usage, today, and current month -- Cost charts, cumulative projection, forecast, token mix, model mix, heatmap, and weekday analysis -- Drill-down modal for per-day details -- CSV export and PDF report export -- Command palette, keyboard shortcuts, and responsive layout -- Animated chart reveals on scroll and on dashboard reload +- Local-first by default: no cloud backend, no remote database, no analytics +- Fast to try: `npx` and `bunx` work without a global install +- Built for daily usage review, cost tracking, and model/provider breakdowns +- Works with `toktrack` exports and legacy `ccusage` JSON +- Can auto-import local `toktrack` data and run in the background ## Quick Start -From the npm registry: +Requirements: -```bash -npx ttdash@latest -``` +- Node.js `20+` +- A modern browser on the same machine +- Typst CLI only if you want PDF export -Or with Bun: +Run `TTDash` directly from the npm registry: ```bash -bunx ttdash@latest +npx --yes @roastcodes/ttdash@latest ``` -For a persistent global install: - -```bash -npm install -g ttdash -ttdash -``` +Or with Bun: ```bash -bun add -g ttdash -ttdash +bunx @roastcodes/ttdash@latest ``` -## Installation From Source - -Clone the repository and install locally: - -macOS / Linux: +Smoke-check the published CLI without starting the dashboard: ```bash -sh install.sh -ttdash -``` - -Windows: - -```bat -install.bat -ttdash +npx --yes @roastcodes/ttdash@latest --help +bunx @roastcodes/ttdash@latest --help ``` -Manual source install: +For a persistent global install: ```bash -npm install -npm run build -npm install -g . +npm install -g @roastcodes/ttdash ttdash ``` -Or with Bun: - ```bash -bun install -bun run build -bun add -g file:$(pwd) +bun add -g @roastcodes/ttdash ttdash ``` -## Usage +## First Run Start the app: @@ -94,11 +65,14 @@ Start the app: ttdash ``` +`TTDash` starts a local server, opens the dashboard in your browser, and automatically retries on the next free port if `3000` is already in use. + Then either: 1. Click `Auto-Import` to load local `toktrack` data 2. Upload a `toktrack` JSON file manually 3. Upload a legacy `ccusage` export +4. Open `Settings` to export or import local backups The auto-import path prefers: @@ -106,88 +80,72 @@ The auto-import path prefers: 2. `bunx toktrack` 3. `npx --yes toktrack` -The server automatically picks the next free port if `3000` is occupied. +## Common Commands -## Sample Data +Run on a specific port: -A synthetic sample dataset for local verification is included at `examples/sample-usage.json`. +```bash +ttdash --port 3010 +``` -## Development +Disable browser auto-open: ```bash -npm install -npm run dev -node server.js +ttdash --no-open ``` -- Vite dev server: `http://localhost:5173` -- API / production server: `http://localhost:3000` - -Build a production bundle: +Import local data immediately on startup: ```bash -npm run build +ttdash --auto-load ``` -## Testing - -Run the fast local verification suite: +Start in the background: ```bash -npm run test:unit +ttdash --background ``` -Create coverage reports in `coverage/` and JUnit output in `test-results/`: +Stop a running background instance: ```bash -npm run test:unit:coverage +ttdash stop ``` -Run the browser smoke test against the built app: +Combine flags when needed: ```bash -npm run test:e2e +ttdash --background --port 3010 --auto-load +ttdash --background --no-open ``` -The Playwright run starts its own isolated local server and test data directory, so it does not touch your normal `TTDash` app data. - -Run the full local test set: +Environment-variable equivalents: ```bash -npm run test:all +PORT=3010 ttdash +NO_OPEN_BROWSER=1 ttdash +HOST=127.0.0.1 ttdash ``` -## Project Structure +## Features -```text -src/ - components/ - cards/ metric sections - charts/ Recharts visualizations and chart containers - features/ auto-import, forecast, heatmap, drill-down, PDF, help - layout/ header and filter controls - tables/ model and recent-day breakdowns - ui/ shared UI primitives - hooks/ data, filter, and metric hooks - lib/ calculations, formatters, API helpers, model/provider utils - types/ shared TypeScript types -server.js local HTTP server and auto-import endpoint -usage-normalizer.js -install.sh -install.bat -dist/ generated production build output -docs/ README assets -examples/ synthetic sample data -``` +- Provider and model filtering across OpenAI, Anthropic, Google, and other imported providers +- KPI sections for overall usage, today, and current month +- Cost charts, cumulative projection, forecast, token mix, model mix, heatmap, and weekday analysis +- Drill-down modal for per-day details +- CSV export and PDF export +- Command palette, keyboard shortcuts, and responsive layout +- Settings-backed defaults, section visibility, and local backups + +## Local Storage and Privacy -## Data & Privacy +`TTDash` is designed to stay local: - No cloud backend - No remote database -- No third-party fonts, analytics, or remote assets at runtime -- Imported data is stored in your local app data directory -- Settings such as language, theme, and provider limits are stored in your local app settings directory -- Auto-import reads local `toktrack` output and normalizes it for the dashboard +- No third-party fonts, analytics, or runtime tracking +- Imported usage data is stored on your machine +- Settings such as language, theme, provider limits, filters, and layout are stored on your machine Platform paths: @@ -195,6 +153,21 @@ Platform paths: - Windows: `%LOCALAPPDATA%\\TTDash\\` for data and `%APPDATA%\\TTDash\\` for settings - Linux: `~/.local/share/ttdash/` for data and `~/.config/ttdash/` for settings +## Backups + +The `Settings` dialog can export and import: + +- app settings backups +- stored usage data backups + +Data-backup import is conservative by design: + +- missing days are added +- identical days are skipped +- conflicting existing days stay local and are reported instead of being overwritten silently + +If you want to fully replace the current dataset with a fresh `toktrack` JSON, keep using the normal upload action in the header. + ## Troubleshooting ### `ttdash` not found after install @@ -218,23 +191,104 @@ PORT=3010 ttdash ### Auto-import cannot find `toktrack` -Install `toktrack` locally or ensure `bunx` / `npx` can execute it. +Install `toktrack` locally or ensure `bunx` / `npx` can execute it in the same terminal environment where you run `ttdash`. + +### PDF export fails + +PDF export requires the Typst CLI to be installed locally. + +macOS: + +```bash +brew install typst +``` + +Other platforms: + +- install Typst from `https://typst.app/` +- make sure `typst --version` works in the same terminal where you run `ttdash` + +## Installation From Source + +Clone the repository and install locally: + +macOS / Linux: + +```bash +sh install.sh +ttdash +``` + +Windows: + +```bat +install.bat +ttdash +``` + +Manual source install: + +```bash +npm install +npm run build +npm install -g . +ttdash +``` + +Or with Bun: + +```bash +bun install +bun run build +bun add -g "file:$(pwd)" +ttdash +``` + +## Development + +Run the app locally from the repo: + +```bash +npm install +npm run dev +node server.js +``` + +- Vite dev server: `http://localhost:5173` +- API / production server: `http://localhost:3000` + +Build the production bundle: + +```bash +npm run build +``` + +Run automated checks: + +```bash +npm run test:unit +npm run test:unit:coverage +npm run verify:package +npm run test:e2e +``` + +The Playwright suite uses its own isolated local app directory. If port `3015` is already occupied locally, run it on another isolated port: + +```bash +PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e +``` -## Tech Stack +## Release and Project Docs -- React 19 -- TypeScript 6 -- Vite 8 -- Tailwind CSS v4 -- Recharts 3 -- Radix UI -- Framer Motion -- Node.js HTTP server +- Contributor guide: [`CONTRIBUTING.md`](CONTRIBUTING.md) +- Release guide: [`RELEASING.md`](RELEASING.md) +- Security policy: [`SECURITY.md`](SECURITY.md) +- Code of conduct: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) ## Status -GitHub Actions runs the build, unit/integration coverage suite, and Playwright smoke test for pull requests and pushes to `main`. +GitHub Actions runs unit/integration coverage, packaged-artifact verification, and Playwright smoke tests for pull requests and pushes to `main`. ## License -MIT. See `LICENSE`. +MIT. See [`LICENSE`](LICENSE). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..93d0639 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,74 @@ +# Releasing TTDash + +## First-Time npm Setup + +Before the first public release, configure npm Trusted Publishing for this repository. + +1. Create or use the npm account that can publish `@roastcodes/ttdash` +2. Enable 2FA on that npm account +3. Ensure you have publish rights in the `roastcodes` npm organization +4. In npm package settings, add this GitHub repository as a trusted publisher for `@roastcodes/ttdash` +5. Confirm the GitHub Actions release workflow is allowed to request an OIDC token + +Trusted Publishing is preferred because it avoids long-lived npm tokens and enables provenance for public publishes. + +If you want npm provenance on the published package, the GitHub repository must be public when the release workflow runs. + +## Release Checklist + +1. Update `package.json` version +2. Add the matching section to `CHANGELOG.md` +3. Run the full local verification suite: + +```bash +npm run test:unit:coverage +npm run build +npm run verify:package +PLAYWRIGHT_TEST_PORT=3016 npm_config_cache=/tmp/ttdash-npm-cache npm run test:e2e +``` + +4. Merge the release changes to `main` +5. Create and push a tag that matches the package version exactly + +Example: + +```bash +VERSION=$(node -p "require('./package.json').version") +git tag "v$VERSION" +git push origin "v$VERSION" +``` + +## What the Release Workflow Does + +On a `v*` tag push, the workflow: + +1. verifies the tagged commit is on `main` +2. verifies the tag matches `package.json` +3. runs unit/integration tests with coverage +4. builds the production bundle +5. verifies the packed npm artifact +6. runs the Playwright smoke suite +7. publishes `@roastcodes/ttdash` to npm through Trusted Publishing +8. waits for npm registry propagation +9. verifies: + - `npx --yes @roastcodes/ttdash@ --help` + - `bunx @roastcodes/ttdash@ --help` +10. creates the GitHub release + +## Post-Publish Checks + +After the workflow succeeds, run a final sanity check: + +```bash +npm view @roastcodes/ttdash version description bin --json +npx --yes @roastcodes/ttdash@latest --help +bunx @roastcodes/ttdash@latest --help +``` + +Optional runtime smoke test: + +```bash +NO_OPEN_BROWSER=1 PORT=3010 npx --yes @roastcodes/ttdash@latest +``` + +Then open `http://127.0.0.1:3010`. diff --git a/SECURITY.md b/SECURITY.md index 7998821..30c1a8f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,25 @@ Security fixes are applied to the latest release on `main`. +If you are reporting a vulnerability, please reproduce it against the current release or current `main` before reporting when possible. + ## Reporting a Vulnerability Please use GitHub private vulnerability reporting if it is enabled for the public repository. -If private reporting is not available, do not open a public issue with full exploit details. Open a minimal issue asking for a private contact channel, or contact the maintainer through GitHub first. +If private reporting is not available: + +- do not open a public issue with exploit details +- open a minimal issue requesting a private contact path, or contact the maintainer through GitHub first + +Please include: + +- a clear description of the issue +- affected versions or commit range if known +- reproduction steps or proof of concept +- impact assessment +- any suggested mitigation if you already have one + +## 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. diff --git a/bun.lock b/bun.lock index dbacb3b..21baa87 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 0, "workspaces": { "": { - "name": "ttdash", + "name": "@roastcodes/ttdash", "dependencies": { "i18next": "^26.0.3", "react-i18next": "^17.0.2", diff --git a/install.bat b/install.bat index 3124e3d..29cea85 100644 --- a/install.bat +++ b/install.bat @@ -5,7 +5,7 @@ cd /d "%SCRIPT_DIR%" || exit /b 1 set "INSTALL_TOOL=npm" set "BUILD_TOOL=npm" set "GLOBAL_TOOL=npm" -set "APP_VERSION=unbekannt" +set "APP_VERSION=unknown" set "APP_NAME=ttdash" for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content -Raw 'package.json' | ConvertFrom-Json).version"`) do set "APP_VERSION=%%v" @@ -14,7 +14,7 @@ for /f "usebackq delims=" %%n in (`powershell -NoProfile -Command "(Get-Content echo. echo ttdash v%APP_VERSION% installer echo %cd% -echo Plattform: Windows ^| Benutzer: %USERNAME% +echo Platform: Windows ^| User: %USERNAME% echo. where bun >nul 2>&1 @@ -25,54 +25,54 @@ if %errorlevel% equ 0 ( ) echo Tooling: -echo - Installation: %INSTALL_TOOL% +echo - Install: %INSTALL_TOOL% echo - Global: %GLOBAL_TOOL% -echo - Build-Ziel: %cd%\dist +echo - Build target: %cd%\dist echo. :: 1 — Dependencies -echo [1/3] Installiere Abhangigkeiten... +echo [1/3] Installing dependencies... if /i "%INSTALL_TOOL%"=="bun" ( - echo - Fuehre bun install aus + echo - Running bun install call bun install >nul 2>&1 if !errorlevel! neq 0 ( - echo x bun install fehlgeschlagen + echo x bun install failed exit /b 1 ) - echo √ bun install abgeschlossen + echo √ bun install completed ) else ( - echo - Fuehre npm install --no-audit --no-fund aus + echo - Running npm install --no-audit --no-fund call npm install --no-audit --no-fund >nul 2>&1 if !errorlevel! neq 0 ( - echo x npm install fehlgeschlagen + echo x npm install failed exit /b 1 ) - echo √ npm install abgeschlossen + echo √ npm install completed ) :: 2 — Build echo. -echo [2/3] Baue Frontend... +echo [2/3] Building frontend... if /i "%BUILD_TOOL%"=="bun" ( - echo - Fuehre bun run build aus + echo - Running bun run build call bun run build >nul 2>&1 if !errorlevel! neq 0 ( - echo x Build fehlgeschlagen + echo x Build failed exit /b 1 ) ) else ( - echo - Fuehre npm run build aus + echo - Running npm run build call npm run build >nul 2>&1 if !errorlevel! neq 0 ( - echo x Build fehlgeschlagen + echo x Build failed exit /b 1 ) ) -echo √ Build abgeschlossen (dist/) +echo √ Build completed (dist/) :: 3 — Global install echo. -echo [3/3] Installiere global... +echo [3/3] Installing globally... if /i "%GLOBAL_TOOL%"=="bun" ( for /f "usebackq delims=" %%p in (`bun pm bin -g 2^>nul`) do set "BUN_GLOBAL_BIN=%%p" if defined BUN_GLOBAL_BIN ( @@ -83,43 +83,45 @@ if /i "%GLOBAL_TOOL%"=="bun" ( for /f "usebackq delims=" %%s in (`bun --eval "const fs = require('fs'); const file = process.env.BUN_GLOBAL_PACKAGE_JSON; const name = process.env.APP_NAME; if (!file || !fs.existsSync(file)) { console.log('clean'); process.exit(0); } const raw = fs.readFileSync(file, 'utf8'); const parsed = JSON.parse(raw); const deps = { ...(parsed.dependencies || {}) }; const hadEntry = Object.prototype.hasOwnProperty.call(deps, name); if (hadEntry) { delete deps[name]; } const next = { ...parsed }; if (Object.keys(deps).length > 0) { next.dependencies = deps; } else { delete next.dependencies; } const normalized = JSON.stringify(next, null, 2) + '\n'; const normalizedChanged = raw !== normalized; if (normalizedChanged || hadEntry) { fs.writeFileSync(file, normalized); } if (hadEntry) { console.log('removed'); } else if (normalizedChanged) { console.log('normalized'); } else { console.log('clean'); }" 2^>nul`) do set "BUN_CLEANUP_STATUS=%%s" if /i not "!BUN_CLEANUP_STATUS!"=="clean" ( if exist "%BUN_GLOBAL_LOCKFILE%" del /f /q "%BUN_GLOBAL_LOCKFILE%" >nul 2>&1 - echo - Vorhandenen Bun-Globaleintrag fuer %APP_NAME% bereinigt + echo - Cleaned up existing global Bun entry for %APP_NAME% ) ) - echo - Versuche bun add -g file:%cd% - call bun add -g file:%cd% >nul 2>&1 + echo - Trying bun add -g "file:%cd%" + call bun add -g "file:%cd%" >nul 2>&1 if !errorlevel! neq 0 ( - echo ! Globale Bun-Installation fehlgeschlagen, wechsle auf npm-Fallback + echo ! Global Bun install failed, switching to npm fallback echo - Fallback: npm install -g . call npm install -g . >nul 2>&1 if !errorlevel! neq 0 ( - echo x Globale Installation fehlgeschlagen ^(Bun und npm^) + echo x Global install failed ^(Bun and npm^) exit /b 1 ) - echo √ Global via npm installiert + echo √ Installed globally via npm ) else ( - echo √ Global via Bun installiert + echo √ Installed globally via Bun ) ) else ( - echo - Fuehre npm install -g . aus + echo - Running npm install -g . call npm install -g . >nul 2>&1 if !errorlevel! neq 0 ( - echo x Globale Installation fehlgeschlagen (evtl. als Admin ausfuehren) + echo x Global install failed ^(try running as admin^) exit /b 1 ) - echo √ Global installiert + echo √ Installed globally ) echo. -echo Fertig! Starte das Dashboard mit: +echo Done! Start the dashboard with: echo ttdash echo. -echo Naechste Schritte: -echo - Anderen Port verwenden: set PORT=3010 ^&^& ttdash -echo - Browser-Autostart deaktivieren: set NO_OPEN_BROWSER=1 ^&^& ttdash -echo - Datenquelle in der App: Auto-Import oder JSON-Upload -echo - Installierte Version: %APP_VERSION% +echo Next steps: +echo - Start in the background: ttdash --background +echo - Stop a background instance: ttdash stop +echo - Use a different port: set PORT=3010 ^&^& ttdash +echo - Disable browser auto-open: set NO_OPEN_BROWSER=1 ^&^& ttdash +echo - Data source in the app: Auto-import or JSON upload +echo - Installed version: %APP_VERSION% echo. -echo Hinweis: Falls 'ttdash' nicht gefunden wird, oeffne ein neues Terminal -echo oder pruefe deinen globalen npm-/bun-Pfad. +echo Note: If 'ttdash' is not found, open a new terminal +echo or check your global npm/bun path. echo. diff --git a/install.sh b/install.sh index f12fd65..678fee4 100755 --- a/install.sh +++ b/install.sh @@ -20,7 +20,7 @@ version="$(sed -n 's/.*"version": "\(.*\)".*/\1/p' "$manifest_file" | head -1)" package_name="$(sed -n 's/.*"name": "\(.*\)".*/\1/p' "$manifest_file" | head -1)" if [ -z "$version" ]; then - version="unbekannt" + version="unknown" fi if [ -z "$package_name" ]; then @@ -87,7 +87,7 @@ if (hadEntry) { console.log("clean"); }' )" || { - warn "Vorhandener Bun-Globaleintrag konnte nicht bereinigt werden" + warn "Could not clean up the existing global Bun entry" return 0 } @@ -95,7 +95,7 @@ if (hadEntry) { return 0 fi - note "Bereinige vorhandenen Bun-Globaleintrag für $package_name" + note "Cleaning up existing global Bun entry for $package_name" if [ -f "$bun_global_lock" ]; then rm -f "$bun_global_lock" fi @@ -105,85 +105,87 @@ cd "$script_dir" printf "${BOLD}ttdash v%s${NC} installer\n" "$version" printf "${DIM}%s${NC}\n" "$(pwd)" -printf "${DIM}Plattform: %s | Shell: %s${NC}\n" "$(uname -s)" "${SHELL:-unbekannt}" +printf "${DIM}Platform: %s | Shell: %s${NC}\n" "$(uname -s)" "${SHELL:-unknown}" if command -v bun >/dev/null 2>&1; then install_tool="bun" global_tool="bun" fi -note "Paketmanager für Installation: $install_tool" -note "Globaler Installer: $global_tool" -note "Build-Ziel: $(pwd)/dist" +note "Package manager for install: $install_tool" +note "Global installer: $global_tool" +note "Build target: $(pwd)/dist" # 1 — Dependencies -info "Installiere Abhängigkeiten..." +info "Installing dependencies..." if [ "$install_tool" = "bun" ]; then - note "Führe bun install aus" + note "Running bun install" if bun install 2>&1 | tail -1; then - ok "bun install abgeschlossen" + ok "bun install completed" else - fail "bun install fehlgeschlagen" + fail "bun install failed" fi else - note "Führe npm install --no-audit --no-fund aus" + note "Running npm install --no-audit --no-fund" if npm install --no-audit --no-fund 2>&1 | tail -1; then - ok "npm install abgeschlossen" + ok "npm install completed" else - fail "npm install fehlgeschlagen" + fail "npm install failed" fi fi # 2 — Build -info "Baue Frontend..." +info "Building frontend..." if [ "$install_tool" = "bun" ]; then - note "Führe bun run build aus" + note "Running bun run build" if bun run build 2>&1 | tail -1; then - ok "Build abgeschlossen (dist/)" + ok "Build completed (dist/)" else - fail "Build fehlgeschlagen" + fail "Build failed" fi else - note "Führe npm run build aus" + note "Running npm run build" if npm run build 2>&1 | tail -1; then - ok "Build abgeschlossen (dist/)" + ok "Build completed (dist/)" else - fail "Build fehlgeschlagen" + fail "Build failed" fi fi # 3 — Global install -info "Installiere global..." +info "Installing globally..." if [ "$global_tool" = "bun" ]; then prepare_bun_global_install - note "Versuche globale Installation mit bun add -g file:$(pwd)" + note "Trying global install with bun add -g file:$(pwd)" if bun add -g "file:$(pwd)" 2>&1 | tail -1; then - ok "Global via Bun installiert" + ok "Installed globally via Bun" else - warn "Globale Bun-Installation fehlgeschlagen, wechsle auf npm-Fallback" + warn "Global Bun install failed, switching to npm fallback" note "Fallback: npm install -g ." if npm install -g . 2>&1 | tail -1; then - ok "Global via npm installiert" + ok "Installed globally via npm" else - fail "Globale Installation fehlgeschlagen (Bun und npm)" + fail "Global install failed (Bun and npm)" fi fi else - note "Führe npm install -g . aus" + note "Running npm install -g ." if npm install -g . 2>&1 | tail -1; then - ok "Global installiert" + ok "Installed globally" else - fail "Globale Installation fehlgeschlagen (evtl. sudo nötig)" + fail "Global install failed (sudo may be required)" fi fi -printf "\n${GREEN}${BOLD}Fertig!${NC} Starte das Dashboard mit:\n" +printf "\n${GREEN}${BOLD}Done!${NC} Start the dashboard with:\n" printf " ${BOLD}ttdash${NC}\n" -printf "\n${BOLD}Nächste Schritte${NC}\n" -printf " ${DIM}• App lokal starten:${NC} ttdash\n" -printf " ${DIM}• Anderen Port verwenden:${NC} PORT=3010 ttdash\n" -printf " ${DIM}• Browser-Autostart deaktivieren:${NC} NO_OPEN_BROWSER=1 ttdash\n" -printf " ${DIM}• Datenquelle im UI:${NC} Auto-Import oder JSON-Upload\n" -printf " ${DIM}• Installierte Version:${NC} %s\n" "$version" -printf "\n${YELLOW}Hinweis:${NC} Falls 'ttdash' nicht gefunden wird, starte ein neues Terminal\n" -printf "oder prüfe deinen globalen Paketpfad.\n" +printf "\n${BOLD}Next steps${NC}\n" +printf " ${DIM}• Start locally:${NC} ttdash\n" +printf " ${DIM}• Start in the background:${NC} ttdash --background\n" +printf " ${DIM}• Stop a background instance:${NC} ttdash stop\n" +printf " ${DIM}• Use a different port:${NC} PORT=3010 ttdash\n" +printf " ${DIM}• Disable browser auto-open:${NC} NO_OPEN_BROWSER=1 ttdash\n" +printf " ${DIM}• Data source in the UI:${NC} Auto-import or JSON upload\n" +printf " ${DIM}• Installed version:${NC} %s\n" "$version" +printf "\n${YELLOW}Note:${NC} If 'ttdash' is not found, open a new terminal\n" +printf "or check your global package path.\n" diff --git a/package-lock.json b/package-lock.json index 96623b3..fe3d948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "ttdash", - "version": "6.0.11", + "name": "@roastcodes/ttdash", + "version": "6.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ttdash", - "version": "6.0.11", + "name": "@roastcodes/ttdash", + "version": "6.1.0", "license": "MIT", "dependencies": { "i18next": "^26.0.3", diff --git a/package.json b/package.json index cc71885..f51c9ed 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ttdash", - "version": "6.0.11", - "description": "Local dashboard for toktrack usage data", + "name": "@roastcodes/ttdash", + "version": "6.1.0", + "description": "Local-first dashboard and CLI for toktrack usage data", "main": "server.js", "repository": { "type": "git", @@ -27,6 +27,9 @@ "test:e2e": "npm run build && playwright test", "test:e2e:ci": "playwright test", "test:all": "npm run test:unit && npm run test:e2e", + "pack:dry-run": "npm pack --dry-run", + "verify:package": "node scripts/verify-package.js", + "verify:release": "npm run test:unit:coverage && npm run build && npm run verify:package", "prepare": "npm run build" }, "files": [ @@ -38,13 +41,19 @@ ], "keywords": [ "toktrack", - "codex", - "claude", + "cli", "dashboard", + "local-first", + "npx", + "bunx", + "reporting", "usage", "tokens" ], "license": "MIT", + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=20" }, diff --git a/playwright.config.ts b/playwright.config.ts index ade72a0..0763a8b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig } from '@playwright/test' +const host = process.env.PLAYWRIGHT_TEST_HOST || '127.0.0.1' +const port = process.env.PLAYWRIGHT_TEST_PORT || '3015' +const baseURL = `http://${host}:${port}` + export default defineConfig({ testDir: './tests/e2e', fullyParallel: false, @@ -10,14 +14,14 @@ export default defineConfig({ ['junit', { outputFile: 'test-results/playwright.junit.xml' }], ], use: { - baseURL: 'http://127.0.0.1:3015', + baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, webServer: { command: 'npm run start:test-server', - url: 'http://127.0.0.1:3015', + url: baseURL, reuseExistingServer: false, timeout: 120_000, }, diff --git a/scripts/start-test-server.js b/scripts/start-test-server.js index a2c02df..05ca0bf 100644 --- a/scripts/start-test-server.js +++ b/scripts/start-test-server.js @@ -12,8 +12,11 @@ fs.mkdirSync(path.join(runtimeRoot, 'config'), { recursive: true }) fs.mkdirSync(path.join(runtimeRoot, 'data'), { recursive: true }) process.env.NO_OPEN_BROWSER = '1' -process.env.HOST = process.env.HOST || '127.0.0.1' -process.env.PORT = process.env.PORT || '3015' +process.env.HOST = process.env.HOST || process.env.PLAYWRIGHT_TEST_HOST || '127.0.0.1' +process.env.PORT = process.env.PORT || process.env.PLAYWRIGHT_TEST_PORT || '3015' +process.env.TTDASH_DATA_DIR = path.join(runtimeRoot, 'data') +process.env.TTDASH_CONFIG_DIR = path.join(runtimeRoot, 'config') +process.env.TTDASH_CACHE_DIR = path.join(runtimeRoot, 'cache') process.env.XDG_CACHE_HOME = path.join(runtimeRoot, 'cache') process.env.XDG_CONFIG_HOME = path.join(runtimeRoot, 'config') process.env.XDG_DATA_HOME = path.join(runtimeRoot, 'data') diff --git a/scripts/verify-package.js b/scripts/verify-package.js new file mode 100644 index 0000000..c82eed5 --- /dev/null +++ b/scripts/verify-package.js @@ -0,0 +1,281 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const { execFileSync, spawn } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const PACKAGE_JSON_PATH = path.join(ROOT, 'package.json'); +const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')); + +function log(message) { + process.stdout.write(`${message}\n`); +} + +function mktemp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function npmCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }); +} + +function createNpmEnv() { + const cacheDir = mktemp('ttdash-npm-cache-'); + + return { + ...process.env, + npm_config_cache: cacheDir, + NPM_CONFIG_CACHE: cacheDir, + }; +} + +function parsePackJson(output) { + const trimmed = output.trim(); + if (trimmed.startsWith('[')) { + return JSON.parse(trimmed); + } + + const start = output.indexOf('['); + const end = output.lastIndexOf(']'); + if (start !== -1 && end !== -1 && end > start) { + return JSON.parse(output.slice(start, end + 1)); + } + + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + + if (!line.startsWith('[')) continue; + + try { + return JSON.parse(line); + } catch {} + } + + throw new Error(`npm pack did not produce JSON output.\n${output}`); +} + +function cliBinName() { + return process.platform === 'win32' ? 'ttdash.cmd' : 'ttdash'; +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Could not resolve a free port.'))); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(address.port); + }); + }); + }); +} + +async function waitForServer(url, child) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < 15000) { + if (child.exitCode !== null) { + throw new Error(`Packaged TTDash exited before startup completed (exit ${child.exitCode}).`); + } + + try { + const response = await fetch(`${url}/api/usage`); + if (response.ok) { + return; + } + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + throw new Error('Timed out waiting for packaged TTDash to start.'); +} + +function waitForChildClose(child, timeoutMs) { + if (child.exitCode !== null) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.removeListener('close', handleClose); + resolve(false); + }, timeoutMs); + + function handleClose() { + clearTimeout(timer); + resolve(true); + } + + child.once('close', handleClose); + }); +} + +async function terminateChild(child, label) { + if (child.exitCode !== null) { + await waitForChildClose(child, 1000); + return; + } + + child.kill('SIGTERM'); + + if (await waitForChildClose(child, 5000)) { + return; + } + + child.kill('SIGKILL'); + + if (await waitForChildClose(child, 5000)) { + return; + } + + throw new Error(`${label} did not exit after SIGTERM/SIGKILL.`); +} + +function verifyInstalledCli(command, tarballPath, npmEnv) { + const installDir = mktemp('ttdash-install-'); + const installPackageJson = path.join(installDir, 'package.json'); + fs.writeFileSync(installPackageJson, JSON.stringify({ name: 'ttdash-package-smoke', private: true }, null, 2) + '\n'); + + run(command, ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballPath], { + cwd: installDir, + env: npmEnv, + }); + + const installedCliPath = path.join(installDir, 'node_modules', '.bin', cliBinName()); + if (!fs.existsSync(installedCliPath)) { + throw new Error(`Installed CLI binary missing: ${installedCliPath}`); + } + + const helpOutput = run(installedCliPath, ['--help'], { + cwd: installDir, + env: npmEnv, + }); + + if (!helpOutput.includes(`TTDash v${packageJson.version}`)) { + throw new Error('Installed tarball CLI help output did not contain the expected version banner.'); + } + + log('Verified installed tarball CLI help output.'); + + return { + installDir, + installedCliPath, + }; +} + +async function main() { + const command = npmCommand(); + const packDir = mktemp('ttdash-pack-'); + const appDataRoot = mktemp('ttdash-pack-app-'); + const npmEnv = createNpmEnv(); + + if (!fs.existsSync(path.join(ROOT, 'dist', 'index.html'))) { + log('Production bundle missing, running build first.'); + run(command, ['run', 'build'], { env: npmEnv }); + } + + const packJson = run(command, ['pack', '--json', '--ignore-scripts', '--pack-destination', packDir], { + env: npmEnv, + }); + const [packInfo] = parsePackJson(packJson); + + if (!packInfo || !packInfo.filename) { + throw new Error('npm pack did not return a tarball filename.'); + } + + const tarballPath = path.join(packDir, packInfo.filename); + if (!fs.existsSync(tarballPath)) { + throw new Error(`Packed tarball missing: ${tarballPath}`); + } + + log(`Packed artifact: ${tarballPath}`); + log(`Tarball size: ${packInfo.size} bytes`); + + const { installDir, installedCliPath } = verifyInstalledCli(command, tarballPath, npmEnv); + + const helpOutput = run(command, ['exec', '--yes', '--package', tarballPath, '--', 'ttdash', '--help'], { + env: npmEnv, + }); + + if (!helpOutput.includes(`TTDash v${packageJson.version}`)) { + throw new Error('Packaged CLI help output did not contain the expected version banner.'); + } + + log('Verified packaged CLI help output.'); + + const port = await getFreePort(); + const url = `http://127.0.0.1:${port}`; + const child = spawn(installedCliPath, ['--no-open', '--port', String(port)], { + cwd: installDir, + env: { + ...npmEnv, + HOME: appDataRoot, + NO_OPEN_BROWSER: '1', + HOST: '127.0.0.1', + PORT: String(port), + XDG_CACHE_HOME: path.join(appDataRoot, 'cache'), + XDG_CONFIG_HOME: path.join(appDataRoot, 'config'), + XDG_DATA_HOME: path.join(appDataRoot, 'data'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + output += chunk.toString(); + }); + + try { + await waitForServer(url, child); + const usageResponse = await fetch(`${url}/api/usage`); + if (!usageResponse.ok) { + throw new Error(`Packaged server returned ${usageResponse.status} from /api/usage.`); + } + log(`Verified packaged startup on ${url}.`); + } finally { + await terminateChild(child, 'Packaged TTDash'); + } + + if (!output.includes('TTDash v')) { + throw new Error('Packaged server startup output was missing the expected banner.'); + } + + log('Package verification completed successfully.'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/server.js b/server.js index 838f7f5..fbfcf01 100755 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const readline = require('readline/promises'); const { spawn } = require('child_process'); const { parseArgs } = require('util'); const { normalizeIncomingData } = require('./usage-normalizer'); @@ -15,10 +16,12 @@ const STATIC_ROOT = path.join(ROOT, 'dist'); const APP_DIR_NAME = 'TTDash'; const APP_DIR_NAME_LINUX = 'ttdash'; const LEGACY_DATA_FILE = path.join(ROOT, 'data.json'); -const CLI_OPTIONS = parseCliArgs(process.argv.slice(2)); +const RAW_CLI_ARGS = process.argv.slice(2); +const NORMALIZED_CLI_ARGS = normalizeCliArgs(RAW_CLI_ARGS); +const CLI_OPTIONS = parseCliArgs(RAW_CLI_ARGS); const ENV_START_PORT = parseInt(process.env.PORT, 10); const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_START_PORT : 3000); -const MAX_PORT = START_PORT + 100; +const MAX_PORT = Math.min(START_PORT + 100, 65535); const BIND_HOST = process.env.HOST || '127.0.0.1'; const API_PREFIX = '/port/5000/api'; const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB @@ -32,14 +35,51 @@ const SECURITY_HEADERS = { 'Content-Security-Policy': "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }; const APP_LABEL = 'TTDash'; +const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup'; +const USAGE_BACKUP_KIND = 'ttdash-usage-backup'; +const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1'; +const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1'; +const BACKGROUND_START_TIMEOUT_MS = 15000; +const DASHBOARD_DATE_PRESETS = ['all', '7d', '30d', 'month', 'year']; +const DASHBOARD_SECTION_IDS = [ + 'insights', + 'metrics', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', +]; const DEFAULT_SETTINGS = { language: 'de', theme: 'dark', providerLimits: {}, + defaultFilters: { + viewMode: 'daily', + datePreset: 'all', + providers: [], + models: [], + }, + sectionVisibility: Object.fromEntries(DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true])), + sectionOrder: DASHBOARD_SECTION_IDS, lastLoadedAt: null, lastLoadSource: null, }; let startupAutoLoadCompleted = false; +const RUNTIME_INSTANCE = { + id: process.env.TTDASH_INSTANCE_ID || `${process.pid}-${Date.now()}`, + pid: process.pid, + startedAt: new Date().toISOString(), + mode: IS_BACKGROUND_CHILD ? 'background' : 'foreground', +}; +let runtimePort = null; +let runtimeUrl = null; function normalizeCliArgs(args) { return args.map((arg) => { @@ -49,6 +89,9 @@ function normalizeCliArgs(args) { if (arg === '-al') { return '--auto-load'; } + if (arg === '-bg') { + return '--background'; + } return arg; }); } @@ -56,21 +99,25 @@ function normalizeCliArgs(args) { function printHelp() { console.log(`TTDash v${APP_VERSION}`); console.log(''); - console.log('Verwendung:'); - console.log(' ttdash [optionen]'); + console.log('Usage:'); + console.log(' ttdash [options]'); + console.log(' ttdash stop'); console.log(''); - console.log('Optionen:'); - console.log(' -p, --port Startport festlegen'); - console.log(' -h, --help Diese Hilfe anzeigen'); - console.log(' -no, --no-open Browser-Autostart deaktivieren'); - console.log(' -al, --auto-load Führt direkt beim Start einen Auto-Import aus'); + console.log('Options:'); + console.log(' -p, --port Set the start port'); + console.log(' -h, --help Show this help'); + console.log(' -no, --no-open Disable browser auto-open'); + console.log(' -al, --auto-load Run auto-import immediately on startup'); + console.log(' -b, --background Start TTDash as a background process'); console.log(''); - console.log('Beispiele:'); + console.log('Examples:'); console.log(' ttdash --port 3010'); console.log(' ttdash -p 3010 -no'); console.log(' ttdash --auto-load'); + console.log(' ttdash --background'); + console.log(' ttdash stop'); console.log(''); - console.log('Umgebungsvariablen:'); + console.log('Environment variables:'); console.log(' PORT=3010 ttdash'); console.log(' NO_OPEN_BROWSER=1 ttdash'); console.log(' HOST=127.0.0.1 ttdash'); @@ -83,7 +130,7 @@ function parseCliArgs(rawArgs) { try { parsed = parseArgs({ args, - allowPositionals: false, + allowPositionals: true, strict: true, options: { port: { @@ -100,6 +147,10 @@ function parseCliArgs(rawArgs) { 'auto-load': { type: 'boolean', }, + background: { + type: 'boolean', + short: 'b', + }, }, }); } catch (error) { @@ -114,11 +165,30 @@ function parseCliArgs(rawArgs) { process.exit(0); } + let command = null; + if (parsed.positionals.length > 1) { + console.error(`Unknown invocation: ${parsed.positionals.join(' ')}`); + console.log(''); + printHelp(); + process.exit(1); + } + + if (parsed.positionals.length === 1) { + if (parsed.positionals[0] !== 'stop') { + console.error(`Unknown command: ${parsed.positionals[0]}`); + console.log(''); + printHelp(); + process.exit(1); + } + + command = 'stop'; + } + let port; if (parsed.values.port !== undefined) { const parsedPort = Number.parseInt(parsed.values.port, 10); if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) { - console.error(`Ungültiger Port: ${parsed.values.port}`); + console.error(`Invalid port: ${parsed.values.port}`); console.log(''); printHelp(); process.exit(1); @@ -127,37 +197,49 @@ function parseCliArgs(rawArgs) { } return { + command, port, noOpen: Boolean(parsed.values['no-open']), autoLoad: Boolean(parsed.values['auto-load']), + background: Boolean(parsed.values.background), }; } function resolveAppPaths() { const homeDir = os.homedir(); + const explicitPaths = { + dataDir: process.env.TTDASH_DATA_DIR, + configDir: process.env.TTDASH_CONFIG_DIR, + cacheDir: process.env.TTDASH_CACHE_DIR, + }; + let platformPaths; if (process.platform === 'darwin') { const appSupportDir = path.join(homeDir, 'Library', 'Application Support', APP_DIR_NAME); - return { + platformPaths = { dataDir: appSupportDir, configDir: appSupportDir, cacheDir: path.join(homeDir, 'Library', 'Caches', APP_DIR_NAME), }; - } - - if (IS_WINDOWS) { - return { + } else if (IS_WINDOWS) { + platformPaths = { dataDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), APP_DIR_NAME), configDir: path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), APP_DIR_NAME), cacheDir: path.join(process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), APP_DIR_NAME, 'Cache'), }; + } else { + const appName = APP_DIR_NAME_LINUX; + platformPaths = { + dataDir: path.join(process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), appName), + configDir: path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), appName), + cacheDir: path.join(process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'), appName), + }; } - const appName = APP_DIR_NAME_LINUX; return { - dataDir: path.join(process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), appName), - configDir: path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), appName), - cacheDir: path.join(process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'), appName), + dataDir: explicitPaths.dataDir || platformPaths.dataDir, + configDir: explicitPaths.configDir || platformPaths.configDir, + cacheDir: explicitPaths.cacheDir || platformPaths.cacheDir, }; } @@ -165,6 +247,11 @@ const APP_PATHS = resolveAppPaths(); const DATA_FILE = path.join(APP_PATHS.dataDir, 'data.json'); const SETTINGS_FILE = path.join(APP_PATHS.configDir, 'settings.json'); const NPX_CACHE_DIR = path.join(APP_PATHS.cacheDir, 'npx-cache'); +const BACKGROUND_INSTANCES_FILE = path.join(APP_PATHS.configDir, 'background-instances.json'); +const BACKGROUND_LOG_DIR = path.join(APP_PATHS.cacheDir, 'background'); +const BACKGROUND_INSTANCES_LOCK_DIR = path.join(APP_PATHS.configDir, 'background-instances.lock'); +const BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS = 5000; +const BACKGROUND_INSTANCES_LOCK_STALE_MS = 10000; const MIME_TYPES = { '.html': 'text/html; charset=utf-8', @@ -190,6 +277,7 @@ function ensureAppDirs() { ensureDir(APP_PATHS.configDir); ensureDir(APP_PATHS.cacheDir); ensureDir(NPX_CACHE_DIR); + ensureDir(BACKGROUND_LOG_DIR); } function writeJsonAtomic(filePath, data) { @@ -199,6 +287,470 @@ function writeJsonAtomic(filePath, data) { fs.renameSync(tempPath, filePath); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function formatDateTime(value) { + return new Intl.DateTimeFormat('de-CH', { + dateStyle: 'short', + timeStyle: 'medium', + }).format(new Date(value)); +} + +function isProcessRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && error.code === 'EPERM'; + } +} + +async function fetchRuntimeIdentity(url, timeoutMs = 1000) { + if (typeof url !== 'string' || !url.trim()) { + return null; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(new URL('/api/runtime', `${url}/`), { + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + if (!payload || typeof payload !== 'object') { + return null; + } + + return payload; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +async function isBackgroundInstanceOwned(instance) { + if (!instance || typeof instance !== 'object') { + return false; + } + + if (!isProcessRunning(instance.pid)) { + return false; + } + + const runtime = await fetchRuntimeIdentity(instance.url); + if (!runtime || typeof runtime.id !== 'string') { + return false; + } + + return runtime.id === instance.id + && runtime.pid === instance.pid + && runtime.port === instance.port; +} + +function normalizeBackgroundInstance(value) { + if (!value || typeof value !== 'object') { + return null; + } + + const pid = Number.parseInt(value.pid, 10); + const port = Number.parseInt(value.port, 10); + const startedAt = normalizeIsoTimestamp(value.startedAt); + const id = typeof value.id === 'string' && value.id.trim() + ? value.id.trim() + : null; + const url = typeof value.url === 'string' && value.url.trim() + ? value.url.trim() + : null; + const host = typeof value.host === 'string' && value.host.trim() + ? value.host.trim() + : BIND_HOST; + + if (!id || !url || !startedAt || !Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || port <= 0) { + return null; + } + + return { + id, + pid, + port, + url, + host, + startedAt, + logFile: typeof value.logFile === 'string' && value.logFile.trim() + ? value.logFile.trim() + : null, + }; +} + +function readBackgroundInstancesRaw() { + try { + const parsed = JSON.parse(fs.readFileSync(BACKGROUND_INSTANCES_FILE, 'utf-8')); + if (Array.isArray(parsed)) { + return parsed; + } + } catch {} + + return []; +} + +function writeBackgroundInstances(instances) { + writeJsonAtomic(BACKGROUND_INSTANCES_FILE, instances); +} + +async function readBackgroundInstancesSnapshot() { + const normalized = readBackgroundInstancesRaw() + .map(normalizeBackgroundInstance) + .filter(Boolean); + const alive = []; + + for (const instance of normalized) { + if (await isBackgroundInstanceOwned(instance)) { + alive.push(instance); + } + } + + const changed = readBackgroundInstancesRaw().length !== alive.length; + + alive.sort((left, right) => { + const byStartedAt = left.startedAt.localeCompare(right.startedAt); + if (byStartedAt !== 0) { + return byStartedAt; + } + return left.port - right.port; + }); + + return { + normalized, + alive, + changed, + }; +} + +async function getBackgroundInstances() { + return (await readBackgroundInstancesSnapshot()).alive; +} + +async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (true) { + try { + fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR); + break; + } catch (error) { + if (!error || error.code !== 'EEXIST') { + throw error; + } + + let lockIsStale = false; + try { + const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR); + lockIsStale = (Date.now() - stats.mtimeMs) > BACKGROUND_INSTANCES_LOCK_STALE_MS; + } catch {} + + if (lockIsStale) { + try { + fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true }); + continue; + } catch {} + } + + if (Date.now() - startedAt >= timeoutMs) { + throw new Error('Could not acquire background registry lock.'); + } + + await sleep(50); + } + } + + try { + return await callback(); + } finally { + try { + fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true }); + } catch {} + } +} + +async function pruneBackgroundInstances() { + return withBackgroundInstancesLock(async () => { + const snapshot = await readBackgroundInstancesSnapshot(); + if (snapshot.changed) { + writeBackgroundInstances(snapshot.alive); + } + + return snapshot.alive; + }); +} + +async function registerBackgroundInstance(instance) { + return withBackgroundInstancesLock(async () => { + const instances = (await readBackgroundInstancesSnapshot()).alive; + const nextInstances = instances.filter((entry) => entry.pid !== instance.pid); + nextInstances.push(instance); + nextInstances.sort((left, right) => { + const byStartedAt = left.startedAt.localeCompare(right.startedAt); + if (byStartedAt !== 0) { + return byStartedAt; + } + return left.port - right.port; + }); + writeBackgroundInstances(nextInstances); + }); +} + +async function unregisterBackgroundInstance(pid) { + return withBackgroundInstancesLock(async () => { + const instances = (await readBackgroundInstancesSnapshot()).alive; + const nextInstances = instances.filter((entry) => entry.pid !== pid); + if (nextInstances.length !== instances.length) { + writeBackgroundInstances(nextInstances); + } + }); +} + +function createBackgroundInstance({ port, url }) { + return { + id: RUNTIME_INSTANCE.id, + pid: RUNTIME_INSTANCE.pid, + port, + url, + host: BIND_HOST, + startedAt: RUNTIME_INSTANCE.startedAt, + logFile: process.env.TTDASH_BACKGROUND_LOG_FILE || null, + }; +} + +function buildBackgroundLogFilePath() { + return path.join(BACKGROUND_LOG_DIR, `server-${Date.now()}.log`); +} + +async function waitForBackgroundInstance(pid, timeoutMs = BACKGROUND_START_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const instance = (await getBackgroundInstances()).find((entry) => entry.pid === pid); + if (instance) { + return instance; + } + + if (!isProcessRunning(pid)) { + return null; + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + return null; +} + +async function waitForBackgroundInstanceExit(instance, timeoutMs = 5000) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (!(await isBackgroundInstanceOwned(instance))) { + return true; + } + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + return !(await isBackgroundInstanceOwned(instance)); +} + +function formatBackgroundInstanceLabel(instance, index) { + const parts = [ + `${index + 1}. ${instance.url}`, + `PID ${instance.pid}`, + `Port ${instance.port}`, + `started ${formatDateTime(instance.startedAt)}`, + ]; + + if (instance.logFile) { + parts.push(`log ${instance.logFile}`); + } + + return parts.join(' | '); +} + +async function promptForBackgroundInstance(instances) { + if (instances.length === 1) { + return instances[0]; + } + + console.log('Multiple TTDash background servers are running:'); + instances.forEach((instance, index) => { + console.log(` ${formatBackgroundInstanceLabel(instance, index)}`); + }); + console.log(''); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + while (true) { + const answer = (await rl.question(`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `)).trim(); + + if (!answer) { + return null; + } + + const selection = Number.parseInt(answer, 10); + if (Number.isInteger(selection) && selection >= 1 && selection <= instances.length) { + return instances[selection - 1]; + } + + console.log(`Invalid selection: ${answer}`); + } + } finally { + rl.close(); + } +} + +async function stopBackgroundInstance(instance) { + if (!(await isBackgroundInstanceOwned(instance))) { + await unregisterBackgroundInstance(instance.pid); + return { + status: 'already-stopped', + instance, + }; + } + + try { + process.kill(instance.pid, 'SIGTERM'); + } catch (error) { + if (error && error.code === 'ESRCH') { + await unregisterBackgroundInstance(instance.pid); + return { + status: 'already-stopped', + instance, + }; + } + + if (error && error.code === 'EPERM') { + return { + status: 'forbidden', + instance, + }; + } + + throw error; + } + + if (await waitForBackgroundInstanceExit(instance)) { + await unregisterBackgroundInstance(instance.pid); + return { + status: 'stopped', + instance, + }; + } + + return { + status: 'timeout', + instance, + }; +} + +async function runStopCommand() { + ensureAppDirs(); + + const instances = await pruneBackgroundInstances(); + if (instances.length === 0) { + console.log('No running TTDash background servers found.'); + return; + } + + const selectedInstance = await promptForBackgroundInstance(instances); + if (!selectedInstance) { + console.log('Canceled.'); + return; + } + + const result = await stopBackgroundInstance(selectedInstance); + if (result.status === 'stopped') { + console.log(`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + return; + } + + if (result.status === 'already-stopped') { + console.log(`Instance was already stopped and was removed from the registry: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + return; + } + + if (result.status === 'forbidden') { + console.error(`Could not stop TTDash background server (permission denied): ${selectedInstance.url} (PID ${selectedInstance.pid})`); + process.exitCode = 1; + return; + } + + console.error(`TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + if (selectedInstance.logFile) { + console.error(`Log file: ${selectedInstance.logFile}`); + } + process.exitCode = 1; +} + +function shouldBackgroundChildOpenBrowser() { + return !(CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1'); +} + +async function startInBackground() { + ensureAppDirs(); + + const logFile = buildBackgroundLogFilePath(); + const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background'); + const logFd = fs.openSync(logFile, 'a'); + + let child; + try { + child = spawn(process.execPath, [__filename, ...childArgs], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: { + ...process.env, + TTDASH_BACKGROUND_CHILD: '1', + TTDASH_BACKGROUND_LOG_FILE: logFile, + TTDASH_FORCE_OPEN_BROWSER: shouldBackgroundChildOpenBrowser() ? '1' : '0', + }, + }); + } finally { + fs.closeSync(logFd); + } + + child.unref(); + + const instance = await waitForBackgroundInstance(child.pid); + if (!instance) { + const logOutput = fs.existsSync(logFile) + ? fs.readFileSync(logFile, 'utf-8').trim() + : ''; + throw new Error(logOutput || `Could not start TTDash as a background process. Log: ${logFile}`); + } + + console.log('TTDash is running in the background.'); + console.log(` URL: ${instance.url}`); + console.log(` PID: ${instance.pid}`); + console.log(` Log: ${logFile}`); + console.log(''); + console.log('Stop it with:'); + console.log(' ttdash stop'); +} + function migrateLegacyDataFile() { if (!fs.existsSync(LEGACY_DATA_FILE) || fs.existsSync(DATA_FILE)) { return; @@ -208,13 +760,13 @@ function migrateLegacyDataFile() { try { fs.renameSync(LEGACY_DATA_FILE, DATA_FILE); - console.log(`Migriere bestehende Daten nach ${DATA_FILE}`); + console.log(`Migrating existing data to ${DATA_FILE}`); } catch { fs.copyFileSync(LEGACY_DATA_FILE, DATA_FILE); try { fs.unlinkSync(LEGACY_DATA_FILE); } catch {} - console.log(`Kopiere bestehende Daten nach ${DATA_FILE}`); + console.log(`Copying existing data to ${DATA_FILE}`); } } @@ -226,6 +778,14 @@ function normalizeTheme(value) { return value === 'light' ? 'light' : 'dark'; } +function normalizeViewMode(value) { + return value === 'monthly' || value === 'yearly' ? value : 'daily'; +} + +function normalizeDashboardDatePreset(value) { + return DASHBOARD_DATE_PRESETS.includes(value) ? value : 'all'; +} + function normalizeLastLoadSource(value) { return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value @@ -250,6 +810,169 @@ function sanitizeCurrency(value) { return Math.max(0, Number(value.toFixed(2))); } +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function computeUsageTotals(daily) { + return daily.reduce((totals, day) => ({ + inputTokens: totals.inputTokens + (day.inputTokens || 0), + outputTokens: totals.outputTokens + (day.outputTokens || 0), + cacheCreationTokens: totals.cacheCreationTokens + (day.cacheCreationTokens || 0), + cacheReadTokens: totals.cacheReadTokens + (day.cacheReadTokens || 0), + thinkingTokens: totals.thinkingTokens + (day.thinkingTokens || 0), + totalCost: totals.totalCost + (day.totalCost || 0), + totalTokens: totals.totalTokens + (day.totalTokens || 0), + requestCount: totals.requestCount + (day.requestCount || 0), + }), { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }); +} + +function sortStrings(values) { + return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === 'string' && value.trim()))] + .sort((left, right) => left.localeCompare(right)); +} + +function canonicalizeModelBreakdown(entry) { + return { + modelName: typeof entry?.modelName === 'string' ? entry.modelName : '', + inputTokens: Number(entry?.inputTokens) || 0, + outputTokens: Number(entry?.outputTokens) || 0, + cacheCreationTokens: Number(entry?.cacheCreationTokens) || 0, + cacheReadTokens: Number(entry?.cacheReadTokens) || 0, + thinkingTokens: Number(entry?.thinkingTokens) || 0, + cost: Number(entry?.cost) || 0, + requestCount: Number(entry?.requestCount) || 0, + }; +} + +function canonicalizeUsageDay(day) { + return { + date: typeof day?.date === 'string' ? day.date : '', + inputTokens: Number(day?.inputTokens) || 0, + outputTokens: Number(day?.outputTokens) || 0, + cacheCreationTokens: Number(day?.cacheCreationTokens) || 0, + cacheReadTokens: Number(day?.cacheReadTokens) || 0, + thinkingTokens: Number(day?.thinkingTokens) || 0, + totalTokens: Number(day?.totalTokens) || 0, + totalCost: Number(day?.totalCost) || 0, + requestCount: Number(day?.requestCount) || 0, + modelsUsed: sortStrings(day?.modelsUsed), + modelBreakdowns: (Array.isArray(day?.modelBreakdowns) ? day.modelBreakdowns : []) + .map(canonicalizeModelBreakdown) + .sort((left, right) => left.modelName.localeCompare(right.modelName)), + }; +} + +function areUsageDaysEquivalent(left, right) { + return JSON.stringify(canonicalizeUsageDay(left)) === JSON.stringify(canonicalizeUsageDay(right)); +} + +function extractSettingsImportPayload(payload) { + if (!isPlainObject(payload)) { + throw new Error('Uploaded JSON is not a settings backup file.'); + } + + if (payload.kind === SETTINGS_BACKUP_KIND) { + if (!Object.prototype.hasOwnProperty.call(payload, 'settings')) { + throw new Error('The settings backup file does not contain any settings.'); + } + if (!isPlainObject(payload.settings)) { + throw new Error('The settings backup file has an invalid settings payload.'); + } + return payload.settings; + } + + if (typeof payload.kind === 'string' && payload.kind === USAGE_BACKUP_KIND) { + throw new Error('This is a data backup file, not a settings file.'); + } + + throw new Error('Uploaded JSON is not a settings backup file.'); +} + +function extractUsageImportPayload(payload) { + if (!isPlainObject(payload)) { + return payload; + } + + if (payload.kind === USAGE_BACKUP_KIND) { + if (!Object.prototype.hasOwnProperty.call(payload, 'data')) { + throw new Error('The usage backup file does not contain any usage data.'); + } + return payload.data; + } + + if (typeof payload.kind === 'string' && payload.kind === SETTINGS_BACKUP_KIND) { + throw new Error('This is a settings backup file, not a data file.'); + } + + return payload; +} + +function mergeUsageData(currentData, importedData) { + const current = currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0 + ? normalizeIncomingData(currentData) + : null; + + if (!current) { + return { + data: importedData, + summary: { + importedDays: importedData.daily.length, + addedDays: importedData.daily.length, + unchangedDays: 0, + conflictingDays: 0, + totalDays: importedData.daily.length, + }, + }; + } + + const currentByDate = new Map(current.daily.map((day) => [day.date, day])); + let addedDays = 0; + let unchangedDays = 0; + let conflictingDays = 0; + + for (const importedDay of importedData.daily) { + const existingDay = currentByDate.get(importedDay.date); + if (!existingDay) { + currentByDate.set(importedDay.date, importedDay); + addedDays += 1; + continue; + } + + if (areUsageDaysEquivalent(existingDay, importedDay)) { + unchangedDays += 1; + continue; + } + + conflictingDays += 1; + } + + const mergedDaily = [...currentByDate.values()].sort((left, right) => left.date.localeCompare(right.date)); + + return { + data: { + daily: mergedDaily, + totals: computeUsageTotals(mergedDaily), + }, + summary: { + importedDays: importedData.daily.length, + addedDays, + unchangedDays, + conflictingDays, + totalDays: mergedDaily.length, + }, + }; +} + function normalizeProviderLimitConfig(value) { if (!value || typeof value !== 'object') { return { @@ -278,12 +1001,64 @@ function normalizeProviderLimits(value) { return next; } +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value + .filter((entry) => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean))]; +} + +function normalizeDefaultFilters(value) { + const source = value && typeof value === 'object' ? value : {}; + + return { + viewMode: normalizeViewMode(source.viewMode), + datePreset: normalizeDashboardDatePreset(source.datePreset), + providers: normalizeStringList(source.providers), + models: normalizeStringList(source.models), + }; +} + +function normalizeSectionVisibility(value) { + const source = value && typeof value === 'object' ? value : {}; + const next = {}; + + for (const sectionId of DASHBOARD_SECTION_IDS) { + next[sectionId] = typeof source[sectionId] === 'boolean' + ? source[sectionId] + : true; + } + + return next; +} + +function normalizeSectionOrder(value) { + if (!Array.isArray(value)) { + return [...DASHBOARD_SECTION_IDS]; + } + + const incoming = value.filter((sectionId) => ( + typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId) + )); + const uniqueIncoming = [...new Set(incoming)]; + const missing = DASHBOARD_SECTION_IDS.filter((sectionId) => !uniqueIncoming.includes(sectionId)); + + return [...uniqueIncoming, ...missing]; +} + function normalizeSettings(value) { const source = value && typeof value === 'object' ? value : {}; return { language: normalizeLanguage(source.language), theme: normalizeTheme(source.theme), providerLimits: normalizeProviderLimits(source.providerLimits), + defaultFilters: normalizeDefaultFilters(source.defaultFilters), + sectionVisibility: normalizeSectionVisibility(source.sectionVisibility), + sectionOrder: normalizeSectionOrder(source.sectionOrder), lastLoadedAt: normalizeIsoTimestamp(source.lastLoadedAt), lastLoadSource: normalizeLastLoadSource(source.lastLoadSource), }; @@ -320,7 +1095,15 @@ function openBrowser(url) { } function shouldOpenBrowser() { - return !(CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1' || !process.stdout.isTTY); + if (CLI_OPTIONS.noOpen || process.env.NO_OPEN_BROWSER === '1' || process.env.CI === '1') { + return false; + } + + if (FORCE_OPEN_BROWSER) { + return true; + } + + return Boolean(process.stdout.isTTY); } function formatCurrency(value) { @@ -338,52 +1121,61 @@ function formatInteger(value) { function describeDataFile() { if (!fs.existsSync(DATA_FILE)) { - return 'keine lokale Datei gefunden'; + return 'no local file found'; } try { const normalized = readData(); if (!normalized) { - return 'vorhanden, aber nicht lesbar'; + return 'present, but unreadable'; } const totalCost = formatCurrency(normalized.totals?.totalCost || 0); const totalTokens = formatInteger(normalized.totals?.totalTokens || 0); const dailyCount = formatInteger(normalized.daily?.length || 0); - return `${dailyCount} Tage, ${totalCost}, ${totalTokens} Tokens`; + return `${dailyCount} days, ${totalCost}, ${totalTokens} tokens`; } catch { - return 'vorhanden, aber nicht lesbar'; + return 'present, but unreadable'; } } function printStartupSummary(url, port) { const browserMode = shouldOpenBrowser() - ? 'aktiviert' - : 'deaktiviert'; + ? 'enabled' + : 'disabled'; const autoLoadMode = CLI_OPTIONS.autoLoad - ? 'aktiviert' - : 'deaktiviert'; + ? 'enabled' + : 'disabled'; + const runtimeMode = IS_BACKGROUND_CHILD + ? 'background' + : 'foreground'; console.log(''); - console.log(`${APP_LABEL} v${APP_VERSION} ist bereit`); + console.log(`${APP_LABEL} v${APP_VERSION} is ready`); console.log(` URL: ${url}`); console.log(` API: ${url}/api/usage`); console.log(` Port: ${port}`); console.log(` Host: ${BIND_HOST}`); + console.log(` Mode: ${runtimeMode}`); console.log(` Static Root: ${STATIC_ROOT}`); - console.log(` Daten-Datei: ${DATA_FILE}`); - console.log(` Settings-Datei: ${SETTINGS_FILE}`); - console.log(` Datenstatus: ${describeDataFile()}`); - console.log(` Browser-Start: ${browserMode}`); + console.log(` Data File: ${DATA_FILE}`); + console.log(` Settings File: ${SETTINGS_FILE}`); + if (IS_BACKGROUND_CHILD && process.env.TTDASH_BACKGROUND_LOG_FILE) { + console.log(` Log File: ${process.env.TTDASH_BACKGROUND_LOG_FILE}`); + } + console.log(` Data Status: ${describeDataFile()}`); + console.log(` Browser Open: ${browserMode}`); console.log(` Auto-Load: ${autoLoadMode}`); console.log(''); - console.log('Verfügbare Wege für Daten:'); - console.log(' 1. Auto-Import aus der App starten'); - console.log(' 2. toktrack JSON per Upload importieren'); + console.log('Available ways to load data:'); + console.log(' 1. Start auto-import from the app'); + console.log(' 2. Import toktrack JSON via upload'); console.log(''); - console.log('Nützliche Kommandos:'); + console.log('Useful commands:'); console.log(` ttdash --port ${port}`); console.log(` ttdash --port ${port} --no-open`); + console.log(' ttdash --background'); + console.log(' ttdash stop'); console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`); console.log(` curl ${url}/api/usage`); console.log(''); @@ -606,8 +1398,8 @@ async function resolveToktrackRunner() { command: TOKTRACK_LOCAL_BIN, prefixArgs: [], env: process.env, - method: 'lokal', - label: 'lokales toktrack', + method: 'local', + label: 'local toktrack', displayCommand: 'node_modules/.bin/toktrack daily --json', }; } @@ -672,7 +1464,7 @@ function runToktrack(runner, args, { streamStderr = false, onStderr, signalOnClo resolve(stdout.trimEnd()); return; } - reject(new Error(stderr.trim() || `${runner.label} konnte nicht gestartet werden.`)); + reject(new Error(stderr.trim() || `Could not start ${runner.label}.`)); }); }); } @@ -685,24 +1477,24 @@ async function performAutoImport({ signalOnClose, } = {}) { if (autoImportRunning) { - throw new Error('Ein Auto-Import läuft bereits. Bitte warten.'); + throw new Error('An auto-import is already running. Please wait.'); } autoImportRunning = true; let progressSeconds = 0; const progressInterval = setInterval(() => { progressSeconds += 5; - onOutput(`Verarbeite Nutzungsdaten... (${progressSeconds}s)`); + onOutput(`Processing usage data... (${progressSeconds}s)`); }, 5000); try { onCheck({ tool: 'toktrack', status: 'checking' }); - onProgress({ message: 'Starte lokalen toktrack-Import...' }); + onProgress({ message: 'Starting local toktrack import...' }); const runner = await resolveToktrackRunner(); if (!runner) { onCheck({ tool: 'toktrack', status: 'not_found' }); - throw new Error('Kein lokales toktrack, Bun oder npm exec gefunden.'); + throw new Error('No local toktrack, Bun, or npm exec installation found.'); } const versionResult = await runToktrack(runner, ['--version']); @@ -712,7 +1504,7 @@ async function performAutoImport({ method: runner.label, version: String(versionResult).replace(/^toktrack\s+/, ''), }); - onProgress({ message: `Lade Nutzungsdaten via ${runner.displayCommand}...` }); + onProgress({ message: `Loading usage data via ${runner.displayCommand}...` }); const rawJson = await runToktrack(runner, ['daily', '--json'], { streamStderr: true, @@ -737,14 +1529,14 @@ async function performAutoImport({ } async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { - console.log('Auto-Load aktiviert, starte Import...'); + console.log('Auto-load enabled, starting import...'); try { const result = await performAutoImport({ source, onCheck: (event) => { if (event.status === 'found') { - console.log(`toktrack gefunden (${event.method}, v${event.version})`); + console.log(`toktrack found (${event.method}, v${event.version})`); } }, onProgress: (event) => { @@ -756,10 +1548,10 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { }); startupAutoLoadCompleted = true; - console.log(`Auto-Load abgeschlossen: ${result.days} Tage importiert, ${formatCurrency(result.totalCost)}.`); + console.log(`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`); } catch (error) { - console.error(`Auto-Load fehlgeschlagen: ${error.message}`); - console.error('Dashboard startet ohne neu importierte Daten.'); + console.error(`Auto-load failed: ${error.message}`); + console.error('Dashboard will start without newly imported data.'); } } @@ -797,23 +1589,58 @@ const server = http.createServer(async (req, res) => { return json(res, 405, { message: 'Method Not Allowed' }); } + if (apiPath === '/runtime') { + if (req.method !== 'GET') { + return json(res, 405, { message: 'Method Not Allowed' }); + } + + return json(res, 200, { + id: RUNTIME_INSTANCE.id, + pid: RUNTIME_INSTANCE.pid, + startedAt: RUNTIME_INSTANCE.startedAt, + mode: RUNTIME_INSTANCE.mode, + port: runtimePort, + url: runtimeUrl, + }); + } + if (apiPath === '/settings') { if (req.method === 'GET') { return json(res, 200, readSettings()); } + if (req.method === 'DELETE') { + try { fs.unlinkSync(SETTINGS_FILE); } catch {} + return json(res, 200, { success: true, settings: readSettings() }); + } + if (req.method === 'PATCH') { try { const body = await readBody(req); return json(res, 200, updateSettings(body)); } catch (e) { - return json(res, 400, { message: e.message || 'Ungültige Settings-Anfrage' }); + return json(res, 400, { message: e.message || 'Invalid settings request' }); } } return json(res, 405, { message: 'Method Not Allowed' }); } + if (apiPath === '/settings/import') { + if (req.method !== 'POST') { + return json(res, 405, { message: 'Method Not Allowed' }); + } + + try { + const body = await readBody(req); + const importedSettings = normalizeSettings(extractSettingsImportPayload(body)); + writeSettings(importedSettings); + return json(res, 200, toSettingsResponse(importedSettings)); + } catch (e) { + return json(res, 400, { message: e.message || 'Invalid settings file' }); + } + } + if (apiPath === '/upload') { if (req.method === 'POST') { try { @@ -827,14 +1654,32 @@ const server = http.createServer(async (req, res) => { } catch (e) { const status = e.message === 'Payload too large' ? 413 : 400; const message = e.message === 'Payload too large' - ? 'Datei zu gross (max. 10 MB)' - : e.message || 'Ungültiges JSON'; + ? 'File too large (max. 10 MB)' + : e.message || 'Invalid JSON'; return json(res, status, { message }); } } return json(res, 405, { message: 'Method Not Allowed' }); } + if (apiPath === '/usage/import') { + if (req.method !== 'POST') { + return json(res, 405, { message: 'Method Not Allowed' }); + } + + try { + const body = await readBody(req); + const importedData = normalizeIncomingData(extractUsageImportPayload(body)); + const currentData = readData(); + const result = mergeUsageData(currentData, importedData); + writeData(result.data); + recordDataLoad('file'); + return json(res, 200, result.summary); + } catch (e) { + return json(res, 400, { message: e.message || 'Invalid usage backup file' }); + } + } + if (apiPath === '/auto-import/stream') { if (req.method !== 'GET') { return json(res, 405, { message: 'Method Not Allowed' }); @@ -881,7 +1726,7 @@ const server = http.createServer(async (req, res) => { res.end(); } catch (err) { if (aborted) { return; } - sendSSE(res, 'error', { message: `Fehler: ${err.message}` }); + sendSSE(res, 'error', { message: `Error: ${err.message}` }); sendSSE(res, 'done', {}); res.end(); } @@ -895,7 +1740,7 @@ const server = http.createServer(async (req, res) => { const data = readData(); if (!data || !Array.isArray(data.daily) || data.daily.length === 0) { - return json(res, 400, { message: 'Keine Daten für den Report vorhanden.' }); + return json(res, 400, { message: 'No data available for the report.' }); } let body = {}; @@ -903,7 +1748,7 @@ const server = http.createServer(async (req, res) => { body = await readBody(req); } catch (e) { const status = e.message === 'Payload too large' ? 413 : 400; - return json(res, status, { message: e.message === 'Payload too large' ? 'Report-Anfrage zu gross' : 'Ungültige Report-Anfrage' }); + return json(res, status, { message: e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request' }); } try { @@ -922,14 +1767,14 @@ const server = http.createServer(async (req, res) => { 'Content-Disposition': `attachment; filename="${result.filename}"`, }, result.pdfPath); } catch (error) { - const message = error && error.message ? error.message : 'PDF-Generierung fehlgeschlagen'; + const message = error && error.message ? error.message : 'PDF generation failed'; const status = error && error.code === 'TYPST_MISSING' ? 503 : 500; return json(res, status, { message }); } } if (apiPath !== null) { - return json(res, 404, { message: 'API-Endpunkt nicht gefunden' }); + return json(res, 404, { message: 'API endpoint not found' }); } // Static file serving @@ -937,7 +1782,7 @@ const server = http.createServer(async (req, res) => { const filePath = path.resolve(STATIC_ROOT, `.${safePath}`); if (!filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && filePath !== path.resolve(STATIC_ROOT, 'index.html')) { - return json(res, 403, { message: 'Zugriff verweigert' }); + return json(res, 403, { message: 'Access denied' }); } serveFile(res, filePath); @@ -946,14 +1791,18 @@ const server = http.createServer(async (req, res) => { function tryListen(port) { return new Promise((resolve, reject) => { if (port > MAX_PORT) { - reject(new Error(`Kein freier Port gefunden (${START_PORT}-${MAX_PORT})`)); + reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`)); return; } const onError = (err) => { server.off('listening', onListening); if (err.code === 'EADDRINUSE') { - console.log(`Port ${port} belegt, versuche ${port + 1}...`); + if (port >= MAX_PORT) { + reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`)); + return; + } + console.log(`Port ${port} is in use, trying ${port + 1}...`); resolve(tryListen(port + 1)); } else { reject(err); @@ -978,6 +1827,12 @@ async function start() { const port = await tryListen(START_PORT); const browserHost = BIND_HOST === '0.0.0.0' ? 'localhost' : BIND_HOST; const url = `http://${browserHost}:${port}`; + runtimePort = port; + runtimeUrl = url; + + if (IS_BACKGROUND_CHILD) { + await registerBackgroundInstance(createBackgroundInstance({ port, url })); + } if (CLI_OPTIONS.autoLoad) { await runStartupAutoLoad({ @@ -989,21 +1844,49 @@ async function start() { openBrowser(url); } -start().catch((error) => { - console.error(error); - process.exit(1); +async function runCli() { + if (CLI_OPTIONS.command === 'stop') { + await runStopCommand(); + return; + } + + if (CLI_OPTIONS.background && !IS_BACKGROUND_CHILD) { + await startInBackground(); + return; + } + + await start(); +} + +runCli().catch((error) => { + Promise.resolve() + .then(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } + }) + .finally(() => { + console.error(error); + process.exit(1); + }); }); // Graceful shutdown on Ctrl+C / kill function shutdown(signal) { - console.log(`\n${signal} empfangen, fahre Server herunter...`); - server.close(() => { - console.log('Server gestoppt.'); + console.log(`\n${signal} received, shutting down server...`); + server.close(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } + console.log('Server stopped.'); process.exit(0); }); // Force exit after 3s if connections don't close - setTimeout(() => { - console.log('Erzwinge Beendigung.'); + setTimeout(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } + console.log('Forcing shutdown.'); process.exit(0); }, 3000); } diff --git a/server/report/index.js b/server/report/index.js index ba1aa5e..f577e49 100644 --- a/server/report/index.js +++ b/server/report/index.js @@ -33,7 +33,7 @@ function compileTypst(workingDir, typPath, pdfPath) { resolve(); return; } - reject(new Error(stderr.trim() || 'Typst-Kompilierung fehlgeschlagen.')); + reject(new Error(stderr.trim() || 'Typst compilation failed.')); }); }); } @@ -288,14 +288,14 @@ function createChartAssets(reportData) { async function generatePdfReport(allDailyData, options = {}) { const typstInstalled = await ensureTypstInstalled(); if (!typstInstalled) { - const error = new Error('Typst CLI nicht gefunden. Unter macOS installieren mit: brew install typst'); + const error = new Error('Typst CLI not found. On macOS install it with: brew install typst'); error.code = 'TYPST_MISSING'; throw error; } const reportData = buildReportData(allDailyData, options); if (!reportData.meta.days || !reportData.meta.periods) { - throw new Error('Keine Daten für den Report vorhanden.'); + throw new Error('No data available for the report.'); } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttdash-report-')); diff --git a/server/report/utils.js b/server/report/utils.js index 7249493..3cae4b3 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -252,6 +252,7 @@ function stdDev(values) { } function computeWeekOverWeekChange(data) { + if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null; if (data.length < 14) return null; const sorted = sortByDate(data); const last7 = sorted.slice(-7); @@ -450,15 +451,21 @@ function computeProviderRows(data) { tokens: 0, requests: 0, days: 0, + _dates: new Set(), }; current.cost += breakdown.cost; current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens; current.requests += breakdown.requestCount; - current.days += entryDays; + if (!current._dates.has(day.date)) { + current._dates.add(day.date); + current.days += entryDays; + } rows.set(provider, current); } } - return Array.from(rows.values()).sort((a, b) => b.cost - a.cost); + return Array.from(rows.values()) + .map(({ _dates, ...entry }) => entry) + .sort((a, b) => b.cost - a.cost); } function getDateRange(data) { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 5085efd..cde1342 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useRef, useState, useCallback, useMemo } from 'react' +import { Fragment, lazy, Suspense, useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useQueryClient } from '@tanstack/react-query' import { SlidersHorizontal } from 'lucide-react' @@ -47,17 +47,55 @@ import { useComputedMetrics } from '@/hooks/use-computed-metrics' import { useToast } from '@/components/ui/toast' import { applyTheme } from '@/lib/app-settings' import { downloadCSV } from '@/lib/csv-export' +import { VERSION } from '@/lib/constants' import { SECTION_HELP } from '@/lib/help-content' -import { generatePdfReport } from '@/lib/api' +import { generatePdfReport, importSettings, importUsageData } from '@/lib/api' import { formatCurrency, formatDateTimeCompact, formatDateTimeFull, formatTokens, formatPercent, periodUnit, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' -import { getUniqueProviders } from '@/lib/model-utils' -import { LimitsModal } from './features/limits/LimitsModal' +import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' +import { SettingsModal } from './features/settings/SettingsModal' import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' -import type { AppLanguage } from '@/types' +import type { AppLanguage, DashboardDefaultFilters, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ProviderLimits } from '@/types' const DrillDownModal = lazy(() => import('./features/drill-down/DrillDownModal').then(module => ({ default: module.DrillDownModal }))) const AutoImportModal = lazy(() => import('./features/auto-import/AutoImportModal').then(module => ({ default: module.AutoImportModal }))) +const SETTINGS_BACKUP_KIND = 'ttdash-settings-backup' +const USAGE_BACKUP_KIND = 'ttdash-usage-backup' +const BACKUP_FORMAT_VERSION = 1 + +type JsonDownloadRecord = { + filename: string + mimeType: string + size: number + text: string +} + +type DashboardTestHooks = { + onJsonDownload?: (record: JsonDownloadRecord) => void + openSettings?: () => void +} + +function downloadJsonFile(filename: string, data: unknown) { + const text = JSON.stringify(data, null, 2) + const blob = new Blob([text], { type: 'application/json' }) + const globalWindow = window as Window & { + __TTDASH_TEST_HOOKS__?: DashboardTestHooks + } + globalWindow.__TTDASH_TEST_HOOKS__?.onJsonDownload?.({ + filename, + mimeType: blob.type, + size: blob.size, + text, + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) +} export function Dashboard() { const { t, i18n } = useTranslation() @@ -67,23 +105,29 @@ export function Dashboard() { const queryClient = useQueryClient() const { addToast } = useToast() const fileInputRef = useRef(null) + const settingsImportInputRef = useRef(null) + const dataImportInputRef = useRef(null) const [drillDownDate, setDrillDownDate] = useState(null) const [helpOpen, setHelpOpen] = useState(false) const [autoImportOpen, setAutoImportOpen] = useState(false) - const [limitsOpen, setLimitsOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const [reportGenerating, setReportGenerating] = useState(false) + const [settingsTransferBusy, setSettingsTransferBusy] = useState(false) + const [dataTransferBusy, setDataTransferBusy] = useState(false) const [dataSource, setDataSource] = useState<{ type: 'stored' | 'auto-import' | 'file'; label?: string; time?: string; title?: string } | null>(null) const [animationSeed, setAnimationSeed] = useState(0) const daily = usageData?.daily ?? [] const hasData = daily.length > 0 const allProviders = useMemo(() => getUniqueProviders(daily.map(d => d.modelsUsed)), [daily]) + const allModelsFromData = useMemo(() => getUniqueModels(daily.map(d => d.modelsUsed)), [daily]) const { settings, providerLimits, setTheme, setLanguage, - setProviderLimits, + saveSettings, + isSaving, } = useAppSettings(allProviders) const isDark = settings.theme === 'dark' @@ -97,6 +141,26 @@ export function Dashboard() { } }, [i18n, settings.language]) + useEffect(() => { + const globalWindow = window as Window & { + __TTDASH_TEST_HOOKS__?: DashboardTestHooks + } + + if (!globalWindow.__TTDASH_TEST_HOOKS__) { + return undefined + } + + globalWindow.__TTDASH_TEST_HOOKS__.openSettings = () => { + setSettingsOpen(true) + } + + return () => { + if (globalWindow.__TTDASH_TEST_HOOKS__?.openSettings) { + delete globalWindow.__TTDASH_TEST_HOOKS__.openSettings + } + } + }, []) + const persistedLoadedTime = useMemo( () => settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined, [settings.lastLoadedAt, i18n.resolvedLanguage], @@ -135,6 +199,7 @@ export function Dashboard() { startDate, setStartDate, endDate, setEndDate, resetAll, + applyDefaultFilters, applyPreset, filteredDailyData, filteredData, @@ -142,7 +207,7 @@ export function Dashboard() { availableProviders, availableModels, dateRange, - } = useDashboardFilters(daily) + } = useDashboardFilters(daily, settings.defaultFilters) const { metrics, modelCosts, providerMetrics, costChartData, modelCostChartData, @@ -166,6 +231,16 @@ export function Dashboard() { const visibleLimitProviders = useMemo(() => ( selectedProviders.length > 0 ? selectedProviders : allProviders ), [selectedProviders, allProviders]) + const settingsProviderOptions = useMemo( + () => [...new Set([...allProviders, ...settings.defaultFilters.providers])].sort((left, right) => left.localeCompare(right)), + [allProviders, settings.defaultFilters.providers], + ) + const settingsModelOptions = useMemo( + () => [...new Set([...allModelsFromData, ...settings.defaultFilters.models])].sort((left, right) => left.localeCompare(right)), + [allModelsFromData, settings.defaultFilters.models], + ) + const sectionVisibility = settings.sectionVisibility + const sectionOrder = settings.sectionOrder // Compute active streak (consecutive days from today backwards) const streak = useMemo(() => { @@ -188,10 +263,25 @@ export function Dashboard() { fileInputRef.current?.click() }, []) + const handleOpenSettings = useCallback(() => { + setSettingsOpen(true) + }, []) + 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) @@ -267,7 +357,7 @@ export function Dashboard() { } finally { setReportGenerating(false) } - }, [reportGenerating, viewMode, selectedMonth, selectedProviders, selectedModels, startDate, endDate, addToast]) + }, [reportGenerating, viewMode, selectedMonth, selectedProviders, selectedModels, startDate, endDate, addToast, i18n.language, t]) const handleAutoImport = useCallback(() => { setAutoImportOpen(true) @@ -287,11 +377,343 @@ export function Dashboard() { addToast(t('toasts.dataImported'), 'success') }, [queryClient, addToast, t]) + const handleExportSettings = useCallback(() => { + downloadJsonFile(`ttdash-settings-backup-${localToday()}.json`, { + kind: SETTINGS_BACKUP_KIND, + version: BACKUP_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + appVersion: VERSION, + settings: { + language: settings.language, + theme: settings.theme, + providerLimits: settings.providerLimits, + 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 = 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 = JSON.parse(await file.text()) + const summary = await importUsageData(parsed) + await queryClient.invalidateQueries({ queryKey: ['usage'] }) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed(prev => prev + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) + setDataSource({ + type: 'file', + label: file.name, + time, + title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, + }) + + const toastType: 'info' | 'success' = summary.conflictingDays > 0 ? 'info' : 'success' + const toastKey = summary.conflictingDays > 0 ? 'toasts.dataBackupImportedWithConflicts' : 'toasts.dataBackupImported' + addToast(t(toastKey, { + added: summary.addedDays, + unchanged: summary.unchangedDays, + conflicts: summary.conflictingDays, + }), toastType) + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setDataTransferBusy(false) + e.target.value = '' + } + }, [queryClient, addToast, t]) + const handleScrollTo = useCallback((section: string) => { const el = document.getElementById(section) el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, []) + const renderSection = useCallback((sectionId: DashboardSectionId) => { + switch (sectionId) { + case 'insights': + return sectionVisibility.insights ? ( +
+ +
+ ) : null + case 'metrics': + return sectionVisibility.metrics ? ( +
+ + + + + +
+ d.totalCost)} viewMode={viewMode} /> +
+
+
+ ) : null + case 'today': + return sectionVisibility.today && todayData ? ( +
+ +
+ ) : null + case 'currentMonth': + return sectionVisibility.currentMonth && hasCurrentMonthData ? ( +
+ +
+ ) : null + case 'activity': + return sectionVisibility.activity ? ( +
+ + +
+ + + +
+
+
+ ) : null + case 'forecastCache': + return sectionVisibility.forecastCache ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'limits': + return sectionVisibility.limits ? ( +
+ + + +
+ ) : null + case 'costAnalysis': + return sectionVisibility.costAnalysis ? ( +
+ + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ ) : null + case 'tokenAnalysis': + return sectionVisibility.tokenAnalysis ? ( +
+ + +
+ + +
+
+
+ ) : null + case 'requestAnalysis': + return sectionVisibility.requestAnalysis && metrics.hasRequestData ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + case 'advancedAnalysis': + return sectionVisibility.advancedAnalysis ? ( +
+ + +
+ + +
+
+ +
+ +
+
+
+ ) : null + case 'comparisons': + return sectionVisibility.comparisons ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'tables': + return sectionVisibility.tables ? ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+ ) : null + default: + return null + } + }, [ + allModels, + comparisonData, + costChartData, + filteredDailyData, + filteredData, + hasCurrentMonthData, + metrics, + modelCostChartData, + modelCosts, + modelPieData, + providerLimits, + providerMetrics, + requestChartData, + sectionVisibility, + selectedMonth, + t, + todayData, + tokenChartData, + tokenPieData, + totalCalendarDays, + viewMode, + visibleLimitProviders, + weekdayData, + ]) + if (isLoading) { return } @@ -299,18 +721,44 @@ export function Dashboard() { if (!hasData) { return ( <> - - + + + + {autoImportOpen && } + ) } return (
- + + +
setLimitsOpen(true)} - title="Provider Limits" + onClick={handleOpenSettings} + title={t('header.settings')} className="h-11 flex-col gap-1 px-0 text-[10px] sm:h-9 sm:flex-row sm:gap-2 sm:px-3 sm:text-sm" > - {t('header.limits')} + {t('header.settings')} )} pdfButton={( @@ -371,199 +819,12 @@ export function Dashboard() { />
-
-
- -
- - {/* Primary Metrics */} -
- - - - - -
- d.totalCost)} viewMode={viewMode} /> -
-
-
- - {/* Today's KPIs */} - {todayData && ( -
- -
- )} - - {/* Current Month KPIs */} - {hasCurrentMonthData && ( -
- -
- )} - - {/* Heatmap Calendar */} -
- - -
- - - -
-
-
- - {/* Cost Forecast + Cache ROI */} -
- - -
- - - - - - -
-
-
- - - - - - {/* Charts */} -
- - -
-
- -
- -
-
- - -
- -
-
- - -
- - -
-
- - -
- - -
-
-
- - {/* Token Analysis */} -
- - -
- - -
-
-
- - {metrics.hasRequestData && ( -
- - - - - -
- -
-
- -
- -
-
-
- )} - -
- - -
- - -
-
- -
- -
-
-
- - {/* Period Comparison + Anomaly Detection */} -
- - -
- - - - - - -
-
-
- - {/* Tables */} -
- - - - - -
- -
-
- -
- -
-
-
+
+ {sectionOrder.map((sectionId) => ( + + {renderSection(sectionId)} + + ))}
{/* Drill-Down Modal */} @@ -581,13 +842,15 @@ export function Dashboard() { {/* Command Palette */} setLimitsOpen(true)} + onOpenSettings={handleOpenSettings} onScrollTo={handleScrollTo} onViewModeChange={setViewMode} onApplyPreset={applyPreset} @@ -616,15 +879,27 @@ export function Dashboard() { {autoImportOpen && } -
) diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index 22384e1..52225a8 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,4 +1,4 @@ -import { Upload, ChartBar, Zap } from 'lucide-react' +import { Upload, ChartBar, Zap, SlidersHorizontal } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' @@ -8,9 +8,10 @@ import { VERSION } from '@/lib/constants' interface EmptyStateProps { onUpload: () => void onAutoImport: () => void + onOpenSettings: () => void } -export function EmptyState({ onUpload, onAutoImport }: EmptyStateProps) { +export function EmptyState({ onUpload, onAutoImport, onOpenSettings }: EmptyStateProps) { const { t } = useTranslation() return ( @@ -38,6 +39,10 @@ export function EmptyState({ onUpload, onAutoImport }: EmptyStateProps) { {t('emptyState.uploadFile')} + diff --git a/src/components/features/command-palette/CommandPalette.tsx b/src/components/features/command-palette/CommandPalette.tsx index b45458b..f8e2c3a 100644 --- a/src/components/features/command-palette/CommandPalette.tsx +++ b/src/components/features/command-palette/CommandPalette.tsx @@ -7,17 +7,20 @@ import { Table, Search, ArrowUp, CircleHelp, Zap, Filter, BarChart3, LineChart, Sigma, CalendarRange, Layers3, ArrowDown, RefreshCcw, SlidersHorizontal, Languages } from 'lucide-react' -import type { AppLanguage, ViewMode } from '@/types' +import { DASHBOARD_SECTION_DEFINITION_MAP } from '@/lib/dashboard-preferences' +import type { AppLanguage, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ViewMode } from '@/types' interface CommandPaletteProps { isDark: boolean - currentLanguage: AppLanguage availableProviders: string[] selectedProviders: string[] availableModels: string[] selectedModels: string[] hasTodaySection: boolean hasMonthSection: boolean + hasRequestSection: boolean + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder reportGenerating: boolean onToggleTheme: () => void onExportCSV: () => void @@ -25,7 +28,7 @@ interface CommandPaletteProps { onDelete: () => void onUpload: () => void onAutoImport: () => void - onOpenLimits: () => void + onOpenSettings: () => void onScrollTo: (section: string) => void onViewModeChange: (mode: ViewMode) => void onApplyPreset: (preset: string) => void @@ -49,6 +52,45 @@ interface CommandItem { icon: React.ReactNode action: () => void group: string + testId?: string +} + +const SECTION_COMMAND_ICON_MAP: Record = { + insights: , + metrics: , + today: , + currentMonth: , + activity: , + forecastCache: , + limits: , + costAnalysis: , + tokenAnalysis: , + requestAnalysis: , + advancedAnalysis: , + comparisons: , + tables: , +} + +const SECTION_COMMAND_KEYWORDS: Record = { + insights: ['summary', 'insight'], + metrics: ['kpi', 'zahlen'], + today: ['today', 'heute'], + currentMonth: ['monat', 'current month'], + activity: ['heatmap', 'aktivität'], + forecastCache: ['forecast', 'cache', 'roi'], + limits: ['limits', 'subscriptions', 'budget', 'anbieter limits'], + costAnalysis: ['charts', 'kostenanalyse'], + tokenAnalysis: ['tokens', 'token analyse'], + requestAnalysis: ['requests', 'request analyse', 'anfragen'], + advancedAnalysis: ['advanced analysis', 'distributions', 'risk', 'verteilungen'], + comparisons: ['anomalie', 'vergleich'], + tables: ['table', 'details'], +} + +const SECTION_COMMAND_ALIASES: Partial> = { + limits: ['limits sektion', 'subscriptions sektion'], + tokenAnalysis: ['token chart'], + requestAnalysis: ['request chart', 'request donut'], } function normalizeSearchValue(value: string) { @@ -117,13 +159,15 @@ function getCommandSearchScore(cmd: CommandItem, query: string) { export function CommandPalette({ isDark, - currentLanguage, availableProviders, selectedProviders, availableModels, selectedModels, hasTodaySection, hasMonthSection, + hasRequestSection, + sectionVisibility, + sectionOrder, reportGenerating, onToggleTheme, onExportCSV, @@ -131,7 +175,7 @@ export function CommandPalette({ onDelete, onUpload, onAutoImport, - onOpenLimits, + onOpenSettings, onScrollTo, onViewModeChange, onApplyPreset, @@ -148,6 +192,22 @@ export function CommandPalette({ const [open, setOpen] = useState(false) const [search, setSearch] = useState('') + const sectionAvailability = useMemo>(() => ({ + insights: true, + metrics: true, + today: hasTodaySection, + currentMonth: hasMonthSection, + activity: true, + forecastCache: true, + limits: true, + costAnalysis: true, + tokenAnalysis: true, + requestAnalysis: hasRequestSection, + advancedAnalysis: true, + comparisons: true, + tables: true, + }), [hasMonthSection, hasRequestSection, hasTodaySection]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { @@ -159,9 +219,33 @@ export function CommandPalette({ return () => document.removeEventListener('keydown', handleKeyDown) }, []) - const baseCommands: CommandItem[] = [ + const sectionNavigationCommands = useMemo(() => ( + sectionOrder.flatMap((sectionId) => { + const section = DASHBOARD_SECTION_DEFINITION_MAP[sectionId] + + if (!sectionVisibility[sectionId] || !sectionAvailability[sectionId]) { + return [] + } + + const sectionLabel = t(section.labelKey) + + return [{ + id: `section-${section.id}`, + label: t('commandPalette.commands.goToSection.label', { section: sectionLabel }), + description: t('commandPalette.commands.goToSection.description', { section: sectionLabel }), + keywords: [sectionLabel, section.domId, ...SECTION_COMMAND_KEYWORDS[section.id]], + aliases: SECTION_COMMAND_ALIASES[section.id], + icon: SECTION_COMMAND_ICON_MAP[section.id], + action: () => onScrollTo(section.domId), + group: t('commandPalette.groups.navigation'), + testId: `command-section-${section.id}`, + }] + }) + ), [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t]) + + const baseCommands = useMemo(() => [ { id: 'auto-import', label: t('commandPalette.commands.autoImport.label'), description: t('commandPalette.commands.autoImport.description'), keywords: ['toktrack', 'import', 'load', 'sync'], aliases: ['auto import', 'daten importieren'], icon: , action: onAutoImport, group: t('commandPalette.groups.actions') }, - { id: 'limits-open', label: t('commandPalette.commands.openLimits.label'), description: t('commandPalette.commands.openLimits.description'), keywords: ['limits', 'subscription', 'anbieter limit', 'budget'], aliases: ['limits dialog', 'subscriptions öffnen', 'provider limits'], icon: , action: onOpenLimits, group: t('commandPalette.groups.actions') }, + { id: 'settings-open', label: t('commandPalette.commands.openSettings.label'), description: t('commandPalette.commands.openSettings.description'), keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], icon: , action: onOpenSettings, group: t('commandPalette.groups.actions') }, { id: 'csv', label: t('commandPalette.commands.exportCsv.label'), description: t('commandPalette.commands.exportCsv.description'), keywords: ['download', 'export', 'csv'], aliases: ['csv download', 'daten exportieren'], shortcut: '⌘E', icon: , action: onExportCSV, group: t('commandPalette.groups.actions') }, { id: 'report', label: reportGenerating ? t('commandPalette.commands.generateReport.labelLoading') : t('commandPalette.commands.generateReport.label'), description: t('commandPalette.commands.generateReport.description'), keywords: ['pdf', 'report', 'bericht', 'export'], aliases: ['report export', 'pdf export', 'bericht generieren'], icon: , action: onGenerateReport, group: t('commandPalette.groups.actions') }, { id: 'upload', label: t('commandPalette.commands.upload.label'), description: t('commandPalette.commands.upload.description'), keywords: ['upload', 'file', 'json', 'import'], aliases: ['datei laden', 'json import'], shortcut: '⌘U', icon: , action: onUpload, group: t('commandPalette.groups.actions') }, @@ -183,24 +267,34 @@ export function CommandPalette({ { id: 'top', label: t('commandPalette.commands.scrollTop.label'), description: t('commandPalette.commands.scrollTop.description'), keywords: ['top', 'start', 'anfang'], shortcut: '⌘↑', icon: , action: () => window.scrollTo({ top: 0, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, { id: 'bottom', label: t('commandPalette.commands.scrollBottom.label'), description: t('commandPalette.commands.scrollBottom.description'), keywords: ['bottom', 'ende'], icon: , action: () => window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }), group: t('commandPalette.groups.navigation') }, { id: 'filters', label: t('commandPalette.commands.filters.label'), description: t('commandPalette.commands.filters.description'), keywords: ['filterbar', 'filter'], icon: , action: () => onScrollTo('filters'), group: t('commandPalette.groups.navigation') }, - { id: 'insights', label: t('commandPalette.commands.insights.label'), description: t('commandPalette.commands.insights.description'), keywords: ['summary', 'insight'], icon: , action: () => onScrollTo('insights'), group: t('commandPalette.groups.navigation') }, - { id: 'metrics', label: t('commandPalette.commands.metrics.label'), description: t('commandPalette.commands.metrics.description'), keywords: ['kpi', 'zahlen'], icon: , action: () => onScrollTo('metrics'), group: t('commandPalette.groups.navigation') }, - ...(hasTodaySection ? [{ id: 'today', label: t('commandPalette.commands.today.label'), description: t('commandPalette.commands.today.description'), keywords: ['today', 'heute'], icon: , action: () => onScrollTo('today'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - ...(hasMonthSection ? [{ id: 'month', label: t('commandPalette.commands.month.label'), description: t('commandPalette.commands.month.description'), keywords: ['monat', 'current month'], icon: , action: () => onScrollTo('current-month'), group: t('commandPalette.groups.navigation') } satisfies CommandItem] : []), - { id: 'activity', label: t('commandPalette.commands.activity.label'), description: t('commandPalette.commands.activity.description'), keywords: ['heatmap', 'aktivität'], icon: , action: () => onScrollTo('activity'), group: t('commandPalette.groups.navigation') }, - { id: 'forecast-cache', label: t('commandPalette.commands.forecastCache.label'), description: t('commandPalette.commands.forecastCache.description'), keywords: ['forecast', 'cache', 'roi'], icon: , action: () => onScrollTo('forecast-cache'), group: t('commandPalette.groups.navigation') }, - { id: 'limits', label: t('commandPalette.commands.limits.label'), description: t('commandPalette.commands.limits.description'), keywords: ['limits', 'subscriptions', 'budget', 'anbieter limits'], aliases: ['limits sektion', 'subscriptions sektion'], icon: , action: () => onScrollTo('limits'), group: t('commandPalette.groups.navigation') }, - { id: 'charts', label: t('commandPalette.commands.charts.label'), description: t('commandPalette.commands.charts.description'), keywords: ['charts', 'kostenanalyse'], icon: , action: () => onScrollTo('charts'), group: t('commandPalette.groups.navigation') }, - { id: 'token-analysis', label: t('commandPalette.commands.tokenAnalysis.label'), description: t('commandPalette.commands.tokenAnalysis.description'), keywords: ['tokens', 'token analyse'], aliases: ['token chart'], icon: , action: () => onScrollTo('token-analysis'), group: t('commandPalette.groups.navigation') }, - { id: 'request-analysis', label: t('commandPalette.commands.requestAnalysis.label'), description: t('commandPalette.commands.requestAnalysis.description'), keywords: ['requests', 'request analyse', 'anfragen'], aliases: ['request chart', 'request donut'], icon: , action: () => onScrollTo('request-analysis'), group: t('commandPalette.groups.navigation') }, - { id: 'comparisons', label: t('commandPalette.commands.comparisons.label'), description: t('commandPalette.commands.comparisons.description'), keywords: ['anomalie', 'vergleich'], icon: , action: () => onScrollTo('comparisons'), group: t('commandPalette.groups.navigation') }, - { id: 'tables', label: t('commandPalette.commands.tables.label'), description: t('commandPalette.commands.tables.description'), keywords: ['table', 'details'], icon:
, action: () => onScrollTo('tables'), group: t('commandPalette.groups.navigation') }, + ...sectionNavigationCommands, { id: 'theme', label: isDark ? t('commandPalette.commands.themeLight.label') : t('commandPalette.commands.themeDark.label'), description: t('commandPalette.commands.themeDark.description'), keywords: ['theme', 'dark', 'light'], shortcut: '⌘D', icon: isDark ? : , action: onToggleTheme, group: t('commandPalette.groups.view') }, { id: 'language-de', label: t('commandPalette.commands.languageGerman.label'), description: t('commandPalette.commands.languageGerman.description'), keywords: ['language', 'sprache', 'deutsch', 'german', 'locale'], aliases: ['switch german', 'auf deutsch', 'sprache deutsch'], icon: , action: () => onLanguageChange('de'), group: t('commandPalette.groups.language') }, { id: 'language-en', label: t('commandPalette.commands.languageEnglish.label'), description: t('commandPalette.commands.languageEnglish.description'), keywords: ['language', 'sprache', 'english', 'englisch', 'locale'], aliases: ['switch english', 'auf englisch', 'sprache english'], icon: , action: () => onLanguageChange('en'), group: t('commandPalette.groups.language') }, { id: 'help', label: t('commandPalette.commands.help.label'), description: t('commandPalette.commands.help.description'), keywords: ['shortcut', 'hilfe'], shortcut: '?', icon: , action: onHelp, group: t('commandPalette.groups.help') }, - ] + ], [ + isDark, + onAutoImport, + onOpenSettings, + onExportCSV, + onGenerateReport, + onUpload, + onDelete, + onViewModeChange, + onApplyPreset, + onClearProviders, + onClearModels, + onClearDateRange, + onResetAll, + onScrollTo, + sectionNavigationCommands, + reportGenerating, + onToggleTheme, + onLanguageChange, + onHelp, + t, + ]) const providerCommands = useMemo(() => ( availableProviders.map(provider => { @@ -238,7 +332,7 @@ export function CommandPalette({ ...baseCommands, ...providerCommands, ...modelCommands, - ], [baseCommands, providerCommands, modelCommands, currentLanguage]) + ], [baseCommands, providerCommands, modelCommands]) const filteredCommands = useMemo(() => ( commands @@ -312,6 +406,7 @@ export function CommandPalette({ runCommand(cmd)} className="flex items-center gap-2 rounded-md px-2 py-2 text-sm cursor-pointer aria-selected:bg-accent" > diff --git a/src/components/features/insights/UsageInsights.tsx b/src/components/features/insights/UsageInsights.tsx index d7c3f57..2a4ebc2 100644 --- a/src/components/features/insights/UsageInsights.tsx +++ b/src/components/features/insights/UsageInsights.tsx @@ -118,7 +118,7 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns ? t('insights.usagePatterns.summaryWithCoverage', { activeDays: metrics.activeDays, totalDays: totalCalendarDays, volatility: formatNumber(Math.round(metrics.requestVolatility)) }) : t('insights.usagePatterns.summaryWithoutCoverage', { activeDays: metrics.activeDays, unit: usageUnit })} details={[ - { label: t('insights.usagePatterns.avgModels'), value: metrics.avgModelsPerDay.toFixed(1) }, + { label: t('insights.usagePatterns.avgModels'), value: metrics.avgModelsPerEntry.toFixed(1) }, { label: t('insights.usagePatterns.providersActive'), value: formatNumber(metrics.providerCount) }, { label: t('insights.usagePatterns.weekendShare'), value: metrics.weekendCostShare !== null ? formatPercent(metrics.weekendCostShare, 0) : '–' }, { label: t('insights.usagePatterns.thinkingShare'), value: metrics.totalTokens > 0 ? formatPercent((metrics.totalThinking / metrics.totalTokens) * 100, 1) : '–' }, diff --git a/src/components/features/limits/LimitsModal.tsx b/src/components/features/limits/LimitsModal.tsx deleted file mode 100644 index 1fd884b..0000000 --- a/src/components/features/limits/LimitsModal.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { InfoButton } from '@/components/features/help/InfoButton' -import { FEATURE_HELP } from '@/lib/help-content' -import { formatDateTimeFull } from '@/lib/formatters' -import { getProviderBadgeClasses } from '@/lib/model-utils' -import { syncProviderLimits } from '@/lib/provider-limits' -import { cn } from '@/lib/cn' -import type { DataLoadSource, ProviderLimits } from '@/types' - -interface LimitsModalProps { - open: boolean - onOpenChange: (open: boolean) => void - providers: string[] - limits: ProviderLimits - lastLoadedAt?: string | null - lastLoadSource?: DataLoadSource - cliAutoLoadActive?: boolean - onSave: (limits: ProviderLimits) => void -} - -function parseNumberInput(value: string): number { - const normalized = value.replace(',', '.').trim() - if (!normalized) return 0 - const parsed = Number.parseFloat(normalized) - if (!Number.isFinite(parsed)) return 0 - return Math.max(0, Number(parsed.toFixed(2))) -} - -export function LimitsModal({ open, onOpenChange, providers, limits, lastLoadedAt, lastLoadSource, cliAutoLoadActive = false, onSave }: LimitsModalProps) { - const { t } = useTranslation() - const [draft, setDraft] = useState(() => syncProviderLimits(providers, limits)) - - useEffect(() => { - if (!open) return - setDraft(syncProviderLimits(providers, limits)) - }, [open, providers, limits]) - - const updateProvider = (provider: string, patch: Partial) => { - setDraft(prev => ({ - ...prev, - [provider]: { - ...prev[provider], - ...patch, - }, - })) - } - - const handleSave = () => { - onSave(syncProviderLimits(providers, draft)) - onOpenChange(false) - } - - const loadSourceLabel = lastLoadSource - ? t(`limits.modal.sources.${lastLoadSource}`) - : t('limits.modal.sources.unknown') - - return ( - - - - - {t('limits.modal.title')} - - - - {t('limits.modal.description')} - - - -
-
- {t('limits.modal.dataStatus')} -
-
-
-
{t('limits.modal.lastLoaded')}
-
- {lastLoadedAt ? formatDateTimeFull(lastLoadedAt) : t('common.notAvailable')} -
-
-
-
{t('limits.modal.loadedVia')}
-
{loadSourceLabel}
-
-
-
{t('limits.modal.cliAutoLoad')}
-
- {cliAutoLoadActive ? t('common.enabled') : t('common.disabled')} -
-
-
-
- - {providers.length === 0 ? ( -
- {t('limits.modal.noProviders')} -
- ) : ( -
- {providers.map((provider) => { - const config = draft[provider] - - return ( -
-
-
-
- - {provider} - - -
-
- -
- - - -
-
-
- ) - })} -
- )} - -
- -
- - -
-
-
-
- ) -} diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx new file mode 100644 index 0000000..e773fd1 --- /dev/null +++ b/src/components/features/settings/SettingsModal.tsx @@ -0,0 +1,664 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { InfoButton } from '@/components/features/help/InfoButton' +import { FEATURE_HELP } from '@/lib/help-content' +import { formatDateTimeFull } from '@/lib/formatters' +import { getProviderBadgeClasses } from '@/lib/model-utils' +import { syncProviderLimits } from '@/lib/provider-limits' +import { + DASHBOARD_SECTION_DEFINITION_MAP, + DASHBOARD_DATE_PRESETS, + DASHBOARD_VIEW_MODES, + DEFAULT_DASHBOARD_FILTERS, + getDefaultDashboardSectionOrder, + getDefaultDashboardSectionVisibility, +} from '@/lib/dashboard-preferences' +import { cn } from '@/lib/cn' +import { ArrowDown, ArrowUp, Database, Download, Eye, Filter, GripVertical, LayoutPanelTop, Settings2, Upload } from 'lucide-react' +import type { + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + DataLoadSource, + ProviderLimits, + ViewMode, +} from '@/types' + +interface SettingsModalProps { + open: boolean + onOpenChange: (open: boolean) => void + limitProviders: string[] + filterProviders: string[] + models: string[] + limits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder + lastLoadedAt?: string | null + lastLoadSource?: DataLoadSource + cliAutoLoadActive?: boolean + hasData: boolean + onSaveSettings: (settings: { + providerLimits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder + }) => Promise | unknown + onExportSettings: () => void + onImportSettings: () => void + onExportData: () => void + onImportData: () => void + settingsBusy?: boolean + dataBusy?: boolean +} + +function parseNumberInput(value: string): number { + const normalized = value.replace(',', '.').trim() + if (!normalized) return 0 + const parsed = Number.parseFloat(normalized) + if (!Number.isFinite(parsed)) return 0 + return Math.max(0, Number(parsed.toFixed(2))) +} + +function toggleSelection(values: string[], value: string) { + return values.includes(value) + ? values.filter(entry => entry !== value) + : [...values, value] +} + +function normalizeSelection(values: string[]) { + return [...new Set(values.map(value => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right)) +} + +function moveSection(order: DashboardSectionOrder, sectionId: DashboardSectionOrder[number], direction: -1 | 1) { + const currentIndex = order.indexOf(sectionId) + const targetIndex = currentIndex + direction + + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= order.length) { + return order + } + + const next = [...order] + const [moved] = next.splice(currentIndex, 1) + next.splice(targetIndex, 0, moved) + return next +} + +function reorderSections(order: DashboardSectionOrder, sourceId: DashboardSectionOrder[number], targetId: DashboardSectionOrder[number]) { + if (sourceId === targetId) return order + + const sourceIndex = order.indexOf(sourceId) + const targetIndex = order.indexOf(targetId) + + if (sourceIndex < 0 || targetIndex < 0) { + return order + } + + const next = [...order] + const [moved] = next.splice(sourceIndex, 1) + next.splice(targetIndex, 0, moved) + return next +} + +export function SettingsModal({ + open, + onOpenChange, + limitProviders, + filterProviders, + models, + limits, + defaultFilters, + sectionVisibility, + sectionOrder, + lastLoadedAt, + lastLoadSource, + cliAutoLoadActive = false, + hasData, + onSaveSettings, + onExportSettings, + onImportSettings, + onExportData, + onImportData, + settingsBusy = false, + dataBusy = false, +}: SettingsModalProps) { + const { t } = useTranslation() + const [limitDraft, setLimitDraft] = useState(() => syncProviderLimits(limitProviders, limits)) + const [defaultFilterDraft, setDefaultFilterDraft] = useState(defaultFilters) + const [sectionVisibilityDraft, setSectionVisibilityDraft] = useState(sectionVisibility) + const [sectionOrderDraft, setSectionOrderDraft] = useState(sectionOrder) + const [draggedSectionId, setDraggedSectionId] = useState(null) + const [dragOverSectionId, setDragOverSectionId] = useState(null) + + useEffect(() => { + if (!open) return + + setLimitDraft(syncProviderLimits(limitProviders, limits)) + setDefaultFilterDraft(defaultFilters) + setSectionVisibilityDraft(sectionVisibility) + setSectionOrderDraft(sectionOrder) + setDraggedSectionId(null) + setDragOverSectionId(null) + }, [open, limitProviders, limits, defaultFilters, sectionVisibility, sectionOrder]) + + const providerOptions = useMemo( + () => normalizeSelection([...filterProviders, ...defaultFilterDraft.providers]), + [filterProviders, defaultFilterDraft.providers], + ) + const modelOptions = useMemo( + () => normalizeSelection([...models, ...defaultFilterDraft.models]), + [models, defaultFilterDraft.models], + ) + + const updateProvider = (provider: string, patch: Partial) => { + setLimitDraft(prev => ({ + ...prev, + [provider]: { + ...prev[provider], + ...patch, + }, + })) + } + + const handleSave = async () => { + const nextProviderLimits = { ...limits } + for (const provider of limitProviders) { + nextProviderLimits[provider] = limitDraft[provider] + } + + await onSaveSettings({ + providerLimits: nextProviderLimits, + defaultFilters: { + ...defaultFilterDraft, + providers: normalizeSelection(defaultFilterDraft.providers), + models: normalizeSelection(defaultFilterDraft.models), + }, + sectionVisibility: sectionVisibilityDraft, + sectionOrder: sectionOrderDraft, + }) + onOpenChange(false) + } + + const handleResetDrafts = () => { + setLimitDraft(syncProviderLimits(limitProviders, {})) + setDefaultFilterDraft(DEFAULT_DASHBOARD_FILTERS) + setSectionVisibilityDraft(getDefaultDashboardSectionVisibility()) + setSectionOrderDraft(getDefaultDashboardSectionOrder()) + setDraggedSectionId(null) + setDragOverSectionId(null) + } + + const handleResetDefaultFilters = () => { + setDefaultFilterDraft(DEFAULT_DASHBOARD_FILTERS) + } + + const handleResetSectionVisibility = () => { + setSectionVisibilityDraft(getDefaultDashboardSectionVisibility()) + setSectionOrderDraft(getDefaultDashboardSectionOrder()) + } + + const handleResetProviderLimits = () => { + setLimitDraft(syncProviderLimits(limitProviders, {})) + } + + const loadSourceLabel = lastLoadSource + ? t(`settings.modal.sources.${lastLoadSource}`) + : t('settings.modal.sources.unknown') + const orderedSections = useMemo( + () => sectionOrderDraft.map((sectionId) => DASHBOARD_SECTION_DEFINITION_MAP[sectionId]), + [sectionOrderDraft], + ) + + return ( + + + + + {t('settings.modal.title')} + + + + {t('settings.modal.description')} + + + +
+
+ {t('settings.modal.dataStatus')} +
+
+
+
{t('settings.modal.lastLoaded')}
+
+ {lastLoadedAt ? formatDateTimeFull(lastLoadedAt) : t('common.notAvailable')} +
+
+
+
{t('settings.modal.loadedVia')}
+
{loadSourceLabel}
+
+
+
{t('settings.modal.cliAutoLoad')}
+
+ {cliAutoLoadActive ? t('common.enabled') : t('common.disabled')} +
+
+
+
+ +
+
+
+
+ + + +
+
{t('settings.modal.defaultFiltersTitle')}
+

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

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

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

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

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

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

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

+
+
+

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

+

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

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

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

+
+
+ +
+ +
+ {limitProviders.length === 0 ? ( +
+ {t('settings.modal.noProviders')} +
+ ) : ( +
+ {limitProviders.map((provider) => { + const config = limitDraft[provider] + + return ( +
+
+
+
+ + {provider} + + +
+
+ +
+ + + +
+
+
+ ) + })} +
+ )} +
+
+ +
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index 395295e..7a82c3c 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -7,7 +7,7 @@ import { getModelColor, getProviderBadgeClasses, getProviderBadgeStyle } from '@ import { formatDate, formatMonthYear, localToday, toLocalDateStr } from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react' -import type { ViewMode } from '@/types' +import type { DashboardDatePreset, ViewMode } from '@/types' interface FilterBarProps { viewMode: ViewMode @@ -53,6 +53,50 @@ function buildCalendarDays(displayMonth: Date) { return cells } +function resolveActivePreset(selectedMonth: string | null, startDate?: string, endDate?: string): DashboardDatePreset | null { + if (selectedMonth) return null + if (!startDate && !endDate) return 'all' + if (!startDate || !endDate) return null + + const today = new Date() + today.setHours(0, 0, 0, 0) + const fmt = toLocalDateStr + + const matchesPreset = (preset: DashboardDatePreset) => { + switch (preset) { + case '7d': { + const start = new Date(today) + start.setDate(today.getDate() - 6) + return startDate === fmt(start) && endDate === fmt(today) + } + case '30d': { + const start = new Date(today) + start.setDate(today.getDate() - 29) + return startDate === fmt(start) && endDate === fmt(today) + } + case 'month': { + const start = new Date(today.getFullYear(), today.getMonth(), 1) + return startDate === fmt(start) && endDate === fmt(today) + } + case 'year': { + const start = new Date(today.getFullYear(), 0, 1) + return startDate === fmt(start) && endDate === fmt(today) + } + case 'all': + default: + return false + } + } + + for (const preset of ['7d', '30d', 'month', 'year'] as DashboardDatePreset[]) { + if (matchesPreset(preset)) { + return preset + } + } + + return null +} + interface DatePickerFieldProps { label: string value?: string @@ -270,10 +314,10 @@ export function FilterBar({ onResetAll, }: FilterBarProps) { const { t } = useTranslation() - const [activePreset, setActivePreset] = useState(null) - - // Reset active preset when month or viewMode changes externally - useEffect(() => { setActivePreset(null) }, [selectedMonth, viewMode]) + const activePreset = useMemo( + () => resolveActivePreset(selectedMonth, startDate, endDate), + [selectedMonth, startDate, endDate], + ) const hasCustomFilters = selectedMonth !== null || selectedProviders.length > 0 || selectedModels.length > 0 || Boolean(startDate || endDate) || viewMode !== 'daily' @@ -287,10 +331,7 @@ export function FilterBar({ {(startDate || endDate) && {t('filterBar.dateFilterActive')}}
- {limitsButton} + {settingsButton}
{pdfButton} diff --git a/src/hooks/use-app-settings.ts b/src/hooks/use-app-settings.ts index fe4313e..11487c2 100644 --- a/src/hooks/use-app-settings.ts +++ b/src/hooks/use-app-settings.ts @@ -1,6 +1,14 @@ import { useCallback, useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { AppLanguage, AppSettings, AppTheme, ProviderLimits } from '@/types' +import type { + AppLanguage, + AppSettings, + AppTheme, + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + ProviderLimits, +} from '@/types' import { fetchSettings, updateSettings, type UpdateSettingsRequest } from '@/lib/api' import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from '@/lib/app-settings' import { syncProviderLimits } from '@/lib/provider-limits' @@ -49,6 +57,10 @@ export function useAppSettings(availableProviders: string[]) { const setTheme = useCallback((theme: AppTheme) => mutation.mutateAsync({ theme }), [mutation]) const setLanguage = useCallback((language: AppLanguage) => mutation.mutateAsync({ language }), [mutation]) const setProviderLimits = useCallback((limits: ProviderLimits) => mutation.mutateAsync({ providerLimits: limits }), [mutation]) + const setDefaultFilters = useCallback((defaultFilters: DashboardDefaultFilters) => mutation.mutateAsync({ defaultFilters }), [mutation]) + const setSectionVisibility = useCallback((sectionVisibility: DashboardSectionVisibility) => mutation.mutateAsync({ sectionVisibility }), [mutation]) + const setSectionOrder = useCallback((sectionOrder: DashboardSectionOrder) => mutation.mutateAsync({ sectionOrder }), [mutation]) + const saveSettings = useCallback((patch: UpdateSettingsRequest) => mutation.mutateAsync(patch), [mutation]) return { settings, @@ -56,6 +68,10 @@ export function useAppSettings(availableProviders: string[]) { setTheme, setLanguage, setProviderLimits, + setDefaultFilters, + setSectionVisibility, + setSectionOrder, + saveSettings, isLoading: query.isLoading, isSaving: mutation.isPending, } diff --git a/src/hooks/use-dashboard-filters.ts b/src/hooks/use-dashboard-filters.ts index 332f293..9d9773d 100644 --- a/src/hooks/use-dashboard-filters.ts +++ b/src/hooks/use-dashboard-filters.ts @@ -1,111 +1,184 @@ -import { useState, useCallback, useMemo } from 'react' -import type { DailyUsage, ViewMode } from '@/types' +import { useState, useCallback, useMemo, useEffect, useRef } from 'react' +import type { DailyUsage, DashboardDefaultFilters, DashboardDatePreset, ViewMode } from '@/types' +import { DEFAULT_DASHBOARD_FILTERS } from '@/lib/dashboard-preferences' import { filterByDateRange, filterByModels, filterByMonth, sortByDate, getAvailableMonths, getDateRange, aggregateToDailyFormat, filterByProviders } from '@/lib/data-transforms' import { toLocalDateStr } from '@/lib/formatters' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' -export function useDashboardFilters(data: DailyUsage[]) { - const [viewMode, setViewMode] = useState('daily') - const [selectedMonth, setSelectedMonth] = useState(null) - const [selectedProviders, setSelectedProviders] = useState([]) - const [selectedModels, setSelectedModels] = useState([]) - const [startDate, setStartDate] = useState(undefined) - const [endDate, setEndDate] = useState(undefined) +function resolvePresetRange(preset: DashboardDatePreset) { + const today = new Date() + today.setHours(0, 0, 0, 0) + const fmt = toLocalDateStr + + switch (preset) { + case '7d': { + const start = new Date(today) + start.setDate(today.getDate() - 6) + return { startDate: fmt(start), endDate: fmt(today) } + } + case '30d': { + const start = new Date(today) + start.setDate(today.getDate() - 29) + return { startDate: fmt(start), endDate: fmt(today) } + } + case 'month': { + const start = new Date(today.getFullYear(), today.getMonth(), 1) + return { startDate: fmt(start), endDate: fmt(today) } + } + case 'year': { + const start = new Date(today.getFullYear(), 0, 1) + return { startDate: fmt(start), endDate: fmt(today) } + } + case 'all': + default: + return { startDate: undefined, endDate: undefined } + } +} + +function sanitizeDefaultFilters(data: DailyUsage[], defaultFilters: DashboardDefaultFilters) { + const providers = new Set(getUniqueProviders(data.map(entry => entry.modelsUsed))) + const models = new Set(getUniqueModels(data.map(entry => entry.modelsUsed))) + + return { + viewMode: defaultFilters.viewMode, + datePreset: defaultFilters.datePreset, + providers: defaultFilters.providers.filter(provider => providers.has(provider)), + models: defaultFilters.models.filter(model => models.has(model)), + } +} + +export function useDashboardFilters(data: DailyUsage[], defaultFilters: DashboardDefaultFilters = DEFAULT_DASHBOARD_FILTERS) { + const resolvedDefaults = useMemo( + () => sanitizeDefaultFilters(data, defaultFilters), + [data, defaultFilters], + ) + const defaultRange = useMemo( + () => resolvePresetRange(resolvedDefaults.datePreset), + [resolvedDefaults.datePreset], + ) + const defaultFiltersKey = useMemo( + () => JSON.stringify(resolvedDefaults), + [resolvedDefaults], + ) + + const [viewModeState, setViewModeState] = useState(resolvedDefaults.viewMode) + const [selectedMonthState, setSelectedMonthState] = useState(null) + const [selectedProvidersState, setSelectedProvidersState] = useState(resolvedDefaults.providers) + const [selectedModelsState, setSelectedModelsState] = useState(resolvedDefaults.models) + const [startDateState, setStartDateState] = useState(defaultRange.startDate) + const [endDateState, setEndDateState] = useState(defaultRange.endDate) + const userModifiedRef = useRef(false) + const appliedDefaultsKeyRef = useRef(defaultFiltersKey) + + const applyDefaultFilters = useCallback((nextDefaultFilters: DashboardDefaultFilters = defaultFilters) => { + const sanitizedDefaults = sanitizeDefaultFilters(data, nextDefaultFilters) + const nextRange = resolvePresetRange(sanitizedDefaults.datePreset) + userModifiedRef.current = false + appliedDefaultsKeyRef.current = JSON.stringify(sanitizedDefaults) + setViewModeState(sanitizedDefaults.viewMode) + setSelectedMonthState(null) + setSelectedProvidersState(sanitizedDefaults.providers) + setSelectedModelsState(sanitizedDefaults.models) + setStartDateState(nextRange.startDate) + setEndDateState(nextRange.endDate) + }, [data, defaultFilters]) + + useEffect(() => { + if (appliedDefaultsKeyRef.current === defaultFiltersKey || userModifiedRef.current) { + return + } + + appliedDefaultsKeyRef.current = defaultFiltersKey + setViewModeState(resolvedDefaults.viewMode) + setSelectedMonthState(null) + setSelectedProvidersState(resolvedDefaults.providers) + setSelectedModelsState(resolvedDefaults.models) + setStartDateState(defaultRange.startDate) + setEndDateState(defaultRange.endDate) + }, [defaultFiltersKey, resolvedDefaults, defaultRange]) + + const setViewMode = useCallback((mode: ViewMode) => { + userModifiedRef.current = true + setViewModeState(mode) + }, []) + + const setSelectedMonth = useCallback((month: string | null) => { + userModifiedRef.current = true + setSelectedMonthState(month) + }, []) + + const setStartDate = useCallback((date: string | undefined) => { + userModifiedRef.current = true + setStartDateState(date) + }, []) + + const setEndDate = useCallback((date: string | undefined) => { + userModifiedRef.current = true + setEndDateState(date) + }, []) const toggleProvider = useCallback((provider: string) => { - setSelectedProviders(prev => + userModifiedRef.current = true + setSelectedProvidersState(prev => prev.includes(provider) ? prev.filter(p => p !== provider) : [...prev, provider] ) - setSelectedModels([]) + setSelectedModelsState([]) }, []) const clearProviders = useCallback(() => { - setSelectedProviders([]) - setSelectedModels([]) + userModifiedRef.current = true + setSelectedProvidersState([]) + setSelectedModelsState([]) }, []) const toggleModel = useCallback((model: string) => { - setSelectedModels(prev => + userModifiedRef.current = true + setSelectedModelsState(prev => prev.includes(model) ? prev.filter(m => m !== model) : [...prev, model] ) }, []) - const clearModels = useCallback(() => setSelectedModels([]), []) + const clearModels = useCallback(() => { + userModifiedRef.current = true + setSelectedModelsState([]) + }, []) const resetAll = useCallback(() => { - setViewMode('daily') - setSelectedMonth(null) - setSelectedProviders([]) - setSelectedModels([]) - setStartDate(undefined) - setEndDate(undefined) - }, []) + applyDefaultFilters() + }, [applyDefaultFilters]) const applyPreset = useCallback((preset: string) => { - setSelectedMonth(null) - const today = new Date() - today.setHours(0, 0, 0, 0) - const fmt = toLocalDateStr - - switch (preset) { - case '7d': { - const start = new Date(today) - start.setDate(today.getDate() - 6) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case '30d': { - const start = new Date(today) - start.setDate(today.getDate() - 29) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case 'month': { - const start = new Date(today.getFullYear(), today.getMonth(), 1) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case 'year': { - const start = new Date(today.getFullYear(), 0, 1) - setStartDate(fmt(start)) - setEndDate(fmt(today)) - break - } - case 'all': - default: - setStartDate(undefined) - setEndDate(undefined) - break - } + userModifiedRef.current = true + setSelectedMonthState(null) + const nextRange = resolvePresetRange(preset as DashboardDatePreset) + setStartDateState(nextRange.startDate) + setEndDateState(nextRange.endDate) }, []) const preProviderFilteredData = useMemo(() => { let result = sortByDate(data) - result = filterByDateRange(result, startDate, endDate) - result = filterByMonth(result, selectedMonth) + result = filterByDateRange(result, startDateState, endDateState) + result = filterByMonth(result, selectedMonthState) return result - }, [data, startDate, endDate, selectedMonth]) + }, [data, startDateState, endDateState, selectedMonthState]) const preModelFilteredData = useMemo(() => { let result = preProviderFilteredData - result = filterByProviders(result, selectedProviders) + result = filterByProviders(result, selectedProvidersState) return result - }, [preProviderFilteredData, selectedProviders]) + }, [preProviderFilteredData, selectedProvidersState]) const filteredDailyData = useMemo(() => { let result = preModelFilteredData - result = filterByModels(result, selectedModels) + result = filterByModels(result, selectedModelsState) return result - }, [preModelFilteredData, selectedModels]) + }, [preModelFilteredData, selectedModelsState]) const filteredData = useMemo(() => { let result = filteredDailyData - result = aggregateToDailyFormat(result, viewMode) + result = aggregateToDailyFormat(result, viewModeState) return result - }, [filteredDailyData, viewMode]) + }, [filteredDailyData, viewModeState]) const availableMonths = useMemo(() => getAvailableMonths(data), [data]) const availableProviders = useMemo(() => getUniqueProviders(preProviderFilteredData.map(d => d.modelsUsed)), [preProviderFilteredData]) @@ -113,13 +186,14 @@ export function useDashboardFilters(data: DailyUsage[]) { const dateRange = useMemo(() => getDateRange(filteredDailyData), [filteredDailyData]) return { - viewMode, setViewMode, - selectedMonth, setSelectedMonth, - selectedProviders, toggleProvider, clearProviders, - selectedModels, toggleModel, clearModels, - startDate, setStartDate, - endDate, setEndDate, + viewMode: viewModeState, setViewMode, + selectedMonth: selectedMonthState, setSelectedMonth, + selectedProviders: selectedProvidersState, toggleProvider, clearProviders, + selectedModels: selectedModelsState, toggleModel, clearModels, + startDate: startDateState, setStartDate, + endDate: endDateState, setEndDate, resetAll, + applyDefaultFilters, applyPreset, filteredDailyData, filteredData, diff --git a/src/lib/api.ts b/src/lib/api.ts index 9e45bba..ddd6ff4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,15 @@ -import type { AppSettings, AppLanguage, AppTheme, ProviderLimits, UsageData, ViewMode } from '@/types' +import type { + AppSettings, + AppLanguage, + AppTheme, + DashboardDefaultFilters, + DashboardSectionOrder, + DashboardSectionVisibility, + ProviderLimits, + UsageData, + UsageImportSummary, + ViewMode, +} from '@/types' import i18n from '@/lib/i18n' import { normalizeAppSettings } from '@/lib/app-settings' @@ -26,10 +37,26 @@ export async function deleteUsage(): Promise { if (!res.ok) throw new Error(i18n.t('api.deleteFailed')) } +export async function importUsageData(data: unknown): Promise { + const res = await fetch('/api/usage/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: i18n.t('api.importUsageFailed') })) + throw new Error(err.message) + } + return res.json() +} + export interface UpdateSettingsRequest { language?: AppLanguage theme?: AppTheme providerLimits?: ProviderLimits + defaultFilters?: DashboardDefaultFilters + sectionVisibility?: DashboardSectionVisibility + sectionOrder?: DashboardSectionOrder } export async function fetchSettings(): Promise { @@ -51,6 +78,19 @@ export async function updateSettings(patch: UpdateSettingsRequest): Promise { + const res = await fetch('/api/settings/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: i18n.t('api.importSettingsFailed') })) + throw new Error(err.message) + } + return normalizeAppSettings(await res.json()) +} + export interface PdfReportRequest { viewMode: ViewMode selectedMonth: string | null diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 61eca49..6899179 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -1,10 +1,21 @@ import type { AppLanguage, AppSettings, AppTheme, DataLoadSource, ProviderLimits } from '@/types' +import { + DEFAULT_DASHBOARD_FILTERS, + getDefaultDashboardSectionOrder, + getDefaultDashboardSectionVisibility, + normalizeDashboardDefaultFilters, + normalizeDashboardSectionOrder, + normalizeDashboardSectionVisibility, +} from '@/lib/dashboard-preferences' import { normalizeProviderLimitConfig } from '@/lib/provider-limits' export const DEFAULT_APP_SETTINGS: AppSettings = { language: 'de', theme: 'dark', providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + sectionVisibility: getDefaultDashboardSectionVisibility(), + sectionOrder: getDefaultDashboardSectionOrder(), lastLoadedAt: null, lastLoadSource: null, cliAutoLoadActive: false, @@ -51,6 +62,9 @@ export function normalizeAppSettings(value: unknown): AppSettings { language: normalizeAppLanguage(source.language), theme: normalizeAppTheme(source.theme), providerLimits: normalizeStoredProviderLimits(source.providerLimits), + defaultFilters: normalizeDashboardDefaultFilters(source.defaultFilters), + sectionVisibility: normalizeDashboardSectionVisibility(source.sectionVisibility), + sectionOrder: normalizeDashboardSectionOrder(source.sectionOrder), lastLoadedAt: normalizeStoredTimestamp(source.lastLoadedAt), lastLoadSource: normalizeDataLoadSource(source.lastLoadSource), cliAutoLoadActive: Boolean(source.cliAutoLoadActive), diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 7b32419..8639b0e 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -7,7 +7,7 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { 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, avgModelsPerDay: 0, avgDailyCost: 0, avgRequestsPerDay: 0, + 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, @@ -70,7 +70,7 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { 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 avgModelsPerDay = activeDays > 0 ? totalModelsUsed / data.length : 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 @@ -120,7 +120,7 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { return { totalCost, totalTokens, activeDays, topModel, topRequestModel, topTokenModel, topModelShare, topThreeModelsShare, topProvider, providerCount: providerCosts.size, hasRequestData, cacheHitRate, - costPerMillion, avgTokensPerRequest, avgCostPerRequest, avgModelsPerDay, avgDailyCost, avgRequestsPerDay, topDay, cheapestDay, busiestWeek, weekendCostShare, + costPerMillion, avgTokensPerRequest, avgCostPerRequest, avgModelsPerEntry, avgDailyCost, avgRequestsPerDay, topDay, cheapestDay, busiestWeek, weekendCostShare, totalInput, totalOutput, totalCacheRead, totalCacheCreate, totalThinking, totalRequests, weekOverWeekChange, @@ -162,6 +162,7 @@ function computeBusiestWeek(data: DailyUsage[]): { start: string; end: string; c } 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) @@ -221,7 +222,7 @@ export function computeModelCosts(data: DailyUsage[]): Map { - const map = new Map() + const map = new Map }>() for (const day of data) { const entryDays = day._aggregatedDays ?? 1 @@ -238,6 +239,7 @@ export function computeProviderMetrics(data: DailyUsage[]): Map(), } existing.cost += breakdown.cost @@ -248,12 +250,18 @@ export function computeProviderMetrics(data: DailyUsage[]): Map { + const { _dates: _unusedDates, ...metrics } = value + return [provider, metrics] + })) } function computeCacheHitRate(cacheRead: number, cacheCreate: number, input: number, output: number, thinking: number): number { diff --git a/src/lib/dashboard-preferences.ts b/src/lib/dashboard-preferences.ts new file mode 100644 index 0000000..e7d40ef --- /dev/null +++ b/src/lib/dashboard-preferences.ts @@ -0,0 +1,111 @@ +import type { + DashboardDatePreset, + DashboardDefaultFilters, + DashboardSectionId, + DashboardSectionOrder, + DashboardSectionVisibility, + ViewMode, +} from '@/types' + +export interface DashboardSectionDefinition { + id: DashboardSectionId + domId: string + 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_SECTION_DEFINITION_MAP = Object.fromEntries( + DASHBOARD_SECTION_DEFINITIONS.map((section) => [section.id, section]), +) as Record + +export const DEFAULT_DASHBOARD_FILTERS: DashboardDefaultFilters = { + viewMode: 'daily', + datePreset: 'all', + providers: [], + models: [], +} + +export function getDefaultDashboardSectionVisibility(): DashboardSectionVisibility { + return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ + ...visibility, + [section.id]: true, + }), {} as DashboardSectionVisibility) +} + +export function getDefaultDashboardSectionOrder(): DashboardSectionOrder { + return DASHBOARD_SECTION_DEFINITIONS.map((section) => section.id) +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + return [...new Set(value + .filter((entry): entry is string => typeof entry === 'string') + .map(entry => entry.trim()) + .filter(Boolean))] +} + +export function normalizeDashboardDatePreset(value: unknown): DashboardDatePreset { + return DASHBOARD_DATE_PRESETS.includes(value as DashboardDatePreset) + ? value as DashboardDatePreset + : 'all' +} + +export function normalizeDashboardViewMode(value: unknown): ViewMode { + return DASHBOARD_VIEW_MODES.includes(value as ViewMode) + ? value as ViewMode + : 'daily' +} + +export function normalizeDashboardDefaultFilters(value: unknown): DashboardDefaultFilters { + const source = value && typeof value === 'object' ? value as Partial : {} + + return { + viewMode: normalizeDashboardViewMode(source.viewMode), + datePreset: normalizeDashboardDatePreset(source.datePreset), + providers: normalizeStringList(source.providers), + models: normalizeStringList(source.models), + } +} + +export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility { + const source = value && typeof value === 'object' ? value as Partial : {} + const defaults = getDefaultDashboardSectionVisibility() + + return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ + ...visibility, + [section.id]: typeof source[section.id] === 'boolean' ? Boolean(source[section.id]) : defaults[section.id], + }), {} as DashboardSectionVisibility) +} + +export function normalizeDashboardSectionOrder(value: unknown): DashboardSectionOrder { + const defaults = getDefaultDashboardSectionOrder() + + if (!Array.isArray(value)) { + return defaults + } + + const incoming = value.filter((sectionId): sectionId is DashboardSectionId => ( + typeof sectionId === 'string' && defaults.includes(sectionId as DashboardSectionId) + )) + const uniqueIncoming = [...new Set(incoming)] + const missing = defaults.filter((sectionId) => !uniqueIncoming.includes(sectionId)) + + return [...uniqueIncoming, ...missing] +} diff --git a/src/locales/de/common.json b/src/locales/de/common.json index d0dbb1e..4dde73a 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -11,6 +11,7 @@ "import": "Import", "upload": "Upload", "limits": "Limits", + "settings": "Einstellungen", "report": "Report", "csv": "CSV", "delete": "Löschen", @@ -24,6 +25,7 @@ "description": "Lade ein `toktrack`- oder Legacy-JSON hoch oder starte den lokalen Auto-Import mit lokalem `toktrack`, `bunx toktrack daily --json` oder `npx --yes toktrack daily --json`.", "autoImport": "Auto-Import", "uploadFile": "Datei hochladen", + "openSettings": "Einstellungen & Backups", "or": "oder" }, "viewModes": { @@ -66,6 +68,8 @@ "no": "Nein", "enabled": "Aktiv", "disabled": "Inaktiv", + "visible": "Sichtbar", + "hidden": "Versteckt", "open": "Öffnen", "close": "Schliessen", "startDate": "Startdatum", @@ -680,9 +684,9 @@ "label": "Auto-Import starten", "description": "Lokalen toktrack Import ausführen" }, - "openLimits": { - "label": "Limits öffnen", - "description": "Provider Limits und Subscriptions konfigurieren" + "openSettings": { + "label": "Einstellungen öffnen", + "description": "Backups, App-Optionen und Provider Limits verwalten" }, "exportCsv": { "label": "CSV exportieren", @@ -761,6 +765,10 @@ "label": "Zu Filtern", "description": "Springt zur Filterleiste" }, + "goToSection": { + "label": "Zu {{section}}", + "description": "Springt zur Sektion {{section}}" + }, "insights": { "label": "Zu Insights", "description": "Springt zur Executive Summary" @@ -831,6 +839,62 @@ } } }, + "settings": { + "modal": { + "title": "Einstellungen", + "description": "Verwalte App-Backups, gespeicherte Daten und Provider Limits an einem Ort.", + "dataStatus": "Datenstatus", + "lastLoaded": "Zuletzt geladen", + "loadedVia": "Geladen über", + "cliAutoLoad": "CLI Auto-Load", + "defaultFiltersTitle": "Standardfilter fürs Dashboard", + "defaultFiltersDescription": "Lege den Filterzustand fest, der beim Öffnen des Dashboards und beim Zurücksetzen verwendet wird.", + "defaultViewMode": "Standardansicht", + "defaultDateRange": "Standardzeitraum", + "filterProviders": "Standard-Anbieterfilter", + "filterModels": "Standard-Modellfilter", + "noModels": "Keine Modelle im geladenen Report gefunden.", + "sectionVisibilityTitle": "Sichtbare Dashboard-Sektionen", + "sectionVisibilityDescription": "Steuere, welche Sektionen im Dashboard gerendert werden und in welcher Reihenfolge sie erscheinen.", + "sectionOrderHint": "Ziehe die Sektionen per Drag and Drop in die gewünschte Reihenfolge. Die aktuelle Reihenfolge ist das Standardlayout.", + "positionLabel": "Position {{position}} von {{total}}", + "moveSectionUp": "{{section}} nach oben verschieben", + "moveSectionDown": "{{section}} nach unten verschieben", + "settingsBackupTitle": "Einstellungen sichern", + "settingsBackupDescription": "Exportiert und importiert Sprache, Theme, Limits und die gespeicherten Lade-Metadaten als versioniertes Backup.", + "dataBackupTitle": "Daten sichern", + "dataBackupDescription": "Exportiert den lokal gespeicherten Nutzungsstand als Backup und importiert Backups konservativ zurück.", + "dataImportPolicy": "Beim Datenimport werden nur fehlende Tage ergänzt. Bestehende Tage mit abweichenden Werten bleiben lokal erhalten und werden als Konflikt gemeldet.", + "dataImportReplaceHint": "Wenn du einen Datensatz vollständig ersetzen willst, nutze weiter den normalen JSON-Upload im Header.", + "providerLimitsTitle": "Provider Limits", + "providerLimitsDescription": "Definiere Subscription und Monatslimit pro Anbieter. Nur Anbieter aus dem aktuell geladenen Report sind editierbar.", + "noProviders": "Keine Anbieter im geladenen Report gefunden.", + "exportSettings": "Einstellungen exportieren", + "importSettings": "Einstellungen importieren", + "exportData": "Daten exportieren", + "importData": "Daten importieren", + "close": "Schliessen", + "save": "Speichern", + "viewModes": { + "daily": "Täglich", + "monthly": "Monatlich", + "yearly": "Jährlich" + }, + "datePresets": { + "all": "Alle Daten", + "7d": "Letzte 7 Tage", + "30d": "Letzte 30 Tage", + "month": "Aktueller Monat", + "year": "Aktuelles Jahr" + }, + "sources": { + "file": "Datei-Upload", + "auto-import": "Auto-Import", + "cli-auto-load": "CLI Auto-Load", + "unknown": "Unbekannt" + } + } + }, "limits": { "sectionTitle": "Limits & Subscriptions", "sectionDescription": "Budget-Risiko getrennt von Subscription-Wirkung im aktuellen Filterkontext", @@ -911,6 +975,8 @@ "fetchUsageFailed": "Fehler beim Laden der Daten", "uploadFailed": "Upload fehlgeschlagen", "deleteFailed": "Löschen fehlgeschlagen", + "importUsageFailed": "Datenimport fehlgeschlagen", + "importSettingsFailed": "Einstellungs-Import fehlgeschlagen", "pdfFailed": "PDF-Generierung fehlgeschlagen" }, "toasts": { @@ -918,6 +984,13 @@ "fileReadFailed": "Datei konnte nicht gelesen werden", "dataDeleted": "Daten gelöscht", "csvExported": "CSV exportiert", - "dataImported": "Daten erfolgreich importiert" + "dataImported": "Daten erfolgreich importiert", + "settingsExported": "Einstellungs-Backup exportiert", + "dataExported": "Daten-Backup exportiert", + "noDataToExport": "Keine Daten zum Exportieren vorhanden", + "settingsImported": "Einstellungen aus {{name}} importiert", + "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 bf66e5d..1cfb7a4 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -11,6 +11,7 @@ "import": "Import", "upload": "Upload", "limits": "Limits", + "settings": "Settings", "report": "Report", "csv": "CSV", "delete": "Delete", @@ -24,6 +25,7 @@ "description": "Upload a `toktrack` or legacy JSON file, or start local auto-import with local `toktrack`, `bunx toktrack daily --json`, or `npx --yes toktrack daily --json`.", "autoImport": "Auto import", "uploadFile": "Upload file", + "openSettings": "Settings & backups", "or": "or" }, "viewModes": { @@ -66,6 +68,8 @@ "no": "No", "enabled": "Enabled", "disabled": "Disabled", + "visible": "Visible", + "hidden": "Hidden", "open": "Open", "close": "Close", "startDate": "Start date", @@ -680,9 +684,9 @@ "label": "Start auto import", "description": "Run local toktrack import" }, - "openLimits": { - "label": "Open limits", - "description": "Configure provider limits and subscriptions" + "openSettings": { + "label": "Open settings", + "description": "Manage backups, app options, and provider limits" }, "exportCsv": { "label": "Export CSV", @@ -761,6 +765,10 @@ "label": "Go to filters", "description": "Jump to the filter bar" }, + "goToSection": { + "label": "Go to {{section}}", + "description": "Jump to the {{section}} section" + }, "insights": { "label": "Go to insights", "description": "Jump to the executive summary" @@ -831,6 +839,62 @@ } } }, + "settings": { + "modal": { + "title": "Settings", + "description": "Manage app backups, stored data, and provider limits in one place.", + "dataStatus": "Data status", + "lastLoaded": "Last loaded", + "loadedVia": "Loaded via", + "cliAutoLoad": "CLI auto-load", + "defaultFiltersTitle": "Default dashboard filters", + "defaultFiltersDescription": "Choose the filter state that should be applied when the dashboard opens or when filters are reset.", + "defaultViewMode": "Default view mode", + "defaultDateRange": "Default date range", + "filterProviders": "Default provider filter", + "filterModels": "Default model filter", + "noModels": "No models found in the loaded report.", + "sectionVisibilityTitle": "Visible dashboard sections", + "sectionVisibilityDescription": "Control which sections are rendered in the dashboard and adjust their order.", + "sectionOrderHint": "Drag sections to reorder them. The current order is the default dashboard layout.", + "positionLabel": "Position {{position}} of {{total}}", + "moveSectionUp": "Move {{section}} up", + "moveSectionDown": "Move {{section}} down", + "settingsBackupTitle": "Back up settings", + "settingsBackupDescription": "Export and import language, theme, limits, and stored load metadata as a versioned backup.", + "dataBackupTitle": "Back up data", + "dataBackupDescription": "Export the locally stored usage state as a backup and import backups conservatively.", + "dataImportPolicy": "Data import only adds missing days. Existing days with different values stay local and are reported as conflicts.", + "dataImportReplaceHint": "If you want to fully replace the dataset, keep using the regular JSON upload in the header.", + "providerLimitsTitle": "Provider limits", + "providerLimitsDescription": "Define subscription and monthly limit per provider. Only providers from the currently loaded report can be edited.", + "noProviders": "No providers found in the loaded report.", + "exportSettings": "Export settings", + "importSettings": "Import settings", + "exportData": "Export data", + "importData": "Import data", + "close": "Close", + "save": "Save", + "viewModes": { + "daily": "Daily", + "monthly": "Monthly", + "yearly": "Yearly" + }, + "datePresets": { + "all": "All data", + "7d": "Last 7 days", + "30d": "Last 30 days", + "month": "Current month", + "year": "Current year" + }, + "sources": { + "file": "File upload", + "auto-import": "Auto import", + "cli-auto-load": "CLI auto-load", + "unknown": "Unknown" + } + } + }, "limits": { "sectionTitle": "Limits & Subscriptions", "sectionDescription": "Budget risk separated from subscription impact in the current filter context", @@ -911,6 +975,8 @@ "fetchUsageFailed": "Failed to load data", "uploadFailed": "Upload failed", "deleteFailed": "Delete failed", + "importUsageFailed": "Data import failed", + "importSettingsFailed": "Settings import failed", "pdfFailed": "PDF generation failed" }, "toasts": { @@ -918,6 +984,13 @@ "fileReadFailed": "Could not read file", "dataDeleted": "Data deleted", "csvExported": "CSV exported", - "dataImported": "Data imported successfully" + "dataImported": "Data imported successfully", + "settingsExported": "Settings backup exported", + "dataExported": "Data backup exported", + "noDataToExport": "No data available to export", + "settingsImported": "Imported settings from {{name}}", + "settingsSaved": "Settings saved", + "dataBackupImported": "Backup imported: added {{added}} new days, skipped {{unchanged}} identical days", + "dataBackupImportedWithConflicts": "Backup imported: added {{added}} new days, kept {{conflicts}} conflicting days local" } } diff --git a/src/types/index.ts b/src/types/index.ts index f1b7426..dfcec9f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,11 +39,44 @@ export interface UsageData { } } +export interface UsageImportSummary { + importedDays: number + addedDays: number + unchangedDays: number + conflictingDays: number + totalDays: number +} + export type AppLanguage = 'de' | 'en' export type AppTheme = 'dark' | 'light' export type ViewMode = 'daily' | 'monthly' | 'yearly' +export type DashboardDatePreset = 'all' | '7d' | '30d' | 'month' | 'year' +export type DashboardSectionId = + | 'insights' + | 'metrics' + | 'today' + | 'currentMonth' + | 'activity' + | 'forecastCache' + | 'limits' + | 'costAnalysis' + | 'tokenAnalysis' + | 'requestAnalysis' + | 'advancedAnalysis' + | 'comparisons' + | 'tables' + +export interface DashboardDefaultFilters { + viewMode: ViewMode + datePreset: DashboardDatePreset + providers: string[] + models: string[] +} + +export type DashboardSectionVisibility = Record +export type DashboardSectionOrder = DashboardSectionId[] export interface DateRange { start: string @@ -66,7 +99,7 @@ export interface DashboardMetrics { costPerMillion: number avgTokensPerRequest: number avgCostPerRequest: number - avgModelsPerDay: number + avgModelsPerEntry: number avgDailyCost: number avgRequestsPerDay: number topDay: { date: string; cost: number } | null @@ -169,6 +202,9 @@ export interface AppSettings { language: AppLanguage theme: AppTheme providerLimits: ProviderLimits + defaultFilters: DashboardDefaultFilters + sectionVisibility: DashboardSectionVisibility + sectionOrder: DashboardSectionOrder lastLoadedAt: string | null lastLoadSource: DataLoadSource cliAutoLoadActive: boolean diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 139e695..3f5af81 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -1,7 +1,28 @@ +import fs from 'node:fs' +import fsPromises from 'node:fs/promises' import path from 'node:path' -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' const sampleUsagePath = path.join(process.cwd(), 'examples', 'sample-usage.json') +const sampleUsage = JSON.parse(fs.readFileSync(sampleUsagePath, 'utf-8')) +const uploadToastPattern = /^(Datei sample-usage\.json erfolgreich geladen|File sample-usage\.json loaded successfully)$/ +const autoImportButtonPattern = /^(Auto-Import|Auto import)$/ +const uploadFileButtonPattern = /^(Datei hochladen|Upload file)$/ +const exportSettingsButtonPattern = /^(Einstellungen exportieren|Export settings)$/ +const exportDataButtonPattern = /^(Daten exportieren|Export data)$/ +const dataImportToastPattern = /^(Backup importiert: 1 neue Tage ergänzt, 1 Konflikttage lokal beibehalten|Backup imported: added 1 new days, kept 1 conflicting days local)$/ +const saveSettingsButtonPattern = /^(Speichern|Save)$/ +const monthlySettingsPattern = /^(Monatlich|Monthly)$/ +const monthlyViewPattern = /^(Monatsansicht|Monthly view)$/ +const dailyViewPattern = /^(Tagesansicht|Daily view)$/ +const last30DaysPattern = /^(Letzte 30 Tage|Last 30 days)$/ +const defaultDailyPattern = /^(Täglich|Daily)$/ +const allDataPattern = /^(Alle Daten|All data)$/ + +async function uploadSampleUsage(page: Page) { + await page.locator('[data-testid="usage-upload-input"]').setInputFiles(sampleUsagePath) + await expect(page.getByText(uploadToastPattern)).toBeVisible() +} test('uploads sample usage data and renders the dashboard without browser errors', async ({ page }) => { const pageErrors: string[] = [] @@ -17,20 +38,385 @@ test('uploads sample usage data and renders the dashboard without browser errors }) await page.request.delete('/api/usage') + await page.request.delete('/api/settings') await page.goto('/') await expect(page.getByRole('heading', { name: 'TTDash' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Auto-Import' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Datei hochladen' })).toBeVisible() + await expect(page.getByRole('button', { name: autoImportButtonPattern })).toBeVisible() + await expect(page.getByRole('button', { name: uploadFileButtonPattern })).toBeVisible() - await page.locator('input[type="file"]').setInputFiles(sampleUsagePath) + await uploadSampleUsage(page) await expect(page.getByRole('button', { name: 'Import' })).toBeVisible() await expect(page.getByRole('button', { name: 'Upload' })).toBeVisible() await expect(page.getByRole('button', { name: 'CSV' })).toBeVisible() - await expect(page.getByText(/^Datei sample-usage\.json erfolgreich geladen$/)).toBeVisible() await expect(page.locator('#token-analysis')).toBeVisible() expect(pageErrors, pageErrors.join('\n')).toEqual([]) }) + +test('manages settings and backup imports through the settings dialog using isolated test storage', async ({ page }, testInfo) => { + await page.request.delete('/api/usage') + await page.request.delete('/api/settings') + await page.addInitScript(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + __TTDASH_TEST_HOOKS__?: { + onJsonDownload?: (record: { filename: string, mimeType: string, size: number, text: string }) => void + openSettings?: () => void + } + } + globalWindow.__TTDASH_DOWNLOAD_RECORDS__ = [] + globalWindow.__TTDASH_TEST_HOOKS__ = { + onJsonDownload: (record) => { + globalWindow.__TTDASH_DOWNLOAD_RECORDS__?.push(record) + }, + } + }) + await page.goto('/') + await uploadSampleUsage(page) + await expect(page.locator('#token-analysis')).toBeVisible() + + await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.locator('[data-section-id="insights"]')).toContainText('Insights') + + await dialog.getByRole('button', { name: monthlySettingsPattern }).click() + await dialog.getByRole('button', { name: last30DaysPattern }).click() + await dialog.getByTestId('move-section-up-tokenAnalysis').click() + await dialog.getByTestId('toggle-section-visibility-tokenAnalysis').click() + await dialog.getByTestId('reset-all-settings-drafts').click() + await expect(dialog.getByRole('button', { name: defaultDailyPattern })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.getByRole('button', { name: allDataPattern })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText(/Sichtbar|Visible/) + await expect.poll(async () => dialog.locator('[data-section-id]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id')))).toEqual([ + 'insights', + 'metrics', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', + ]) + await dialog.getByRole('button', { name: saveSettingsButtonPattern }).click() + + await expect(dialog).toBeHidden() + await expect(page.locator('#token-analysis')).toBeVisible() + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(dailyViewPattern) + + await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + await expect(dialog).toBeVisible() + await dialog.getByTestId('reset-default-filters').click() + await expect(dialog.getByRole('button', { name: defaultDailyPattern })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.getByRole('button', { name: allDataPattern })).toHaveAttribute('aria-pressed', 'true') + await dialog.getByRole('button', { name: monthlySettingsPattern }).click() + await dialog.getByRole('button', { name: last30DaysPattern }).click() + await dialog.getByTestId('reset-section-visibility').click() + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText(/Sichtbar|Visible/) + await dialog.getByTestId('move-section-up-tokenAnalysis').click() + await dialog.getByTestId('toggle-section-visibility-tokenAnalysis').click() + await dialog.getByRole('button', { name: saveSettingsButtonPattern }).click() + + await expect(dialog).toBeHidden() + await expect(page.locator('#token-analysis')).toHaveCount(0) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(monthlyViewPattern) + + await page.reload() + await expect(page.locator('#token-analysis')).toHaveCount(0) + await expect(page.locator('#filters').getByRole('combobox').first()).toContainText(monthlyViewPattern) + + await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + await expect(dialog).toBeVisible() + + await page.getByRole('button', { name: exportSettingsButtonPattern }).click() + await expect.poll(async () => { + const records = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + }) + return records.length + }).toBe(1) + const exportedSettingsRecord = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + return records[0] + }) + expect(exportedSettingsRecord.filename).toMatch(/^ttdash-settings-backup-\d{4}-\d{2}-\d{2}\.json$/) + const exportedSettings = JSON.parse(exportedSettingsRecord.text) + expect(exportedSettings.kind).toBe('ttdash-settings-backup') + expect(exportedSettings.settings.defaultFilters.viewMode).toBe('monthly') + expect(exportedSettings.settings.defaultFilters.datePreset).toBe('30d') + expect(exportedSettings.settings.sectionVisibility.tokenAnalysis).toBe(false) + expect(exportedSettings.settings.sectionOrder.indexOf('tokenAnalysis')).toBeLessThan(exportedSettings.settings.sectionOrder.indexOf('costAnalysis')) + + await page.getByRole('button', { name: exportDataButtonPattern }).click() + await expect.poll(async () => { + const records = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + return globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + }) + return records.length + }).toBe(2) + const exportedDataRecord = await page.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ filename: string, mimeType: string, size: number, text: string }> + } + const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] + return records[1] + }) + expect(exportedDataRecord.filename).toMatch(/^ttdash-data-backup-\d{4}-\d{2}-\d{2}\.json$/) + const exportedData = JSON.parse(exportedDataRecord.text) + expect(exportedData.kind).toBe('ttdash-usage-backup') + expect(exportedData.data.daily).toHaveLength(5) + + const importDataPath = testInfo.outputPath('usage-backup-import.json') + await fsPromises.writeFile(importDataPath, JSON.stringify({ + kind: 'ttdash-usage-backup', + version: 1, + data: { + daily: [ + sampleUsage.daily[0], + { + ...sampleUsage.daily[1], + totalCost: 999, + }, + { + ...sampleUsage.daily[0], + date: '2026-03-31', + }, + ], + }, + }, null, 2)) + + await page.locator('[data-testid="data-import-input"]').setInputFiles(importDataPath) + await expect(page.getByText(dataImportToastPattern)).toBeVisible() + + const mergedUsageResponse = await page.request.get('/api/usage') + expect(mergedUsageResponse.ok()).toBe(true) + const mergedUsage = await mergedUsageResponse.json() + expect(mergedUsage.daily).toHaveLength(6) + expect(mergedUsage.daily[0].date).toBe('2026-03-31') + expect(mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost).toBe(3.94) + + const importSettingsPath = testInfo.outputPath('settings-backup-import.json') + await fsPromises.writeFile(importSettingsPath, JSON.stringify({ + kind: 'ttdash-settings-backup', + version: 1, + settings: { + language: 'en', + theme: 'light', + providerLimits: { + OpenAI: { + hasSubscription: true, + subscriptionPrice: 20, + monthlyLimit: 400, + }, + }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['tables', 'advancedAnalysis', 'metrics', 'insights'], + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', + }, + }, null, 2)) + + await page.locator('[data-testid="settings-import-input"]').setInputFiles(importSettingsPath) + await expect(page.getByRole('button', { name: 'Export settings' })).toBeVisible() + + const importedSettingsResponse = await page.request.get('/api/settings') + expect(importedSettingsResponse.ok()).toBe(true) + const importedSettings = await importedSettingsResponse.json() + expect(importedSettings.language).toBe('en') + expect(importedSettings.theme).toBe('light') + expect(importedSettings.providerLimits.OpenAI.monthlyLimit).toBe(400) + expect(importedSettings.defaultFilters).toEqual({ + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }) + expect(importedSettings.sectionVisibility.tokenAnalysis).toBe(false) + expect(importedSettings.sectionVisibility.comparisons).toBe(false) + expect(importedSettings.sectionOrder.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) +}) + +test('loads persisted settings on a fresh browser start and applies them immediately', async ({ browser, page }) => { + await page.request.delete('/api/usage') + await page.request.delete('/api/settings') + + const patchSettingsResponse = await page.request.patch('/api/settings', { + data: { + language: 'en', + theme: 'light', + providerLimits: { + OpenAI: { + hasSubscription: true, + subscriptionPrice: 20, + monthlyLimit: 400, + }, + }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['tables', 'advancedAnalysis', 'metrics', 'insights'], + }, + }) + expect(patchSettingsResponse.ok()).toBe(true) + + const uploadResponse = await page.request.post('/api/upload', { + data: sampleUsage, + }) + expect(uploadResponse.ok()).toBe(true) + + const context = await browser.newContext() + await context.addInitScript(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__ = {} + }) + + const freshPage = await context.newPage() + + try { + await freshPage.goto('/') + await expect(freshPage.locator('#token-analysis')).toHaveCount(0) + await expect(freshPage.locator('#comparisons')).toHaveCount(0) + await expect.poll(async () => freshPage.evaluate(() => document.documentElement.classList.contains('dark'))).toBe(false) + await expect(freshPage.getByRole('button', { name: 'Settings' })).toBeVisible() + await expect(freshPage.locator('#filters').getByText('Filter status')).toBeVisible() + await expect(freshPage.locator('#filters').getByText('1 providers active')).toBeVisible() + await expect(freshPage.locator('#filters').getByText('1 models active')).toBeVisible() + await expect(freshPage.locator('#filters').getByRole('combobox').first()).toContainText('Monthly view') + await expect(freshPage.getByRole('button', { name: 'Delete' })).toBeVisible() + await expect.poll(async () => freshPage.evaluate(() => { + const tables = document.getElementById('tables') + const advancedAnalysis = document.getElementById('advanced-analysis') + const metrics = document.getElementById('metrics') + const insights = document.getElementById('insights') + + if (!tables || !advancedAnalysis || !metrics || !insights) { + return false + } + + const tablesBeforeAdvanced = Boolean(tables.compareDocumentPosition(advancedAnalysis) & Node.DOCUMENT_POSITION_FOLLOWING) + const advancedBeforeMetrics = Boolean(advancedAnalysis.compareDocumentPosition(metrics) & Node.DOCUMENT_POSITION_FOLLOWING) + const metricsBeforeInsights = Boolean(metrics.compareDocumentPosition(insights) & Node.DOCUMENT_POSITION_FOLLOWING) + + return tablesBeforeAdvanced && advancedBeforeMetrics && metricsBeforeInsights + })).toBe(true) + + await freshPage.keyboard.press('Control+k') + await expect(freshPage.getByTestId('command-section-advancedAnalysis')).toBeVisible() + const orderedSectionCommandIds = await freshPage.locator('[data-testid^="command-section-"]').evaluateAll((nodes) => ( + nodes.map((node) => node.getAttribute('data-testid')?.replace('command-section-', '')) + )) + expect(orderedSectionCommandIds.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) + await freshPage.keyboard.press('Escape') + + await freshPage.evaluate(() => { + const globalWindow = window as typeof window & { + __TTDASH_TEST_HOOKS__?: { + openSettings?: () => void + } + } + globalWindow.__TTDASH_TEST_HOOKS__?.openSettings?.() + }) + + const dialog = freshPage.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Export settings' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'OpenAI', exact: true })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Monthly' })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.getByRole('button', { name: 'Last 30 days' })).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.locator('[data-section-id="advancedAnalysis"]')).toContainText('Distributions & Risk') + await expect(dialog.locator('[data-section-id="insights"]')).toContainText('Insights') + await expect(dialog.locator('[data-section-id="tokenAnalysis"]')).toContainText('Hidden') + const orderedSectionIds = await dialog.locator('[data-section-id]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-section-id'))) + expect(orderedSectionIds.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) + const openAiCard = dialog.locator('[data-provider-id="OpenAI"]') + await expect(openAiCard.locator('input[type="number"]').nth(0)).toHaveValue('20') + await expect(openAiCard.locator('input[type="number"]').nth(1)).toHaveValue('400') + await dialog.getByTestId('reset-provider-limits').click() + await expect(openAiCard.locator('input[type="number"]').nth(0)).toHaveValue('0') + await expect(openAiCard.locator('input[type="number"]').nth(1)).toHaveValue('0') + } finally { + await context.close() + } +}) + +test('uses the current UI language when generating a PDF report after switching locale', async ({ page }) => { + await page.request.delete('/api/usage') + + let reportRequest: Record | null = null + + await page.route('**/api/report/pdf', async route => { + reportRequest = JSON.parse(route.request().postData() ?? '{}') as Record + await route.fulfill({ + status: 200, + contentType: 'application/pdf', + body: Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n'), + }) + }) + + await page.goto('/') + await uploadSampleUsage(page) + await page.getByTitle(/English|Englisch/).click() + await expect(page.locator('#filters').getByText('Filter status')).toBeVisible() + + await page.getByRole('button', { name: 'Report' }).click() + + await expect.poll(() => reportRequest?.language).toBe('en') +}) diff --git a/tests/frontend/filter-bar.test.tsx b/tests/frontend/filter-bar.test.tsx new file mode 100644 index 0000000..df48ab1 --- /dev/null +++ b/tests/frontend/filter-bar.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FilterBar } from '@/components/layout/FilterBar' +import { initI18n } from '@/lib/i18n' + +describe('FilterBar', () => { + beforeEach(async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-06T12:00:00Z')) + await initI18n('en') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('derives preset highlighting from the actual date range and clears it for custom ranges or month filters', () => { + const noop = vi.fn() + + const { rerender } = render( + , + ) + + expect(screen.getByRole('button', { name: '7D' }).className).toContain('bg-primary') + + rerender( + , + ) + + expect(screen.getByRole('button', { name: '7D' }).className).not.toContain('bg-primary') + expect(screen.getByRole('button', { name: 'All' }).className).not.toContain('bg-primary') + + rerender( + , + ) + + expect(screen.getByRole('button', { name: 'All' }).className).toContain('bg-primary') + + rerender( + , + ) + + expect(screen.getByRole('button', { name: 'All' }).className).not.toContain('bg-primary') + }) +}) diff --git a/tests/frontend/use-dashboard-filters.test.tsx b/tests/frontend/use-dashboard-filters.test.tsx index da2d040..918a164 100644 --- a/tests/frontend/use-dashboard-filters.test.tsx +++ b/tests/frontend/use-dashboard-filters.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useDashboardFilters } from '@/hooks/use-dashboard-filters' +import type { DashboardDefaultFilters } from '@/types' import { dashboardFixture } from '../fixtures/usage-data' describe('useDashboardFilters', () => { @@ -66,4 +67,71 @@ describe('useDashboardFilters', () => { expect(result.current.startDate).toBe('2026-03-31') expect(result.current.endDate).toBe('2026-04-06') }) + + it('hydrates from external default filters and restores them on reset', () => { + const defaults: DashboardDefaultFilters = { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + } + + const { result } = renderHook(({ filters }) => useDashboardFilters(dashboardFixture, filters), { + initialProps: { filters: defaults }, + }) + + expect(result.current.viewMode).toBe('monthly') + expect(result.current.selectedProviders).toEqual(['OpenAI']) + expect(result.current.selectedModels).toEqual(['GPT-5.4']) + expect(result.current.startDate).toBe('2026-03-08') + expect(result.current.endDate).toBe('2026-04-06') + + act(() => { + result.current.toggleProvider('Anthropic') + result.current.applyPreset('7d') + }) + + expect(result.current.selectedProviders).toEqual(['OpenAI', 'Anthropic']) + expect(result.current.startDate).toBe('2026-03-31') + + act(() => { + result.current.resetAll() + }) + + expect(result.current.viewMode).toBe('monthly') + expect(result.current.selectedProviders).toEqual(['OpenAI']) + expect(result.current.selectedModels).toEqual(['GPT-5.4']) + expect(result.current.startDate).toBe('2026-03-08') + expect(result.current.endDate).toBe('2026-04-06') + }) + + it('applies persisted defaults when matching data becomes available later', () => { + const defaults: DashboardDefaultFilters = { + viewMode: 'daily', + datePreset: 'all', + providers: ['OpenAI'], + models: ['GPT-5.4'], + } + + const { result, rerender } = renderHook( + ({ data, filters }) => useDashboardFilters(data, filters), + { + initialProps: { + data: [], + filters: defaults, + }, + }, + ) + + expect(result.current.selectedProviders).toEqual([]) + expect(result.current.selectedModels).toEqual([]) + + rerender({ + data: dashboardFixture, + filters: defaults, + }) + + expect(result.current.selectedProviders).toEqual(['OpenAI']) + expect(result.current.selectedModels).toEqual(['GPT-5.4']) + }) }) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 4accb13..c374fb3 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,10 +1,11 @@ import { createServer } from 'node:net' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import sampleUsage from '../../examples/sample-usage.json' +import { DEFAULT_DASHBOARD_FILTERS, getDefaultDashboardSectionOrder } from '@/lib/dashboard-preferences' let child: ChildProcessWithoutNullStreams | null = null let baseUrl = '' @@ -57,6 +58,195 @@ async function waitForServer(url: string) { throw new Error(`Timed out waiting for server startup:\n${output}`) } +async function waitForUrlAvailable(url: string) { + const startedAt = Date.now() + + while (Date.now() - startedAt < 15_000) { + try { + const response = await fetch(`${url}/api/usage`) + if (response.ok) { + return + } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + throw new Error(`Timed out waiting for server startup: ${url}`) +} + +async function waitForServerUnavailable(url: string) { + const startedAt = Date.now() + + while (Date.now() - startedAt < 15_000) { + try { + await fetch(`${url}/api/usage`) + } catch { + return + } + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + throw new Error(`Timed out waiting for server shutdown: ${url}`) +} + +async function waitForProcessServer( + currentChild: ChildProcessWithoutNullStreams, + url: string, + getOutput: () => string, +) { + const startedAt = Date.now() + + while (Date.now() - startedAt < 15_000) { + if (currentChild.exitCode !== null) { + throw new Error(`Server exited before becoming ready:\n${getOutput()}`) + } + + try { + const response = await fetch(`${url}/api/usage`) + if (response.ok) { + return + } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + throw new Error(`Timed out waiting for server startup:\n${getOutput()}`) +} + +async function stopProcess(currentChild: ChildProcessWithoutNullStreams) { + if (currentChild.exitCode !== null) { + return + } + + currentChild.kill('SIGTERM') + await new Promise(resolve => currentChild.once('close', resolve)) +} + +function createCliEnv(root: string) { + return { + ...process.env, + HOME: root, + USERPROFILE: root, + APPDATA: path.join(root, 'AppData', 'Roaming'), + LOCALAPPDATA: path.join(root, 'AppData', 'Local'), + HOST: '127.0.0.1', + NO_OPEN_BROWSER: '1', + XDG_CACHE_HOME: path.join(root, 'cache'), + XDG_CONFIG_HOME: path.join(root, 'config'), + XDG_DATA_HOME: path.join(root, 'data'), + } +} + +async function startStandaloneServer({ + root, + args = [], + envOverrides = {}, +}: { + root: string + args?: string[] + envOverrides?: NodeJS.ProcessEnv +}) { + const port = Number(envOverrides.PORT) || await getFreePort() + const url = `http://127.0.0.1:${port}` + let serverOutput = '' + + const currentChild = spawn(process.execPath, ['server.js', ...args], { + cwd: process.cwd(), + env: { + ...createCliEnv(root), + PORT: String(port), + ...envOverrides, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + currentChild.stdout.on('data', chunk => { + serverOutput += chunk.toString() + }) + + currentChild.stderr.on('data', chunk => { + serverOutput += chunk.toString() + }) + + await waitForProcessServer(currentChild, url, () => serverOutput) + + return { + child: currentChild, + url, + port, + getOutput: () => serverOutput, + } +} + +function getCliConfigDir(root: string) { + if (process.platform === 'darwin') { + return path.join(root, 'Library', 'Application Support', 'TTDash') + } + + if (process.platform === 'win32') { + return path.join(root, 'AppData', 'Roaming', 'TTDash') + } + + return path.join(root, 'config', 'ttdash') +} + +function readBackgroundRegistry(root: string) { + const registryPath = path.join(getCliConfigDir(root), 'background-instances.json') + return JSON.parse(readFileSync(registryPath, 'utf-8')) as Array<{ url: string, port: number, pid: number }> +} + +function writeBackgroundRegistry(root: string, entries: unknown) { + const registryPath = path.join(getCliConfigDir(root), 'background-instances.json') + mkdirSync(path.dirname(registryPath), { recursive: true }) + writeFileSync(registryPath, JSON.stringify(entries, null, 2)) +} + +async function runCli(args: string[], { env, input }: { env: NodeJS.ProcessEnv, input?: string }) { + return await new Promise<{ code: number | null, output: string }>((resolve, reject) => { + const cli = spawn(process.execPath, ['server.js', ...args], { + cwd: process.cwd(), + env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let cliOutput = '' + + cli.stdout.on('data', chunk => { + cliOutput += chunk.toString() + }) + + cli.stderr.on('data', chunk => { + cliOutput += chunk.toString() + }) + + cli.on('error', reject) + cli.on('close', code => { + resolve({ code, output: cliOutput }) + }) + + if (input) { + cli.stdin.write(input) + } + cli.stdin.end() + }) +} + +async function stopAllBackgroundServers(env: NodeJS.ProcessEnv) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const result = await runCli(['stop'], { + env, + input: '1\n', + }) + + if (result.output.includes('No running TTDash background servers found.')) { + return + } + } +} + beforeAll(async () => { const port = await getFreePort() baseUrl = `http://127.0.0.1:${port}` @@ -122,6 +312,23 @@ describe('local server API', () => { language: 'de', theme: 'dark', providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + sectionVisibility: { + insights: true, + metrics: true, + today: true, + currentMonth: true, + activity: true, + forecastCache: true, + limits: true, + costAnalysis: true, + tokenAnalysis: true, + requestAnalysis: true, + advancedAnalysis: true, + comparisons: true, + tables: true, + }, + sectionOrder: getDefaultDashboardSectionOrder(), lastLoadedAt: null, lastLoadSource: null, cliAutoLoadActive: false, @@ -162,6 +369,17 @@ describe('local server API', () => { monthlyLimit: 500.555, }, }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + sectionOrder: ['metrics', 'insights', 'today'], }), }) @@ -176,6 +394,32 @@ describe('local server API', () => { monthlyLimit: 500.56, }, }, + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + insights: true, + }, + sectionOrder: [ + 'metrics', + 'insights', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + 'tables', + ], cliAutoLoadActive: false, }) @@ -191,11 +435,527 @@ describe('local server API', () => { expect(finalUsage.totals.totalCost).toBe(0) const finalSettingsResponse = await fetch(`${baseUrl}/api/settings`) - expect(await finalSettingsResponse.json()).toMatchObject({ + const finalSettings = await finalSettingsResponse.json() + expect(finalSettings).toMatchObject({ language: 'en', theme: 'light', + defaultFilters: { + viewMode: 'monthly', + datePreset: '30d', + providers: ['OpenAI'], + models: ['GPT-5.4'], + }, + sectionVisibility: { + tokenAnalysis: false, + comparisons: false, + }, + lastLoadedAt: null, + lastLoadSource: null, + }) + expect(finalSettings.sectionOrder.slice(0, 3)).toEqual(['metrics', 'insights', 'today']) + }) + + it('imports settings backups and merges usage backups without overwriting conflicting local days', async () => { + const seedResponse = await fetch(`${baseUrl}/api/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sampleUsage), + }) + expect(seedResponse.status).toBe(200) + + const settingsImportResponse = await fetch(`${baseUrl}/api/settings/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-settings-backup', + version: 1, + settings: { + language: 'de', + theme: 'light', + providerLimits: { + Anthropic: { + hasSubscription: true, + subscriptionPrice: 21.499, + monthlyLimit: 300.111, + }, + }, + defaultFilters: { + viewMode: 'yearly', + datePreset: 'year', + providers: ['Anthropic'], + models: ['Claude Sonnet 4.5'], + }, + sectionVisibility: { + tables: false, + advancedAnalysis: false, + }, + sectionOrder: ['tables', 'metrics', 'insights'], + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', + }, + }), + }) + + expect(settingsImportResponse.status).toBe(200) + expect(await settingsImportResponse.json()).toMatchObject({ + language: 'de', + theme: 'light', + providerLimits: { + Anthropic: { + hasSubscription: true, + subscriptionPrice: 21.5, + monthlyLimit: 300.11, + }, + }, + defaultFilters: { + viewMode: 'yearly', + datePreset: 'year', + providers: ['Anthropic'], + models: ['Claude Sonnet 4.5'], + }, + sectionVisibility: { + tables: false, + advancedAnalysis: false, + insights: true, + }, + sectionOrder: [ + 'tables', + 'metrics', + 'insights', + 'today', + 'currentMonth', + 'activity', + 'forecastCache', + 'limits', + 'costAnalysis', + 'tokenAnalysis', + 'requestAnalysis', + 'advancedAnalysis', + 'comparisons', + ], + lastLoadedAt: '2026-04-01T12:30:00.000Z', + lastLoadSource: 'file', + cliAutoLoadActive: false, + }) + + const newImportedDay = { + ...sampleUsage.daily[0], + date: '2026-03-31', + } + + const usageImportResponse = await fetch(`${baseUrl}/api/usage/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-usage-backup', + version: 1, + data: { + daily: [ + sampleUsage.daily[0], + { + ...sampleUsage.daily[1], + totalCost: 999, + modelBreakdowns: sampleUsage.daily[1].modelBreakdowns.map((entry, index) => ( + index === 0 + ? { ...entry, cost: 997 } + : entry + )), + }, + newImportedDay, + ], + }, + }), + }) + + expect(usageImportResponse.status).toBe(200) + expect(await usageImportResponse.json()).toEqual({ + importedDays: 3, + addedDays: 1, + unchangedDays: 1, + conflictingDays: 1, + totalDays: 6, + }) + + const mergedUsageResponse = await fetch(`${baseUrl}/api/usage`) + expect(mergedUsageResponse.status).toBe(200) + const mergedUsage = await mergedUsageResponse.json() + expect(mergedUsage.daily).toHaveLength(6) + expect(mergedUsage.daily[0].date).toBe('2026-03-31') + expect(mergedUsage.daily.find((day: { date: string }) => day.date === '2026-04-02')?.totalCost).toBeCloseTo(3.94, 6) + + const mergedSettingsResponse = await fetch(`${baseUrl}/api/settings`) + expect(mergedSettingsResponse.status).toBe(200) + const mergedSettings = await mergedSettingsResponse.json() + expect(mergedSettings).toMatchObject({ + theme: 'light', + language: 'de', + defaultFilters: { + viewMode: 'yearly', + datePreset: 'year', + providers: ['Anthropic'], + models: ['Claude Sonnet 4.5'], + }, + sectionVisibility: { + tables: false, + advancedAnalysis: false, + }, + lastLoadSource: 'file', + }) + expect(mergedSettings.sectionOrder.slice(0, 3)).toEqual(['tables', 'metrics', 'insights']) + }) + + it('rejects unrelated JSON and wrong backup types for settings import', async () => { + const invalidPayloadResponse = await fetch(`${baseUrl}/api/settings/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + foo: 'bar', + }), + }) + + expect(invalidPayloadResponse.status).toBe(400) + expect(await invalidPayloadResponse.json()).toEqual({ + message: 'Uploaded JSON is not a settings backup file.', + }) + + const invalidSettingsPayloadResponse = await fetch(`${baseUrl}/api/settings/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-settings-backup', + version: 1, + settings: [], + }), + }) + + expect(invalidSettingsPayloadResponse.status).toBe(400) + expect(await invalidSettingsPayloadResponse.json()).toEqual({ + message: 'The settings backup file has an invalid settings payload.', + }) + + const usageBackupResponse = await fetch(`${baseUrl}/api/settings/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-usage-backup', + version: 1, + data: sampleUsage, + }), + }) + + expect(usageBackupResponse.status).toBe(400) + expect(await usageBackupResponse.json()).toEqual({ + message: 'This is a data backup file, not a settings file.', + }) + + const settingsBackupResponse = await fetch(`${baseUrl}/api/usage/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'ttdash-settings-backup', + version: 1, + settings: { + language: 'en', + }, + }), + }) + + expect(settingsBackupResponse.status).toBe(400) + expect(await settingsBackupResponse.json()).toEqual({ + message: 'This is a settings backup file, not a data file.', + }) + }) + + it('resets persisted settings to defaults via DELETE /api/settings', async () => { + const patchResponse = await fetch(`${baseUrl}/api/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + language: 'en', + theme: 'light', + sectionVisibility: { + tokenAnalysis: false, + }, + sectionOrder: ['tables', 'metrics', 'insights'], + }), + }) + + expect(patchResponse.status).toBe(200) + + const deleteResponse = await fetch(`${baseUrl}/api/settings`, { + method: 'DELETE', + }) + + expect(deleteResponse.status).toBe(200) + expect(await deleteResponse.json()).toMatchObject({ + success: true, + settings: { + language: 'de', + theme: 'dark', + providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + sectionOrder: getDefaultDashboardSectionOrder(), + lastLoadedAt: null, + lastLoadSource: null, + cliAutoLoadActive: false, + }, + }) + + const settingsResponse = await fetch(`${baseUrl}/api/settings`) + expect(settingsResponse.status).toBe(200) + expect(await settingsResponse.json()).toMatchObject({ + language: 'de', + theme: 'dark', + providerLimits: {}, + defaultFilters: DEFAULT_DASHBOARD_FILTERS, + sectionOrder: getDefaultDashboardSectionOrder(), lastLoadedAt: null, lastLoadSource: null, + cliAutoLoadActive: false, + }) + }) + + it('rejects report generation when no usage data exists', async () => { + await fetch(`${baseUrl}/api/usage`, { + method: 'DELETE', + }) + + const response = await fetch(`${baseUrl}/api/report/pdf`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ viewMode: 'daily' }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + message: 'No data available for the report.', + }) + }) + + it('rejects malformed report payloads before report generation starts', 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 response = await fetch(`${baseUrl}/api/report/pdf`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"viewMode":"daily"', + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + message: 'Invalid report request', + }) + }) + + it('starts background servers and stops the selected instance via the CLI', async () => { + const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-test-')) + const backgroundEnv = createCliEnv(backgroundRoot) + const firstPort = await getFreePort() + const secondPort = await getFreePort() + const firstUrl = `http://127.0.0.1:${firstPort}` + const secondUrl = `http://127.0.0.1:${secondPort}` + + try { + const firstStart = await runCli(['--background', '--no-open', '--port', String(firstPort)], { + env: backgroundEnv, + }) + + expect(firstStart.code).toBe(0) + expect(firstStart.output).toContain('TTDash is running in the background.') + expect(firstStart.output).toContain(firstUrl) + await waitForUrlAvailable(firstUrl) + + const secondStart = await runCli(['--background', '--no-open', '--port', String(secondPort)], { + env: backgroundEnv, + }) + + expect(secondStart.code).toBe(0) + expect(secondStart.output).toContain('TTDash is running in the background.') + expect(secondStart.output).toContain(secondUrl) + await waitForUrlAvailable(secondUrl) + + const stopSecond = await runCli(['stop'], { + env: backgroundEnv, + input: '2\n', + }) + + expect(stopSecond.code).toBe(0) + expect(stopSecond.output).toContain('Multiple TTDash background servers are running:') + expect(stopSecond.output).toContain(firstUrl) + expect(stopSecond.output).toContain(secondUrl) + expect(stopSecond.output).toContain(`Stopped TTDash background server: ${secondUrl}`) + + const firstUsageResponse = await fetch(`${firstUrl}/api/usage`) + expect(firstUsageResponse.status).toBe(200) + await waitForServerUnavailable(secondUrl) + + const stopFirst = await runCli(['stop'], { + env: backgroundEnv, + }) + + expect(stopFirst.code).toBe(0) + expect(stopFirst.output).toContain(`Stopped TTDash background server: ${firstUrl}`) + await waitForServerUnavailable(firstUrl) + } finally { + await stopAllBackgroundServers(backgroundEnv) + rmSync(backgroundRoot, { recursive: true, force: true }) + } + }, 45_000) + + it('keeps both instances in the registry when background starts happen concurrently', async () => { + const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-parallel-test-')) + const backgroundEnv = createCliEnv(backgroundRoot) + const firstPort = await getFreePort() + const secondPort = await getFreePort() + const firstUrl = `http://127.0.0.1:${firstPort}` + const secondUrl = `http://127.0.0.1:${secondPort}` + + try { + const [firstStart, secondStart] = await Promise.all([ + runCli(['--background', '--no-open', '--port', String(firstPort)], { + env: backgroundEnv, + }), + runCli(['--background', '--no-open', '--port', String(secondPort)], { + env: backgroundEnv, + }), + ]) + + expect(firstStart.code).toBe(0) + expect(secondStart.code).toBe(0) + expect(firstStart.output).toContain('TTDash is running in the background.') + expect(secondStart.output).toContain('TTDash is running in the background.') + + await waitForUrlAvailable(firstUrl) + await waitForUrlAvailable(secondUrl) + + const registry = readBackgroundRegistry(backgroundRoot) + expect(registry).toHaveLength(2) + expect(registry.map(instance => instance.url).sort()).toEqual([firstUrl, secondUrl].sort()) + } finally { + await stopAllBackgroundServers(backgroundEnv) + rmSync(backgroundRoot, { recursive: true, force: true }) + } + }, 45_000) + + it('prunes stale background entries that point to a live non-matching process', async () => { + const backgroundRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-background-stale-test-')) + const backgroundEnv = createCliEnv(backgroundRoot) + + try { + const runtimeResponse = await fetch(`${baseUrl}/api/runtime`) + expect(runtimeResponse.status).toBe(200) + const runtime = await runtimeResponse.json() + + writeBackgroundRegistry(backgroundRoot, [{ + id: 'stale-entry', + pid: child?.pid, + port: runtime.port, + url: baseUrl, + host: '127.0.0.1', + startedAt: new Date().toISOString(), + logFile: null, + }]) + + const stopResult = await runCli(['stop'], { + env: backgroundEnv, + }) + + expect(stopResult.code).toBe(0) + expect(stopResult.output).toContain('No running TTDash background servers found.') + + const usageResponse = await fetch(`${baseUrl}/api/usage`) + expect(usageResponse.status).toBe(200) + expect(readBackgroundRegistry(backgroundRoot)).toEqual([]) + } finally { + rmSync(backgroundRoot, { recursive: true, force: true }) + } + }) + + it('keeps explicit runtime dir overrides independent', async () => { + const runtimeRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-runtime-dir-test-')) + const explicitConfigDir = path.join(runtimeRoot, 'explicit-config') + + const expectedPlatformPaths = process.platform === 'darwin' + ? { + dataFile: path.join(runtimeRoot, 'Library', 'Application Support', 'TTDash', 'data.json'), + settingsFile: path.join(explicitConfigDir, 'settings.json'), + cacheDir: path.join(runtimeRoot, 'Library', 'Caches', 'TTDash'), + } + : process.platform === 'win32' + ? { + dataFile: path.join(runtimeRoot, 'AppData', 'Local', 'TTDash', 'data.json'), + settingsFile: path.join(explicitConfigDir, 'settings.json'), + cacheDir: path.join(runtimeRoot, 'AppData', 'Local', 'TTDash', 'Cache'), + } + : { + dataFile: path.join(runtimeRoot, 'data', 'ttdash', 'data.json'), + settingsFile: path.join(explicitConfigDir, 'settings.json'), + cacheDir: path.join(runtimeRoot, 'cache', 'ttdash'), + } + + let standaloneServer: Awaited> | null = null + + try { + standaloneServer = await startStandaloneServer({ + root: runtimeRoot, + envOverrides: { + TTDASH_CONFIG_DIR: explicitConfigDir, + }, + }) + + 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(standaloneServer.getOutput()).toContain(`Data File: ${expectedPlatformPaths.dataFile}`) + expect(standaloneServer.getOutput()).toContain(`Settings File: ${expectedPlatformPaths.settingsFile}`) + expect(existsSync(expectedPlatformPaths.dataFile)).toBe(true) + expect(existsSync(expectedPlatformPaths.settingsFile)).toBe(true) + expect(existsSync(path.join(expectedPlatformPaths.cacheDir, 'npx-cache'))).toBe(true) + expect(existsSync(path.join(explicitConfigDir, 'data.json'))).toBe(false) + } finally { + if (standaloneServer) { + await stopProcess(standaloneServer.child) + } + rmSync(runtimeRoot, { recursive: true, force: true }) + } }) + + it('fails cleanly when port 65535 is busy instead of retrying to 65536', async () => { + const occupiedPortServer = createServer() + const cliRoot = mkdtempSync(path.join(tmpdir(), 'ttdash-port-limit-test-')) + + try { + await new Promise((resolve, reject) => { + occupiedPortServer.once('error', reject) + occupiedPortServer.listen(65535, '127.0.0.1', () => resolve()) + }) + + const result = await runCli(['--port', '65535'], { + env: createCliEnv(cliRoot), + }) + + expect(result.code).toBe(1) + expect(result.output).toContain('No free port found (65535-65535)') + expect(result.output).not.toContain('trying 65536') + } finally { + await new Promise(resolve => occupiedPortServer.close(() => resolve(undefined))) + rmSync(cliRoot, { recursive: true, force: true }) + } + }, 20_000) }) diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 5311ef9..c94d61e 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { computeMetrics, computeMovingAverage } from '@/lib/calculations' +import { computeMetrics, computeMovingAverage, computeProviderMetrics } from '@/lib/calculations' import { aggregateToDailyFormat, filterByProviders } from '@/lib/data-transforms' import { dashboardFixture } from '../fixtures/usage-data' @@ -51,11 +51,89 @@ describe('dashboard analytics', () => { expect(metrics.topProvider?.cost).toBe(17) expect(metrics.topProvider?.share).toBeCloseTo(56.6667, 3) expect(metrics.avgDailyCost).toBe(7.5) + expect(metrics.avgModelsPerEntry).toBeCloseTo(1.75, 3) expect(metrics.avgRequestsPerDay).toBeCloseTo(4.25, 3) expect(metrics.weekendCostShare).toBeCloseTo(16.6667, 3) expect(metrics.cacheHitRate).toBeCloseTo(8, 3) }) + it('disables week-over-week comparisons for aggregated monthly views while keeping per-entry model averages stable', () => { + const monthly = aggregateToDailyFormat(dashboardFixture, 'monthly') + const metrics = computeMetrics(monthly) + + expect(metrics.avgModelsPerEntry).toBeCloseTo(2.5, 3) + expect(metrics.weekOverWeekChange).toBeNull() + }) + + it('counts provider activity days once per date even when multiple provider models are present', () => { + const providerMetrics = computeProviderMetrics([ + { + date: '2026-04-01', + inputTokens: 170, + outputTokens: 90, + cacheCreationTokens: 20, + cacheReadTokens: 10, + thinkingTokens: 0, + totalTokens: 290, + totalCost: 11, + requestCount: 5, + modelsUsed: ['gpt-5.4', 'gpt-5'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 20, + cacheReadTokens: 5, + thinkingTokens: 0, + cost: 7, + requestCount: 3, + }, + { + modelName: 'gpt-5', + inputTokens: 70, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 5, + thinkingTokens: 0, + cost: 4, + requestCount: 2, + }, + ], + }, + { + date: '2026-04-02', + inputTokens: 90, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 130, + totalCost: 5, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 90, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 5, + requestCount: 2, + }, + ], + }, + ]) + + expect(providerMetrics.get('OpenAI')).toMatchObject({ + cost: 16, + requests: 7, + days: 2, + }) + }) + it('computes moving averages with leading gaps instead of partial windows', () => { expect(computeMovingAverage([1, 2, 3, 4], 3)).toEqual([ undefined, diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts new file mode 100644 index 0000000..b45b144 --- /dev/null +++ b/tests/unit/report-utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { dashboardFixture } from '../fixtures/usage-data' + +describe('report utils', () => { + it('keeps aggregated trend metrics disabled for monthly report data', async () => { + const { buildReportData } = await import('../../server/report/utils.js') + + const report = buildReportData(dashboardFixture, { + viewMode: 'monthly', + language: 'en', + }) + + expect(report.meta.filterSummary.viewMode).toBe('Monthly view') + expect(report.metrics.weekOverWeekChange).toBeNull() + }) +})