diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9016800..bd77260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 24 cache: npm @@ -31,11 +31,20 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Check formatting + run: npm run format:check + + - name: Run ESLint + run: npm run lint + + - name: Run TypeScript checks + run: ./node_modules/.bin/tsc --noEmit + - name: Run unit and integration tests with coverage run: npm run test:unit:coverage - name: Build production bundle - run: npm run build + run: npm run build:app - name: Verify packed npm artifact run: npm run verify:package @@ -48,7 +57,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-reports if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3bca1e..cfd36c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ concurrency: jobs: release: + environment: release runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -27,15 +28,16 @@ jobs: steps: - name: Create release app token id: app-token - uses: actions/create-github-app-token@v2.1.4 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} + permission-contents: write repositories: ttdash - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 ref: main @@ -78,7 +80,7 @@ jobs: fi - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 24 cache: npm @@ -115,11 +117,20 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Check formatting + run: npm run format:check + + - name: Run ESLint + run: npm run lint + + - name: Run TypeScript checks + run: ./node_modules/.bin/tsc --noEmit + - name: Run unit and integration tests with coverage run: npm run test:unit:coverage - name: Build production bundle - run: npm run build + run: npm run build:app - name: Verify packed npm artifact run: npm run verify:package @@ -131,9 +142,9 @@ jobs: run: npm run test:e2e:ci - name: Set up Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: latest + bun-version: 1.3.4 - name: Create release commit and tag run: | diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ec712f1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +coverage/ +dist/ +node_modules/ +playwright-report/ +test-results/ +.playwright-mcp/ +.tmp-playwright/ +.tmp-smoke-*/ +package-lock.json +bun.lock diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..db1bfeb --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "singleQuote": true, + "semi": false, + "trailingComma": "all", + "printWidth": 100, + "overrides": [ + { + "files": ["server.js", "usage-normalizer.js", "scripts/**/*.js", "server/**/*.js"], + "options": { + "semi": true + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index c56d481..467def2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,35 @@ # Repository Guidelines ## Project Structure & Module Organization + `src/` contains the Vite frontend. Use `components/` for UI, grouped by `ui/`, `layout/`, `cards/`, `charts/`, `tables/`, and `features/`. Put shared logic in `lib/`, reusable stateful logic in `hooks/`, and TypeScript shapes in `types/`. Static assets live in `public/`. The production bundle is generated into `dist/`. `server.js` serves `dist/`, exposes `/api`, and handles local data import. ## Build, Test, and Development Commands + Install dependencies with `npm install`. - `npm run dev`: starts the Vite dev server on port `5173`. - `node server.js`: runs the local API/static server on port `3000`. -- `npm run build`: creates the production bundle in `dist/`. +- `npm run build`: runs `prettier --check`, `eslint`, and then creates the production bundle in `dist/`. +- `npm run build:app`: creates the production bundle in `dist/` without the lint/format gate. +- `npm run verify`: runs the main local quality gate (`format:check`, `lint`, `tsc --noEmit`, unit tests, `build:app`, and `verify:package`). - `npm run preview`: serves the built frontend for a production-style check. - `npm start`: runs the packaged server entrypoint. During development, keep `npm run dev` and `node server.js` running in separate terminals so `/api` requests resolve correctly. ## Coding Style & Naming Conventions + 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 -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. + +Automated tests are part of the repo now. Before opening a PR, run `npm run verify` and `npm run test:e2e`. If you want the same gate the release workflow uses, also run `npm run test:unit:coverage`. 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. ## Configuration Tips + Use `PORT=8080 node server.js` to override the default server port. Do not commit generated `dist/` output or local usage data unless the change explicitly requires it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 128ccec..42b24c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,35 @@ ## [Unreleased] +## [6.1.6] - 2026-04-13 + +### Added + +- **Striktere Code-Quality-Gates** — ESLint, typed TypeScript-ESLint-Regeln und Prettier sind jetzt vollständig im Repo eingerichtet und als verbindliche Prüfungen in den lokalen Verify-Pfad sowie die GitHub-Workflows integriert +- **Gezielte Infrastruktur-Tests** — neue Unit-Tests decken die Server-Helfer für Runner-Auflösung und Portsuche sowie die gemeinsame Modellnormalisierung und die Limits-Badge-Logik explizit ab + +### Improved + +- **TypeScript-Hardening** — die Compiler-Konfiguration ist jetzt deutlich strenger und nutzt zusätzliche Best-Practice-Flags wie `noImplicitReturns`, `noImplicitOverride`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax` und weitere Konsistenz-Gates +- **Konsistente Modellnormalisierung** — UI und PDF-/Report-Pfad verwenden jetzt dieselbe datengetriebene Normalisierung und Provider-Zuordnung für aktuelle `toktrack`-Modellfamilien wie Claude, GPT, Gemini, Codex, OpenAI-`o` und OpenCode +- **Maintainer- und Release-Tooling** — README, Contribution-, Release- und Agent-Dokumentation wurden an die neuen Lint-, Format- und Verify-Workflows angepasst; GitHub Actions nutzt jetzt zusätzlich SHA-gepinnte Actions, minimale App-Token-Rechte und ein dediziertes Release-Environment +- **Konfigurationsklarheit** — die Vitest-Konfiguration dokumentiert jetzt explizit, warum die asynchrone Vite-Config vor dem Mergen manuell aufgelöst wird + +### Fixed + +- **Unbenutzter Code und Compiler-Warnpfade** — ungenutzte Imports, Helpers und Parameter wurden entfernt oder bereinigt, sodass die neuen Compiler- und Lint-Gates auf dem gesamten Repo sauber greifen +- **Server-Runner und Portsuche** — die Windows-/Cross-Platform-Runner-Auflösung ist weniger dupliziert, und die Portsuche für den lokalen Server läuft jetzt iterativ statt rekursiv +- **Kleine UI-Wartbarkeitsprobleme** — redundante Drill-Down-Modal-Logik und schwer lesbare Badge-Bedingungen in der Limits-Sektion wurden vereinfacht, ohne das Verhalten zu ändern + ## [6.1.5] - 2026-04-12 ### Added + - **Report insight callouts** — the Typst PDF report now highlights key findings such as sparse data coverage, provider concentration, cache contribution, and the strongest rolling 7-day cost window - **Report chart test coverage** — dedicated unit tests now cover SVG chart formatting, localized axis rendering, and long-label truncation for the Typst report assets ### Improved + - **GitHub Actions Node 24 readiness** — the release workflow now pins `actions/create-github-app-token` to `v2.1.4`, aligning the release path with the current Node 24-compatible action runtime guidance - **Typst report structure** — the PDF layout now uses a clearer executive-summary flow with localized headings, prepared report text blocks, and more robust section rendering for filtered report scenarios - **Report localization and semantics** — peak-period labeling, interpretation text, filter summaries, and report-specific strings are now more precise and consistently localized in both German and English @@ -18,6 +40,7 @@ - **Build and test config loading** — the Vite version injection path now reads `package.json` asynchronously, and the Vitest config resolves the async Vite config cleanly before merging test settings ### Fixed + - **Report temp-file cleanup** — server-side PDF generation now cleans up Typst working directories internally even when compilation fails - **PDF response lifecycle** — the report API now returns the compiled PDF from memory instead of exposing temporary file paths, avoiding leaked temp directories and simplifying cleanup - **Chart formatting consistency** — token-axis labels and font fallbacks no longer depend on hardcoded locale/font assumptions that caused inconsistent PDF output across environments @@ -29,11 +52,13 @@ ## [6.1.4] - 2026-04-11 ### Added + - **GitHub-driven release flow** — releases can now be started manually from GitHub Actions with a target version input, instead of relying on a locally created tag on `main` - **CI release gate** — the release workflow now verifies that the latest `CI` run for the current `main` commit completed successfully before any version bump, tag, or npm publish step begins - **Release app verification** — a dedicated GitHub API helper now validates the `CI` precondition directly from the workflow, so release gating stays tied to the exact `main` SHA ### Improved + - **Single human-managed version source** — the frontend app version is now injected from `package.json` at build time instead of being maintained as a second manual version constant - **Protected-branch compatibility** — the release workflow now uses the dedicated `ttdash-release` GitHub App token for checkout, push, tag creation, and GitHub release creation, so the release path works cleanly with branch rules and ruleset bypasses - **Release recovery behavior** — rerunning a failed release with the same version now resumes cleanly when the version bump commit, tag, or npm publication already exists @@ -42,12 +67,14 @@ ## [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 @@ -55,6 +82,7 @@ - **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 @@ -62,164 +90,199 @@ ## [6.0.11] - 2026-04-10 ### Fixed + - **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** — 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 project context** — the documentation now points explicitly to `toktrack` as the primary data source and credits `mag123c` ## [6.0.9] - 2026-04-09 ### Added + - **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 + - **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 consistency** — lockfiles and published runtime contents now stay aligned so builds and installs remain reproducible ## [6.0.8] - 2026-04-08 ### Added + - **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 + - **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 ### Added + - **Cache-Hit-Rate in der Request-Analyse** — neue kombinierte Visualisierung mit Zeitverlauf links und Modell-Snapshot rechts, vollständig filterkompatibel und mit denselben Aufbauanimationen wie die übrigen Diagramme ### Improved + - **Modellabdeckung im Cache-Hit-Rate-Verlauf** — alle aktiven Modelle, inklusive `GPT-5` und `GPT-5.4`, erscheinen jetzt zuverlässig in der Zeitreihen-Legende und im Diagramm - **Snapshot-Animation & Tooltip-Klarheit** — horizontale Balken bauen sich sauber von links nach rechts auf; Tooltips im Zeitverlauf blenden irrelevante `0.0%`-Serien aus und zeigen die aktiven Modelle lesbarer an ## [6.0.6] - 2026-04-08 ### Added + - **Plattformgerechte Persistenz** — Nutzungsdaten und App-Einstellungen liegen jetzt in OS-konformen User-Verzeichnissen statt im Projekt- bzw. Installationsordner; bestehende `data.json` wird beim Start automatisch migriert ### Improved + - **Stabile Settings über Ports hinweg** — Sprache, Theme und Provider-Limits werden serverseitig in lokalen App-Settings gespeichert und bleiben dadurch auch bei automatischem Portwechsel erhalten - **Robustere Dateischreibvorgänge** — `data.json` und `settings.json` werden atomar geschrieben, damit lokale Persistenz bei Abbruch oder Neustart nicht inkonsistent wird ## [6.0.5] - 2026-04-04 ### Improved + - **Dependency-Updates** — `@tanstack/react-query`, `i18next` und `react-i18next` sind auf die jeweils aktuellen Registry-Versionen angehoben - **Kompatibilitätsprüfung** — Dashboard-Build sowie Browser-Smoketests für Jahresansicht, Filter, Datepicker, Command Palette und Sprachwechsel wurden nach dem Upgrade erneut verifiziert ## [6.0.4] - 2026-04-04 ### Added + - **Globaler Filter-Reset** — der Filterstatus enthält jetzt einen `Reset all`-Button, und die Command Palette bietet eine direkte Aktion zum Zurücksetzen aller Filter auf den Default-Zustand ### Improved + - **Eigener Datums-Kalender** — der Zeitraumfilter nutzt jetzt einen dunklen, portalbasierten Kalender statt des nativen Browser-Datepickers, damit Darstellung und Stacking im Dark Mode konsistent bleiben - **Datepicker-Stabilität** — der Kalender liegt jetzt zuverlässig über dem Dashboard und wird nicht mehr von nachfolgenden Sektionen oder Animationen überlagert ## [6.0.3] - 2026-04-04 ### Added + - **Dashboard-Mehrsprachigkeit** — das Dashboard und der PDF-Report unterstützen jetzt Deutsch und Englisch auf Basis von `i18next` und `react-i18next` - **Sprachwechsel in der Command Palette** — `cmd+k` enthält jetzt direkte Aktionen zum Wechseln zwischen Deutsch und Englisch ### Improved + - **Vollständige EN-Abdeckung** — Forecast, Cache-ROI, Vergleiche, Anomalien, Tabellen, Help-Panel, Auto-Import und ergänzende Dashboard-Stat-Karten sind vollständig in die neue Übersetzungsstruktur migriert - **Locale-sensitive UI-Formate** — Datums-, Zahlen- und Wochentagsdarstellungen reagieren jetzt konsistent auf die aktive Sprache ## [6.0.2] - 2026-04-03 ### Added + - **Limits & Subscriptions** — neues Provider-Limits-Modal mit lokaler Persistenz, Limits-Button im Header, eigener Dashboard-Sektion und Command-Palette-Einträgen für Konfiguration und Navigation ### Improved + - **Provider-Limits Visualisierung** — Budget- und Subscription-Status werden jetzt pro Anbieter in klar getrennten, animierten Tracks mit Break-even- bzw. Limit-Markierung dargestellt ### Fixed + - **Jahresansicht & Filterwechsel** — Tages-, Monats- und Jahresansicht bleiben bei Presets sowie Anbieter-, Modell- und Datumsfiltern stabil; Hook-Reihenfolgen in Analyse- und Forecast-Komponenten sind konsistent - **Provider-Limits Tooltip-Clipping** — Info-Labels im Limits-Dialog werden am oberen Rand nicht mehr abgeschnitten ## [6.0.1] - 2026-04-03 ### Added + - **PDF-Report in der Command Palette** — `cmd+k` enthält jetzt eine direkte Aktion zum Generieren des aktuell gefilterten PDF-Reports ### Fixed + - **Request-Qualität Info-Tooltip** — das Info-Label in der Karte wird nicht mehr am oberen Rand abgeschnitten - **Gemeinsame Report-Aktion** — Toolbar-Button und Command Palette verwenden jetzt denselben Exportpfad inklusive Ladezustand und Toast-Feedback ## [6.0.0] - 2026-04-03 ### Added + - **Typst-Report-Pipeline** — PDF-Reports werden jetzt serverseitig mit Typst kompiliert, inklusive sauberem Layout, eingebetteten SVG-Charts und filterkonsistenten Reportdaten statt DOM-Screenshot-Export - **Report-Smoke-Test** — neue Prüfmatrix deckt Tages-, Monats- und Jahresansicht sowie kombinierte Provider-, Modell-, Monats- und Datumsfilter für die PDF-Generierung ab ### Improved + - **Filtertreue im PDF** — Report-Downloads übernehmen jetzt dieselben aktiven UI-Filter wie das Dashboard, inklusive Monatsauswahl, Datumsbereich, Providern und Modellen - **Mobile/Responsive Report-Flow** — der Report-Button und der Downloadpfad funktionieren jetzt auch unter enger Viewport-Breite stabil ### Fixed + - **PDF-Layoutfehler** — Tabellenköpfe, Filterdarstellung und Einpunkt-Charts im Report verhalten sich jetzt robust auch bei extrem kleinen oder stark gefilterten Datensätzen - **Typst-CLI Fallback** — Systeme ohne installierte Typst-CLI erhalten eine klare macOS-Hinweismeldung mit `brew install typst` ## [5.3.6] - 2026-04-02 ### Added + - **Erweiterte Command Palette** — `cmd+k` bietet jetzt zusätzliche Sprungziele, Ansichtswechsel, Zeitraum-Presets sowie direkte Anbieter- und Modell-Filterbefehle auf Basis der aktuell verfügbaren Daten - **Kontextsprünge** — direkte Navigation zu `Heute` und `Monat`, wenn diese Bereiche im aktuellen Filterzustand vorhanden sind ### Improved + - **Favicon-Auslieferung** — Root-, `public/`- und `dist/`-Icons sind jetzt synchron; HTML enthält zusätzliche `shortcut icon`- und `apple-touch-icon`-Links für robustere Browser-Erkennung ## [5.3.5] - 2026-04-02 ### Improved + - **Filter-Konsistenz über alle Ansichten** — Tages-, Monats- und Jahressicht basieren jetzt auf derselben vollständig gefilterten Tagesbasis, damit Anbieter-, Modell- und Zeitraumfilter in KPIs, Header, Vergleichskarten und Tabellen übereinstimmen - **Favicon & App-Branding** — neues `TTDash`-Monogramm als optimiertes SVG/PNG mit klarerer Wiedererkennbarkeit und besserer Lesbarkeit bei kleinen Größen - **Release-Output im Terminal** — Installer und Server-Start zeigen die aktuelle App-Version jetzt dynamisch direkt aus `package.json` ### Fixed + - **Heute-/Monat-Karten bei Kombinationsfiltern** — Bereiche mit aktiven Anbieter-, Modell- und Datumsfiltern greifen nicht mehr auf unfiltrierte Rohdaten zurück - **Header-Zeitraum & Periodenvergleich** — Datumsbadge und Vergleichswerte folgen jetzt derselben Filterbasis wie die restlichen Dashboard-Metriken ## [5.3.4] - 2026-04-02 ### Added + - **Dashboard Insights** — neue verdichtete Analyse-Sektion mit Provider-Dominanz, Modell-Konzentration, Kosten- und Request-Ökonomie sowie Aktivitätsmustern - **Responsive Tabellen-Karten** — `Recent Days` und `Model Efficiency` liefern auf kleinen Screens jetzt echte Card-Layouts statt primär horizontaler Scrollflächen ### Improved + - **Dashboard-Informationsdichte** — KPI-Karten, Chart-Untertitel und Tabellen-Summaries zeigen mehr Kontext, abgeleitete Kennzahlen und klarere Hilfstexte - **Unbekannte Modellfamilien** — neue `toktrack`-Modelle werden robuster normalisiert, erhalten deterministische Farben und bleiben in Filtern, Charts und Tooltips sauber lesbar - **Zahlenformatierung & Tooltips** — lange Werte werden kompakt dargestellt; Tooltips zeigen exakte Zahlen, Labels und zusätzliche Insights - **Responsive Layouts** — Header, Filter-Bar, Karten, Zoom-Ansichten und Tabellen verhalten sich stabiler bei Resize, Tablet-Breite und Mobile ### Fixed + - **Windows Auto-Import** — Prozessstart für `toktrack`, `npx.cmd` und `bunx` ist unter Windows robuster, damit der Auto-Upload nicht mehr am `spawn`-Pfad scheitert - **Expanded Donut-Charts** — Donuts sitzen im Zoom-Dialog tiefer, nutzen die verfügbare Fläche besser und kollidieren weniger mit Legenden - **Request-Ökonomie ohne Request-Daten** — bei fehlenden `requestCount`-Feldern zeigt das UI jetzt `n/v` statt irreführender Nullwerte - **Numerische Ausreißer im UI** — rohe lange Float-Werte werden nicht mehr ungefiltert im Dashboard angezeigt - **Heuristik-Hinweise für Preis-Fallbacks** — Cache-ROI kennzeichnet fehlende Preisdefinitionen für unbekannte Modelle explizit statt stillschweigend - **Erweiterbarkeit für neue Anbieter** — Provider-Erkennung deckt zusätzliche Familien wie `xAI`, `Meta`, `Cohere`, `Mistral`, `DeepSeek` und `Alibaba` besser ab + ## [5.3.3] - 2026-04-02 ### Improved + - **Performance-Optimierungen** — PDF-Export und schwere Modals werden jetzt lazy geladen; Datenpfade für gleitende Durchschnitte, Metriken und Filter wurden effizienter gemacht - **Bundle-Splitting** — Vendor-Code ist in getrennte Chunks für React, Recharts, Motion und UI aufgeteilt, damit das Dashboard schneller initial lädt ### Fixed + - **Dashboard-Renderpfad** — Datenquellen-Initialisierung erfolgt nicht mehr während des Renderns, wodurch unnötige Renders und React-Warnungen vermieden werden - **PDF-Export Ladezustand** — Export-Button bleibt nach Abschluss nicht mehr fälschlich im aktiven Zustand hängen - **Server-Sicherheitsheader** — lokale Responses liefern jetzt grundlegende Schutz-Header wie `nosniff`, `DENY` und `same-origin` @@ -227,6 +290,7 @@ ## [5.3.2] - 2026-04-02 ### Added + - **Toktrack-Migration & Rebranding** — Dashboard, Paket und UI laufen jetzt unter `TTDash` mit `toktrack` als primärem Datenformat; Legacy-`ccusage`-JSON bleibt kompatibel - **Anbieter-Filter** — Filterung nach `OpenAI`, `Anthropic`, `Google` usw. mit passender Einschränkung der sichtbaren Modelle - **Anbieter-Badges** — farbige Provider-Labels in Tabellen, Drill-downs und Filtern für bessere Modell-Zuordnung @@ -234,6 +298,7 @@ - **Bun-aware Installation** — `install.sh` und `install.bat` nutzen Bun, wenn verfügbar, sonst npm ### Improved + - **Auto-Import Runner-Auswahl** — nutzt zuerst lokales `toktrack`, dann `bunx`, dann `npx --yes toktrack`; Statusmeldungen zeigen den tatsächlich verwendeten Pfad - **Monatsprognose** — Forecast basiert jetzt auf Kalender-Tageskosten, geglätteter Run-Rate und defensiverer Volatilitätsbewertung statt einfacher linearer Regression - **Kumulative Monatsprojektion** — verwendet dieselbe Shared-Forecast-Logik wie die Prognose-Karte @@ -241,6 +306,7 @@ - **Lokaler App-Start** — öffnet beim Start aus dem Terminal direkt den Browser ### Fixed + - **Heatmap-Tooltip** — Hover-Labels sitzen wieder direkt über der Zelle statt viewport-versetzt - **Dialog-A11y** — fehlende Beschreibungen für Radix-Dialoge ergänzt - **Favicon & Tab-Titel** — Branding auf `TTDash` aktualisiert @@ -249,12 +315,14 @@ ## [5.3.1] - 2026-04-01 ### Fixed + - **Datum in Heute/Monat-Sektion falsch** — `toISOString()` lieferte UTC-Datum statt Lokalzeit, wodurch zwischen Mitternacht und 02:00 MESZ das gestrige Datum angezeigt wurde. Betraf: Heute-KPIs, Monats-KPIs, Heatmap-Markierung, Streak-Berechnung, Datumsfilter-Presets, PDF/CSV-Dateinamen - Neue `toLocalDateStr()`, `localToday()`, `localMonth()` Hilfsfunktionen ersetzen alle 7 `toISOString().slice()`-Aufrufe durch korrekte lokale Datumsberechnung ## [5.3.0] - 2026-03-31 ### Fixed + - **Monatsansicht & Jahresansicht komplett überarbeitet** — alle Metriken, Diagramme und Tabellen zeigen jetzt korrekte Daten in der Monats- und Jahresansicht: - **Aktive Tage** — zeigt die tatsächliche Anzahl aktiver Tage (vorher: 1 pro Monat/Jahr wegen fehlender Aggregation) - **Ø Kosten** — korrekte Durchschnittsberechnung pro Tag (vorher: durch Anzahl Perioden geteilt statt Anzahl Tage) @@ -269,6 +337,7 @@ - **PeriodComparison Monat-Bug** — `setMonth()` Overflow behoben: März 31 → Feb 31 → März 3 (klassischer JS-Date-Bug bei Monaten mit weniger Tagen) ### Technical + - Neues `_aggregatedDays`-Feld in `DailyUsage` trackt die Anzahl aggregierter Tage pro Eintrag - `aggregateToDailyFormat()` setzt `date` auf Period-Key ("2026-03" / "2026") statt erstes Tagesdatum - `computeMetrics()` und `computeModelCosts()` nutzen `_aggregatedDays` für korrekte Berechnungen @@ -279,35 +348,42 @@ ## [5.2.1] - 2026-03-31 ### Fixed + - **install.sh `-e` Ausgabe** — `echo -e` durch `printf` ersetzt, damit das Script auch mit `sh install.sh` korrekt funktioniert (POSIX-Shell kennt `echo -e` nicht) ## [5.2.0] - 2026-03-31 ### Added + - **Monats-KPIs** — neue Sektion unter "Heute" zeigt 6 Kennzahlen des laufenden Monats: Kosten (mit Trend vs. Vormonat), Tokens, aktive Tage/Abdeckung, Modelle, $/1M Tokens, Cache-Hit-Rate. Wird automatisch ausgeblendet wenn keine Daten für den aktuellen Monat vorhanden sind ## [5.1.1] - 2026-03-31 ### Fixed + - **Browser Tab Titel** — zeigt jetzt "CCUsage — Claude Code Dashboard" statt "localhost:3000" ## [5.1.0] - 2026-03-31 ### Added + - **Datenquellen-Badge im Header** — zeigt woher die Daten stammen: "Gespeichert" (grau, bei App-Start), "Auto-Import · HH:MM" (grün, nach Import), oder "dateiname.json · HH:MM" (blau, nach Upload). Wird bei Löschen zurückgesetzt - **Graceful Shutdown** — Server fährt bei Ctrl+C (SIGINT) und kill (SIGTERM) sauber herunter, schliesst offene Verbindungen ordentlich mit 3s Force-Exit Fallback ### Improved + - **Header Responsive** — 2-Zeilen-Layout statt 1-Zeile: Zeile 1 = Branding + Meta-Badges + Utility-Icons, Zeile 2 = Action-Buttons. Funktioniert sauber auf Desktop (1440px), Tablet (768px) und Mobile (375px) ## [5.0.1] - 2026-03-31 ### Fixed + - **7-Tage Ø Linien unsichtbar** — Recharts 3 Line-Drawing-Animation überschrieb `stroke-dasharray` auf gestrichelten Linien, wodurch das Dash-Pattern zerstört wurde. Fix: `isAnimationActive={false}` auf allen 10 gestrichelten MA7/Prognose-Linien in 6 Chart-Komponenten ## [5.0.0] - 2026-03-31 ### Added + - **Token-Effizienz Chart** — $/1M Tokens über die Zeit mit 7-Tage Ø und Durchschnitts-Referenzlinie, zeigt ob Kosten-Optimierung (Cache, Modell-Wahl) wirkt - **Modell-Mix Chart** — Stacked percentage area chart zeigt Modell-Nutzungsanteile über die Zeit, visualisiert Migration-Muster (z.B. Wechsel von Opus 4.5 zu 4.6) - **Aktiv-Streak** — Header zeigt konsekutive aktive Tage als 🔥-Badge @@ -320,6 +396,7 @@ - **install.bat** — Windows-kompatibles Installationsscript ### Improved + - **FilterBar** — aktiver Preset-Button (7T, 30T, etc.) visuell hervorgehoben, Reset bei Filterwechsel - **SectionHeaders** — linker Akzent-Border (`border-l-2 border-primary/40`) für visuelle Hierarchie - **MetricCard Trends** — Badges mit farbigem Hintergrund-Pill statt reinem Text @@ -339,6 +416,7 @@ - **TodayMetrics** — "$/1M Tokens" statt redundantem "Top Modell Kosten", korrektes Icon ### Fixed + - **Keyboard Shortcuts** — nicht-implementierte Shortcuts (⌘E, ⌘U, ⌘D, ⌘↑) aus Hilfe entfernt, die mit Browser-Shortcuts kollidierten - **CustomTooltip Total** — MA7-Durchschnittswerte werden nicht mehr fälschlich ins Total eingerechnet - **Token-Linien Dash-Pattern** — 7-Tage Ø Linien in Tokens-Charts nutzen jetzt `"5 5"` wie Kosten-Charts @@ -346,6 +424,7 @@ ## [4.0.0] - 2026-03-31 ### Added + - **Auto-Import** — one-click data import directly from Claude Code usage logs via `ccusage` programmatic API, no manual file export needed - SSE streaming with real-time progress in a terminal-style modal - Fetches latest model pricing from LiteLLM for accurate cost calculation @@ -356,6 +435,7 @@ - **Install script** — `install.sh` for one-command setup (install, build, global install) ### Changed + - `ccusage` is now a production dependency instead of requiring external installation - EmptyState now shows Auto-Import as primary action, manual upload as secondary - Server no longer needs `child_process` for data import (uses programmatic API) @@ -363,6 +443,7 @@ ## [3.1.0] - 2026-03-31 ### Upgraded + - **React** 18.3.1 → 19.2.4 - **react-dom** 18.3.1 → 19.2.4 - **TypeScript** 5.9.3 → 6.0.2 @@ -376,6 +457,7 @@ - **@types/react-dom** 18.3.7 → 19.2.3 ### Changed + - Removed deprecated `baseUrl` from tsconfig.json (TypeScript 6 requirement) - Renamed deprecated lucide icons: `HelpCircle` → `CircleHelp`, `AlertTriangle` → `TriangleAlert`, `Loader2` → `LoaderCircle`, `BarChart3` → `ChartBar` - Adapted Recharts 3 type changes (`activeTooltipIndex`, deprecated `Cell`) @@ -385,6 +467,7 @@ ## [3.0.0] - 2026-03-31 ### Added + - **Date Range Filter** with preset buttons (7T, 30T, Monat, Jahr, Alle) - **Token-Analyse Redesign** — two separate charts for Cache and I/O tokens with independent Y-axes, solving the scale problem where Cache Read (4.5B) made Input/Output (3.2M) invisible - **Per-Type 7-Tage Durchschnitt** for all four token types (Cache Read, Cache Write, Input, Output) @@ -405,6 +488,7 @@ - **Light Mode** fully polished alongside dark mode ### Fixed + - **PDF Export** — resolved html2canvas crash with Tailwind CSS v4 `oklab()` colors via canvas-based RGB conversion - **Model Filter** — now correctly filters costs within each day (previously showed all models' costs if any matched) - **MA7 Line invisible** — switched from `AreaChart` to `ComposedChart` so `` components render correctly alongside `` @@ -424,6 +508,7 @@ - **Gradient ID conflicts** — unique IDs via `useId()` prevent SVG conflicts in zoom mode ### Changed + - **Forecast colors** — Prognose line is teal (distinct from blue Ist-Kosten), Konfidenzband is transparent teal - **CostByModelOverTime title** — removed misleading "7-Tage Ø" since chart shows individual model lines - **Token chart layout** — split into Cache Tokens (top) + I/O Tokens (bottom) with summary tiles @@ -434,6 +519,7 @@ ## [2.0.0] - 2026-03-30 ### Added + - Complete frontend rebuild with Vite + React + TypeScript + Tailwind CSS v4 - Interactive charts with Recharts (cost over time, model breakdown, tokens, heatmap, etc.) - Command Palette (Cmd+K) for keyboard navigation @@ -451,6 +537,7 @@ ## [1.0.0] - Initial Release ### Added + - Node.js HTTP server with static file serving - JSON data upload/download API - Basic dashboard functionality diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a1e38f..7525e01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,12 +44,22 @@ Make sure the change is small, focused, and aligned with the existing product di Run the main local checks: ```bash -npm run build -npm run test:unit -npm run verify:package +npm run verify npm run test:e2e ``` +`npm run verify` covers formatting, ESLint, `tsc --noEmit`, unit tests, the production bundle, and packaged-artifact verification. If you want the same coverage gate used in release preparation, also run: + +```bash +npm run test:unit:coverage +``` + +If you only need the production bundle without the lint/format gate, use: + +```bash +npm run build:app +``` + If local port `3015` is already occupied, run Playwright on another isolated port: ```bash diff --git a/README.md b/README.md index 8070465..21d8a68 100644 --- a/README.md +++ b/README.md @@ -263,15 +263,25 @@ Build the production bundle: npm run build ``` +`npm run build` is the gated build and runs `format:check` and `lint` before bundling. If you only want the Vite production bundle, use: + +```bash +npm run build:app +``` + Run automated checks: ```bash -npm run test:unit -npm run test:unit:coverage -npm run verify:package +npm run verify npm run test:e2e ``` +If you want the release-style coverage run as well, execute: + +```bash +npm run test:unit:coverage +``` + The Playwright suite uses its own isolated local app directory. If port `3015` is already occupied locally, run it on another isolated port: ```bash @@ -287,7 +297,7 @@ PLAYWRIGHT_TEST_PORT=3016 npm run test:e2e ## Status -GitHub Actions runs unit/integration coverage, packaged-artifact verification, and Playwright smoke tests for pull requests and pushes to `main`. +GitHub Actions now runs formatting checks, ESLint, `tsc --noEmit`, unit/integration coverage, the production bundle, packaged-artifact verification, and Playwright smoke tests for pull requests and pushes to `main`. ## License diff --git a/RELEASING.md b/RELEASING.md index dcea89c..b98e904 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,9 +37,8 @@ If branch protection or rulesets block the `ttdash-release` app from writing to Optional local confidence check before starting the workflow: ```bash +npm run verify 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 ``` @@ -51,17 +50,19 @@ On a manual `workflow_dispatch` run against `main`, the workflow: or resumes a partially completed release when the requested version is already on `main` 2. verifies the latest `CI` run for the current `main` commit succeeded 3. bumps `package.json` and `package-lock.json` to the requested version -4. runs unit/integration tests with coverage -5. builds the production bundle -6. verifies the packed npm artifact -7. runs the Playwright smoke suite -8. creates and pushes the release commit and annotated tag -9. publishes `@roastcodes/ttdash` to npm through Trusted Publishing -10. waits for npm registry propagation -11. verifies: - - `npx --yes @roastcodes/ttdash@ --help` - - `bunx @roastcodes/ttdash@ --help` -12. creates the GitHub release +4. runs `prettier --check`, ESLint, and `tsc --noEmit` +5. runs unit/integration tests with coverage +6. builds the production bundle +7. verifies the packed npm artifact +8. runs the Playwright smoke suite +9. creates and pushes the release commit and annotated tag +10. publishes `@roastcodes/ttdash` to npm through Trusted Publishing +11. waits for npm registry propagation +12. verifies: + - `npx --yes @roastcodes/ttdash@ --help` + - `bunx @roastcodes/ttdash@ --help` + +13. creates the GitHub release Note: the workflow reruns the release-critical test suite itself after the version bump. This is necessary because the workflow-created push back to `main` should not be relied on to trigger the normal `CI` workflow again. If a release fails after the version bump was already pushed, rerunning the workflow with the same version resumes that release instead of forcing another version bump. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..5ff6928 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,96 @@ +import { defineConfig } from 'eslint/config' +import js from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' +import reactHooks from 'eslint-plugin-react-hooks' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default defineConfig( + { + ignores: [ + 'coverage/**', + 'dist/**', + 'node_modules/**', + 'playwright-report/**', + 'test-results/**', + '.playwright-mcp/**', + '.tmp-playwright/**', + '.tmp-smoke-*/**', + ], + }, + { + files: ['**/*.{js,cjs}'], + ...js.configs.recommended, + languageOptions: { + ...js.configs.recommended.languageOptions, + ecmaVersion: 'latest', + sourceType: 'commonjs', + globals: { + ...globals.node, + }, + }, + }, + { + files: ['**/*.mjs'], + ...js.configs.recommended, + languageOptions: { + ...js.configs.recommended.languageOptions, + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.node, + }, + }, + }, + { + files: ['**/*.{ts,tsx}'], + extends: [...tseslint.configs.recommended], + plugins: { + 'react-hooks': reactHooks, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + ...globals.vitest, + }, + }, + rules: { + 'no-undef': 'off', + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/rules-of-hooks': 'error', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + fixStyle: 'separate-type-imports', + prefer: 'type-imports', + }, + ], + }, + }, + { + files: ['src/**/*.{ts,tsx}'], + extends: [...tseslint.configs.recommendedTypeCheckedOnly], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-misused-promises': [ + 'error', + { + checksVoidReturn: { + attributes: false, + }, + }, + ], + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + }, + }, + eslintConfigPrettier, +) diff --git a/examples/sample-usage.json b/examples/sample-usage.json index 3f895cf..05a4e42 100644 --- a/examples/sample-usage.json +++ b/examples/sample-usage.json @@ -10,10 +10,7 @@ "totalTokens": 449300, "totalCost": 4.83, "requestCount": 84, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -47,10 +44,7 @@ "totalTokens": 358000, "totalCost": 3.94, "requestCount": 73, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -84,11 +78,7 @@ "totalTokens": 512200, "totalCost": 5.52, "requestCount": 96, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5", - "gemini-2.5-pro" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5", "gemini-2.5-pro"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -132,10 +122,7 @@ "totalTokens": 302600, "totalCost": 3.11, "requestCount": 61, - "modelsUsed": [ - "gpt-5.4", - "gemini-2.5-pro" - ], + "modelsUsed": ["gpt-5.4", "gemini-2.5-pro"], "modelBreakdowns": [ { "modelName": "gpt-5.4", @@ -169,10 +156,7 @@ "totalTokens": 228200, "totalCost": 2.47, "requestCount": 49, - "modelsUsed": [ - "gpt-5.4", - "claude-sonnet-4-5" - ], + "modelsUsed": ["gpt-5.4", "claude-sonnet-4-5"], "modelBreakdowns": [ { "modelName": "gpt-5.4", diff --git a/index.html b/index.html index 67e43be..13d7e84 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + diff --git a/package-lock.json b/package-lock.json index 357fe65..84e78ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "ttdash": "server.js" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-select": "^2.2.2", @@ -34,15 +35,21 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.0.1", "framer-motion": "^12.6.5", + "globals": "^17.5.0", "jsdom": "^29.0.2", "lucide-react": "^1.7.0", + "prettier": "^3.8.2", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.3", "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", "vite": "^8.0.8", "vitest": "^4.1.3" }, @@ -102,7 +109,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -112,6 +118,153 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -132,6 +285,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", @@ -157,6 +334,40 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -368,6 +579,163 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -428,6 +796,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2080,6 +2500,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2107,34 +2534,316 @@ "dev": true, "license": "MIT" }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.4", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, @@ -2277,6 +2986,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2302,6 +3051,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -2354,6 +3110,26 @@ "dev": true, "license": "MIT" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2364,6 +3140,82 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2374,6 +3226,39 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2414,6 +3299,33 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2421,6 +3333,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2588,106 +3515,341 @@ "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=0.10" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "dependencies": { + "estraverse": "^5.2.0" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "engines": { + "node": ">=4.0" } }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, "node_modules/estree-walker": { "version": "3.0.3", @@ -2699,6 +3861,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2716,6 +3888,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2734,6 +3927,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/framer-motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", @@ -2777,6 +4021,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -2787,6 +4041,32 @@ "node": ">=6" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2804,6 +4084,23 @@ "node": ">=8" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2864,6 +4161,16 @@ } } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -2875,6 +4182,33 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2895,6 +4229,29 @@ "node": ">=12" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2902,6 +4259,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2956,8 +4320,20 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } }, "node_modules/jsdom": { "version": "29.0.2", @@ -2989,15 +4365,86 @@ "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "engines": { + "node": ">= 0.8.0" } }, "node_modules/lightningcss": { @@ -3261,6 +4708,29 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.3.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", @@ -3347,6 +4817,19 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/motion-dom": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", @@ -3364,6 +4847,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3383,6 +4873,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3394,6 +4898,69 @@ ], "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -3407,6 +4974,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3510,6 +5097,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3774,6 +5387,16 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", @@ -3848,6 +5471,29 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3892,6 +5538,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4041,6 +5700,19 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4048,6 +5720,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -4062,6 +5747,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", @@ -4072,6 +5781,47 @@ "node": ">=20.18.1" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -4374,6 +6124,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4391,6 +6157,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4407,6 +6183,49 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 8326ead..ba0b594 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,12 @@ }, "scripts": { "dev": "vite", - "build": "vite build", + "build:app": "vite build", + "build": "npm run format:check && npm run lint && npm run build:app", + "format": "prettier . --write", + "format:check": "prettier . --check", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "preview": "vite preview", "start": "node server.js", "start:test-server": "node scripts/start-test-server.js", @@ -24,14 +29,15 @@ "test:unit": "vitest run", "test:unit:watch": "vitest", "test:unit:coverage": "vitest run --coverage", - "test:e2e": "npm run build && playwright test", + "test:e2e": "npm run build:app && 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": "npm run format:check && npm run lint && tsc --noEmit && npm run test:unit && npm run build:app && npm run verify:package", "verify:package": "node scripts/verify-package.js", "verify:registry-install": "node scripts/verify-registry-install.js", - "verify:release": "npm run test:unit:coverage && npm run build && npm run verify:package", - "prepare": "npm run build" + "verify:release": "npm run format:check && npm run lint && tsc --noEmit && npm run test:unit:coverage && npm run build:app && npm run verify:package", + "prepare": "npm run build:app" }, "files": [ "server.js", @@ -59,6 +65,7 @@ "node": ">=20" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-select": "^2.2.2", @@ -76,15 +83,21 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.0.1", "framer-motion": "^12.6.5", + "globals": "^17.5.0", "jsdom": "^29.0.2", "lucide-react": "^1.7.0", + "prettier": "^3.8.2", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.3", "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", "vite": "^8.0.8", "vitest": "^4.1.3" }, diff --git a/scripts/report-smoke.js b/scripts/report-smoke.js index 5532209..a2a8837 100644 --- a/scripts/report-smoke.js +++ b/scripts/report-smoke.js @@ -16,19 +16,103 @@ const outDir = path.join('/tmp', 'ttdash-report-matrix'); fs.mkdirSync(outDir, { recursive: true }); const cases = [ - { name: 'daily-all-de', viewMode: 'daily', language: 'de', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'daily-all-en', viewMode: 'daily', language: 'en', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'monthly-all', viewMode: 'monthly', language: 'en', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'yearly-all', viewMode: 'yearly', language: 'de', selectedMonth: null, selectedProviders: [], selectedModels: [] }, - { name: 'daily-anthropic', viewMode: 'daily', selectedMonth: null, selectedProviders: ['Anthropic'], selectedModels: [] }, - { name: 'daily-openai', viewMode: 'daily', selectedMonth: null, selectedProviders: ['OpenAI'], selectedModels: [] }, - { name: 'daily-google', viewMode: 'daily', selectedMonth: null, selectedProviders: ['Google'], selectedModels: [] }, - { name: 'monthly-opus46', viewMode: 'monthly', selectedMonth: null, selectedProviders: [], selectedModels: ['Opus 4.6'] }, - { name: 'daily-gpt54', viewMode: 'daily', selectedMonth: null, selectedProviders: [], selectedModels: ['GPT-5.4'] }, - { name: 'daily-mar-2026', viewMode: 'daily', selectedMonth: '2026-03', selectedProviders: [], selectedModels: [] }, - { name: 'daily-last-week', viewMode: 'daily', selectedMonth: null, selectedProviders: [], selectedModels: [], startDate: '2026-03-28', endDate: '2026-04-03' }, - { name: 'monthly-mar-openai', viewMode: 'monthly', selectedMonth: '2026-03', selectedProviders: ['OpenAI'], selectedModels: [] }, - { name: 'yearly-anthropic-opus46', viewMode: 'yearly', selectedMonth: null, selectedProviders: ['Anthropic'], selectedModels: ['Opus 4.6'] }, + { + name: 'daily-all-de', + viewMode: 'daily', + language: 'de', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'daily-all-en', + viewMode: 'daily', + language: 'en', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'monthly-all', + viewMode: 'monthly', + language: 'en', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'yearly-all', + viewMode: 'yearly', + language: 'de', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + }, + { + name: 'daily-anthropic', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: ['Anthropic'], + selectedModels: [], + }, + { + name: 'daily-openai', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: ['OpenAI'], + selectedModels: [], + }, + { + name: 'daily-google', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: ['Google'], + selectedModels: [], + }, + { + name: 'monthly-opus46', + viewMode: 'monthly', + selectedMonth: null, + selectedProviders: [], + selectedModels: ['Opus 4.6'], + }, + { + name: 'daily-gpt54', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: [], + selectedModels: ['GPT-5.4'], + }, + { + name: 'daily-mar-2026', + viewMode: 'daily', + selectedMonth: '2026-03', + selectedProviders: [], + selectedModels: [], + }, + { + name: 'daily-last-week', + viewMode: 'daily', + selectedMonth: null, + selectedProviders: [], + selectedModels: [], + startDate: '2026-03-28', + endDate: '2026-04-03', + }, + { + name: 'monthly-mar-openai', + viewMode: 'monthly', + selectedMonth: '2026-03', + selectedProviders: ['OpenAI'], + selectedModels: [], + }, + { + name: 'yearly-anthropic-opus46', + viewMode: 'yearly', + selectedMonth: null, + selectedProviders: ['Anthropic'], + selectedModels: ['Opus 4.6'], + }, ]; main().catch((error) => { @@ -57,22 +141,34 @@ async function main() { throw new Error('pdfinfo output missing page count'); } if (!text.includes(generated.meta.filterSummary.viewMode)) { - throw new Error(`PDF text does not contain view mode ${generated.meta.filterSummary.viewMode}`); + throw new Error( + `PDF text does not contain view mode ${generated.meta.filterSummary.viewMode}`, + ); } if (!text.includes(generated.text.sections.overview)) { - throw new Error(`PDF text does not contain overview heading ${generated.text.sections.overview}`); + throw new Error( + `PDF text does not contain overview heading ${generated.text.sections.overview}`, + ); } if (!text.includes(generated.text.sections.interpretation)) { - throw new Error(`PDF text does not contain interpretation heading ${generated.text.sections.interpretation}`); + throw new Error( + `PDF text does not contain interpretation heading ${generated.text.sections.interpretation}`, + ); } if (!text.includes(generated.summaryCards[0].label)) { - throw new Error(`PDF text does not contain summary label ${generated.summaryCards[0].label}`); + throw new Error( + `PDF text does not contain summary label ${generated.summaryCards[0].label}`, + ); } if (generated.insights.items.length > 0 && !text.includes(generated.text.sections.insights)) { - throw new Error(`PDF text does not contain insights heading ${generated.text.sections.insights}`); + throw new Error( + `PDF text does not contain insights heading ${generated.text.sections.insights}`, + ); } - console.log(`[ok] ${testCase.name}: ${generated.meta.days} days, ${generated.meta.periods} periods`); + console.log( + `[ok] ${testCase.name}: ${generated.meta.days} days, ${generated.meta.periods} periods`, + ); } catch (error) { failures += 1; console.error(`[fail] ${testCase.name}: ${error.message}`); diff --git a/scripts/start-test-server.js b/scripts/start-test-server.js index 05ca0bf..4093fbb 100644 --- a/scripts/start-test-server.js +++ b/scripts/start-test-server.js @@ -1,24 +1,24 @@ #!/usr/bin/env node -const fs = require('fs') -const path = require('path') +const fs = require('fs'); +const path = require('path'); -const root = path.resolve(__dirname, '..') -const runtimeRoot = path.join(root, '.tmp-playwright', 'app') +const root = path.resolve(__dirname, '..'); +const runtimeRoot = path.join(root, '.tmp-playwright', 'app'); -fs.rmSync(runtimeRoot, { recursive: true, force: true }) -fs.mkdirSync(path.join(runtimeRoot, 'cache'), { recursive: true }) -fs.mkdirSync(path.join(runtimeRoot, 'config'), { recursive: true }) -fs.mkdirSync(path.join(runtimeRoot, 'data'), { recursive: true }) +fs.rmSync(runtimeRoot, { recursive: true, force: true }); +fs.mkdirSync(path.join(runtimeRoot, 'cache'), { recursive: true }); +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 || 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') +process.env.NO_OPEN_BROWSER = '1'; +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'); -require(path.join(root, 'server.js')) +require(path.join(root, 'server.js')).bootstrapCli(); diff --git a/scripts/verify-main-ci.js b/scripts/verify-main-ci.js index 1c6bdfe..6eb1982 100644 --- a/scripts/verify-main-ci.js +++ b/scripts/verify-main-ci.js @@ -64,7 +64,9 @@ function parseArgs(argv) { } if (!options.repo || !options.workflow || !options.sha) { - fail('Usage: node scripts/verify-main-ci.js --repo --workflow --sha [--branch main] [--retries N] [--retry-delay-ms MS]'); + fail( + 'Usage: node scripts/verify-main-ci.js --repo --workflow --sha [--branch main] [--retries N] [--retry-delay-ms MS]', + ); } if (!Number.isInteger(options.retries) || options.retries <= 0) { @@ -87,7 +89,9 @@ async function sleep(ms) { } async function fetchWorkflowRuns(options, token) { - const url = new URL(`https://api.github.com/repos/${options.repo}/actions/workflows/${encodeURIComponent(options.workflow)}/runs`); + const url = new URL( + `https://api.github.com/repos/${options.repo}/actions/workflows/${encodeURIComponent(options.workflow)}/runs`, + ); url.searchParams.set('branch', options.branch); url.searchParams.set('event', 'push'); url.searchParams.set('per_page', '30'); @@ -105,9 +109,10 @@ async function fetchWorkflowRuns(options, token) { const body = await response.text(); const normalizedBody = body.replace(/\s+/g, ' ').trim(); const maxPreviewLength = 200; - const bodyPreview = normalizedBody.length > maxPreviewLength - ? `${normalizedBody.slice(0, maxPreviewLength)}…` - : normalizedBody; + const bodyPreview = + normalizedBody.length > maxPreviewLength + ? `${normalizedBody.slice(0, maxPreviewLength)}…` + : normalizedBody; const previewSuffix = bodyPreview ? ` Response preview: ${bodyPreview}` : ''; throw new Error(`GitHub API request failed with ${response.status}.${previewSuffix}`); } @@ -132,9 +137,13 @@ async function main() { const run = payload.workflow_runs.find((candidate) => candidate.head_sha === options.sha); if (!run) { - log(`CI workflow run for ${options.sha} not found yet (attempt ${attempt}/${options.retries}).`); + log( + `CI workflow run for ${options.sha} not found yet (attempt ${attempt}/${options.retries}).`, + ); } else if (run.status !== 'completed') { - log(`CI workflow run is still in progress: ${describeRun(run)} (attempt ${attempt}/${options.retries}).`); + log( + `CI workflow run is still in progress: ${describeRun(run)} (attempt ${attempt}/${options.retries}).`, + ); } else if (run.conclusion === 'success') { log(`Verified CI success for ${options.sha}: ${describeRun(run)}.`); return; diff --git a/scripts/verify-package.js b/scripts/verify-package.js index c82eed5..f59c011 100644 --- a/scripts/verify-package.js +++ b/scripts/verify-package.js @@ -65,7 +65,9 @@ function parsePackJson(output) { try { return JSON.parse(line); - } catch {} + } catch { + // Ignore non-JSON log lines and keep searching for the pack payload. + } } throw new Error(`npm pack did not produce JSON output.\n${output}`); @@ -111,7 +113,9 @@ async function waitForServer(url, child) { if (response.ok) { return; } - } catch {} + } catch { + // Ignore transient startup failures while the server is still booting. + } await new Promise((resolve) => setTimeout(resolve, 200)); } @@ -163,7 +167,10 @@ async function terminateChild(child, label) { 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'); + 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, @@ -181,7 +188,9 @@ function verifyInstalledCli(command, tarballPath, npmEnv) { }); if (!helpOutput.includes(`TTDash v${packageJson.version}`)) { - throw new Error('Installed tarball CLI help output did not contain the expected version banner.'); + throw new Error( + 'Installed tarball CLI help output did not contain the expected version banner.', + ); } log('Verified installed tarball CLI help output.'); @@ -200,12 +209,16 @@ async function main() { if (!fs.existsSync(path.join(ROOT, 'dist', 'index.html'))) { log('Production bundle missing, running build first.'); - run(command, ['run', 'build'], { env: npmEnv }); + run(command, ['run', 'build:app'], { env: npmEnv }); } - const packJson = run(command, ['pack', '--json', '--ignore-scripts', '--pack-destination', packDir], { - env: npmEnv, - }); + const packJson = run( + command, + ['pack', '--json', '--ignore-scripts', '--pack-destination', packDir], + { + env: npmEnv, + }, + ); const [packInfo] = parsePackJson(packJson); if (!packInfo || !packInfo.filename) { @@ -222,9 +235,13 @@ async function main() { const { installDir, installedCliPath } = verifyInstalledCli(command, tarballPath, npmEnv); - const helpOutput = run(command, ['exec', '--yes', '--package', tarballPath, '--', 'ttdash', '--help'], { - env: 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.'); diff --git a/scripts/verify-registry-install.js b/scripts/verify-registry-install.js index bee5475..b7b52cf 100644 --- a/scripts/verify-registry-install.js +++ b/scripts/verify-registry-install.js @@ -56,7 +56,9 @@ function parseArgs(argv) { } if (!options.packageName || !options.version) { - fail('Usage: node scripts/verify-registry-install.js --package --version [--retries N] [--retry-delay-ms MS]'); + fail( + 'Usage: node scripts/verify-registry-install.js --package --version [--retries N] [--retry-delay-ms MS]', + ); } if (!Number.isInteger(options.retries) || options.retries <= 0) { @@ -76,10 +78,17 @@ function sleep(ms) { function createIsolatedWorkingDir(prefix) { const cwd = mktemp(prefix); - fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ - name: 'ttdash-registry-verify', - private: true, - }, null, 2) + '\n'); + fs.writeFileSync( + path.join(cwd, 'package.json'), + JSON.stringify( + { + name: 'ttdash-registry-verify', + private: true, + }, + null, + 2, + ) + '\n', + ); return cwd; } @@ -132,22 +141,26 @@ async function verifyNpmExec(packageName, version, retries, retryDelayMs) { const cacheDir = mktemp('ttdash-registry-npm-cache-'); try { - const output = runCommand('npm', [ - 'exec', - '--yes', - '--prefer-online', - '--package', - `${packageName}@${version}`, - '--', - 'ttdash', - '--help', - ], { - cwd, - env: buildEnv({ - npm_config_cache: cacheDir, - NPM_CONFIG_CACHE: cacheDir, - }), - }); + const output = runCommand( + 'npm', + [ + 'exec', + '--yes', + '--prefer-online', + '--package', + `${packageName}@${version}`, + '--', + 'ttdash', + '--help', + ], + { + cwd, + env: buildEnv({ + npm_config_cache: cacheDir, + NPM_CONFIG_CACHE: cacheDir, + }), + }, + ); verifyExpectedVersion(output, expected); log(`Verified npm exec install path on attempt ${attempt}.`); @@ -166,7 +179,9 @@ async function verifyNpmExec(packageName, version, retries, retryDelayMs) { } } - throw new Error(`npm exec install path did not become ready in time.\n${formatCommandError(lastError)}`); + throw new Error( + `npm exec install path did not become ready in time.\n${formatCommandError(lastError)}`, + ); } async function verifyBunx(packageName, version, retries, retryDelayMs) { @@ -179,10 +194,7 @@ async function verifyBunx(packageName, version, retries, retryDelayMs) { const bunCacheDir = mktemp('ttdash-registry-bun-cache-'); try { - const output = runCommand('bunx', [ - `${packageName}@${version}`, - '--help', - ], { + const output = runCommand('bunx', [`${packageName}@${version}`, '--help'], { cwd, env: buildEnv({ BUN_INSTALL: bunInstallDir, @@ -207,7 +219,9 @@ async function verifyBunx(packageName, version, retries, retryDelayMs) { } } - throw new Error(`bunx install path did not become ready in time.\n${formatCommandError(lastError)}`); + throw new Error( + `bunx install path did not become ready in time.\n${formatCommandError(lastError)}`, + ); } async function main() { diff --git a/server.js b/server.js index e449560..8757179 100755 --- a/server.js +++ b/server.js @@ -26,13 +26,19 @@ 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 const IS_WINDOWS = process.platform === 'win32'; -const TOKTRACK_LOCAL_BIN = path.join(ROOT, 'node_modules', '.bin', IS_WINDOWS ? 'toktrack.cmd' : 'toktrack'); +const TOKTRACK_LOCAL_BIN = path.join( + ROOT, + 'node_modules', + '.bin', + IS_WINDOWS ? 'toktrack.cmd' : 'toktrack', +); const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'Cross-Origin-Opener-Policy': 'same-origin', - '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'", + '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'; @@ -66,7 +72,9 @@ const DEFAULT_SETTINGS = { providers: [], models: [], }, - sectionVisibility: Object.fromEntries(DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true])), + sectionVisibility: Object.fromEntries( + DASHBOARD_SECTION_IDS.map((sectionId) => [sectionId, true]), + ), sectionOrder: DASHBOARD_SECTION_IDS, lastLoadedAt: null, lastLoadSource: null, @@ -223,14 +231,27 @@ function resolveAppPaths() { }; } 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'), + 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), + 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), }; @@ -355,9 +376,9 @@ async function isBackgroundInstanceOwned(instance) { return false; } - return runtime.id === instance.id - && runtime.pid === instance.pid - && runtime.port === instance.port; + return ( + runtime.id === instance.id && runtime.pid === instance.pid && runtime.port === instance.port + ); } function normalizeBackgroundInstance(value) { @@ -368,17 +389,19 @@ function normalizeBackgroundInstance(value) { 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) { + 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; } @@ -389,9 +412,8 @@ function normalizeBackgroundInstance(value) { url, host, startedAt, - logFile: typeof value.logFile === 'string' && value.logFile.trim() - ? value.logFile.trim() - : null, + logFile: + typeof value.logFile === 'string' && value.logFile.trim() ? value.logFile.trim() : null, }; } @@ -401,7 +423,9 @@ function readBackgroundInstancesRaw() { if (Array.isArray(parsed)) { return parsed; } - } catch {} + } catch { + // Ignore missing or invalid background registry state. + } return []; } @@ -411,9 +435,7 @@ function writeBackgroundInstances(instances) { } async function readBackgroundInstancesSnapshot() { - const normalized = readBackgroundInstancesRaw() - .map(normalizeBackgroundInstance) - .filter(Boolean); + const normalized = readBackgroundInstancesRaw().map(normalizeBackgroundInstance).filter(Boolean); const alive = []; for (const instance of normalized) { @@ -443,7 +465,10 @@ async function getBackgroundInstances() { return (await readBackgroundInstancesSnapshot()).alive; } -async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS) { +async function withBackgroundInstancesLock( + callback, + timeoutMs = BACKGROUND_INSTANCES_LOCK_TIMEOUT_MS, +) { const startedAt = Date.now(); while (true) { @@ -458,18 +483,22 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST let lockIsStale = false; try { const stats = fs.statSync(BACKGROUND_INSTANCES_LOCK_DIR); - lockIsStale = (Date.now() - stats.mtimeMs) > BACKGROUND_INSTANCES_LOCK_STALE_MS; - } catch {} + lockIsStale = Date.now() - stats.mtimeMs > BACKGROUND_INSTANCES_LOCK_STALE_MS; + } catch { + // Ignore stat races while the lock directory is changing. + } if (lockIsStale) { try { fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true }); continue; - } catch {} + } catch { + // Ignore lock cleanup races and retry until timeout. + } } if (Date.now() - startedAt >= timeoutMs) { - throw new Error('Could not acquire background registry lock.'); + throw new Error('Could not acquire background registry lock.', { cause: error }); } await sleep(50); @@ -481,7 +510,9 @@ async function withBackgroundInstancesLock(callback, timeoutMs = BACKGROUND_INST } finally { try { fs.rmSync(BACKGROUND_INSTANCES_LOCK_DIR, { recursive: true, force: true }); - } catch {} + } catch { + // Ignore cleanup races after the lock holder exits. + } } } @@ -604,7 +635,11 @@ async function promptForBackgroundInstance(instances) { try { while (true) { - const answer = (await rl.question(`Which instance should be stopped? [1-${instances.length}, Enter=cancel] `)).trim(); + const answer = ( + await rl.question( + `Which instance should be stopped? [1-${instances.length}, Enter=cancel] `, + ) + ).trim(); if (!answer) { return null; @@ -683,22 +718,30 @@ async function runStopCommand() { const result = await stopBackgroundInstance(selectedInstance); if (result.status === 'stopped') { - console.log(`Stopped TTDash background server: ${selectedInstance.url} (PID ${selectedInstance.pid})`); + 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})`); + 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})`); + 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})`); + console.error( + `TTDash background server did not respond to SIGTERM: ${selectedInstance.url} (PID ${selectedInstance.pid})`, + ); if (selectedInstance.logFile) { console.error(`Log file: ${selectedInstance.logFile}`); } @@ -736,9 +779,7 @@ async function startInBackground() { const instance = await waitForBackgroundInstance(child.pid); if (!instance) { - const logOutput = fs.existsSync(logFile) - ? fs.readFileSync(logFile, 'utf-8').trim() - : ''; + 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}`); } @@ -765,7 +806,9 @@ function migrateLegacyDataFile() { fs.copyFileSync(LEGACY_DATA_FILE, DATA_FILE); try { fs.unlinkSync(LEGACY_DATA_FILE); - } catch {} + } catch { + // Ignore best-effort cleanup failures after copying legacy data. + } console.log(`Copying existing data to ${DATA_FILE}`); } } @@ -787,9 +830,7 @@ function normalizeDashboardDatePreset(value) { } function normalizeLastLoadSource(value) { - return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' - ? value - : null; + return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value : null; } function normalizeIsoTimestamp(value) { @@ -815,30 +856,38 @@ function isPlainObject(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, - }); + 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)); + return [ + ...new Set( + (Array.isArray(values) ? values : []).filter( + (value) => typeof value === 'string' && value.trim(), + ), + ), + ].sort((left, right) => left.localeCompare(right)); } function canonicalizeModelBreakdown(entry) { @@ -918,9 +967,10 @@ function extractUsageImportPayload(payload) { } function mergeUsageData(currentData, importedData) { - const current = currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0 - ? normalizeIncomingData(currentData) - : null; + const current = + currentData && Array.isArray(currentData.daily) && currentData.daily.length > 0 + ? normalizeIncomingData(currentData) + : null; if (!current) { return { @@ -956,7 +1006,9 @@ function mergeUsageData(currentData, importedData) { conflictingDays += 1; } - const mergedDaily = [...currentByDate.values()].sort((left, right) => left.date.localeCompare(right.date)); + const mergedDaily = [...currentByDate.values()].sort((left, right) => + left.date.localeCompare(right.date), + ); return { data: { @@ -1006,10 +1058,14 @@ function normalizeStringList(value) { return []; } - return [...new Set(value - .filter((entry) => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter(Boolean))]; + return [ + ...new Set( + value + .filter((entry) => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; } function normalizeDefaultFilters(value) { @@ -1028,9 +1084,7 @@ function normalizeSectionVisibility(value) { const next = {}; for (const sectionId of DASHBOARD_SECTION_IDS) { - next[sectionId] = typeof source[sectionId] === 'boolean' - ? source[sectionId] - : true; + next[sectionId] = typeof source[sectionId] === 'boolean' ? source[sectionId] : true; } return next; @@ -1041,9 +1095,9 @@ function normalizeSectionOrder(value) { return [...DASHBOARD_SECTION_IDS]; } - const incoming = value.filter((sectionId) => ( - typeof sectionId === 'string' && DASHBOARD_SECTION_IDS.includes(sectionId) - )); + 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)); @@ -1077,14 +1131,8 @@ function openBrowser(url) { } const platform = process.platform; - const command = platform === 'darwin' - ? 'open' - : platform === 'win32' - ? 'cmd' - : 'xdg-open'; - const args = platform === 'win32' - ? ['/c', 'start', '', url] - : [url]; + const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]; const child = spawn(command, args, { detached: true, @@ -1140,15 +1188,9 @@ function describeDataFile() { } function printStartupSummary(url, port) { - const browserMode = shouldOpenBrowser() - ? 'enabled' - : 'disabled'; - const autoLoadMode = CLI_OPTIONS.autoLoad - ? 'enabled' - : 'disabled'; - const runtimeMode = IS_BACKGROUND_CHILD - ? 'background' - : 'foreground'; + const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled'; + const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled'; + const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground'; console.log(''); console.log(`${APP_LABEL} v${APP_VERSION} is ready`); @@ -1331,23 +1373,6 @@ function json(res, status, data) { res.end(JSON.stringify(data)); } -function sendFile(res, status, headers, filePath) { - const stream = fs.createReadStream(filePath); - res.writeHead(status, { - ...headers, - ...SECURITY_HEADERS, - }); - stream.on('error', () => { - if (!res.headersSent) { - res.writeHead(500, SECURITY_HEADERS); - res.end('Internal Server Error'); - return; - } - res.destroy(); - }); - stream.pipe(res); -} - function sendBuffer(res, status, headers, buffer) { res.writeHead(status, { 'Content-Length': buffer.length, @@ -1385,6 +1410,22 @@ function shouldUseShell(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command); } +function getExecutableName(baseName, isWindows = IS_WINDOWS) { + if (!isWindows) { + return baseName; + } + + switch (baseName) { + case 'bun': + case 'bunx': + return 'bun.exe'; + case 'npx': + return 'npx.cmd'; + default: + return baseName; + } +} + function spawnCommand(command, args, options = {}) { return spawn(command, args, { ...options, @@ -1413,9 +1454,9 @@ async function resolveToktrackRunner() { }; } - if (await commandExists(IS_WINDOWS ? 'bun.exe' : 'bun')) { + if (await commandExists(getExecutableName('bun'))) { return { - command: IS_WINDOWS ? 'bun.exe' : 'bunx', + command: getExecutableName('bunx'), prefixArgs: IS_WINDOWS ? ['x', 'toktrack'] : ['toktrack'], env: process.env, method: 'bunx', @@ -1424,9 +1465,9 @@ async function resolveToktrackRunner() { }; } - if (await commandExists(IS_WINDOWS ? 'npx.cmd' : 'npx')) { + if (await commandExists(getExecutableName('npx'))) { return { - command: IS_WINDOWS ? 'npx.cmd' : 'npx', + command: getExecutableName('npx'), prefixArgs: ['--yes', 'toktrack'], env: { ...process.env, @@ -1557,7 +1598,9 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) { }); startupAutoLoadCompleted = true; - console.log(`Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`); + console.log( + `Auto-load complete: imported ${result.days} days, ${formatCurrency(result.totalCost)}.`, + ); } catch (error) { console.error(`Auto-load failed: ${error.message}`); console.error('Dashboard will start without newly imported data.'); @@ -1576,22 +1619,30 @@ const server = http.createServer(async (req, res) => { if (apiPath === '/usage') { if (req.method === 'GET') { const data = readData(); - return json(res, 200, data || { - daily: [], - totals: { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, + return json( + res, + 200, + data || { + daily: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, }, - }); + ); } if (req.method === 'DELETE') { - try { fs.unlinkSync(DATA_FILE); } catch {} + try { + fs.unlinkSync(DATA_FILE); + } catch { + // Ignore missing data files during reset. + } clearDataLoadState(); return json(res, 200, { success: true }); } @@ -1619,7 +1670,11 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'DELETE') { - try { fs.unlinkSync(SETTINGS_FILE); } catch {} + try { + fs.unlinkSync(SETTINGS_FILE); + } catch { + // Ignore missing settings files during reset. + } return json(res, 200, { success: true, settings: readSettings() }); } @@ -1662,9 +1717,10 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { days, totalCost }); } catch (e) { const status = e.message === 'Payload too large' ? 413 : 400; - const message = e.message === 'Payload too large' - ? 'File too large (max. 10 MB)' - : e.message || 'Invalid JSON'; + const message = + e.message === 'Payload too large' + ? 'File too large (max. 10 MB)' + : e.message || 'Invalid JSON'; return json(res, status, { message }); } } @@ -1697,13 +1753,15 @@ const server = http.createServer(async (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'X-Accel-Buffering': 'no', ...SECURITY_HEADERS, }); let aborted = false; - req.on('close', () => { aborted = true; }); + req.on('close', () => { + aborted = true; + }); try { const result = await performAutoImport({ @@ -1728,13 +1786,17 @@ const server = http.createServer(async (req, res) => { }, }); - if (aborted) { return; } + if (aborted) { + return; + } sendSSE(res, 'success', result); sendSSE(res, 'done', {}); res.end(); } catch (err) { - if (aborted) { return; } + if (aborted) { + return; + } sendSSE(res, 'error', { message: `Error: ${err.message}` }); sendSSE(res, 'done', {}); res.end(); @@ -1752,20 +1814,28 @@ const server = http.createServer(async (req, res) => { return json(res, 400, { message: 'No data available for the report.' }); } - let body = {}; + let body; try { 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 request too large' : 'Invalid report request' }); + return json(res, status, { + message: + e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request', + }); } try { const result = await generatePdfReport(data.daily, body || {}); - return sendBuffer(res, 200, { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="${result.filename}"`, - }, result.buffer); + return sendBuffer( + res, + 200, + { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${result.filename}"`, + }, + result.buffer, + ); } catch (error) { const message = error && error.message ? error.message : 'PDF generation failed'; const status = error && error.code === 'TYPST_MISSING' ? 503 : 500; @@ -1781,43 +1851,68 @@ const server = http.createServer(async (req, res) => { const safePath = pathname === '/' ? '/index.html' : pathname; const filePath = path.resolve(STATIC_ROOT, `.${safePath}`); - if (!filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && filePath !== path.resolve(STATIC_ROOT, 'index.html')) { + if ( + !filePath.startsWith(path.resolve(STATIC_ROOT) + path.sep) && + filePath !== path.resolve(STATIC_ROOT, 'index.html') + ) { return json(res, 403, { message: 'Access denied' }); } serveFile(res, filePath); }); -function tryListen(port) { - return new Promise((resolve, reject) => { - if (port > MAX_PORT) { - reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`)); - return; - } +function createNoFreePortError(rangeStartPort, maxPort) { + return new Error(`No free port found (${rangeStartPort}-${maxPort})`); +} + +async function listenOnAvailablePort( + serverInstance, + port, + maxPort, + bindHost, + log = console.log, + rangeStartPort = port, +) { + if (port > maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); + } + + for (let currentPort = port; currentPort <= maxPort; currentPort += 1) { + try { + await new Promise((resolve, reject) => { + const onError = (err) => { + serverInstance.off('listening', onListening); + reject(err); + }; + + const onListening = () => { + serverInstance.off('error', onError); + resolve(); + }; + + serverInstance.once('error', onError); + serverInstance.once('listening', onListening); + serverInstance.listen(currentPort, bindHost); + }); - const onError = (err) => { - server.off('listening', onListening); - if (err.code === 'EADDRINUSE') { - if (port >= MAX_PORT) { - reject(new Error(`No free port found (${START_PORT}-${MAX_PORT})`)); - return; + return currentPort; + } catch (err) { + if (err && err.code === 'EADDRINUSE') { + if (currentPort >= maxPort) { + throw createNoFreePortError(rangeStartPort, maxPort); } - console.log(`Port ${port} is in use, trying ${port + 1}...`); - resolve(tryListen(port + 1)); - } else { - reject(err); + log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`); + continue; } - }; + throw err; + } + } - const onListening = () => { - server.off('error', onError); - resolve(port); - }; + throw createNoFreePortError(rangeStartPort, maxPort); +} - server.once('error', onError); - server.once('listening', onListening); - server.listen(port, BIND_HOST); - }); +function tryListen(port) { + return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT); } async function start() { @@ -1858,18 +1953,40 @@ async function runCli() { await start(); } -runCli().catch((error) => { - Promise.resolve() - .then(async () => { - if (IS_BACKGROUND_CHILD) { - await unregisterBackgroundInstance(process.pid); - } - }) - .finally(() => { - console.error(error); - process.exit(1); - }); -}); +function registerShutdownHandlers() { + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +function bootstrapCli() { + runCli().catch((error) => { + Promise.resolve() + .then(async () => { + if (IS_BACKGROUND_CHILD) { + await unregisterBackgroundInstance(process.pid); + } + }) + .finally(() => { + console.error(error); + process.exit(1); + }); + }); + + registerShutdownHandlers(); +} + +module.exports = { + bootstrapCli, + runCli, + __test__: { + getExecutableName, + listenOnAvailablePort, + }, +}; + +if (require.main === module) { + bootstrapCli(); +} // Graceful shutdown on Ctrl+C / kill function shutdown(signal) { @@ -1890,6 +2007,3 @@ function shutdown(signal) { process.exit(0); }, 3000); } - -process.on('SIGINT', () => shutdown('SIGINT')); -process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/server/model-normalization.json b/server/model-normalization.json new file mode 100644 index 0000000..b316fa2 --- /dev/null +++ b/server/model-normalization.json @@ -0,0 +1,28 @@ +{ + "displayAliases": [ + { "pattern": "(^|-)gpt-5-4$", "name": "GPT-5.4" }, + { "pattern": "(^|-)gpt-5$", "name": "GPT-5" }, + { "pattern": "(^|-)opus-4-6$", "name": "Opus 4.6" }, + { "pattern": "(^|-)opus-4-5$", "name": "Opus 4.5" }, + { "pattern": "(^|-)sonnet-4-6$", "name": "Sonnet 4.6" }, + { "pattern": "(^|-)sonnet-4-5$", "name": "Sonnet 4.5" }, + { "pattern": "(^|-)haiku-4-5$", "name": "Haiku 4.5" }, + { "pattern": "(^|-)gemini-3-flash-preview$", "name": "Gemini 3 Flash Preview" }, + { "pattern": "(^|-)opencode$", "name": "OpenCode" } + ], + "providerMatchers": [ + { "pattern": "(^|-)opencode($|-)", "provider": "OpenCode" }, + { + "pattern": "openai-codex|(^|-)codex($|-)|(^|-)gpt($|-)|(^|[^a-z0-9])o\\d(?:$|[^a-z0-9])|openai", + "provider": "OpenAI" + }, + { "pattern": "claude|anthropic|opus|sonnet|haiku", "provider": "Anthropic" }, + { "pattern": "gemini|google|vertex", "provider": "Google" }, + { "pattern": "grok|xai", "provider": "xAI" }, + { "pattern": "llama|meta-llama|meta/", "provider": "Meta" }, + { "pattern": "command|cohere", "provider": "Cohere" }, + { "pattern": "mistral", "provider": "Mistral" }, + { "pattern": "deepseek", "provider": "DeepSeek" }, + { "pattern": "qwen|alibaba", "provider": "Alibaba" } + ] +} diff --git a/server/report/charts.js b/server/report/charts.js index 2d86402..d85ebd1 100644 --- a/server/report/charts.js +++ b/server/report/charts.js @@ -25,7 +25,18 @@ function truncateSvgLabel(value, maxLength = 28) { return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; } -function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fill = 'rgba(31, 111, 235, 0.14)', formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }) { +function lineChart( + data, + { + valueKey, + secondaryKey, + title, + stroke = '#1f6feb', + fill = 'rgba(31, 111, 235, 0.14)', + formatter = (value) => String(value), + fontFamily = DEFAULT_FONT_FAMILY, + }, +) { const width = 980; const height = 360; const margin = { top: 42, right: 28, bottom: 54, left: 74 }; @@ -39,16 +50,18 @@ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fi const y = (value) => margin.top + plotHeight - (value / maxValue) * plotHeight; const linePoints = values.map((value, index) => `${x(index)},${y(value)}`).join(' '); - const areaPoints = data.length > 1 - ? [ - `${margin.left},${margin.top + plotHeight}`, - ...values.map((value, index) => `${x(index)},${y(value)}`), - `${margin.left + plotWidth},${margin.top + plotHeight}`, - ].join(' ') - : ''; - const secondaryPoints = secondaryValues.length > 0 - ? secondaryValues.map((value, index) => `${x(index)},${y(value)}`).join(' ') - : ''; + const areaPoints = + data.length > 1 + ? [ + `${margin.left},${margin.top + plotHeight}`, + ...values.map((value, index) => `${x(index)},${y(value)}`), + `${margin.left + plotWidth},${margin.top + plotHeight}`, + ].join(' ') + : ''; + const secondaryPoints = + secondaryValues.length > 0 + ? secondaryValues.map((value, index) => `${x(index)},${y(value)}`).join(' ') + : ''; const tickCount = 4; const yTicks = Array.from({ length: tickCount + 1 }, (_, index) => { const value = (maxValue / tickCount) * index; @@ -59,33 +72,66 @@ function lineChart(data, { valueKey, secondaryKey, title, stroke = '#1f6feb', fi }); const labelStep = Math.max(1, Math.ceil(data.length / 6)); - return svgDoc(width, height, ` + return svgDoc( + width, + height, + ` ${escapeXml(title)} - ${yTicks.map((tick) => ` + ${yTicks + .map( + (tick) => ` ${escapeXml(formatter(tick.value))} - `).join('')} + `, + ) + .join('')} ${areaPoints ? `` : ''} ${secondaryPoints ? `` : ''} - ${data.length > 1 - ? `` - : ``} - ${values.map((value, index) => ` + ${ + data.length > 1 + ? `` + : `` + } + ${values + .map( + (value, index) => ` - `).join('')} - ${data.map((entry, index) => index % labelStep === 0 || index === data.length - 1 ? ` + `, + ) + .join('')} + ${data + .map((entry, index) => + index % labelStep === 0 || index === data.length - 1 + ? ` ${escapeXml(entry.label)} - ` : '').join('')} - `); + ` + : '', + ) + .join('')} + `, + ); } -function horizontalBarChart(data, { title, formatter = (value) => String(value), getValue, getLabel, getColor, fontFamily = DEFAULT_FONT_FAMILY }) { +function horizontalBarChart( + data, + { + title, + formatter = (value) => String(value), + getValue, + getLabel, + getColor, + fontFamily = DEFAULT_FONT_FAMILY, + }, +) { const width = 980; const height = 360; - const longestLabelLength = data.reduce((max, entry) => Math.max(max, String(getLabel(entry) || '').length), 0); + const longestLabelLength = data.reduce( + (max, entry) => Math.max(max, String(getLabel(entry) || '').length), + 0, + ); const margin = { top: 46, right: 100, @@ -94,39 +140,56 @@ function horizontalBarChart(data, { title, formatter = (value) => String(value), }; const plotWidth = width - margin.left - margin.right; const barGap = 18; - const barHeight = Math.min(28, (height - margin.top - margin.bottom - barGap * (data.length - 1)) / Math.max(data.length, 1)); + const barHeight = Math.min( + 28, + (height - margin.top - margin.bottom - barGap * (data.length - 1)) / Math.max(data.length, 1), + ); const maxValue = Math.max(...data.map(getValue), 1); - return svgDoc(width, height, ` + return svgDoc( + width, + height, + ` ${escapeXml(title)} - ${data.map((entry, index) => { - const y = margin.top + index * (barHeight + barGap); - const value = getValue(entry); - const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); - return ` + ${data + .map((entry, index) => { + const y = margin.top + index * (barHeight + barGap); + const value = getValue(entry); + const barWidth = clamp((value / maxValue) * plotWidth, 0, plotWidth); + return ` ${escapeXml(truncateSvgLabel(getLabel(entry), 30))} ${escapeXml(formatter(value))} `; - }).join('')} - `); + }) + .join('')} + `, + ); } -function stackedBarChart(data, { title, segments, formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }) { +function stackedBarChart( + data, + { title, segments, formatter = (value) => String(value), fontFamily = DEFAULT_FONT_FAMILY }, +) { const width = 980; const height = 380; const margin = { top: 52, right: 30, bottom: 56, left: 74 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; - const totals = data.map((entry) => segments.reduce((sum, segment) => sum + (Number(entry[segment.key]) || 0), 0)); + const totals = data.map((entry) => + segments.reduce((sum, segment) => sum + (Number(entry[segment.key]) || 0), 0), + ); const maxValue = Math.max(...totals, 1); const barWidth = Math.max(10, plotWidth / Math.max(data.length * 1.8, 1)); const gap = data.length > 1 ? (plotWidth - data.length * barWidth) / (data.length - 1) : 0; const labelStep = Math.max(1, Math.ceil(data.length / 7)); - return svgDoc(width, height, ` + return svgDoc( + width, + height, + ` ${escapeXml(title)} ${Array.from({ length: 5 }, (_, index) => { @@ -137,26 +200,36 @@ function stackedBarChart(data, { title, segments, formatter = (value) => String( ${escapeXml(formatter(value))} `; }).join('')} - ${data.map((entry, index) => { - const x = margin.left + index * (barWidth + gap); - let offset = 0; - const rects = segments.map((segment) => { - const value = Number(entry[segment.key]) || 0; - const h = maxValue > 0 ? (value / maxValue) * plotHeight : 0; - const y = margin.top + plotHeight - offset - h; - offset += h; - return ``; - }).join(''); - const label = index % labelStep === 0 || index === data.length - 1 - ? `${escapeXml(entry.label)}` - : ''; - return `${rects}${label}`; - }).join('')} - ${segments.map((segment, index) => ` + ${data + .map((entry, index) => { + const x = margin.left + index * (barWidth + gap); + let offset = 0; + const rects = segments + .map((segment) => { + const value = Number(entry[segment.key]) || 0; + const h = maxValue > 0 ? (value / maxValue) * plotHeight : 0; + const y = margin.top + plotHeight - offset - h; + offset += h; + return ``; + }) + .join(''); + const label = + index % labelStep === 0 || index === data.length - 1 + ? `${escapeXml(entry.label)}` + : ''; + return `${rects}${label}`; + }) + .join('')} + ${segments + .map( + (segment, index) => ` ${escapeXml(segment.label)} - `).join('')} - `); + `, + ) + .join('')} + `, + ); } module.exports = { diff --git a/server/report/index.js b/server/report/index.js index 3256c07..477025a 100644 --- a/server/report/index.js +++ b/server/report/index.js @@ -292,11 +292,31 @@ function createChartAssets(reportData) { title: reportData.text.charts.tokenTrend, formatter: (value) => formatCompactAxis(value, reportData.meta.language), segments: [ - { key: 'input', label: translate(reportData.meta.language, 'common.input'), color: '#0f766e' }, - { key: 'output', label: translate(reportData.meta.language, 'common.output'), color: '#1d4ed8' }, - { key: 'cacheWrite', label: translate(reportData.meta.language, 'common.cacheWrite'), color: '#b45309' }, - { key: 'cacheRead', label: translate(reportData.meta.language, 'common.cacheRead'), color: '#7c3aed' }, - { key: 'thinking', label: translate(reportData.meta.language, 'common.thinking'), color: '#be185d' }, + { + key: 'input', + label: translate(reportData.meta.language, 'common.input'), + color: '#0f766e', + }, + { + key: 'output', + label: translate(reportData.meta.language, 'common.output'), + color: '#1d4ed8', + }, + { + key: 'cacheWrite', + label: translate(reportData.meta.language, 'common.cacheWrite'), + color: '#b45309', + }, + { + key: 'cacheRead', + label: translate(reportData.meta.language, 'common.cacheRead'), + color: '#7c3aed', + }, + { + key: 'thinking', + label: translate(reportData.meta.language, 'common.thinking'), + color: '#be185d', + }, ], }), }; diff --git a/server/report/utils.js b/server/report/utils.js index 94a997b..4aaa303 100644 --- a/server/report/utils.js +++ b/server/report/utils.js @@ -1,5 +1,15 @@ const { version: APP_VERSION } = require('../../package.json'); const { getLanguage, getLocale, translate } = require('./i18n'); +const modelNormalizationSpec = require('../model-normalization.json'); + +const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ + ...alias, + matcher: new RegExp(alias.pattern, 'i'), +})); +const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ + ...matcher, + matcher: new RegExp(matcher.pattern, 'i'), +})); const MODEL_COLORS = { 'Opus 4.6': 'rgb(175, 92, 224)', @@ -10,8 +20,8 @@ const MODEL_COLORS = { 'GPT-5.4': 'rgb(230, 98, 56)', 'GPT-5': 'rgb(230, 98, 56)', 'Gemini 3 Flash Preview': 'rgb(237, 188, 8)', - 'Gemini': 'rgb(237, 188, 8)', - 'OpenCode': 'rgb(51, 181, 193)', + Gemini: 'rgb(237, 188, 8)', + OpenCode: 'rgb(51, 181, 193)', }; const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; @@ -23,58 +33,158 @@ function titleCaseSegment(segment) { return segment.charAt(0).toUpperCase() + segment.slice(1); } -function normalizeModelName(raw) { - const lower = String(raw || '').toLowerCase().trim(); - if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4'; - if (lower.includes('gpt-5')) return 'GPT-5'; - if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6'; - if (lower.includes('opus-4-5') || lower.includes('opus-4.5')) return 'Opus 4.5'; - if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return 'Sonnet 4.6'; - if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) return 'Sonnet 4.5'; - if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return 'Haiku 4.5'; - if (lower.includes('gemini-3-flash-preview')) return 'Gemini 3 Flash Preview'; - if (lower.includes('gemini')) return 'Gemini'; - if (lower.includes('opencode')) return 'OpenCode'; - if (lower.includes('haiku')) return 'Haiku'; - - const stripped = String(raw || '') +function capitalize(segment) { + if (!segment) return ''; + return segment.charAt(0).toUpperCase() + segment.slice(1); +} + +function formatVersion(version) { + return version.replace(/-/g, '.'); +} + +function canonicalizeModelName(raw) { + const normalized = String(raw || '') .trim() - .replace(/^(claude|anthropic|openai|google|vertex|models)\//i, '') - .replace(/^(claude|anthropic|openai|google|vertex|models)-/i, '') + .toLowerCase() .replace(/^model[:/ -]*/i, '') + .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') + .replace(/\./g, '-') .replace(/[_/]+/g, '-') .replace(/\s+/g, '-') .replace(/-{2,}/g, '-') .replace(/^-|-$/g, ''); - const familyMatch = stripped.match(/(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i); + const suffixStart = normalized.lastIndexOf('-'); + if (suffixStart > 0) { + const suffix = normalized.slice(suffixStart + 1); + if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { + return normalized.slice(0, suffixStart); + } + } + + return normalized; +} + +function parseClaudeName(rest) { + const parts = rest.split('-', 2); + if (parts.length < 2) { + return `Claude ${capitalize(rest)}`; + } + + return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim(); +} + +function parseGptName(rest) { + const parts = rest.split('-'); + const variant = parts[0] || ''; + const minor = parts[1] || ''; + + if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { + const version = `${variant}.${minor}`; + if (parts.length > 2) { + const suffix = parts.slice(2).map(capitalize).join(' '); + return `GPT-${version}${suffix ? ` ${suffix}` : ''}`; + } + return `GPT-${version}`; + } + + if (parts.length > 1) { + const suffix = parts.slice(1).map(capitalize).join(' '); + return `GPT-${variant}${suffix ? ` ${suffix}` : ''}`; + } + + return `GPT-${rest}`; +} + +function parseGeminiName(rest) { + const parts = rest.split('-'); + if (parts.length < 2) { + return `Gemini ${rest}`; + } + + const versionParts = []; + const tierParts = []; + + for (const part of parts) { + if (/^\d+$/.test(part) && tierParts.length === 0) { + versionParts.push(part); + } else { + tierParts.push(capitalize(part)); + } + } + + const version = versionParts.join('.'); + const tier = tierParts.join(' '); + + return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}`; +} + +function parseCodexName(rest) { + const normalized = rest.replace(/-latest$/i, ''); + if (!normalized) { + return 'Codex'; + } + return `Codex ${normalized.split('-').map(capitalize).join(' ')}`; +} + +function parseOSeries(name) { + const separatorIndex = name.indexOf('-'); + if (separatorIndex === -1) { + return name; + } + return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`; +} + +function normalizeModelName(raw) { + const canonical = canonicalizeModelName(raw); + + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) return alias.name; + } + + if (canonical.startsWith('claude-')) { + return parseClaudeName(canonical.slice('claude-'.length)); + } + + if (canonical.startsWith('gpt-')) { + return parseGptName(canonical.slice('gpt-'.length)); + } + + if (canonical.startsWith('gemini-')) { + return parseGeminiName(canonical.slice('gemini-'.length)); + } + + if (canonical.startsWith('codex-')) { + return parseCodexName(canonical.slice('codex-'.length)); + } + + if (/^o\d/i.test(canonical)) { + return parseOSeries(canonical); + } + + const familyMatch = canonical.match( + /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, + ); if (familyMatch) { const family = familyMatch[1]; - const suffix = familyMatch[2] ? familyMatch[2].replace(/-/g, '.') : ''; + if (/^codex$/i.test(family)) { + return parseCodexName(familyMatch[2] || ''); + } + if (/^(o\d)$/i.test(family)) return parseOSeries(canonical); + + const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : ''; if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`; - if (/^(o\d)$/i.test(family)) return family.toUpperCase(); return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim(); } - return stripped - .split('-') - .filter(Boolean) - .map(titleCaseSegment) - .join(' ') || String(raw || ''); + return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || ''); } function getModelProvider(raw) { - const lower = String(raw || '').toLowerCase(); - if (lower.includes('gpt') || lower.includes('openai') || lower.includes('/o1') || lower.includes('/o3') || /\bo\d\b/.test(lower)) return 'OpenAI'; - if (lower.includes('claude') || lower.includes('opus') || lower.includes('sonnet') || lower.includes('haiku')) return 'Anthropic'; - if (lower.includes('gemini')) return 'Google'; - if (lower.includes('grok') || lower.includes('xai')) return 'xAI'; - if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) return 'Meta'; - if (lower.includes('command') || lower.includes('cohere')) return 'Cohere'; - if (lower.includes('mistral')) return 'Mistral'; - if (lower.includes('deepseek')) return 'DeepSeek'; - if (lower.includes('qwen') || lower.includes('alibaba')) return 'Alibaba'; - if (lower.includes('opencode')) return 'OpenCode'; + const canonical = canonicalizeModelName(raw); + for (const matcher of PROVIDER_MATCHERS) { + if (matcher.matcher.test(canonical)) return matcher.provider; + } return 'Other'; } @@ -127,7 +237,8 @@ function recalculateDayFromBreakdowns(day, modelBreakdowns) { thinkingTokens, totalCost, requestCount, - totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, + totalTokens: + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, modelsUsed: modelBreakdowns.map((item) => item.modelName), modelBreakdowns, }; @@ -138,8 +249,12 @@ function filterByProviders(data, selectedProviders) { const selected = new Set(selectedProviders); return data .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(getModelProvider(entry.modelName))); - return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null; + const filteredBreakdowns = day.modelBreakdowns.filter((entry) => + selected.has(getModelProvider(entry.modelName)), + ); + return filteredBreakdowns.length > 0 + ? recalculateDayFromBreakdowns(day, filteredBreakdowns) + : null; }) .filter(Boolean); } @@ -149,17 +264,19 @@ function filterByModels(data, selectedModels) { const selected = new Set(selectedModels); return data .map((day) => { - const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(normalizeModelName(entry.modelName))); - return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null; + const filteredBreakdowns = day.modelBreakdowns.filter((entry) => + selected.has(normalizeModelName(entry.modelName)), + ); + return filteredBreakdowns.length > 0 + ? recalculateDayFromBreakdowns(day, filteredBreakdowns) + : null; }) .filter(Boolean); } function aggregateToDailyFormat(data, viewMode) { if (viewMode === 'daily') return data; - const groupKey = viewMode === 'monthly' - ? (date) => date.slice(0, 7) - : (date) => date.slice(0, 4); + const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4); const groups = new Map(); for (const day of data) { @@ -239,7 +356,8 @@ function toWeekdayData(data) { } return WEEKDAYS.map((label, index) => { const values = weekdayCosts[index]; - const average = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; + const average = + values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0; return { day: label, cost: average }; }); } @@ -338,7 +456,8 @@ function computeMetrics(data) { totalCacheCreate += day.cacheCreationTokens; totalThinking += day.thinkingTokens; activeDays += day._aggregatedDays || 1; - if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) hasRequestData = true; + if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) + hasRequestData = true; if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost }; if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost }; @@ -416,7 +535,12 @@ function computeModelRows(data) { _dates: new Set(), }; current.cost += breakdown.cost; - current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens; + current.tokens += + breakdown.inputTokens + + breakdown.outputTokens + + breakdown.cacheCreationTokens + + breakdown.cacheReadTokens + + breakdown.thinkingTokens; current.requests += breakdown.requestCount; if (!current._dates.has(day.date)) { current._dates.add(day.date); @@ -454,7 +578,12 @@ function computeProviderRows(data) { _dates: new Set(), }; current.cost += breakdown.cost; - current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens; + current.tokens += + breakdown.inputTokens + + breakdown.outputTokens + + breakdown.cacheCreationTokens + + breakdown.cacheReadTokens + + breakdown.thinkingTokens; current.requests += breakdown.requestCount; if (!current._dates.has(day.date)) { current._dates.add(day.date); @@ -464,7 +593,13 @@ function computeProviderRows(data) { } } return Array.from(rows.values()) - .map(({ _dates, ...entry }) => entry) + .map((entry) => ({ + name: entry.name, + cost: entry.cost, + tokens: entry.tokens, + requests: entry.requests, + days: entry.days, + })) .sort((a, b) => b.cost - a.cost); } @@ -491,7 +626,12 @@ function formatDate(dateStr, mode = 'short', language = 'de') { } const date = new Date(`${dateStr}T00:00:00`); if (mode === 'long') { - return date.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); + return date.toLocaleDateString(locale, { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); } return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' }); } @@ -504,7 +644,10 @@ function formatDateAxis(dateStr, language = 'de') { const date = new Date(Number(year), Number(month) - 1); return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' }); } - return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, { day: '2-digit', month: '2-digit' }); + return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + }); } function formatFilterValue(value, language = 'de') { @@ -575,10 +718,12 @@ function formatCompactAxis(value, language = 'de') { return formatCompactNumber(value, language); } -function summarizeSelection(values, language, { emptyKey, maxVisible = 3, normalize = (value) => value } = {}) { - const normalized = (values || []) - .map(normalize) - .filter(Boolean); +function summarizeSelection( + values, + language, + { emptyKey, maxVisible = 3, normalize = (value) => value } = {}, +) { + const normalized = (values || []).map(normalize).filter(Boolean); if (normalized.length === 0) { return translate(language, emptyKey); @@ -586,9 +731,8 @@ function summarizeSelection(values, language, { emptyKey, maxVisible = 3, normal const visible = normalized.slice(0, maxVisible); const hidden = normalized.length - visible.length; - const suffix = hidden > 0 - ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}` - : ''; + const suffix = + hidden > 0 ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}` : ''; return `${visible.join(', ')}${suffix}`; } @@ -657,7 +801,10 @@ function periodUnit(viewMode, language = 'de') { function applyReportFilters(allDailyData, filters) { const sorted = sortByDate(allDailyData); - const preProvider = filterByMonth(filterByDateRange(sorted, filters.startDate, filters.endDate), filters.selectedMonth); + const preProvider = filterByMonth( + filterByDateRange(sorted, filters.startDate, filters.endDate), + filters.selectedMonth, + ); const preModel = filterByProviders(preProvider, filters.selectedProviders || []); const filteredDaily = filterByModels(preModel, filters.selectedModels || []); const filtered = aggregateToDailyFormat(filteredDaily, filters.viewMode || 'daily'); @@ -692,32 +839,76 @@ function buildReportData(allDailyData, options = {}) { emptyKey: 'report.filters.all', normalize: normalizeModelName, }); - const monthLabel = formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all'); - const startDateLabel = formatFilterValue(filters.startDate || null, language) || translate(language, 'report.filters.noFilter'); - const endDateLabel = formatFilterValue(filters.endDate || null, language) || translate(language, 'report.filters.noFilter'); - const peakPeriodLabel = metrics.topDay ? formatDate(metrics.topDay.date, 'long', language) : notAvailable; + const monthLabel = + formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all'); + const startDateLabel = + formatFilterValue(filters.startDate || null, language) || + translate(language, 'report.filters.noFilter'); + const endDateLabel = + formatFilterValue(filters.endDate || null, language) || + translate(language, 'report.filters.noFilter'); + const peakPeriodLabel = metrics.topDay + ? formatDate(metrics.topDay.date, 'long', language) + : notAvailable; const topModelValue = metrics.topModel ? metrics.topModel.name : notAvailable; const topProviderValue = metrics.topProvider ? metrics.topProvider.name : notAvailable; const insights = buildInsights(metrics, { filteredDaily, filtered, language }); const avgPeriodCost = filtered.length > 0 ? metrics.totalCost / filtered.length : 0; - const recentRows = sortByDate(filtered).slice(-12).reverse().map((entry) => ({ - period: entry.date, - label: formatDate(entry.date, 'long', language), - cost: entry.totalCost, - costLabel: formatCurrency(entry.totalCost, language), - tokens: entry.totalTokens, - tokensLabel: formatCompact(entry.totalTokens, language), - requests: entry.requestCount, - requestsLabel: formatInteger(entry.requestCount, language), - })); + const recentRows = sortByDate(filtered) + .slice(-12) + .reverse() + .map((entry) => ({ + period: entry.date, + label: formatDate(entry.date, 'long', language), + cost: entry.totalCost, + costLabel: formatCurrency(entry.totalCost, language), + tokens: entry.totalTokens, + tokensLabel: formatCompact(entry.totalTokens, language), + requests: entry.requestCount, + requestsLabel: formatInteger(entry.requestCount, language), + })); const summaryCards = [ - { label: translate(language, 'common.costs'), value: formatCurrency(metrics.totalCost, language), note: metrics.topProvider ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}` : notAvailable, tone: 'accent' }, - { label: translate(language, 'common.tokens'), value: formatCompact(metrics.totalTokens, language), note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`, tone: 'accent' }, - { label: translate(language, 'common.requests'), value: formatInteger(metrics.totalRequests, language), note: metrics.hasRequestData ? `${formatPercent(metrics.cacheHitRate, language)} Cache` : notAvailable, tone: 'good' }, - { label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`, value: formatCurrency(avgPeriodCost, language), note: `${reportDataLabel(filters.viewMode, language)}`, tone: 'accent' }, - { label: translate(language, 'common.model'), value: topModelValue, note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable, tone: 'warn' }, - { label: translate(language, 'report.summary.peakPeriod'), value: peakPeriodLabel, note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable, tone: 'warn' }, + { + label: translate(language, 'common.costs'), + value: formatCurrency(metrics.totalCost, language), + note: metrics.topProvider + ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}` + : notAvailable, + tone: 'accent', + }, + { + label: translate(language, 'common.tokens'), + value: formatCompact(metrics.totalTokens, language), + note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`, + tone: 'accent', + }, + { + label: translate(language, 'common.requests'), + value: formatInteger(metrics.totalRequests, language), + note: metrics.hasRequestData + ? `${formatPercent(metrics.cacheHitRate, language)} Cache` + : notAvailable, + tone: 'good', + }, + { + label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`, + value: formatCurrency(avgPeriodCost, language), + note: `${reportDataLabel(filters.viewMode, language)}`, + tone: 'accent', + }, + { + label: translate(language, 'common.model'), + value: topModelValue, + note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable, + tone: 'warn', + }, + { + label: translate(language, 'report.summary.peakPeriod'), + value: peakPeriodLabel, + note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable, + tone: 'warn', + }, ]; const interpretationSummary = translate(language, 'report.interpretation.summary', { @@ -785,10 +976,18 @@ function buildReportData(allDailyData, options = {}) { })), recentPeriods: recentRows, labels: { - dateRangeText: dateRange ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` : translate(language, 'common.noData'), - topModel: metrics.topModel ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})` : notAvailable, - topProvider: metrics.topProvider ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})` : notAvailable, - topDay: metrics.topDay ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})` : notAvailable, + dateRangeText: dateRange + ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` + : translate(language, 'common.noData'), + topModel: metrics.topModel + ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})` + : notAvailable, + topProvider: metrics.topProvider + ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})` + : notAvailable, + topDay: metrics.topDay + ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})` + : notAvailable, }, interpretation: { summary: interpretationSummary, @@ -836,7 +1035,10 @@ function buildReportData(allDailyData, options = {}) { }, }, formatting: { - axisDates: filtered.map((entry) => ({ date: entry.date, label: formatDateAxis(entry.date, language) })), + axisDates: filtered.map((entry) => ({ + date: entry.date, + label: formatDateAxis(entry.date, language), + })), }, }; } @@ -862,4 +1064,8 @@ module.exports = { formatDateAxis, getModelColor, truncateLabel, + __test__: { + getModelProvider, + normalizeModelName, + }, }; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index cde1342..23113cf 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -49,16 +49,45 @@ 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, importSettings, importUsageData } from '@/lib/api' -import { formatCurrency, formatDateTimeCompact, formatDateTimeFull, formatTokens, formatPercent, periodUnit, localToday, toLocalDateStr } from '@/lib/formatters' +import { + generatePdfReport, + importSettings, + importUsageData, + type PdfReportRequest, +} from '@/lib/api' +import { + formatCurrency, + formatDateTimeCompact, + formatDateTimeFull, + formatTokens, + formatPercent, + periodUnit, + localToday, + toLocalDateStr, +} from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' import { SettingsModal } from './features/settings/SettingsModal' import { ProviderLimitsSection } from './features/limits/ProviderLimitsSection' -import type { AppLanguage, DashboardDefaultFilters, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ProviderLimits } 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 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 @@ -114,21 +143,20 @@ export function Dashboard() { 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 [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 daily = useMemo(() => usageData?.daily ?? [], [usageData]) const hasData = daily.length > 0 - const allProviders = useMemo(() => getUniqueProviders(daily.map(d => d.modelsUsed)), [daily]) - const allModelsFromData = useMemo(() => getUniqueModels(daily.map(d => d.modelsUsed)), [daily]) - const { - settings, - providerLimits, - setTheme, - setLanguage, - saveSettings, - isSaving, - } = useAppSettings(allProviders) + 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, saveSettings, isSaving } = + useAppSettings(allProviders) const isDark = settings.theme === 'dark' useEffect(() => { @@ -162,42 +190,55 @@ export function Dashboard() { }, []) const persistedLoadedTime = useMemo( - () => settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined, - [settings.lastLoadedAt, i18n.resolvedLanguage], + () => (settings.lastLoadedAt ? formatDateTimeCompact(settings.lastLoadedAt) : undefined), + [settings.lastLoadedAt], ) const persistedLoadedTitle = useMemo( - () => settings.lastLoadedAt ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) : undefined, - [settings.lastLoadedAt, i18n.resolvedLanguage, t], + () => + settings.lastLoadedAt + ? t('header.loadedAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) + : undefined, + [settings.lastLoadedAt, t], ) const persistedDataSource = useMemo(() => { if (!hasData) return null return { type: 'stored' as const, - time: persistedLoadedTime, - title: persistedLoadedTitle, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), + ...(persistedLoadedTitle ? { title: persistedLoadedTitle } : {}), } }, [hasData, persistedLoadedTime, persistedLoadedTitle]) const headerDataSource = dataSource ?? persistedDataSource - const startupAutoLoadBadge = useMemo(() => ( - settings.cliAutoLoadActive - ? { - active: true, - time: persistedLoadedTime, - title: settings.lastLoadedAt - ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) - : t('header.autoLoadActive'), - } - : null - ), [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, i18n.resolvedLanguage, t]) + const startupAutoLoadBadge = useMemo( + () => + settings.cliAutoLoadActive + ? { + active: true, + ...(persistedLoadedTime ? { time: persistedLoadedTime } : {}), + title: settings.lastLoadedAt + ? t('header.autoLoadAt', { time: formatDateTimeFull(settings.lastLoadedAt) }) + : t('header.autoLoadActive'), + } + : null, + [settings.cliAutoLoadActive, settings.lastLoadedAt, persistedLoadedTime, t], + ) const { - viewMode, setViewMode, - selectedMonth, setSelectedMonth, - selectedProviders, toggleProvider, clearProviders, - selectedModels, toggleModel, clearModels, - startDate, setStartDate, - endDate, setEndDate, + viewMode, + setViewMode, + selectedMonth, + setSelectedMonth, + selectedProviders, + toggleProvider, + clearProviders, + selectedModels, + toggleModel, + clearModels, + startDate, + setStartDate, + endDate, + setEndDate, resetAll, applyDefaultFilters, applyPreset, @@ -210,9 +251,18 @@ export function Dashboard() { } = useDashboardFilters(daily, settings.defaultFilters) const { - metrics, modelCosts, providerMetrics, costChartData, modelCostChartData, - tokenChartData, requestChartData, weekdayData, allModels, modelPieData, tokenPieData, - } = useComputedMetrics(filteredData, viewMode) + metrics, + modelCosts, + providerMetrics, + costChartData, + modelCostChartData, + tokenChartData, + requestChartData, + weekdayData, + allModels, + modelPieData, + tokenPieData, + } = useComputedMetrics(filteredData) // Full dataset with only model filter applied (no date/month filter) for PeriodComparison const comparisonData = filteredDailyData @@ -226,17 +276,30 @@ export function Dashboard() { }, [dateRange, viewMode]) const todayStr = localToday() - const todayData = useMemo(() => filteredDailyData.find(d => d.date === todayStr) ?? null, [filteredDailyData, todayStr]) - const hasCurrentMonthData = useMemo(() => filteredDailyData.some(d => d.date.startsWith(todayStr.slice(0, 7))), [filteredDailyData, todayStr]) - const visibleLimitProviders = useMemo(() => ( - selectedProviders.length > 0 ? selectedProviders : allProviders - ), [selectedProviders, allProviders]) + const todayData = useMemo( + () => filteredDailyData.find((d) => d.date === todayStr) ?? null, + [filteredDailyData, todayStr], + ) + const hasCurrentMonthData = useMemo( + () => filteredDailyData.some((d) => d.date.startsWith(todayStr.slice(0, 7))), + [filteredDailyData, todayStr], + ) + const visibleLimitProviders = useMemo( + () => (selectedProviders.length > 0 ? selectedProviders : allProviders), + [selectedProviders, allProviders], + ) const settingsProviderOptions = useMemo( - () => [...new Set([...allProviders, ...settings.defaultFilters.providers])].sort((left, right) => left.localeCompare(right)), + () => + [...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)), + () => + [...new Set([...allModelsFromData, ...settings.defaultFilters.models])].sort((left, right) => + left.localeCompare(right), + ), [allModelsFromData, settings.defaultFilters.models], ) const sectionVisibility = settings.sectionVisibility @@ -244,7 +307,7 @@ export function Dashboard() { // Compute active streak (consecutive days from today backwards) const streak = useMemo(() => { - const dates = new Set(filteredDailyData.map(d => d.date)) + const dates = new Set(filteredDailyData.map((d) => d.date)) let count = 0 const d = new Date(todayStr + 'T00:00:00') while (dates.has(toLocalDateStr(d))) { @@ -256,7 +319,7 @@ export function Dashboard() { const drillDownDay = useMemo(() => { if (!drillDownDate) return null - return filteredData.find(d => d.date === drillDownDate) ?? null + return filteredData.find((d) => d.date === drillDownDate) ?? null }, [drillDownDate, filteredData]) const handleUpload = useCallback(() => { @@ -271,54 +334,66 @@ export function Dashboard() { void setTheme(isDark ? 'light' : 'dark') }, [isDark, setTheme]) - const handleSaveSettings = useCallback(async (nextSettings: { - providerLimits: ProviderLimits - defaultFilters: DashboardDefaultFilters - sectionVisibility: DashboardSectionVisibility - sectionOrder: DashboardSectionOrder - }) => { - const updatedSettings = await saveSettings(nextSettings) - applyDefaultFilters(updatedSettings.defaultFilters) - addToast(t('toasts.settingsSaved'), 'success') - }, [saveSettings, applyDefaultFilters, addToast, t]) - - const handleLanguageChange = useCallback((language: AppLanguage) => { - if (settings.language !== language) { - void setLanguage(language) - } - if (i18n.resolvedLanguage !== language) { - void i18n.changeLanguage(language) - } - }, [i18n, setLanguage, settings.language]) + 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 handleFileChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - try { - const text = await file.text() - const json = JSON.parse(text) - await uploadMutation.mutateAsync(json) - void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) - const now = new Date() - const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) - setDataSource({ - type: 'file', - label: file.name, - time, - title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, - }) - addToast(t('toasts.fileLoaded', { name: file.name }), 'success') - } catch { - addToast(t('toasts.fileReadFailed'), 'error') - } - e.target.value = '' - }, [uploadMutation, addToast, t]) + const handleLanguageChange = useCallback( + (language: AppLanguage) => { + if (settings.language !== language) { + void setLanguage(language) + } + if (i18n.resolvedLanguage !== language) { + void i18n.changeLanguage(language) + } + }, + [i18n, setLanguage, settings.language], + ) + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + try { + const text = await file.text() + const json: unknown = JSON.parse(text) + await uploadMutation.mutateAsync(json) + void queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((prev) => prev + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { + hour: '2-digit', + minute: '2-digit', + }) + setDataSource({ + type: 'file', + label: file.name, + time, + title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, + }) + addToast(t('toasts.fileLoaded', { name: file.name }), 'success') + } catch { + addToast(t('toasts.fileReadFailed'), 'error') + } + e.target.value = '' + }, + [uploadMutation, queryClient, addToast, t], + ) const handleDelete = useCallback(async () => { await deleteMutation.mutateAsync() void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) + setAnimationSeed((prev) => prev + 1) setDataSource(null) addToast(t('toasts.dataDeleted'), 'info') }, [deleteMutation, queryClient, addToast, t]) @@ -333,15 +408,17 @@ export function Dashboard() { setReportGenerating(true) try { - const blob = await generatePdfReport({ + const requestLanguage: PdfReportRequest['language'] = i18n.language === 'en' ? 'en' : 'de' + const request: PdfReportRequest = { viewMode, selectedMonth, selectedProviders, selectedModels, - startDate, - endDate, - language: i18n.language === 'en' ? 'en' : 'de', - }) + language: requestLanguage, + ...(startDate ? { startDate } : {}), + ...(endDate ? { endDate } : {}), + } + const blob = await generatePdfReport(request) const objectUrl = URL.createObjectURL(blob) const a = document.createElement('a') a.href = objectUrl @@ -353,11 +430,25 @@ export function Dashboard() { addToast(t('commandPalette.commands.generateReport.label'), 'success') } catch (error) { console.error('PDF generation failed:', error) - addToast(`${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error') + addToast( + `${t('api.pdfFailed')}: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error', + ) } finally { setReportGenerating(false) } - }, [reportGenerating, viewMode, selectedMonth, selectedProviders, selectedModels, startDate, endDate, addToast, i18n.language, t]) + }, [ + reportGenerating, + viewMode, + selectedMonth, + selectedProviders, + selectedModels, + startDate, + endDate, + addToast, + i18n.language, + t, + ]) const handleAutoImport = useCallback(() => { setAutoImportOpen(true) @@ -366,12 +457,12 @@ export function Dashboard() { const handleAutoImportSuccess = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ['usage'] }) void queryClient.invalidateQueries({ queryKey: ['settings'] }) - setAnimationSeed(prev => prev + 1) + setAnimationSeed((prev) => prev + 1) const now = new Date() const time = now.toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit' }) setDataSource({ type: 'auto-import', - time, + ...(time ? { time } : {}), title: t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) }), }) addToast(t('toasts.dataImported'), 'success') @@ -421,298 +512,426 @@ export function Dashboard() { dataImportInputRef.current?.click() }, []) - const handleSettingsImportChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + 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]) + setSettingsTransferBusy(true) + try { + const parsed: unknown = JSON.parse(await file.text()) + const imported = await importSettings(parsed) + queryClient.setQueryData(['settings'], imported) + applyDefaultFilters(imported.defaultFilters) + addToast(t('toasts.settingsImported', { name: file.name }), 'success') + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setSettingsTransferBusy(false) + e.target.value = '' + } + }, + [queryClient, applyDefaultFilters, addToast, t], + ) - const handleDataImportChange = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + 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]) + setDataTransferBusy(true) + try { + const parsed: unknown = JSON.parse(await file.text()) + const summary = await importUsageData(parsed) + await queryClient.invalidateQueries({ queryKey: ['usage'] }) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + setAnimationSeed((prev) => prev + 1) + const now = new Date() + const time = now.toLocaleTimeString(getCurrentLocale(), { + hour: '2-digit', + minute: '2-digit', + }) + setDataSource({ + type: 'file', + label: file.name, + ...(time ? { time } : {}), + title: `${file.name} · ${t('header.loadedAt', { time: formatDateTimeFull(now.toISOString()) })}`, + }) + + const toastType: 'info' | 'success' = summary.conflictingDays > 0 ? 'info' : 'success' + const toastKey = + summary.conflictingDays > 0 + ? 'toasts.dataBackupImportedWithConflicts' + : 'toasts.dataBackupImported' + addToast( + t(toastKey, { + added: summary.addedDays, + unchanged: summary.unchangedDays, + conflicts: summary.conflictingDays, + }), + toastType, + ) + } catch (error) { + addToast(error instanceof Error ? error.message : t('toasts.fileReadFailed'), 'error') + } finally { + setDataTransferBusy(false) + e.target.value = '' + } + }, + [queryClient, addToast, t], + ) const handleScrollTo = useCallback((section: string) => { const el = document.getElementById(section) el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, []) - const renderSection = useCallback((sectionId: DashboardSectionId) => { - switch (sectionId) { - case 'insights': - return sectionVisibility.insights ? ( -
- -
- ) : null - case 'metrics': - return sectionVisibility.metrics ? ( -
- - - - - -
- d.totalCost)} viewMode={viewMode} /> -
-
-
- ) : null - case 'today': - return sectionVisibility.today && todayData ? ( -
- -
- ) : null - case 'currentMonth': - return sectionVisibility.currentMonth && hasCurrentMonthData ? ( -
- -
- ) : null - case 'activity': - return sectionVisibility.activity ? ( -
- - -
- - - -
-
-
- ) : null - case 'forecastCache': - return sectionVisibility.forecastCache ? ( -
- - -
- - - - - - -
-
-
- ) : null - case 'limits': - return sectionVisibility.limits ? ( -
- - { + 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 '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 'forecastCache': + return sectionVisibility.forecastCache ? ( +
+ + +
+ + + + + + +
+
+
+ ) : null + case 'limits': + return sectionVisibility.limits ? ( +
+ + -
-
- -
- -
-
-
- ) : 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, - ]) +
+
+ ) : 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 @@ -721,12 +940,43 @@ export function Dashboard() { if (!hasData) { return ( <> - - - - + + + + - {autoImportOpen && } + {autoImportOpen && ( + + )} - - - + + +
{t('header.settings')} - )} - pdfButton={( - - )} + } + pdfButton={ + + } />
@@ -810,8 +1078,8 @@ export function Dashboard() { selectedModels={selectedModels} onToggleModel={toggleModel} onClearModels={clearModels} - startDate={startDate} - endDate={endDate} + {...(startDate ? { startDate } : {})} + {...(endDate ? { endDate } : {})} onStartDateChange={setStartDate} onEndDateChange={setEndDate} onApplyPreset={applyPreset} @@ -819,11 +1087,12 @@ export function Dashboard() { />
-
+
{sectionOrder.map((sectionId) => ( - - {renderSection(sectionId)} - + {renderSection(sectionId)} ))}
@@ -833,7 +1102,7 @@ export function Dashboard() { setDrillDownDate(null)} /> )} @@ -876,7 +1145,13 @@ export function Dashboard() { /> - {autoImportOpen && } + {autoImportOpen && ( + + )} - -
- -
-
-

- TTDash -

-

v{VERSION}

-
-

- {t('emptyState.description')} -

- -

{t('emptyState.or')}

- - -
+ +
+ +
+
+

+ TTDash +

+

v{VERSION}

+
+

+ {t('emptyState.description')} +

+ +

{t('emptyState.or')}

+ + +
) diff --git a/src/components/cards/MetricCard.tsx b/src/components/cards/MetricCard.tsx index 57fc36f..5286b28 100644 --- a/src/components/cards/MetricCard.tsx +++ b/src/components/cards/MetricCard.tsx @@ -13,9 +13,22 @@ interface MetricCardProps { className?: string } -export function MetricCard({ label, value, subtitle, icon, trend, info, className }: MetricCardProps) { +export function MetricCard({ + label, + value, + subtitle, + icon, + trend, + info, + className, +}: MetricCardProps) { return ( - +
{label} @@ -25,18 +38,16 @@ export function MetricCard({ label, value, subtitle, icon, trend, info, classNam
{value}
- {subtitle && ( - {subtitle} - )} + {subtitle && {subtitle}} {trend && trend.value !== 0 && ( - 0 - ? 'text-red-400 bg-red-400/10' - : 'text-green-400 bg-green-400/10' - )}> - {trend.value > 0 ? '↑' : '↓'}{Math.abs(trend.value).toFixed(1)}% - {trend.label && ` ${trend.label}`} + 0 ? 'text-red-400 bg-red-400/10' : 'text-green-400 bg-green-400/10', + )} + > + {trend.value > 0 ? '↑' : '↓'} + {Math.abs(trend.value).toFixed(1)}%{trend.label && ` ${trend.label}`} )}
diff --git a/src/components/cards/MonthMetrics.tsx b/src/components/cards/MonthMetrics.tsx index f8455a7..7fd542c 100644 --- a/src/components/cards/MonthMetrics.tsx +++ b/src/components/cards/MonthMetrics.tsx @@ -1,6 +1,15 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { TrendingDown, DollarSign, Coins, Cpu, Database, CalendarDays, Activity, BrainCircuit } from 'lucide-react' +import { + TrendingDown, + DollarSign, + Coins, + Cpu, + Database, + CalendarDays, + Activity, + BrainCircuit, +} from 'lucide-react' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' import { SectionHeader } from '@/components/ui/section-header' @@ -20,14 +29,14 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const currentMonth = localMonth() const monthData = useMemo( - () => daily.filter(d => d.date.startsWith(currentMonth)), + () => daily.filter((d) => d.date.startsWith(currentMonth)), [daily, currentMonth], ) const prevMonth = useMemo(() => { - const [y, m] = currentMonth.split('-').map(Number) + const [y = 0, m = 1] = currentMonth.split('-').map(Number) const pm = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}` - return daily.filter(d => d.date.startsWith(pm)) + return daily.filter((d) => d.date.startsWith(pm)) }, [daily, currentMonth]) const agg = useMemo(() => { @@ -65,25 +74,48 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { const dayOfMonth = today.getDate() return { - totalCost, totalTokens, inputTokens, outputTokens, - cacheRead, cacheCreate, thinkingTokens, requestCount, cacheHitRate, costPerMillion, - activeDays: monthData.length, dayOfMonth, - modelCount: models.size, topModel, + totalCost, + totalTokens, + inputTokens, + outputTokens, + cacheRead, + cacheCreate, + thinkingTokens, + requestCount, + cacheHitRate, + costPerMillion, + activeDays: monthData.length, + dayOfMonth, + modelCount: models.size, + topModel, } }, [monthData]) - const prevMonthCost = useMemo( - () => prevMonth.reduce((s, d) => s + d.totalCost, 0), - [prevMonth], - ) + const prevMonthCost = useMemo(() => prevMonth.reduce((s, d) => s + d.totalCost, 0), [prevMonth]) if (!agg) return null - const diffToPrev = prevMonthCost > 0 - ? ((agg.totalCost - prevMonthCost) / prevMonthCost) * 100 - : null + const diffToPrev = + prevMonthCost > 0 ? ((agg.totalCost - prevMonthCost) / prevMonthCost) * 100 : null const ioTotal = agg.inputTokens + agg.outputTokens + const tokensSubtitle = + agg.inputTokens > 0 && agg.outputTokens > 0 + ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) + : null + const modelsSubtitle = agg.topModel + ? t('metricCards.month.topModel', { value: agg.topModel.name }) + : null + const costPerMillionSubtitle = + metrics.costPerMillion > 0 + ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) + : null + const thinkingSubtitle = + agg.totalTokens > 0 + ? t('metricCards.month.thinkingSubtitle', { + value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%`, + }) + : null return (
@@ -98,35 +130,41 @@ export function MonthMetrics({ daily, metrics }: MonthMetricsProps) { } - subtitle={t('metricCards.month.avgPerDay', { value: formatCurrency(agg.totalCost / agg.activeDays) })} + subtitle={t('metricCards.month.avgPerDay', { + value: formatCurrency(agg.totalCost / agg.activeDays), + })} icon={} - trend={diffToPrev !== null ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } : null} + trend={ + diffToPrev !== null + ? { value: diffToPrev, label: t('metricCards.month.vsPreviousMonth') } + : null + } /> } - subtitle={agg.inputTokens > 0 && agg.outputTokens > 0 - ? t('metricCards.month.ioRatio', { value: (agg.inputTokens / agg.outputTokens).toFixed(1) }) - : undefined} icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> } /> } + {...(modelsSubtitle ? { subtitle: modelsSubtitle } : {})} /> } - subtitle={metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : undefined} icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> 0 ? : t('common.notAvailable')} - subtitle={agg.requestCount > 0 - ? t('metricCards.month.requestsSubtitle', { value: (agg.requestCount / agg.activeDays).toFixed(1), cost: formatCurrency(agg.totalCost / agg.requestCount) }) - : t('metricCards.month.requestCountersMissing')} + value={ + agg.requestCount > 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + agg.requestCount > 0 + ? t('metricCards.month.requestsSubtitle', { + value: (agg.requestCount / agg.activeDays).toFixed(1), + cost: formatCurrency(agg.totalCost / agg.requestCount), + }) + : t('metricCards.month.requestCountersMissing') + } icon={} /> } - subtitle={agg.totalTokens > 0 ? t('metricCards.month.thinkingSubtitle', { value: `${((agg.thinkingTokens / agg.totalTokens) * 100).toFixed(1)}%` }) : undefined} icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} />
diff --git a/src/components/cards/PrimaryMetrics.tsx b/src/components/cards/PrimaryMetrics.tsx index e777403..82cfd04 100644 --- a/src/components/cards/PrimaryMetrics.tsx +++ b/src/components/cards/PrimaryMetrics.tsx @@ -1,4 +1,13 @@ -import { DollarSign, Coins, Calendar, Cpu, Database, TrendingDown, Activity, BrainCircuit } from 'lucide-react' +import { + DollarSign, + Coins, + Calendar, + Cpu, + Database, + TrendingDown, + Activity, + BrainCircuit, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' @@ -12,22 +21,60 @@ interface PrimaryMetricsProps { viewMode?: ViewMode } -export function PrimaryMetrics({ metrics, totalCalendarDays, viewMode = 'daily' }: PrimaryMetricsProps) { +export function PrimaryMetrics({ + metrics, + totalCalendarDays, + viewMode = 'daily', +}: PrimaryMetricsProps) { const { t } = useTranslation() // Calculate input/output ratio - const ioRatio = metrics.totalInput > 0 && metrics.totalOutput > 0 - ? (metrics.totalInput / metrics.totalOutput).toFixed(1) - : null + const ioRatio = + metrics.totalInput > 0 && metrics.totalOutput > 0 + ? (metrics.totalInput / metrics.totalOutput).toFixed(1) + : null - const coverageRate = totalCalendarDays && viewMode === 'daily' - ? (metrics.activeDays / totalCalendarDays) * 100 + const coverageRate = + totalCalendarDays && viewMode === 'daily' + ? (metrics.activeDays / totalCalendarDays) * 100 + : null + const topModelSubtitle = metrics.topModel + ? `${formatCurrency(metrics.topModel.cost)} · ${t('metricCards.primary.share', { value: formatPercent(metrics.topModelShare, 0) })}${metrics.topRequestModel ? ` · ${t('metricCards.primary.requestLead', { value: metrics.topRequestModel.name })}` : ''}` : null + const cacheHitRateSubtitle = + metrics.totalTokens > 0 + ? t('metricCards.primary.allTokensViaCacheRead', { + value: formatPercent((metrics.totalCacheRead / metrics.totalTokens) * 100), + }) + : null + const thinkingInsight = + metrics.totalTokens > 0 + ? t('metricCards.primary.thinkingShareOfVolume', { + value: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), + }) + : null + const thinkingSubtitle = + metrics.totalTokens > 0 + ? t('metricCards.primary.thinkingSubtitle', { + share: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), + tokens: formatTokens(metrics.totalThinking / Math.max(metrics.totalRequests, 1)), + }) + : null return (
} + value={ + + } subtitle={`Ø ${formatCurrency(metrics.avgDailyCost)}/${periodUnit(viewMode)} · ${formatCurrency(metrics.avgCostPerRequest)}/Req`} icon={} trend={metrics.weekOverWeekChange !== null ? { value: metrics.weekOverWeekChange } : null} @@ -35,35 +82,51 @@ export function PrimaryMetrics({ metrics, totalCalendarDays, viewMode = 'daily' /> } - subtitle={ioRatio ? `I/O ${ioRatio}:1 · ${formatTokens(metrics.avgTokensPerRequest)} / Request` : `${formatTokens(metrics.avgTokensPerRequest)} / Request`} + value={ + + } + subtitle={ + ioRatio + ? `I/O ${ioRatio}:1 · ${formatTokens(metrics.avgTokensPerRequest)} / Request` + : `${formatTokens(metrics.avgTokensPerRequest)} / Request` + } icon={} info={METRIC_HELP.totalTokens} /> } info={METRIC_HELP.activeDays} /> } info={METRIC_HELP.topModel} + {...(topModelSubtitle ? { subtitle: topModelSubtitle } : {})} /> } - subtitle={metrics.totalTokens > 0 ? t('metricCards.primary.allTokensViaCacheRead', { value: formatPercent((metrics.totalCacheRead / metrics.totalTokens) * 100) }) : undefined} icon={} info={METRIC_HELP.cacheHitRate} + {...(cacheHitRateSubtitle ? { subtitle: cacheHitRateSubtitle } : {})} /> : t('common.notAvailable')} - subtitle={metrics.hasRequestData - ? t('metricCards.primary.requestsSubtitle', { requests: metrics.avgRequestsPerDay.toFixed(1), unit: periodUnit(viewMode), cost: formatCurrency(metrics.avgCostPerRequest), volatility: Math.round(metrics.requestVolatility) }) - : t('metricCards.primary.requestCountersMissing')} + value={ + metrics.hasRequestData ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={ + metrics.hasRequestData + ? t('metricCards.primary.requestsSubtitle', { + requests: metrics.avgRequestsPerDay.toFixed(1), + unit: periodUnit(viewMode), + cost: formatCurrency(metrics.avgCostPerRequest), + volatility: Math.round(metrics.requestVolatility), + }) + : t('metricCards.primary.requestCountersMissing') + } icon={} /> 0 ? t('metricCards.primary.thinkingShareOfVolume', { value: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100) }) : undefined} />} - subtitle={metrics.totalTokens > 0 - ? t('metricCards.primary.thinkingSubtitle', { share: formatPercent((metrics.totalThinking / metrics.totalTokens) * 100), tokens: formatTokens(metrics.totalThinking / Math.max(metrics.totalRequests, 1)) }) - : undefined} + value={ + + } icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} />
) diff --git a/src/components/cards/SecondaryMetrics.tsx b/src/components/cards/SecondaryMetrics.tsx index 93d5568..ab5982a 100644 --- a/src/components/cards/SecondaryMetrics.tsx +++ b/src/components/cards/SecondaryMetrics.tsx @@ -2,7 +2,13 @@ import { TrendingUp, ChartBar, Sigma, Building2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' -import { formatDate, formatCurrency, formatNumber, formatPercent, periodUnit } from '@/lib/formatters' +import { + formatDate, + formatCurrency, + formatNumber, + formatPercent, + periodUnit, +} from '@/lib/formatters' import { METRIC_HELP } from '@/lib/help-content' import type { DashboardMetrics, ViewMode } from '@/types' @@ -12,63 +18,106 @@ interface SecondaryMetricsProps { viewMode?: ViewMode } -export function SecondaryMetrics({ metrics, dailyCosts, viewMode = 'daily' }: SecondaryMetricsProps) { +export function SecondaryMetrics({ + metrics, + dailyCosts, + viewMode = 'daily', +}: SecondaryMetricsProps) { const { t } = useTranslation() // Calculate spread between most and least expensive days - const costSpread = metrics.topDay && metrics.cheapestDay - ? metrics.topDay.cost - metrics.cheapestDay.cost - : null + const costSpread = + metrics.topDay && metrics.cheapestDay ? metrics.topDay.cost - metrics.cheapestDay.cost : null // Calculate median const median = (() => { if (!dailyCosts || dailyCosts.length === 0) return null const sorted = [...dailyCosts].sort((a, b) => a - b) const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 + const midValue = sorted[mid] + if (midValue === undefined) return null + if (sorted.length % 2) return midValue + const previousValue = sorted[mid - 1] + if (previousValue === undefined) return null + return (previousValue + midValue) / 2 })() const requestLeader = metrics.topRequestModel - ? t('metricCards.secondary.requestLeader', { model: metrics.topRequestModel.name, requests: formatNumber(metrics.topRequestModel.requests) }) + ? t('metricCards.secondary.requestLeader', { + model: metrics.topRequestModel.name, + requests: formatNumber(metrics.topRequestModel.requests), + }) + : null + const topDaySubtitle = metrics.topDay ? formatDate(metrics.topDay.date, 'long') : null + const topProviderSubtitle = metrics.topProvider + ? t('metricCards.secondary.dominantProviderSubtitle', { + share: formatPercent(metrics.topProvider.share, 0), + cost: formatCurrency(metrics.topProvider.cost), + requestLeader: requestLeader ? ` · ${requestLeader}` : '', + }) : null + const peakSubtitle = + viewMode === 'daily' && metrics.busiestWeek + ? `${formatDate(metrics.busiestWeek.start)} – ${formatDate(metrics.busiestWeek.end)}` + : costSpread !== null + ? t('metricCards.secondary.spread', { value: formatCurrency(costSpread) }) + : null + const medianSubtitle = + median !== null && metrics.avgDailyCost > 0 + ? t('metricCards.secondary.vsAverageWithVolatility', { + direction: median < metrics.avgDailyCost ? '↓' : '↑', + value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed( + 0, + ), + volatility: Math.round(metrics.requestVolatility), + }) + : null return (
: '–'} - subtitle={metrics.topDay ? formatDate(metrics.topDay.date, 'long') : undefined} + label={ + viewMode === 'yearly' + ? t('metricCards.secondary.mostExpensiveYear') + : viewMode === 'monthly' + ? t('metricCards.secondary.mostExpensiveMonth') + : t('metricCards.secondary.mostExpensiveDay') + } + value={ + metrics.topDay ? : '–' + } icon={} info={METRIC_HELP.mostExpensiveDay} + {...(topDaySubtitle ? { subtitle: topDaySubtitle } : {})} /> } info={t('metricCards.secondary.medianInfo')} + {...(topProviderSubtitle ? { subtitle: topProviderSubtitle } : {})} /> : } - subtitle={viewMode === 'daily' && metrics.busiestWeek - ? `${formatDate(metrics.busiestWeek.start)} – ${formatDate(metrics.busiestWeek.end)}` - : costSpread !== null ? t('metricCards.secondary.spread', { value: formatCurrency(costSpread) }) : undefined} + label={ + viewMode === 'daily' + ? t('metricCards.secondary.peak7Days') + : t('metricCards.secondary.avgCostPerUnit', { unit: periodUnit(viewMode) }) + } + value={ + viewMode === 'daily' && metrics.busiestWeek ? ( + + ) : ( + + ) + } icon={} info={METRIC_HELP.avgCostPerDay} + {...(peakSubtitle ? { subtitle: peakSubtitle } : {})} /> : '–'} - subtitle={median !== null && metrics.avgDailyCost > 0 - ? `${t('metricCards.secondary.vsAverage', { direction: median < metrics.avgDailyCost ? '↓' : '↑', value: Math.abs(((median - metrics.avgDailyCost) / metrics.avgDailyCost) * 100).toFixed(0) })} · σ Req ${Math.round(metrics.requestVolatility)}` - : undefined} icon={} info={t('metricCards.secondary.medianInfo')} + {...(medianSubtitle ? { subtitle: medianSubtitle } : {})} />
) diff --git a/src/components/cards/TodayMetrics.tsx b/src/components/cards/TodayMetrics.tsx index f728297..7a831be 100644 --- a/src/components/cards/TodayMetrics.tsx +++ b/src/components/cards/TodayMetrics.tsx @@ -1,4 +1,12 @@ -import { TrendingDown, DollarSign, Coins, Cpu, Database, Activity, BrainCircuit } from 'lucide-react' +import { + TrendingDown, + DollarSign, + Coins, + Cpu, + Database, + Activity, + BrainCircuit, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { MetricCard } from './MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' @@ -16,17 +24,56 @@ interface TodayMetricsProps { export function TodayMetrics({ today, metrics }: TodayMetricsProps) { const { t } = useTranslation() - const cacheHitRate = (today.cacheReadTokens + today.cacheCreationTokens) > 0 - ? (today.cacheReadTokens / (today.cacheReadTokens + today.cacheCreationTokens + today.inputTokens + today.outputTokens + today.thinkingTokens)) * 100 - : 0 + const modelsCount = today.modelsUsed?.length ?? 0 + const cacheHitRate = + today.cacheReadTokens + today.cacheCreationTokens > 0 + ? (today.cacheReadTokens / + (today.cacheReadTokens + + today.cacheCreationTokens + + today.inputTokens + + today.outputTokens + + today.thinkingTokens)) * + 100 + : 0 const topModel = today.modelBreakdowns?.length - ? today.modelBreakdowns.reduce((a, b) => a.cost > b.cost ? a : b) + ? today.modelBreakdowns.reduce((a, b) => (a.cost > b.cost ? a : b)) : null - const diffToAvg = metrics.avgDailyCost > 0 - ? ((today.totalCost - metrics.avgDailyCost) / metrics.avgDailyCost) * 100 + const diffToAvg = + metrics.avgDailyCost > 0 + ? ((today.totalCost - metrics.avgDailyCost) / metrics.avgDailyCost) * 100 + : null + const costSubtitle = + diffToAvg !== null + ? t('metricCards.today.avgPerDay', { value: formatCurrency(metrics.avgDailyCost) }) + : null + const tokensSubtitle = + today.inputTokens > 0 && today.outputTokens > 0 + ? t('metricCards.today.ioRatio', { + value: (today.inputTokens / today.outputTokens).toFixed(1), + }) + : null + const modelSubtitle = topModel + ? t('metricCards.today.topModel', { value: normalizeModelName(topModel.modelName) }) : null + const costPerMillionSubtitle = + metrics.costPerMillion > 0 + ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) + : null + const requestsSubtitle = + today.requestCount > 0 && modelsCount > 0 + ? t('metricCards.today.requestsSubtitle', { + value: (today.requestCount / modelsCount).toFixed(1), + cost: formatCurrency(today.totalCost / today.requestCount), + }) + : t('metricCards.today.requestCountersMissing') + const thinkingSubtitle = + today.totalTokens > 0 + ? t('metricCards.today.thinkingSubtitle', { + value: formatPercent((today.thinkingTokens / today.totalTokens) * 100), + }) + : null return (
@@ -40,49 +87,76 @@ export function TodayMetrics({ today, metrics }: TodayMetricsProps) { } - subtitle={diffToAvg !== null ? t('metricCards.today.avgPerDay', { value: formatCurrency(metrics.avgDailyCost) }) : undefined} icon={} - trend={diffToAvg !== null ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } : null} + trend={ + diffToAvg !== null + ? { value: diffToAvg, label: t('metricCards.today.vsAverageShort') } + : null + } + {...(costSubtitle ? { subtitle: costSubtitle } : {})} /> 0 ? today.totalTokens / today.requestCount : 0)} / Request`} />} - subtitle={today.inputTokens > 0 && today.outputTokens > 0 - ? t('metricCards.today.ioRatio', { value: (today.inputTokens / today.outputTokens).toFixed(1) }) - : undefined} + value={ + 0 ? today.totalTokens / today.requestCount : 0)} / Request`} + /> + } icon={} + {...(tokensSubtitle ? { subtitle: tokensSubtitle } : {})} /> } + {...(modelSubtitle ? { subtitle: modelSubtitle } : {})} /> 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0} type="currency" />} - subtitle={metrics.costPerMillion > 0 ? t('metricCards.today.overallAverage', { value: formatCurrency(metrics.costPerMillion) }) : undefined} + value={ + 0 ? today.totalCost / (today.totalTokens / 1_000_000) : 0 + } + type="currency" + /> + } icon={} + {...(costPerMillionSubtitle ? { subtitle: costPerMillionSubtitle } : {})} /> } - subtitle={t('metricCards.today.cacheShare', { value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100) })} + subtitle={t('metricCards.today.cacheShare', { + value: formatPercent((today.cacheReadTokens / (today.totalTokens || 1)) * 100), + })} icon={} /> 0 ? : t('common.notAvailable')} - subtitle={today.requestCount > 0 && today.modelsUsed?.length - ? t('metricCards.today.requestsSubtitle', { value: (today.requestCount / today.modelsUsed.length).toFixed(1), cost: formatCurrency(today.totalCost / today.requestCount) }) - : t('metricCards.today.requestCountersMissing')} + value={ + today.requestCount > 0 ? ( + + ) : ( + t('common.notAvailable') + ) + } + subtitle={requestsSubtitle} icon={} /> } - subtitle={today.totalTokens > 0 ? t('metricCards.today.thinkingSubtitle', { value: formatPercent((today.thinkingTokens / today.totalTokens) * 100) }) : undefined} icon={} + {...(thinkingSubtitle ? { subtitle: thinkingSubtitle } : {})} />
diff --git a/src/components/charts/ChartCard.tsx b/src/components/charts/ChartCard.tsx index d05c165..8a114f8 100644 --- a/src/components/charts/ChartCard.tsx +++ b/src/components/charts/ChartCard.tsx @@ -1,4 +1,12 @@ -import { createContext, useState, useMemo, useCallback, useContext, useRef, type ReactNode } from 'react' +import { + createContext, + useState, + useMemo, + useCallback, + useContext, + useRef, + type ReactNode, +} from 'react' import { useTranslation } from 'react-i18next' import { motion, useInView } from 'framer-motion' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card' @@ -22,6 +30,41 @@ interface ChartCardProps { expandedExtra?: ReactNode } +export function stringifyCsvCell(value: unknown): string { + let stringValue = '' + + if (value == null) return '' + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + stringValue = String(value) + } else { + try { + stringValue = JSON.stringify(value) ?? '' + } catch { + stringValue = '' + } + } + + return `"${stringValue.replace(/"/g, '""')}"` +} + +export function buildChartCsv(chartData: Record[]): string { + if (chartData.length === 0) return '' + + const firstRow = chartData[0] + if (!firstRow) return '' + + const keys = Object.keys(firstRow) + return [ + keys.map((key) => stringifyCsvCell(key)).join(','), + ...chartData.map((row) => keys.map((key) => stringifyCsvCell(row[key])).join(',')), + ].join('\n') +} + const ChartAnimationContext = createContext(false) export function useChartAnimationActive() { @@ -39,21 +82,28 @@ interface ChartRevealProps { duration?: number } -export function ChartReveal({ children, variant = 'line', delay = 0, duration = 0.7 }: ChartRevealProps) { +export function ChartReveal({ + children, + variant = 'line', + delay = 0, + duration = 0.7, +}: ChartRevealProps) { const active = useChartAnimationActive() const resolvedDuration = variant === 'radial' ? Math.max(duration, 0.95) : Math.max(duration, 0.9) - const hidden = variant === 'bar' - ? { opacity: 0, clipPath: 'inset(100% 0 0 0 round 16px)', y: 10 } - : variant === 'radial' - ? { opacity: 0, scale: 0.82, rotate: -18 } - : { opacity: 0, clipPath: 'inset(0 100% 0 0 round 16px)', x: -8 } + const hidden = + variant === 'bar' + ? { opacity: 0, clipPath: 'inset(100% 0 0 0 round 16px)', y: 10 } + : variant === 'radial' + ? { opacity: 0, scale: 0.82, rotate: -18 } + : { opacity: 0, clipPath: 'inset(0 100% 0 0 round 16px)', x: -8 } - const visible = variant === 'bar' - ? { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', y: 0 } - : variant === 'radial' - ? { opacity: 1, scale: 1, rotate: 0 } - : { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', x: 0 } + const visible = + variant === 'bar' + ? { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', y: 0 } + : variant === 'radial' + ? { opacity: 1, scale: 1, rotate: 0 } + : { opacity: 1, clipPath: 'inset(0 0 0 0 round 16px)', x: 0 } return ( (null) @@ -84,7 +146,9 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c const stats = useMemo(() => { if (!chartData || !valueKey) return null - const values = chartData.map(d => d[valueKey]).filter((v): v is number => typeof v === 'number' && !isNaN(v)) + const values = chartData + .map((d) => d[valueKey]) + .filter((v): v is number => typeof v === 'number' && !isNaN(v)) if (values.length === 0) return null const sum = values.reduce((s, v) => s + v, 0) return { @@ -97,18 +161,19 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c }, [chartData, valueKey]) const fmt = valueFormatter ?? formatCurrency - const renderChildren = (isExpanded: boolean) => typeof children === 'function' - ? children(isExpanded) - : children + const renderChildren = (isExpanded: boolean) => + typeof children === 'function' ? children(isExpanded) : children const handleExport = useCallback(() => { if (!chartData || chartData.length === 0) return - const keys = Object.keys(chartData[0]) - const csv = [keys.join(','), ...chartData.map(row => keys.map(k => String(row[k] ?? '')).join(','))].join('\n') + const csv = buildChartCsv(chartData) + if (!csv) return const blob = new Blob([csv], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') - a.href = url; a.download = `${title}.csv`; a.click() + a.href = url + a.download = `${title}.csv` + a.click() URL.revokeObjectURL(url) }, [chartData, title]) @@ -120,14 +185,10 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c {info && }
- {summary && ( - {summary} - )} + {summary && {summary}}
- {subtitle && ( - {subtitle} - )} + {subtitle && {subtitle}} ) @@ -159,7 +220,7 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c
-
+

{title}

@@ -177,23 +238,35 @@ export function ChartCard({ title, subtitle, summary, info, expandable = true, c {stats && (
-
Min
+
+ {t('dashboard.stats.min')} +
{fmt(stats.min)}
-
Max
+
+ {t('dashboard.stats.max')} +
{fmt(stats.max)}
-
Avg
+
+ {t('dashboard.stats.avg')} +
{fmt(stats.avg)}
-
Gesamt
-
{fmt(stats.total)}
+
+ {t('dashboard.stats.total')} +
+
+ {fmt(stats.total)} +
-
Datenpunkte
+
+ {t('dashboard.stats.dataPoints')} +
{stats.count}
diff --git a/src/components/charts/CorrelationAnalysis.tsx b/src/components/charts/CorrelationAnalysis.tsx index 19b61fd..997d7c5 100644 --- a/src/components/charts/CorrelationAnalysis.tsx +++ b/src/components/charts/CorrelationAnalysis.tsx @@ -1,12 +1,27 @@ import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { motion, useInView } from 'framer-motion' -import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ZAxis } from 'recharts' +import { + ResponsiveContainer, + ScatterChart, + Scatter, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ZAxis, +} from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoButton } from '@/components/features/help/InfoButton' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' -import { formatCurrency, formatDate, formatNumber, formatPercent, formatTokens } from '@/lib/formatters' +import { + formatCurrency, + formatDate, + formatNumber, + formatPercent, + formatTokens, +} from '@/lib/formatters' import type { DailyUsage } from '@/types' interface CorrelationAnalysisProps { @@ -23,22 +38,50 @@ interface ScatterPoint { cacheRate?: number } +function getCorrelationInterpretation( + t: ReturnType['t'], + correlationValue: number, + mode: 'requestCost' | 'cacheEfficiency', +) { + if (mode === 'requestCost') { + if (correlationValue >= 0.6) return t('charts.correlation.strongRequestCost') + if (correlationValue >= 0.3) return t('charts.correlation.mediumRequestCost') + return t('charts.correlation.weakRequestCost') + } + + if (correlationValue <= -0.3) return t('charts.correlation.negativeCache') + if (correlationValue < 0.2) return t('charts.correlation.neutralCache') + return t('charts.correlation.positiveCache') +} + function correlation(valuesA: number[], valuesB: number[]) { if (valuesA.length !== valuesB.length || valuesA.length < 2) return 0 const avgA = valuesA.reduce((sum, value) => sum + value, 0) / valuesA.length const avgB = valuesB.reduce((sum, value) => sum + value, 0) / valuesB.length - const covariance = valuesA.reduce((sum, value, index) => sum + (value - avgA) * (valuesB[index] - avgB), 0) + const covariance = valuesA.reduce((sum, value, index) => { + const otherValue = valuesB[index] + return otherValue === undefined ? sum : sum + (value - avgA) * (otherValue - avgB) + }, 0) const varianceA = valuesA.reduce((sum, value) => sum + (value - avgA) ** 2, 0) const varianceB = valuesB.reduce((sum, value) => sum + (value - avgB) ** 2, 0) if (varianceA === 0 || varianceB === 0) return 0 return covariance / Math.sqrt(varianceA * varianceB) } -function ScatterTooltip({ active, payload, mode }: { active?: boolean; payload?: Array<{ payload: ScatterPoint }>; mode: 'requestCost' | 'cacheEfficiency' }) { +function ScatterTooltip({ + active, + payload, + mode, +}: { + active?: boolean + payload?: Array<{ payload: ScatterPoint }> + mode: 'requestCost' | 'cacheEfficiency' +}) { const { t } = useTranslation() if (!active || !payload?.length) return null - const point = payload[0].payload + const point = payload[0]?.payload + if (!point) return null return (
@@ -48,7 +91,9 @@ function ScatterTooltip({ active, payload, mode }: { active?: boolean; payload?: <>
{t('charts.correlation.requestsLabel')} - {point.requests !== undefined ? formatNumber(point.requests) : '–'} + + {point.requests !== undefined ? formatNumber(point.requests) : '–'} +
{t('charts.correlation.cost')} @@ -56,22 +101,30 @@ function ScatterTooltip({ active, payload, mode }: { active?: boolean; payload?:
{t('charts.correlation.tokensLabel')} - {point.tokens ? formatTokens(point.tokens) : '–'} + + {point.tokens !== undefined ? formatTokens(point.tokens) : '–'} +
) : ( <>
{t('charts.correlation.cacheRate')} - {point.cacheRate !== undefined ? formatPercent(point.cacheRate, 1) : '–'} + + {point.cacheRate !== undefined ? formatPercent(point.cacheRate, 1) : '–'} +
- {t('charts.correlation.costPerRequest')} + + {t('charts.correlation.costPerRequest')} + {formatCurrency(point.y)}
{t('charts.correlation.requestsLabel')} - {point.requests !== undefined ? formatNumber(point.requests) : '–'} + + {point.requests !== undefined ? formatNumber(point.requests) : '–'} +
)} @@ -122,14 +175,33 @@ function CorrelationPanel({ >
-
{title}
+
+ {title} +
{subtitle}
- - + + } cursor={{ strokeDasharray: '4 4' }} /> (() => data.map(entry => ({ - x: entry.requestCount, - y: entry.totalCost, - z: Math.max(5, Math.sqrt(entry.totalTokens / 1000)), - label: entry.date, - tokens: entry.totalTokens, - requests: entry.requestCount, - })), [data]) - - const cacheVsCostPerRequest = useMemo(() => data - .filter(entry => entry.requestCount > 0 && entry.totalTokens > 0) - .map(entry => { - const cacheShare = (entry.cacheReadTokens / entry.totalTokens) * 100 - return { - x: cacheShare, - y: entry.totalCost / entry.requestCount, - z: Math.max(5, Math.sqrt(entry.requestCount)), + const requestVsCost = useMemo( + () => + data.map((entry) => ({ + x: entry.requestCount, + y: entry.totalCost, + z: Math.max(5, Math.sqrt(entry.totalTokens / 1000)), label: entry.date, - cacheRate: cacheShare, + tokens: entry.totalTokens, requests: entry.requestCount, - } - }), [data]) + })), + [data], + ) - const requestCostCorrelation = correlation(requestVsCost.map(point => point.x), requestVsCost.map(point => point.y)) - const cacheEfficiencyCorrelation = correlation(cacheVsCostPerRequest.map(point => point.x), cacheVsCostPerRequest.map(point => point.y)) + const cacheVsCostPerRequest = useMemo( + () => + data + .filter((entry) => entry.requestCount > 0 && entry.totalTokens > 0) + .map((entry) => { + const cacheShare = (entry.cacheReadTokens / entry.totalTokens) * 100 + return { + x: cacheShare, + y: entry.totalCost / entry.requestCount, + z: Math.max(5, Math.sqrt(entry.requestCount)), + label: entry.date, + cacheRate: cacheShare, + requests: entry.requestCount, + } + }), + [data], + ) + + const requestCostCorrelation = correlation( + requestVsCost.map((point) => point.x), + requestVsCost.map((point) => point.y), + ) + const cacheEfficiencyCorrelation = correlation( + cacheVsCostPerRequest.map((point) => point.x), + cacheVsCostPerRequest.map((point) => point.y), + ) if (data.length < 2) { return ( @@ -212,7 +298,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { color={CHART_COLORS.cost} xAxisName={t('charts.correlation.requestsAxis')} yAxisName={t('charts.correlation.cost')} - footer={requestCostCorrelation >= 0.6 ? t('charts.correlation.strongRequestCost') : requestCostCorrelation >= 0.3 ? t('charts.correlation.mediumRequestCost') : t('charts.correlation.weakRequestCost')} + footer={getCorrelationInterpretation(t, requestCostCorrelation, 'requestCost')} delay={0.02} /> @@ -226,7 +312,7 @@ export function CorrelationAnalysis({ data }: CorrelationAnalysisProps) { xAxisName={t('charts.correlation.cacheRate')} xTickFormatter={(value) => formatPercent(value, 0)} yAxisName={t('charts.correlation.costPerRequestAxis')} - footer={cacheEfficiencyCorrelation <= -0.3 ? t('charts.correlation.negativeCache') : cacheEfficiencyCorrelation < 0.2 ? t('charts.correlation.neutralCache') : t('charts.correlation.positiveCache')} + footer={getCorrelationInterpretation(t, cacheEfficiencyCorrelation, 'cacheEfficiency')} delay={0.08} /> diff --git a/src/components/charts/CostByModel.tsx b/src/components/charts/CostByModel.tsx index cc547ae..2a4e994 100644 --- a/src/components/charts/CostByModel.tsx +++ b/src/components/charts/CostByModel.tsx @@ -20,7 +20,14 @@ function CenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: number }; {t('charts.costByModel.total')} - + {total} @@ -32,7 +39,14 @@ export function CostByModel({ data }: CostByModelProps) { const total = data.reduce((sum, d) => sum + d.value, 0) return ( - []} valueKey="value" valueFormatter={formatCurrency}> + []} + valueKey="value" + valueFormatter={formatCurrency} + > {(expanded) => { const chartHeight = expanded ? 560 : 320 const pieCenterY = expanded ? '66%' : '57%' @@ -69,8 +83,12 @@ export function CostByModel({ data }: CostByModelProps) { { - const entry = data.find(d => d.name === value) - return {value} ({entry ? formatCurrency(entry.value) : ''}) + const entry = data.find((d) => d.name === value) + return ( + + {value} ({entry ? formatCurrency(entry.value) : ''}) + + ) }} /> diff --git a/src/components/charts/CostByModelOverTime.tsx b/src/components/charts/CostByModelOverTime.tsx index 8b64170..3cf45fc 100644 --- a/src/components/charts/CostByModelOverTime.tsx +++ b/src/components/charts/CostByModelOverTime.tsx @@ -1,10 +1,19 @@ -import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts' +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { getModelColor } from '@/lib/model-utils' -import { formatCurrency, formatDateAxis } from '@/lib/formatters' +import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' import type { ChartDataPoint } from '@/types' @@ -15,46 +24,67 @@ interface CostByModelOverTimeProps { export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) { const { t } = useTranslation() - const topModel = models - .map(model => ({ - model, - total: data.reduce((sum, point) => sum + (typeof point[model] === 'number' ? point[model] : 0), 0), - })) - .sort((a, b) => b.total - a.total)[0] ?? null + const topModel = + models + .map((model) => ({ + model, + total: data.reduce((sum, point) => { + const value = coerceNumber(point[model]) + return sum + (value ?? 0) + }, 0), + })) + .sort((a, b) => b.total - a.total)[0] ?? null // Expanded extra: taller chart with per-model 7-day MA lines const expandedChart = ( {(animate) => (
-
{t('charts.costByModelOverTime.movingAverageHeading')}
+
+ {t('charts.costByModelOverTime.movingAverageHeading')} +
- - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} - cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} - /> - - {models.map(model => ( - + - ))} + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} + /> + + {models.map((model, index) => ( + + ))} @@ -66,7 +96,14 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) return ( []} @@ -79,29 +116,44 @@ export function CostByModelOverTime({ data, models }: CostByModelOverTimeProps) - - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} - cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} - /> - - {models.map(model => ( - + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} /> - ))} + + {models.map((model, index) => ( + + ))} diff --git a/src/components/charts/CostByWeekday.tsx b/src/components/charts/CostByWeekday.tsx index 2e9a503..c79d7ae 100644 --- a/src/components/charts/CostByWeekday.tsx +++ b/src/components/charts/CostByWeekday.tsx @@ -1,10 +1,19 @@ -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts' +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Cell, +} from 'recharts' import { useState, useId } from 'react' import { useTranslation } from 'react-i18next' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' -import { formatCurrency } from '@/lib/formatters' +import { coerceNumber, formatCurrency } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' import type { WeekdayData } from '@/types' @@ -18,12 +27,12 @@ export function CostByWeekday({ data }: CostByWeekdayProps) { const uid = useId() const gid = (n: string) => `${uid}-${n}`.replace(/:/g, '') - const maxCost = Math.max(...data.map(d => d.cost)) - const minCost = Math.min(...data.map(d => d.cost)) - const peakIndex = data.findIndex(d => d.cost === maxCost) - const lowIndex = data.findIndex(d => d.cost === minCost) + const maxCost = Math.max(...data.map((d) => d.cost)) + const minCost = Math.min(...data.map((d) => d.cost)) + const peakIndex = data.findIndex((d) => d.cost === maxCost) + const lowIndex = data.findIndex((d) => d.cost === minCost) const weekendCost = data - .filter(entry => entry.day === 'Sa' || entry.day === 'So') + .filter((entry) => entry.day === 'Sa' || entry.day === 'So') .reduce((sum, entry) => sum + entry.cost, 0) const weekTotal = data.reduce((sum, entry) => sum + entry.cost, 0) @@ -45,57 +54,69 @@ export function CostByWeekday({ data }: CostByWeekdayProps) { { - if (state?.activeTooltipIndex !== undefined && typeof state.activeTooltipIndex === 'number') { - setActiveIndex(state.activeTooltipIndex) - } - }} - onMouseLeave={() => setActiveIndex(null)} - > - - - - - - - - - - - - - - - - - - - - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} - cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} - /> - { + if ( + state?.activeTooltipIndex !== undefined && + typeof state.activeTooltipIndex === 'number' + ) { + setActiveIndex(state.activeTooltipIndex) + } + }} + onMouseLeave={() => setActiveIndex(null)} > - {data.map((_, index) => { - let fill = `url(#${gid('weekday')})` - if (activeIndex === index) fill = `url(#${gid('weekdayActive')})` - else if (index === peakIndex) fill = `url(#${gid('weekdayPeak')})` - else if (index === lowIndex) fill = `url(#${gid('weekdayLow')})` - return - })} - + + + + + + + + + + + + + + + + + + + + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + {data.map((_, index) => { + let fill = `url(#${gid('weekday')})` + if (activeIndex === index) fill = `url(#${gid('weekdayActive')})` + else if (index === peakIndex) fill = `url(#${gid('weekdayPeak')})` + else if (index === lowIndex) fill = `url(#${gid('weekdayLow')})` + return + })} + diff --git a/src/components/charts/CostOverTime.tsx b/src/components/charts/CostOverTime.tsx index bcbc7a1..1947d8f 100644 --- a/src/components/charts/CostOverTime.tsx +++ b/src/components/charts/CostOverTime.tsx @@ -1,10 +1,20 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' -import { formatCurrency, formatDateAxis } from '@/lib/formatters' +import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' import type { ChartDataPoint } from '@/types' @@ -19,7 +29,14 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { const summary = useMemo(() => { if (data.length === 0) return null const latest = data[data.length - 1] - const peak = [...data].sort((a, b) => b.cost - a.cost)[0] + let peak = data[0] + for (let index = 1; index < data.length; index += 1) { + const candidate = data[index] + if (candidate && peak && candidate.cost > peak.cost) { + peak = candidate + } + } + if (!latest || !peak) return null return { latest: latest.cost, peak: peak.cost, @@ -30,7 +47,15 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { return ( []} valueKey="cost" @@ -40,52 +65,83 @@ export function CostOverTime({ data, onClickDay }: CostOverTimeProps) { {(animate) => ( - { - if (onClickDay && e?.activeTooltipIndex != null && typeof e.activeTooltipIndex === 'number') { - const point = data[e.activeTooltipIndex] - if (point?.date) { - onClickDay(point.date) + { + if ( + onClickDay && + e?.activeTooltipIndex != null && + typeof e.activeTooltipIndex === 'number' + ) { + const point = data[e.activeTooltipIndex] + if (point?.date) { + onClickDay(point.date) + } } - } - }}> - - - - - - - - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - + }} + > + + + + + + + + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + diff --git a/src/components/charts/CumulativeCost.tsx b/src/components/charts/CumulativeCost.tsx index bf68834..938f172 100644 --- a/src/components/charts/CumulativeCost.tsx +++ b/src/components/charts/CumulativeCost.tsx @@ -1,10 +1,19 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' -import { formatCurrency, formatDateAxis } from '@/lib/formatters' +import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import { computeCurrentMonthForecast } from '@/lib/calculations' import { CHART_HELP } from '@/lib/help-content' import type { ChartDataPoint, DailyUsage } from '@/types' @@ -36,7 +45,7 @@ export function CumulativeCost({ data, rawData }: CumulativeCostProps) { const endDate = `${currentMonth}-${String(daysInMonth).padStart(2, '0')}` return [ - ...data.map(d => ({ ...d, projected: undefined as number | undefined })), + ...data.map((d) => ({ ...d, projected: undefined as number | undefined })), // Bridge point on last actual date { ...data[data.length - 1], projected: last.cumulative }, // Projected end-of-month point @@ -47,51 +56,81 @@ export function CumulativeCost({ data, rawData }: CumulativeCostProps) { const lastCumulative = data[data.length - 1]?.cumulative ?? 0 return ( - []} valueKey="cumulative" valueFormatter={formatCurrency}> + []} + valueKey="cumulative" + valueFormatter={formatCurrency} + > {(animate) => ( []} margin={CHART_MARGIN}> - - - - - - - - - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - + + + + + + + + + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + diff --git a/src/components/charts/CustomTooltip.tsx b/src/components/charts/CustomTooltip.tsx index c4861c1..6a09419 100644 --- a/src/components/charts/CustomTooltip.tsx +++ b/src/components/charts/CustomTooltip.tsx @@ -29,33 +29,40 @@ export function CustomTooltip({ // Separate actual values from moving average (Ø) lines const isMA = (entry: TooltipPayloadEntry) => - entry.name.includes('Ø') || entry.dataKey?.toString().includes('MA7') || entry.dataKey?.toString().includes('_ma7') + entry.name.includes('Ø') || + entry.dataKey?.toString().includes('MA7') || + entry.dataKey?.toString().includes('_ma7') const isPinned = (entry: TooltipPayloadEntry) => pinnedEntryNames.includes(entry.name) - const hasNonZeroValue = (entry: TooltipPayloadEntry) => !hideZeroValues || Math.abs(entry.value ?? 0) > 0.0001 + const hasNonZeroValue = (entry: TooltipPayloadEntry) => + !hideZeroValues || Math.abs(entry.value ?? 0) > 0.0001 const actualEntries = payload - .filter(e => !isMA(e) && !isPinned(e) && hasNonZeroValue(e)) + .filter((e) => !isMA(e) && !isPinned(e) && hasNonZeroValue(e)) .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)) - const pinnedEntries = payload.filter(e => !isMA(e) && isPinned(e) && hasNonZeroValue(e)) - const maEntries = payload.filter(e => isMA(e)) + const pinnedEntries = payload.filter((e) => !isMA(e) && isPinned(e) && hasNonZeroValue(e)) + const maEntries = payload.filter((e) => isMA(e)) const total = actualEntries.reduce((sum, entry) => sum + (entry.value ?? 0), 0) const showTotal = showComputedTotal && actualEntries.length >= 2 const point = payload[0]?.payload ?? {} - const focusEntry = actualEntries.length === 1 ? actualEntries[0] : pinnedEntries.length === 1 ? pinnedEntries[0] : null + const focusEntry = + actualEntries.length === 1 + ? actualEntries[0] + : pinnedEntries.length === 1 + ? pinnedEntries[0] + : null const prevValueRaw = focusEntry ? point[`${focusEntry.dataKey}Prev`] : undefined const prevValue = typeof prevValueRaw === 'number' ? prevValueRaw : null const matchingMA = focusEntry - ? maEntries.find(entry => entry.dataKey === `${focusEntry.dataKey}MA7` || entry.dataKey === `${focusEntry.dataKey.toString().toLowerCase()}MA7`) - ?? (maEntries.length === 1 ? maEntries[0] : null) - : null - const deltaVsPrevious = focusEntry && prevValue !== null - ? focusEntry.value - prevValue - : null - const deltaVsAverage = focusEntry && matchingMA - ? focusEntry.value - matchingMA.value + ? (maEntries.find( + (entry) => + entry.dataKey === `${focusEntry.dataKey}MA7` || + entry.dataKey === `${focusEntry.dataKey.toString().toLowerCase()}MA7`, + ) ?? (maEntries.length === 1 ? maEntries[0] : null)) : null + const deltaVsPrevious = focusEntry && prevValue !== null ? focusEntry.value - prevValue : null + const deltaVsAverage = focusEntry && matchingMA ? focusEntry.value - matchingMA.value : null return (
@@ -136,7 +143,8 @@ export function CustomTooltip({ vs. vorher: - {(deltaVsPrevious >= 0 ? '+' : '')}{formatter ? formatter(deltaVsPrevious, 'Delta') : deltaVsPrevious} + {deltaVsPrevious >= 0 ? '+' : ''} + {formatter ? formatter(deltaVsPrevious, 'Delta') : deltaVsPrevious}
)} @@ -145,7 +153,8 @@ export function CustomTooltip({ vs. Ø: - {(deltaVsAverage >= 0 ? '+' : '')}{formatter ? formatter(deltaVsAverage, 'Delta') : deltaVsAverage} + {deltaVsAverage >= 0 ? '+' : ''} + {formatter ? formatter(deltaVsAverage, 'Delta') : deltaVsAverage}
)} diff --git a/src/components/charts/DistributionAnalysis.tsx b/src/components/charts/DistributionAnalysis.tsx index 783398d..7f4f5ee 100644 --- a/src/components/charts/DistributionAnalysis.tsx +++ b/src/components/charts/DistributionAnalysis.tsx @@ -1,7 +1,16 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { motion } from 'framer-motion' -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts' +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Cell, +} from 'recharts' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { InfoButton } from '@/components/features/help/InfoButton' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -42,17 +51,27 @@ function toBins(values: number[], formatter: (value: number) => string): Distrib for (const value of values) { const bucketIndex = Math.min(bucketCount - 1, Math.floor((value - min) / bucketSize)) - bins[bucketIndex].count += 1 + const bucket = bins[bucketIndex] + if (bucket) { + bucket.count += 1 + } } return bins } -function DistributionTooltip({ active, payload }: { active?: boolean; payload?: Array<{ value: number; payload: DistributionBin }> }) { +function DistributionTooltip({ + active, + payload, +}: { + active?: boolean + payload?: Array<{ value: number; payload: DistributionBin }> +}) { const { t } = useTranslation() if (!active || !payload?.length) return null const entry = payload[0] + if (!entry) return null return (
@@ -78,14 +97,25 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA const distributions = useMemo(() => { if (data.length < 2) return [] - const costs = data.map(entry => entry.totalCost) - const requests = data.map(entry => entry.requestCount) - const tokensPerRequest = data.map(entry => entry.requestCount > 0 ? entry.totalTokens / entry.requestCount : 0) + const costs = data.map((entry) => entry.totalCost) + const requests = data.map((entry) => entry.requestCount) + const tokensPerRequest = data.map((entry) => + entry.requestCount > 0 ? entry.totalTokens / entry.requestCount : 0, + ) return [ - { title: t('charts.distribution.costPerPeriod', { period: periodLabel(viewMode) }), data: toBins(costs, formatCurrency) }, - { title: t('charts.distribution.requestsPerPeriod', { period: periodLabel(viewMode) }), data: toBins(requests, formatNumber) }, - { title: t('charts.distribution.tokensPerRequest'), data: toBins(tokensPerRequest, formatTokens) }, + { + title: t('charts.distribution.costPerPeriod', { period: periodLabel(viewMode) }), + data: toBins(costs, formatCurrency), + }, + { + title: t('charts.distribution.requestsPerPeriod', { period: periodLabel(viewMode) }), + data: toBins(requests, formatNumber), + }, + { + title: t('charts.distribution.tokensPerRequest'), + data: toBins(tokensPerRequest, formatTokens), + }, ] }, [data, viewMode, t]) @@ -108,7 +138,7 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA } return ( - + {t('charts.distribution.title')} @@ -126,8 +156,12 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA >
-
{distribution.title}
-
{distribution.data.length} {t('charts.distribution.buckets')}
+
+ {distribution.title} +
+
+ {distribution.data.length} {t('charts.distribution.buckets')} +
@@ -148,8 +182,17 @@ export function DistributionAnalysis({ data, viewMode = 'daily' }: DistributionA textAnchor={distribution.data.length > 5 ? 'end' : 'middle'} height={distribution.data.length > 5 ? 48 : 30} /> - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> {distribution.data.map((_, binIndex) => { - const intensity = distribution.data.length > 1 ? binIndex / (distribution.data.length - 1) : 0 + const intensity = + distribution.data.length > 1 ? binIndex / (distribution.data.length - 1) : 0 const opacity = 0.45 + intensity * 0.35 - return + return ( + + ) })} diff --git a/src/components/charts/ModelMix.tsx b/src/components/charts/ModelMix.tsx index 5222e7d..d08a483 100644 --- a/src/components/charts/ModelMix.tsx +++ b/src/components/charts/ModelMix.tsx @@ -1,11 +1,19 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts' +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { CHART_HELP } from '@/lib/help-content' import { getModelColor, normalizeModelName } from '@/lib/model-utils' -import { formatDateAxis, formatPercent } from '@/lib/formatters' +import { coerceNumber, formatDateAxis, formatPercent } from '@/lib/formatters' import type { DailyUsage } from '@/types' interface ModelMixProps { @@ -27,9 +35,14 @@ function MixTooltip({ active, payload, label }: MixTooltipProps) {
{sorted.map((entry, i) => (
- + {entry.name}: - {formatPercent(entry.value)} + + {formatPercent(entry.value)} +
))}
@@ -47,7 +60,7 @@ export function ModelMix({ data }: ModelMixProps) { } const models = Array.from(modelSet).sort() - const chartData = sorted.map(d => { + const chartData = sorted.map((d) => { const total = d.totalCost const point: Record = { date: d.date } const costs: Record = {} @@ -77,43 +90,63 @@ export function ModelMix({ data }: ModelMixProps) { - - {models.map(model => { - const color = getModelColor(model) - const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` - return ( - - - - - ) - })} - - - - `${Math.round(v)}%`} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} domain={[0, 100]} ticks={[0, 25, 50, 75, 100]} /> - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - {models.map(model => { - const color = getModelColor(model) - const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` - return ( - + {models.map((model) => { + const color = getModelColor(model) + const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` + return ( + + + + + ) + })} + + + - ) - })} + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatPercent(Math.round(numericValue), 0) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + domain={[0, 100]} + ticks={[0, 25, 50, 75, 100]} + /> + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + {models.map((model, index) => { + const color = getModelColor(model) + const id = `mix-grad-${model.replace(/[\s.]/g, '-')}` + return ( + + ) + })} diff --git a/src/components/charts/RequestCacheHitRateByModel.tsx b/src/components/charts/RequestCacheHitRateByModel.tsx index 7e715d8..ff999e8 100644 --- a/src/components/charts/RequestCacheHitRateByModel.tsx +++ b/src/components/charts/RequestCacheHitRateByModel.tsx @@ -1,6 +1,19 @@ import { useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, BarChart, Bar, Cell } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + BarChart, + Bar, + Cell, +} from 'recharts' import { ChartAnimationAware, ChartCard, ChartReveal } from './ChartCard' import { CHART_ANIMATION, CHART_COLORS, CHART_MARGIN } from './chart-theme' import { CustomTooltip } from './CustomTooltip' @@ -8,7 +21,7 @@ import { CHART_HELP } from '@/lib/help-content' import { computeCacheHitRateByModel, computeMovingAverage } from '@/lib/calculations' import { formatDateAxis, formatPercent, periodUnit } from '@/lib/formatters' import { getModelColor, normalizeModelName } from '@/lib/model-utils' -import type { CacheHitRateByModelChartDataPoint, DailyUsage, ViewMode } from '@/types' +import type { DailyUsage, ViewMode } from '@/types' interface RequestCacheHitRateByModelProps { timelineData: DailyUsage[] @@ -20,30 +33,46 @@ function formatRate(value: number) { return formatPercent(value, 1) } -function computePointRate(input: number, output: number, cacheCreate: number, cacheRead: number, thinking: number) { +function computePointRate( + input: number, + output: number, + cacheCreate: number, + cacheRead: number, + thinking: number, +) { const base = input + output + cacheCreate + cacheRead + thinking return base > 0 ? (cacheRead / base) * 100 : 0 } -export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode }: RequestCacheHitRateByModelProps) { +export function RequestCacheHitRateByModel({ + timelineData, + summaryData, + viewMode, +}: RequestCacheHitRateByModelProps) { const { t } = useTranslation() const uid = useId().replace(/:/g, '') const totalLabel = t('charts.requestCacheHitRate.total') - const trendLabel = viewMode === 'daily' ? t('charts.requestCacheHitRate.trailing7Rate') : t('charts.requestCacheHitRate.trendRate') + const trendLabel = + viewMode === 'daily' + ? t('charts.requestCacheHitRate.trailing7Rate') + : t('charts.requestCacheHitRate.trendRate') const barData = useMemo( - () => computeCacheHitRateByModel(summaryData).map(entry => ({ - ...entry, - model: entry.model === 'Total' ? totalLabel : entry.model, - })), + () => + computeCacheHitRateByModel(summaryData).map((entry) => ({ + ...entry, + model: entry.model === 'Total' ? totalLabel : entry.model, + })), [summaryData, totalLabel], ) const summary = useMemo(() => { if (barData.length === 0) return null const total = barData[0] + if (!total) return null const topModel = barData.slice(1).sort((a, b) => b.totalRate - a.totalRate)[0] ?? null - const dominantModel = barData.slice(1).sort((a, b) => b.totalBaseTokens - a.totalBaseTokens)[0] ?? null + const dominantModel = + barData.slice(1).sort((a, b) => b.totalBaseTokens - a.totalBaseTokens)[0] ?? null return { total, @@ -56,13 +85,17 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode const lineData = useMemo(() => { if (timelineData.length === 0) return [] - const topModels = barData - .slice(1) - .map(entry => entry.model) + const topModels = barData.slice(1).map((entry) => entry.model) const sorted = [...timelineData].sort((a, b) => a.date.localeCompare(b.date)) - const totalRates = sorted.map(point => - computePointRate(point.inputTokens, point.outputTokens, point.cacheCreationTokens, point.cacheReadTokens, point.thinkingTokens), + const totalRates = sorted.map((point) => + computePointRate( + point.inputTokens, + point.outputTokens, + point.cacheCreationTokens, + point.cacheReadTokens, + point.thinkingTokens, + ), ) const totalTrend = computeMovingAverage(totalRates, Math.min(7, sorted.length)) @@ -70,12 +103,21 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode for (const model of topModels) modelSeries[model] = [] for (const point of sorted) { - const byModel = new Map() + const byModel = new Map< + string, + { input: number; output: number; cacheCreate: number; cacheRead: number; thinking: number } + >() for (const breakdown of point.modelBreakdowns) { const name = normalizeModelName(breakdown.modelName) if (!topModels.includes(name)) continue - const current = byModel.get(name) ?? { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0 } + const current = byModel.get(name) ?? { + input: 0, + output: 0, + cacheCreate: 0, + cacheRead: 0, + thinking: 0, + } current.input += breakdown.inputTokens current.output += breakdown.outputTokens current.cacheCreate += breakdown.cacheCreationTokens @@ -86,11 +128,20 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode for (const model of topModels) { const current = byModel.get(model) - modelSeries[model].push( - current - ? computePointRate(current.input, current.output, current.cacheCreate, current.cacheRead, current.thinking) - : 0, - ) + const series = modelSeries[model] + if (series) { + series.push( + current + ? computePointRate( + current.input, + current.output, + current.cacheCreate, + current.cacheRead, + current.thinking, + ) + : 0, + ) + } } } @@ -102,7 +153,7 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode } for (const model of topModels) { - row[model] = modelSeries[model][index] + row[model] = modelSeries[model]?.[index] ?? 0 } return row @@ -116,7 +167,9 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode const lineHeight = viewMode === 'daily' ? 280 : 250 const expandedLineHeight = viewMode === 'daily' ? 360 : 320 - const lineSeries = Object.keys(lineData[0] ?? {}).filter(key => key !== 'date' && key !== 'totalRate' && key !== 'totalRate_ma7') + const lineSeries = Object.keys(lineData[0] ?? {}).filter( + (key) => key !== 'date' && key !== 'totalRate' && key !== 'totalRate_ma7', + ) const expandedExtra = (
@@ -125,12 +178,20 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode
{entry.model}
-
{t('charts.requestCacheHitRate.totalRate')}
-
{formatRate(entry.totalRate)}
+
+ {t('charts.requestCacheHitRate.totalRate')} +
+
+ {formatRate(entry.totalRate)} +
-
{t('charts.requestCacheHitRate.trailing7Rate')}
-
{formatRate(entry.trailing7Rate)}
+
+ {t('charts.requestCacheHitRate.trailing7Rate')} +
+
+ {formatRate(entry.trailing7Rate)} +
@@ -155,24 +216,38 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode <>
-
{t('charts.requestCacheHitRate.totalRate')}
-
{formatRate(summary.total.totalRate)}
+
+ {t('charts.requestCacheHitRate.totalRate')} +
+
+ {formatRate(summary.total.totalRate)} +
-
{t('charts.requestCacheHitRate.trailing7Rate')}
-
{formatRate(summary.total.trailing7Rate)}
+
+ {t('charts.requestCacheHitRate.trailing7Rate')} +
+
+ {formatRate(summary.total.trailing7Rate)} +
-
{t('charts.requestCacheHitRate.topModel')}
+
+ {t('charts.requestCacheHitRate.topModel')} +
{summary.topModel?.model ?? '–'}
-
{t('charts.requestCacheHitRate.models')}
+
+ {t('charts.requestCacheHitRate.models')} +
{summary.models}
-
+
{t('charts.requestCacheHitRate.timelineHeading', { unit: periodUnit(viewMode) })} @@ -180,7 +255,10 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode {(animate) => ( - + @@ -188,18 +266,36 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode - - - + + + formatRate(value)} pinnedEntryNames={[t('charts.requestCacheHitRate.totalRate')]} showComputedTotal={false} hideZeroValues /> - )} + } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} /> @@ -211,7 +307,12 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode name={t('charts.requestCacheHitRate.totalRate')} strokeWidth={2} dot={false} - activeDot={{ r: 5, strokeWidth: 2, stroke: CHART_COLORS.cost, fill: 'hsl(var(--background))' }} + activeDot={{ + r: 5, + strokeWidth: 2, + stroke: CHART_COLORS.cost, + fill: 'hsl(var(--background))', + }} isAnimationActive={animate} animationDuration={CHART_ANIMATION.duration} animationEasing={CHART_ANIMATION.easing} @@ -259,15 +360,23 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode
{(animate) => ( - - - - + + + + - + formatRate(value)} - pinnedEntryNames={[t('charts.requestCacheHitRate.totalRate'), t('charts.requestCacheHitRate.trailing7Rate')]} + pinnedEntryNames={[ + t('charts.requestCacheHitRate.totalRate'), + t('charts.requestCacheHitRate.trailing7Rate'), + ]} showComputedTotal={false} /> - )} + } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} /> @@ -309,7 +421,11 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode {barData.map((entry) => ( ))} @@ -327,7 +443,11 @@ export function RequestCacheHitRateByModel({ timelineData, summaryData, viewMode {barData.map((entry) => ( ))} diff --git a/src/components/charts/RequestsOverTime.tsx b/src/components/charts/RequestsOverTime.tsx index b3bbda1..f6696a8 100644 --- a/src/components/charts/RequestsOverTime.tsx +++ b/src/components/charts/RequestsOverTime.tsx @@ -1,6 +1,19 @@ import { useMemo, useId } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, PieChart, Pie, Cell } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + PieChart, + Pie, + Cell, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -24,7 +37,13 @@ function formatRequests(value: number) { }).format(value) } -function RequestCenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: number }; total: string }) { +function RequestCenterLabel({ + viewBox, + total, +}: { + viewBox?: { cx: number; cy: number } + total: string +}) { const { t } = useTranslation() if (!viewBox) return null const { cx, cy } = viewBox @@ -34,7 +53,14 @@ function RequestCenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: nu {t('charts.requestsOverTime.total')} - + {total} @@ -45,8 +71,14 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque const { t } = useTranslation() const uid = useId().replace(/:/g, '') const averageLabel = t('charts.requestsOverTime.averagePerUnit', { unit: periodUnit(viewMode) }) - const trendLabel = viewMode === 'daily' ? t('charts.requestsOverTime.movingAverage') : t('charts.requestsOverTime.trend') - const trendHeading = viewMode === 'daily' ? t('charts.requestsOverTime.movingAverageHeading') : t('charts.requestsOverTime.trendHeading') + const trendLabel = + viewMode === 'daily' + ? t('charts.requestsOverTime.movingAverage') + : t('charts.requestsOverTime.trend') + const trendHeading = + viewMode === 'daily' + ? t('charts.requestsOverTime.movingAverageHeading') + : t('charts.requestsOverTime.trendHeading') const summary = useMemo(() => { if (data.length === 0) return null @@ -61,18 +93,19 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque key === 'totalRequestsPrev' || key.endsWith('_ma7') || key.endsWith('Prev') - ) continue + ) + continue if (typeof value === 'number') { modelTotals.set(key, (modelTotals.get(key) ?? 0) + value) } } } - const topModels = Array.from(modelTotals.entries()) - .sort((a, b) => b[1] - a[1]) + const topModels = Array.from(modelTotals.entries()).sort((a, b) => b[1] - a[1]) const totalRequests = data.reduce((sum, point) => sum + point.totalRequests, 0) const peak = [...data].sort((a, b) => b.totalRequests - a.totalRequests)[0] + if (!peak) return null return { totalRequests, @@ -102,14 +135,31 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque {(animate) => (
-
{trendHeading}
+
+ {trendHeading} +
- - - formatRequests(v)} />} cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} /> + + + formatRequests(v)} />} + cursor={{ stroke: 'hsl(var(--muted))', strokeWidth: 1 }} + />
-
{t('charts.requestsOverTime.requestsByModelTotal')}
+
+ {t('charts.requestsOverTime.requestsByModelTotal')} +
{(summary?.topModels ?? []).map(([model, total]) => { - const share = summary && summary.totalRequests > 0 ? (total / summary.totalRequests) * 100 : 0 + const share = + summary && summary.totalRequests > 0 ? (total / summary.totalRequests) * 100 : 0 return (
- +
{model}
{share.toFixed(1)}%
-
{formatRequests(total)}
-
{t('charts.requestsOverTime.requestsInRange')}
+
+ {formatRequests(total)} +
+
+ {t('charts.requestsOverTime.requestsInRange')} +
) })} @@ -174,7 +234,15 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque return ( : undefined} chartData={data as unknown as Record[]} @@ -193,26 +261,46 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque <>
-
{t('charts.requestsOverTime.total')}
-
{summary ? formatRequests(summary.totalRequests) : '0'}
+
+ {t('charts.requestsOverTime.total')} +
+
+ {summary ? formatRequests(summary.totalRequests) : '0'} +
-
{averageLabel}
-
{summary && data.length > 0 ? formatRequests(summary.totalRequests / data.length) : '0'}
+
+ {averageLabel} +
+
+ {summary && data.length > 0 + ? formatRequests(summary.totalRequests / data.length) + : '0'} +
-
{t('charts.requestsOverTime.topModel')}
-
{summary?.topModels[0]?.[0] ?? '–'}
+
+ {t('charts.requestsOverTime.topModel')} +
+
+ {summary?.topModels[0]?.[0] ?? '–'} +
-
{t('charts.requestsOverTime.topShare')}
+
+ {t('charts.requestsOverTime.topShare')} +
- {summary && summary.totalRequests > 0 && summary.topModels[0] ? `${((summary.topModels[0][1] / summary.totalRequests) * 100).toFixed(1)}%` : '–'} + {summary && summary.totalRequests > 0 && summary.topModels[0] + ? `${((summary.topModels[0][1] / summary.totalRequests) * 100).toFixed(1)}%` + : '–'}
-
+
{(animate) => ( @@ -221,15 +309,47 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque - - + + - - - + + + formatRequests(v)} pinnedEntryNames={[t('charts.requestsOverTime.totalRequestsSeries')]} showComputedTotal={false} />} + content={ + formatRequests(v)} + pinnedEntryNames={[ + t('charts.requestsOverTime.totalRequestsSeries'), + ]} + showComputedTotal={false} + /> + } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.12 }} /> @@ -241,7 +361,12 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque name={t('charts.requestsOverTime.totalRequestsSeries')} strokeWidth={1.8} dot={false} - activeDot={{ r: 5, strokeWidth: 2, stroke: CHART_COLORS.cumulative, fill: 'hsl(var(--background))' }} + activeDot={{ + r: 5, + strokeWidth: 2, + stroke: CHART_COLORS.cumulative, + fill: 'hsl(var(--background))', + }} isAnimationActive={animate} animationDuration={CHART_ANIMATION.duration} /> @@ -302,14 +427,25 @@ export function RequestsOverTime({ data, viewMode = 'daily', onClickDay }: Reque {donutData.map((entry) => ( ))} - + - formatRequests(v)} />} /> + formatRequests(v)} />} + /> { - const entry = donutData.find(d => d.name === value) - return {value} ({entry ? formatRequests(entry.value) : ''}) + const entry = donutData.find((d) => d.name === value) + return ( + + {value} ({entry ? formatRequests(entry.value) : ''}) + + ) }} /> diff --git a/src/components/charts/TokenEfficiency.tsx b/src/components/charts/TokenEfficiency.tsx index f3742d4..7de1b6f 100644 --- a/src/components/charts/TokenEfficiency.tsx +++ b/src/components/charts/TokenEfficiency.tsx @@ -1,12 +1,22 @@ import { useMemo, useId } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' import { computeMovingAverage } from '@/lib/calculations' import { CHART_HELP } from '@/lib/help-content' -import { formatCurrency, formatDateAxis } from '@/lib/formatters' +import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import type { DailyUsage } from '@/types' interface TokenEfficiencyProps { @@ -19,13 +29,11 @@ export function TokenEfficiency({ data }: TokenEfficiencyProps) { const { chartData, avg } = useMemo(() => { const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)) - const effValues = sorted.map(d => - d.totalTokens > 0 ? d.totalCost / (d.totalTokens / 1_000_000) : 0 + const effValues = sorted.map((d) => + d.totalTokens > 0 ? d.totalCost / (d.totalTokens / 1_000_000) : 0, ) const ma7 = computeMovingAverage(effValues) - const avg = effValues.length > 0 - ? effValues.reduce((s, v) => s + v, 0) / effValues.length - : 0 + const avg = effValues.length > 0 ? effValues.reduce((s, v) => s + v, 0) / effValues.length : 0 return { chartData: sorted.map((d, i) => ({ @@ -53,41 +61,64 @@ export function TokenEfficiency({ data }: TokenEfficiencyProps) { - - - - - - - - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - + + + + + + + + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + diff --git a/src/components/charts/TokenTypes.tsx b/src/components/charts/TokenTypes.tsx index 1629a3e..5c599a0 100644 --- a/src/components/charts/TokenTypes.tsx +++ b/src/components/charts/TokenTypes.tsx @@ -7,11 +7,11 @@ import { formatTokens } from '@/lib/formatters' import { CHART_HELP } from '@/lib/help-content' const TOKEN_COLORS: Record = { - 'Input': CHART_COLORS.input, - 'Output': CHART_COLORS.output, + Input: CHART_COLORS.input, + Output: CHART_COLORS.output, 'Cache Write': CHART_COLORS.cacheWrite, 'Cache Read': CHART_COLORS.cacheRead, - 'Thinking': CHART_COLORS.cost, + Thinking: CHART_COLORS.cost, } interface TokenTypesProps { @@ -27,7 +27,14 @@ function CenterLabel({ viewBox, total }: { viewBox?: { cx: number; cy: number }; {t('charts.tokenTypes.total')} - + {total} @@ -39,7 +46,14 @@ export function TokenTypes({ data }: TokenTypesProps) { const total = data.reduce((sum, d) => sum + d.value, 0) return ( - []} valueKey="value" valueFormatter={formatTokens}> + []} + valueKey="value" + valueFormatter={formatTokens} + > {(expanded) => { const chartHeight = expanded ? 560 : 320 const pieCenterY = expanded ? '66%' : '57%' @@ -67,7 +81,10 @@ export function TokenTypes({ data }: TokenTypesProps) { animationEasing={CHART_ANIMATION.easing} > {data.map((entry) => ( - + ))} @@ -75,8 +92,12 @@ export function TokenTypes({ data }: TokenTypesProps) { { - const entry = data.find(d => d.name === value) - return {value} ({entry ? formatTokens(entry.value) : ''}) + const entry = data.find((d) => d.name === value) + return ( + + {value} ({entry ? formatTokens(entry.value) : ''}) + + ) }} /> diff --git a/src/components/charts/TokensOverTime.tsx b/src/components/charts/TokensOverTime.tsx index 118f5d5..e00f656 100644 --- a/src/components/charts/TokensOverTime.tsx +++ b/src/components/charts/TokensOverTime.tsx @@ -1,6 +1,15 @@ import { useMemo, useId } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Line } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Line, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from './ChartCard' import { CustomTooltip } from './CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from './chart-theme' @@ -20,7 +29,11 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { const gid = (name: string) => `${uid}-${name}`.replace(/:/g, '') const totals = useMemo(() => { - let cacheRead = 0, cacheWrite = 0, input = 0, output = 0, thinking = 0 + let cacheRead = 0, + cacheWrite = 0, + input = 0, + output = 0, + thinking = 0 for (const d of data) { cacheRead += d['Cache Read'] cacheWrite += d['Cache Write'] @@ -28,18 +41,35 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { output += d.Output thinking += d.Thinking } - return { cacheRead, cacheWrite, input, output, thinking, total: cacheRead + cacheWrite + input + output + thinking } + return { + cacheRead, + cacheWrite, + input, + output, + thinking, + total: cacheRead + cacheWrite + input + output + thinking, + } }, [data]) // Total tokens per day for the expanded extra chart - const totalPerDay = useMemo(() => - data.map((d, i) => ({ - date: d.date, - total: d.Input + d.Output + d['Cache Write'] + d['Cache Read'] + d.Thinking, - totalPrev: i > 0 ? data[i - 1].Input + data[i - 1].Output + data[i - 1]['Cache Write'] + data[i - 1]['Cache Read'] + data[i - 1].Thinking : undefined, - tokenMA7: d.tokenMA7, - })), - [data] + const totalPerDay = useMemo( + () => + data.map((d, i) => ({ + date: d.date, + total: d.Input + d.Output + d['Cache Write'] + d['Cache Read'] + d.Thinking, + totalPrev: (() => { + const previousDay = i > 0 ? data[i - 1] : undefined + return previousDay + ? previousDay.Input + + previousDay.Output + + previousDay['Cache Write'] + + previousDay['Cache Read'] + + previousDay.Thinking + : undefined + })(), + tokenMA7: d.tokenMA7, + })), + [data], ) const handleClick = (e: unknown) => { @@ -53,22 +83,61 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { {(animate) => (
-
{t('charts.tokensOverTime.allTypes')}
+
+ {t('charts.tokensOverTime.allTypes')} +
- - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + @@ -91,16 +160,22 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { > {/* Summary row with totals per type */}
- {([ - { label: 'Cache Read', value: totals.cacheRead, color: CHART_COLORS.cacheRead }, - { label: 'Cache Write', value: totals.cacheWrite, color: CHART_COLORS.cacheWrite }, - { label: 'Output', value: totals.output, color: CHART_COLORS.output }, - { label: 'Input', value: totals.input, color: CHART_COLORS.input }, - { label: 'Thinking', value: totals.thinking, color: CHART_COLORS.cost }, - ] as const).map(item => ( + {( + [ + { label: 'Cache Read', value: totals.cacheRead, color: CHART_COLORS.cacheRead }, + { label: 'Cache Write', value: totals.cacheWrite, color: CHART_COLORS.cacheWrite }, + { label: 'Output', value: totals.output, color: CHART_COLORS.output }, + { label: 'Input', value: totals.input, color: CHART_COLORS.input }, + { label: 'Thinking', value: totals.thinking, color: CHART_COLORS.cost }, + ] as const + ).map((item) => (
-
{item.label}
-
{formatTokens(item.value)}
+
+ {item.label} +
+
+ {formatTokens(item.value)} +
{totals.total > 0 ? `${((item.value / totals.total) * 100).toFixed(1)}%` : '–'}
@@ -110,30 +185,95 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { {/* Chart 1: Cache Tokens (large scale) with per-type MA7 */}
-
{t('charts.tokensOverTime.cacheTokens')}
+
+ {t('charts.tokensOverTime.cacheTokens')} +
{(animate) => ( - - - - - - - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - - + + + + + + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + + @@ -143,30 +283,90 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) { {/* Chart 2: I/O Tokens (small scale) with per-type MA7 */}
-
{t('charts.tokensOverTime.inputOutputTokens')}
+
+ {t('charts.tokensOverTime.inputOutputTokens')} +
{(animate) => ( - - - - - - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - - - + + + + + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + + + @@ -175,24 +375,64 @@ export function TokensOverTime({ data, onClickDay }: TokensOverTimeProps) {
-
{t('charts.tokensOverTime.thinkingTokens')}
+
+ {t('charts.tokensOverTime.thinkingTokens')} +
{(animate) => ( - - - - - - - - - - } cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} /> - - + + + + + + + + + + } + cursor={{ fill: 'hsl(var(--muted))', opacity: 0.15 }} + /> + + diff --git a/src/components/features/animations/FadeIn.tsx b/src/components/features/animations/FadeIn.tsx index c801065..29cc150 100644 --- a/src/components/features/animations/FadeIn.tsx +++ b/src/components/features/animations/FadeIn.tsx @@ -9,7 +9,13 @@ interface FadeInProps { direction?: 'up' | 'down' | 'left' | 'right' | 'none' } -export function FadeIn({ children, delay = 0, duration = 0.5, className, direction = 'up' }: FadeInProps) { +export function FadeIn({ + children, + delay = 0, + duration = 0.5, + className, + direction = 'up', +}: FadeInProps) { const offsets = { up: { y: 20 }, down: { y: -20 }, diff --git a/src/components/features/anomaly/AnomalyDetection.tsx b/src/components/features/anomaly/AnomalyDetection.tsx index 0a073a2..4d4a8dc 100644 --- a/src/components/features/anomaly/AnomalyDetection.tsx +++ b/src/components/features/anomaly/AnomalyDetection.tsx @@ -19,7 +19,7 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma const { t } = useTranslation() const { anomalies, mean, stdDev } = useMemo(() => { if (data.length < 3) return { anomalies: [], mean: 0, stdDev: 0 } - const costs = data.map(d => d.totalCost) + const costs = data.map((d) => d.totalCost) const m = costs.reduce((s, v) => s + v, 0) / costs.length const sd = Math.sqrt(costs.reduce((s, v) => s + (v - m) ** 2, 0) / costs.length) return { anomalies: computeAnomalies(data), mean: m, stdDev: sd } @@ -55,12 +55,16 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma

- {t('anomaly.description', { period: periodLabel(viewMode, true), mean: formatCurrency(mean), stdDev: formatCurrency(stdDev) })} + {t('anomaly.description', { + period: periodLabel(viewMode, true), + mean: formatCurrency(mean), + stdDev: formatCurrency(stdDev), + })}

- {anomalies + {[...anomalies] .sort((a, b) => b.totalCost - a.totalCost) - .map(day => { + .map((day) => { const zScoreNum = stdDev > 0 ? (day.totalCost - mean) / stdDev : 0 const zScore = zScoreNum.toFixed(1) const isHigh = day.totalCost > mean @@ -76,22 +80,31 @@ export function AnomalyDetection({ data, onClickDay, viewMode = 'daily' }: Anoma onClick={() => onClickDay?.(day.date)} >
-
+
{formatDate(day.date, 'long')} {severity === 'critical' && ( - {t('anomaly.critical')} + + {t('anomaly.critical')} + )}
- + {formatCurrency(day.totalCost)} - - {isHigh ? '+' : ''}{zScore}σ + + {isHigh ? '+' : ''} + {zScore}σ
diff --git a/src/components/features/auto-import/AutoImportModal.tsx b/src/components/features/auto-import/AutoImportModal.tsx index 34ca7a7..654194c 100644 --- a/src/components/features/auto-import/AutoImportModal.tsx +++ b/src/components/features/auto-import/AutoImportModal.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { LoaderCircle, CheckCircle2, XCircle, Terminal } from 'lucide-react' import { startAutoImport } from '@/lib/auto-import' @@ -37,8 +43,12 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod const closeRef = useRef<{ close: () => void } | null>(null) const addLine = useCallback((type: LineType, text: string) => { - setLines(prev => [...prev, { type, text }]) + setLines((prev) => [...prev, { type, text }]) }, []) + const autoImportTranslator = useCallback( + (key: string, vars?: Record) => (vars ? t(key, vars) : t(key)), + [t], + ) useEffect(() => { if (!open) return @@ -47,37 +57,50 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod setLines([]) setSummary(null) - const handle = startAutoImport({ - onCheck: (data: CheckEvent) => { - if (data.status === 'checking') { - addLine('check', t('autoImportModal.checkingTool', { tool: data.tool })) - } else if (data.status === 'found') { - addLine('check', t('autoImportModal.toolFound', { tool: data.tool, method: data.method, version: data.version })) - setStatus('running') - } else if (data.status === 'not_found') { - addLine('check', t('autoImportModal.toolNotFound', { tool: data.tool })) - } - }, - onProgress: (data) => { - addLine('progress', data.message) - }, - onStderr: (data) => { - addLine('stderr', data.line) - }, - onSuccess: (data: SuccessEvent) => { - addLine('success', t('autoImportModal.importedDays', { days: data.days, cost: data.totalCost.toFixed(2) })) - setSummary(data) - setStatus('success') - onSuccess() - }, - onError: (data) => { - addLine('error', data.message) - setStatus('error') - }, - onDone: () => { - closeRef.current = null + const handle = startAutoImport( + { + onCheck: (data: CheckEvent) => { + if (data.status === 'checking') { + addLine('check', t('autoImportModal.checkingTool', { tool: data.tool })) + } else if (data.status === 'found') { + addLine( + 'check', + t('autoImportModal.toolFound', { + tool: data.tool, + method: data.method, + version: data.version, + }), + ) + setStatus('running') + } else if (data.status === 'not_found') { + addLine('check', t('autoImportModal.toolNotFound', { tool: data.tool })) + } + }, + onProgress: (data) => { + addLine('progress', data.message) + }, + onStderr: (data) => { + addLine('stderr', data.line) + }, + onSuccess: (data: SuccessEvent) => { + addLine( + 'success', + t('autoImportModal.importedDays', { days: data.days, cost: data.totalCost.toFixed(2) }), + ) + setSummary(data) + setStatus('success') + onSuccess() + }, + onError: (data) => { + addLine('error', data.message) + setStatus('error') + }, + onDone: () => { + closeRef.current = null + }, }, - }, t) + autoImportTranslator, + ) closeRef.current = handle @@ -85,7 +108,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod handle.close() closeRef.current = null } - }, [open, addLine, onSuccess, t]) + }, [open, addLine, autoImportTranslator, onSuccess, t]) // Auto-scroll useEffect(() => { @@ -97,16 +120,24 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod const isRunning = status === 'checking' || status === 'running' return ( - { if (!isRunning) onOpenChange(v) }}> - { if (isRunning) e.preventDefault() }}> + { + if (!isRunning) onOpenChange(v) + }} + > + { + if (isRunning) e.preventDefault() + }} + > {t('autoImportModal.title')} - - {t('autoImportModal.description')} - + {t('autoImportModal.description')} {/* Terminal output */} @@ -133,7 +164,9 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod <> - {status === 'checking' ? t('autoImportModal.checkingPrerequisites') : t('autoImportModal.importingData')} + {status === 'checking' + ? t('autoImportModal.checkingPrerequisites') + : t('autoImportModal.importingData')} )} @@ -141,7 +174,10 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod <> - {t('autoImportModal.loadedDays', { days: summary.days, cost: summary.totalCost.toFixed(2) })} + {t('autoImportModal.loadedDays', { + days: summary.days, + cost: summary.totalCost.toFixed(2), + })} )} @@ -154,7 +190,7 @@ export function AutoImportModal({ open, onOpenChange, onSuccess }: AutoImportMod
{!isRunning && ( - )} diff --git a/src/components/features/cache-roi/CacheROI.tsx b/src/components/features/cache-roi/CacheROI.tsx index 4face90..2fee727 100644 --- a/src/components/features/cache-roi/CacheROI.tsx +++ b/src/components/features/cache-roi/CacheROI.tsx @@ -18,37 +18,45 @@ interface CacheROIProps { export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) { const { t } = useTranslation() - const { actualCost, hypotheticalCost, savings, savingsPercent, dailyAvg, heuristicModels } = useMemo(() => { - let actual = 0 - let hypothetical = 0 - const heuristicModels = new Set() + const { actualCost, hypotheticalCost, savings, savingsPercent, dailyAvg, heuristicModels } = + useMemo(() => { + let actual = 0 + let hypothetical = 0 + const heuristicModels = new Set() - for (const d of data) { - actual += d.totalCost + for (const d of data) { + actual += d.totalCost - for (const mb of d.modelBreakdowns) { - const name = normalizeModelName(mb.modelName) - const prices = MODEL_PRICES[name] - if (!prices) { - // If no pricing info, assume cache read saves ~90% vs input - heuristicModels.add(name) - hypothetical += mb.cost + (mb.cacheReadTokens / 1_000_000) * 10 - continue + for (const mb of d.modelBreakdowns) { + const name = normalizeModelName(mb.modelName) + const prices = MODEL_PRICES[name] + if (!prices) { + // If no pricing info, assume cache read saves ~90% vs input + heuristicModels.add(name) + hypothetical += mb.cost + (mb.cacheReadTokens / 1_000_000) * 10 + continue + } + // What it would have cost if cache reads were regular input tokens + const cacheReadAsInput = (mb.cacheReadTokens / 1_000_000) * prices.input + const actualCacheReadCost = (mb.cacheReadTokens / 1_000_000) * prices.cacheRead + hypothetical += mb.cost - actualCacheReadCost + cacheReadAsInput } - // What it would have cost if cache reads were regular input tokens - const cacheReadAsInput = (mb.cacheReadTokens / 1_000_000) * prices.input - const actualCacheReadCost = (mb.cacheReadTokens / 1_000_000) * prices.cacheRead - hypothetical += mb.cost - actualCacheReadCost + cacheReadAsInput } - } - const saved = hypothetical - actual - const pct = hypothetical > 0 ? (saved / hypothetical) * 100 : 0 - const totalPeriods = data.reduce((s, d) => s + (d._aggregatedDays ?? 1), 0) - const dailyAvg = totalPeriods > 0 ? actual / totalPeriods : 0 + const saved = hypothetical - actual + const pct = hypothetical > 0 ? (saved / hypothetical) * 100 : 0 + const totalPeriods = data.reduce((s, d) => s + (d._aggregatedDays ?? 1), 0) + const dailyAvg = totalPeriods > 0 ? actual / totalPeriods : 0 - return { actualCost: actual, hypotheticalCost: hypothetical, savings: saved, savingsPercent: pct, dailyAvg, heuristicModels: Array.from(heuristicModels).sort() } - }, [data]) + return { + actualCost: actual, + hypotheticalCost: hypothetical, + savings: saved, + savingsPercent: pct, + dailyAvg, + heuristicModels: Array.from(heuristicModels).sort(), + } + }, [data]) if (data.length === 0) { return ( @@ -82,18 +90,23 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
{t('cacheRoi.heuristicFallback', { count: heuristicModels.length, - modelsLabel: heuristicModels.length === 1 ? t('cacheRoi.model') : t('cacheRoi.models'), + modelsLabel: + heuristicModels.length === 1 ? t('cacheRoi.model') : t('cacheRoi.models'), })}
)}
{t('cacheRoi.withoutCache')}
-
+
+ +
{t('cacheRoi.withCacheActual')}
-
+
+ +
{t('cacheRoi.savings')}
@@ -103,7 +116,9 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
-
{t('cacheRoi.avgCostPerUnit', { unit: periodUnit(viewMode) })}
+
+ {t('cacheRoi.avgCostPerUnit', { unit: periodUnit(viewMode) })} +
@@ -121,13 +136,21 @@ export function CacheROI({ data, viewMode = 'daily' }: CacheROIProps) {
{t('cacheRoi.withCache')}
-
+
- {t('cacheRoi.paid')} - {t('cacheRoi.saved')} + + {t('cacheRoi.paid')} + + + {' '} + {t('cacheRoi.saved')} +
diff --git a/src/components/features/command-palette/CommandPalette.tsx b/src/components/features/command-palette/CommandPalette.tsx index f8e2c3a..e21a3dd 100644 --- a/src/components/features/command-palette/CommandPalette.tsx +++ b/src/components/features/command-palette/CommandPalette.tsx @@ -3,12 +3,37 @@ import { useTranslation } from 'react-i18next' import { Command } from 'cmdk' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { - Download, Trash2, Upload, Sun, Moon, Calendar, ChartBar, - Table, Search, ArrowUp, CircleHelp, Zap, Filter, BarChart3, - LineChart, Sigma, CalendarRange, Layers3, ArrowDown, RefreshCcw, SlidersHorizontal, Languages + Download, + Trash2, + Upload, + Sun, + Moon, + Calendar, + ChartBar, + Table, + Search, + ArrowUp, + CircleHelp, + Zap, + Filter, + BarChart3, + LineChart, + Sigma, + CalendarRange, + Layers3, + ArrowDown, + RefreshCcw, + SlidersHorizontal, + Languages, } from 'lucide-react' import { DASHBOARD_SECTION_DEFINITION_MAP } from '@/lib/dashboard-preferences' -import type { AppLanguage, DashboardSectionId, DashboardSectionOrder, DashboardSectionVisibility, ViewMode } from '@/types' +import type { + AppLanguage, + DashboardSectionId, + DashboardSectionOrder, + DashboardSectionVisibility, + ViewMode, +} from '@/types' interface CommandPaletteProps { isDark: boolean @@ -103,12 +128,11 @@ function normalizeSearchValue(value: string) { } function getCommandSearchText(cmd: CommandItem) { - return normalizeSearchValue([ - cmd.label, - cmd.description, - ...(cmd.keywords ?? []), - ...(cmd.aliases ?? []), - ].filter(Boolean).join(' ')) + return normalizeSearchValue( + [cmd.label, cmd.description, ...(cmd.keywords ?? []), ...(cmd.aliases ?? [])] + .filter(Boolean) + .join(' '), + ) } function getCommandSearchScore(cmd: CommandItem, query: string) { @@ -136,12 +160,12 @@ function getCommandSearchScore(cmd: CommandItem, query: string) { continue } - if ((cmd.aliases ?? []).some(alias => normalizeSearchValue(alias) === term)) { + if ((cmd.aliases ?? []).some((alias) => normalizeSearchValue(alias) === term)) { score += 70 continue } - if ((cmd.keywords ?? []).some(keyword => normalizeSearchValue(keyword).startsWith(term))) { + if ((cmd.keywords ?? []).some((keyword) => normalizeSearchValue(keyword).startsWith(term))) { score += 55 continue } @@ -192,158 +216,409 @@ 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]) + 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') { e.preventDefault() - setOpen(prev => !prev) + setOpen((prev) => !prev) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, []) - const sectionNavigationCommands = useMemo(() => ( - sectionOrder.flatMap((sectionId) => { - const section = DASHBOARD_SECTION_DEFINITION_MAP[sectionId] - - if (!sectionVisibility[sectionId] || !sectionAvailability[sectionId]) { - return [] - } - - const sectionLabel = t(section.labelKey) + 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]], + icon: SECTION_COMMAND_ICON_MAP[section.id], + action: () => onScrollTo(section.domId), + group: t('commandPalette.groups.navigation'), + testId: `command-section-${section.id}`, + ...(SECTION_COMMAND_ALIASES[section.id] + ? { aliases: SECTION_COMMAND_ALIASES[section.id] } + : {}), + }, + ] + }), + [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t], + ) - 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), + const baseCommands = useMemo( + () => [ + { + id: 'auto-import', + label: t('commandPalette.commands.autoImport.label'), + description: t('commandPalette.commands.autoImport.description'), + keywords: ['toktrack', 'import', 'load', 'sync'], + aliases: ['auto import', 'daten importieren'], + icon: , + action: onAutoImport, + group: t('commandPalette.groups.actions'), + }, + { + id: 'settings-open', + label: t('commandPalette.commands.openSettings.label'), + description: t('commandPalette.commands.openSettings.description'), + keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], + aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], + icon: , + action: onOpenSettings, + group: t('commandPalette.groups.actions'), + }, + { + id: 'csv', + label: t('commandPalette.commands.exportCsv.label'), + description: t('commandPalette.commands.exportCsv.description'), + keywords: ['download', 'export', 'csv'], + aliases: ['csv download', 'daten exportieren'], + shortcut: '⌘E', + icon: , + action: onExportCSV, + group: t('commandPalette.groups.actions'), + }, + { + 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'), + }, + { + id: 'delete', + label: t('commandPalette.commands.delete.label'), + description: t('commandPalette.commands.delete.description'), + keywords: ['reset data', 'clear data', 'delete'], + aliases: ['daten reset', 'alles loeschen'], + icon: , + action: onDelete, + group: t('commandPalette.groups.actions'), + }, + + { + id: 'view-daily', + label: t('commandPalette.commands.viewDaily.label'), + description: t('commandPalette.commands.viewDaily.description'), + keywords: ['daily', 'tage', 'tag', 'tagesansicht'], + aliases: ['daily view'], + icon: , + action: () => onViewModeChange('daily'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'view-monthly', + label: t('commandPalette.commands.viewMonthly.label'), + description: t('commandPalette.commands.viewMonthly.description'), + keywords: ['monthly', 'monate', 'monat', 'monatsansicht'], + aliases: ['monthly view'], + icon: , + action: () => onViewModeChange('monthly'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'view-yearly', + label: t('commandPalette.commands.viewYearly.label'), + description: t('commandPalette.commands.viewYearly.description'), + keywords: ['yearly', 'jahre', 'jahr', 'jahresansicht'], + aliases: ['yearly view'], + icon: , + action: () => onViewModeChange('yearly'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-7d', + label: t('commandPalette.commands.preset7d.label'), + description: t('commandPalette.commands.preset7d.description'), + keywords: ['7d', '7 tage'], + icon: , + action: () => onApplyPreset('7d'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-30d', + label: t('commandPalette.commands.preset30d.label'), + description: t('commandPalette.commands.preset30d.description'), + keywords: ['30d', '30 tage'], + icon: , + action: () => onApplyPreset('30d'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-month', + label: t('commandPalette.commands.presetMonth.label'), + description: t('commandPalette.commands.presetMonth.description'), + keywords: ['current month', 'monat'], + icon: , + action: () => onApplyPreset('month'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-year', + label: t('commandPalette.commands.presetYear.label'), + description: t('commandPalette.commands.presetYear.description'), + keywords: ['current year', 'jahr'], + icon: , + action: () => onApplyPreset('year'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'preset-all', + label: t('commandPalette.commands.presetAll.label'), + description: t('commandPalette.commands.presetAll.description'), + keywords: ['all', 'alles'], + icon: , + action: () => onApplyPreset('all'), + group: t('commandPalette.groups.filters'), + }, + { + id: 'clear-providers', + label: t('commandPalette.commands.clearProviders.label'), + description: t('commandPalette.commands.clearProviders.description'), + keywords: ['provider', 'anbieter', 'clear'], + icon: , + action: onClearProviders, + group: t('commandPalette.groups.filters'), + }, + { + id: 'clear-models', + label: t('commandPalette.commands.clearModels.label'), + description: t('commandPalette.commands.clearModels.description'), + keywords: ['models', 'modelle', 'clear'], + icon: , + action: onClearModels, + group: t('commandPalette.groups.filters'), + }, + { + id: 'clear-dates', + label: t('commandPalette.commands.clearDates.label'), + description: t('commandPalette.commands.clearDates.description'), + keywords: ['date', 'datum', 'range', 'clear'], + icon: , + action: onClearDateRange, + group: t('commandPalette.groups.filters'), + }, + { + id: 'reset-all', + label: t('commandPalette.commands.resetAll.label'), + description: t('commandPalette.commands.resetAll.description'), + keywords: ['reset all', 'alles zurücksetzen', 'default', 'clear filters'], + aliases: ['reset dashboard', 'alles reset', 'filter reset'], + icon: , + action: onResetAll, + group: t('commandPalette.groups.filters'), + }, + + { + 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'), - testId: `command-section-${section.id}`, - }] - }) - ), [onScrollTo, sectionAvailability, sectionOrder, sectionVisibility, t]) - - const baseCommands = useMemo(() => [ - { id: 'auto-import', label: t('commandPalette.commands.autoImport.label'), description: t('commandPalette.commands.autoImport.description'), keywords: ['toktrack', 'import', 'load', 'sync'], aliases: ['auto import', 'daten importieren'], icon: , action: onAutoImport, group: t('commandPalette.groups.actions') }, - { id: 'settings-open', label: t('commandPalette.commands.openSettings.label'), description: t('commandPalette.commands.openSettings.description'), keywords: ['settings', 'limits', 'subscription', 'anbieter limit', 'backup'], aliases: ['settings dialog', 'einstellungen öffnen', 'provider limits'], icon: , action: onOpenSettings, group: t('commandPalette.groups.actions') }, - { id: 'csv', label: t('commandPalette.commands.exportCsv.label'), description: t('commandPalette.commands.exportCsv.description'), keywords: ['download', 'export', 'csv'], aliases: ['csv download', 'daten exportieren'], shortcut: '⌘E', icon: , action: onExportCSV, group: t('commandPalette.groups.actions') }, - { 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') }, - { id: 'delete', label: t('commandPalette.commands.delete.label'), description: t('commandPalette.commands.delete.description'), keywords: ['reset data', 'clear data', 'delete'], aliases: ['daten reset', 'alles loeschen'], icon: , action: onDelete, group: t('commandPalette.groups.actions') }, - - { id: 'view-daily', label: t('commandPalette.commands.viewDaily.label'), description: t('commandPalette.commands.viewDaily.description'), keywords: ['daily', 'tage', 'tag', 'tagesansicht'], aliases: ['daily view'], icon: , action: () => onViewModeChange('daily'), group: t('commandPalette.groups.filters') }, - { id: 'view-monthly', label: t('commandPalette.commands.viewMonthly.label'), description: t('commandPalette.commands.viewMonthly.description'), keywords: ['monthly', 'monate', 'monat', 'monatsansicht'], aliases: ['monthly view'], icon: , action: () => onViewModeChange('monthly'), group: t('commandPalette.groups.filters') }, - { id: 'view-yearly', label: t('commandPalette.commands.viewYearly.label'), description: t('commandPalette.commands.viewYearly.description'), keywords: ['yearly', 'jahre', 'jahr', 'jahresansicht'], aliases: ['yearly view'], icon: , action: () => onViewModeChange('yearly'), group: t('commandPalette.groups.filters') }, - { id: 'preset-7d', label: t('commandPalette.commands.preset7d.label'), description: t('commandPalette.commands.preset7d.description'), keywords: ['7d', '7 tage'], icon: , action: () => onApplyPreset('7d'), group: t('commandPalette.groups.filters') }, - { id: 'preset-30d', label: t('commandPalette.commands.preset30d.label'), description: t('commandPalette.commands.preset30d.description'), keywords: ['30d', '30 tage'], icon: , action: () => onApplyPreset('30d'), group: t('commandPalette.groups.filters') }, - { id: 'preset-month', label: t('commandPalette.commands.presetMonth.label'), description: t('commandPalette.commands.presetMonth.description'), keywords: ['current month', 'monat'], icon: , action: () => onApplyPreset('month'), group: t('commandPalette.groups.filters') }, - { id: 'preset-year', label: t('commandPalette.commands.presetYear.label'), description: t('commandPalette.commands.presetYear.description'), keywords: ['current year', 'jahr'], icon: , action: () => onApplyPreset('year'), group: t('commandPalette.groups.filters') }, - { id: 'preset-all', label: t('commandPalette.commands.presetAll.label'), description: t('commandPalette.commands.presetAll.description'), keywords: ['all', 'alles'], icon: , action: () => onApplyPreset('all'), group: t('commandPalette.groups.filters') }, - { id: 'clear-providers', label: t('commandPalette.commands.clearProviders.label'), description: t('commandPalette.commands.clearProviders.description'), keywords: ['provider', 'anbieter', 'clear'], icon: , action: onClearProviders, group: t('commandPalette.groups.filters') }, - { id: 'clear-models', label: t('commandPalette.commands.clearModels.label'), description: t('commandPalette.commands.clearModels.description'), keywords: ['models', 'modelle', 'clear'], icon: , action: onClearModels, group: t('commandPalette.groups.filters') }, - { id: 'clear-dates', label: t('commandPalette.commands.clearDates.label'), description: t('commandPalette.commands.clearDates.description'), keywords: ['date', 'datum', 'range', 'clear'], icon: , action: onClearDateRange, group: t('commandPalette.groups.filters') }, - { id: 'reset-all', label: t('commandPalette.commands.resetAll.label'), description: t('commandPalette.commands.resetAll.description'), keywords: ['reset all', 'alles zurücksetzen', 'default', 'clear filters'], aliases: ['reset dashboard', 'alles reset', 'filter reset'], icon: , action: onResetAll, group: t('commandPalette.groups.filters') }, - - { 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') }, - ...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 => { - const selected = selectedProviders.includes(provider) - return { - id: `provider-${provider}`, - label: `${selected ? t('commandPalette.commands.clearProviders.label') : t('common.provider')}: ${provider}`, - description: selected ? `${t('commandPalette.commands.clearProviders.description')}: ${provider}` : `${t('common.provider')} ${provider}`, - keywords: ['anbieter', 'provider', provider.toLowerCase()], - aliases: [`filter ${provider.toLowerCase()}`, `${provider.toLowerCase()} daten`], + }, + { + 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: () => onToggleProvider(provider), - group: t('commandPalette.groups.providers'), - } - }) - ), [availableProviders, selectedProviders, onToggleProvider, t]) - - const modelCommands = useMemo(() => ( - availableModels.map(model => { - const selected = selectedModels.includes(model) - return { - id: `model-${model}`, - label: `${selected ? t('commandPalette.commands.clearModels.label') : t('common.model')}: ${model}`, - description: selected ? `${t('commandPalette.commands.clearModels.description')}: ${model}` : `${t('common.model')} ${model}`, - keywords: ['modell', 'model', model.toLowerCase()], - aliases: [`filter ${model.toLowerCase()}`, `${model.toLowerCase()} requests`, `${model.toLowerCase()} kosten`], - icon: , - action: () => onToggleModel(model), - group: t('commandPalette.groups.models'), - } - }) - ), [availableModels, selectedModels, onToggleModel, t]) - - const commands = useMemo(() => [ - ...baseCommands, - ...providerCommands, - ...modelCommands, - ], [baseCommands, providerCommands, modelCommands]) - - const filteredCommands = useMemo(() => ( - commands - .map((cmd, index) => ({ cmd, score: getCommandSearchScore(cmd, search), index })) - .filter(entry => entry.score > 0) - .sort((a, b) => b.score - a.score || a.index - b.index) - .map(entry => entry.cmd) - ), [commands, search]) + action: () => onScrollTo('filters'), + 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) => { + const selected = selectedProviders.includes(provider) + return { + id: `provider-${provider}`, + label: `${selected ? t('commandPalette.commands.clearProviders.label') : t('common.provider')}: ${provider}`, + description: selected + ? `${t('commandPalette.commands.clearProviders.description')}: ${provider}` + : `${t('common.provider')} ${provider}`, + keywords: ['anbieter', 'provider', provider.toLowerCase()], + aliases: [`filter ${provider.toLowerCase()}`, `${provider.toLowerCase()} daten`], + icon: , + action: () => onToggleProvider(provider), + group: t('commandPalette.groups.providers'), + } + }), + [availableProviders, selectedProviders, onToggleProvider, t], + ) + + const modelCommands = useMemo( + () => + availableModels.map((model) => { + const selected = selectedModels.includes(model) + return { + id: `model-${model}`, + label: `${selected ? t('commandPalette.commands.clearModels.label') : t('common.model')}: ${model}`, + description: selected + ? `${t('commandPalette.commands.clearModels.description')}: ${model}` + : `${t('common.model')} ${model}`, + keywords: ['modell', 'model', model.toLowerCase()], + aliases: [ + `filter ${model.toLowerCase()}`, + `${model.toLowerCase()} requests`, + `${model.toLowerCase()} kosten`, + ], + icon: , + action: () => onToggleModel(model), + group: t('commandPalette.groups.models'), + } + }), + [availableModels, selectedModels, onToggleModel, t], + ) + + const commands = useMemo( + () => [...baseCommands, ...providerCommands, ...modelCommands], + [baseCommands, providerCommands, modelCommands], + ) + + const filteredCommands = useMemo( + () => + commands + .map((cmd, index) => ({ cmd, score: getCommandSearchScore(cmd, search), index })) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map((entry) => entry.cmd), + [commands, search], + ) const visibleCommands = useMemo(() => filteredCommands.slice(0, 9), [filteredCommands]) - const groups = useMemo(() => Array.from(new Set(filteredCommands.map(c => c.group))), [filteredCommands]) + const groups = useMemo( + () => Array.from(new Set(filteredCommands.map((c) => c.group))), + [filteredCommands], + ) const runCommand = (cmd: CommandItem) => { setOpen(false) @@ -380,9 +655,7 @@ export function CommandPalette({ {t('commandPalette.title')} - - {t('commandPalette.description')} - + {t('commandPalette.description')}
@@ -397,38 +670,47 @@ export function CommandPalette({ {t('commandPalette.empty')} - {groups.map(group => ( - - {filteredCommands.filter(c => c.group === group).map(cmd => { - const quickIndex = visibleCommands.findIndex(visible => visible.id === cmd.id) - - return ( - runCommand(cmd)} - className="flex items-center gap-2 rounded-md px-2 py-2 text-sm cursor-pointer aria-selected:bg-accent" - > - {cmd.icon} -
-
{cmd.label}
- {cmd.description && ( -
{cmd.description}
- )} -
- {cmd.shortcut && ( - - {cmd.shortcut} - - )} - {quickIndex >= 0 && ( - - {quickIndex + 1} - - )} -
- )})} + {groups.map((group) => ( + + {filteredCommands + .filter((c) => c.group === group) + .map((cmd) => { + const quickIndex = visibleCommands.findIndex((visible) => visible.id === cmd.id) + + return ( + runCommand(cmd)} + className="flex items-center gap-2 rounded-md px-2 py-2 text-sm cursor-pointer aria-selected:bg-accent" + > + {cmd.icon} +
+
{cmd.label}
+ {cmd.description && ( +
+ {cmd.description} +
+ )} +
+ {cmd.shortcut && ( + + {cmd.shortcut} + + )} + {quickIndex >= 0 && ( + + {quickIndex + 1} + + )} +
+ ) + })}
))} diff --git a/src/components/features/comparison/PeriodComparison.tsx b/src/components/features/comparison/PeriodComparison.tsx index de10c8c..8c412e4 100644 --- a/src/components/features/comparison/PeriodComparison.tsx +++ b/src/components/features/comparison/PeriodComparison.tsx @@ -15,14 +15,23 @@ interface PeriodComparisonProps { type Preset = 'week' | 'month' | 'custom' -function getDelta(a: number, b: number, higherIsGood = false): { value: number; color: string; arrow: string; hasData: boolean } { - if (b === 0 && a === 0) return { value: 0, color: 'text-muted-foreground', arrow: '', hasData: false } +function getDelta( + a: number, + b: number, + higherIsGood = false, +): { value: number; color: string; arrow: string; hasData: boolean } { + if (b === 0 && a === 0) + return { value: 0, color: 'text-muted-foreground', arrow: '', hasData: false } if (b === 0) return { value: 0, color: 'text-muted-foreground', arrow: '↑', hasData: false } const pct = ((a - b) / b) * 100 const isPositive = pct > 0 // For costs: higher is bad (red). For cache-rate: higher is good (green). - const color = pct === 0 ? 'text-muted-foreground' - : (isPositive === higherIsGood) ? 'text-green-400' : 'text-red-400' + const color = + pct === 0 + ? 'text-muted-foreground' + : isPositive === higherIsGood + ? 'text-green-400' + : 'text-red-400' const arrow = pct > 0 ? '↑' : pct < 0 ? '↓' : '' return { value: Math.abs(pct), color, arrow, hasData: true } } @@ -36,7 +45,9 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { if (sorted.length === 0) return { periodA: [], periodB: [], labelA: '', labelB: '' } // Use the date string directly to avoid timezone issues with toISOString() - const lastStr = sorted[sorted.length - 1].date + const lastEntry = sorted[sorted.length - 1] + if (!lastEntry) return { periodA: [], periodB: [], labelA: '', labelB: '' } + const lastStr = lastEntry.date const lastDate = new Date(lastStr + 'T00:00:00') // Helper: format local date as YYYY-MM-DD without timezone shift @@ -65,8 +76,8 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { const twoWeeksAgoStr = fmtLocal(lastMonday) return { - periodA: sorted.filter(d => d.date >= weekAgoStr && d.date <= lastStr), - periodB: sorted.filter(d => d.date >= twoWeeksAgoStr && d.date < weekAgoStr), + periodA: sorted.filter((d) => d.date >= weekAgoStr && d.date <= lastStr), + periodB: sorted.filter((d) => d.date >= twoWeeksAgoStr && d.date < weekAgoStr), labelA: t('comparison.thisWeek'), labelB: t('comparison.lastWeek'), } @@ -80,8 +91,8 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { const prevMonth = fmtLocal(prevDate).slice(0, 7) return { - periodA: sorted.filter(d => d.date.startsWith(currentMonth)), - periodB: sorted.filter(d => d.date.startsWith(prevMonth)), + periodA: sorted.filter((d) => d.date.startsWith(currentMonth)), + periodB: sorted.filter((d) => d.date.startsWith(prevMonth)), labelA: t('comparison.thisMonth'), labelB: t('comparison.lastMonth'), } @@ -102,7 +113,9 @@ export function PeriodComparison({ data }: PeriodComparisonProps) {

{t('comparison.notEnoughData')}

-

{t('comparison.requiresDays', { count: data.length })}

+

+ {t('comparison.requiresDays', { count: data.length })} +

@@ -110,15 +123,45 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { } const hasPrevData = periodB.length > 0 - const fmtB = (val: string) => hasPrevData ? val : '–' + const fmtB = (val: string) => (hasPrevData ? val : '–') const comparisons = [ - { label: t('comparison.cost'), a: formatCurrency(metricsA.totalCost), b: fmtB(formatCurrency(metricsB.totalCost)), delta: getDelta(metricsA.totalCost, metricsB.totalCost) }, - { label: t('comparison.tokens'), a: formatTokens(metricsA.totalTokens), b: fmtB(formatTokens(metricsB.totalTokens)), delta: getDelta(metricsA.totalTokens, metricsB.totalTokens) }, - { label: '$/1M', a: `$${metricsA.costPerMillion.toFixed(2)}`, b: fmtB(`$${metricsB.costPerMillion.toFixed(2)}`), delta: getDelta(metricsA.costPerMillion, metricsB.costPerMillion) }, - { label: t('comparison.avgPerDay'), a: formatCurrency(metricsA.avgDailyCost), b: fmtB(formatCurrency(metricsB.avgDailyCost)), delta: getDelta(metricsA.avgDailyCost, metricsB.avgDailyCost) }, - { label: t('comparison.cacheRate'), a: formatPercent(metricsA.cacheHitRate), b: fmtB(formatPercent(metricsB.cacheHitRate)), delta: getDelta(metricsA.cacheHitRate, metricsB.cacheHitRate, true) }, - { label: t('comparison.days'), a: String(metricsA.activeDays), b: fmtB(String(metricsB.activeDays)), delta: getDelta(metricsA.activeDays, metricsB.activeDays) }, + { + label: t('comparison.cost'), + a: formatCurrency(metricsA.totalCost), + b: fmtB(formatCurrency(metricsB.totalCost)), + delta: getDelta(metricsA.totalCost, metricsB.totalCost), + }, + { + label: t('comparison.tokens'), + a: formatTokens(metricsA.totalTokens), + b: fmtB(formatTokens(metricsB.totalTokens)), + delta: getDelta(metricsA.totalTokens, metricsB.totalTokens), + }, + { + label: '$/1M', + a: `$${metricsA.costPerMillion.toFixed(2)}`, + b: fmtB(`$${metricsB.costPerMillion.toFixed(2)}`), + delta: getDelta(metricsA.costPerMillion, metricsB.costPerMillion), + }, + { + label: t('comparison.avgPerDay'), + a: formatCurrency(metricsA.avgDailyCost), + b: fmtB(formatCurrency(metricsB.avgDailyCost)), + delta: getDelta(metricsA.avgDailyCost, metricsB.avgDailyCost), + }, + { + label: t('comparison.cacheRate'), + a: formatPercent(metricsA.cacheHitRate), + b: fmtB(formatPercent(metricsB.cacheHitRate)), + delta: getDelta(metricsA.cacheHitRate, metricsB.cacheHitRate, true), + }, + { + label: t('comparison.days'), + a: String(metricsA.activeDays), + b: fmtB(String(metricsB.activeDays)), + delta: getDelta(metricsA.activeDays, metricsB.activeDays), + }, ] return ( @@ -154,29 +197,43 @@ export function PeriodComparison({ data }: PeriodComparisonProps) { - + - + - {comparisons.map(row => ( + {comparisons.map((row) => ( - + ))} diff --git a/src/components/features/drill-down/DrillDownModal.tsx b/src/components/features/drill-down/DrillDownModal.tsx index 0aa8a3c..40e3b81 100644 --- a/src/components/features/drill-down/DrillDownModal.tsx +++ b/src/components/features/drill-down/DrillDownModal.tsx @@ -1,10 +1,21 @@ import { useMemo } from 'react' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts' import { CustomTooltip } from '@/components/charts/CustomTooltip' import { formatCurrency, formatTokens, formatPercent, formatDate } from '@/lib/formatters' import { FormattedValue } from '@/components/ui/formatted-value' -import { normalizeModelName, getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' +import { + normalizeModelName, + getModelColor, + getModelProvider, + getProviderBadgeClasses, +} from '@/lib/model-utils' import { cn } from '@/lib/cn' import type { DailyUsage } from '@/types' @@ -18,12 +29,38 @@ interface DrillDownModalProps { export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDownModalProps) { const modelData = useMemo(() => { if (!day) return [] - const map = new Map() + const map = new Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + } + >() for (const mb of day.modelBreakdowns) { const name = normalizeModelName(mb.modelName) - const ex = map.get(name) ?? { cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreate: 0, thinking: 0, requests: 0 } + const ex = map.get(name) ?? { + cost: 0, + tokens: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheCreate: 0, + thinking: 0, + requests: 0, + } ex.cost += mb.cost - ex.tokens += mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens + mb.thinkingTokens + ex.tokens += + mb.inputTokens + + mb.outputTokens + + mb.cacheCreationTokens + + mb.cacheReadTokens + + mb.thinkingTokens ex.input += mb.inputTokens ex.output += mb.outputTokens ex.cacheRead += mb.cacheReadTokens @@ -39,48 +76,85 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo if (!day) return null - const cacheRate = (day.cacheReadTokens + day.cacheCreationTokens + day.inputTokens + day.outputTokens + day.thinkingTokens) > 0 - ? (day.cacheReadTokens / (day.cacheReadTokens + day.cacheCreationTokens + day.inputTokens + day.outputTokens + day.thinkingTokens)) * 100 - : 0 + const tokensTotal = + day.cacheReadTokens + + day.cacheCreationTokens + + day.inputTokens + + day.outputTokens + + day.thinkingTokens + const hasTokens = tokensTotal > 0 - const pieData = modelData.map(m => ({ name: m.name, value: m.cost })) - const avgTokensPerRequest = day.requestCount > 0 ? day.totalTokens / day.requestCount : 0 + const cacheRate = hasTokens ? (day.cacheReadTokens / tokensTotal) * 100 : 0 + + const pieData = modelData.map((m) => ({ name: m.name, value: m.cost })) + const avgTokensPerRequest = day.requestCount > 0 ? tokensTotal / day.requestCount : 0 const avgCostPerRequest = day.requestCount > 0 ? day.totalCost / day.requestCount : 0 - const costRanking = [...contextData].sort((a, b) => b.totalCost - a.totalCost).findIndex(entry => entry.date === day.date) + 1 - const requestRanking = [...contextData].sort((a, b) => b.requestCount - a.requestCount).findIndex(entry => entry.date === day.date) + 1 + const costPerMillion = hasTokens ? day.totalCost / (tokensTotal / 1_000_000) : null + const costRanking = + [...contextData] + .sort((a, b) => b.totalCost - a.totalCost) + .findIndex((entry) => entry.date === day.date) + 1 + const requestRanking = + [...contextData] + .sort((a, b) => b.requestCount - a.requestCount) + .findIndex((entry) => entry.date === day.date) + 1 const previousSeven = [...contextData] - .filter(entry => entry.date < day.date) + .filter((entry) => entry.date < day.date) .sort((a, b) => a.date.localeCompare(b.date)) .slice(-7) - const avgCost7 = previousSeven.length > 0 ? previousSeven.reduce((sum, entry) => sum + entry.totalCost, 0) / previousSeven.length : null - const avgRequests7 = previousSeven.length > 0 ? previousSeven.reduce((sum, entry) => sum + entry.requestCount, 0) / previousSeven.length : null - const topRequestModel = modelData.reduce((best, current) => { - if (!best || current.requests > best.requests) return current - return best - }, null as (typeof modelData)[number] | null) + const avgCost7 = + previousSeven.length > 0 + ? previousSeven.reduce((sum, entry) => sum + entry.totalCost, 0) / previousSeven.length + : null + const avgRequests7 = + previousSeven.length > 0 + ? previousSeven.reduce((sum, entry) => sum + entry.requestCount, 0) / previousSeven.length + : null + const topRequestModel = modelData.reduce( + (best, current) => { + if (!best || current.requests > best.requests) return current + return best + }, + null as (typeof modelData)[number] | null, + ) + const formatTokenShare = (value: number) => + hasTokens ? formatPercent((value / tokensTotal) * 100) : '–' return ( !o && onClose()}> - {formatDate(day.date, 'long')} — {formatCurrency(day.totalCost)} + + {formatDate(day.date, 'long')} — {formatCurrency(day.totalCost)} + - Detaillierte Tagesansicht mit Token-Verteilung, Modellanteilen, Requests und Thinking Tokens. + Detaillierte Tagesansicht mit Token-Verteilung, Modellanteilen, Requests und Thinking + Tokens.
Tokens
-
+
+ +
$/1M
-
+
+ {costPerMillion !== null ? ( + + ) : ( + '–' + )} +
Cache-Rate
-
+
+ +
Modelle
@@ -88,19 +162,27 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Requests
-
+
+ +
Thinking
-
+
+ +
Tokens / Req
-
+
+ +
Kosten / Req
-
+
+ +
Kosten-Rang
@@ -108,7 +190,9 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Request-Rang
-
{requestRanking > 0 ? `#${requestRanking}` : '–'}
+
+ {requestRanking > 0 ? `#${requestRanking}` : '–'} +
@@ -119,11 +203,19 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Kosten vs. 7T-Ø
-
{avgCost7 !== null ? `${day.totalCost >= avgCost7 ? '↑' : '↓'} ${formatCurrency(Math.abs(day.totalCost - avgCost7))}` : '–'}
+
+ {avgCost7 !== null + ? `${day.totalCost >= avgCost7 ? '↑' : '↓'} ${formatCurrency(Math.abs(day.totalCost - avgCost7))}` + : '–'} +
Requests vs. 7T-Ø
-
{avgRequests7 !== null ? `${day.requestCount >= avgRequests7 ? '↑' : '↓'} ${Math.abs(day.requestCount - avgRequests7).toFixed(0)}` : '–'}
+
+ {avgRequests7 !== null + ? `${day.requestCount >= avgRequests7 ? '↑' : '↓'} ${Math.abs(day.requestCount - avgRequests7).toFixed(0)}` + : '–'} +
@@ -131,27 +223,67 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
Token-Verteilung
- {day.totalTokens > 0 && ([ - { value: day.cacheReadTokens, color: 'hsl(160, 50%, 42%)', label: 'Cache Read' }, - { value: day.cacheCreationTokens, color: 'hsl(262, 60%, 55%)', label: 'Cache Write' }, - { value: day.inputTokens, color: 'hsl(340, 55%, 52%)', label: 'Input' }, - { value: day.outputTokens, color: 'hsl(35, 80%, 52%)', label: 'Output' }, - { value: day.thinkingTokens, color: 'hsl(12, 78%, 56%)', label: 'Thinking' }, - ] as const).map(seg => ( -
- ))} + {hasTokens && + ( + [ + { value: day.cacheReadTokens, color: 'hsl(160, 50%, 42%)', label: 'Cache Read' }, + { + value: day.cacheCreationTokens, + color: 'hsl(262, 60%, 55%)', + label: 'Cache Write', + }, + { value: day.inputTokens, color: 'hsl(340, 55%, 52%)', label: 'Input' }, + { value: day.outputTokens, color: 'hsl(35, 80%, 52%)', label: 'Output' }, + { value: day.thinkingTokens, color: 'hsl(12, 78%, 56%)', label: 'Thinking' }, + ] as const + ).map((seg) => ( +
+ ))}
- Cache Read {formatPercent((day.cacheReadTokens / day.totalTokens) * 100)} - Cache Write {formatPercent((day.cacheCreationTokens / day.totalTokens) * 100)} - Input {formatPercent((day.inputTokens / day.totalTokens) * 100)} - Output {formatPercent((day.outputTokens / day.totalTokens) * 100)} - Thinking {formatPercent((day.thinkingTokens / day.totalTokens) * 100)} + + + Cache Read {formatTokenShare(day.cacheReadTokens)} + + + + Cache Write {formatTokenShare(day.cacheCreationTokens)} + + + + Input {formatTokenShare(day.inputTokens)} + + + + Output {formatTokenShare(day.outputTokens)} + + + + Thinking {formatTokenShare(day.thinkingTokens)} +
@@ -159,8 +291,16 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
- - {pieData.map(entry => ( + + {pieData.map((entry) => ( ))} @@ -170,26 +310,47 @@ export function DrillDownModal({ day, contextData = [], open, onClose }: DrillDo
- {modelData.map(model => { + {modelData.map((model) => { const share = day.totalCost > 0 ? (model.cost / day.totalCost) * 100 : 0 return ( -
+
- + {model.name} - + {getModelProvider(model.name)} - {formatPercent(share)} + + {formatPercent(share)} +
- - - {model.requests} Req + + + + + + + + {model.requests} Req +
- {model.requests > 0 ? `${formatCurrency(model.cost / model.requests)}/Req · ${formatTokens(model.tokens / model.requests)}/Req` : 'Keine Requests'} + {model.requests > 0 + ? `${formatCurrency(model.cost / model.requests)}/Req · ${formatTokens(model.tokens / model.requests)}/Req` + : 'Keine Requests'}
diff --git a/src/components/features/forecast/CostForecast.tsx b/src/components/features/forecast/CostForecast.tsx index 3307283..fd14b8b 100644 --- a/src/components/features/forecast/CostForecast.tsx +++ b/src/components/features/forecast/CostForecast.tsx @@ -1,10 +1,20 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { ResponsiveContainer, ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts' +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' import { ChartCard, ChartAnimationAware, ChartReveal } from '@/components/charts/ChartCard' import { CustomTooltip } from '@/components/charts/CustomTooltip' import { CHART_COLORS, CHART_MARGIN, CHART_ANIMATION } from '@/components/charts/chart-theme' -import { formatCurrency, formatDateAxis } from '@/lib/formatters' +import { coerceNumber, formatCurrency, formatDateAxis } from '@/lib/formatters' import { computeCurrentMonthForecast } from '@/lib/calculations' import { MetricCard } from '@/components/cards/MetricCard' import { FormattedValue } from '@/components/ui/formatted-value' @@ -19,7 +29,16 @@ interface CostForecastProps { export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { const { t } = useTranslation() - const { chartData, forecastTotal, currentMonthTotal, dailyAvgTrend, projectedDailyBurn, remainingDays, confidence, confidenceColor } = useMemo(() => { + const { + chartData, + forecastTotal, + currentMonthTotal, + dailyAvgTrend, + projectedDailyBurn, + remainingDays, + confidence, + confidenceColor, + } = useMemo(() => { const forecast = computeCurrentMonthForecast(data) if (!forecast) { @@ -50,13 +69,21 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { daysInMonth, } = forecast - const confidenceColor = confidence === 'high' - ? 'text-green-400 bg-green-400/10' - : confidence === 'medium' - ? 'text-yellow-400 bg-yellow-400/10' - : 'text-red-400 bg-red-400/10' + const confidenceColor = + confidence === 'high' + ? 'text-green-400 bg-green-400/10' + : confidence === 'medium' + ? 'text-yellow-400 bg-yellow-400/10' + : 'text-red-400 bg-red-400/10' - const points: { date: string; cost?: number; forecast?: number; lower?: number; upper?: number; band?: number }[] = [] + const points: { + date: string + cost?: number + forecast?: number + lower?: number + upper?: number + band?: number + }[] = [] for (const point of elapsedCalendarSeries) { points.push({ date: point.date, cost: point.cost }) @@ -65,10 +92,12 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { const lastActualCost = elapsedCalendarSeries[elapsedCalendarSeries.length - 1]?.cost ?? 0 if (points.length > 0) { const lastPoint = points[points.length - 1] - lastPoint.forecast = lastActualCost - lastPoint.lower = Math.max(0, lastActualCost - volatility) - lastPoint.upper = lastActualCost + volatility - lastPoint.band = (lastPoint.upper ?? 0) - (lastPoint.lower ?? 0) + if (lastPoint) { + lastPoint.forecast = lastActualCost + lastPoint.lower = Math.max(0, lastActualCost - volatility) + lastPoint.upper = lastActualCost + volatility + lastPoint.band = (lastPoint.upper ?? 0) - (lastPoint.lower ?? 0) + } } for (let day = elapsedCalendarSeries.length + 1; day <= daysInMonth; day++) { @@ -114,9 +143,15 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { return (
} - subtitle={t('forecast.totalOverPeriods', { total: formatCurrency(total), count: data.length, unit: viewMode === 'monthly' ? t('periods.months') : t('periods.years') })} + subtitle={t('forecast.totalOverPeriods', { + total: formatCurrency(total), + count: data.length, + unit: viewMode === 'monthly' ? t('periods.months') : t('periods.years'), + })} icon={} />
@@ -129,9 +164,7 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) {

{t('forecast.noForecast')}

-

- {t('forecast.requiresTwoDays')} -

+

{t('forecast.requiresTwoDays')}

) @@ -140,11 +173,24 @@ export function CostForecast({ data, viewMode = 'daily' }: CostForecastProps) { return (
{t('forecast.monthEndForecast')} {t(`forecast.${confidence}`)}} - value={<>~} + label={ + + {t('forecast.monthEndForecast')}{' '} + + {t(`forecast.${confidence}`)} + + + } + value={} subtitle={`${t('forecast.soFar', { value: formatCurrency(currentMonthTotal) })} · ${t('forecast.remainingDays', { count: remainingDays })}${dailyAvgTrend ? ` · ${t('forecast.projectedPerDay', { value: formatCurrency(projectedDailyBurn) })}` : ''}`} icon={} - trend={dailyAvgTrend && dailyAvgTrend.change !== 0 ? { value: dailyAvgTrend.change, label: t('forecast.vsLastWeek') } : null} + trend={ + dailyAvgTrend && dailyAvgTrend.change !== 0 + ? { value: dailyAvgTrend.change, label: t('forecast.vsLastWeek') } + : null + } /> - - - - - - - - - formatCurrency(v)} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> - formatCurrency(v)} />} /> - - - - - + + + + + + + + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(numericValue) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} />} /> + + + + + diff --git a/src/components/features/heatmap/HeatmapCalendar.tsx b/src/components/features/heatmap/HeatmapCalendar.tsx index 264e795..c89fe40 100644 --- a/src/components/features/heatmap/HeatmapCalendar.tsx +++ b/src/components/features/heatmap/HeatmapCalendar.tsx @@ -3,7 +3,13 @@ import { useTranslation } from 'react-i18next' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { InfoButton } from '@/components/features/help/InfoButton' import { CHART_HELP } from '@/lib/help-content' -import { formatCurrency, formatNumber, formatTokens, localToday, toLocalDateStr } from '@/lib/formatters' +import { + formatCurrency, + formatNumber, + formatTokens, + localToday, + toLocalDateStr, +} from '@/lib/formatters' import { getCurrentLocale } from '@/lib/i18n' import type { DailyUsage, ViewMode } from '@/types' @@ -23,32 +29,67 @@ function getColor(value: number, maxValue: number, hue: number): string { if (value === 0) return 'hsl(224, 12%, 14%)' const intensity = Math.min(value / maxValue, 1) if (intensity < 0.15) return `hsl(${hue}, 70%, 18%)` - if (intensity < 0.30) return `hsl(${hue}, 70%, 26%)` + if (intensity < 0.3) return `hsl(${hue}, 70%, 26%)` if (intensity < 0.45) return `hsl(${hue}, 70%, 34%)` - if (intensity < 0.60) return `hsl(${hue}, 70%, 42%)` + if (intensity < 0.6) return `hsl(${hue}, 70%, 42%)` if (intensity < 0.75) return `hsl(${hue}, 70%, 52%)` - if (intensity < 0.90) return `hsl(${hue}, 70%, 60%)` + if (intensity < 0.9) return `hsl(${hue}, 70%, 60%)` return `hsl(${hue}, 70%, 70%)` } -export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: HeatmapCalendarProps) { +export function HeatmapCalendar({ + data, + viewMode = 'daily', + metric = 'cost', +}: HeatmapCalendarProps) { const { t } = useTranslation() - const [tooltip, setTooltip] = useState<{ x: number; y: number; date: string; value: number } | null>(null) + const [tooltip, setTooltip] = useState<{ + x: number + y: number + date: string + value: number + } | null>(null) const overlayRef = useRef(null) const dayLabels = useMemo( - () => Array.from({ length: 7 }, (_, index) => index).map((index) => index % 2 === 1 ? '' : new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }).format(new Date(Date.UTC(2024, 0, 1 + index))).slice(0, 2)), - [] + () => + Array.from({ length: 7 }, (_, index) => index).map((index) => + index % 2 === 1 + ? '' + : new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) + .format(new Date(Date.UTC(2024, 0, 1 + index))) + .slice(0, 2), + ), + [], ) const config = { - cost: { title: t('charts.heatmap.costTitle'), empty: t('charts.heatmap.costEmpty'), formatter: formatCurrency, accessor: (entry: DailyUsage) => entry.totalCost, hue: 215 }, - requests: { title: t('charts.heatmap.requestsTitle'), empty: t('charts.heatmap.requestsEmpty'), formatter: formatNumber, accessor: (entry: DailyUsage) => entry.requestCount, hue: 160 }, - tokens: { title: t('charts.heatmap.tokensTitle'), empty: t('charts.heatmap.tokensEmpty'), formatter: formatTokens, accessor: (entry: DailyUsage) => entry.totalTokens, hue: 35 }, + cost: { + title: t('charts.heatmap.costTitle'), + empty: t('charts.heatmap.costEmpty'), + formatter: formatCurrency, + accessor: (entry: DailyUsage) => entry.totalCost, + hue: 215, + }, + requests: { + title: t('charts.heatmap.requestsTitle'), + empty: t('charts.heatmap.requestsEmpty'), + formatter: formatNumber, + accessor: (entry: DailyUsage) => entry.requestCount, + hue: 160, + }, + tokens: { + title: t('charts.heatmap.tokensTitle'), + empty: t('charts.heatmap.tokensEmpty'), + formatter: formatTokens, + accessor: (entry: DailyUsage) => entry.totalTokens, + hue: 35, + }, }[metric] - const infoText = metric === 'cost' - ? CHART_HELP.heatmap - : metric === 'requests' - ? CHART_HELP.requestHeatmap - : CHART_HELP.tokenHeatmap + const infoText = + metric === 'cost' + ? CHART_HELP.heatmap + : metric === 'requests' + ? CHART_HELP.requestHeatmap + : CHART_HELP.tokenHeatmap const { cells, weeks, months, maxValue } = useMemo(() => { if (data.length === 0) return { cells: [], weeks: 0, months: [], maxValue: 0 } @@ -62,8 +103,12 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H } const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)) - const startDate = new Date(sorted[0].date + 'T00:00:00') - const endDate = new Date(sorted[sorted.length - 1].date + 'T00:00:00') + const firstEntry = sorted[0] + const lastEntry = sorted[sorted.length - 1] + if (!firstEntry || !lastEntry) return { cells: [], weeks: 0, months: [], maxValue: 0 } + + const startDate = new Date(firstEntry.date + 'T00:00:00') + const endDate = new Date(lastEntry.date + 'T00:00:00') // Align to Monday const startDow = (startDate.getDay() + 6) % 7 @@ -72,7 +117,7 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H const result: { date: string; value: number; week: number; day: number }[] = [] const monthLabels: { label: string; week: number }[] = [] - let currentDate = new Date(alignedStart) + const currentDate = new Date(alignedStart) let week = 0 let lastMonth = -1 @@ -117,7 +162,9 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H

{config.empty}

-

{t('charts.heatmap.switchToDaily')}

+

+ {t('charts.heatmap.switchToDaily')} +

@@ -141,76 +188,77 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H
- {/* Day labels */} - {dayLabels.map((label, i) => ( - label && ( + {/* Day labels */} + {dayLabels.map( + (label, i) => + label && ( + + {label} + + ), + )} + + {/* Month labels */} + {months.map((m, i) => ( - {label} + {m.label} - ) - ))} - - {/* Month labels */} - {months.map((m, i) => ( - - {m.label} - - ))} + ))} - {/* Cells */} - {cells.map((cell, i) => { - const isToday = cell.date === todayStr - return ( - - { - const bounds = overlayRef.current?.getBoundingClientRect() - if (!bounds) return - setTooltip({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top - 12, - date: cell.date, - value: cell.value, - }) - }} - onMouseLeave={() => setTooltip(null)} - /> - {isToday && ( + {/* Cells */} + {cells.map((cell, i) => { + const isToday = cell.date === todayStr + return ( + { + const bounds = overlayRef.current?.getBoundingClientRect() + if (!bounds) return + setTooltip({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top - 12, + date: cell.date, + value: cell.value, + }) + }} + onMouseLeave={() => setTooltip(null)} /> - )} - - ) - })} + {isToday && ( + + )} + + ) + })}
@@ -227,7 +275,7 @@ export function HeatmapCalendar({ data, viewMode = 'daily', metric = 'cost' }: H {/* Legend */}
{t('charts.heatmap.less')} - {[0, 0.15, 0.30, 0.45, 0.60, 0.75, 0.90, 1].map((level, i) => ( + {[0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9, 1].map((level, i) => (
>(() => ({ - totalCost: t('helpPanel.metricLabels.totalCost'), - totalTokens: t('helpPanel.metricLabels.totalTokens'), - activeDays: t('helpPanel.metricLabels.activeDays'), - topModel: t('helpPanel.metricLabels.topModel'), - cacheHitRate: t('helpPanel.metricLabels.cacheHitRate'), - costPerMillion: t('helpPanel.metricLabels.costPerMillion'), - mostExpensiveDay: t('helpPanel.metricLabels.mostExpensiveDay'), - cheapestDay: t('helpPanel.metricLabels.cheapestDay'), - avgCostPerDay: t('helpPanel.metricLabels.avgCostPerDay'), - outputTokens: t('helpPanel.metricLabels.outputTokens'), - }), [t]) - const chartLabels = useMemo>(() => ({ - costOverTime: t('helpPanel.chartLabels.costOverTime'), - costByModel: t('helpPanel.chartLabels.costByModel'), - costByModelOverTime: t('helpPanel.chartLabels.costByModelOverTime'), - cumulativeCost: t('helpPanel.chartLabels.cumulativeCost'), - costByWeekday: t('helpPanel.chartLabels.costByWeekday'), - tokensOverTime: t('helpPanel.chartLabels.tokensOverTime'), - requestsOverTime: t('helpPanel.chartLabels.requestsOverTime'), - requestCacheHitRate: t('helpPanel.chartLabels.requestCacheHitRate'), - tokenTypes: t('helpPanel.chartLabels.tokenTypes'), - tokenEfficiency: t('helpPanel.chartLabels.tokenEfficiency'), - modelMix: t('helpPanel.chartLabels.modelMix'), - distributionAnalysis: t('helpPanel.chartLabels.distributionAnalysis'), - correlationAnalysis: t('helpPanel.chartLabels.correlationAnalysis'), - heatmap: t('helpPanel.chartLabels.heatmap'), - requestHeatmap: t('helpPanel.chartLabels.requestHeatmap'), - tokenHeatmap: t('helpPanel.chartLabels.tokenHeatmap'), - forecast: t('helpPanel.chartLabels.forecast'), - cacheROI: t('helpPanel.chartLabels.cacheROI'), - periodComparison: t('helpPanel.chartLabels.periodComparison'), - anomalyDetection: t('helpPanel.chartLabels.anomalyDetection'), - }), [t]) - const sectionLabels = useMemo>(() => ({ - insights: t('helpPanel.sectionLabels.insights'), - metrics: t('helpPanel.sectionLabels.metrics'), - today: t('helpPanel.sectionLabels.today'), - currentMonth: t('helpPanel.sectionLabels.currentMonth'), - activity: t('helpPanel.sectionLabels.activity'), - forecastCache: t('helpPanel.sectionLabels.forecastCache'), - costAnalysis: t('helpPanel.sectionLabels.costAnalysis'), - tokenAnalysis: t('helpPanel.sectionLabels.tokenAnalysis'), - requestAnalysis: t('helpPanel.sectionLabels.requestAnalysis'), - advancedAnalysis: t('helpPanel.sectionLabels.advancedAnalysis'), - comparisons: t('helpPanel.sectionLabels.comparisons'), - tables: t('helpPanel.sectionLabels.tables'), - limits: t('helpPanel.sectionLabels.limits'), - }), [t]) - const featureLabels = useMemo>(() => ({ - requestQuality: t('helpPanel.featureLabels.requestQuality'), - providerLimits: t('helpPanel.featureLabels.providerLimits'), - concentrationRisk: t('helpPanel.featureLabels.concentrationRisk'), - providerEfficiency: t('helpPanel.featureLabels.providerEfficiency'), - modelEfficiency: t('helpPanel.featureLabels.modelEfficiency'), - recentDays: t('helpPanel.featureLabels.recentDays'), - }), [t]) + const metricLabels = useMemo>( + () => ({ + totalCost: t('helpPanel.metricLabels.totalCost'), + totalTokens: t('helpPanel.metricLabels.totalTokens'), + activeDays: t('helpPanel.metricLabels.activeDays'), + topModel: t('helpPanel.metricLabels.topModel'), + cacheHitRate: t('helpPanel.metricLabels.cacheHitRate'), + costPerMillion: t('helpPanel.metricLabels.costPerMillion'), + mostExpensiveDay: t('helpPanel.metricLabels.mostExpensiveDay'), + cheapestDay: t('helpPanel.metricLabels.cheapestDay'), + avgCostPerDay: t('helpPanel.metricLabels.avgCostPerDay'), + outputTokens: t('helpPanel.metricLabels.outputTokens'), + }), + [t], + ) + const chartLabels = useMemo>( + () => ({ + costOverTime: t('helpPanel.chartLabels.costOverTime'), + costByModel: t('helpPanel.chartLabels.costByModel'), + costByModelOverTime: t('helpPanel.chartLabels.costByModelOverTime'), + cumulativeCost: t('helpPanel.chartLabels.cumulativeCost'), + costByWeekday: t('helpPanel.chartLabels.costByWeekday'), + tokensOverTime: t('helpPanel.chartLabels.tokensOverTime'), + requestsOverTime: t('helpPanel.chartLabels.requestsOverTime'), + requestCacheHitRate: t('helpPanel.chartLabels.requestCacheHitRate'), + tokenTypes: t('helpPanel.chartLabels.tokenTypes'), + tokenEfficiency: t('helpPanel.chartLabels.tokenEfficiency'), + modelMix: t('helpPanel.chartLabels.modelMix'), + distributionAnalysis: t('helpPanel.chartLabels.distributionAnalysis'), + correlationAnalysis: t('helpPanel.chartLabels.correlationAnalysis'), + heatmap: t('helpPanel.chartLabels.heatmap'), + requestHeatmap: t('helpPanel.chartLabels.requestHeatmap'), + tokenHeatmap: t('helpPanel.chartLabels.tokenHeatmap'), + forecast: t('helpPanel.chartLabels.forecast'), + cacheROI: t('helpPanel.chartLabels.cacheROI'), + periodComparison: t('helpPanel.chartLabels.periodComparison'), + anomalyDetection: t('helpPanel.chartLabels.anomalyDetection'), + }), + [t], + ) + const sectionLabels = useMemo>( + () => ({ + insights: t('helpPanel.sectionLabels.insights'), + metrics: t('helpPanel.sectionLabels.metrics'), + today: t('helpPanel.sectionLabels.today'), + currentMonth: t('helpPanel.sectionLabels.currentMonth'), + activity: t('helpPanel.sectionLabels.activity'), + forecastCache: t('helpPanel.sectionLabels.forecastCache'), + costAnalysis: t('helpPanel.sectionLabels.costAnalysis'), + tokenAnalysis: t('helpPanel.sectionLabels.tokenAnalysis'), + requestAnalysis: t('helpPanel.sectionLabels.requestAnalysis'), + advancedAnalysis: t('helpPanel.sectionLabels.advancedAnalysis'), + comparisons: t('helpPanel.sectionLabels.comparisons'), + tables: t('helpPanel.sectionLabels.tables'), + limits: t('helpPanel.sectionLabels.limits'), + }), + [t], + ) + const featureLabels = useMemo>( + () => ({ + requestQuality: t('helpPanel.featureLabels.requestQuality'), + providerLimits: t('helpPanel.featureLabels.providerLimits'), + concentrationRisk: t('helpPanel.featureLabels.concentrationRisk'), + providerEfficiency: t('helpPanel.featureLabels.providerEfficiency'), + modelEfficiency: t('helpPanel.featureLabels.modelEfficiency'), + recentDays: t('helpPanel.featureLabels.recentDays'), + }), + [t], + ) return ( {t('header.help')} - - {t('commandPalette.description')} - + {t('commandPalette.description')} {/* Keyboard shortcuts */} diff --git a/src/components/features/help/InfoButton.tsx b/src/components/features/help/InfoButton.tsx index 5208bfc..1c4b968 100644 --- a/src/components/features/help/InfoButton.tsx +++ b/src/components/features/help/InfoButton.tsx @@ -17,7 +17,10 @@ export function InfoButton({ text, className }: InfoButtonProps) { type="button" aria-label={t('common.showInfo')} data-info-button="true" - className={cn('inline-flex items-center justify-center text-muted-foreground/50 hover:text-muted-foreground transition-colors', className)} + className={cn( + 'inline-flex items-center justify-center text-muted-foreground/50 hover:text-muted-foreground transition-colors', + className, + )} > diff --git a/src/components/features/insights/UsageInsights.tsx b/src/components/features/insights/UsageInsights.tsx index 2a4ebc2..70e84d4 100644 --- a/src/components/features/insights/UsageInsights.tsx +++ b/src/components/features/insights/UsageInsights.tsx @@ -6,7 +6,14 @@ import { SectionHeader } from '@/components/ui/section-header' import { FadeIn } from '@/components/features/animations/FadeIn' import { FormattedValue } from '@/components/ui/formatted-value' import { SECTION_HELP } from '@/lib/help-content' -import { formatCurrency, formatDate, formatNumber, formatPercent, formatTokens, periodUnit } from '@/lib/formatters' +import { + formatCurrency, + formatDate, + formatNumber, + formatPercent, + formatTokens, + periodUnit, +} from '@/lib/formatters' import type { DashboardMetrics, ViewMode } from '@/types' interface UsageInsightsProps { @@ -29,7 +36,9 @@ function InsightCard({ title, icon, value, summary, details }: InsightCardProps)
-
{title}
+
+ {title} +
{value}
@@ -39,8 +48,13 @@ function InsightCard({ title, icon, value, summary, details }: InsightCardProps)

{summary}

{details.map((detail) => ( -
-
{detail.label}
+
+
+ {detail.label} +
{detail.value}
))} @@ -51,16 +65,23 @@ function InsightCard({ title, icon, value, summary, details }: InsightCardProps) export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageInsightsProps) { const { t } = useTranslation() - const coverageRate = totalCalendarDays && viewMode === 'daily' - ? (metrics.activeDays / totalCalendarDays) * 100 - : null + const coverageRate = + totalCalendarDays && viewMode === 'daily' + ? (metrics.activeDays / totalCalendarDays) * 100 + : null - const usageUnit = viewMode === 'yearly' ? t('periods.years') : viewMode === 'monthly' ? t('periods.months') : t('periods.days') - const peakSignal = metrics.topThreeModelsShare >= 80 - ? t('insights.peakWindow.signalStrong') - : metrics.topThreeModelsShare >= 55 - ? t('insights.peakWindow.signalModerate') - : t('insights.peakWindow.signalWide') + const usageUnit = + viewMode === 'yearly' + ? t('periods.years') + : viewMode === 'monthly' + ? t('periods.months') + : t('periods.days') + const peakSignal = + metrics.topThreeModelsShare >= 80 + ? t('insights.peakWindow.signalStrong') + : metrics.topThreeModelsShare >= 55 + ? t('insights.peakWindow.signalModerate') + : t('insights.peakWindow.signalWide') return (
@@ -76,14 +97,28 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns title={t('insights.concentration.title')} icon={} value={metrics.topProvider ? formatPercent(metrics.topProvider.share, 0) : '–'} - summary={metrics.topProvider - ? t('insights.concentration.summary', { provider: metrics.topProvider.name, model: metrics.topModel?.name ?? t('metricCards.primary.topModel') }) - : t('insights.concentration.fallback')} + summary={ + metrics.topProvider + ? t('insights.concentration.summary', { + provider: metrics.topProvider.name, + model: metrics.topModel?.name ?? t('metricCards.primary.topModel'), + }) + : t('insights.concentration.fallback') + } details={[ - { label: t('insights.concentration.topProvider'), value: metrics.topProvider?.name ?? '–' }, + { + label: t('insights.concentration.topProvider'), + value: metrics.topProvider?.name ?? '–', + }, { label: t('insights.concentration.topModel'), value: metrics.topModel?.name ?? '–' }, - { label: t('insights.concentration.topModelShare'), value: formatPercent(metrics.topModelShare, 0) }, - { label: t('insights.concentration.topThreeModels'), value: formatPercent(metrics.topThreeModelsShare, 0) }, + { + label: t('insights.concentration.topModelShare'), + value: formatPercent(metrics.topModelShare, 0), + }, + { + label: t('insights.concentration.topThreeModels'), + value: formatPercent(metrics.topThreeModelsShare, 0), + }, ]} /> @@ -92,19 +127,54 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns } - value={metrics.hasRequestData ? : t('common.notAvailable')} - summary={metrics.hasRequestData - ? t('insights.requestEconomy.summary', { - cost: formatCurrency(metrics.avgCostPerRequest), - tokens: formatTokens(metrics.avgTokensPerRequest), - leader: metrics.topRequestModel ? t('insights.requestEconomy.leader', { model: metrics.topRequestModel.name }) : '', - }).trim() - : t('insights.requestEconomy.fallback')} + value={ + metrics.hasRequestData ? ( + + ) : ( + t('common.notAvailable') + ) + } + summary={ + metrics.hasRequestData + ? t('insights.requestEconomy.summary', { + cost: formatCurrency(metrics.avgCostPerRequest), + tokens: formatTokens(metrics.avgTokensPerRequest), + leader: metrics.topRequestModel + ? t('insights.requestEconomy.leader', { model: metrics.topRequestModel.name }) + : '', + }).trim() + : t('insights.requestEconomy.fallback') + } details={[ - { label: t('insights.requestEconomy.avgRequests', { unit: periodUnit(viewMode) }), value: metrics.hasRequestData ? metrics.avgRequestsPerDay.toFixed(1) : t('common.notAvailable') }, - { label: t('insights.requestEconomy.avgTokensPerRequest'), value: metrics.hasRequestData ? formatTokens(metrics.avgTokensPerRequest) : t('common.notAvailable') }, - { label: t('insights.requestEconomy.costPerMillion'), value: formatCurrency(metrics.costPerMillion) }, - { label: t('insights.requestEconomy.totalRequests'), value: metrics.hasRequestData ? formatNumber(metrics.totalRequests) : t('common.notAvailable') }, + { + label: t('insights.requestEconomy.avgRequests', { unit: periodUnit(viewMode) }), + value: metrics.hasRequestData + ? metrics.avgRequestsPerDay.toFixed(1) + : t('common.notAvailable'), + }, + { + label: t('insights.requestEconomy.avgTokensPerRequest'), + value: metrics.hasRequestData + ? formatTokens(metrics.avgTokensPerRequest) + : t('common.notAvailable'), + }, + { + label: t('insights.requestEconomy.costPerMillion'), + value: formatCurrency(metrics.costPerMillion), + }, + { + label: t('insights.requestEconomy.totalRequests'), + value: metrics.hasRequestData + ? formatNumber(metrics.totalRequests) + : t('common.notAvailable'), + }, ]} /> @@ -113,15 +183,46 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns } - value={coverageRate !== null ? formatPercent(coverageRate, 0) : formatNumber(metrics.activeDays)} - summary={coverageRate !== null - ? t('insights.usagePatterns.summaryWithCoverage', { activeDays: metrics.activeDays, totalDays: totalCalendarDays, volatility: formatNumber(Math.round(metrics.requestVolatility)) }) - : t('insights.usagePatterns.summaryWithoutCoverage', { activeDays: metrics.activeDays, unit: usageUnit })} + value={ + coverageRate !== null + ? formatPercent(coverageRate, 0) + : formatNumber(metrics.activeDays) + } + summary={ + coverageRate !== null + ? 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.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) : '–' }, + { + 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) + : '–', + }, ]} /> @@ -130,14 +231,34 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns } - value={metrics.busiestWeek ? formatCurrency(metrics.busiestWeek.cost) : formatCurrency(metrics.topDay?.cost ?? 0)} - summary={metrics.busiestWeek - ? t('insights.peakWindow.summary', { start: formatDate(metrics.busiestWeek.start), end: formatDate(metrics.busiestWeek.end) }) - : t('insights.peakWindow.fallback')} + value={ + metrics.busiestWeek + ? formatCurrency(metrics.busiestWeek.cost) + : formatCurrency(metrics.topDay?.cost ?? 0) + } + summary={ + metrics.busiestWeek + ? t('insights.peakWindow.summary', { + start: formatDate(metrics.busiestWeek.start), + end: formatDate(metrics.busiestWeek.end), + }) + : t('insights.peakWindow.fallback') + } details={[ - { label: t('insights.peakWindow.peakDay'), value: metrics.topDay ? `${formatDate(metrics.topDay.date)} · ${formatCurrency(metrics.topDay.cost)}` : '–' }, - { label: t('insights.peakWindow.avgPerUnit', { unit: periodUnit(viewMode) }), value: formatCurrency(metrics.avgDailyCost) }, - { label: t('insights.peakWindow.peak7DayAverage'), value: metrics.busiestWeek ? formatCurrency(metrics.busiestWeek.cost / 7) : '–' }, + { + label: t('insights.peakWindow.peakDay'), + value: metrics.topDay + ? `${formatDate(metrics.topDay.date)} · ${formatCurrency(metrics.topDay.cost)}` + : '–', + }, + { + label: t('insights.peakWindow.avgPerUnit', { unit: periodUnit(viewMode) }), + value: formatCurrency(metrics.avgDailyCost), + }, + { + label: t('insights.peakWindow.peak7DayAverage'), + value: metrics.busiestWeek ? formatCurrency(metrics.busiestWeek.cost / 7) : '–', + }, { label: t('insights.peakWindow.signal'), value: peakSignal }, ]} /> @@ -153,13 +274,17 @@ export function UsageInsights({ metrics, viewMode, totalCalendarDays }: UsageIns {metrics.topProvider ? t('insights.quickRead.summary', { - provider: metrics.topProvider.name, - providerShare: formatPercent(metrics.topProvider.share, 0), - topThreeShare: formatPercent(metrics.topThreeModelsShare, 0), - requestLeader: metrics.topRequestModel - ? t('insights.quickRead.requestLeader', { requestModel: metrics.topRequestModel.name, tokenModel: metrics.topTokenModel?.name ?? t('metricCards.primary.topModel') }) - : '', - }).trim() + provider: metrics.topProvider.name, + providerShare: formatPercent(metrics.topProvider.share, 0), + topThreeShare: formatPercent(metrics.topThreeModelsShare, 0), + requestLeader: metrics.topRequestModel + ? t('insights.quickRead.requestLeader', { + requestModel: metrics.topRequestModel.name, + tokenModel: + metrics.topTokenModel?.name ?? t('metricCards.primary.topModel'), + }) + : '', + }).trim() : t('insights.quickRead.fallback')}
diff --git a/src/components/features/limits/ProviderLimitsSection.tsx b/src/components/features/limits/ProviderLimitsSection.tsx index 6217dc9..16e4197 100644 --- a/src/components/features/limits/ProviderLimitsSection.tsx +++ b/src/components/features/limits/ProviderLimitsSection.tsx @@ -10,6 +10,7 @@ import { ReferenceLine, ResponsiveContainer, Tooltip, + type TooltipValueType, XAxis, YAxis, } from 'recharts' @@ -21,7 +22,12 @@ import { CHART_ANIMATION, CHART_COLORS, CHART_MARGIN } from '@/components/charts import { buildProviderMonthlyCosts, getLatestMonth } from '@/lib/provider-limits' import i18n from '@/lib/i18n' import { CHART_HELP, SECTION_HELP } from '@/lib/help-content' -import { formatCurrency, formatCurrencyExact, formatMonthYear } from '@/lib/formatters' +import { + coerceNumber, + formatCurrency, + formatCurrencyExact, + formatMonthYear, +} from '@/lib/formatters' import { getProviderBadgeStyle } from '@/lib/model-utils' import type { DailyUsage, ProviderLimits } from '@/types' @@ -62,7 +68,31 @@ function subscriptionLabel(row: ProviderLimitRow) { return i18n.t('limits.statuses.belowSubscription') } -export function ProviderLimitsSection({ data, providers, limits, selectedMonth }: ProviderLimitsSectionProps) { +function formatLimitBadge(row: ProviderLimitRow, subscriptionProgress: number) { + if (row.monthlyLimit > 0) { + return i18n.t('limits.badge.limit', { value: row.utilization?.toFixed(0) ?? '0' }) + } + + if (row.hasSubscription) { + return i18n.t('limits.badge.subscription', { + value: Math.min(subscriptionProgress, 999).toFixed(0), + }) + } + + return i18n.t('limits.badge.open') +} + +function toTooltipNumber(value: TooltipValueType | undefined) { + const numericValue = Array.isArray(value) ? Number(value[0] ?? 0) : Number(value ?? 0) + return Number.isFinite(numericValue) ? numericValue : 0 +} + +export function ProviderLimitsSection({ + data, + providers, + limits, + selectedMonth, +}: ProviderLimitsSectionProps) { const { t } = useTranslation() const sectionRef = useRef(null) const inView = useInView(sectionRef, { once: true, amount: 0.2 }) @@ -80,58 +110,63 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } const latestMonth = getLatestMonth(data) const resolvedFocusMonth = selectedMonth ?? latestMonth - const nextRows: ProviderLimitRow[] = providers.map((provider) => { - const config = limits[provider] - const cost = resolvedFocusMonth ? (monthMap.get(resolvedFocusMonth)?.get(provider) ?? 0) : 0 - const totalCost = providerTotals.get(provider) ?? 0 - const monthlyLimit = config?.monthlyLimit ?? 0 - const hasSubscription = Boolean(config?.hasSubscription) - const subscriptionPrice = hasSubscription ? config.subscriptionPrice : 0 - const overrun = monthlyLimit > 0 ? Math.max(cost - monthlyLimit, 0) : 0 - const remaining = monthlyLimit > 0 ? Math.max(monthlyLimit - cost, 0) : null - const utilization = monthlyLimit > 0 ? (cost / monthlyLimit) * 100 : null - const subscriptionDelta = hasSubscription ? cost - subscriptionPrice : null - const subscriptionGain = subscriptionDelta !== null ? Math.max(subscriptionDelta, 0) : 0 - const subscriptionGap = subscriptionDelta !== null ? Math.max(-subscriptionDelta, 0) : 0 - - let riskStatus: ProviderLimitRow['riskStatus'] = 'none' - if (monthlyLimit > 0 && cost >= monthlyLimit) riskStatus = 'limit' - else if (monthlyLimit > 0 && cost >= monthlyLimit * 0.8) riskStatus = 'warning' - else if (monthlyLimit > 0) riskStatus = 'ok' - - const subscriptionStatus: ProviderLimitRow['subscriptionStatus'] = !hasSubscription - ? 'none' - : subscriptionGain > 0 - ? 'gain' - : 'gap' - - return { - provider, - cost, - totalCost, - monthlyLimit, - subscriptionPrice, - hasSubscription, - remaining, - overrun, - utilization, - subscriptionDelta, - subscriptionGain, - subscriptionGap, - riskStatus, - subscriptionStatus, - } - }).sort((a, b) => { - if (a.riskStatus === 'limit' && b.riskStatus !== 'limit') return -1 - if (a.riskStatus !== 'limit' && b.riskStatus === 'limit') return 1 - if (a.subscriptionStatus === 'gain' && b.subscriptionStatus !== 'gain') return -1 - if (a.subscriptionStatus !== 'gain' && b.subscriptionStatus === 'gain') return 1 - return b.cost - a.cost - }) + const nextRows: ProviderLimitRow[] = providers + .map((provider) => { + const config = limits[provider] + const cost = resolvedFocusMonth ? (monthMap.get(resolvedFocusMonth)?.get(provider) ?? 0) : 0 + const totalCost = providerTotals.get(provider) ?? 0 + const monthlyLimit = config?.monthlyLimit ?? 0 + const hasSubscription = Boolean(config?.hasSubscription) + const subscriptionPrice = hasSubscription ? (config?.subscriptionPrice ?? 0) : 0 + const overrun = monthlyLimit > 0 ? Math.max(cost - monthlyLimit, 0) : 0 + const remaining = monthlyLimit > 0 ? Math.max(monthlyLimit - cost, 0) : null + const utilization = monthlyLimit > 0 ? (cost / monthlyLimit) * 100 : null + const subscriptionDelta = hasSubscription ? cost - subscriptionPrice : null + const subscriptionGain = subscriptionDelta !== null ? Math.max(subscriptionDelta, 0) : 0 + const subscriptionGap = subscriptionDelta !== null ? Math.max(-subscriptionDelta, 0) : 0 + + let riskStatus: ProviderLimitRow['riskStatus'] = 'none' + if (monthlyLimit > 0 && cost >= monthlyLimit) riskStatus = 'limit' + else if (monthlyLimit > 0 && cost >= monthlyLimit * 0.8) riskStatus = 'warning' + else if (monthlyLimit > 0) riskStatus = 'ok' + + const subscriptionStatus: ProviderLimitRow['subscriptionStatus'] = !hasSubscription + ? 'none' + : subscriptionGain > 0 + ? 'gain' + : 'gap' + + return { + provider, + cost, + totalCost, + monthlyLimit, + subscriptionPrice, + hasSubscription, + remaining, + overrun, + utilization, + subscriptionDelta, + subscriptionGain, + subscriptionGap, + riskStatus, + subscriptionStatus, + } + }) + .sort((a, b) => { + if (a.riskStatus === 'limit' && b.riskStatus !== 'limit') return -1 + if (a.riskStatus !== 'limit' && b.riskStatus === 'limit') return 1 + if (a.subscriptionStatus === 'gain' && b.subscriptionStatus !== 'gain') return -1 + if (a.subscriptionStatus !== 'gain' && b.subscriptionStatus === 'gain') return 1 + return b.cost - a.cost + }) const nextTimeline = months.map((month) => { const monthCosts = monthMap.get(month) ?? new Map() - const totalCost = providers.reduce((sum, provider) => sum + (monthCosts.get(provider) ?? 0), 0) + const totalCost = providers.reduce( + (sum, provider) => sum + (monthCosts.get(provider) ?? 0), + 0, + ) const totalLimit = providers.reduce((sum, provider) => { const limit = limits[provider]?.monthlyLimit ?? 0 return sum + (limit > 0 ? limit : 0) @@ -173,8 +208,8 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } rows: nextRows, focusMonth: resolvedFocusMonth, timelineData: nextTimeline, - atLimitCount: nextRows.filter(row => row.riskStatus === 'limit').length, - nearLimitCount: nextRows.filter(row => row.riskStatus === 'warning').length, + atLimitCount: nextRows.filter((row) => row.riskStatus === 'limit').length, + nearLimitCount: nextRows.filter((row) => row.riskStatus === 'warning').length, subscriptionTotal: nextRows.reduce((sum, row) => sum + row.subscriptionPrice, 0), subscriptionGainTotal: nextRows.reduce((sum, row) => sum + row.subscriptionGain, 0), } @@ -207,10 +242,30 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{[ - { label: t('limits.cards.atLimit'), value: String(atLimitCount), hint: focusMonth ? formatMonthYear(focusMonth) : t('limits.cards.noMonth'), icon: }, - { label: t('limits.cards.nearLimit'), value: String(nearLimitCount), hint: t('limits.cards.nearLimitHint'), icon: }, - { label: t('limits.cards.subscriptionVolume'), value: formatCurrency(subscriptionTotal), hint: t('limits.cards.subscriptionVolumeHint'), icon: }, - { label: t('limits.cards.subscriptionValue'), value: formatCurrency(subscriptionGainTotal), hint: t('limits.cards.subscriptionValueHint'), icon: }, + { + label: t('limits.cards.atLimit'), + value: String(atLimitCount), + hint: focusMonth ? formatMonthYear(focusMonth) : t('limits.cards.noMonth'), + icon: , + }, + { + label: t('limits.cards.nearLimit'), + value: String(nearLimitCount), + hint: t('limits.cards.nearLimitHint'), + icon: , + }, + { + label: t('limits.cards.subscriptionVolume'), + value: formatCurrency(subscriptionTotal), + hint: t('limits.cards.subscriptionVolumeHint'), + icon: , + }, + { + label: t('limits.cards.subscriptionValue'), + value: formatCurrency(subscriptionGainTotal), + hint: t('limits.cards.subscriptionValueHint'), + icon: , + }, ].map((item, index) => (
-
{item.label}
+
+ {item.label} +
{item.value}
{item.hint}
@@ -239,10 +296,13 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{rows.map((row, index) => { const providerStyle = getProviderBadgeStyle(row.provider) - const riskProgress = row.monthlyLimit > 0 ? Math.min((row.cost / row.monthlyLimit) * 100, 100) : 0 - const subscriptionProgress = row.hasSubscription && row.subscriptionPrice > 0 - ? Math.min((row.cost / row.subscriptionPrice) * 100, 100) - : 0 + const riskProgress = + row.monthlyLimit > 0 ? Math.min((row.cost / row.monthlyLimit) * 100, 100) : 0 + const subscriptionProgress = + row.hasSubscription && row.subscriptionPrice > 0 + ? (row.cost / row.subscriptionPrice) * 100 + : 0 + const subscriptionProgressWidth = Math.min(subscriptionProgress, 100) return ( - +
{row.provider}
{riskLabel(row)}
-
{subscriptionLabel(row)}
+
+ {subscriptionLabel(row)} +
- {row.monthlyLimit > 0 ? `${row.utilization?.toFixed(0)}% Limit` : row.hasSubscription ? `${Math.min(subscriptionProgress, 999).toFixed(0)}% Sub` : 'Offen'} + {formatLimitBadge(row, subscriptionProgress)}
-
{t('limits.tracks.usageFocusMonth')}
-
{formatCurrency(row.cost)}
+
+ {t('limits.tracks.usageFocusMonth')} +
+
+ {formatCurrency(row.cost)} +
-
{t('limits.tracks.limitSubscription')}
+
+ {t('limits.tracks.limitSubscription')} +
- {row.monthlyLimit > 0 ? formatCurrency(row.monthlyLimit) : t('limits.statuses.noLimit')} / {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'} + {row.monthlyLimit > 0 + ? formatCurrency(row.monthlyLimit) + : t('limits.statuses.noLimit')}{' '} + / {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'}
@@ -284,16 +363,34 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{t('limits.tracks.budgetRisk')} - {row.monthlyLimit > 0 ? (row.overrun > 0 ? `+${formatCurrency(row.overrun)}` : formatCurrency(row.remaining ?? 0)) : t('limits.statuses.noLimit')} + + {row.monthlyLimit > 0 + ? row.overrun > 0 + ? `+${formatCurrency(row.overrun)}` + : formatCurrency(row.remaining ?? 0) + : t('limits.statuses.noLimit')} +
{row.monthlyLimit > 0 ? ( ) : (
@@ -304,17 +401,41 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
{t('limits.tracks.subscriptionEffect')} - - {!row.hasSubscription ? t('limits.statuses.noSubscription') : row.subscriptionStatus === 'gain' ? `+${formatCurrency(row.subscriptionGain)}` : formatCurrency(row.subscriptionGap)} + + {!row.hasSubscription + ? t('limits.statuses.noSubscription') + : row.subscriptionStatus === 'gain' + ? `+${formatCurrency(row.subscriptionGain)}` + : formatCurrency(row.subscriptionGap)}
{row.hasSubscription ? ( ) : (
@@ -332,7 +453,9 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth }
[]} valueKey="cost" @@ -340,16 +463,22 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } >
{rows.map((row, index) => { - const maxValue = row.monthlyLimit > 0 - ? Math.max(row.monthlyLimit, row.cost, 1) - : Math.max(row.cost, 1) + const maxValue = + row.monthlyLimit > 0 + ? Math.max(row.monthlyLimit, row.cost, 1) + : Math.max(row.cost, 1) const scaleMax = maxValue * 1.15 const costWidth = `${(row.cost / scaleMax) * 100}%` - const limitPosition = row.monthlyLimit > 0 ? `${(row.monthlyLimit / scaleMax) * 100}%` : '0%' - const withinLimitWidth = row.monthlyLimit > 0 ? `${(Math.min(row.cost, row.monthlyLimit) / scaleMax) * 100}%` : costWidth - const overLimitWidth = row.monthlyLimit > 0 && row.overrun > 0 - ? `${(row.overrun / scaleMax) * 100}%` - : '0%' + const limitPosition = + row.monthlyLimit > 0 ? `${(row.monthlyLimit / scaleMax) * 100}%` : '0%' + const withinLimitWidth = + row.monthlyLimit > 0 + ? `${(Math.min(row.cost, row.monthlyLimit) / scaleMax) * 100}%` + : costWidth + const overLimitWidth = + row.monthlyLimit > 0 && row.overrun > 0 + ? `${(row.overrun / scaleMax) * 100}%` + : '0%' return ( 0 - ? t('limits.tracks.alreadyAboveLimit', { value: formatCurrency(row.overrun) }) - : t('limits.tracks.stillToLimit', { value: formatCurrency(row.remaining ?? 0) })} + ? t('limits.tracks.alreadyAboveLimit', { + value: formatCurrency(row.overrun), + }) + : t('limits.tracks.stillToLimit', { + value: formatCurrency(row.remaining ?? 0), + })}
-
{t('limits.tracks.usage')} {formatCurrency(row.cost)}
-
{t('limits.tracks.limit')} {row.monthlyLimit > 0 ? formatCurrency(row.monthlyLimit) : '–'}
+
+ {t('limits.tracks.usage')} {formatCurrency(row.cost)} +
+
+ {t('limits.tracks.limit')}{' '} + {row.monthlyLimit > 0 ? formatCurrency(row.monthlyLimit) : '–'} +
@@ -382,14 +520,28 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } {row.monthlyLimit > 0 ? ( <> -
-
+
+
{row.overrun > 0 && ( @@ -398,12 +550,22 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } style={{ left: limitPosition }} initial={{ width: 0 }} animate={inView ? { width: overLimitWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.14 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.14 + index * 0.04, + ease: 'easeOut', + }} /> )} -
-
+
+
{t('limits.tracks.limit')}
@@ -412,30 +574,66 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } className="absolute left-0 top-5 h-4 rounded-full bg-muted-foreground/40" initial={{ width: 0 }} animate={inView ? { width: costWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.08 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.08 + index * 0.04, + ease: 'easeOut', + }} /> )} -
$0
-
{formatCurrency(scaleMax)}
+
+ $0 +
+
+ {formatCurrency(scaleMax)} +
-
{t('limits.tracks.currentlyUsed')}
-
{formatCurrency(row.cost)}
+
+ {t('limits.tracks.currentlyUsed')} +
+
+ {formatCurrency(row.cost)} +
-
{t('limits.tracks.remainingToLimit')}
-
0 && row.overrun === 0 ? 'mt-1 font-medium text-sky-300' : 'mt-1 font-medium text-muted-foreground'}> - {row.monthlyLimit > 0 ? (row.overrun === 0 ? formatCurrency(row.remaining ?? 0) : '$0.00') : '–'} +
+ {t('limits.tracks.remainingToLimit')} +
+
0 && row.overrun === 0 + ? 'mt-1 font-medium text-sky-300' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.monthlyLimit > 0 + ? row.overrun === 0 + ? formatCurrency(row.remaining ?? 0) + : '$0.00' + : '–'}
-
{t('limits.tracks.alreadyOverLimit')}
-
0 ? 'mt-1 font-medium text-red-300' : 'mt-1 font-medium text-muted-foreground'}> - {row.monthlyLimit > 0 ? (row.overrun > 0 ? formatCurrency(row.overrun) : '$0.00') : '–'} +
+ {t('limits.tracks.alreadyOverLimit')} +
+
0 + ? 'mt-1 font-medium text-red-300' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.monthlyLimit > 0 + ? row.overrun > 0 + ? formatCurrency(row.overrun) + : '$0.00' + : '–'}
@@ -447,7 +645,11 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } []} valueKey="cost" @@ -460,11 +662,16 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } : Math.max(row.cost, 1) const scaleMax = maxValue * 1.15 const costWidth = `${(row.cost / scaleMax) * 100}%` - const subPosition = row.hasSubscription ? `${(row.subscriptionPrice / scaleMax) * 100}%` : '0%' - const withinSubscriptionWidth = row.hasSubscription ? `${(Math.min(row.cost, row.subscriptionPrice) / scaleMax) * 100}%` : costWidth - const overSubscriptionWidth = row.hasSubscription && row.subscriptionGain > 0 - ? `${(row.subscriptionGain / scaleMax) * 100}%` + const subPosition = row.hasSubscription + ? `${(row.subscriptionPrice / scaleMax) * 100}%` : '0%' + const withinSubscriptionWidth = row.hasSubscription + ? `${(Math.min(row.cost, row.subscriptionPrice) / scaleMax) * 100}%` + : costWidth + const overSubscriptionWidth = + row.hasSubscription && row.subscriptionGain > 0 + ? `${(row.subscriptionGain / scaleMax) * 100}%` + : '0%' return (
-
{t('limits.tracks.usage')} {formatCurrency(row.cost)}
-
{t('limits.tracks.subscription')} {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'}
+
+ {t('limits.tracks.usage')} {formatCurrency(row.cost)} +
+
+ {t('limits.tracks.subscription')}{' '} + {row.hasSubscription ? formatCurrency(row.subscriptionPrice) : '–'} +
@@ -497,14 +713,24 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } {row.hasSubscription ? ( <> -
-
+
+
{row.subscriptionGain > 0 && ( @@ -513,12 +739,22 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } style={{ left: subPosition }} initial={{ width: 0 }} animate={inView ? { width: overSubscriptionWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.14 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.14 + index * 0.04, + ease: 'easeOut', + }} /> )} -
-
+
+
{t('limits.tracks.breakEven')}
@@ -527,30 +763,66 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } className="absolute left-0 top-5 h-4 rounded-full bg-muted-foreground/40" initial={{ width: 0 }} animate={inView ? { width: costWidth } : { width: 0 }} - transition={{ duration: 0.75, delay: 0.08 + index * 0.04, ease: 'easeOut' }} + transition={{ + duration: 0.75, + delay: 0.08 + index * 0.04, + ease: 'easeOut', + }} /> )} -
$0
-
{formatCurrency(scaleMax)}
+
+ $0 +
+
+ {formatCurrency(scaleMax)} +
-
{t('limits.tracks.currentlyUsed')}
-
{formatCurrency(row.cost)}
+
+ {t('limits.tracks.currentlyUsed')} +
+
+ {formatCurrency(row.cost)} +
-
{t('limits.tracks.remainingToBreakEven')}
-
0 ? 'mt-1 font-medium text-amber-200' : 'mt-1 font-medium text-muted-foreground'}> - {row.hasSubscription ? (row.subscriptionGap > 0 ? formatCurrency(row.subscriptionGap) : '$0.00') : '–'} +
+ {t('limits.tracks.remainingToBreakEven')} +
+
0 + ? 'mt-1 font-medium text-amber-200' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.hasSubscription + ? row.subscriptionGap > 0 + ? formatCurrency(row.subscriptionGap) + : '$0.00' + : '–'}
-
{t('limits.tracks.alreadyAboveBreakEven')}
-
0 ? 'mt-1 font-medium text-emerald-300' : 'mt-1 font-medium text-muted-foreground'}> - {row.hasSubscription ? (row.subscriptionGain > 0 ? formatCurrency(row.subscriptionGain) : '$0.00') : '–'} +
+ {t('limits.tracks.alreadyAboveBreakEven')} +
+
0 + ? 'mt-1 font-medium text-emerald-300' + : 'mt-1 font-medium text-muted-foreground' + } + > + {row.hasSubscription + ? row.subscriptionGain > 0 + ? formatCurrency(row.subscriptionGain) + : '$0.00' + : '–'}
@@ -585,21 +857,101 @@ export function ProviderLimitsSection({ data, providers, limits, selectedMonth } - - - formatCurrency(Math.abs(value))} stroke={CHART_COLORS.axis} fontSize={11} tickLine={false} axisLine={false} /> + + + { + const numericValue = coerceNumber(value) + return numericValue === null ? '' : formatCurrency(Math.abs(numericValue)) + }} + stroke={CHART_COLORS.axis} + fontSize={11} + tickLine={false} + axisLine={false} + /> [formatCurrencyExact(Math.abs(value)), name]} + formatter={( + value: TooltipValueType | undefined, + name: string | number | undefined, + ) => [formatCurrencyExact(Math.abs(toTooltipNumber(value))), name ?? '']} labelFormatter={(label) => formatMonthYear(String(label))} - contentStyle={{ borderRadius: 12, borderColor: 'hsl(var(--border))', background: 'color-mix(in srgb, hsl(var(--popover)) 90%, transparent)' }} + contentStyle={{ + borderRadius: 12, + borderColor: 'hsl(var(--border))', + background: 'color-mix(in srgb, hsl(var(--popover)) 90%, transparent)', + }} /> - - - - - + + + + + diff --git a/src/components/features/pdf-report/PDFReport.tsx b/src/components/features/pdf-report/PDFReport.tsx index 491c63f..3e36512 100644 --- a/src/components/features/pdf-report/PDFReport.tsx +++ b/src/components/features/pdf-report/PDFReport.tsx @@ -19,7 +19,11 @@ export function PDFReportButton({ generating, onGenerate }: PDFReportProps) { title={t('commandPalette.commands.generateReport.label')} 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" > - {generating ? : } + {generating ? ( + + ) : ( + + )} {t('header.report')} ) diff --git a/src/components/features/request-quality/RequestQuality.tsx b/src/components/features/request-quality/RequestQuality.tsx index 2ab7509..46805bf 100644 --- a/src/components/features/request-quality/RequestQuality.tsx +++ b/src/components/features/request-quality/RequestQuality.tsx @@ -16,22 +16,28 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { const { t } = useTranslation() const sectionRef = useRef(null) const inView = useInView(sectionRef, { once: true, amount: 0.25 }) - const cachePerRequest = metrics.totalRequests > 0 ? metrics.totalCacheRead / metrics.totalRequests : 0 - const thinkingPerRequest = metrics.totalRequests > 0 ? metrics.totalThinking / metrics.totalRequests : 0 + const cachePerRequest = + metrics.totalRequests > 0 ? metrics.totalCacheRead / metrics.totalRequests : 0 + const thinkingPerRequest = + metrics.totalRequests > 0 ? metrics.totalThinking / metrics.totalRequests : 0 const inputOutputRatio = metrics.totalOutput > 0 ? metrics.totalInput / metrics.totalOutput : 0 const requestDensity = metrics.activeDays > 0 ? metrics.totalRequests / metrics.activeDays : 0 const qualityMetrics = [ { label: t('requestQuality.tokensPerRequest'), - value: metrics.hasRequestData ? formatTokens(metrics.avgTokensPerRequest) : t('common.notAvailable'), + value: metrics.hasRequestData + ? formatTokens(metrics.avgTokensPerRequest) + : t('common.notAvailable'), accent: 'var(--chart-2)', hint: t('requestQuality.tokensHint'), progress: Math.min(metrics.avgTokensPerRequest / 200_000, 1), }, { label: t('requestQuality.costPerRequest'), - value: metrics.hasRequestData ? formatCurrency(metrics.avgCostPerRequest) : t('common.notAvailable'), + value: metrics.hasRequestData + ? formatCurrency(metrics.avgCostPerRequest) + : t('common.notAvailable'), accent: 'var(--chart-4)', hint: t('requestQuality.costHint'), progress: Math.min(metrics.avgCostPerRequest / 0.25, 1), @@ -70,7 +76,9 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }} transition={{ duration: 0.35, delay: 0.05 }} > -
{item.label}
+
+ {item.label} +
{item.value}
{item.hint}
@@ -78,7 +86,9 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) { className="h-full rounded-full transition-all duration-500" style={{ backgroundColor: `hsl(${item.accent})` }} initial={{ width: 0 }} - animate={inView ? { width: `${Math.max(item.progress * 100, 6)}%` } : { width: 0 }} + animate={ + inView ? { width: `${Math.max(item.progress * 100, 6)}%` } : { width: 0 } + } transition={{ duration: 0.7, delay: 0.08 }} />
@@ -87,26 +97,75 @@ export function RequestQuality({ metrics, viewMode }: RequestQualityProps) {
- -
{t('requestQuality.requestDensity')}
-
{formatNumber(Math.round(requestDensity))}
-
{t('requestQuality.averagePerActiveUnit', { unit: viewMode === 'yearly' ? t('periods.year') : viewMode === 'monthly' ? t('periods.month') : t('periods.day') })}
+ +
+ {t('requestQuality.requestDensity')} +
+
+ {formatNumber(Math.round(requestDensity))} +
+
+ {t('requestQuality.averagePerActiveUnit', { + unit: + viewMode === 'yearly' + ? t('periods.year') + : viewMode === 'monthly' + ? t('periods.month') + : t('periods.day'), + })} +
- -
{t('requestQuality.cacheHitRate')}
-
{formatPercent(metrics.cacheHitRate, 1)}
+ +
+ {t('requestQuality.cacheHitRate')} +
+
+ {formatPercent(metrics.cacheHitRate, 1)} +
{t('requestQuality.cacheHitHint')}
- -
{t('requestQuality.inputOutput')}
-
{inputOutputRatio.toFixed(2)}:1
-
{t('requestQuality.inputOutputHint')}
+ +
+ {t('requestQuality.inputOutput')} +
+
+ {inputOutputRatio.toFixed(2)}:1 +
+
+ {t('requestQuality.inputOutputHint')} +
- -
{t('requestQuality.topRequestModel')}
-
{metrics.topRequestModel?.name ?? '–'}
+ +
+ {t('requestQuality.topRequestModel')} +
+
+ {metrics.topRequestModel?.name ?? '–'} +
- {metrics.topRequestModel ? `${formatNumber(metrics.topRequestModel.requests)} ${t('common.requests')}` : t('requestQuality.noRequestLeader')} + {metrics.topRequestModel + ? `${formatNumber(metrics.topRequestModel.requests)} ${t('common.requests')}` + : t('requestQuality.noRequestLeader')}
diff --git a/src/components/features/risk/ConcentrationRisk.tsx b/src/components/features/risk/ConcentrationRisk.tsx index 7ffb936..9a2f1b6 100644 --- a/src/components/features/risk/ConcentrationRisk.tsx +++ b/src/components/features/risk/ConcentrationRisk.tsx @@ -13,11 +13,17 @@ interface ConcentrationRiskProps { function describeRisk(value: number) { if (value >= 0.6) return { label: 'high', tone: 'text-red-400 bg-red-400/10 border-red-400/20' } - if (value >= 0.35) return { label: 'medium', tone: 'text-amber-300 bg-amber-400/10 border-amber-400/20' } + if (value >= 0.35) + return { label: 'medium', tone: 'text-amber-300 bg-amber-400/10 border-amber-400/20' } return { label: 'low', tone: 'text-green-400 bg-green-400/10 border-green-400/20' } } -export function ConcentrationRisk({ topModelShare, topProviderShare, modelConcentrationIndex, providerConcentrationIndex }: ConcentrationRiskProps) { +export function ConcentrationRisk({ + topModelShare, + topProviderShare, + modelConcentrationIndex, + providerConcentrationIndex, +}: ConcentrationRiskProps) { const { t } = useTranslation() const modelRisk = describeRisk(modelConcentrationIndex) const providerRisk = describeRisk(providerConcentrationIndex) @@ -35,28 +41,52 @@ export function ConcentrationRisk({ topModelShare, topProviderShare, modelConcen
-
{t('risk.modelDependency')}
+
+ {t('risk.modelDependency')} +
{formatPercent(topModelShare, 1)}
- {t(`risk.${modelRisk.label}`)} + + {t(`risk.${modelRisk.label}`)} +
-
+
+
+
+ {t('risk.modelHint', { value: modelConcentrationIndex.toFixed(2) })}
-
{t('risk.modelHint', { value: modelConcentrationIndex.toFixed(2) })}
-
{t('risk.providerDependency')}
-
{formatPercent(topProviderShare, 1)}
+
+ {t('risk.providerDependency')} +
+
+ {formatPercent(topProviderShare, 1)} +
- {t(`risk.${providerRisk.label}`)} + + {t(`risk.${providerRisk.label}`)} +
-
+
+
+
+ {t('risk.providerHint', { value: providerConcentrationIndex.toFixed(2) })}
-
{t('risk.providerHint', { value: providerConcentrationIndex.toFixed(2) })}
diff --git a/src/components/features/settings/SettingsModal.tsx b/src/components/features/settings/SettingsModal.tsx index e773fd1..6629994 100644 --- a/src/components/features/settings/SettingsModal.tsx +++ b/src/components/features/settings/SettingsModal.tsx @@ -1,12 +1,18 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +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 { DEFAULT_PROVIDER_LIMIT_CONFIG, syncProviderLimits } from '@/lib/provider-limits' import { DASHBOARD_SECTION_DEFINITION_MAP, DASHBOARD_DATE_PRESETS, @@ -16,14 +22,24 @@ import { 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 { + 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 { @@ -45,7 +61,7 @@ interface SettingsModalProps { defaultFilters: DashboardDefaultFilters sectionVisibility: DashboardSectionVisibility sectionOrder: DashboardSectionOrder - }) => Promise | unknown + }) => Promise | void onExportSettings: () => void onImportSettings: () => void onExportData: () => void @@ -63,16 +79,33 @@ function parseNumberInput(value: string): number { } function toggleSelection(values: string[], value: string) { - return values.includes(value) - ? values.filter(entry => entry !== value) - : [...values, value] + 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)) + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((left, right) => + left.localeCompare(right), + ) +} + +export function buildProviderLimitsState( + providers: string[], + draft: ProviderLimits, +): ProviderLimits { + const nextProviderLimits: ProviderLimits = {} + + for (const provider of providers) { + nextProviderLimits[provider] = draft[provider] ?? { ...DEFAULT_PROVIDER_LIMIT_CONFIG } + } + + return nextProviderLimits } -function moveSection(order: DashboardSectionOrder, sectionId: DashboardSectionOrder[number], direction: -1 | 1) { +function moveSection( + order: DashboardSectionOrder, + sectionId: DashboardSectionOrder[number], + direction: -1 | 1, +) { const currentIndex = order.indexOf(sectionId) const targetIndex = currentIndex + direction @@ -82,11 +115,16 @@ function moveSection(order: DashboardSectionOrder, sectionId: DashboardSectionOr const next = [...order] const [moved] = next.splice(currentIndex, 1) + if (!moved) return order next.splice(targetIndex, 0, moved) return next } -function reorderSections(order: DashboardSectionOrder, sourceId: DashboardSectionOrder[number], targetId: DashboardSectionOrder[number]) { +export function reorderSections( + order: DashboardSectionOrder, + sourceId: DashboardSectionOrder[number], + targetId: DashboardSectionOrder[number], +) { if (sourceId === targetId) return order const sourceIndex = order.indexOf(sourceId) @@ -98,7 +136,9 @@ function reorderSections(order: DashboardSectionOrder, sourceId: DashboardSectio const next = [...order] const [moved] = next.splice(sourceIndex, 1) - next.splice(targetIndex, 0, moved) + if (!moved) return order + const insertionIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex + next.splice(insertionIndex, 0, moved) return next } @@ -125,12 +165,20 @@ export function SettingsModal({ dataBusy = false, }: SettingsModalProps) { const { t } = useTranslation() - const [limitDraft, setLimitDraft] = useState(() => syncProviderLimits(limitProviders, limits)) - const [defaultFilterDraft, setDefaultFilterDraft] = useState(defaultFilters) - const [sectionVisibilityDraft, setSectionVisibilityDraft] = useState(sectionVisibility) + 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) + const [draggedSectionId, setDraggedSectionId] = useState( + null, + ) + const [dragOverSectionId, setDragOverSectionId] = useState( + null, + ) useEffect(() => { if (!open) return @@ -153,23 +201,18 @@ export function SettingsModal({ ) const updateProvider = (provider: string, patch: Partial) => { - setLimitDraft(prev => ({ + setLimitDraft((prev) => ({ ...prev, [provider]: { - ...prev[provider], + ...(prev[provider] ?? DEFAULT_PROVIDER_LIMIT_CONFIG), ...patch, }, })) } const handleSave = async () => { - const nextProviderLimits = { ...limits } - for (const provider of limitProviders) { - nextProviderLimits[provider] = limitDraft[provider] - } - await onSaveSettings({ - providerLimits: nextProviderLimits, + providerLimits: buildProviderLimitsState(limitProviders, limitDraft), defaultFilters: { ...defaultFilterDraft, providers: normalizeSelection(defaultFilterDraft.providers), @@ -207,7 +250,10 @@ export function SettingsModal({ ? t(`settings.modal.sources.${lastLoadSource}`) : t('settings.modal.sources.unknown') const orderedSections = useMemo( - () => sectionOrderDraft.map((sectionId) => DASHBOARD_SECTION_DEFINITION_MAP[sectionId]), + () => + sectionOrderDraft + .map((sectionId) => DASHBOARD_SECTION_DEFINITION_MAP[sectionId]) + .filter((section) => section !== undefined), [sectionOrderDraft], ) @@ -219,9 +265,7 @@ export function SettingsModal({ {t('settings.modal.title')} - - {t('settings.modal.description')} - + {t('settings.modal.description')}
@@ -230,17 +274,23 @@ export function SettingsModal({
-
{t('settings.modal.lastLoaded')}
+
+ {t('settings.modal.lastLoaded')} +
{lastLoadedAt ? formatDateTimeFull(lastLoadedAt) : t('common.notAvailable')}
-
{t('settings.modal.loadedVia')}
+
+ {t('settings.modal.loadedVia')} +
{loadSourceLabel}
-
{t('settings.modal.cliAutoLoad')}
+
+ {t('settings.modal.cliAutoLoad')} +
{cliAutoLoadActive ? t('common.enabled') : t('common.disabled')}
@@ -256,8 +306,12 @@ export function SettingsModal({
-
{t('settings.modal.defaultFiltersTitle')}
-

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

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

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

@@ -291,7 +347,9 @@ export function SettingsModal({
-
{t('settings.modal.defaultDateRange')}
+
+ {t('settings.modal.defaultDateRange')} +
{DASHBOARD_DATE_PRESETS.map((preset) => ( @@ -308,7 +368,9 @@ export function SettingsModal({
-
{t('settings.modal.filterProviders')}
+
+ {t('settings.modal.filterProviders')} +
{providerOptions.length === 0 ? (
{t('settings.modal.noProviders')} @@ -322,12 +384,17 @@ export function SettingsModal({ key={provider} type="button" aria-pressed={selected} - onClick={() => setDefaultFilterDraft(prev => ({ ...prev, providers: toggleSelection(prev.providers, provider) }))} + onClick={() => + setDefaultFilterDraft((prev) => ({ + ...prev, + providers: toggleSelection(prev.providers, provider), + })) + } className={cn( 'inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-colors', selected ? 'border-primary/30 bg-primary text-primary-foreground' - : getProviderBadgeClasses(provider) + : getProviderBadgeClasses(provider), )} > {provider} @@ -339,7 +406,9 @@ export function SettingsModal({
-
{t('settings.modal.filterModels')}
+
+ {t('settings.modal.filterModels')} +
{modelOptions.length === 0 ? (
{t('settings.modal.noModels')} @@ -353,12 +422,17 @@ export function SettingsModal({ key={model} type="button" aria-pressed={selected} - onClick={() => setDefaultFilterDraft(prev => ({ ...prev, models: toggleSelection(prev.models, model) }))} + onClick={() => + setDefaultFilterDraft((prev) => ({ + ...prev, + models: toggleSelection(prev.models, model), + })) + } className={cn( 'inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-colors', selected ? 'border-primary/30 bg-primary text-primary-foreground' - : 'border-border bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground' + : 'border-border bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground', )} > {model} @@ -378,8 +452,12 @@ export function SettingsModal({
-
{t('settings.modal.sectionVisibilityTitle')}
-

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

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

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

@@ -470,9 +560,13 @@ export function SettingsModal({ size="icon" className="h-8 w-8" data-testid={`move-section-down-${section.id}`} - onClick={() => setSectionOrderDraft((prev) => moveSection(prev, section.id, 1))} + onClick={() => + setSectionOrderDraft((prev) => moveSection(prev, section.id, 1)) + } disabled={index === orderedSections.length - 1} - aria-label={t('settings.modal.moveSectionDown', { section: t(section.labelKey) })} + aria-label={t('settings.modal.moveSectionDown', { + section: t(section.labelKey), + })} > @@ -480,10 +574,12 @@ export function SettingsModal({ type="button" data-testid={`toggle-section-visibility-${section.id}`} aria-pressed={visible} - onClick={() => setSectionVisibilityDraft(prev => ({ - ...prev, - [section.id]: !prev[section.id], - }))} + onClick={() => + setSectionVisibilityDraft((prev) => ({ + ...prev, + [section.id]: !prev[section.id], + })) + } className={cn( 'inline-flex min-w-[88px] items-center justify-center rounded-full border px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] transition-colors', visible @@ -508,16 +604,30 @@ export function SettingsModal({
-
{t('settings.modal.settingsBackupTitle')}
-

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

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

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

- - @@ -530,8 +640,12 @@ export function SettingsModal({
-
{t('settings.modal.dataBackupTitle')}
-

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

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

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

@@ -541,11 +655,21 @@ export function SettingsModal({ {t('settings.modal.dataImportReplaceHint')}

- - @@ -560,8 +684,12 @@ export function SettingsModal({
-
{t('settings.modal.providerLimitsTitle')}
-

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

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

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

@@ -654,8 +809,12 @@ export function SettingsModal({ {t('common.reset')}
- - + +
diff --git a/src/components/layout/FilterBar.tsx b/src/components/layout/FilterBar.tsx index 7a82c3c..c1396b4 100644 --- a/src/components/layout/FilterBar.tsx +++ b/src/components/layout/FilterBar.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select' +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select' import { cn } from '@/lib/cn' import { getModelColor, getProviderBadgeClasses, getProviderBadgeStyle } from '@/lib/model-utils' import { formatDate, formatMonthYear, localToday, toLocalDateStr } from '@/lib/formatters' @@ -53,7 +59,11 @@ function buildCalendarDays(displayMonth: Date) { return cells } -function resolveActivePreset(selectedMonth: string | null, startDate?: string, endDate?: string): DashboardDatePreset | null { +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 @@ -109,23 +119,30 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { const containerRef = useRef(null) const triggerRef = useRef(null) const overlayRef = useRef(null) - const [overlayStyle, setOverlayStyle] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 292 }) + const [overlayStyle, setOverlayStyle] = useState<{ top: number; left: number; width: number }>({ + top: 0, + left: 0, + width: 292, + }) const selectedDate = useMemo(() => parseLocalDate(value), [value]) - const [displayMonth, setDisplayMonth] = useState(() => selectedDate ?? parseLocalDate(localToday()) ?? new Date()) + const [displayMonth, setDisplayMonth] = useState( + () => selectedDate ?? parseLocalDate(localToday()) ?? new Date(), + ) const weekdayLabels = useMemo( - () => Array.from({ length: 7 }, (_, index) => - new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) - .format(new Date(Date.UTC(2024, 0, 1 + index))) - .replace('.', '') - .slice(0, 2) - ), - [] + () => + Array.from({ length: 7 }, (_, index) => + new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) + .format(new Date(Date.UTC(2024, 0, 1 + index))) + .replace('.', '') + .slice(0, 2), + ), + [], ) const monthLabel = useMemo( () => displayMonth.toLocaleDateString(getCurrentLocale(), { month: 'long', year: 'numeric' }), - [displayMonth] + [displayMonth], ) const calendarDays = useMemo(() => buildCalendarDays(displayMonth), [displayMonth]) @@ -148,8 +165,11 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { const viewportHeight = window.innerHeight const estimatedHeight = 330 const left = Math.min(Math.max(12, rect.left), Math.max(12, viewportWidth - width - 12)) - const showAbove = rect.bottom + estimatedHeight > viewportHeight - 12 && rect.top > estimatedHeight - const top = showAbove ? Math.max(12, rect.top - estimatedHeight - 8) : Math.min(viewportHeight - estimatedHeight - 12, rect.bottom + 8) + const showAbove = + rect.bottom + estimatedHeight > viewportHeight - 12 && rect.top > estimatedHeight + const top = showAbove + ? Math.max(12, rect.top - estimatedHeight - 8) + : Math.min(viewportHeight - estimatedHeight - 12, rect.bottom + 8) setOverlayStyle({ top, left, width }) } @@ -183,7 +203,7 @@ function DatePickerField({ label, value, onChange }: DatePickerFieldProps) { - {open && typeof document !== 'undefined' && createPortal( -
-
- -
{monthLabel}
- -
+ {open && + typeof document !== 'undefined' && + createPortal( +
+
+ +
{monthLabel}
+ +
-
- {weekdayLabels.map((day) => ( -
- {day} -
- ))} -
+
+ {weekdayLabels.map((day) => ( +
+ {day} +
+ ))} +
-
- {calendarDays.map((day, index) => { - if (!day) { - return
- } +
+ {calendarDays.map((day, index) => { + if (!day) { + return
+ } - const iso = toLocalDateStr(day) - const isSelected = value === iso - const isToday = iso === today + const iso = toLocalDateStr(day) + const isSelected = value === iso + const isToday = iso === today - return ( - - ) - })} -
+ return ( + + ) + })} +
-
- - -
-
, - document.body - )} +
+ + +
+
, + document.body, + )}
) } export function FilterBar({ - viewMode, onViewModeChange, - selectedMonth, onMonthChange, - availableMonths, availableProviders, selectedProviders, - onToggleProvider, onClearProviders, allModels, - selectedModels, onToggleModel, onClearModels, - startDate, endDate, - onStartDateChange, onEndDateChange, + viewMode, + onViewModeChange, + selectedMonth, + onMonthChange, + availableMonths, + availableProviders, + selectedProviders, + onToggleProvider, + onClearProviders, + allModels, + selectedModels, + onToggleModel, + onClearModels, + startDate, + endDate, + onStartDateChange, + onEndDateChange, onApplyPreset, onResetAll, }: FilterBarProps) { @@ -319,16 +358,33 @@ export function FilterBar({ [selectedMonth, startDate, endDate], ) - const hasCustomFilters = selectedMonth !== null || selectedProviders.length > 0 || selectedModels.length > 0 || Boolean(startDate || endDate) || viewMode !== 'daily' + const hasCustomFilters = + selectedMonth !== null || + selectedProviders.length > 0 || + selectedModels.length > 0 || + Boolean(startDate || endDate) || + viewMode !== 'daily' return (
{t('filterBar.status')} - {selectedProviders.length > 0 ? t('filterBar.providersActive', { count: selectedProviders.length }) : t('common.allProviders')} - {selectedModels.length > 0 ? t('filterBar.modelsActive', { count: selectedModels.length }) : t('common.allModels')} - {(startDate || endDate) && {t('filterBar.dateFilterActive')}} + + {selectedProviders.length > 0 + ? t('filterBar.providersActive', { count: selectedProviders.length }) + : t('common.allProviders')} + + + {selectedModels.length > 0 + ? t('filterBar.modelsActive', { count: selectedModels.length }) + : t('common.allModels')} + + {(startDate || endDate) && ( + + {t('filterBar.dateFilterActive')} + + )}
- - {t('filterBar.until')} - + + + {t('filterBar.until')} + + - ) - })} + {availableProviders.map((provider) => { + const isSelected = selectedProviders.includes(provider) + return ( + + ) + })} {selectedProviders.length > 0 && ( -
@@ -136,38 +188,70 @@ export function Header({ dateRange, isDark, currentLanguage, helpOpen, streak, d ))}
- - ⌘K -
- -
-
- {settingsButton} -
-
- {pdfButton} -
-
- diff --git a/src/components/tables/ModelEfficiency.tsx b/src/components/tables/ModelEfficiency.tsx index 3de46b1..9b563bc 100644 --- a/src/components/tables/ModelEfficiency.tsx +++ b/src/components/tables/ModelEfficiency.tsx @@ -4,7 +4,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { FormattedValue } from '@/components/ui/formatted-value' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' -import { formatPercent, formatTokens, periodUnit, periodLabel, formatNumber } from '@/lib/formatters' +import { + formatPercent, + formatTokens, + periodUnit, + periodLabel, + formatNumber, +} from '@/lib/formatters' import { getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' @@ -27,49 +33,98 @@ interface ModelData { } interface ModelEfficiencyProps { - modelCosts: Map + modelCosts: Map< + string, + { + cost: number + tokens: number + input?: number + output?: number + cacheRead?: number + cacheCreate?: number + thinking?: number + days: number + requests: number + costPerDay?: number + } + > totalCost: number viewMode?: ViewMode } -type SortKey = 'cost' | 'tokens' | 'costPerMillion' | 'costPerRequest' | 'tokensPerRequest' | 'share' | 'requestShare' | 'cacheShare' | 'thinkingShare' | 'days' | 'requests' | 'costPerDay' +type SortKey = + | 'cost' + | 'tokens' + | 'costPerMillion' + | 'costPerRequest' + | 'tokensPerRequest' + | 'share' + | 'requestShare' + | 'cacheShare' + | 'thinkingShare' + | 'days' + | 'requests' + | 'costPerDay' -export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: ModelEfficiencyProps) { +export function ModelEfficiency({ + modelCosts, + totalCost, + viewMode = 'daily', +}: ModelEfficiencyProps) { const { t } = useTranslation() const [sortKey, setSortKey] = useState('cost') const [sortAsc, setSortAsc] = useState(false) - const models = useMemo(() => Array.from(modelCosts.entries()).map(([name, v]) => ({ - name, - cost: v.cost, - tokens: v.tokens, - costPerMillion: v.tokens > 0 ? v.cost / (v.tokens / 1_000_000) : 0, - costPerRequest: v.requests > 0 ? v.cost / v.requests : 0, - tokensPerRequest: v.requests > 0 ? v.tokens / v.requests : 0, - share: totalCost > 0 ? (v.cost / totalCost) * 100 : 0, - requestShare: 0, - cacheShare: v.tokens > 0 ? ((v.cacheRead ?? 0) / v.tokens) * 100 : 0, - thinkingShare: v.tokens > 0 ? ((v.thinking ?? 0) / v.tokens) * 100 : 0, - days: v.days, - requests: v.requests, - costPerDay: v.days > 0 ? v.cost / v.days : 0, - })), [modelCosts, totalCost]) + const models = useMemo( + () => + Array.from(modelCosts.entries()).map(([name, v]) => ({ + name, + cost: v.cost, + tokens: v.tokens, + costPerMillion: v.tokens > 0 ? v.cost / (v.tokens / 1_000_000) : 0, + costPerRequest: v.requests > 0 ? v.cost / v.requests : 0, + tokensPerRequest: v.requests > 0 ? v.tokens / v.requests : 0, + share: totalCost > 0 ? (v.cost / totalCost) * 100 : 0, + requestShare: 0, + cacheShare: v.tokens > 0 ? ((v.cacheRead ?? 0) / v.tokens) * 100 : 0, + thinkingShare: v.tokens > 0 ? ((v.thinking ?? 0) / v.tokens) * 100 : 0, + days: v.days, + requests: v.requests, + costPerDay: v.days > 0 ? v.cost / v.days : 0, + })), + [modelCosts, totalCost], + ) - const totalRequests = useMemo(() => models.reduce((sum, model) => sum + model.requests, 0), [models]) - const enrichedModels = useMemo(() => models.map(model => ({ - ...model, - requestShare: totalRequests > 0 ? (model.requests / totalRequests) * 100 : 0, - })), [models, totalRequests]) + const totalRequests = useMemo( + () => models.reduce((sum, model) => sum + model.requests, 0), + [models], + ) + const enrichedModels = useMemo( + () => + models.map((model) => ({ + ...model, + requestShare: totalRequests > 0 ? (model.requests / totalRequests) * 100 : 0, + })), + [models, totalRequests], + ) - const sorted = useMemo(() => [...enrichedModels].sort((a, b) => { - const diff = a[sortKey] - b[sortKey] - return sortAsc ? diff : -diff - }), [enrichedModels, sortAsc, sortKey]) + const sorted = useMemo( + () => + [...enrichedModels].sort((a, b) => { + const diff = a[sortKey] - b[sortKey] + return sortAsc ? diff : -diff + }), + [enrichedModels, sortAsc, sortKey], + ) const topModel = sorted[0] ?? null - const mostEfficient = useMemo(() => [...models] - .filter(model => model.tokens > 0) - .sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, [models]) + const mostEfficient = useMemo( + () => + [...models] + .filter((model) => model.tokens > 0) + .sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, + [models], + ) const handleSort = (key: SortKey) => { if (key === sortKey) { @@ -80,18 +135,28 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M } } + const getAriaSort = (field: SortKey): 'ascending' | 'descending' | 'none' => + sortKey === field ? (sortAsc ? 'ascending' : 'descending') : 'none' + const SortHeader = ({ label, field }: { label: string; field: SortKey }) => (
) @@ -104,40 +169,73 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M {t('tables.modelEfficiency.title')} - {t('tables.modelEfficiency.count', { count: models.length })} + + {t('tables.modelEfficiency.count', { count: models.length })} +
-
{t('tables.modelEfficiency.topModel')}
+
+ {t('tables.modelEfficiency.topModel')} +
{topModel?.name ?? '–'}
-
{topModel ? t('tables.modelEfficiency.share', { value: formatPercent(topModel.share, 0) }) : '–'}
+
+ {topModel + ? t('tables.modelEfficiency.share', { value: formatPercent(topModel.share, 0) }) + : '–'} +
-
{t('tables.modelEfficiency.mostEfficient')}
+
+ {t('tables.modelEfficiency.mostEfficient')} +
{mostEfficient?.name ?? '–'}
-
{mostEfficient ? t('tables.modelEfficiency.share', { value: formatPercent(mostEfficient.share, 0) }) : '–'}
+
+ {mostEfficient + ? t('tables.modelEfficiency.share', { + value: formatPercent(mostEfficient.share, 0), + }) + : '–'} +
-
{t('tables.modelEfficiency.totalRequests')}
+
+ {t('tables.modelEfficiency.totalRequests')} +
{formatNumber(totalRequests)}
-
{models.length > 0 ? t('tables.modelEfficiency.perModel', { value: (totalRequests / models.length).toFixed(0) }) : '–'}
+
+ {models.length > 0 + ? t('tables.modelEfficiency.perModel', { + value: (totalRequests / models.length).toFixed(0), + }) + : '–'} +
-
{t('tables.modelEfficiency.topModelTokens')}
-
{topModel ? formatTokens(topModel.tokens) : '–'}
-
{topModel ? `${topModel.days} ${periodLabel(viewMode, true)}` : '–'}
+
+ {t('tables.modelEfficiency.topModelTokens')} +
+
+ {topModel ? formatTokens(topModel.tokens) : '–'} +
+
+ {topModel ? `${topModel.days} ${periodLabel(viewMode, true)}` : '–'} +
- {sorted.map(model => ( + {sorted.map((model) => (
- + {model.name}
@@ -145,8 +243,12 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
-
-
{t('tables.modelEfficiency.share', { value: formatPercent(model.share, 1) })}
+
+ +
+
+ {t('tables.modelEfficiency.share', { value: formatPercent(model.share, 1) })} +
@@ -156,7 +258,9 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
$/1M
-
+
+ +
{t('common.requests')}
@@ -164,7 +268,9 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
$/Req
-
+
+ +
Tokens/Req
@@ -183,7 +289,9 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M
{t('comparison.metric')} + {t('comparison.metric')} + {labelB} {labelA}{t('comparison.delta')} + {t('comparison.delta')} +
{row.label} {row.b} + + {row.a} {row.delta.hasData ? ( - - {row.delta.arrow}{formatPercent(row.delta.value)} + + {row.delta.arrow} + {formatPercent(row.delta.value)} - ) : '–'} + ) : ( + '–' + )}
handleSort(field)} > - +
- + @@ -191,21 +299,41 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M - + - - + + - {sorted.map(model => ( - + {sorted.map((model) => ( + - - + + - - - + + + diff --git a/src/components/tables/ProviderEfficiency.tsx b/src/components/tables/ProviderEfficiency.tsx index 9b671fe..106eb1c 100644 --- a/src/components/tables/ProviderEfficiency.tsx +++ b/src/components/tables/ProviderEfficiency.tsx @@ -4,7 +4,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { FormattedValue } from '@/components/ui/formatted-value' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' -import { formatPercent, formatTokens, periodLabel, periodUnit, formatNumber } from '@/lib/formatters' +import { + formatPercent, + formatTokens, + periodLabel, + periodUnit, + formatNumber, +} from '@/lib/formatters' import { getProviderBadgeClasses } from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' @@ -24,31 +30,54 @@ interface ProviderEfficiencyProps { viewMode?: ViewMode } -type SortKey = 'cost' | 'share' | 'requests' | 'tokens' | 'costPerRequest' | 'costPerMillion' | 'cacheShare' +type SortKey = + | 'cost' + | 'share' + | 'requests' + | 'tokens' + | 'costPerRequest' + | 'costPerMillion' + | 'cacheShare' -export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'daily' }: ProviderEfficiencyProps) { +export function ProviderEfficiency({ + providerMetrics, + totalCost, + viewMode = 'daily', +}: ProviderEfficiencyProps) { const { t } = useTranslation() const [sortKey, setSortKey] = useState('cost') const [sortAsc, setSortAsc] = useState(false) - const rows = useMemo(() => ( - Array.from(providerMetrics.entries()).map(([name, value]) => ({ - name, - ...value, - share: totalCost > 0 ? (value.cost / totalCost) * 100 : 0, - costPerRequest: value.requests > 0 ? value.cost / value.requests : 0, - costPerMillion: value.tokens > 0 ? value.cost / (value.tokens / 1_000_000) : 0, - cacheShare: value.tokens > 0 ? (value.cacheRead / value.tokens) * 100 : 0, - })) - ), [providerMetrics, totalCost]) + const rows = useMemo( + () => + Array.from(providerMetrics.entries()).map(([name, value]) => ({ + name, + ...value, + share: totalCost > 0 ? (value.cost / totalCost) * 100 : 0, + costPerRequest: value.requests > 0 ? value.cost / value.requests : 0, + costPerMillion: value.tokens > 0 ? value.cost / (value.tokens / 1_000_000) : 0, + cacheShare: value.tokens > 0 ? (value.cacheRead / value.tokens) * 100 : 0, + })), + [providerMetrics, totalCost], + ) - const sorted = useMemo(() => [...rows].sort((a, b) => { - const diff = a[sortKey] - b[sortKey] - return sortAsc ? diff : -diff - }), [rows, sortAsc, sortKey]) + const sorted = useMemo( + () => + [...rows].sort((a, b) => { + const diff = a[sortKey] - b[sortKey] + return sortAsc ? diff : -diff + }), + [rows, sortAsc, sortKey], + ) const lead = sorted[0] ?? null - const efficient = useMemo(() => [...rows].filter(row => row.tokens > 0).sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, [rows]) + const efficient = useMemo( + () => + [...rows] + .filter((row) => row.tokens > 0) + .sort((a, b) => a.costPerMillion - b.costPerMillion)[0] ?? null, + [rows], + ) const totalRequests = useMemo(() => rows.reduce((sum, row) => sum + row.requests, 0), [rows]) const handleSort = (key: SortKey) => { @@ -59,12 +88,28 @@ export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'dai } } + const getAriaSort = (field: SortKey): 'ascending' | 'descending' | 'none' => + sortKey === field ? (sortAsc ? 'ascending' : 'descending') : 'none' + const SortHeader = ({ label, field }: { label: string; field: SortKey }) => ( - ) @@ -76,62 +121,111 @@ export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'dai {t('tables.providerEfficiency.title')} - {t('tables.providerEfficiency.count', { count: rows.length })} + + {t('tables.providerEfficiency.count', { count: rows.length })} +
-
{t('tables.providerEfficiency.leadProvider')}
+
+ {t('tables.providerEfficiency.leadProvider')} +
{lead?.name ?? '–'}
-
{lead ? t('tables.providerEfficiency.share', { value: formatPercent(lead.share, 0) }) : '–'}
+
+ {lead + ? t('tables.providerEfficiency.share', { value: formatPercent(lead.share, 0) }) + : '–'} +
-
{t('tables.providerEfficiency.mostEfficient')}
+
+ {t('tables.providerEfficiency.mostEfficient')} +
{efficient?.name ?? '–'}
-
{efficient ? `${efficient.costPerMillion.toFixed(2)} $/1M` : '–'}
+
+ {efficient ? `${efficient.costPerMillion.toFixed(2)} $/1M` : '–'} +
-
{t('tables.providerEfficiency.totalRequests')}
+
+ {t('tables.providerEfficiency.totalRequests')} +
{formatNumber(totalRequests)}
-
{rows.length > 0 ? t('tables.providerEfficiency.perProvider', { value: (totalRequests / rows.length).toFixed(0) }) : '–'}
+
+ {rows.length > 0 + ? t('tables.providerEfficiency.perProvider', { + value: (totalRequests / rows.length).toFixed(0), + }) + : '–'} +
-
{t('tables.providerEfficiency.avgPerUnit', { unit: periodUnit(viewMode) })}
-
{lead ? : '–'}
-
{lead ? `${lead.days} ${periodLabel(viewMode, true)}` : '–'}
+
+ {t('tables.providerEfficiency.avgPerUnit', { unit: periodUnit(viewMode) })} +
+
+ {lead ? ( + + ) : ( + '–' + )} +
+
+ {lead ? `${lead.days} ${periodLabel(viewMode, true)}` : '–'} +
- {sorted.map(row => ( + {sorted.map((row) => (
- + {row.name} -
{t('tables.providerEfficiency.share', { value: formatPercent(row.share, 1) })}
+
+ {t('tables.providerEfficiency.share', { value: formatPercent(row.share, 1) })} +
-
-
{formatNumber(row.requests)} {t('tables.providerEfficiency.req')}
+
+ +
+
+ {formatNumber(row.requests)} {t('tables.providerEfficiency.req')} +
-
{t('tables.providerEfficiency.tokens')}
+
+ {t('tables.providerEfficiency.tokens')} +
{formatTokens(row.tokens)}
$/Req
-
+
+ +
$/1M
-
+
+ +
-
{t('tables.providerEfficiency.cacheShare')}
+
+ {t('tables.providerEfficiency.cacheShare')} +
{formatPercent(row.cacheShare, 1)}
@@ -143,31 +237,61 @@ export function ProviderEfficiency({ providerMetrics, totalCost, viewMode = 'dai
{t('tables.modelEfficiency.model')} + {t('tables.modelEfficiency.model')} +
- + {model.name} - + {getModelProvider(model.name)} @@ -220,17 +348,33 @@ export function ModelEfficiency({ modelCosts, totalCost, viewMode = 'daily' }: M -
+
{formatPercent(model.share)}
{formatNumber(model.requests)}{formatPercent(model.requestShare, 1)} + {formatNumber(model.requests)} + + {formatPercent(model.requestShare, 1)} + {formatTokens(model.tokensPerRequest)}{formatPercent(model.cacheShare, 1)}{formatPercent(model.thinkingShare, 1)} + {formatTokens(model.tokensPerRequest)} + + {formatPercent(model.cacheShare, 1)} + + {formatPercent(model.thinkingShare, 1)} + handleSort(field)}> - + +
- + - - + + - {sorted.map(row => ( - + {sorted.map((row) => ( + - - - - - - - + + + + + + + ))} diff --git a/src/components/tables/RecentDays.tsx b/src/components/tables/RecentDays.tsx index d727459..32b9a2d 100644 --- a/src/components/tables/RecentDays.tsx +++ b/src/components/tables/RecentDays.tsx @@ -6,7 +6,12 @@ import { FormattedValue } from '@/components/ui/formatted-value' import { InfoButton } from '@/components/features/help/InfoButton' import { FEATURE_HELP } from '@/lib/help-content' import { formatCurrency, formatDate, formatPercent, formatNumber } from '@/lib/formatters' -import { normalizeModelName, getModelColor, getModelProvider, getProviderBadgeClasses } from '@/lib/model-utils' +import { + normalizeModelName, + getModelColor, + getModelProvider, + getProviderBadgeClasses, +} from '@/lib/model-utils' import { cn } from '@/lib/cn' import { ArrowUpDown } from 'lucide-react' import { periodLabel } from '@/lib/formatters' @@ -30,9 +35,12 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP const items = [...data] items.sort((a, b) => { switch (sortKey) { - case 'date': return sortAsc ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date) - case 'cost': return sortAsc ? a.totalCost - b.totalCost : b.totalCost - a.totalCost - case 'tokens': return sortAsc ? a.totalTokens - b.totalTokens : b.totalTokens - a.totalTokens + case 'date': + return sortAsc ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date) + case 'cost': + return sortAsc ? a.totalCost - b.totalCost : b.totalCost - a.totalCost + case 'tokens': + return sortAsc ? a.totalTokens - b.totalTokens : b.totalTokens - a.totalTokens case 'costPerM': { const aPerM = a.totalTokens > 0 ? a.totalCost / (a.totalTokens / 1e6) : 0 const bPerM = b.totalTokens > 0 ? b.totalCost / (b.totalTokens / 1e6) : 0 @@ -44,53 +52,85 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP }, [data, sortKey, sortAsc]) const displayed = showAll ? sorted : sorted.slice(0, 30) - const chronological = useMemo(() => [...data].sort((a, b) => a.date.localeCompare(b.date)), [data]) + const chronological = useMemo( + () => [...data].sort((a, b) => a.date.localeCompare(b.date)), + [data], + ) const benchmarkMap = useMemo(() => { - const map = new Map() + const map = new Map< + string, + { prevCostDelta?: number; avgCost7?: number; avgRequests7?: number } + >() chronological.forEach((day, index) => { const previous = index > 0 ? chronological[index - 1] : null const window = chronological.slice(Math.max(0, index - 7), index) + const prevCostDelta = + previous && previous.totalCost > 0 + ? ((day.totalCost - previous.totalCost) / previous.totalCost) * 100 + : null + const avgCost7 = + window.length > 0 + ? window.reduce((sum, item) => sum + item.totalCost, 0) / window.length + : null + const avgRequests7 = + window.length > 0 + ? window.reduce((sum, item) => sum + item.requestCount, 0) / window.length + : null map.set(day.date, { - prevCostDelta: previous && previous.totalCost > 0 ? ((day.totalCost - previous.totalCost) / previous.totalCost) * 100 : undefined, - avgCost7: window.length > 0 ? window.reduce((sum, item) => sum + item.totalCost, 0) / window.length : undefined, - avgRequests7: window.length > 0 ? window.reduce((sum, item) => sum + item.requestCount, 0) / window.length : undefined, + ...(prevCostDelta !== null ? { prevCostDelta } : {}), + ...(avgCost7 !== null ? { avgCost7 } : {}), + ...(avgRequests7 !== null ? { avgRequests7 } : {}), }) }) return map }, [chronological]) - const maxCost = useMemo( - () => Math.max(...data.map(d => d.totalCost), 0), - [data] - ) + const maxCost = useMemo(() => Math.max(...data.map((d) => d.totalCost), 0), [data]) const summary = useMemo(() => { if (data.length === 0) return null const totalCost = data.reduce((sum, day) => sum + day.totalCost, 0) const totalTokens = data.reduce((sum, day) => sum + day.totalTokens, 0) const totalRequests = data.reduce((sum, day) => sum + day.requestCount, 0) - const cacheShare = totalTokens > 0 ? data.reduce((sum, day) => sum + day.cacheReadTokens, 0) / totalTokens * 100 : 0 + const cacheShare = + totalTokens > 0 + ? (data.reduce((sum, day) => sum + day.cacheReadTokens, 0) / totalTokens) * 100 + : 0 const top = [...data].sort((a, b) => b.totalCost - a.totalCost)[0] ?? null return { totalCost, totalTokens, totalRequests, cacheShare, top } }, [data]) const handleSort = (key: SortKey) => { if (key === sortKey) setSortAsc(!sortAsc) - else { setSortKey(key); setSortAsc(false) } + else { + setSortKey(key) + setSortAsc(false) + } } + const getAriaSort = (field: SortKey): 'ascending' | 'descending' | 'none' => + sortKey === field ? (sortAsc ? 'ascending' : 'descending') : 'none' + return (
- {viewMode === 'monthly' ? t('tables.recentDays.monthsDetail') : viewMode === 'yearly' ? t('tables.recentDays.yearsDetail') : t('tables.recentDays.daysDetail')} + {viewMode === 'monthly' + ? t('tables.recentDays.monthsDetail') + : viewMode === 'yearly' + ? t('tables.recentDays.yearsDetail') + : t('tables.recentDays.daysDetail')} - {t('tables.recentDays.showing', { shown: displayed.length, total: sorted.length, unit: periodLabel(viewMode, true) })} + {t('tables.recentDays.showing', { + shown: displayed.length, + total: sorted.length, + unit: periodLabel(viewMode, true), + })}
{sorted.length > 30 && ( @@ -103,34 +143,57 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP {summary && (
-
{t('tables.recentDays.totalCost')}
-
+
+ {t('tables.recentDays.totalCost')} +
+
+ +
-
{t('tables.recentDays.totalTokens')}
-
+
+ {t('tables.recentDays.totalTokens')} +
+
+ +
-
{t('tables.recentDays.requests')}
-
+
+ {t('tables.recentDays.requests')} +
+
+ +
-
{t('tables.recentDays.cacheReadShare')}
+
+ {t('tables.recentDays.cacheReadShare')} +
{formatPercent(summary.cacheShare, 1)}
-
{t('tables.recentDays.peak')}
-
{summary.top ? formatDate(summary.top.date) : '–'}
-
{summary.top ? `${summary.top.totalCost.toFixed(2)} USD` : '–'}
+
+ {t('tables.recentDays.peak')} +
+
+ {summary.top ? formatDate(summary.top.date) : '–'} +
+
+ {summary.top ? formatCurrency(summary.top.totalCost) : '–'} +
)}
- {displayed.map(day => { + {displayed.map((day) => { const costPerM = day.totalTokens > 0 ? day.totalCost / (day.totalTokens / 1_000_000) : 0 const uniqueModels = day.modelBreakdowns - .map(mb => ({ name: normalizeModelName(mb.modelName), provider: getModelProvider(mb.modelName) })) - .filter((entry, i, a) => a.findIndex(item => item.name === entry.name) === i) + .map((mb) => ({ + name: normalizeModelName(mb.modelName), + provider: getModelProvider(mb.modelName), + })) + .filter((entry, i, a) => a.findIndex((item) => item.name === entry.name) === i) return (
{t('common.input')}
-
+
+ +
{t('common.output')}
-
+
+ +
$/1M
-
+
+ +
@@ -178,7 +256,12 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP }} > {name} - + {provider} @@ -191,7 +274,10 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{viewMode === 'daily' && benchmarkMap.get(day.date)?.avgCost7 !== undefined && (
- {t('tables.recentDays.avg7d')} {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} · {t('tables.recentDays.reqAvg')} {benchmarkMap.get(day.date)!.avgRequests7?.toFixed(0) ?? '–'} + {t('tables.recentDays.avg7d')}{' '} + {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} ·{' '} + {t('tables.recentDays.reqAvg')}{' '} + {benchmarkMap.get(day.date)!.avgRequests7?.toFixed(0) ?? '–'}
)} @@ -203,30 +289,109 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{t('tables.providerEfficiency.provider')} + {t('tables.providerEfficiency.provider')} +
- + {row.name} {formatPercent(row.share, 1)}{formatNumber(row.requests)}{formatTokens(row.tokens)}{formatPercent(row.cacheShare, 1)} + + + {formatPercent(row.share, 1)} + + {formatNumber(row.requests)} + + {formatTokens(row.tokens)} + + + + + + {formatPercent(row.cacheShare, 1)} +
- - - + + + - - - - - - - + + + + - - {displayed.map(day => { - const costPerM = day.totalTokens > 0 ? day.totalCost / (day.totalTokens / 1_000_000) : 0 + {displayed.map((day) => { + const costPerM = + day.totalTokens > 0 ? day.totalCost / (day.totalTokens / 1_000_000) : 0 const intensity = maxCost > 0 ? day.totalCost / maxCost : 0 return ( onClickDay?.(day.date)} > - + ) diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index c4ad3b9..fa164cb 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -16,12 +16,11 @@ const badgeVariants = cva( defaultVariants: { variant: 'default', }, - } + }, ) export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e41c3ae..90a1ea0 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -26,12 +26,11 @@ const buttonVariants = cva( variant: 'default', size: 'default', }, - } + }, ) export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } @@ -39,13 +38,9 @@ const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( - + ) - } + }, ) Button.displayName = 'Button' diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index caadc0c..d67c9ce 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -2,50 +2,55 @@ import * as React from 'react' import { motion } from 'framer-motion' import { cn } from '@/lib/cn' -const Card = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ) -) +type CardProps = React.ComponentPropsWithoutRef + +const Card = React.forwardRef(({ className, ...props }, ref) => ( + +)) Card.displayName = 'Card' const CardHeader = React.forwardRef>( ({ className, ...props }, ref) => (
- ) + ), ) CardHeader.displayName = 'CardHeader' const CardTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -

- ) +

+ ), ) CardTitle.displayName = 'CardTitle' const CardContent = React.forwardRef>( ({ className, ...props }, ref) => (
- ) + ), ) CardContent.displayName = 'CardContent' -const CardDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -

- ) -) +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) CardDescription.displayName = 'CardDescription' export { Card, CardHeader, CardTitle, CardContent, CardDescription } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 1b135d4..ac05f3b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef< ref={ref} className={cn( 'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', - className + className, )} {...props} /> @@ -32,7 +32,7 @@ const DialogContent = React.forwardRef< ref={ref} className={cn( 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl', - className + className, )} {...props} > @@ -73,4 +73,12 @@ const DialogDescription = React.forwardRef< )) DialogDescription.displayName = 'DialogDescription' -export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogDescription } +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/expandable-card.tsx b/src/components/ui/expandable-card.tsx index 612b730..89ff069 100644 --- a/src/components/ui/expandable-card.tsx +++ b/src/components/ui/expandable-card.tsx @@ -12,7 +12,13 @@ interface ExpandableCardProps { stats?: { label: string; value: string }[] } -export function ExpandableCard({ children, title, className, expandedClassName, stats }: ExpandableCardProps) { +export function ExpandableCard({ + children, + title, + className, + expandedClassName, + stats, +}: ExpandableCardProps) { const { t } = useTranslation() const [expanded, setExpanded] = useState(false) @@ -30,10 +36,12 @@ export function ExpandableCard({ children, title, className, expandedClassName,

- + {title ?? t('common.expand')} Expanded card view with additional metrics and full content. @@ -41,9 +49,11 @@ export function ExpandableCard({ children, title, className, expandedClassName,
{stats && stats.length > 0 && (
- {stats.map(s => ( + {stats.map((s) => (
-
{s.label}
+
+ {s.label} +
{s.value}
))} diff --git a/src/components/ui/formatted-value.tsx b/src/components/ui/formatted-value.tsx index 1e95e27..98df64c 100644 --- a/src/components/ui/formatted-value.tsx +++ b/src/components/ui/formatted-value.tsx @@ -1,5 +1,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { formatCurrency, formatCurrencyExact, formatTokens, formatTokensExact, formatNumber, formatPercent } from '@/lib/formatters' +import { + formatCurrency, + formatCurrencyExact, + formatTokens, + formatTokensExact, + formatNumber, + formatPercent, +} from '@/lib/formatters' import { cn } from '@/lib/cn' type ValueType = 'currency' | 'tokens' | 'number' | 'percent' @@ -8,7 +15,7 @@ interface FormattedValueProps { value: number type: ValueType className?: string - decimals?: number // for percent type + decimals?: number // for percent type label?: string insight?: string } @@ -29,7 +36,14 @@ const EXACT_FORMATTERS: Record string> = { percent: (v) => formatPercent(v, 4), } -export function FormattedValue({ value, type, className, decimals, label, insight }: FormattedValueProps) { +export function FormattedValue({ + value, + type, + className, + decimals, + label, + insight, +}: FormattedValueProps) { const abbreviated = FORMATTERS[type](value, decimals) const exact = EXACT_FORMATTERS[type](value) @@ -43,15 +57,26 @@ export function FormattedValue({ value, type, className, decimals, label, insigh return ( - + {abbreviated}
- {label &&
{label}
} + {label && ( +
+ {label} +
+ )}
{exact}
- {insight &&
{insight}
} + {insight && ( +
{insight}
+ )}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 9f431c9..7e5d8b0 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -14,7 +14,7 @@ const SelectTrigger = React.forwardRef< ref={ref} className={cn( 'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', - className + className, )} {...props} > @@ -36,13 +36,17 @@ const SelectContent = React.forwardRef< className={cn( 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95', position === 'popper' && 'translate-y-1', - className + className, )} position={position} {...props} > {children} @@ -59,7 +63,7 @@ const SelectItem = React.forwardRef< ref={ref} className={cn( 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', - className + className, )} {...props} > diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index 498ee9c..39791e1 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -1,12 +1,7 @@ import { cn } from '@/lib/cn' function Skeleton({ className, ...props }: React.HTMLAttributes) { - return ( -
- ) + return
} function MetricCardSkeleton() { @@ -24,7 +19,9 @@ function MetricCardSkeleton() { function ChartCardSkeleton({ className }: { className?: string }) { return ( -
+
@@ -56,19 +53,25 @@ function DashboardSkeleton() {
- {[1, 2, 3].map(i => )} + {[1, 2, 3].map((i) => ( + + ))}
{/* Primary metrics */}
- {[1, 2, 3, 4, 5, 6].map(i => )} + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))}
{/* Secondary metrics */}
- {[1, 2, 3, 4].map(i => )} + {[1, 2, 3, 4].map((i) => ( + + ))}
{/* Chart area */} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 8dc4a82..418d6ac 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -20,21 +20,21 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { const addToast = useCallback((message: string, type: Toast['type'] = 'info') => { const id = Math.random().toString(36).slice(2) - setToasts(prev => [...prev, { id, message, type }]) + setToasts((prev) => [...prev, { id, message, type }]) setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) + setToasts((prev) => prev.filter((t) => t.id !== id)) }, 4000) }, []) const removeToast = useCallback((id: string) => { - setToasts(prev => prev.filter(t => t.id !== id)) + setToasts((prev) => prev.filter((t) => t.id !== id)) }, []) return ( {children}
- {toasts.map(toast => ( + {toasts.map((toast) => (
diff --git a/src/hooks/use-app-settings.ts b/src/hooks/use-app-settings.ts index 11487c2..8c502fd 100644 --- a/src/hooks/use-app-settings.ts +++ b/src/hooks/use-app-settings.ts @@ -55,12 +55,30 @@ 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]) + 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, diff --git a/src/hooks/use-computed-metrics.ts b/src/hooks/use-computed-metrics.ts index 232b3a8..e8cab01 100644 --- a/src/hooks/use-computed-metrics.ts +++ b/src/hooks/use-computed-metrics.ts @@ -1,10 +1,16 @@ import { useMemo } from 'react' -import type { DailyUsage, ViewMode } from '@/types' +import type { DailyUsage } from '@/types' import { computeMetrics, computeModelCosts, computeProviderMetrics } from '@/lib/calculations' -import { toCostChartData, toModelCostChartData, toTokenChartData, toRequestChartData, toWeekdayData } from '@/lib/data-transforms' +import { + toCostChartData, + toModelCostChartData, + toTokenChartData, + toRequestChartData, + toWeekdayData, +} from '@/lib/data-transforms' import { getUniqueModels } from '@/lib/model-utils' -export function useComputedMetrics(data: DailyUsage[], viewMode: ViewMode = 'daily') { +export function useComputedMetrics(data: DailyUsage[]) { const metrics = useMemo(() => computeMetrics(data), [data]) const modelCosts = useMemo(() => computeModelCosts(data), [data]) const providerMetrics = useMemo(() => computeProviderMetrics(data), [data]) @@ -13,7 +19,7 @@ export function useComputedMetrics(data: DailyUsage[], viewMode: ViewMode = 'dai const tokenChartData = useMemo(() => toTokenChartData(data), [data]) const requestChartData = useMemo(() => toRequestChartData(data), [data]) const weekdayData = useMemo(() => toWeekdayData(data), [data]) - const allModels = useMemo(() => getUniqueModels(data.map(d => d.modelsUsed)), [data]) + const allModels = useMemo(() => getUniqueModels(data.map((d) => d.modelsUsed)), [data]) const modelPieData = useMemo(() => { return Array.from(modelCosts.entries()) @@ -21,13 +27,16 @@ export function useComputedMetrics(data: DailyUsage[], viewMode: ViewMode = 'dai .sort((a, b) => b.value - a.value) }, [modelCosts]) - const tokenPieData = useMemo(() => [ - { name: 'Input', value: metrics.totalInput }, - { name: 'Output', value: metrics.totalOutput }, - { name: 'Cache Write', value: metrics.totalCacheCreate }, - { name: 'Cache Read', value: metrics.totalCacheRead }, - { name: 'Thinking', value: metrics.totalThinking }, - ], [metrics]) + const tokenPieData = useMemo( + () => [ + { name: 'Input', value: metrics.totalInput }, + { name: 'Output', value: metrics.totalOutput }, + { name: 'Cache Write', value: metrics.totalCacheCreate }, + { name: 'Cache Read', value: metrics.totalCacheRead }, + { name: 'Thinking', value: metrics.totalThinking }, + ], + [metrics], + ) return { metrics, diff --git a/src/hooks/use-dashboard-filters.ts b/src/hooks/use-dashboard-filters.ts index 9d9773d..85f144d 100644 --- a/src/hooks/use-dashboard-filters.ts +++ b/src/hooks/use-dashboard-filters.ts @@ -1,7 +1,16 @@ 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 { + filterByDateRange, + filterByModels, + filterByMonth, + sortByDate, + getAvailableMonths, + getDateRange, + aggregateToDailyFormat, + filterByProviders, +} from '@/lib/data-transforms' import { toLocalDateStr } from '@/lib/formatters' import { getUniqueModels, getUniqueProviders } from '@/lib/model-utils' @@ -36,18 +45,21 @@ function resolvePresetRange(preset: DashboardDatePreset) { } 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))) + 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)), + 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) { +export function useDashboardFilters( + data: DailyUsage[], + defaultFilters: DashboardDefaultFilters = DEFAULT_DASHBOARD_FILTERS, +) { const resolvedDefaults = useMemo( () => sanitizeDefaultFilters(data, defaultFilters), [data, defaultFilters], @@ -56,32 +68,34 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar () => resolvePresetRange(resolvedDefaults.datePreset), [resolvedDefaults.datePreset], ) - const defaultFiltersKey = useMemo( - () => JSON.stringify(resolvedDefaults), - [resolvedDefaults], - ) + 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 [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]) + 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) { @@ -119,8 +133,8 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar const toggleProvider = useCallback((provider: string) => { userModifiedRef.current = true - setSelectedProvidersState(prev => - prev.includes(provider) ? prev.filter(p => p !== provider) : [...prev, provider] + setSelectedProvidersState((prev) => + prev.includes(provider) ? prev.filter((p) => p !== provider) : [...prev, provider], ) setSelectedModelsState([]) }, []) @@ -133,8 +147,8 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar const toggleModel = useCallback((model: string) => { userModifiedRef.current = true - setSelectedModelsState(prev => - prev.includes(model) ? prev.filter(m => m !== model) : [...prev, model] + setSelectedModelsState((prev) => + prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model], ) }, []) @@ -181,17 +195,31 @@ export function useDashboardFilters(data: DailyUsage[], defaultFilters: Dashboar }, [filteredDailyData, viewModeState]) const availableMonths = useMemo(() => getAvailableMonths(data), [data]) - const availableProviders = useMemo(() => getUniqueProviders(preProviderFilteredData.map(d => d.modelsUsed)), [preProviderFilteredData]) - const availableModels = useMemo(() => getUniqueModels(preModelFilteredData.map(d => d.modelsUsed)), [preModelFilteredData]) + const availableProviders = useMemo( + () => getUniqueProviders(preProviderFilteredData.map((d) => d.modelsUsed)), + [preProviderFilteredData], + ) + const availableModels = useMemo( + () => getUniqueModels(preModelFilteredData.map((d) => d.modelsUsed)), + [preModelFilteredData], + ) const dateRange = useMemo(() => getDateRange(filteredDailyData), [filteredDailyData]) return { - viewMode: viewModeState, setViewMode, - selectedMonth: selectedMonthState, setSelectedMonth, - selectedProviders: selectedProvidersState, toggleProvider, clearProviders, - selectedModels: selectedModelsState, toggleModel, clearModels, - startDate: startDateState, setStartDate, - endDate: endDateState, setEndDate, + viewMode: viewModeState, + setViewMode, + selectedMonth: selectedMonthState, + setSelectedMonth, + selectedProviders: selectedProvidersState, + toggleProvider, + clearProviders, + selectedModels: selectedModelsState, + toggleModel, + clearModels, + startDate: startDateState, + setStartDate, + endDate: endDateState, + setEndDate, resetAll, applyDefaultFilters, applyPreset, diff --git a/src/hooks/use-provider-limits.ts b/src/hooks/use-provider-limits.ts index 03d01a2..90fec20 100644 --- a/src/hooks/use-provider-limits.ts +++ b/src/hooks/use-provider-limits.ts @@ -6,7 +6,7 @@ export function useProviderLimits(availableProviders: string[]) { const [limits, setLimits] = useState({}) useEffect(() => { - setLimits(prev => syncProviderLimits(availableProviders, prev)) + setLimits((prev) => syncProviderLimits(availableProviders, prev)) }, [availableProviders]) return { diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 923c89f..8804b10 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -14,7 +14,7 @@ export function useTheme() { } }, [isDark]) - const toggle = useCallback(() => setIsDark(prev => !prev), []) + const toggle = useCallback(() => setIsDark((prev) => !prev), []) return { isDark, toggle } } diff --git a/src/hooks/use-usage-data.ts b/src/hooks/use-usage-data.ts index 70b2b72..5a84961 100644 --- a/src/hooks/use-usage-data.ts +++ b/src/hooks/use-usage-data.ts @@ -13,7 +13,7 @@ export function useUploadData() { return useMutation({ mutationFn: (data: unknown) => uploadData(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['usage'] }) + void queryClient.invalidateQueries({ queryKey: ['usage'] }) }, }) } @@ -23,7 +23,7 @@ export function useDeleteData() { return useMutation({ mutationFn: deleteUsage, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['usage'] }) + void queryClient.invalidateQueries({ queryKey: ['usage'] }) }, }) } diff --git a/src/index.css b/src/index.css index b821be7..aea50a9 100644 --- a/src/index.css +++ b/src/index.css @@ -1,8 +1,9 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @theme { - --font-sans: "Avenir Next", "Segoe UI Variable", "Segoe UI", "Helvetica Neue", "Arial Nova", sans-serif; - --font-mono: "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + --font-sans: + 'Avenir Next', 'Segoe UI Variable', 'Segoe UI', 'Helvetica Neue', 'Arial Nova', sans-serif; + --font-mono: 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace; --radius: 0.5rem; --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); @@ -34,13 +35,21 @@ } @keyframes accordion-down { - from { height: 0; } - to { height: var(--radix-accordion-content-height); } + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } } @keyframes accordion-up { - from { height: var(--radix-accordion-content-height); } - to { height: 0; } + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } } :root { @@ -156,8 +165,13 @@ body { } @keyframes subtle-glow { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } } .pdf-rendering * { @@ -169,7 +183,7 @@ body { background: hsl(var(--card)) !important; } -.pdf-rendering [class*="backdrop-blur"] { +.pdf-rendering [class*='backdrop-blur'] { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background: hsl(var(--card)) !important; diff --git a/src/lib/api.ts b/src/lib/api.ts index ddd6ff4..9426abb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,10 +13,30 @@ import type { import i18n from '@/lib/i18n' import { normalizeAppSettings } from '@/lib/app-settings' +interface ApiErrorPayload { + message?: string +} + +async function parseResponseJson(response: Response): Promise { + const data: unknown = await response.json() + return data as T +} + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const payload = await parseResponseJson(response) + return typeof payload.message === 'string' && payload.message.trim() + ? payload.message + : fallback + } catch { + return fallback + } +} + export async function fetchUsage(): Promise { const res = await fetch('/api/usage') if (!res.ok) throw new Error(i18n.t('api.fetchUsageFailed')) - return res.json() + return parseResponseJson(res) } export async function uploadData(data: unknown): Promise<{ days: number; totalCost: number }> { @@ -26,10 +46,9 @@ export async function uploadData(data: unknown): Promise<{ days: number; totalCo body: JSON.stringify(data), }) if (!res.ok) { - const err = await res.json().catch(() => ({ message: i18n.t('api.uploadFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.uploadFailed'))) } - return res.json() + return parseResponseJson<{ days: number; totalCost: number }>(res) } export async function deleteUsage(): Promise { @@ -44,10 +63,9 @@ export async function importUsageData(data: unknown): Promise ({ message: i18n.t('api.importUsageFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.importUsageFailed'))) } - return res.json() + return parseResponseJson(res) } export interface UpdateSettingsRequest { @@ -62,7 +80,7 @@ export interface UpdateSettingsRequest { export async function fetchSettings(): Promise { const res = await fetch('/api/settings') if (!res.ok) throw new Error('Failed to load settings') - return normalizeAppSettings(await res.json()) + return normalizeAppSettings(await parseResponseJson(res)) } export async function updateSettings(patch: UpdateSettingsRequest): Promise { @@ -72,10 +90,9 @@ export async function updateSettings(patch: UpdateSettingsRequest): Promise ({ message: 'Failed to save settings' })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, 'Failed to save settings')) } - return normalizeAppSettings(await res.json()) + return normalizeAppSettings(await parseResponseJson(res)) } export async function importSettings(data: unknown): Promise { @@ -85,10 +102,9 @@ export async function importSettings(data: unknown): Promise { body: JSON.stringify(data), }) if (!res.ok) { - const err = await res.json().catch(() => ({ message: i18n.t('api.importSettingsFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.importSettingsFailed'))) } - return normalizeAppSettings(await res.json()) + return normalizeAppSettings(await parseResponseJson(res)) } export interface PdfReportRequest { @@ -109,8 +125,7 @@ export async function generatePdfReport(request: PdfReportRequest): Promise ({ message: i18n.t('api.pdfFailed') })) - throw new Error(err.message) + throw new Error(await readErrorMessage(res, i18n.t('api.pdfFailed'))) } return res.blob() diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index 6899179..f7a4595 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -41,9 +41,7 @@ export function normalizeStoredProviderLimits(value: unknown): ProviderLimits { } export function normalizeDataLoadSource(value: unknown): DataLoadSource { - return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' - ? value - : null + return value === 'file' || value === 'auto-import' || value === 'cli-auto-load' ? value : null } export function normalizeStoredTimestamp(value: unknown): string | null { @@ -56,7 +54,7 @@ export function normalizeStoredTimestamp(value: unknown): string | null { } export function normalizeAppSettings(value: unknown): AppSettings { - const source = value && typeof value === 'object' ? value as Partial : {} + const source = value && typeof value === 'object' ? (value as Partial) : {} return { language: normalizeAppLanguage(source.language), diff --git a/src/lib/auto-import.ts b/src/lib/auto-import.ts index 2f8d0e3..5ff00e1 100644 --- a/src/lib/auto-import.ts +++ b/src/lib/auto-import.ts @@ -1,21 +1,57 @@ -export interface CheckEvent { tool: string; status: string; method?: string; version?: string } -export interface ProgressEvent { message: string } -export interface StderrEvent { line: string } -export interface SuccessEvent { days: number; totalCost: number } -export interface ErrorEvent { message: string } +export interface CheckEvent { + tool: string + status: string + method?: string + version?: string +} +export interface ProgressEvent { + message: string +} +export interface StderrEvent { + line: string +} +export interface SuccessEvent { + days: number + totalCost: number +} +export interface ErrorEvent { + message: string +} -function translateAutoImportMessage(message, t) { +type AutoImportTranslationVars = Record +type AutoImportTranslator = (key: string, vars?: AutoImportTranslationVars) => string + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseEventData(event: Event): T | null { + if (!(event instanceof MessageEvent) || typeof event.data !== 'string') { + return null + } + + try { + const data: unknown = JSON.parse(event.data) + return isPlainObject(data) ? (data as T) : null + } catch { + return null + } +} + +function translateAutoImportMessage(message: string, t: AutoImportTranslator) { if (message === 'Starte lokalen toktrack-Import...') { return t('autoImportModal.startingLocalImport') } if (message.startsWith('Lade Nutzungsdaten via ')) { - return t('autoImportModal.loadingUsageData', { command: message.replace('Lade Nutzungsdaten via ', '').replace(/\.\.\.$/, '') }) + return t('autoImportModal.loadingUsageData', { + command: message.replace('Lade Nutzungsdaten via ', '').replace(/\.\.\.$/, ''), + }) } const processingMatch = message.match(/^Verarbeite Nutzungsdaten\.\.\. \((\d+)s\)$/) if (processingMatch) { - return t('autoImportModal.processingUsageData', { seconds: processingMatch[1] }) + return t('autoImportModal.processingUsageData', { seconds: processingMatch[1] ?? '0' }) } if (message === 'Verbindung zum Server verloren.') { @@ -37,34 +73,47 @@ function translateAutoImportMessage(message, t) { return message } -export function startAutoImport(callbacks: { - onCheck: (data: CheckEvent) => void - onProgress: (data: ProgressEvent) => void - onStderr: (data: StderrEvent) => void - onSuccess: (data: SuccessEvent) => void - onError: (data: ErrorEvent) => void - onDone: () => void -}, t = (key, vars) => key): { close: () => void } { +export function startAutoImport( + callbacks: { + onCheck: (data: CheckEvent) => void + onProgress: (data: ProgressEvent) => void + onStderr: (data: StderrEvent) => void + onSuccess: (data: SuccessEvent) => void + onError: (data: ErrorEvent) => void + onDone: () => void + }, + t: AutoImportTranslator = (key) => key, +): { close: () => void } { const es = new EventSource('/api/auto-import/stream') - es.addEventListener('check', (e) => { - callbacks.onCheck(JSON.parse(e.data)) + es.addEventListener('check', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onCheck(data) + } }) - es.addEventListener('progress', (e) => { - const data = JSON.parse(e.data) - callbacks.onProgress({ ...data, message: translateAutoImportMessage(data.message, t) }) + es.addEventListener('progress', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onProgress({ ...data, message: translateAutoImportMessage(data.message, t) }) + } }) - es.addEventListener('stderr', (e) => { - const data = JSON.parse(e.data) - callbacks.onStderr({ ...data, line: translateAutoImportMessage(data.line, t) }) + es.addEventListener('stderr', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onStderr({ ...data, line: translateAutoImportMessage(data.line, t) }) + } }) - es.addEventListener('success', (e) => { - callbacks.onSuccess(JSON.parse(e.data)) + es.addEventListener('success', (event) => { + const data = parseEventData(event) + if (data) { + callbacks.onSuccess(data) + } }) - es.addEventListener('error', (e) => { + es.addEventListener('error', (event) => { // SSE 'error' can be both our custom event and a connection error - if (e instanceof MessageEvent && e.data) { - const data = JSON.parse(e.data) + const data = parseEventData(event) + if (data) { callbacks.onError({ ...data, message: translateAutoImportMessage(data.message, t) }) } else { callbacks.onError({ message: t('autoImportModal.serverConnectionLost') }) diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 8639b0e..c598f83 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -1,21 +1,56 @@ -import type { AggregateMetrics, CacheHitRateByModelChartDataPoint, DailyUsage, DashboardMetrics } from '@/types' +import type { + AggregateMetrics, + CacheHitRateByModelChartDataPoint, + DailyUsage, + DashboardMetrics, +} from '@/types' import { getModelProvider, normalizeModelName } from './model-utils' export function computeMetrics(data: DailyUsage[]): DashboardMetrics { if (data.length === 0) { return { - totalCost: 0, totalTokens: 0, activeDays: 0, topModel: null, - topRequestModel: null, topTokenModel: null, - topModelShare: 0, topThreeModelsShare: 0, topProvider: null, providerCount: 0, hasRequestData: false, - cacheHitRate: 0, costPerMillion: 0, avgTokensPerRequest: 0, avgCostPerRequest: 0, avgModelsPerEntry: 0, avgDailyCost: 0, avgRequestsPerDay: 0, - topDay: null, cheapestDay: null, busiestWeek: null, weekendCostShare: null, totalInput: 0, totalOutput: 0, - totalCacheRead: 0, totalCacheCreate: 0, totalThinking: 0, totalRequests: 0, weekOverWeekChange: null, - requestVolatility: 0, modelConcentrationIndex: 0, providerConcentrationIndex: 0, + totalCost: 0, + totalTokens: 0, + activeDays: 0, + topModel: null, + topRequestModel: null, + topTokenModel: null, + topModelShare: 0, + topThreeModelsShare: 0, + topProvider: null, + providerCount: 0, + hasRequestData: false, + cacheHitRate: 0, + costPerMillion: 0, + avgTokensPerRequest: 0, + avgCostPerRequest: 0, + avgModelsPerEntry: 0, + avgDailyCost: 0, + avgRequestsPerDay: 0, + topDay: null, + cheapestDay: null, + busiestWeek: null, + weekendCostShare: null, + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + totalThinking: 0, + totalRequests: 0, + weekOverWeekChange: null, + requestVolatility: 0, + modelConcentrationIndex: 0, + providerConcentrationIndex: 0, } } - let topDay = { date: data[0].date, cost: data[0].totalCost } - let cheapestDay = { date: data[0].date, cost: data[0].totalCost } + const firstDay = data[0] + if (!firstDay) { + throw new Error('computeMetrics received empty data unexpectedly') + } + + let topDay = { date: firstDay.date, cost: firstDay.totalCost } + let cheapestDay = { date: firstDay.date, cost: firstDay.totalCost } let totalCost = 0 let totalTokens = 0 let totalInput = 0 @@ -43,7 +78,8 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { totalCacheCreate += d.cacheCreationTokens totalThinking += d.thinkingTokens totalRequests += d.requestCount - if (d.requestCount > 0 || d.modelBreakdowns.some(mb => mb.requestCount > 0)) hasRequestData = true + if (d.requestCount > 0 || d.modelBreakdowns.some((mb) => mb.requestCount > 0)) + hasRequestData = true activeDays += d._aggregatedDays ?? 1 totalModelsUsed += d.modelsUsed.length @@ -58,7 +94,15 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { for (const mb of d.modelBreakdowns) { const name = normalizeModelName(mb.modelName) modelCosts.set(name, (modelCosts.get(name) ?? 0) + mb.cost) - modelTokens.set(name, (modelTokens.get(name) ?? 0) + mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens + mb.thinkingTokens) + modelTokens.set( + name, + (modelTokens.get(name) ?? 0) + + mb.inputTokens + + mb.outputTokens + + mb.cacheCreationTokens + + mb.cacheReadTokens + + mb.thinkingTokens, + ) modelRequests.set(name, (modelRequests.get(name) ?? 0) + mb.requestCount) const provider = getModelProvider(mb.modelName) providerCosts.set(provider, (providerCosts.get(provider) ?? 0) + mb.cost) @@ -80,16 +124,23 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { } let topRequestModel: { name: string; requests: number } | null = null for (const [name, requests] of modelRequests) { - if (!topRequestModel || requests > topRequestModel.requests) topRequestModel = { name, requests } + if (!topRequestModel || requests > topRequestModel.requests) + topRequestModel = { name, requests } } let topTokenModel: { name: string; tokens: number } | null = null for (const [name, tokens] of modelTokens) { if (!topTokenModel || tokens > topTokenModel.tokens) topTokenModel = { name, tokens } } const topModelShare = topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0 - const topThreeModelsShare = totalCost > 0 - ? [...modelCosts.values()].sort((a, b) => b - a).slice(0, 3).reduce((sum, value) => sum + value, 0) / totalCost * 100 - : 0 + const topThreeModelsShare = + totalCost > 0 + ? ([...modelCosts.values()] + .sort((a, b) => b - a) + .slice(0, 3) + .reduce((sum, value) => sum + value, 0) / + totalCost) * + 100 + : 0 let topProvider: { name: string; cost: number; share: number } | null = null for (const [name, cost] of providerCosts) { @@ -100,37 +151,67 @@ export function computeMetrics(data: DailyUsage[]): DashboardMetrics { const busiestWeek = computeBusiestWeek(data) const weekendCostShare = weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null - const requestValues = data.map(entry => entry.requestCount) + const requestValues = data.map((entry) => entry.requestCount) const requestVolatility = stdDev(requestValues) - const modelConcentrationIndex = totalCost > 0 - ? [...modelCosts.values()].reduce((sum, cost) => { - const share = cost / totalCost - return sum + share * share - }, 0) - : 0 - const providerConcentrationIndex = totalCost > 0 - ? [...providerCosts.values()].reduce((sum, cost) => { - const share = cost / totalCost - return sum + share * share - }, 0) - : 0 + const modelConcentrationIndex = + totalCost > 0 + ? [...modelCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 + const providerConcentrationIndex = + totalCost > 0 + ? [...providerCosts.values()].reduce((sum, cost) => { + const share = cost / totalCost + return sum + share * share + }, 0) + : 0 // Week-over-week change const weekOverWeekChange = computeWeekOverWeekChange(data) return { - totalCost, totalTokens, activeDays, topModel, topRequestModel, topTokenModel, topModelShare, topThreeModelsShare, topProvider, providerCount: providerCosts.size, hasRequestData, cacheHitRate, - costPerMillion, avgTokensPerRequest, avgCostPerRequest, avgModelsPerEntry, avgDailyCost, avgRequestsPerDay, topDay, cheapestDay, busiestWeek, weekendCostShare, - totalInput, totalOutput, totalCacheRead, totalCacheCreate, - totalThinking, totalRequests, + totalCost, + totalTokens, + activeDays, + topModel, + topRequestModel, + topTokenModel, + topModelShare, + topThreeModelsShare, + topProvider, + providerCount: providerCosts.size, + hasRequestData, + cacheHitRate, + costPerMillion, + avgTokensPerRequest, + avgCostPerRequest, + avgModelsPerEntry, + avgDailyCost, + avgRequestsPerDay, + topDay, + cheapestDay, + busiestWeek, + weekendCostShare, + totalInput, + totalOutput, + totalCacheRead, + totalCacheCreate, + totalThinking, + totalRequests, weekOverWeekChange, - requestVolatility, modelConcentrationIndex, providerConcentrationIndex, + requestVolatility, + modelConcentrationIndex, + providerConcentrationIndex, } } -function computeBusiestWeek(data: DailyUsage[]): { start: string; end: string; cost: number } | null { +function computeBusiestWeek( + data: DailyUsage[], +): { start: string; end: string; cost: number } | null { const sorted = data - .filter(entry => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) + .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) .sort((a, b) => a.date.localeCompare(b.date)) if (sorted.length < 3) return null @@ -138,21 +219,28 @@ function computeBusiestWeek(data: DailyUsage[]): { start: string; end: string; c let bestWindow: { start: string; end: string; cost: number } | null = null for (let start = 0; start < sorted.length; start++) { - const startDate = new Date(`${sorted[start].date}T00:00:00`) + const startEntry = sorted[start] + if (!startEntry) continue + + const startDate = new Date(`${startEntry.date}T00:00:00`) const endLimit = new Date(startDate) endLimit.setDate(endLimit.getDate() + 6) let windowCost = 0 let end = start - while (end < sorted.length && new Date(`${sorted[end].date}T00:00:00`) <= endLimit) { - windowCost += sorted[end].totalCost + while (end < sorted.length) { + const endEntry = sorted[end] + if (!endEntry) break + if (new Date(`${endEntry.date}T00:00:00`) > endLimit) break + windowCost += endEntry.totalCost end++ } - if (!bestWindow || windowCost > bestWindow.cost) { + const finalEntry = sorted[end - 1] + if (finalEntry && (!bestWindow || windowCost > bestWindow.cost)) { bestWindow = { - start: sorted[start].date, - end: sorted[end - 1].date, + start: startEntry.date, + end: finalEntry.date, cost: windowCost, } } @@ -162,7 +250,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.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) @@ -174,14 +262,23 @@ export function computeWeekOverWeekChange(data: DailyUsage[]): number | null { } export function computeMovingAverage(values: number[], window = 7): (number | undefined)[] { - const result: (number | undefined)[] = new Array(values.length) + const result = Array(values.length) let sum = 0 for (let i = 0; i < values.length; i++) { - sum += values[i] + const currentValue = values[i] + if (currentValue === undefined) { + result[i] = undefined + continue + } + + sum += currentValue if (i >= window) { - sum -= values[i - window] + const outgoingValue = values[i - window] + if (outgoingValue !== undefined) { + sum -= outgoingValue + } } result[i] = i < window - 1 ? undefined : sum / window @@ -190,21 +287,58 @@ export function computeMovingAverage(values: number[], window = 7): (number | un return result } -export function computeModelCosts(data: DailyUsage[]): Map { - const map = new Map - }>() +export function computeModelCosts(data: DailyUsage[]): Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + days: number + } +> { + const map = new Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + days: number + _dates: Set + } + >() for (const d of data) { const entryDays = d._aggregatedDays ?? 1 for (const mb of d.modelBreakdowns) { const name = normalizeModelName(mb.modelName) - const existing = map.get(name) ?? { cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreate: 0, thinking: 0, requests: 0, days: 0, _dates: new Set() } + const existing = map.get(name) ?? { + cost: 0, + tokens: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheCreate: 0, + thinking: 0, + requests: 0, + days: 0, + _dates: new Set(), + } existing.cost += mb.cost - existing.tokens += mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens + mb.thinkingTokens + existing.tokens += + mb.inputTokens + + mb.outputTokens + + mb.cacheCreationTokens + + mb.cacheReadTokens + + mb.thinkingTokens existing.input += mb.inputTokens existing.output += mb.outputTokens existing.cacheRead += mb.cacheReadTokens @@ -249,7 +383,12 @@ export function computeProviderMetrics(data: DailyUsage[]): Map { - const { _dates: _unusedDates, ...metrics } = value - return [provider, metrics] - })) + return new Map( + Array.from(map.entries()).map(([provider, value]) => [ + provider, + { + cost: value.cost, + tokens: value.tokens, + input: value.input, + output: value.output, + cacheRead: value.cacheRead, + cacheCreate: value.cacheCreate, + thinking: value.thinking, + requests: value.requests, + days: value.days, + }, + ]), + ) } -function computeCacheHitRate(cacheRead: number, cacheCreate: number, input: number, output: number, thinking: number): number { +function computeCacheHitRate( + cacheRead: number, + cacheCreate: number, + input: number, + output: number, + thinking: number, +): number { const base = cacheRead + cacheCreate + input + output + thinking return base > 0 ? (cacheRead / base) * 100 : 0 } -export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByModelChartDataPoint[] { +export function computeCacheHitRateByModel( + data: DailyUsage[], +): CacheHitRateByModelChartDataPoint[] { if (data.length === 0) return [] const sorted = [...data] - .filter(entry => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) + .filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date)) .sort((a, b) => a.date.localeCompare(b.date)) if (sorted.length === 0) return [] const trailingWindow = sorted.slice(-Math.min(7, sorted.length)) - const totals = new Map() - const trailing = new Map() + const totals = new Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >() + const trailing = new Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >() const updateMetricMap = ( - target: Map, + target: Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >, modelName: string, cacheRead: number, cacheCreate: number, @@ -292,7 +460,13 @@ export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByMo thinking: number, ) => { const key = normalizeModelName(modelName) - const current = target.get(key) ?? { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 } + const current = target.get(key) ?? { + cacheRead: 0, + cacheCreate: 0, + input: 0, + output: 0, + thinking: 0, + } current.cacheRead += cacheRead current.cacheCreate += cacheCreate current.input += input @@ -329,39 +503,94 @@ export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByMo } } - const sumMetricMap = (source: Map) => ( - Array.from(source.values()).reduce((acc, metric) => ({ - cacheRead: acc.cacheRead + metric.cacheRead, - cacheCreate: acc.cacheCreate + metric.cacheCreate, - input: acc.input + metric.input, - output: acc.output + metric.output, - thinking: acc.thinking + metric.thinking, - }), { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 }) - ) + const sumMetricMap = ( + source: Map< + string, + { cacheRead: number; cacheCreate: number; input: number; output: number; thinking: number } + >, + ) => + Array.from(source.values()).reduce( + (acc, metric) => ({ + cacheRead: acc.cacheRead + metric.cacheRead, + cacheCreate: acc.cacheCreate + metric.cacheCreate, + input: acc.input + metric.input, + output: acc.output + metric.output, + thinking: acc.thinking + metric.thinking, + }), + { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 }, + ) const totalAll = sumMetricMap(totals) const trailingAll = sumMetricMap(trailing) - const rows: CacheHitRateByModelChartDataPoint[] = [{ - model: 'Total', - totalRate: computeCacheHitRate(totalAll.cacheRead, totalAll.cacheCreate, totalAll.input, totalAll.output, totalAll.thinking), - trailing7Rate: computeCacheHitRate(trailingAll.cacheRead, trailingAll.cacheCreate, trailingAll.input, trailingAll.output, trailingAll.thinking), - totalBaseTokens: totalAll.cacheRead + totalAll.cacheCreate + totalAll.input + totalAll.output + totalAll.thinking, - trailing7BaseTokens: trailingAll.cacheRead + trailingAll.cacheCreate + trailingAll.input + trailingAll.output + trailingAll.thinking, - }] + const rows: CacheHitRateByModelChartDataPoint[] = [ + { + model: 'Total', + totalRate: computeCacheHitRate( + totalAll.cacheRead, + totalAll.cacheCreate, + totalAll.input, + totalAll.output, + totalAll.thinking, + ), + trailing7Rate: computeCacheHitRate( + trailingAll.cacheRead, + trailingAll.cacheCreate, + trailingAll.input, + trailingAll.output, + trailingAll.thinking, + ), + totalBaseTokens: + totalAll.cacheRead + + totalAll.cacheCreate + + totalAll.input + + totalAll.output + + totalAll.thinking, + trailing7BaseTokens: + trailingAll.cacheRead + + trailingAll.cacheCreate + + trailingAll.input + + trailingAll.output + + trailingAll.thinking, + }, + ] const modelRows = Array.from(totals.entries()) .map(([model, metric]) => { - const trailingMetric = trailing.get(model) ?? { cacheRead: 0, cacheCreate: 0, input: 0, output: 0, thinking: 0 } + const trailingMetric = trailing.get(model) ?? { + cacheRead: 0, + cacheCreate: 0, + input: 0, + output: 0, + thinking: 0, + } return { model, - totalRate: computeCacheHitRate(metric.cacheRead, metric.cacheCreate, metric.input, metric.output, metric.thinking), - trailing7Rate: computeCacheHitRate(trailingMetric.cacheRead, trailingMetric.cacheCreate, trailingMetric.input, trailingMetric.output, trailingMetric.thinking), - totalBaseTokens: metric.cacheRead + metric.cacheCreate + metric.input + metric.output + metric.thinking, - trailing7BaseTokens: trailingMetric.cacheRead + trailingMetric.cacheCreate + trailingMetric.input + trailingMetric.output + trailingMetric.thinking, + totalRate: computeCacheHitRate( + metric.cacheRead, + metric.cacheCreate, + metric.input, + metric.output, + metric.thinking, + ), + trailing7Rate: computeCacheHitRate( + trailingMetric.cacheRead, + trailingMetric.cacheCreate, + trailingMetric.input, + trailingMetric.output, + trailingMetric.thinking, + ), + totalBaseTokens: + metric.cacheRead + metric.cacheCreate + metric.input + metric.output + metric.thinking, + trailing7BaseTokens: + trailingMetric.cacheRead + + trailingMetric.cacheCreate + + trailingMetric.input + + trailingMetric.output + + trailingMetric.thinking, } }) - .filter(entry => entry.totalBaseTokens > 0) + .filter((entry) => entry.totalBaseTokens > 0) .sort((a, b) => b.totalBaseTokens - a.totalBaseTokens) return [...rows, ...modelRows] @@ -369,21 +598,26 @@ export function computeCacheHitRateByModel(data: DailyUsage[]): CacheHitRateByMo export function computeAnomalies(data: DailyUsage[], threshold = 2): DailyUsage[] { if (data.length < 3) return [] - const costs = data.map(d => d.totalCost) + const costs = data.map((d) => d.totalCost) const mean = costs.reduce((s, v) => s + v, 0) / costs.length const stdDev = Math.sqrt(costs.reduce((s, v) => s + (v - mean) ** 2, 0) / costs.length) if (stdDev === 0) return [] - return data.filter(d => Math.abs(d.totalCost - mean) > threshold * stdDev) + return data.filter((d) => Math.abs(d.totalCost - mean) > threshold * stdDev) } export function linearRegression(values: number[]): { slope: number; intercept: number } { const n = values.length if (n < 2) return { slope: 0, intercept: values[0] ?? 0 } - let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0 + let sumX = 0, + sumY = 0, + sumXY = 0, + sumXX = 0 for (let i = 0; i < n; i++) { + const value = values[i] + if (value === undefined) continue sumX += i - sumY += values[i] - sumXY += i * values[i] + sumY += value + sumXY += i * value sumXX += i * i } const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) @@ -408,9 +642,12 @@ function quantile(values: number[], q: number): number { const index = (sorted.length - 1) * q const lower = Math.floor(index) const upper = Math.ceil(index) - if (lower === upper) return sorted[lower] + const lowerValue = sorted[lower] + const upperValue = sorted[upper] + if (lowerValue === undefined || upperValue === undefined) return 0 + if (lower === upper) return lowerValue const weight = index - lower - return sorted[lower] * (1 - weight) + sorted[upper] * weight + return lowerValue * (1 - weight) + upperValue * weight } function winsorizedAverage(values: number[], limit = 0.15): number { @@ -418,7 +655,7 @@ function winsorizedAverage(values: number[], limit = 0.15): number { if (values.length < 4) return average(values) const low = quantile(values, limit) const high = quantile(values, 1 - limit) - return average(values.map(value => Math.min(high, Math.max(low, value)))) + return average(values.map((value) => Math.min(high, Math.max(low, value)))) } function clamp(value: number, min: number, max: number): number { @@ -429,14 +666,17 @@ export function computeCurrentMonthForecast(data: DailyUsage[]) { if (data.length < 2) return null const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)) - const lastDate = new Date(sorted[sorted.length - 1].date + 'T00:00:00') - const currentMonth = sorted[sorted.length - 1].date.slice(0, 7) - const monthData = sorted.filter(d => d.date.startsWith(currentMonth)) + const lastEntry = sorted[sorted.length - 1] + if (!lastEntry) return null + + const lastDate = new Date(lastEntry.date + 'T00:00:00') + const currentMonth = lastEntry.date.slice(0, 7) + const monthData = sorted.filter((d) => d.date.startsWith(currentMonth)) if (monthData.length < 2) return null const monthTotal = monthData.reduce((sum, day) => sum + day.totalCost, 0) - const monthCostMap = new Map(monthData.map(day => [day.date, day.totalCost])) + const monthCostMap = new Map(monthData.map((day) => [day.date, day.totalCost])) const daysInMonth = new Date(lastDate.getFullYear(), lastDate.getMonth() + 1, 0).getDate() const elapsedDays = lastDate.getDate() const remainingDays = Math.max(0, daysInMonth - elapsedDays) @@ -450,24 +690,30 @@ export function computeCurrentMonthForecast(data: DailyUsage[]) { } }) - const elapsedCosts = elapsedCalendarSeries.map(point => point.cost) + const elapsedCosts = elapsedCalendarSeries.map((point) => point.cost) const monthToDateAvg = monthTotal / elapsedDays const recentWindow = elapsedCosts.slice(-Math.min(7, elapsedCosts.length)) - const previousWindow = elapsedCosts.slice(-Math.min(14, elapsedCosts.length), -Math.min(7, elapsedCosts.length)) + const previousWindow = elapsedCosts.slice( + -Math.min(14, elapsedCosts.length), + -Math.min(7, elapsedCosts.length), + ) const recentAvg = winsorizedAverage(recentWindow) const previousAvg = previousWindow.length > 0 ? winsorizedAverage(previousWindow) : 0 - const trendAdjustment = previousAvg > 0 - ? clamp((recentAvg - previousAvg) / previousAvg, -0.35, 0.35) * 0.25 - : 0 - const projectedDailyBurn = Math.max(0, (monthToDateAvg * 0.6 + recentAvg * 0.4) * (1 + trendAdjustment)) + const trendAdjustment = + previousAvg > 0 ? clamp((recentAvg - previousAvg) / previousAvg, -0.35, 0.35) * 0.25 : 0 + const projectedDailyBurn = Math.max( + 0, + (monthToDateAvg * 0.6 + recentAvg * 0.4) * (1 + trendAdjustment), + ) const volatility = stdDev(recentWindow.length >= 4 ? recentWindow : elapsedCosts) const lowerDaily = Math.max(0, projectedDailyBurn - volatility) const upperDaily = projectedDailyBurn + volatility const forecastTotal = monthTotal + projectedDailyBurn * remainingDays - const dailyAvgTrend = previousAvg > 0 - ? { avg: recentAvg, change: ((recentAvg - previousAvg) / previousAvg) * 100 } - : { avg: recentAvg, change: 0 } + const dailyAvgTrend = + previousAvg > 0 + ? { avg: recentAvg, change: ((recentAvg - previousAvg) / previousAvg) * 100 } + : { avg: recentAvg, change: 0 } let confidence = 'low' if (elapsedDays >= 14 && volatility <= projectedDailyBurn * 0.75) confidence = 'high' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a0a9c0..75f0193 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -9,8 +9,8 @@ export const MODEL_COLORS: Record = { 'GPT-5.4': 'hsl(12, 78%, 56%)', 'GPT-5': 'hsl(12, 78%, 56%)', 'Gemini 3 Flash Preview': 'hsl(48, 92%, 50%)', - 'Gemini': 'hsl(48, 92%, 50%)', - 'OpenCode': 'hsl(186, 58%, 48%)', + Gemini: 'hsl(48, 92%, 50%)', + OpenCode: 'hsl(186, 58%, 48%)', } export const MODEL_COLOR_DEFAULT = 'hsl(220, 8%, 56%)' @@ -23,7 +23,10 @@ export const VIEW_MODE_LABELS = { yearly: 'Jahresansicht', } as const -export const MODEL_PRICES: Record = { +export const MODEL_PRICES: Record< + string, + { input: number; output: number; cacheRead: number; cacheWrite: number } +> = { 'Opus 4.6': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, 'Opus 4.5': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, 'Sonnet 4.6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, @@ -32,6 +35,6 @@ export const MODEL_PRICES: Record { + const header = + 'date,totalCost,totalTokens,inputTokens,outputTokens,cacheCreationTokens,cacheReadTokens,thinkingTokens,requestCount,models' + const rows = data.map((d) => { const models = d.modelBreakdowns - .map(mb => normalizeModelName(mb.modelName)) + .map((mb) => normalizeModelName(mb.modelName)) .filter((v, i, a) => a.indexOf(v) === i) .join('; ') return `${d.date},${d.totalCost.toFixed(2)},${d.totalTokens},${d.inputTokens},${d.outputTokens},${d.cacheCreationTokens},${d.cacheReadTokens},${d.thinkingTokens},${d.requestCount},"${models}"` diff --git a/src/lib/dashboard-preferences.ts b/src/lib/dashboard-preferences.ts index e7d40ef..3267f93 100644 --- a/src/lib/dashboard-preferences.ts +++ b/src/lib/dashboard-preferences.ts @@ -21,12 +21,28 @@ export const DASHBOARD_SECTION_DEFINITIONS: DashboardSectionDefinition[] = [ { 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: '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: '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' }, ] @@ -42,10 +58,13 @@ export const DEFAULT_DASHBOARD_FILTERS: DashboardDefaultFilters = { } export function getDefaultDashboardSectionVisibility(): DashboardSectionVisibility { - return DASHBOARD_SECTION_DEFINITIONS.reduce((visibility, section) => ({ - ...visibility, - [section.id]: true, - }), {} as DashboardSectionVisibility) + return DASHBOARD_SECTION_DEFINITIONS.reduce( + (visibility, section) => ({ + ...visibility, + [section.id]: true, + }), + {} as DashboardSectionVisibility, + ) } export function getDefaultDashboardSectionOrder(): DashboardSectionOrder { @@ -55,26 +74,29 @@ export function getDefaultDashboardSectionOrder(): DashboardSectionOrder { 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))] + 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 + ? (value as DashboardDatePreset) : 'all' } export function normalizeDashboardViewMode(value: unknown): ViewMode { - return DASHBOARD_VIEW_MODES.includes(value as ViewMode) - ? value as ViewMode - : 'daily' + 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 : {} + const source = + value && typeof value === 'object' ? (value as Partial) : {} return { viewMode: normalizeDashboardViewMode(source.viewMode), @@ -85,13 +107,20 @@ export function normalizeDashboardDefaultFilters(value: unknown): DashboardDefau } export function normalizeDashboardSectionVisibility(value: unknown): DashboardSectionVisibility { - const source = value && typeof value === 'object' ? value as Partial : {} + 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) + 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 { @@ -101,9 +130,10 @@ export function normalizeDashboardSectionOrder(value: unknown): DashboardSection return defaults } - const incoming = value.filter((sectionId): sectionId is DashboardSectionId => ( - typeof sectionId === 'string' && defaults.includes(sectionId as DashboardSectionId) - )) + 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)) diff --git a/src/lib/data-transforms.ts b/src/lib/data-transforms.ts index 175c71a..1b6a490 100644 --- a/src/lib/data-transforms.ts +++ b/src/lib/data-transforms.ts @@ -1,9 +1,19 @@ -import type { DailyUsage, ChartDataPoint, TokenChartDataPoint, RequestChartDataPoint, WeekdayData, ViewMode } from '@/types' +import type { + DailyUsage, + ChartDataPoint, + TokenChartDataPoint, + RequestChartDataPoint, + WeekdayData, + ViewMode, +} from '@/types' import { computeMovingAverage } from './calculations' import { getModelProvider, normalizeModelName } from './model-utils' import { getCurrentLocale } from './i18n' -function recalculateDayFromBreakdowns(day: DailyUsage, filteredBreakdowns: DailyUsage['modelBreakdowns']): DailyUsage { +function recalculateDayFromBreakdowns( + day: DailyUsage, + filteredBreakdowns: DailyUsage['modelBreakdowns'], +): DailyUsage { let totalCost = 0 let inputTokens = 0 let outputTokens = 0 @@ -25,7 +35,8 @@ function recalculateDayFromBreakdowns(day: DailyUsage, filteredBreakdowns: Daily return { ...day, totalCost, - totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, + totalTokens: + inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens, inputTokens, outputTokens, cacheCreationTokens, @@ -33,12 +44,12 @@ function recalculateDayFromBreakdowns(day: DailyUsage, filteredBreakdowns: Daily thinkingTokens, requestCount, modelBreakdowns: filteredBreakdowns, - modelsUsed: filteredBreakdowns.map(mb => mb.modelName), + modelsUsed: [...new Set(filteredBreakdowns.map((mb) => normalizeModelName(mb.modelName)))], } } export function filterByDateRange(data: DailyUsage[], start?: string, end?: string): DailyUsage[] { - return data.filter(d => { + return data.filter((d) => { if (start && d.date < start) return false if (end && d.date > end) return false return true @@ -50,9 +61,9 @@ export function filterByModels(data: DailyUsage[], selectedModels: string[]): Da const selected = new Set(selectedModels) return data - .map(d => { - const filteredBreakdowns = d.modelBreakdowns.filter(mb => - selected.has(normalizeModelName(mb.modelName)) + .map((d) => { + const filteredBreakdowns = d.modelBreakdowns.filter((mb) => + selected.has(normalizeModelName(mb.modelName)), ) if (filteredBreakdowns.length === 0) return null @@ -66,9 +77,9 @@ export function filterByProviders(data: DailyUsage[], selectedProviders: string[ const selected = new Set(selectedProviders) return data - .map(d => { - const filteredBreakdowns = d.modelBreakdowns.filter(mb => - selected.has(getModelProvider(mb.modelName)) + .map((d) => { + const filteredBreakdowns = d.modelBreakdowns.filter((mb) => + selected.has(getModelProvider(mb.modelName)), ) if (filteredBreakdowns.length === 0) return null @@ -79,7 +90,7 @@ export function filterByProviders(data: DailyUsage[], selectedProviders: string[ export function filterByMonth(data: DailyUsage[], month: string | null): DailyUsage[] { if (!month) return data - return data.filter(d => d.date.startsWith(month)) + return data.filter((d) => d.date.startsWith(month)) } export function sortByDate(data: DailyUsage[]): DailyUsage[] { @@ -96,10 +107,21 @@ export function getAvailableMonths(data: DailyUsage[]): string[] { export function getDateRange(data: DailyUsage[]): { start: string; end: string } | null { if (data.length === 0) return null - let start = data[0].date - let end = data[0].date - for (let i = 1; i < data.length; i++) { - const date = data[i].date + let firstEntry: DailyUsage | null = null + for (const entry of data) { + if (entry) { + firstEntry = entry + break + } + } + if (!firstEntry) return null + + let start = firstEntry.date + let end = firstEntry.date + for (let i = 0; i < data.length; i++) { + const entry = data[i] + if (!entry) continue + const date = entry.date if (date < start) start = date if (date > end) end = date } @@ -108,22 +130,27 @@ export function getDateRange(data: DailyUsage[]): { start: string; end: string } export function toCostChartData(data: DailyUsage[]): ChartDataPoint[] { const sorted = sortByDate(data) - const costs = sorted.map(d => d.totalCost) + const costs = sorted.map((d) => d.totalCost) const ma7 = computeMovingAverage(costs) let cumulative = 0 return sorted.map((d, i) => { cumulative += d.totalCost - return { + const point: ChartDataPoint = { date: d.date, cost: d.totalCost, - costPrev: i > 0 ? sorted[i - 1].totalCost : undefined, - ma7: ma7[i], cumulative, } + const previousPoint = i > 0 ? sorted[i - 1] : undefined + const costPrev = previousPoint?.totalCost ?? null + if (costPrev !== null) point.costPrev = costPrev + if (ma7[i] !== undefined) point.ma7 = ma7[i] + return point }) } -export function toModelCostChartData(data: DailyUsage[]): (ChartDataPoint & Record)[] { +export function toModelCostChartData( + data: DailyUsage[], +): (ChartDataPoint & Record)[] { const sorted = sortByDate(data) const allModels = new Set() for (const d of sorted) { @@ -144,13 +171,16 @@ export function toModelCostChartData(data: DailyUsage[]): (ChartDataPoint & Reco dayCosts[name] = (dayCosts[name] ?? 0) + mb.cost } for (const name of modelNames) { - modelCostsArrays[name].push(dayCosts[name] ?? 0) + const costsForModel = modelCostsArrays[name] + if (costsForModel) { + costsForModel.push(dayCosts[name] ?? 0) + } } } const modelMA7: Record = {} for (const name of modelNames) { - modelMA7[name] = computeMovingAverage(modelCostsArrays[name]) + modelMA7[name] = computeMovingAverage(modelCostsArrays[name] ?? []) } return sorted.map((d, i) => { @@ -161,7 +191,7 @@ export function toModelCostChartData(data: DailyUsage[]): (ChartDataPoint & Reco } for (const name of modelNames) { if (!(name in point)) point[name] = 0 - point[`${name}_ma7`] = modelMA7[name][i] + point[`${name}_ma7`] = modelMA7[name]?.[i] } return point as ChartDataPoint & Record }) @@ -169,39 +199,44 @@ export function toModelCostChartData(data: DailyUsage[]): (ChartDataPoint & Reco export function toTokenChartData(data: DailyUsage[]): TokenChartDataPoint[] { const sorted = sortByDate(data) - const totals = sorted.map(d => d.totalTokens) - const inputs = sorted.map(d => d.inputTokens) - const outputs = sorted.map(d => d.outputTokens) - const cacheWrites = sorted.map(d => d.cacheCreationTokens) - const cacheReads = sorted.map(d => d.cacheReadTokens) - const thinking = sorted.map(d => d.thinkingTokens) + const totals = sorted.map((d) => d.totalTokens) + const inputs = sorted.map((d) => d.inputTokens) + const outputs = sorted.map((d) => d.outputTokens) + const cacheWrites = sorted.map((d) => d.cacheCreationTokens) + const cacheReads = sorted.map((d) => d.cacheReadTokens) + const thinking = sorted.map((d) => d.thinkingTokens) const ma7 = computeMovingAverage(totals) const inputMA7 = computeMovingAverage(inputs) const outputMA7 = computeMovingAverage(outputs) const cacheWriteMA7 = computeMovingAverage(cacheWrites) const cacheReadMA7 = computeMovingAverage(cacheReads) const thinkingMA7 = computeMovingAverage(thinking) - return sorted.map((d, i) => ({ - date: d.date, - Input: d.inputTokens, - Output: d.outputTokens, - 'Cache Write': d.cacheCreationTokens, - 'Cache Read': d.cacheReadTokens, - Thinking: d.thinkingTokens, - totalTokens: d.totalTokens, - totalTokensPrev: i > 0 ? sorted[i - 1].totalTokens : undefined, - tokenMA7: ma7[i], - inputMA7: inputMA7[i], - outputMA7: outputMA7[i], - cacheWriteMA7: cacheWriteMA7[i], - cacheReadMA7: cacheReadMA7[i], - thinkingMA7: thinkingMA7[i], - })) + return sorted.map((d, i) => { + const point: TokenChartDataPoint = { + date: d.date, + Input: d.inputTokens, + Output: d.outputTokens, + 'Cache Write': d.cacheCreationTokens, + 'Cache Read': d.cacheReadTokens, + Thinking: d.thinkingTokens, + totalTokens: d.totalTokens, + } + const previousPoint = i > 0 ? sorted[i - 1] : undefined + const totalTokensPrev = previousPoint?.totalTokens ?? null + if (totalTokensPrev !== null) point.totalTokensPrev = totalTokensPrev + if (ma7[i] !== undefined) point.tokenMA7 = ma7[i] + if (inputMA7[i] !== undefined) point.inputMA7 = inputMA7[i] + if (outputMA7[i] !== undefined) point.outputMA7 = outputMA7[i] + if (cacheWriteMA7[i] !== undefined) point.cacheWriteMA7 = cacheWriteMA7[i] + if (cacheReadMA7[i] !== undefined) point.cacheReadMA7 = cacheReadMA7[i] + if (thinkingMA7[i] !== undefined) point.thinkingMA7 = thinkingMA7[i] + return point + }) } export function toRequestChartData(data: DailyUsage[]): RequestChartDataPoint[] { const sorted = sortByDate(data) - const totals = sorted.map(d => d.requestCount) + const totals = sorted.map((d) => d.requestCount) const totalMA7 = computeMovingAverage(totals) const allModels = new Set() @@ -222,22 +257,27 @@ export function toRequestChartData(data: DailyUsage[]): RequestChartDataPoint[] dayRequests[name] = (dayRequests[name] ?? 0) + mb.requestCount } for (const name of modelNames) { - modelRequestArrays[name].push(dayRequests[name] ?? 0) + const requestsForModel = modelRequestArrays[name] + if (requestsForModel) { + requestsForModel.push(dayRequests[name] ?? 0) + } } } const modelMA7: Record = {} for (const name of modelNames) { - modelMA7[name] = computeMovingAverage(modelRequestArrays[name]) + modelMA7[name] = computeMovingAverage(modelRequestArrays[name] ?? []) } return sorted.map((d, i) => { - const point: Record = { + const point: RequestChartDataPoint = { date: d.date, totalRequests: d.requestCount, - totalRequestsPrev: i > 0 ? sorted[i - 1].requestCount : undefined, - totalRequestsMA7: totalMA7[i], } + const previousPoint = i > 0 ? sorted[i - 1] : undefined + const totalRequestsPrev = previousPoint?.requestCount ?? null + if (totalRequestsPrev !== null) point.totalRequestsPrev = totalRequestsPrev + if (totalMA7[i] !== undefined) point.totalRequestsMA7 = totalMA7[i] for (const mb of d.modelBreakdowns) { const name = normalizeModelName(mb.modelName) @@ -246,30 +286,37 @@ export function toRequestChartData(data: DailyUsage[]): RequestChartDataPoint[] for (const name of modelNames) { if (!(name in point)) point[name] = 0 - point[`${name}_ma7`] = modelMA7[name][i] + point[`${name}_ma7`] = modelMA7[name]?.[i] } - return point as RequestChartDataPoint + return point }) } export function toWeekdayData(data: DailyUsage[]): WeekdayData[] { const weekdayCosts: Record = { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] } + const weekdayFormatter = new Intl.DateTimeFormat(getCurrentLocale(), { + weekday: 'short', + timeZone: 'UTC', + }) const weekdayLabels = Array.from({ length: 7 }, (_, index) => - new Intl.DateTimeFormat(getCurrentLocale(), { weekday: 'short' }) + weekdayFormatter .format(new Date(Date.UTC(2024, 0, 1 + index))) .replace('.', '') - .slice(0, 2) + .slice(0, 2), ) for (const d of data) { // Skip non-daily entries (monthly "2026-03" or yearly "2026") if (d.date.length !== 10) continue const date = new Date(d.date + 'T00:00:00') const dow = (date.getDay() + 6) % 7 // Monday = 0 - weekdayCosts[dow].push(d.totalCost) + const costsForWeekday = weekdayCosts[dow] + if (costsForWeekday) { + costsForWeekday.push(d.totalCost) + } } return weekdayLabels.map((day, i) => { - const costs = weekdayCosts[i] + const costs = weekdayCosts[i] ?? [] const avg = costs.length > 0 ? costs.reduce((s, v) => s + v, 0) / costs.length : 0 return { day, cost: avg } }) @@ -278,9 +325,8 @@ export function toWeekdayData(data: DailyUsage[]): WeekdayData[] { export function aggregateToDailyFormat(data: DailyUsage[], mode: ViewMode): DailyUsage[] { if (mode === 'daily') return data - const groupKey = mode === 'monthly' - ? (date: string) => date.slice(0, 7) - : (date: string) => date.slice(0, 4) + const groupKey = + mode === 'monthly' ? (date: string) => date.slice(0, 7) : (date: string) => date.slice(0, 4) const map = new Map() @@ -312,17 +358,47 @@ export function aggregateToDailyFormat(data: DailyUsage[], mode: ViewMode): Dail return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date)) } -export function aggregateByMonth(data: DailyUsage[]): { period: string; totalCost: number; totalTokens: number; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; thinkingTokens: number; requestCount: number; days: number; modelBreakdowns: DailyUsage['modelBreakdowns'] }[] { - const map = new Map() +export function aggregateByMonth(data: DailyUsage[]): { + period: string + totalCost: number + totalTokens: number + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + requestCount: number + days: number + modelBreakdowns: DailyUsage['modelBreakdowns'] +}[] { + const map = new Map< + string, + { + totalCost: number + totalTokens: number + inputTokens: number + outputTokens: number + cacheCreationTokens: number + cacheReadTokens: number + thinkingTokens: number + requestCount: number + days: number + modelBreakdowns: DailyUsage['modelBreakdowns'] + } + >() for (const d of data) { const month = d.date.slice(0, 7) const existing = map.get(month) ?? { - totalCost: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, - cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0, requestCount: 0, days: 0, modelBreakdowns: [], + totalCost: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + requestCount: 0, + days: 0, + modelBreakdowns: [], } existing.totalCost += d.totalCost existing.totalTokens += d.totalTokens diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index f3b620a..6916845 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -15,6 +15,19 @@ export function localMonth(): string { return localToday().slice(0, 7) } +export function coerceNumber(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + + return null +} + export function formatCurrency(value: number): string { if (value >= 1000) return `$${(value / 1000).toFixed(1)}k` if (value >= 100) return `$${Math.round(value)}` @@ -56,9 +69,10 @@ export function formatDate(dateStr: string, mode: 'short' | 'long' | 'weekday' = // Monthly period: "2026-03" if (/^\d{4}-\d{2}$/.test(dateStr)) { - const [y, m] = dateStr.split('-') - const d = new Date(parseInt(y), parseInt(m) - 1) - if (mode === 'short') return d.toLocaleDateString(getCurrentLocale(), { month: 'short', year: '2-digit' }) + const [y = '0', m = '1'] = dateStr.split('-') + const d = new Date(parseInt(y, 10), parseInt(m, 10) - 1) + if (mode === 'short') + return d.toLocaleDateString(getCurrentLocale(), { month: 'short', year: '2-digit' }) return d.toLocaleDateString(getCurrentLocale(), { month: 'long', year: 'numeric' }) } @@ -84,8 +98,8 @@ export function formatDateAxis(dateStr: string): string { // Monthly period: "2026-03" if (/^\d{4}-\d{2}$/.test(dateStr)) { - const [y, m] = dateStr.split('-') - const d = new Date(parseInt(y), parseInt(m) - 1) + const [y = '0', m = '1'] = dateStr.split('-') + const d = new Date(parseInt(y, 10), parseInt(m, 10) - 1) return d.toLocaleDateString(getCurrentLocale(), { month: 'short', year: '2-digit' }) } @@ -108,8 +122,16 @@ export function periodUnit(viewMode: 'daily' | 'monthly' | 'yearly'): string { } export function formatMonthYear(dateStr: string): string { - const [year, month] = dateStr.split('-') - const date = new Date(parseInt(year), parseInt(month) - 1) + if (!/^\d{4}-\d{2}$/.test(dateStr)) return '' + + const [year = '', month = ''] = dateStr.split('-') + const parsedYear = Number.parseInt(year, 10) + const parsedMonth = Number.parseInt(month, 10) + + if (!Number.isInteger(parsedYear) || !Number.isInteger(parsedMonth)) return '' + if (parsedMonth < 1 || parsedMonth > 12) return '' + + const date = new Date(parsedYear, parsedMonth - 1) return date.toLocaleDateString(getCurrentLocale(), { month: 'long', year: 'numeric' }) } diff --git a/src/lib/help-content.ts b/src/lib/help-content.ts index 736e425..ee96125 100644 --- a/src/lib/help-content.ts +++ b/src/lib/help-content.ts @@ -1,4 +1,4 @@ -import i18n, { getCurrentLanguage } from '@/lib/i18n' +import { getCurrentLanguage } from '@/lib/i18n' const HELP_CONTENT = { de: { @@ -7,64 +7,114 @@ const HELP_CONTENT = { { keys: 'ESC', description: 'Dialog / Zoom schliessen' }, ], metric: { - totalCost: 'Zeigt die Gesamtkosten aller API-Aufrufe im gewählten Zeitraum. Die Berechnung basiert auf den hinterlegten Token-Preisen pro Modell.', - totalTokens: 'Zeigt die Summe aller verarbeiteten Tokens aus Input, Output und Cache. Ein Token entspricht grob vier Zeichen Text.', - activeDays: 'Zeigt, an wie vielen Tagen im gewählten Zeitraum mindestens ein API-Aufruf vorhanden war.', + totalCost: + 'Zeigt die Gesamtkosten aller API-Aufrufe im gewählten Zeitraum. Die Berechnung basiert auf den hinterlegten Token-Preisen pro Modell.', + totalTokens: + 'Zeigt die Summe aller verarbeiteten Tokens aus Input, Output und Cache. Ein Token entspricht grob vier Zeichen Text.', + activeDays: + 'Zeigt, an wie vielen Tagen im gewählten Zeitraum mindestens ein API-Aufruf vorhanden war.', topModel: 'Zeigt das Modell mit den höchsten Gesamtkosten im aktuellen Ausschnitt.', - cacheHitRate: 'Zeigt den Anteil der Tokens, die aus dem Cache gelesen wurden. Höhere Werte sprechen meist für bessere Kosteneffizienz.', - costPerMillion: 'Zeigt die durchschnittlichen Kosten pro 1 Million verarbeiteter Tokens. Niedrigere Werte sprechen für effizientere Nutzung.', - mostExpensiveDay: 'Zeigt den Zeitraumspunkt mit den höchsten API-Kosten im aktuellen Ausschnitt.', - cheapestDay: 'Zeigt den Zeitraumspunkt mit den niedrigsten API-Kosten im aktuellen Ausschnitt.', + cacheHitRate: + 'Zeigt den Anteil der Tokens, die aus dem Cache gelesen wurden. Höhere Werte sprechen meist für bessere Kosteneffizienz.', + costPerMillion: + 'Zeigt die durchschnittlichen Kosten pro 1 Million verarbeiteter Tokens. Niedrigere Werte sprechen für effizientere Nutzung.', + mostExpensiveDay: + 'Zeigt den Zeitraumspunkt mit den höchsten API-Kosten im aktuellen Ausschnitt.', + cheapestDay: + 'Zeigt den Zeitraumspunkt mit den niedrigsten API-Kosten im aktuellen Ausschnitt.', avgCostPerDay: 'Zeigt die durchschnittlichen Kosten pro aktivem Zeitraumspunkt.', - outputTokens: 'Zeigt die Menge der generierten Output-Tokens. Diese sind meist teurer als reine Input-Tokens.', + outputTokens: + 'Zeigt die Menge der generierten Output-Tokens. Diese sind meist teurer als reine Input-Tokens.', }, chart: { - costOverTime: 'Zeigt die API-Kosten im Zeitverlauf zusammen mit einem gleitenden 7-Tage-Durchschnitt. Klick auf einen Punkt öffnet den Drilldown.', - costByModel: 'Zeigt die Kostenverteilung nach Modell als Donut. So wird sichtbar, welche Modelle den grössten Kostenanteil tragen.', - costByModelOverTime: 'Zeigt, wie sich die Kosten je Modell über die Zeit entwickeln. Gut geeignet, um Treiber und Verschiebungen im Modellmix zu erkennen.', - cumulativeCost: 'Zeigt die kumulierten Gesamtkosten über den gewählten Zeitraum. Falls möglich, wird zusätzlich die Monatsend-Projektion eingeblendet.', - costByWeekday: 'Zeigt die durchschnittlichen Kosten pro Wochentag. So werden wiederkehrende Lastmuster über die Woche sichtbar.', - tokensOverTime: 'Zeigt den Token-Verbrauch über die Zeit, getrennt nach Input, Output, Cache Write, Cache Read und Thinking.', - requestsOverTime: 'Zeigt Requests im Zeitverlauf mit Gesamtlinie, Modelllinien und Trendlinie. Klick auf einen Punkt öffnet den Drilldown.', - requestCacheHitRate: 'Zeigt die Cache-Hit-Rate pro Modell zusammen mit dem gefilterten Gesamtwert und dem gleitenden 7-Tage-Durchschnitt auf Basis des gewählten Tagesbereichs.', - tokenTypes: 'Zeigt die Verteilung der Token-Typen als Donut. So wird sichtbar, welcher Anteil auf Input, Output, Cache oder Thinking entfällt.', - tokenEfficiency: 'Zeigt die Kosten pro 1 Million Tokens im Zeitverlauf. So lässt sich erkennen, ob Modellmix und Cache-Nutzung effizienter oder teurer werden.', - modelMix: 'Zeigt den prozentualen Kostenanteil der Modelle je Zeitraumspunkt. So werden Modellwechsel und Konzentration sichtbar.', - distributionAnalysis: 'Zeigt Histogramme für Kosten, Requests und Tokens pro Request. So wird die Streuung sichtbar, nicht nur der Durchschnitt.', - correlationAnalysis: 'Zeigt Punktdiagramme für mögliche Zusammenhänge, etwa Requests zu Kosten oder Cache-Rate zu Kosten pro Request. Die Korrelation ist ein Signal, aber kein Beweis für Kausalität.', - heatmap: 'Zeigt eine Kalender-Heatmap der täglichen Kosten. Dunklere Felder stehen für höhere Werte.', - requestHeatmap: 'Zeigt eine Kalender-Heatmap der Requests pro Tag. So werden Lastmuster unabhängig von Kosten sichtbar.', - tokenHeatmap: 'Zeigt eine Kalender-Heatmap des Tokenvolumens pro Tag. So lassen sich volumenstarke und kostenstarke Tage besser unterscheiden.', - forecast: 'Zeigt die Kostenprognose für den laufenden Monat auf Basis geglätteter Kalendertageskosten. Ergänzt wird sie durch Trend und Unsicherheitsband.', - cacheROI: 'Zeigt den Effekt der Cache-Nutzung, indem hypothetische Kosten ohne Cache mit den tatsächlichen Kosten verglichen werden.', - providerLimitProgress: 'Zeigt pro Anbieter, wie stark das konfigurierte Monatslimit bereits verbraucht ist. Überschreitungen werden separat markiert.', - providerSubscriptionMix: 'Vergleicht pro Anbieter die fixe Subscription mit den variablen API-Kosten und blendet optional das gesetzte Monatslimit ein.', - providerLimitTimeline: 'Zeigt im Monatsverlauf die Summe der aktuellen Provider-Kosten gegen die Summe aller konfigurierten Limits. So werden Engpässe früh sichtbar.', - periodComparison: 'Zeigt den Vergleich zweier Zeiträume, etwa Woche gegen Vorwoche oder Monat gegen Vormonat, anhand zentraler Kennzahlen.', - anomalyDetection: 'Zeigt auffällige Zeitraumspunkte mit ungewöhnlich hohen oder niedrigen Kosten. Grundlage ist die Abweichung vom Mittelwert in Standardabweichungen.', + costOverTime: + 'Zeigt die API-Kosten im Zeitverlauf zusammen mit einem gleitenden 7-Tage-Durchschnitt. Klick auf einen Punkt öffnet den Drilldown.', + costByModel: + 'Zeigt die Kostenverteilung nach Modell als Donut. So wird sichtbar, welche Modelle den grössten Kostenanteil tragen.', + costByModelOverTime: + 'Zeigt, wie sich die Kosten je Modell über die Zeit entwickeln. Gut geeignet, um Treiber und Verschiebungen im Modellmix zu erkennen.', + cumulativeCost: + 'Zeigt die kumulierten Gesamtkosten über den gewählten Zeitraum. Falls möglich, wird zusätzlich die Monatsend-Projektion eingeblendet.', + costByWeekday: + 'Zeigt die durchschnittlichen Kosten pro Wochentag. So werden wiederkehrende Lastmuster über die Woche sichtbar.', + tokensOverTime: + 'Zeigt den Token-Verbrauch über die Zeit, getrennt nach Input, Output, Cache Write, Cache Read und Thinking.', + requestsOverTime: + 'Zeigt Requests im Zeitverlauf mit Gesamtlinie, Modelllinien und Trendlinie. Klick auf einen Punkt öffnet den Drilldown.', + requestCacheHitRate: + 'Zeigt die Cache-Hit-Rate pro Modell zusammen mit dem gefilterten Gesamtwert und dem gleitenden 7-Tage-Durchschnitt auf Basis des gewählten Tagesbereichs.', + tokenTypes: + 'Zeigt die Verteilung der Token-Typen als Donut. So wird sichtbar, welcher Anteil auf Input, Output, Cache oder Thinking entfällt.', + tokenEfficiency: + 'Zeigt die Kosten pro 1 Million Tokens im Zeitverlauf. So lässt sich erkennen, ob Modellmix und Cache-Nutzung effizienter oder teurer werden.', + modelMix: + 'Zeigt den prozentualen Kostenanteil der Modelle je Zeitraumspunkt. So werden Modellwechsel und Konzentration sichtbar.', + distributionAnalysis: + 'Zeigt Histogramme für Kosten, Requests und Tokens pro Request. So wird die Streuung sichtbar, nicht nur der Durchschnitt.', + correlationAnalysis: + 'Zeigt Punktdiagramme für mögliche Zusammenhänge, etwa Requests zu Kosten oder Cache-Rate zu Kosten pro Request. Die Korrelation ist ein Signal, aber kein Beweis für Kausalität.', + heatmap: + 'Zeigt eine Kalender-Heatmap der täglichen Kosten. Dunklere Felder stehen für höhere Werte.', + requestHeatmap: + 'Zeigt eine Kalender-Heatmap der Requests pro Tag. So werden Lastmuster unabhängig von Kosten sichtbar.', + tokenHeatmap: + 'Zeigt eine Kalender-Heatmap des Tokenvolumens pro Tag. So lassen sich volumenstarke und kostenstarke Tage besser unterscheiden.', + forecast: + 'Zeigt die Kostenprognose für den laufenden Monat auf Basis geglätteter Kalendertageskosten. Ergänzt wird sie durch Trend und Unsicherheitsband.', + cacheROI: + 'Zeigt den Effekt der Cache-Nutzung, indem hypothetische Kosten ohne Cache mit den tatsächlichen Kosten verglichen werden.', + providerLimitProgress: + 'Zeigt pro Anbieter, wie stark das konfigurierte Monatslimit bereits verbraucht ist. Überschreitungen werden separat markiert.', + providerSubscriptionMix: + 'Vergleicht pro Anbieter die fixe Subscription mit den variablen API-Kosten und blendet optional das gesetzte Monatslimit ein.', + providerLimitTimeline: + 'Zeigt im Monatsverlauf die Summe der aktuellen Provider-Kosten gegen die Summe aller konfigurierten Limits. So werden Engpässe früh sichtbar.', + periodComparison: + 'Zeigt den Vergleich zweier Zeiträume, etwa Woche gegen Vorwoche oder Monat gegen Vormonat, anhand zentraler Kennzahlen.', + anomalyDetection: + 'Zeigt auffällige Zeitraumspunkte mit ungewöhnlich hohen oder niedrigen Kosten. Grundlage ist die Abweichung vom Mittelwert in Standardabweichungen.', }, section: { - insights: 'Zeigt verdichtete Aussagen zu Konzentration, Request-Ökonomie, Nutzungsmuster und Peak-Fenstern. Diese Sektion ist als schneller Einstieg vor dem Detailblick gedacht.', - metrics: 'Zeigt die wichtigsten Kennzahlen auf einen Blick. Hover über abgekürzte Werte zeigt den exakten Zahlenwert.', - today: 'Zeigt die KPIs des aktuellen Tages im Datensatz. So wird sichtbar, wie stark der Tageswert vom Zeitraumdurchschnitt abweicht.', - currentMonth: 'Zeigt die KPIs des laufenden Monats. So werden Fortschritt, Abdeckung und der Vergleich mit dem Vormonat sichtbar.', - activity: 'Zeigt Kalenderansichten für Kosten, Requests und Tokens. So werden Lastspitzen, Lücken und saisonale Muster sichtbar.', - forecastCache: 'Zeigt Monatsprognose, Cache-Ersparnis und operative Request-Qualität in einem Block. So entsteht ein gemeinsamer Blick auf Ausblick und Effizienz.', - limits: 'Zeigt pro Anbieter konfigurierte Subscriptions und Monatslimits. Die Sektion macht sichtbar, wie weit Kostenbudgets im aktuellen Ausschnitt ausgereizt sind.', - costAnalysis: 'Zeigt Kostenverlauf und Kostenverteilung nach Modell. So wird sichtbar, wo Geld ausgegeben wurde und welche Modelle die Haupttreiber sind.', - tokenAnalysis: 'Zeigt Tokenvolumen, Token-Typen, Wochentagsmuster und Effizienz. So lässt sich besser einordnen, ob Kosten eher aus Menge oder Preisniveau entstehen.', - requestAnalysis: 'Zeigt Requests gesamt, nach Modell und im Verlauf. Im Zoom kommen zusätzliche Trends und Verteilungen hinzu.', - advancedAnalysis: 'Zeigt Verteilungen, Korrelationen und Konzentrationsrisiken. So wird sichtbar, wie stabil, konzentriert oder ungewöhnlich die Nutzung ist.', - comparisons: 'Zeigt Veränderungen zwischen Perioden und markiert Ausreisser. So lassen sich Verschiebungen schneller einordnen.', - tables: 'Zeigt detaillierte Tabellen mit Sortierung und Drilldown. So lassen sich einzelne Modelle, Provider und Tage gezielt prüfen.', + insights: + 'Zeigt verdichtete Aussagen zu Konzentration, Request-Ökonomie, Nutzungsmuster und Peak-Fenstern. Diese Sektion ist als schneller Einstieg vor dem Detailblick gedacht.', + metrics: + 'Zeigt die wichtigsten Kennzahlen auf einen Blick. Hover über abgekürzte Werte zeigt den exakten Zahlenwert.', + today: + 'Zeigt die KPIs des aktuellen Tages im Datensatz. So wird sichtbar, wie stark der Tageswert vom Zeitraumdurchschnitt abweicht.', + currentMonth: + 'Zeigt die KPIs des laufenden Monats. So werden Fortschritt, Abdeckung und der Vergleich mit dem Vormonat sichtbar.', + activity: + 'Zeigt Kalenderansichten für Kosten, Requests und Tokens. So werden Lastspitzen, Lücken und saisonale Muster sichtbar.', + forecastCache: + 'Zeigt Monatsprognose, Cache-Ersparnis und operative Request-Qualität in einem Block. So entsteht ein gemeinsamer Blick auf Ausblick und Effizienz.', + limits: + 'Zeigt pro Anbieter konfigurierte Subscriptions und Monatslimits. Die Sektion macht sichtbar, wie weit Kostenbudgets im aktuellen Ausschnitt ausgereizt sind.', + costAnalysis: + 'Zeigt Kostenverlauf und Kostenverteilung nach Modell. So wird sichtbar, wo Geld ausgegeben wurde und welche Modelle die Haupttreiber sind.', + tokenAnalysis: + 'Zeigt Tokenvolumen, Token-Typen, Wochentagsmuster und Effizienz. So lässt sich besser einordnen, ob Kosten eher aus Menge oder Preisniveau entstehen.', + requestAnalysis: + 'Zeigt Requests gesamt, nach Modell und im Verlauf. Im Zoom kommen zusätzliche Trends und Verteilungen hinzu.', + advancedAnalysis: + 'Zeigt Verteilungen, Korrelationen und Konzentrationsrisiken. So wird sichtbar, wie stabil, konzentriert oder ungewöhnlich die Nutzung ist.', + comparisons: + 'Zeigt Veränderungen zwischen Perioden und markiert Ausreisser. So lassen sich Verschiebungen schneller einordnen.', + tables: + 'Zeigt detaillierte Tabellen mit Sortierung und Drilldown. So lassen sich einzelne Modelle, Provider und Tage gezielt prüfen.', }, feature: { - requestQuality: 'Zeigt verdichtete Request-Signale wie Tokens pro Request, Kosten pro Request sowie Cache- und Thinking-Anteil. So lässt sich die operative Anfragequalität schneller einordnen.', - providerLimits: 'Hier werden fixe Subscription-Kosten und variable Monatslimits pro Anbieter gepflegt. Die Eingaben bleiben lokal im Browser gespeichert und gelten nur für Anbieter, die im geladenen Report vorkommen.', - concentrationRisk: 'Zeigt die Abhängigkeit von einzelnen Modellen und Providern. Hohe Werte bedeuten, dass wenige Akteure einen grossen Teil der Kosten tragen.', - providerEfficiency: 'Zeigt den Vergleich der Anbieter nach Kosten, Requests, Tokens und Effizienzkennzahlen wie $/Req oder $/1M Tokens.', - modelEfficiency: 'Zeigt den Vergleich der Modelle nach Kosten, Volumen und Effizienz. So lassen sich teure oder ineffiziente Kandidaten schnell erkennen.', - recentDays: 'Zeigt die Detailtabelle pro Tag, Monat oder Jahr mit Drilldown und Benchmarks gegen Vortag und 7-Tage-Mittel.', + requestQuality: + 'Zeigt verdichtete Request-Signale wie Tokens pro Request, Kosten pro Request sowie Cache- und Thinking-Anteil. So lässt sich die operative Anfragequalität schneller einordnen.', + providerLimits: + 'Hier werden fixe Subscription-Kosten und variable Monatslimits pro Anbieter gepflegt. Die Eingaben bleiben lokal im Browser gespeichert und gelten nur für Anbieter, die im geladenen Report vorkommen.', + concentrationRisk: + 'Zeigt die Abhängigkeit von einzelnen Modellen und Providern. Hohe Werte bedeuten, dass wenige Akteure einen grossen Teil der Kosten tragen.', + providerEfficiency: + 'Zeigt den Vergleich der Anbieter nach Kosten, Requests, Tokens und Effizienzkennzahlen wie $/Req oder $/1M Tokens.', + modelEfficiency: + 'Zeigt den Vergleich der Modelle nach Kosten, Volumen und Effizienz. So lassen sich teure oder ineffiziente Kandidaten schnell erkennen.', + recentDays: + 'Zeigt die Detailtabelle pro Tag, Monat oder Jahr mit Drilldown und Benchmarks gegen Vortag und 7-Tage-Mittel.', }, }, en: { @@ -73,64 +123,110 @@ const HELP_CONTENT = { { keys: 'ESC', description: 'Close dialog / zoom view' }, ], metric: { - totalCost: 'Shows total API cost across the selected range. The calculation is based on configured per-model token prices.', - totalTokens: 'Shows the sum of all processed tokens across input, output, and cache. One token is roughly four text characters.', + totalCost: + 'Shows total API cost across the selected range. The calculation is based on configured per-model token prices.', + totalTokens: + 'Shows the sum of all processed tokens across input, output, and cache. One token is roughly four text characters.', activeDays: 'Shows how many days in the selected range contain at least one API call.', topModel: 'Shows the model with the highest total cost in the current slice.', - cacheHitRate: 'Shows the share of tokens served from cache. Higher values usually indicate better cost efficiency.', - costPerMillion: 'Shows average cost per 1 million processed tokens. Lower values indicate more efficient usage.', + cacheHitRate: + 'Shows the share of tokens served from cache. Higher values usually indicate better cost efficiency.', + costPerMillion: + 'Shows average cost per 1 million processed tokens. Lower values indicate more efficient usage.', mostExpensiveDay: 'Shows the period point with the highest API cost in the current slice.', cheapestDay: 'Shows the period point with the lowest API cost in the current slice.', avgCostPerDay: 'Shows the average cost per active period point.', - outputTokens: 'Shows the volume of generated output tokens. These are usually more expensive than pure input tokens.', + outputTokens: + 'Shows the volume of generated output tokens. These are usually more expensive than pure input tokens.', }, chart: { - costOverTime: 'Shows API cost over time together with a rolling 7-day average. Clicking a point opens the drilldown.', - costByModel: 'Shows cost distribution by model as a donut chart. This makes the main cost drivers visible.', - costByModelOverTime: 'Shows how cost per model evolves over time. Useful for spotting shifts in the model mix.', - cumulativeCost: 'Shows cumulative total cost over the selected range. When possible, a month-end projection is added.', - costByWeekday: 'Shows average cost by weekday, making recurring weekly load patterns visible.', - tokensOverTime: 'Shows token usage over time split by input, output, cache write, cache read, and thinking.', - requestsOverTime: 'Shows requests over time with total line, per-model lines, and a trend line. Clicking a point opens the drilldown.', - requestCacheHitRate: 'Shows cache hit rate per model together with the filtered overall total and the trailing 7-day average based on the selected daily range.', - tokenTypes: 'Shows the distribution of token types as a donut chart so input, output, cache, and thinking shares become visible.', - tokenEfficiency: 'Shows cost per 1 million tokens over time. This helps spot whether model mix and cache usage are becoming more or less efficient.', - modelMix: 'Shows the percentage cost share of models at each period point. This makes model shifts and concentration visible.', - distributionAnalysis: 'Shows histograms for costs, requests, and tokens per request. This highlights spread, not just averages.', - correlationAnalysis: 'Shows scatter plots for potential relationships such as requests to cost or cache rate to cost per request. Correlation is a signal, not proof of causality.', + costOverTime: + 'Shows API cost over time together with a rolling 7-day average. Clicking a point opens the drilldown.', + costByModel: + 'Shows cost distribution by model as a donut chart. This makes the main cost drivers visible.', + costByModelOverTime: + 'Shows how cost per model evolves over time. Useful for spotting shifts in the model mix.', + cumulativeCost: + 'Shows cumulative total cost over the selected range. When possible, a month-end projection is added.', + costByWeekday: + 'Shows average cost by weekday, making recurring weekly load patterns visible.', + tokensOverTime: + 'Shows token usage over time split by input, output, cache write, cache read, and thinking.', + requestsOverTime: + 'Shows requests over time with total line, per-model lines, and a trend line. Clicking a point opens the drilldown.', + requestCacheHitRate: + 'Shows cache hit rate per model together with the filtered overall total and the trailing 7-day average based on the selected daily range.', + tokenTypes: + 'Shows the distribution of token types as a donut chart so input, output, cache, and thinking shares become visible.', + tokenEfficiency: + 'Shows cost per 1 million tokens over time. This helps spot whether model mix and cache usage are becoming more or less efficient.', + modelMix: + 'Shows the percentage cost share of models at each period point. This makes model shifts and concentration visible.', + distributionAnalysis: + 'Shows histograms for costs, requests, and tokens per request. This highlights spread, not just averages.', + correlationAnalysis: + 'Shows scatter plots for potential relationships such as requests to cost or cache rate to cost per request. Correlation is a signal, not proof of causality.', heatmap: 'Shows a calendar heatmap of daily cost. Darker cells indicate higher values.', - requestHeatmap: 'Shows a calendar heatmap of requests per day. This reveals load patterns independently of cost.', - tokenHeatmap: 'Shows a calendar heatmap of token volume per day. This helps distinguish high-volume days from high-cost days.', - forecast: 'Shows cost forecast for the current month based on smoothed calendar-day costs, complemented by trend and uncertainty band.', - cacheROI: 'Shows the impact of cache usage by comparing hypothetical no-cache costs with actual costs.', - providerLimitProgress: 'Shows how much of each configured monthly provider limit has already been used. Overruns are marked separately.', - providerSubscriptionMix: 'Compares fixed subscription cost with variable API cost per provider and can optionally include the configured monthly limit.', - providerLimitTimeline: 'Shows monthly provider cost against total configured limits over time so bottlenecks become visible early.', - periodComparison: 'Shows the comparison of two periods, such as week-over-week or month-over-month, using central metrics.', - anomalyDetection: 'Shows unusual period points with unusually high or low cost based on deviation from the mean in standard deviations.', + requestHeatmap: + 'Shows a calendar heatmap of requests per day. This reveals load patterns independently of cost.', + tokenHeatmap: + 'Shows a calendar heatmap of token volume per day. This helps distinguish high-volume days from high-cost days.', + forecast: + 'Shows cost forecast for the current month based on smoothed calendar-day costs, complemented by trend and uncertainty band.', + cacheROI: + 'Shows the impact of cache usage by comparing hypothetical no-cache costs with actual costs.', + providerLimitProgress: + 'Shows how much of each configured monthly provider limit has already been used. Overruns are marked separately.', + providerSubscriptionMix: + 'Compares fixed subscription cost with variable API cost per provider and can optionally include the configured monthly limit.', + providerLimitTimeline: + 'Shows monthly provider cost against total configured limits over time so bottlenecks become visible early.', + periodComparison: + 'Shows the comparison of two periods, such as week-over-week or month-over-month, using central metrics.', + anomalyDetection: + 'Shows unusual period points with unusually high or low cost based on deviation from the mean in standard deviations.', }, section: { - insights: 'Shows condensed statements about concentration, request economics, usage patterns, and peak windows. This section is meant as a fast entry point before deeper analysis.', - metrics: 'Shows the most important KPIs at a glance. Hover abbreviated values to see exact numbers.', - today: 'Shows the KPIs of the current day in the dataset. This makes it easy to compare the day against the period average.', - currentMonth: 'Shows the KPIs of the current month, including progress, coverage, and comparison to the previous month.', - activity: 'Shows calendar views for cost, requests, and tokens, making spikes, gaps, and seasonal patterns visible.', - forecastCache: 'Shows month forecast, cache savings, and operational request quality in one block for a combined outlook on efficiency.', - limits: 'Shows configured subscriptions and monthly limits per provider. This section makes it visible how far budgets are stretched in the current slice.', - costAnalysis: 'Shows cost trend and cost distribution by model so it is clear where spend happened and which models dominate.', - tokenAnalysis: 'Shows token volume, token types, weekday patterns, and efficiency so you can judge whether cost comes from volume or pricing level.', - requestAnalysis: 'Shows requests overall, by model, and over time. The expanded views add extra trends and distributions.', - advancedAnalysis: 'Shows distributions, correlations, and concentration risk so usage stability, concentration, and unusual behavior become visible.', - comparisons: 'Shows changes between periods and marks outliers so shifts can be understood faster.', - tables: 'Shows detailed tables with sorting and drilldown so individual models, providers, and days can be inspected directly.', + insights: + 'Shows condensed statements about concentration, request economics, usage patterns, and peak windows. This section is meant as a fast entry point before deeper analysis.', + metrics: + 'Shows the most important KPIs at a glance. Hover abbreviated values to see exact numbers.', + today: + 'Shows the KPIs of the current day in the dataset. This makes it easy to compare the day against the period average.', + currentMonth: + 'Shows the KPIs of the current month, including progress, coverage, and comparison to the previous month.', + activity: + 'Shows calendar views for cost, requests, and tokens, making spikes, gaps, and seasonal patterns visible.', + forecastCache: + 'Shows month forecast, cache savings, and operational request quality in one block for a combined outlook on efficiency.', + limits: + 'Shows configured subscriptions and monthly limits per provider. This section makes it visible how far budgets are stretched in the current slice.', + costAnalysis: + 'Shows cost trend and cost distribution by model so it is clear where spend happened and which models dominate.', + tokenAnalysis: + 'Shows token volume, token types, weekday patterns, and efficiency so you can judge whether cost comes from volume or pricing level.', + requestAnalysis: + 'Shows requests overall, by model, and over time. The expanded views add extra trends and distributions.', + advancedAnalysis: + 'Shows distributions, correlations, and concentration risk so usage stability, concentration, and unusual behavior become visible.', + comparisons: + 'Shows changes between periods and marks outliers so shifts can be understood faster.', + tables: + 'Shows detailed tables with sorting and drilldown so individual models, providers, and days can be inspected directly.', }, feature: { - requestQuality: 'Shows condensed request signals such as tokens per request, cost per request, and cache and thinking shares. This helps assess operational request quality faster.', - providerLimits: 'This is where fixed subscription cost and variable monthly limits are maintained per provider. Values are stored in the local app settings and only apply to providers present in the loaded report.', - concentrationRisk: 'Shows dependency on individual models and providers. Higher values mean that a small number of actors carries a large share of cost.', - providerEfficiency: 'Shows the provider comparison by cost, requests, tokens, and efficiency metrics such as $/req or $/1M tokens.', - modelEfficiency: 'Shows the model comparison by cost, volume, and efficiency so expensive or inefficient candidates are easy to spot.', - recentDays: 'Shows the detailed table per day, month, or year with drilldown and benchmarks against the previous day and 7-day average.', + requestQuality: + 'Shows condensed request signals such as tokens per request, cost per request, and cache and thinking shares. This helps assess operational request quality faster.', + providerLimits: + 'This is where fixed subscription cost and variable monthly limits are maintained per provider. Values are stored in the local app settings and only apply to providers present in the loaded report.', + concentrationRisk: + 'Shows dependency on individual models and providers. Higher values mean that a small number of actors carries a large share of cost.', + providerEfficiency: + 'Shows the provider comparison by cost, requests, tokens, and efficiency metrics such as $/req or $/1M tokens.', + modelEfficiency: + 'Shows the model comparison by cost, volume, and efficiency so expensive or inefficient candidates are easy to spot.', + recentDays: + 'Shows the detailed table per day, month, or year with drilldown and benchmarks against the previous day and 7-day average.', }, }, } as const @@ -139,11 +235,35 @@ function current() { return HELP_CONTENT[getCurrentLanguage()] } -function dynamicMap>(selector: () => T): Record { - return new Proxy({} as Record, { - get: (_, key) => selector()[String(key)] ?? '', +type AppHelpContent = typeof HELP_CONTENT.en +type HelpMap> = { [K in keyof T]: string } +export type MetricHelp = HelpMap +export type ChartHelp = HelpMap +export type SectionHelp = HelpMap +export type FeatureHelp = HelpMap + +function dynamicMap>(selector: () => T): T { + return new Proxy({} as T, { + get: (_, key) => { + const map = selector() + if (!Object.prototype.hasOwnProperty.call(map, key)) return undefined + return Reflect.get(map, key) + }, + has: (_, key) => { + const map = selector() + return Object.prototype.hasOwnProperty.call(map, key) + }, ownKeys: () => Reflect.ownKeys(selector()), - getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }), + getOwnPropertyDescriptor: (_, key) => { + const map = selector() + if (!Object.prototype.hasOwnProperty.call(map, key)) return undefined + + return { + value: Reflect.get(map, key), + enumerable: true, + configurable: true, + } + }, }) } @@ -151,7 +271,7 @@ export function getKeyboardShortcuts() { return current().keyboardShortcuts } -export const METRIC_HELP = dynamicMap(() => current().metric) -export const CHART_HELP = dynamicMap(() => current().chart) -export const SECTION_HELP = dynamicMap(() => current().section) -export const FEATURE_HELP = dynamicMap(() => current().feature) +export const METRIC_HELP: MetricHelp = dynamicMap(() => current().metric) +export const CHART_HELP: ChartHelp = dynamicMap(() => current().chart) +export const SECTION_HELP: SectionHelp = dynamicMap(() => current().section) +export const FEATURE_HELP: FeatureHelp = dynamicMap(() => current().feature) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 7edffe4..97eab35 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -19,21 +19,19 @@ export async function initI18n(language: AppLanguage = 'de') { const nextLanguage = normalizeLanguage(language) if (!i18n.isInitialized) { - await i18n - .use(initReactI18next) - .init({ - resources: { - de: { common: de }, - en: { common: en }, - }, - lng: nextLanguage, - fallbackLng: 'de', - defaultNS: 'common', - ns: ['common'], - interpolation: { - escapeValue: false, - }, - }) + await i18n.use(initReactI18next).init({ + resources: { + de: { common: de }, + en: { common: en }, + }, + lng: nextLanguage, + fallbackLng: 'de', + defaultNS: 'common', + ns: ['common'], + interpolation: { + escapeValue: false, + }, + }) } else if (i18n.resolvedLanguage !== nextLanguage) { await i18n.changeLanguage(nextLanguage) } diff --git a/src/lib/model-utils.ts b/src/lib/model-utils.ts index 5061a7c..40c3aef 100644 --- a/src/lib/model-utils.ts +++ b/src/lib/model-utils.ts @@ -1,6 +1,15 @@ import { MODEL_COLORS, MODEL_COLOR_DEFAULT } from './constants' +import modelNormalizationSpec from '../../server/model-normalization.json' const DYNAMIC_COLOR_CACHE = new Map() +const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({ + ...alias, + matcher: new RegExp(alias.pattern, 'i'), +})) +const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({ + ...matcher, + matcher: new RegExp(matcher.pattern, 'i'), +})) function titleCaseSegment(segment: string): string { if (!segment) return segment @@ -9,6 +18,107 @@ function titleCaseSegment(segment: string): string { return segment.charAt(0).toUpperCase() + segment.slice(1) } +function capitalize(segment: string): string { + if (!segment) return '' + return segment.charAt(0).toUpperCase() + segment.slice(1) +} + +function formatVersion(version: string): string { + return version.replace(/-/g, '.') +} + +function canonicalizeModelName(raw: string): string { + const normalized = String(raw || '') + .trim() + .toLowerCase() + .replace(/^model[:/ -]*/i, '') + .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '') + .replace(/\./g, '-') + .replace(/[_/]+/g, '-') + .replace(/\s+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, '') + + const suffixStart = normalized.lastIndexOf('-') + if (suffixStart > 0) { + const suffix = normalized.slice(suffixStart + 1) + if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) { + return normalized.slice(0, suffixStart) + } + } + + return normalized +} + +function parseClaudeName(rest: string): string { + const parts = rest.split('-', 2) + if (parts.length < 2) { + return `Claude ${capitalize(rest)}` + } + return `${capitalize(parts[0] ?? '')} ${formatVersion(parts[1] ?? '')}`.trim() +} + +function parseGptName(rest: string): string { + const parts = rest.split('-') + const variant = parts[0] ?? '' + const minor = parts[1] ?? '' + + if (minor && minor.length <= 2 && /^\d+$/.test(minor)) { + const version = `${variant}.${minor}` + if (parts.length > 2) { + const suffix = parts.slice(2).map(capitalize).join(' ') + return `GPT-${version}${suffix ? ` ${suffix}` : ''}` + } + return `GPT-${version}` + } + + if (parts.length > 1) { + const suffix = parts.slice(1).map(capitalize).join(' ') + return `GPT-${variant}${suffix ? ` ${suffix}` : ''}` + } + + return `GPT-${rest}` +} + +function parseGeminiName(rest: string): string { + const parts = rest.split('-') + if (parts.length < 2) { + return `Gemini ${rest}` + } + + const versionParts: string[] = [] + const tierParts: string[] = [] + + for (const part of parts) { + if (/^\d+$/.test(part) && tierParts.length === 0) { + versionParts.push(part) + } else { + tierParts.push(capitalize(part)) + } + } + + const version = versionParts.join('.') + const tier = tierParts.join(' ') + + return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}` +} + +function parseCodexName(rest: string): string { + const normalized = rest.replace(/-latest$/i, '') + if (!normalized) { + return 'Codex' + } + return `Codex ${normalized.split('-').map(capitalize).join(' ')}` +} + +function parseOSeries(name: string): string { + const separatorIndex = name.indexOf('-') + if (separatorIndex === -1) { + return name + } + return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}` +} + function dynamicColor(name: string): string { const cached = DYNAMIC_COLOR_CACHE.get(name) if (cached) return cached @@ -27,57 +137,63 @@ function dynamicColor(name: string): string { } export function normalizeModelName(raw: string): string { - const lower = raw.toLowerCase().trim() - if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4' - if (lower.includes('gpt-5')) return 'GPT-5' - if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6' - if (lower.includes('opus-4-5') || lower.includes('opus-4.5')) return 'Opus 4.5' - if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return 'Sonnet 4.6' - if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) return 'Sonnet 4.5' - if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return 'Haiku 4.5' - if (lower.includes('gemini-3-flash-preview')) return 'Gemini 3 Flash Preview' - if (lower.includes('gemini')) return 'Gemini' - if (lower.includes('opencode')) return 'OpenCode' - if (lower.includes('haiku')) return 'Haiku' - - const stripped = raw - .trim() - .replace(/^(claude|anthropic|openai|google|vertex|models)\//i, '') - .replace(/^(claude|anthropic|openai|google|vertex|models)-/i, '') - .replace(/^model[:/ -]*/i, '') - .replace(/[_/]+/g, '-') - .replace(/\s+/g, '-') - .replace(/-{2,}/g, '-') - .replace(/^-|-$/g, '') + const canonical = canonicalizeModelName(raw) + for (const alias of DISPLAY_ALIASES) { + if (alias.matcher.test(canonical)) { + return alias.name + } + } - const familyMatch = stripped.match(/(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i) + if (canonical.startsWith('claude-')) { + return parseClaudeName(canonical.slice('claude-'.length)) + } + + if (canonical.startsWith('gpt-')) { + return parseGptName(canonical.slice('gpt-'.length)) + } + + if (canonical.startsWith('gemini-')) { + return parseGeminiName(canonical.slice('gemini-'.length)) + } + + if (canonical.startsWith('codex-')) { + return parseCodexName(canonical.slice('codex-'.length)) + } + + if (/^o\d/i.test(canonical)) { + return parseOSeries(canonical) + } + + const familyMatch = canonical.match( + /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i, + ) if (familyMatch) { const family = familyMatch[1] - const suffix = familyMatch[2]?.replace(/-/g, '.') ?? '' + if (!family) return canonical + + if (/^codex$/i.test(family)) { + return parseCodexName(familyMatch[2] ?? '') + } + + if (/^(o\d)$/i.test(family)) { + return parseOSeries(canonical) + } + + const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '' if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}` - if (/^(o\d)$/i.test(family)) return family.toUpperCase() return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim() } - return stripped - .split('-') - .filter(Boolean) - .map(titleCaseSegment) - .join(' ') || raw + return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || raw } export function getModelProvider(raw: string): string { - const lower = raw.toLowerCase() - if (lower.includes('gpt') || lower.includes('openai') || lower.includes('/o1') || lower.includes('/o3') || /\bo\d\b/.test(lower)) return 'OpenAI' - if (lower.includes('claude') || lower.includes('opus') || lower.includes('sonnet') || lower.includes('haiku')) return 'Anthropic' - if (lower.includes('gemini')) return 'Google' - if (lower.includes('grok') || lower.includes('xai')) return 'xAI' - if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) return 'Meta' - if (lower.includes('command') || lower.includes('cohere')) return 'Cohere' - if (lower.includes('mistral')) return 'Mistral' - if (lower.includes('deepseek')) return 'DeepSeek' - if (lower.includes('qwen') || lower.includes('alibaba')) return 'Alibaba' - if (lower.includes('opencode')) return 'OpenCode' + const canonical = canonicalizeModelName(raw) + for (const matcher of PROVIDER_MATCHERS) { + if (matcher.matcher.test(canonical)) { + return matcher.provider + } + } return 'Other' } @@ -108,30 +224,78 @@ export function getProviderBadgeClasses(provider: string): string { } } -export function getProviderBadgeStyle(provider: string): { color: string; backgroundColor: string; borderColor: string } { +export function getProviderBadgeStyle(provider: string): { + color: string + backgroundColor: string + borderColor: string +} { switch (provider) { case 'OpenAI': - return { color: 'rgb(52, 211, 153)', backgroundColor: 'rgba(16, 185, 129, 0.10)', borderColor: 'rgba(16, 185, 129, 0.20)' } + return { + color: 'rgb(52, 211, 153)', + backgroundColor: 'rgba(16, 185, 129, 0.10)', + borderColor: 'rgba(16, 185, 129, 0.20)', + } case 'Anthropic': - return { color: 'rgb(251, 146, 60)', backgroundColor: 'rgba(249, 115, 22, 0.10)', borderColor: 'rgba(249, 115, 22, 0.20)' } + return { + color: 'rgb(251, 146, 60)', + backgroundColor: 'rgba(249, 115, 22, 0.10)', + borderColor: 'rgba(249, 115, 22, 0.20)', + } case 'Google': - return { color: 'rgb(56, 189, 248)', backgroundColor: 'rgba(14, 165, 233, 0.10)', borderColor: 'rgba(14, 165, 233, 0.20)' } + return { + color: 'rgb(56, 189, 248)', + backgroundColor: 'rgba(14, 165, 233, 0.10)', + borderColor: 'rgba(14, 165, 233, 0.20)', + } case 'xAI': - return { color: 'rgb(232, 121, 249)', backgroundColor: 'rgba(217, 70, 239, 0.10)', borderColor: 'rgba(217, 70, 239, 0.20)' } + return { + color: 'rgb(232, 121, 249)', + backgroundColor: 'rgba(217, 70, 239, 0.10)', + borderColor: 'rgba(217, 70, 239, 0.20)', + } case 'Meta': - return { color: 'rgb(96, 165, 250)', backgroundColor: 'rgba(59, 130, 246, 0.10)', borderColor: 'rgba(59, 130, 246, 0.20)' } + return { + color: 'rgb(96, 165, 250)', + backgroundColor: 'rgba(59, 130, 246, 0.10)', + borderColor: 'rgba(59, 130, 246, 0.20)', + } case 'Cohere': - return { color: 'rgb(163, 230, 53)', backgroundColor: 'rgba(132, 204, 22, 0.10)', borderColor: 'rgba(132, 204, 22, 0.20)' } + return { + color: 'rgb(163, 230, 53)', + backgroundColor: 'rgba(132, 204, 22, 0.10)', + borderColor: 'rgba(132, 204, 22, 0.20)', + } case 'Mistral': - return { color: 'rgb(252, 211, 77)', backgroundColor: 'rgba(245, 158, 11, 0.10)', borderColor: 'rgba(245, 158, 11, 0.20)' } + return { + color: 'rgb(252, 211, 77)', + backgroundColor: 'rgba(245, 158, 11, 0.10)', + borderColor: 'rgba(245, 158, 11, 0.20)', + } case 'DeepSeek': - return { color: 'rgb(45, 212, 191)', backgroundColor: 'rgba(20, 184, 166, 0.10)', borderColor: 'rgba(20, 184, 166, 0.20)' } + return { + color: 'rgb(45, 212, 191)', + backgroundColor: 'rgba(20, 184, 166, 0.10)', + borderColor: 'rgba(20, 184, 166, 0.20)', + } case 'Alibaba': - return { color: 'rgb(250, 204, 21)', backgroundColor: 'rgba(234, 179, 8, 0.10)', borderColor: 'rgba(234, 179, 8, 0.20)' } + return { + color: 'rgb(250, 204, 21)', + backgroundColor: 'rgba(234, 179, 8, 0.10)', + borderColor: 'rgba(234, 179, 8, 0.20)', + } case 'OpenCode': - return { color: 'rgb(34, 211, 238)', backgroundColor: 'rgba(6, 182, 212, 0.10)', borderColor: 'rgba(6, 182, 212, 0.20)' } + return { + color: 'rgb(34, 211, 238)', + backgroundColor: 'rgba(6, 182, 212, 0.10)', + borderColor: 'rgba(6, 182, 212, 0.20)', + } default: - return { color: 'rgb(148, 163, 184)', backgroundColor: 'rgba(100, 116, 139, 0.10)', borderColor: 'rgba(100, 116, 139, 0.20)' } + return { + color: 'rgb(148, 163, 184)', + backgroundColor: 'rgba(100, 116, 139, 0.10)', + borderColor: 'rgba(100, 116, 139, 0.20)', + } } } diff --git a/src/lib/provider-limits.ts b/src/lib/provider-limits.ts index dec908c..f17ad56 100644 --- a/src/lib/provider-limits.ts +++ b/src/lib/provider-limits.ts @@ -24,7 +24,7 @@ export function normalizeProviderLimitConfig(value: unknown): ProviderLimitConfi } export function syncProviderLimits(providers: string[], source: unknown): ProviderLimits { - const input = source && typeof source === 'object' ? source as Record : {} + const input = source && typeof source === 'object' ? (source as Record) : {} const next: ProviderLimits = {} for (const provider of providers) { @@ -36,11 +36,11 @@ export function syncProviderLimits(providers: string[], source: unknown): Provid export function getLatestMonth(data: DailyUsage[]): string | null { const months = data - .map(entry => entry.date.slice(0, 7)) - .filter(month => /^\d{4}-\d{2}$/.test(month)) + .map((entry) => entry.date.slice(0, 7)) + .filter((month) => /^\d{4}-\d{2}$/.test(month)) .sort() - return months.length > 0 ? months[months.length - 1] : null + return months.length > 0 ? (months[months.length - 1] ?? null) : null } export function buildProviderMonthlyCosts(data: DailyUsage[]) { diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 113aa2b..03201b5 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -72,6 +72,8 @@ "hidden": "Versteckt", "open": "Öffnen", "close": "Schliessen", + "previousMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", "startDate": "Startdatum", "endDate": "Enddatum", "selectedProviders": "Ausgewählte Provider", @@ -165,6 +167,9 @@ "cacheRoi": "Cache ROI" }, "stats": { + "min": "Min", + "max": "Max", + "avg": "Ø", "cacheHitRate": "Cache-Hit-Rate", "totalTokens": "Gesamt-Tokens", "cacheRead": "Cache Read", @@ -206,6 +211,7 @@ "spread": "Spanne: {{value}}", "medianPerUnit": "Median/{{unit}}", "vsAverage": "{{direction}}{{value}}% vs. Ø", + "vsAverageWithVolatility": "{{direction}}{{value}}% vs. Ø · σ Req {{volatility}}", "medianInfo": "Der Median zeigt den typischen Wert und ist weniger anfällig für Ausreisser als der Durchschnitt.", "requestLeader": "{{model}} · {{requests}} Req", "dominantProviderSubtitle": "{{share}} Anteil · {{cost}}{{requestLeader}}" @@ -250,6 +256,7 @@ "ioRatio": "I/O Ratio: {{value}}:1", "topModel": "Top: {{value}}", "cacheMix": "In: {{input}} / Out: {{output}}", + "costPerRequest": "{{value}} / Req", "requestsSubtitle": "Ø {{value}}/Tag · {{cost}}/Req", "requestCountersMissing": "Keine Request-Zähler", "thinkingSubtitle": "{{value}} Anteil" @@ -919,6 +926,11 @@ "subscriptionPaysOff": "Subscription zahlt sich aus", "belowSubscription": "Noch unter Subscription" }, + "badge": { + "limit": "{{value}}% Limit", + "subscription": "{{value}}% Abo", + "open": "Offen" + }, "tracks": { "budgetTitle": "Budget-Status je Anbieter", "budgetSubtitle": "Jeder Track zeigt pro Anbieter direkt den Abstand bis zum Limit oder den bereits eingetretenen Überzug", @@ -930,7 +942,9 @@ "portfolioSubtitle": "Monatlicher Verlauf von Usage, konfigurierten Limits und Subscription-Wirkung", "usage": "Usage", "limit": "Limit", + "limits": "Limits", "subscription": "Subscription", + "subscriptions": "Subscriptions", "breakEven": "Break-even", "currentlyUsed": "Aktuell verbraucht", "remainingToLimit": "Bis Limit offen", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index d22d09b..b734cd7 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -72,6 +72,8 @@ "hidden": "Hidden", "open": "Open", "close": "Close", + "previousMonth": "Previous month", + "nextMonth": "Next month", "startDate": "Start date", "endDate": "End date", "selectedProviders": "Selected providers", @@ -165,6 +167,9 @@ "cacheRoi": "Cache ROI" }, "stats": { + "min": "Min", + "max": "Max", + "avg": "Avg", "cacheHitRate": "Cache hit rate", "totalTokens": "Total tokens", "cacheRead": "Cache read", @@ -206,6 +211,7 @@ "spread": "Spread: {{value}}", "medianPerUnit": "Median/{{unit}}", "vsAverage": "{{direction}}{{value}}% vs avg", + "vsAverageWithVolatility": "{{direction}}{{value}}% vs avg · σ Req {{volatility}}", "medianInfo": "The median shows the typical value and is less sensitive to outliers than the average.", "requestLeader": "{{model}} · {{requests}} req", "dominantProviderSubtitle": "{{share}} share · {{cost}}{{requestLeader}}" @@ -250,6 +256,7 @@ "ioRatio": "I/O ratio: {{value}}:1", "topModel": "Top: {{value}}", "cacheMix": "In: {{input}} / Out: {{output}}", + "costPerRequest": "{{value}} / req", "requestsSubtitle": "Avg {{value}}/day · {{cost}}/req", "requestCountersMissing": "No request counters", "thinkingSubtitle": "{{value}} share" @@ -919,6 +926,11 @@ "subscriptionPaysOff": "Subscription pays off", "belowSubscription": "Still below subscription" }, + "badge": { + "limit": "{{value}}% Limit", + "subscription": "{{value}}% Sub", + "open": "Open" + }, "tracks": { "budgetTitle": "Budget status by provider", "budgetSubtitle": "Each track shows the remaining distance to the limit or the already incurred overrun for each provider", @@ -930,7 +942,9 @@ "portfolioSubtitle": "Monthly trend of usage, configured limits, and subscription impact", "usage": "Usage", "limit": "Limit", + "limits": "Limits", "subscription": "Subscription", + "subscriptions": "Subscriptions", "breakEven": "Break-even", "currentlyUsed": "Currently used", "remainingToLimit": "Remaining to limit", diff --git a/src/types/index.ts b/src/types/index.ts index dfcec9f..d7ad7ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -130,12 +130,25 @@ export interface AggregatedPeriod { thinkingTokens: number requestCount: number days: number - modelBreakdowns: Map + modelBreakdowns: Map< + string, + { + cost: number + tokens: number + input: number + output: number + cacheRead: number + cacheCreate: number + thinking: number + requests: number + } + > } export interface ChartDataPoint { date: string cost: number + costPrev?: number ma7?: number cumulative?: number [key: string]: unknown @@ -148,6 +161,8 @@ export interface TokenChartDataPoint { 'Cache Write': number 'Cache Read': number Thinking: number + totalTokens: number + totalTokensPrev?: number tokenMA7?: number inputMA7?: number outputMA7?: number @@ -159,6 +174,7 @@ export interface TokenChartDataPoint { export interface RequestChartDataPoint { date: string totalRequests: number + totalRequestsPrev?: number totalRequestsMA7?: number [key: string]: unknown } diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 3f5af81..3cfd054 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -5,12 +5,14 @@ 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 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 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)$/ @@ -24,16 +26,18 @@ async function uploadSampleUsage(page: Page) { await expect(page.getByText(uploadToastPattern)).toBeVisible() } -test('uploads sample usage data and renders the dashboard without browser errors', async ({ page }) => { +test('uploads sample usage data and renders the dashboard without browser errors', async ({ + page, +}) => { const pageErrors: string[] = [] - page.on('console', message => { + page.on('console', (message) => { if (message.type() === 'error') { pageErrors.push(message.text()) } }) - page.on('pageerror', error => { + page.on('pageerror', (error) => { pageErrors.push(error.message) }) @@ -56,14 +60,26 @@ test('uploads sample usage data and renders the dashboard without browser errors expect(pageErrors, pageErrors.join('\n')).toEqual([]) }) -test('manages settings and backup imports through the settings dialog using isolated test storage', async ({ page }, testInfo) => { +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_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 + onJsonDownload?: (record: { + filename: string + mimeType: string + size: number + text: string + }) => void openSettings?: () => void } } @@ -95,29 +111,45 @@ test('manages settings and backup imports through the settings dialog using isol 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 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 expect(page.locator('#filters').getByRole('combobox').first()).toContainText( + dailyViewPattern, + ) await page.evaluate(() => { const globalWindow = window as typeof window & { @@ -129,23 +161,35 @@ test('manages settings and backup imports through the settings dialog using isol }) 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 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 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 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 expect(page.locator('#filters').getByRole('combobox').first()).toContainText( + monthlyViewPattern, + ) await page.evaluate(() => { const globalWindow = window as typeof window & { @@ -158,43 +202,71 @@ test('manages settings and backup imports through the settings dialog using isol 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__ ?? [] + 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 }) - return records.length - }).toBe(1) + .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 }> + __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$/) + 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')) + 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__ ?? [] + 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 }) - return records.length - }).toBe(2) + .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 }> + __TTDASH_DOWNLOAD_RECORDS__?: Array<{ + filename: string + mimeType: string + size: number + text: string + }> } const records = globalWindow.__TTDASH_DOWNLOAD_RECORDS__ ?? [] return records[1] @@ -205,23 +277,30 @@ test('manages settings and backup imports through the settings dialog using isol 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', + 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)) + }, + null, + 2, + ), + ) await page.locator('[data-testid="data-import-input"]').setInputFiles(importDataPath) await expect(page.getByText(dataImportToastPattern)).toBeVisible() @@ -231,37 +310,46 @@ test('manages settings and backup imports through the settings dialog using isol 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) + 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, + 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', }, }, - 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)) + null, + 2, + ), + ) await page.locator('[data-testid="settings-import-input"]').setInputFiles(importSettingsPath) await expect(page.getByRole('button', { name: 'Export settings' })).toBeVisible() @@ -280,10 +368,18 @@ test('manages settings and backup imports through the settings dialog using isol }) expect(importedSettings.sectionVisibility.tokenAnalysis).toBe(false) expect(importedSettings.sectionVisibility.comparisons).toBe(false) - expect(importedSettings.sectionOrder.slice(0, 4)).toEqual(['tables', 'advancedAnalysis', 'metrics', 'insights']) + 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 }) => { +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') @@ -334,36 +430,59 @@ test('loads persisted settings on a fresh browser start and applies them immedia 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 + .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.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 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']) + 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(() => { @@ -379,13 +498,28 @@ test('loads persisted settings on a fresh browser start and applies them immedia 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.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 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') @@ -397,12 +531,14 @@ test('loads persisted settings on a fresh browser start and applies them immedia } }) -test('uses the current UI language when generating a PDF report after switching locale', async ({ page }) => { +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 => { + await page.route('**/api/report/pdf', async (route) => { reportRequest = JSON.parse(route.request().postData() ?? '{}') as Record await route.fulfill({ status: 200, diff --git a/tests/frontend/chart-card.test.tsx b/tests/frontend/chart-card.test.tsx new file mode 100644 index 0000000..297f3ed --- /dev/null +++ b/tests/frontend/chart-card.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChartCard } from '@/components/charts/ChartCard' +import { initI18n } from '@/lib/i18n' + +describe('ChartCard', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('uses localized stat labels in the expanded view', () => { + render( + +
Content
+
, + ) + + fireEvent.click(screen.getByRole('button', { name: /demo chart expand/i })) + + expect(screen.getByText('Total')).toBeInTheDocument() + expect(screen.getByText('Data points')).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/cost-by-model-over-time.test.tsx b/tests/frontend/cost-by-model-over-time.test.tsx new file mode 100644 index 0000000..13c3e24 --- /dev/null +++ b/tests/frontend/cost-by-model-over-time.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CostByModelOverTime } from '@/components/charts/CostByModelOverTime' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +describe('CostByModelOverTime', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('ignores non-finite series values when computing the top model summary', () => { + render( + + + , + ) + + expect(screen.getByText(/gpt-5\.4/i)).toBeInTheDocument() + expect(screen.getByText(/\$9\.00/)).toBeInTheDocument() + expect(screen.queryByText(/nan/i)).not.toBeInTheDocument() + expect(screen.queryByText(/infinity/i)).not.toBeInTheDocument() + }) +}) diff --git a/tests/frontend/cost-over-time.test.tsx b/tests/frontend/cost-over-time.test.tsx new file mode 100644 index 0000000..0c0bf39 --- /dev/null +++ b/tests/frontend/cost-over-time.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CostOverTime } from '@/components/charts/CostOverTime' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +describe('CostOverTime', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('summarizes the latest point and peak day without reordering the source data', () => { + const data = [ + { date: '2026-04-01', cost: 4 }, + { date: '2026-04-02', cost: 12 }, + { date: '2026-04-03', cost: 6 }, + ] + + render( + + + , + ) + + expect(screen.getByText(/latest \$6\.00 · peak \$12\.0 on 04\/02/i)).toBeInTheDocument() + expect(data.map((point) => point.date)).toEqual(['2026-04-01', '2026-04-02', '2026-04-03']) + }) +}) diff --git a/tests/frontend/filter-bar.test.tsx b/tests/frontend/filter-bar.test.tsx index df48ab1..4c2f43a 100644 --- a/tests/frontend/filter-bar.test.tsx +++ b/tests/frontend/filter-bar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react' +import { fireEvent, 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' @@ -124,4 +124,36 @@ describe('FilterBar', () => { expect(screen.getByRole('button', { name: 'All' }).className).not.toContain('bg-primary') }) + + it('localizes the calendar month navigation aria labels', () => { + const noop = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Start date' })) + expect(screen.getByRole('button', { name: 'Previous month' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next month' })).toBeInTheDocument() + }) }) diff --git a/tests/frontend/phase4-correctness.test.tsx b/tests/frontend/phase4-correctness.test.tsx new file mode 100644 index 0000000..ed2a8a0 --- /dev/null +++ b/tests/frontend/phase4-correctness.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TodayMetrics } from '@/components/cards/TodayMetrics' +import { DrillDownModal } from '@/components/features/drill-down/DrillDownModal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage, DashboardMetrics } from '@/types' + +const emptyMetrics: 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, + 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, +} + +describe('phase 4 UI correctness', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + + await initI18n('en') + }) + + it('falls back safely when today.modelsUsed is missing', () => { + const today = { + date: '2026-04-06', + inputTokens: 50, + outputTokens: 25, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 75, + totalCost: 3, + requestCount: 2, + modelBreakdowns: [], + } as unknown as DailyUsage + + render( + + + , + ) + + expect(screen.getByText('No request counters')).toBeInTheDocument() + expect(screen.getAllByText('0').length).toBeGreaterThan(0) + }) + + it('avoids Infinity and NaN in the drill-down modal when a day has zero tokens', () => { + const day: DailyUsage = { + date: '2026-04-06', + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 0, + totalCost: 4, + requestCount: 1, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 4, + requestCount: 1, + }, + ], + } + + render( + + {}} /> + , + ) + + expect(document.body.textContent).not.toContain('Infinity') + expect(document.body.textContent).not.toContain('NaN') + expect(screen.getAllByText('–').length).toBeGreaterThan(0) + }) + + it('uses the canonical token sum instead of a stale day.totalTokens value', () => { + const day: DailyUsage = { + date: '2026-04-07', + inputTokens: 60, + outputTokens: 20, + cacheCreationTokens: 10, + cacheReadTokens: 10, + thinkingTokens: 0, + totalTokens: 1, + totalCost: 5, + requestCount: 2, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5.4', + inputTokens: 60, + outputTokens: 20, + cacheCreationTokens: 10, + cacheReadTokens: 10, + thinkingTokens: 0, + cost: 5, + requestCount: 2, + }, + ], + } + + render( + + {}} /> + , + ) + + expect(screen.getAllByText('100').length).toBeGreaterThan(0) + expect(screen.getByText(/\$50\.0k/)).toBeInTheDocument() + expect(screen.getByText('Cache Read 10.0%')).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/provider-limits-section.test.tsx b/tests/frontend/provider-limits-section.test.tsx new file mode 100644 index 0000000..a69366d --- /dev/null +++ b/tests/frontend/provider-limits-section.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ProviderLimitsSection } from '@/components/features/limits/ProviderLimitsSection' +import { initI18n } from '@/lib/i18n' +import { TooltipProvider } from '@/components/ui/tooltip' + +describe('ProviderLimitsSection', () => { + beforeEach(async () => { + class MockIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + await initI18n('de') + }) + + it('renders the limit badge for limit, subscription, and open states', () => { + render( + + + , + ) + + expect(screen.getByText('0% Limit')).toBeInTheDocument() + expect(screen.getByText('240% Abo')).toBeInTheDocument() + expect(screen.getByText('Offen')).toBeInTheDocument() + }) +}) diff --git a/tests/frontend/sortable-tables.test.tsx b/tests/frontend/sortable-tables.test.tsx new file mode 100644 index 0000000..147f990 --- /dev/null +++ b/tests/frontend/sortable-tables.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ModelEfficiency } from '@/components/tables/ModelEfficiency' +import { ProviderEfficiency } from '@/components/tables/ProviderEfficiency' +import { RecentDays } from '@/components/tables/RecentDays' +import { TooltipProvider } from '@/components/ui/tooltip' +import { initI18n } from '@/lib/i18n' + +function renderWithProviders(ui: React.ReactNode) { + return render({ui}) +} + +describe('sortable tables', () => { + beforeEach(async () => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + await initI18n('en') + }) + + it('exposes accessible sort state for provider efficiency headers', () => { + renderWithProviders( + , + ) + + const costButton = screen.getByRole('button', { name: /^cost$/i }) + const requestsButton = screen.getByRole('button', { name: /^req$/i }) + const costHeader = costButton.closest('th') + const requestsHeader = requestsButton.closest('th') + + expect(costHeader).toHaveAttribute('aria-sort', 'descending') + expect(costButton).toBeInTheDocument() + expect(requestsHeader).toHaveAttribute('aria-sort', 'none') + + fireEvent.click(requestsButton) + expect(screen.getByRole('button', { name: /^req$/i }).closest('th')).toHaveAttribute( + 'aria-sort', + 'descending', + ) + }) + + it('renders model efficiency sort controls as buttons inside column headers', () => { + renderWithProviders( + , + ) + + const costButton = screen.getByRole('button', { name: /^cost$/i }) + const tokensButton = screen.getByRole('button', { name: /^tokens$/i }) + const costHeader = costButton.closest('th') + + expect(costHeader).toHaveAttribute('aria-sort', 'descending') + expect(costButton).toBeInTheDocument() + + fireEvent.click(tokensButton) + expect(screen.getByRole('button', { name: /^tokens$/i }).closest('th')).toHaveAttribute( + 'aria-sort', + 'descending', + ) + }) + + it('updates aria-sort when recent days headers are toggled', () => { + renderWithProviders( + , + ) + + const dateHeader = screen.getByRole('columnheader', { name: /date/i }) + const costHeader = screen.getByRole('columnheader', { name: /^cost$/i }) + + expect(dateHeader).toHaveAttribute('aria-sort', 'descending') + expect(costHeader).toHaveAttribute('aria-sort', 'none') + + fireEvent.click(within(costHeader).getByRole('button', { name: /^cost$/i })) + expect(costHeader).toHaveAttribute('aria-sort', 'descending') + + fireEvent.click(within(costHeader).getByRole('button', { name: /^cost$/i })) + expect(costHeader).toHaveAttribute('aria-sort', 'ascending') + }) +}) diff --git a/tests/frontend/use-dashboard-filters.test.tsx b/tests/frontend/use-dashboard-filters.test.tsx index 918a164..f5ad17d 100644 --- a/tests/frontend/use-dashboard-filters.test.tsx +++ b/tests/frontend/use-dashboard-filters.test.tsx @@ -25,7 +25,7 @@ describe('useDashboardFilters', () => { result.current.toggleProvider('OpenAI') }) - expect(result.current.filteredDailyData.map(entry => entry.date)).toEqual([ + expect(result.current.filteredDailyData.map((entry) => entry.date)).toEqual([ '2026-03-30', '2026-03-31', '2026-04-06', diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index d0e68ef..2076a54 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -5,7 +5,10 @@ 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' +import { + DEFAULT_DASHBOARD_FILTERS, + getDefaultDashboardSectionOrder, +} from '@/lib/dashboard-preferences' let child: ChildProcessWithoutNullStreams | null = null let baseUrl = '' @@ -57,7 +60,7 @@ async function waitForServer(url: string) { } } catch {} - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server startup:\n${output}`) @@ -74,7 +77,7 @@ async function waitForUrlAvailable(url: string) { } } catch {} - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server startup: ${url}`) @@ -90,7 +93,7 @@ async function waitForServerUnavailable(url: string) { return } - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server shutdown: ${url}`) @@ -115,7 +118,7 @@ async function waitForProcessServer( } } catch {} - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for server startup:\n${getOutput()}`) @@ -127,7 +130,7 @@ async function stopProcess(currentChild: ChildProcessWithoutNullStreams) { } currentChild.kill('SIGTERM') - await new Promise(resolve => currentChild.once('close', resolve)) + await new Promise((resolve) => currentChild.once('close', resolve)) } function createCliEnv(root: string) { @@ -154,7 +157,7 @@ async function startStandaloneServer({ args?: string[] envOverrides?: NodeJS.ProcessEnv }) { - const port = Number(envOverrides.PORT) || await getFreePort() + const port = Number(envOverrides.PORT) || (await getFreePort()) const url = `http://127.0.0.1:${port}` let serverOutput = '' @@ -168,11 +171,11 @@ async function startStandaloneServer({ stdio: ['ignore', 'pipe', 'pipe'], }) - currentChild.stdout.on('data', chunk => { + currentChild.stdout.on('data', (chunk) => { serverOutput += chunk.toString() }) - currentChild.stderr.on('data', chunk => { + currentChild.stderr.on('data', (chunk) => { serverOutput += chunk.toString() }) @@ -200,7 +203,11 @@ function getCliConfigDir(root: string) { 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 }> + return JSON.parse(readFileSync(registryPath, 'utf-8')) as Array<{ + url: string + port: number + pid: number + }> } function writeBackgroundRegistry(root: string, entries: unknown) { @@ -209,8 +216,8 @@ function writeBackgroundRegistry(root: string, entries: unknown) { 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) => { +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, @@ -219,16 +226,16 @@ async function runCli(args: string[], { env, input }: { env: NodeJS.ProcessEnv, let cliOutput = '' - cli.stdout.on('data', chunk => { + cli.stdout.on('data', (chunk) => { cliOutput += chunk.toString() }) - cli.stderr.on('data', chunk => { + cli.stderr.on('data', (chunk) => { cliOutput += chunk.toString() }) cli.on('error', reject) - cli.on('close', code => { + cli.on('close', (code) => { resolve({ code, output: cliOutput }) }) @@ -272,11 +279,11 @@ beforeAll(async () => { stdio: ['ignore', 'pipe', 'pipe'], }) - child.stdout.on('data', chunk => { + child.stdout.on('data', (chunk) => { output += chunk.toString() }) - child.stderr.on('data', chunk => { + child.stderr.on('data', (chunk) => { output += chunk.toString() }) @@ -560,11 +567,9 @@ describe('local server API', () => { { ...sampleUsage.daily[1], totalCost: 999, - modelBreakdowns: sampleUsage.daily[1].modelBreakdowns.map((entry, index) => ( - index === 0 - ? { ...entry, cost: 997 } - : entry - )), + modelBreakdowns: sampleUsage.daily[1].modelBreakdowns.map((entry, index) => + index === 0 ? { ...entry, cost: 997 } : entry, + ), }, newImportedDay, ], @@ -586,7 +591,9 @@ describe('local server API', () => { 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) + 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) @@ -802,9 +809,12 @@ describe('local server API', () => { expect(firstStart.output).toContain(firstUrl) await waitForUrlAvailable(firstUrl) - const secondStart = await runCli(['--background', '--no-open', '--port', String(secondPort)], { - env: backgroundEnv, - }) + 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.') @@ -867,7 +877,7 @@ describe('local server API', () => { const registry = readBackgroundRegistry(backgroundRoot) expect(registry).toHaveLength(2) - expect(registry.map(instance => instance.url).sort()).toEqual([firstUrl, secondUrl].sort()) + expect(registry.map((instance) => instance.url).sort()).toEqual([firstUrl, secondUrl].sort()) } finally { await stopAllBackgroundServers(backgroundEnv) rmSync(backgroundRoot, { recursive: true, force: true }) @@ -883,15 +893,17 @@ describe('local server API', () => { 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, - }]) + 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, @@ -912,23 +924,30 @@ describe('local server API', () => { 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' + const expectedPlatformPaths = + process.platform === 'darwin' ? { - 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'), + dataFile: path.join( + runtimeRoot, + 'Library', + 'Application Support', + 'TTDash', + 'data.json', + ), settingsFile: path.join(explicitConfigDir, 'settings.json'), - cacheDir: path.join(runtimeRoot, 'cache', 'ttdash'), + 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 @@ -954,8 +973,12 @@ describe('local server API', () => { }) expect(settingsResponse.status).toBe(200) - expect(standaloneServer.getOutput()).toContain(`Data File: ${expectedPlatformPaths.dataFile}`) - expect(standaloneServer.getOutput()).toContain(`Settings File: ${expectedPlatformPaths.settingsFile}`) + 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) @@ -986,7 +1009,7 @@ describe('local server API', () => { 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))) + 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 c94d61e..0fc1470 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -7,16 +7,12 @@ describe('dashboard analytics', () => { it('recalculates totals when provider filtering removes model breakdowns', () => { const filtered = filterByProviders(dashboardFixture, ['OpenAI']) - expect(filtered.map(entry => entry.date)).toEqual([ - '2026-03-30', - '2026-03-31', - '2026-04-06', - ]) + expect(filtered.map((entry) => entry.date)).toEqual(['2026-03-30', '2026-03-31', '2026-04-06']) expect(filtered[0]).toMatchObject({ totalCost: 6, totalTokens: 210, requestCount: 3, - modelsUsed: ['gpt-5.4'], + modelsUsed: ['GPT-5.4'], }) }) @@ -135,11 +131,6 @@ describe('dashboard analytics', () => { }) it('computes moving averages with leading gaps instead of partial windows', () => { - expect(computeMovingAverage([1, 2, 3, 4], 3)).toEqual([ - undefined, - undefined, - 2, - 3, - ]) + expect(computeMovingAverage([1, 2, 3, 4], 3)).toEqual([undefined, undefined, 2, 3]) }) }) diff --git a/tests/unit/code-rabbit-phase1.test.ts b/tests/unit/code-rabbit-phase1.test.ts new file mode 100644 index 0000000..58a891a --- /dev/null +++ b/tests/unit/code-rabbit-phase1.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { buildChartCsv, stringifyCsvCell } from '@/components/charts/ChartCard' +import { + buildProviderLimitsState, + reorderSections, +} from '@/components/features/settings/SettingsModal' +import { parseEventData } from '@/lib/auto-import' + +describe('phase 1 helper fixes', () => { + it('reorders sections to the target slot when dragging downward', () => { + expect(reorderSections(['metrics', 'activity', 'tables'], 'metrics', 'tables')).toEqual([ + 'activity', + 'metrics', + 'tables', + ]) + }) + + it('replaces provider limit state instead of preserving stale providers', () => { + expect( + buildProviderLimitsState(['OpenAI', 'Anthropic'], { + OpenAI: { + monthlyLimit: 120, + hasSubscription: false, + subscriptionPrice: 0, + }, + Anthropic: { + monthlyLimit: 0, + hasSubscription: true, + subscriptionPrice: 50, + }, + Legacy: { + monthlyLimit: 999, + hasSubscription: true, + subscriptionPrice: 999, + }, + }), + ).toEqual({ + OpenAI: { + monthlyLimit: 120, + hasSubscription: false, + subscriptionPrice: 0, + }, + Anthropic: { + monthlyLimit: 0, + hasSubscription: true, + subscriptionPrice: 50, + }, + }) + }) + + it('returns null for malformed or non-object auto-import events', () => { + expect(parseEventData(new MessageEvent('message', { data: '{"message":"ok"}' }))).toEqual({ + message: 'ok', + }) + expect(parseEventData(new MessageEvent('message', { data: '{"message"' }))).toBeNull() + expect(parseEventData(new MessageEvent('message', { data: '[]' }))).toBeNull() + expect(parseEventData(new MessageEvent('message', { data: '"oops"' }))).toBeNull() + }) + + it('quotes CSV cells and preserves commas, quotes, and newlines', () => { + expect(stringifyCsvCell('value,with,"quotes"\nand newline')).toBe( + '"value,with,""quotes""\nand newline"', + ) + expect( + buildChartCsv([ + { + label: 'hello,world', + note: 'line 1\nline 2', + quote: '"quoted"', + }, + ]), + ).toBe('"label","note","quote"\n"hello,world","line 1\nline 2","""quoted"""') + }) +}) diff --git a/tests/unit/code-rabbit-phase4.test.ts b/tests/unit/code-rabbit-phase4.test.ts new file mode 100644 index 0000000..da7acb0 --- /dev/null +++ b/tests/unit/code-rabbit-phase4.test.ts @@ -0,0 +1,110 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { filterByModels, getDateRange, toWeekdayData } from '@/lib/data-transforms' +import { coerceNumber, formatMonthYear } from '@/lib/formatters' +import { initI18n } from '@/lib/i18n' +import type { DailyUsage } from '@/types' + +describe('phase 4 correctness helpers', () => { + beforeAll(async () => { + await initI18n('en') + }) + + it('returns null instead of coercing malformed numeric values to zero', () => { + expect(coerceNumber(undefined)).toBeNull() + expect(coerceNumber(Number.NaN)).toBeNull() + expect(coerceNumber(Number.POSITIVE_INFINITY)).toBeNull() + expect(coerceNumber('not-a-number')).toBeNull() + expect(coerceNumber('42.5')).toBe(42.5) + expect(coerceNumber(7)).toBe(7) + }) + + it('rejects malformed month identifiers instead of defaulting them to January', () => { + expect(formatMonthYear('2026')).toBe('') + expect(formatMonthYear('2026-13')).toBe('') + expect(formatMonthYear('2026-04')).toBe('April 2026') + }) + + it('deduplicates normalized modelsUsed entries after model filtering', () => { + const data: DailyUsage[] = [ + { + date: '2026-04-01', + inputTokens: 30, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 40, + totalCost: 3, + requestCount: 2, + modelsUsed: ['gpt-5-4', 'gpt-5.4'], + modelBreakdowns: [ + { + modelName: 'gpt-5-4', + inputTokens: 20, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 2, + requestCount: 1, + }, + { + modelName: 'gpt-5.4', + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + cost: 1, + requestCount: 1, + }, + ], + }, + ] + + expect(filterByModels(data, ['GPT-5.4'])[0]?.modelsUsed).toEqual(['GPT-5.4']) + }) + + it('finds the date range from the first valid entry instead of assuming index zero is usable', () => { + const validEntry: DailyUsage = { + date: '2026-04-03', + inputTokens: 1, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 2, + totalCost: 1, + requestCount: 1, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + } + const laterEntry: DailyUsage = { ...validEntry, date: '2026-04-06' } + + expect(getDateRange([undefined, validEntry, laterEntry] as unknown as DailyUsage[])).toEqual({ + start: '2026-04-03', + end: '2026-04-06', + }) + }) + + it('keeps weekday labels aligned with Monday-first buckets', () => { + const weekdayData = toWeekdayData([ + { + date: '2026-04-06', + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalTokens: 15, + totalCost: 9, + requestCount: 1, + modelsUsed: ['gpt-5.4'], + modelBreakdowns: [], + }, + ]) + + expect(weekdayData[0]?.day).toBe('Mo') + expect(weekdayData[0]?.cost).toBe(9) + }) +}) diff --git a/tests/unit/help-content.test.ts b/tests/unit/help-content.test.ts new file mode 100644 index 0000000..fb9e2c4 --- /dev/null +++ b/tests/unit/help-content.test.ts @@ -0,0 +1,26 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { CHART_HELP } from '@/lib/help-content' +import { initI18n } from '@/lib/i18n' + +describe('help-content proxy semantics', () => { + beforeAll(async () => { + await initI18n('en') + }) + + it('only reports own property descriptors for existing help entries', () => { + expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'costOverTime')).toBe(true) + expect(Object.getOwnPropertyDescriptor(CHART_HELP, 'costOverTime')).toMatchObject({ + enumerable: true, + configurable: true, + }) + + expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'missingHelpKey')).toBe(false) + expect(Object.getOwnPropertyDescriptor(CHART_HELP, 'missingHelpKey')).toBeUndefined() + }) + + it('does not expose prototype properties as help keys', () => { + expect('toString' in CHART_HELP).toBe(false) + expect(Object.prototype.hasOwnProperty.call(CHART_HELP, 'toString')).toBe(false) + expect(CHART_HELP.toString).toBeUndefined() + }) +}) diff --git a/tests/unit/model-normalization.test.ts b/tests/unit/model-normalization.test.ts new file mode 100644 index 0000000..92ebe36 --- /dev/null +++ b/tests/unit/model-normalization.test.ts @@ -0,0 +1,62 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' +import { + getModelProvider as getUiModelProvider, + normalizeModelName as normalizeUiModelName, +} from '@/lib/model-utils' + +const require = createRequire(import.meta.url) +const { + __test__: { + getModelProvider: getReportModelProvider, + normalizeModelName: normalizeReportModelName, + }, +} = require('../../server/report/utils.js') as { + __test__: { + getModelProvider: (raw: string) => string + normalizeModelName: (raw: string) => string + } +} + +const MODEL_CASES = [ + { raw: 'claude-opus-4.5', name: 'Opus 4.5', provider: 'Anthropic' }, + { raw: 'claude-opus-4-5-20251101', name: 'Opus 4.5', provider: 'Anthropic' }, + { raw: 'claude-sonnet-4-20250514', name: 'Sonnet 4', provider: 'Anthropic' }, + { raw: 'claude-haiku-4-5', name: 'Haiku 4.5', provider: 'Anthropic' }, + { raw: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI' }, + { raw: 'gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' }, + { raw: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' }, + { raw: 'gpt-5-4-codex', name: 'GPT-5.4 Codex', provider: 'OpenAI' }, + { raw: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI' }, + { raw: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'Google' }, + { raw: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'Google' }, + { + raw: 'gemini-3-flash-preview', + name: 'Gemini 3 Flash Preview', + provider: 'Google', + }, + { + raw: 'gemini-3-flash-preview-experimental', + name: 'Gemini 3 Flash Preview Experimental', + provider: 'Google', + }, + { raw: 'codex-mini-latest', name: 'Codex Mini', provider: 'OpenAI' }, + { raw: 'o4-mini', name: 'o4 Mini', provider: 'OpenAI' }, + { raw: 'o1', name: 'o1', provider: 'OpenAI' }, + { raw: 'opencode', name: 'OpenCode', provider: 'OpenCode' }, +] as const + +describe('model normalization parity', () => { + it.each(MODEL_CASES)('normalizes $raw consistently in UI and report', ({ raw, name }) => { + expect(normalizeUiModelName(raw)).toBe(name) + expect(normalizeReportModelName(raw)).toBe(name) + }) + + it.each(MODEL_CASES)( + 'maps provider for $raw consistently in UI and report', + ({ raw, provider }) => { + expect(getUiModelProvider(raw)).toBe(provider) + expect(getReportModelProvider(raw)).toBe(provider) + }, + ) +}) diff --git a/tests/unit/report-charts.test.ts b/tests/unit/report-charts.test.ts index 333f19d..83c785d 100644 --- a/tests/unit/report-charts.test.ts +++ b/tests/unit/report-charts.test.ts @@ -4,37 +4,47 @@ describe('report charts', () => { it('uses the provided formatter for stacked chart y-axis labels', async () => { const { stackedBarChart } = await import('../../server/report/charts.js') - const svg = stackedBarChart([ - { label: 'Mar', input: 1200, output: 300, cacheWrite: 0, cacheRead: 0, thinking: 0 }, - { label: 'Apr', input: 2400, output: 600, cacheWrite: 100, cacheRead: 20, thinking: 0 }, - ], { - title: 'Token mix', - formatter: value => `fmt:${Math.round(value)}`, - segments: [ - { key: 'input', label: 'Input', color: '#000' }, - { key: 'output', label: 'Output', color: '#111' }, - { key: 'cacheWrite', label: 'Cache Write', color: '#222' }, - { key: 'cacheRead', label: 'Cache Read', color: '#333' }, - { key: 'thinking', label: 'Thinking', color: '#444' }, + const svg = stackedBarChart( + [ + { label: 'Mar', input: 1200, output: 300, cacheWrite: 0, cacheRead: 0, thinking: 0 }, + { label: 'Apr', input: 2400, output: 600, cacheWrite: 100, cacheRead: 20, thinking: 0 }, ], - }) + { + title: 'Token mix', + formatter: (value) => `fmt:${Math.round(value)}`, + segments: [ + { key: 'input', label: 'Input', color: '#000' }, + { key: 'output', label: 'Output', color: '#111' }, + { key: 'cacheWrite', label: 'Cache Write', color: '#222' }, + { key: 'cacheRead', label: 'Cache Read', color: '#333' }, + { key: 'thinking', label: 'Thinking', color: '#444' }, + ], + }, + ) expect(svg).toContain('fmt:0') expect(svg).toContain('fmt:780') - expect(svg).not.toContain("de-CH") + expect(svg).not.toContain('de-CH') }) it('truncates overly long horizontal bar labels to keep the chart readable', async () => { const { horizontalBarChart } = await import('../../server/report/charts.js') - const svg = horizontalBarChart([ - { name: 'This is a very long model name that should not overflow the chart area', value: 42, color: '#123456' }, - ], { - title: 'Top models', - getValue: entry => entry.value, - getLabel: entry => entry.name, - getColor: entry => entry.color, - }) + const svg = horizontalBarChart( + [ + { + name: 'This is a very long model name that should not overflow the chart area', + value: 42, + color: '#123456', + }, + ], + { + title: 'Top models', + getValue: (entry) => entry.value, + getLabel: (entry) => entry.name, + getColor: (entry) => entry.color, + }, + ) expect(svg).toContain('This is a very long model nam…') }) diff --git a/tests/unit/report-utils.test.ts b/tests/unit/report-utils.test.ts index e38c152..288b534 100644 --- a/tests/unit/report-utils.test.ts +++ b/tests/unit/report-utils.test.ts @@ -24,8 +24,12 @@ describe('report utils', () => { selectedModels: ['gpt-5.4', 'claude-sonnet-4-5', 'gemini-2.5-pro', 'opencode'], }) - expect(report.meta.filterSummary.selectedProvidersLabel).toBe('OpenAI, Anthropic, Google +1 more') - expect(report.meta.filterSummary.selectedModelsLabel).toBe('GPT-5.4, Sonnet 4.5, Gemini +1 more') + expect(report.meta.filterSummary.selectedProvidersLabel).toBe( + 'OpenAI, Anthropic, Google +1 more', + ) + expect(report.meta.filterSummary.selectedModelsLabel).toBe( + 'GPT-5.4, Sonnet 4.5, Gemini 2.5 Pro +1 more', + ) expect(report.summaryCards[5].label).toBe('Peak period') expect(report.summaryCards[5].value).not.toMatch(/^\d{4}-\d{2}-\d{2}$/) }) @@ -53,8 +57,14 @@ describe('report utils', () => { }) expect(report.insights.items.length).toBeGreaterThan(0) - expect(report.insights.items.some((item: { title: string }) => item.title === 'Data coverage')).toBe(true) - expect(report.insights.items.some((item: { title: string }) => item.title === 'Provider concentration')).toBe(true) + expect( + report.insights.items.some((item: { title: string }) => item.title === 'Data coverage'), + ).toBe(true) + expect( + report.insights.items.some( + (item: { title: string }) => item.title === 'Provider concentration', + ), + ).toBe(true) }) it('formats compact chart axes for the current language', async () => { @@ -70,10 +80,10 @@ describe('report utils', () => { it('keeps cache insights visible without request counters when token cache data exists', async () => { const { buildReportData } = await import('../../server/report/utils.js') - const dataWithoutRequests = dashboardFixture.map(day => ({ + const dataWithoutRequests = dashboardFixture.map((day) => ({ ...day, requestCount: 0, - modelBreakdowns: day.modelBreakdowns.map(entry => ({ + modelBreakdowns: day.modelBreakdowns.map((entry) => ({ ...entry, requestCount: 0, })), @@ -86,7 +96,9 @@ describe('report utils', () => { expect(report.metrics.hasRequestData).toBe(false) expect(report.metrics.cacheHitRate).toBeGreaterThan(0) - expect(report.insights.items.some((item: { title: string }) => item.title === 'Cache contribution')).toBe(true) + expect( + report.insights.items.some((item: { title: string }) => item.title === 'Cache contribution'), + ).toBe(true) }) it('keeps percent strings in german report output and localizes the report header', async () => { @@ -98,7 +110,9 @@ describe('report utils', () => { }) expect(report.summaryCards[0].note).toContain('%') - expect(report.insights.items.some((item: { body: string }) => item.body.includes('%'))).toBe(true) + expect(report.insights.items.some((item: { body: string }) => item.body.includes('%'))).toBe( + true, + ) expect(report.text.headerEyebrow).toBe('TTDash PDF-Bericht') }) @@ -111,7 +125,9 @@ describe('report utils', () => { }) expect(report.labels.topModel).toContain(report.summaryCards[4].note) - expect(report.labels.topProvider).toContain(report.summaryCards[0].note.replace(`${report.metrics.topProvider?.name} `, '')) + expect(report.labels.topProvider).toContain( + report.summaryCards[0].note.replace(`${report.metrics.topProvider?.name} `, ''), + ) }) it('uses period averages for aggregated summary cards', async () => { @@ -133,4 +149,18 @@ describe('report utils', () => { expect(yearlyReport.summaryCards[3].value).toBe('$30.00') expect(monthlyReport.summaryCards[3].value).not.toBe('$7.50') }) + + it('normalizes current toktrack model families in report filter summaries', async () => { + const { buildReportData } = await import('../../server/report/utils.js') + + const report = buildReportData(dashboardFixture, { + viewMode: 'daily', + language: 'en', + selectedModels: ['gpt-5.3-codex', 'gemini-2.5-flash', 'codex-mini-latest', 'o4-mini'], + }) + + expect(report.meta.filterSummary.selectedModelsLabel).toBe( + 'GPT-5.3 Codex, Gemini 2.5 Flash, Codex Mini +1 more', + ) + }) }) diff --git a/tests/unit/server-helpers.test.ts b/tests/unit/server-helpers.test.ts new file mode 100644 index 0000000..0de76a7 --- /dev/null +++ b/tests/unit/server-helpers.test.ts @@ -0,0 +1,111 @@ +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + __test__: { getExecutableName, listenOnAvailablePort }, +} = require('../../server.js') as { + __test__: { + getExecutableName: (baseName: string, isWindows?: boolean) => string + listenOnAvailablePort: ( + serverInstance: { + once: (event: string, handler: (...args: unknown[]) => void) => unknown + off: (event: string, handler: (...args: unknown[]) => void) => unknown + listen: (port: number, bindHost: string) => void + }, + port: number, + maxPort: number, + bindHost: string, + log?: (message: string) => void, + rangeStartPort?: number, + ) => Promise + } +} + +function createFakeServer( + onListen: (port: number, bindHost: string, emitter: EventEmitter) => void, +) { + const emitter = new EventEmitter() + + return { + once(event: string, handler: (...args: unknown[]) => void) { + emitter.once(event, handler) + return this + }, + off(event: string, handler: (...args: unknown[]) => void) { + emitter.off(event, handler) + return this + }, + listen(port: number, bindHost: string) { + onListen(port, bindHost, emitter) + }, + } +} + +describe('server helper utilities', () => { + it('maps executable names correctly across platforms', () => { + expect(getExecutableName('bun', true)).toBe('bun.exe') + expect(getExecutableName('bunx', true)).toBe('bun.exe') + expect(getExecutableName('npx', true)).toBe('npx.cmd') + expect(getExecutableName('toktrack', true)).toBe('toktrack') + expect(getExecutableName('bun', false)).toBe('bun') + expect(getExecutableName('bunx', false)).toBe('bunx') + expect(getExecutableName('npx', false)).toBe('npx') + }) + + it('retries iteratively on EADDRINUSE and logs each skipped port', async () => { + const attempts: number[] = [] + const logs: string[] = [] + const fakeServer = createFakeServer((port, _bindHost, emitter) => { + attempts.push(port) + queueMicrotask(() => { + if (port < 3002) { + emitter.emit('error', Object.assign(new Error('busy'), { code: 'EADDRINUSE' })) + return + } + emitter.emit('listening') + }) + }) + + const resolvedPort = await listenOnAvailablePort( + fakeServer, + 3000, + 3002, + '127.0.0.1', + (message) => logs.push(message), + ) + + expect(resolvedPort).toBe(3002) + expect(attempts).toEqual([3000, 3001, 3002]) + expect(logs).toEqual([ + 'Port 3000 is in use, trying 3001...', + 'Port 3001 is in use, trying 3002...', + ]) + }) + + it('throws the configured range error when no free port exists', async () => { + const fakeServer = createFakeServer((_port, _bindHost, emitter) => { + queueMicrotask(() => { + emitter.emit('error', Object.assign(new Error('busy'), { code: 'EADDRINUSE' })) + }) + }) + + await expect( + listenOnAvailablePort(fakeServer, 4100, 4101, '127.0.0.1', () => undefined, 4100), + ).rejects.toThrow('No free port found (4100-4101)') + }) + + it('rethrows non-EADDRINUSE errors unchanged', async () => { + const permissionError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + const fakeServer = createFakeServer((_port, _bindHost, emitter) => { + queueMicrotask(() => { + emitter.emit('error', permissionError) + }) + }) + + await expect( + listenOnAvailablePort(fakeServer, 5200, 5201, '127.0.0.1', () => undefined, 5200), + ).rejects.toBe(permissionError) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 3543da3..181bdc2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,16 +4,26 @@ "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "verbatimModuleSyntax": true, "skipLibCheck": true, "moduleResolution": "bundler", + "allowUnreachableCode": false, + "allowUnusedLabels": false, "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"] } }, diff --git a/usage-normalizer.js b/usage-normalizer.js index 26cbdf1..1cdb259 100644 --- a/usage-normalizer.js +++ b/usage-normalizer.js @@ -1,9 +1,9 @@ function toNumber(value) { - return Number.isFinite(value) ? value : Number(value) || 0 + return Number.isFinite(value) ? value : Number(value) || 0; } function toStringValue(value) { - return typeof value === 'string' ? value : '' + return typeof value === 'string' ? value : ''; } function normalizeLegacyModelBreakdown(entry) { @@ -20,13 +20,13 @@ function normalizeLegacyModelBreakdown(entry) { } function withDailyTotals(day) { - const totalTokens = toNumber(day.totalTokens) || ( + const totalTokens = + toNumber(day.totalTokens) || toNumber(day.inputTokens) + - toNumber(day.outputTokens) + - toNumber(day.cacheCreationTokens) + - toNumber(day.cacheReadTokens) + - toNumber(day.thinkingTokens) - ); + toNumber(day.outputTokens) + + toNumber(day.cacheCreationTokens) + + toNumber(day.cacheReadTokens) + + toNumber(day.thinkingTokens); return { date: toStringValue(day.date), @@ -38,8 +38,12 @@ function withDailyTotals(day) { totalTokens, totalCost: toNumber(day.totalCost), requestCount: toNumber(day.requestCount), - modelsUsed: Array.isArray(day.modelsUsed) ? day.modelsUsed.filter((value) => typeof value === 'string') : [], - modelBreakdowns: Array.isArray(day.modelBreakdowns) ? day.modelBreakdowns.map(normalizeLegacyModelBreakdown) : [], + modelsUsed: Array.isArray(day.modelsUsed) + ? day.modelsUsed.filter((value) => typeof value === 'string') + : [], + modelBreakdowns: Array.isArray(day.modelBreakdowns) + ? day.modelBreakdowns.map(normalizeLegacyModelBreakdown) + : [], }; } @@ -66,9 +70,10 @@ function normalizeLegacyDay(entry) { } function normalizeToktrackDay(entry) { - const models = entry?.models && typeof entry.models === 'object' && !Array.isArray(entry.models) - ? entry.models - : {}; + const models = + entry?.models && typeof entry.models === 'object' && !Array.isArray(entry.models) + ? entry.models + : {}; const modelBreakdowns = Object.entries(models).map(([modelName, modelData]) => ({ modelName, @@ -98,25 +103,28 @@ function normalizeToktrackDay(entry) { } function computeTotals(daily) { - return daily.reduce((totals, day) => ({ - inputTokens: totals.inputTokens + day.inputTokens, - outputTokens: totals.outputTokens + day.outputTokens, - cacheCreationTokens: totals.cacheCreationTokens + day.cacheCreationTokens, - cacheReadTokens: totals.cacheReadTokens + day.cacheReadTokens, - thinkingTokens: totals.thinkingTokens + day.thinkingTokens, - totalCost: totals.totalCost + day.totalCost, - totalTokens: totals.totalTokens + day.totalTokens, - requestCount: totals.requestCount + day.requestCount, - }), { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - thinkingTokens: 0, - totalCost: 0, - totalTokens: 0, - requestCount: 0, - }); + return daily.reduce( + (totals, day) => ({ + inputTokens: totals.inputTokens + day.inputTokens, + outputTokens: totals.outputTokens + day.outputTokens, + cacheCreationTokens: totals.cacheCreationTokens + day.cacheCreationTokens, + cacheReadTokens: totals.cacheReadTokens + day.cacheReadTokens, + thinkingTokens: totals.thinkingTokens + day.thinkingTokens, + totalCost: totals.totalCost + day.totalCost, + totalTokens: totals.totalTokens + day.totalTokens, + requestCount: totals.requestCount + day.requestCount, + }), + { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0, + totalCost: 0, + totalTokens: 0, + requestCount: 0, + }, + ); } function normalizeIncomingData(payload) { @@ -125,7 +133,9 @@ function normalizeIncomingData(payload) { if (Array.isArray(payload)) { daily = payload.map(normalizeToktrackDay); } else if (payload && typeof payload === 'object' && Array.isArray(payload.daily)) { - const looksLikeToktrack = payload.daily.some((item) => item && typeof item === 'object' && 'total_input_tokens' in item); + const looksLikeToktrack = payload.daily.some( + (item) => item && typeof item === 'object' && 'total_input_tokens' in item, + ); daily = looksLikeToktrack ? payload.daily.map(normalizeToktrackDay) : payload.daily.map(normalizeLegacyDay); @@ -133,9 +143,7 @@ function normalizeIncomingData(payload) { throw new Error('Die JSON-Datei muss ein gültiges tägliches Nutzungsformat enthalten.'); } - const filtered = daily - .filter((item) => item.date) - .sort((a, b) => a.date.localeCompare(b.date)); + const filtered = daily.filter((item) => item.date).sort((a, b) => a.date.localeCompare(b.date)); if (filtered.length === 0) { throw new Error('Keine Nutzungsdaten gefunden.'); diff --git a/vitest.config.ts b/vitest.config.ts index 29f3e65..4d86682 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,41 +2,44 @@ import { defineConfig, mergeConfig } from 'vitest/config' import viteConfig from './vite.config' export default defineConfig(async () => { - const resolvedViteConfig = typeof viteConfig === 'function' - ? await viteConfig({ - command: 'serve', - mode: 'test', - isSsrBuild: false, - isPreview: false, - }) - : viteConfig + // Resolve the imported Vite config explicitly before mergeConfig because + // vite.config may export either a config object or an async config factory. + // Vitest needs stable test-mode inputs when reusing that config here. + const resolvedViteConfig = + typeof viteConfig === 'function' + ? await viteConfig({ + command: 'serve', + mode: 'test', + isSsrBuild: false, + isPreview: false, + }) + : viteConfig - return mergeConfig(resolvedViteConfig, defineConfig({ - test: { - environment: 'node', - setupFiles: ['./vitest.setup.ts'], - include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], - reporters: ['default', 'junit'], - outputFile: { - junit: './test-results/vitest.junit.xml', + return mergeConfig( + resolvedViteConfig, + defineConfig({ + test: { + environment: 'node', + setupFiles: ['./vitest.setup.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + reporters: ['default', 'junit'], + outputFile: { + junit: './test-results/vitest.junit.xml', + }, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: './coverage', + include: ['src/hooks/**/*.ts', 'src/lib/**/*.ts', 'usage-normalizer.js'], + exclude: [ + 'src/lib/i18n.ts', + 'src/lib/constants.ts', + 'src/lib/help-content.ts', + 'src/lib/cn.ts', + 'tests/**', + ], + }, }, - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov'], - reportsDirectory: './coverage', - include: [ - 'src/hooks/**/*.ts', - 'src/lib/**/*.ts', - 'usage-normalizer.js', - ], - exclude: [ - 'src/lib/i18n.ts', - 'src/lib/constants.ts', - 'src/lib/help-content.ts', - 'src/lib/cn.ts', - 'tests/**', - ], - }, - }, - })) + }), + ) })

handleSort('date')}> - {t('tables.recentDays.date')} + + handleSort('cost')}> - {t('tables.recentDays.cost')} + + handleSort('tokens')}> - {t('tables.recentDays.tokens')} + + + + {t('common.input')} + + {t('common.output')} + + {t('common.cacheWrite')} {t('common.input')}{t('common.output')}{t('common.cacheWrite')}{t('common.cacheRead')}{t('common.thinking')}{t('common.requestsShort')} handleSort('costPerM')}> - $/1M + + {t('common.cacheRead')} + + {t('common.thinking')} + + {t('common.requestsShort')} + + + + {t('tables.recentDays.models')} {t('tables.recentDays.models')}
{formatDate(day.date, 'long')} + {formatDate(day.date, 'long')} + -
0 ? (day.totalCost / maxCost) * 100 : 0}%` }} /> - +
0 ? (day.totalCost / maxCost) * 100 : 0}%` }} + /> + + +
@@ -267,8 +439,13 @@ export function RecentDays({ data, onClickDay, viewMode = 'daily' }: RecentDaysP
{day.modelBreakdowns - .map(mb => ({ name: normalizeModelName(mb.modelName), provider: getModelProvider(mb.modelName) })) - .filter((entry, i, a) => a.findIndex(item => item.name === entry.name) === i) + .map((mb) => ({ + name: normalizeModelName(mb.modelName), + provider: getModelProvider(mb.modelName), + })) + .filter( + (entry, i, a) => a.findIndex((item) => item.name === entry.name) === i, + ) .map(({ name, provider }) => ( {name} - + {provider} ))}
- {viewMode === 'daily' && benchmarkMap.get(day.date)?.avgCost7 !== undefined && ( -
- {t('tables.recentDays.previousDay')} {benchmarkMap.get(day.date)?.prevCostDelta !== undefined ? `${benchmarkMap.get(day.date)!.prevCostDelta! >= 0 ? '↑' : '↓'}${Math.abs(benchmarkMap.get(day.date)!.prevCostDelta!).toFixed(0)}%` : '–'} · {t('tables.recentDays.avg7d')} {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} -
- )} + {viewMode === 'daily' && + benchmarkMap.get(day.date)?.avgCost7 !== undefined && ( +
+ {t('tables.recentDays.previousDay')}{' '} + {benchmarkMap.get(day.date)?.prevCostDelta !== undefined + ? `${benchmarkMap.get(day.date)!.prevCostDelta! >= 0 ? '↑' : '↓'}${Math.abs(benchmarkMap.get(day.date)!.prevCostDelta!).toFixed(0)}%` + : '–'}{' '} + · {t('tables.recentDays.avg7d')}{' '} + {formatCurrency(benchmarkMap.get(day.date)!.avgCost7!)} +
+ )}