claude-token-tracker
pepperonas/claude-token-tracker/CLAUDE.md
No build step — vanilla JS frontend served directly from public/. Environment config via .env (see .env.example). Dashboard that tracks Claude Code token usage. Pure Node.js HTTP server (no Express), SQLite database, vanilla JS frontend with Chart.js. Supports single-user (local) and multi-user (hosted) modes. Single-user mode (default): **Multi-user mode (MULTI_USER=true):**
CLAUDE.md11 starsChanged 2 months ago
- Reads credentials
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm start # Start server on port 5010
npm test # Run all tests (vitest)
npm run test:watch # Tests in watch mode
npm run test:coverage # Coverage report
npm run lint # ESLint (lib/ + server.js only)
npx vitest run test/parser.test.js # Run a single test file
npm run badges # Recompute the test/LOC badges in all READMEs
bash scripts/deploy.sh # Deploy to VPS (tracker.celox.io)
```
No build step — vanilla JS frontend served directly from `public/`. Environment config via `.env` (see `.env.example`).
## Architecture
Dashboard that tracks Claude Code token usage. Pure Node.js HTTP server (no Express), SQLite database, vanilla JS frontend with Chart.js. Supports single-user (local) and multi-user (hosted) modes.
### Data Flow
**Single-user mode (default):**
```
~/.claude/projects/**/session.jsonl
→ lib/parser.js (incremental byte-offset parsing, dedup by message ID)
→ lib/db.js (SQLite with WAL mode, INSERT OR REPLACE)
→ lib/aggregator.js (in-memory stats, pre-computed on startup)
→ server.js (60+ JSON API endpoints + SSE live updates)
→ public/ (Chart.js charts, i18n DE/EN, cache toggle)
```
**Multi-user mode (`MULTI_USER=true`):**
```
sync-agent (client machine) → POST /api/sync (API key auth)
→ lib/db.js (per-user message storage with user_id)
→ lib/aggregator.js AggregatorCache (per-user, lazy loaded, incremental sync updates, 30min eviction)
→ server.js (GitHub OAuth + session cookies)
→ public/ (login overlay, user info, sync setup)
```
### Key Modules
- **`lib/parser.js`** — Reads JSONL files, extracts token counts/tools/model from `type: 'assistant'` messages and rate-limit events from `type: 'queue-operation'` with `content: '/rate-limit-options'`. Tracks byte offsets per file for incremental parsing. Deduplicates by `message.id` (last entry wins for streaming; verified against real data: 0 assistant lines without a `message.id`, and no id spans two projects or sessions, so attribution is stable). Extracts the **cache-write TTL split** from `usage.cache_creation` into `cacheCreate5m` / `cacheCreate1h` — the two tiers are priced differently and the total alone cannot be priced correctly. Both stay 0 when the field is absent, which `lib/pricing.js` reads as "split unknown". Builds `toolCounts` map (`{ Read: 2, Write: 1 }`) for per-tool call counts (streaming merge via `Math.max`). Detects sub-agent messages via `/subagents/` in file path (`isSubagent` flag). Returns `{ messages, rateLimitEvents, newOffset }`.
- **`lib/aggregator.js`** — In-memory analytics engine. Maintains `_daily`, `_sessions`, `_projects`, `_models`, `_tools`, `_hourly`, `_rateLimits`, `_toolStats`, `_mcpServers`, `_subagentStats`, `_subagentDaily`, `_toolCostDaily` maps. All API data served from these pre-computed structures. Tool cost attribution: proportionally distributes message cost/tokens across tool calls (`costPerCall = cost / totalCalls`). `parseMcpTool(name)` categorizes tools as built-in vs MCP (via `mcp__server__tool` prefix). `computeActiveMinutes(timestamps, maxGapMin=5)` calculates actual working time by summing inter-message gaps capped at 5 min (distinguishes active coding from idle/waiting). Sessions track `_timestamps` array and expose `activeMin` field. `getOverview()` computes `totalActiveMin` from a **unified in-period timestamp timeline** (all messages in range, sorted) — not per-session summation — so it's bounded by wall-clock time. Per-session summing was buggy: multi-day sessions contributed their full history to any day, and parallel sessions were double-counted. Also returns `avgActiveMinPerDay` (= `totalActiveMin / activeDays`) and `activeDays` (count of days in range where `messages > 0`) — divisor is active days, not period length, so sparse usage isn't understated. Methods: `getToolStats(from, to)`, `getMcpServers(from, to)`, `getSubagentStats(from, to)`, `getToolCostDaily(from, to)`, `getProjectDetail(name, from, to)` (returns comprehensive project data: tokens, cost, sessions, lines, daily breakdown, model/tool breakdown, per-component cost split `inputCost`/`outputCost`/`cacheReadCost`/`cacheCreate5mCost`/`cacheCreate1hCost` (they sum to `cost`), cache-TTL coverage `cacheCreate5mTokens`/`cacheCreate1hTokens`/`cacheCreateUnsplitTokens`, `spanMin`, and `totalActiveMin`). ⚠️ **It had the very bug `getOverview` was fixed for** (until 2026-08-30): active time was summed per session and the KPI actually shown was `totalDurationMin` = the **sum of session spans**. That counts idle time as work, double-counts overlapping sessions, and pulls in the entire history of a session that merely *overlaps* the period — measured 2344h inside a 2062h window, and 659h inside a 240h window. Both now come from `computeActiveMinutes(periodTimestamps)` on one project-wide timeline collected in the same message scan; the span sum survives only as `sessionSpanSumMin` (reference, never a KPI) and `totalDurationMin` is a deprecated alias of `totalActiveMin` for old clients. `sessions` counts distinct session IDs of **in-period messages**, matching `getProjects()` — `getSessions()` filters on overlap and disagreed under a period filter. `addRateLimitEvents()` tracks rate-limit hits per day, `getRateLimits(from, to)` returns `{ total, daily }`. `getTrends(now = new Date())` powers the overview trend cards — four now-anchored comparisons (today, Monday-based calendar week, month, rolling 7 days), each with `current` / `prevSame` (previous period cut off at the SAME point: yesterday up to this time of day, last week up to this weekday+time, last month up to this day-of-month+time, clamped to shorter months) / `prevFull` (complete previous total), sparkline series (hourly for today/yesterday, daily otherwise), and the month's `elapsedFraction` for a month-end projection; every sums object carries `tokens`/`tokensNoCache`/`cost`/`costNoCache`/`messages`/`activeMin` so the frontend honours the cache and token↔cost toggles; single message scan, `now` injectable for tests. The **same scan** also returns the trend-chart payload: `daily90` (the last 90 **local** days as `{date, tokens, tokensNoCache, cost, costNoCache, messages}` — indexed by date string, not `ms/86400000`, so a DST shift can't smear a day) and `momentum` (`{ windowDays: 7, projects[], models[] }`, each entry `{ name, cur, prev }` comparing the last 7 days with the 7 days before; empty entries filtered, projects capped at 20 / models at 8). Adding these cost no extra pass — do not add a second endpoint that re-scans for them. `getHourlyWeekday(from, to)` returns the overview heatmap grid — 7 weekday rows (`dayIndex` 0=Sun…6=Sat, matching `Date#getDay`) × 24 hour cells with `tokens`/`tokensNoCache` (input+output, for the cache toggle)/`messages`/`cost`/`costNoCache`, plus global `maxTokens`/`maxTokensNoCache`/`maxCost`/`maxCostNoCache` for colour-scaling without a second pass (local time, like `getHourly`/`getDayOfWeek`). `getDaily()` includes a per-day **cost breakdown** (`inputCost`/`outputCost`/`cacheReadCost`/`cacheCreateCost`, 4 decimals) alongside the rounded `cost` total — feeds the overview cost-mode stacked chart. All per-component cost accumulation resolves prices via `getPricing(msg.model, msg.timestamp)` (time-aware, LiteLLM-override-aware — an older version read the hard-coded `PRICING` table directly and silently ignored live overrides). **Per-message cache (perf)**: `_applyDelta` computes `msg._date`/`_ms`/`_hour`/`_day`/`_cost`/`_pricing` once and caches them on the message object; ALL period-filtered query loops use these cached fields instead of `toLocalDate()`/`new Date()`/`calculateCost()`/`getPricing()` per message per request (4–10× endpoint speedup at 144k messages; costs are frozen at add time — a pricing refresh takes effect via `/api/rebuild`/restart, same semantics as the precomputed maps). `computeActiveMinutes` takes epoch-ms numbers (sessions store `_timestamps` as numbers; ISO strings still accepted). When adding a new query method, use `msg._date`/`msg._hour`/`msg._day`/`msg._cost`/`msg._pricing` — do not reintroduce per-message Date allocation. `AggregatorCache` class provides per-user lazy loading with 30min eviction for multi-user mode. Composite cache keys: `"userId:deviceId"` or `"userId:all"`. `addToUser(userId, messages, rateLimitEvents)` incrementally updates all cached aggregators for a user (avoids full rebuild, does NOT reset eviction timer — only user requests via `get()` keep cache alive). Max-age of 2h forces full DB rebuild even for active caches (guards against incremental drift). `invalidateUser(userId)` clears all entries for that user. **Project merge (aliases)**: `setProjectAliases(map)` installs a flattened `{alias: canonical}` map; `_addMessage()` rewrites `msg.project` to its canonical name at the single choke-point every message passes through (startup load, watcher, sync) — so a merge folds existing **and future** messages across all views (projects, sessions, project-detail) without touching stored data. `resolveProject(name)` + alias resolution at the top of `getProjectDetail()`/`getSessions()` make a request for a merged-away name return the canonical project's data (so existing shares keep working). The alias map is NOT cleared by `reset()` (it's config, not data), so `/api/rebuild` keeps active merges. `AggregatorCache` takes a 3rd `getAliasesForUser` arg and sets per-user aliases **before** adding messages in `_buildEntry`. **Memory**: `_addMessage` interns repeated strings (project/model/sessionId/stopReason/tool names, plus `_date` in `_applyDelta`) via a module-level pool so ~150k retained messages share one copy per distinct value; internal query loops iterate `this._messageById.values()` directly (the `messages` getter allocates a fresh full array — don't use it in query methods); `hasMessage(id)`/`messageCount` expose the ID map for cheap dedup/counting; `addMessages()` accepts any iterable (arrays or the db.js streaming generators).
- **`lib/db.js`** — SQLite layer with `messages`, `message_tools`, `parse_state`, `metadata`, `users`, `user_sessions`, `achievements`, `github_cache`, `rate_limit_events`, `devices`, `project_shares` tables. `message_tools` has `call_count` column for per-tool call counts, `messages` has `is_subagent`, `device_id` and the cache-write TTL columns `cache_create_5m` / `cache_create_1h` (additive migration; both 0 while `cache_create_tokens > 0` means "stored before the split was parsed"). The split **must** be persisted: costs are recomputed from stored token fields on every load, so dropping it would silently re-price 1h writes down to the 5m rate on the next restart, `rate_limit_events` has `device_id` column. All multi-row inserts use `db.transaction()`. Queries reconstruct `toolCounts` map via `GROUP_CONCAT(mt.call_count)`. User-scoped functions: `insertMessagesForUser()`, `getMessagesForUser()`/`streamMessagesForUser()`. **Streaming loaders (memory)**: `streamAllMessages()`/`streamMessagesForUser()` are generators over `stmt.iterate()` — use them for whole-DB scans that feed an aggregator; the array-returning variants materialize ~175k row+message objects at once, a transient peak that permanently inflated RSS by hundreds of MB. **No mmap**: `mmap_size` deliberately stays 0 (a 256MB mmap counted the whole 100MB+ DB file toward RSS while analytics are served from the in-memory aggregator; default 16MB page cache suffices — do not re-add it). `initDB` runs a best-effort `wal_checkpoint(TRUNCATE)` to fold the watcher-grown WAL back into the main file. Rate-limit functions: `insertRateLimitEvents()`, `insertRateLimitEventsForUser()`, `getAllRateLimitEvents()`, `getRateLimitEventsForUser()`. Device functions: `createDevice()`, `getDevicesForUser()`, `findDeviceByApiKey()`, `getDeviceById()`, `renameDevice()`, `deleteDevice()`, `regenerateDeviceKey()`, `updateDeviceLastSync()`. Share functions: `createProjectShare(project, label, expiresInDays)` generates 48-char hex token, `getProjectShare(id)` with expiry check, `listProjectShares()`, `deleteProjectShare(id)`. **Project-alias (merge) table** `project_aliases(user_id, alias, canonical, created_at, PK(user_id, alias))` is non-destructive (original `project` on each message is untouched). `createProjectAlias(userId, alias, canonical)` (INSERT OR REPLACE), `deleteProjectAlias(userId, alias)`, `getProjectAliasRows(userId)` (raw, for UI listing), `getProjectAliasMap(userId)` (flattened `{alias: canonical}`, **always user-scoped** — there is deliberately no cross-user/global alias map). `_flattenAliases()` resolves chains (A→B, B→C ⇒ A→C), drops self-maps and neutralizes cycles to a no-op (no hang). Migration: `_migrateApiKeysToDevices()` moves legacy `users.api_key` to `devices` table on startup. **Indexes**: compound `idx_messages_user_device_ts(user_id, device_id, timestamp)` and `idx_rle_user_device_ts(user_id, device_id, timestamp)` for multi-user/device queries (created conditionally after column migrations), `idx_sessions_expires_at` for session cleanup, `idx_devices_user_created(user_id, created_at)` for sorted device lists.
- **`lib/auth.js`** — GitHub OAuth flow (server-side, native `https.request`), session management (`crypto.randomBytes` tokens, HttpOnly cookies, 30-day expiry), `authenticateRequest()` middleware. `authenticateApiKey()` returns `{ user, device }` — looks up device first via `findDeviceByApiKey`, falls back to legacy `findUserByApiKey`. Single-user mode returns DUMMY_USER.
- **`lib/pricing.js`** — Per-model pricing (input/output/cacheRead/cacheCreate per 1M tokens). Hard-coded `PRICING` table for the current generation (Opus 5/4.8/4.7/4.6/4.5, Sonnet 5/4.6/4.5/3.7, Fable 5, Haiku 4.5) acts as the **fallback only** (kept current so an offline boot doesn't undercount e.g. Opus 4.8 as Sonnet via `DEFAULT_PRICING`; **Opus 5 was missing until 2026-08-30** — 38.9k messages / 19.5B tokens, the single largest model, would have priced as Sonnet on any offline boot. When a new model appears in the data, add it here, not just to the label derivation). Live overrides are fetched from LiteLLM's `model_prices_and_context_window.json` by `lib/pricing-fetcher.js` and held in `_PRICING_OVERRIDES`. **Cache-write TTL tiers**: a cache write is billed at 1.25x base input for the 5-minute TTL and **2x base input for the 1-hour TTL** (`cacheCreate1hPrice(p) = p.input * 2`, derived so it can never drift from `input`). `calculateCost` splits `cacheCreateTokens` using `cacheCreate1h` (clamped to the total) and charges the remainder at the 5m rate. **Claude Code writes overwhelmingly to the 1-hour cache** — 90.5% of all cache-write tokens in real data — so the old flat 1.25x understated cost by **8.5%** ($2,749 across the JSONL window). A message with no split recorded (`cacheCreate5m + cacheCreate1h === 0`) keeps the 5m rate: those are rows stored before the split was parsed, and re-pricing them upward on unverifiable data would be worse than leaving them low. **Time-aware pricing**: `PRICING_EPOCHS` holds time-windowed prices for models whose price changed over time under the SAME model ID. It is currently **empty** — Sonnet 5's introductory $2/$10 became the standard price (Anthropic cancelled the increase to $3/$15 once scheduled for 2026-09-01), so its epoch was removed rather than left asserting a change that never happened; the fallback is $2/$10. The mechanism is still tested via the `_setEpochs()` test hook, so the table can reflect reality without losing coverage. `getPricing(model, timestamp)` resolves `epoch ?? _PRICING_OVERRIDES[m] ?? PRICING[m] ?? DEFAULT_PRICING` — the epoch wins over live LiteLLM overrides because LiteLLM only knows the *current* price, and a historical message must keep its historical price forever. `calculateCost(model, usage, timestamp)` falls back to `usage.timestamp` when no explicit timestamp is passed — aggregator call sites pass the full message object, so every cost calculation is automatically time-aware with zero call-site changes. Epochs are exposed in `getPricingMeta()` (`epochs` field). `getModelLabel()` prefers the curated hard-coded label over the auto-derived LiteLLM-derived one. `getPricingMeta()` exposes `{ source, fetchedAt, overrideCount, fallbackCount, epochs, models[] }` for transparency (each model entry has `origin: 'litellm' | 'fallback'` and `cacheCreate1h`). Unknown models fall back to Sonnet pricing.
- **`lib/pricing-fetcher.js`** — Pulls Anthropic model prices from `https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json` (~daily-updated community dataset). `convertLiteLLMToOverrides(payload)` keeps only `litellm_provider: 'anthropic'` entries, strips the `anthropic/` prefix, drops Bedrock/Vertex variants (`anthropic.claude-…`, `…@…`), converts per-token costs (e.g. `3e-06`) to per-1M-tokens (`3`). Missing `cache_*` fields fall back to `input * 0.1` (cacheRead) and `input * 1.25` (cacheCreate). `_deriveLabel(modelId)` handles three Anthropic naming layouts (claude-opus-4-6 → "Opus 4.6", claude-3-7-sonnet-* → "Sonnet 3.7", claude-3-opus-* → "Opus 3"). It **strips a trailing release-date snapshot** (`-\d{6,}$`) first so a date can't be read as a minor version (`claude-opus-4-20250514` → "Opus 4", not "Opus 4.20250514"), matches an **open `FAMILY` set** (`opus|sonnet|haiku|fable`, extend for new families), and handles **single-digit versions with no minor** (`claude-sonnet-5` → "Sonnet 5", `claude-fable-5` → "Fable 5"). New models are detected & priced fully automatically from LiteLLM; only the display label needs this derivation (and hard-coded `PRICING` labels still win for curated names via `getModelLabel`). `initPricing(db)`: at boot, synchronously loads cached overrides from the `metadata` table (keys `pricing_overrides_json`, `pricing_fetched_at`, `pricing_source`) so the very first cost calc already uses fresh data, then kicks off an async refresh, then schedules a 24h `setInterval` (with `.unref()` so tests don't hang on it). `refreshPricing()` is also exposed via `POST /api/pricing/refresh` for on-demand refresh. `getLastError()` exposes the most recent fetch failure for the UI. On any fetch failure the cached/hard-coded fallback continues to be used — costs never go to zero, only stale.
- **`lib/watcher.js`** — Chokidar file watcher (no `awaitWriteFinish` — it blocks events on continuously written JSONL files). On file change: incremental parse → update aggregator → broadcast SSE (with userId filtering in multi-user mode). **Chokidar 4.x compat**: `ignored` uses a path-based function (not regex) — a dotfile regex would match `.claude` in the watched path and silently ignore all files.
- **`lib/achievements.js`** — 1200 achievement definitions across 14 categories with 5 tiers. Tier-based points (Bronze: 10, Silver: 25, Gold: 50, Platinum: 100, Diamond: 250). `buildStats(agg)` computes comprehensive stats from aggregator. `checkAchievements()` inserts newly unlocked achievements and returns new keys. `getAchievementsByKeys(keys)` returns details for SSE notifications. `getAchievementsResponse()` returns all 1200 with unlock status and point values. New achievements trigger `achievement-unlocked` SSE events with emoji/tier/points data, shown as animated slide-in popup in frontend. **Historical backfill**: `backfillAchievements(agg, userId, db)` replays the message history day by day (fresh Aggregator fed per day, still-locked achievements re-checked after each day) so `unlocked_at` lands on the day each condition was first met — runs automatically on a FRESH init (0 unlocks + existing history) instead of the bulk unlock that stamped hundreds of achievements "now", and on demand via `POST /api/achievements/recompute`. **That endpoint has no UI** (the button was removed 2026-08-30 — never used, and the automatic paths cover every case that actually changes unlock dates: a fresh install, and a `ACH_BACKFILL_FLAG` version bump when unlock semantics change). It stays as the maintainer's recovery path via curl, e.g. after a project merge shifts per-project counts. A one-time per-user migration (metadata flag `ach_backfill_v1_<userId>`) also recomputes EXISTING pre-backfill data automatically — single-user at startup, multi-user on the first `GET /api/achievements`; guarded on `messageCount > 0` so an empty aggregator never wipes unlocks. **Sample gates**: ratio/average achievements (`RATIO_KEY_RE`: avg_*, cache_rate_*, deletion_ratio_*, output_ratio_*, tokens_per_msg_*, tokens_per_dollar_*, msgs_per_session_*, sessions_per_day_*, model loyalty/majority) require tier-scaled minimum active days (bronze 3 / silver 5 / gold 7 / platinum 14 / diamond 30) via `achievementPasses()` — used by BOTH checkAchievements and the backfill replay; a ratio over one day is a sample-size artifact, not an earned badge. Flag version is `ach_backfill_v3_` — bump it when unlock semantics change so existing data re-migrates once. The rewrite uses `replaceAchievementsForUser` (clear + insert in ONE transaction — a concurrent watcher-driven check can never observe the emptied table and re-stamp everything with now). Categories: tokens, sessions, messages, cost, lines, models, tools, time, projects, streaks, cache, special, efficiency, ratelimits.
**Wave 2 (500 added 2026-08-30)**: thresholds are **derived, not guessed** — `today + measured_rate × horizon` against a snapshot of the real history (208 active days: 248k messages, 80.6B tokens, $59k, 3,474 sessions, 1,733.65h of real work), spread over roughly one to twenty months of further work. All 500 were locked on the day they shipped; `test/achievements.test.js` pins that baseline and asserts ≥400 stay locked, so the "hard but achievable" claim is checkable rather than asserted. Three shapes: **ladders** (414, a themed name progression per metric), **combinations** (60, two conditions at once — e.g. hours of real work *and* days that were deep), and **landmarks** (26, single memorable numbers like the 10,000 hours). ⚠️ Wave 2 uses **`activeMin`** (gap-capped real work) throughout and never `durationMin`: the latter is last-minus-first message including idle, and on real data it ran **36× high** (62,147h of "session time" against 1,732h of work; one session spanned 168 days, and "16h marathon" fired 179 times where real work hit 16h only 24 times). Wave 1's time achievements still use it — deliberately, so existing unlocks are not revoked — and a test pins that wave 2 never inherits it.
**New stat dimensions for wave 2**: real working time (`totalActiveHours`, `deepSessions_*`, `deepDays_*`, `maxDayActiveMin`, `avgActiveMinPerSession`), MCP (`mcpServerCount`, `mcpToolCalls`, `mcpToolShare`), sub-agents (`subagentMessages/Cost/Share`), `cacheSavingsUsd` (what cache reads would have cost at full input price minus what they did cost — $400k on this history), per-release model counts via `modelMessagesOf(label)`, project depth (`projectsAbove100Usd/500Usd/1kUsd/50Sessions`), and rhythm (`maxHoursInDay`, `daysWith8Hours/12Hours`, `longestWeekdayStreak` — weekdays only, so weekends neither break nor extend it).
⚠️ **Eight wave-1 achievements were unreachable by construction, not hard** (corrected 2026-08-30, keys kept for history): `output_ratio_60/70/80` demanded a 60–80 % output share where the real figure is **0.204 %** (cache reads are 98 % of all tokens — it could never fire); `model_haiku_majority` demanded Haiku above 50 % of messages against 2.9 % actual with 82.6 % Opus; and `streak_1500/2000`, `active_days_3650`, `months_active_60` demanded four to ten years of unbroken work. A test now pins the corrected bounds so they cannot be restored. When adding an achievement, check it against real stats first — an impossible badge is padding, not a goal.
- **`lib/report-project.js`** — Standalone, print-optimised **per-project report** (`generateProjectReport(data, { periodLabel, print })` over a `getProjectDetail()` payload). Deliberately self-contained: no CDN, no external stylesheet, no chart library — the page is meant to be downloaded, mailed around and printed, so anything fetched at render time would eventually 404, and a canvas chart prints as a blank box in several browsers. Charts are hand-rolled **inline SVG** (vertical bars for the daily cost, horizontal share bars for models). Sections: KPIs, cost split by component (with the 5m/1h cache tiers as separate rows), cost-over-time chart, model table, tools, sessions, and a **methodology block** so the report explains itself away from the dashboard. **"PDF" is the browser's own print-to-PDF** (`?print=1` opens the dialog on load) — there is no server-side PDF engine, and adding one would mean a new native dependency for a worse-looking result. Project names come from directory names on disk (attacker-influencable) and are HTML-escaped throughout. A test pins that the output contains no `http(s)://`, `<script src>` or `<link href>`.
- **`lib/export-html.js`** — Generates self-contained interactive HTML export with Chart.js (CDN), 8 tabbed views (Overview, Charts, Sessions, Projects, Models, Tools, Productivity, Achievements), sortable tables, rate-limit KPI + daily chart. Tools tab includes cost/type columns from `toolStats` data. Mobile-responsive with breakpoints at 768px/480px/412px (S24 Ultra). Called by `GET /api/export-html`.
- **`lib/github.js`** — GitHub integration via GraphQL (contributions, repos, PRs) and REST (billing, actions usage, code frequency, languages). `cachedFetch()` with configurable TTL (`GITHUB_CACHE_TTL_MINUTES`, default 60) stores in `github_cache` SQLite table. Token resolution: per-user `github_token` in multi-user, `GITHUB_TOKEN` env var in single-user. `getActionsUsageByRepo()` iterates top 20 repos → workflows → timing endpoints with OS multipliers (Ubuntu 1x, macOS 10x, Windows 2x). PR stats include `codeByState` (additions/deletions per state) and `totalChangedFiles`. Billing detects plan (Pro if includedMinutes >= 3000) and includes storage quotas.
- **`lib/anthropic-api.js`** — Anthropic Admin API integration for organization usage/cost tracking. Follows `github.js` pattern exactly: `initAnthropicApi(db)`, SWR `cachedFetch()` reusing `github_cache` table with `anthropic-` key prefix, configurable TTL (`ANTHROPIC_CACHE_TTL_MINUTES`, default 60). `getDashboardData()` fetches 4 requests in parallel (usage by model, usage by api_key_id+model, cost report, API key names) and combines into aggregated dashboard data: daily costs/tokens by model, model breakdown, cache efficiency, plus per-API-key data (`keyBreakdown`, `keyTotals`, `dailyTokensByKey`). Key costs calculated via `lib/pricing.js` since cost endpoint doesn't support `group_by api_key_id`. `getApiKeyNamesDirect()` fetches key id→name mapping, cached separately (`anthropic-apikeys`). Token resolution: single-user checks `metadata` table (encrypted), then `ANTHROPIC_ADMIN_KEY` env var fallback; multi-user uses per-user `anthropic_key_encrypted` column. Keys encrypted with AES-256-GCM using `SESSION_SECRET`. Budget stored in `metadata` table.
- **`lib/plan-usage.js`** — Claude.ai plan usage limits (current session %, weekly all-models %, Sonnet-only %). OAuth token auto-detection: macOS Keychain (`security find-generic-password`) → `~/.config/claude/credentials.json` → encrypted metadata fallback. Fetches from unofficial `api.claude.ai/api/organizations/{org_id}/usage` endpoint (org ID via `/api/bootstrap`). 5-minute in-memory + metadata cache to respect rate limits. `_normalizeUsageResponse()` handles snake_case/camelCase variants. `storeSyncedPlanUsage()` accepts data from sync agent. Token encrypted with AES-256-GCM (reuses `anthropic-api.js` encryption).
- **`lib/backup.js`** — SQLite `VACUUM INTO` for atomic backups, auto-pruning to 10 copies. Safety check: rejects new backups that are <50% the size of the last backup to prevent saving corrupt/empty DBs.
- **`server.js`** — Vanilla `http.createServer`. Exports `startServer()` for test use. Bootstrap **streams** messages into the aggregator (`aggregator.addMessages(streamAllMessages())`) and dedups newly parsed JSONL messages against `aggregator.hasMessage(id)` instead of building a second full ID set; `AggregatorCache` is constructed with `streamMessagesForUser`. 10s after `listen` a one-shot full GC runs (via `v8.setFlagsFromString('--expose-gc')` + `vm`) so V8 returns the startup churn to the OS — combined, these dropped resident memory from ~509MB to ~130MB at 176k messages. Static files are served with an mtime+size **ETag** and `Cache-Control: public, max-age=0, must-revalidate` — browsers revalidate and get a 304 instead of re-downloading (~640KB of JS/CSS per reload before). Compression is left to the prod nginx proxy (which gzips both static and API responses). ~60 routes: auth (`/auth/*`), sync (`/api/sync`), sync-agent install (`/api/sync-agent/install.sh`), active sessions (`/api/active-sessions`), config (`/api/config`), GitHub endpoints (`/api/github/*`), Anthropic API endpoints (`/api/anthropic/*`), plan-usage endpoints (`/api/plan-usage`, `/api/plan-usage/token`, `/api/plan-usage/refresh`), pricing endpoints (`/api/pricing` GET is **public** — no auth, returns `getPricingMeta()` + `lastError` for transparency; `/api/pricing/refresh` POST is auth-gated and forces a LiteLLM refresh), rate-limits (`/api/rate-limits`), tool-stats (`/api/tool-stats`, `/api/mcp-servers`, `/api/subagent-stats`, `/api/tool-cost-daily`), heatmap (`/api/hourly-weekday` — weekday × hour grid for the overview heatmap), trends (`/api/trends` — now-anchored usage-trend comparisons for the overview trend cards, independent of the period filter), device CRUD (`/api/devices`, `/api/devices/:id`, `/api/devices/:id/regenerate-key`), project-detail (`/api/project-detail?name=...`), project report (`/api/project-report?name=...&from=&to=&download=1|&print=1` — HTML; `download=1` sets a `Content-Disposition` filename flattened from the project name, `print=1` injects the auto-print hook), project merge (`GET /api/project-aliases` lists active merges, `POST /api/project-merge {sources[], target}` folds sources into target, `DELETE /api/project-aliases {alias}` un-merges — all auth-gated; `applyProjectMergeChange()` busts `_shareAggCache` and either rebuilds the single-user global aggregator from DB or `invalidateUser()`s the multi-user cache so the merge is visible immediately. **Security**: the merge endpoint validates every source/target against the requesting user's own scoped aggregator (`getProjects()`), so a user can only merge projects they actually own. The multi-user (cross-user) share aggregator deliberately applies **no** alias map — folding it with one user's merges would let any user rewrite project-name resolution for everyone's shares (cross-tenant poisoning); multi-user shares resolve to the literal project name, while per-user dashboards and single-user shares fold via their own user-scoped alias map), share management (`/api/shares`, `/api/shares/projects`, `/api/share-admin-key` — admin key or session auth), public share (`/api/public/share/:token` — no auth, rate-limited 30 req/min/IP, CORS restricted to ops.celox.io/tracker.celox.io, global `_shareAggCache` with 5-min TTL avoids re-aggregating all messages per request). **CORS**: `sendJSON()` sets NO `Access-Control-Allow-Origin` — it only passes through one a route set via `res.setHeader()` beforehand. A blanket `*` there (the old behaviour) let any website read the single-user API on localhost AND overrode the share endpoint's origin allowlist, because `writeHead()`'s header object wins over `setHeader()`. Do not reintroduce a wildcard, and never set CORS inside `sendJSON`, database download (`/api/download-db` — streams SQLite file), export-html, all analytics endpoints. Auth gate on `/api/*` in multi-user mode. `generateInstallScript()` embeds sync-agent files + config into a self-contained bash installer. Sync endpoint accepts optional `rateLimitEvents` array and `planUsage` object alongside `messages` (backwards-compatible), passes `deviceId` to insert functions. Uses `aggregatorCache.addToUser()` for incremental cache updates instead of full invalidation — prevents Event Loop blocking on large datasets. **Important**: the `addToUser()` message mapping must use `m.id` (not `m.uuid`) to match the sync agent's message format — using `m.uuid` causes all messages to deduplicate under `undefined`, losing all but the last message per sync batch. Analytics endpoints accept `?device=<id>` query parameter for per-device filtering. Achievement checks broadcast `achievement-unlocked` SSE events when new achievements are found.
### Sync Agent
Standalone CLI tool in `sync-agent/` directory (v0.1.0). Watches `~/.claude/projects/` on client machine and uploads token data + rate-limit events to the hosted server via `POST /api/sync` with API key auth. Has its own `package.json` (only `chokidar` dependency) and inline parser (no imports from main project). Event-based sync (~600ms latency), batches of max 500 messages, exponential backoff retry. Rate-limit events sent alongside messages in sync payload. Stability: `watcher.on('error')` handler for FSEvents errors, `unhandledRejection` guard, 30-minute heartbeat log, suppressed repeated plan-usage errors. **Chokidar 4.x compat**: `ignored` uses a path-based function (not regex) because Chokidar 4.x tests the full path — a dotfile regex like `/(^|[\/\\])\../` would match `.claude` in the watched path and silently ignore all files.
**Web-based install**: `GET /api/sync-agent/install.sh?key=API_KEY` (or `?key=API_KEY&device=ID`) returns a personalized shell script that installs the agent with pre-configured `config.json`, verifies server connectivity, and sets up autostart (launchd on macOS, systemd on Linux). Supports device-specific API keys. The script is generated server-side by `generateInstallScript()` which embeds `sync-agent/index.js` and `sync-agent/package.json` via heredocs. Windows: `GET /api/sync-agent/install.ps1?key=API_KEY`.
### Frontend
- **No framework** — vanilla DOM with `textContent` (no `innerHTML`)
- **State**: Single global `state` object (activeTab, period, includeCache, sessionFilter, multiUser, user, device, devices)
- **Auth flow**: `checkAuth()` on load → `/api/config` to detect mode → `/auth/me` to check session → show login overlay or dashboard
- **Cache toggle**: Cached tokens visible by default (shows real resource consumption). `getDisplayTokens()` / `getDisplayCost()` filter based on `state.includeCache`. Persisted in `localStorage`.
- **Metric toggle (tokens ↔ cost)**: pill toggle (`.metric-toggle`, above the overview charts) switches the overview charts between token counts and dollars. `state.metricMode` (`'tokens'`|`'cost'`), persisted as `localStorage 'metricMode'`, survives reloads. In cost mode: the daily chart stacks `inputCost`/`outputCost`/`cacheReadCost`/`cacheCreateCost` (all 4 components, so bar height = the day's real total cost and matches the cost-trend line), the model doughnut uses `cost`, the hourly chart shows `$`/hour instead of messages, and the heatmap colours by `cost`/`costNoCache` with `maxCost`/`maxCostNoCache` scaling (tooltip always shows tokens + messages + cost in both modes). Chart titles swap via `t('dailyCostUsage')`/`t('costByHour')` — set in `loadOverview()` after `applyTranslations()`, so language switches stay correct. `createDailyTokenChart`/`createModelDoughnut`/`createHourlyChart` take a trailing `mode` param. Token mode is byte-identical to the old behaviour (daily chart without cache stacks, hourly = messages).
- **i18n**: `data-i18n` attributes on HTML elements, `t(key)` lookup function, translations in `public/js/i18n.js`
- **Charts**: All chart creators go through `renderChart(canvasId, ctx, config)` (charts.js): if an instance of the same type already exists on the canvas, its `data`/`options` are swapped in place + `update('none')` — **no destroy/recreate**, so data refreshes never blank the canvas or replay draw animations (only KPI numbers animate on refreshes). Options are replaced wholesale so tooltip callbacks closing over the new data stay fresh. A new chart is only created on first paint or type change. `restoreChartLegendState` is idempotent for doughnuts (checks `getDataVisibility` before toggling) so re-applying after an in-place update doesn't un-hide slices. Global `chartAnimateNext` flag disables creation animation after the first SSE update. When adding a chart, use the `renderChart(...)` pattern — do not call `new Chart` directly or `destroyChart` per render.
- **Period navigation**: Prev/next arrow buttons beside the date picker jump by the selected period duration (1 day for today/custom, 7/30 days for those periods). Disabled for "All Time".
- **Weekday-aware dates**: `weekdayShort(dateStr)` (charts.js) + `formatDateWithWeekday(dateStr, withYear)` (app.js) compute the short weekday in **local time** from a `YYYY-MM-DD` string (parsed via `new Date(y, m-1, d)` to avoid the UTC-midnight off-by-one), localized via `currentLang`. `formatChartDate` prefixes every chart axis label (`Sa 06-27`); `updatePeriodRange()` (called at the top of `loadTab`) fills the `#period-range` status-bar header (`Do 28.05.2026 – Sa 27.06.2026`, or `Gesamt`); `formatPeriodLabel` (comparison labels) uses the same helper. Weekday names live in `_WEEKDAY_SHORT`/`WEEKDAY_SHORT` (de/en).
- **Usage heatmap**: overview weekday × hour grid rendered by `_renderHeatmap(rows, maxVal)` as a **CSS grid of DOM cells** (no Chart.js / extra lib). `loadOverview()` fetches `/api/hourly-weekday` for multi-day ranges → `renderHeatmapWeekday()` (7 rows reordered Mon→Sun); a single day → `renderHeatmapSingleDay(hourly, from)` (one 24-hour row from the already-loaded `hourly` data). Cell value honours the cache toggle (`state.includeCache` → `tokens` vs `tokensNoCache`); colour via `_heatColor(ratio)` (perceptual easing, `--heat-rgb`/`--heat-empty` tokens); per-cell `title` tooltip (tokens · messages · cost) + a legend. **Entrance = a diagonal MD3-Expressive spring wave**: each cell carries an inline `--d = row+col` driving a staggered `animation-delay`, scaling+fading in on `--ease-spatial-expressive-fast`; row/hour labels (`--r`/`--c`) and the legend follow. The wave is gated by a `uheat-anim` class that `_renderHeatmap()` toggles on the persistent `#usage-heatmap` container **only when `body.motion-quiet` is absent**. **In-place fast path**: when the grid shape (`rows:cols`, tracked as `el._uheatShape`) is unchanged, `_renderHeatmap` updates cell colours/titles/row-labels on the existing DOM nodes and returns — no rebuild, no wave, no flicker on data refreshes or period changes within the same shape. A full rebuild (and thus the wave) only happens on first paint or when the shape changes (single-day 1×24 ↔ multi-day 7×24). Hover springs the cell up (`--ease-spatial-expressive-fast`) with an accent glow. The period-range header (`updatePeriodRange()`) spring-swaps (`uheat-range-in`) when the range text actually changes (also skipped under `motion-quiet`). **Gotcha**: the classes use a dedicated `uheat-` prefix — `.heatmap`/`.heatmap-grid` are already owned by the GitHub contribution graph (`grid-auto-flow: column`), which otherwise scrambled the weekday rows into a horizontal zigzag.
- **Usage trend cards**: four static `.kpi-card.trend-card`s in `#trend-section` (Overview, below the period-filtered KPI grids, above the stats banner) — Heute / Diese Woche / Dieser Monat / Letzte 7 Tage. `loadTrends()` (part of `loadOverview`'s sideLoads) fetches `/api/trends` and `renderTrends()` updates everything **in place** (textContent + SVG path `d` swaps — no DOM rebuild, so live refreshes don't flicker; static markup means tilt/value-pop/entrance bind automatically). Delta badge compares against `prevSame` (▲/▼, `|pct| < 3 %` renders as flat `≈`), sub-line shows the previous period's full total (month card additionally the projection `current / elapsedFraction`), card `title` tooltip shows messages + active time for both sides. Metric selection honours `state.metricMode` + `state.includeCache` (`_trendMetricOf`/`_trendSeriesKey`). Sparklines are hand-rolled inline SVG (no Chart.js): both periods share one y-max, and `_sparkPath`'s `denom` fixes the x-scale to the period's total slot count so the running period's line visibly stops where the period stands (hours-so-far today, days-so-far in week/month); previous period is dashed/dim, the current line's colour matches the card's positional value colour. i18n keys `trend*`; demo data key `trends`.
- **Cumulative lines & messages**: a second full-width overview box directly below `chart-overview-lines`, `createCumulativeLinesChart('chart-overview-cumulative', daily, hourly, period)` — the same four series (`linesWritten`/`linesAdded`/`linesRemoved`/`messages`) as running totals. Fed by the payload `loadOverview` already has, so it costs **no extra request**, and it follows the same period rule as the chart above (a single day is read hour by hour). The arithmetic lives in the pure `cumulativeRows(rows, keys)`, which is what the tests pin: a missing, null or unparseable value counts as 0 rather than turning every later point into NaN — one gap would otherwise empty the whole chart silently, and a day with no edits carries no `linesAdded` at all. ⚠️ **One shared y-axis, deliberately** — the chart above needs a second axis because a day's messages vanish beside that day's lines, but cumulated the two land within ~5x (measured over the full history: 1.48M lines written against 298k messages). A second axis would invite comparing two curves whose relative height is an arbitrary choice of scale. ⚠️ **Not stacked** — `written + edited + deleted` adds removals to additions; the point of a cumulative view is comparing the curves. Colours are identical to the chart above so the pair reads as one system. `tests/cumulative-chart.test.js` pins the arithmetic, both decisions (no `yAxisID`, no `stacked` in the function body) and the wiring.
- **Usage trend charts**: five Chart.js charts inside `#trend-section` (below the trend cards, `.trend-charts` grids), fed by the **same `/api/trends` payload** as the cards — no extra request. `renderTrendCharts()` (called at the end of `renderTrends()`) resolves one metric `key` from `_trendSeriesKey()` + `mode` from `state.metricMode` and passes both into the creators, so the cache and token↔cost toggles apply to every chart. (1) `createTrend90Chart` — 90 daily bars + 7d/30d trailing moving averages (`_movingAvg`, leading slots stay `null`; no partial window). (2) `createTrendMonthCumulativeChart` — cumulative month-to-date vs the full previous month plus a dashed projection segment (`curTotal / elapsedFraction`), cumulation stops at today so trailing zero-days can't flatline the line. (3) `createTrendWeekCompareChart` — grouped bars Mon–Sun, this week vs last week; **days still ahead of today are `null`, not 0**, so they don't read as a collapse. (4) `createTrendMomentumChart` — diverging horizontal bars of `cur − prev` per project (top 8 by |Δ|, green/red, labels = last **two** path segments; the leaf alone is ambiguous). (5) `createTrendModelMixChart` — 100 % stacked bars "last 7 days" vs "previous 7 days" (absolute values in the tooltip via a `_abs` dataset field), which surfaces a model shift even when total volume is flat. `_toggleTrendBox()` hides the momentum/mix boxes when there's nothing to compare. i18n keys `trendChart*`/`trendAvg*`/`trendCur7`/`trendPrev7` + `tooltipTrend*`.
- **Active sessions**: `loadActiveSessions()` fetches `/api/active-sessions` and renders cards in overview tab. Sessions with `lastTs` within 10 minutes are shown.
- **Active time KPIs**: Two overview cards — "Active Work Time" (`totalActiveMin`) in row 1 and "Avg Work Time/Day" (`avgActiveMinPerDay`) in row 2. Both formatted via `_formatActiveTime(min)` (→ `"5 Std. 20 Min."` / `"5h 20m"`). Avg card's subtitle uses `t('avgActiveTimeSub').replace('{days}', activeDays)` to show how many active days the average is based on.
- **Plan usage**: `loadPlanUsage()` fetches `/api/plan-usage` and renders 3 progress bars (session, weekly all-models, weekly Sonnet) in overview tab between active sessions and KPIs. Hidden when no OAuth token or data available. Refresh button clears server cache and re-fetches. Uses `_renderUsageBar()` with warn (≥70%) and danger (≥90%) color thresholds.
- **Achievements**: Timeline chart (bar+line) showing daily unlocks and cumulative points. Clicking a timeline bar opens `#ach-day-dialog` listing exactly which achievements unlocked that day (emoji, i18n name/desc, tier-coloured border + label, points, day total; sorted by tier; Escape/overlay close). Tier-based point values displayed on each card. Stats header shows total points and average achievements per day. Real-time unlock notification popup (bottom-right, slide-in animation, auto-dismiss 15s, closeable via X). Multiple achievements stack vertically. Triggered by `achievement-unlocked` SSE events.
- **Tools tab**: Tool Cost Attribution with proportional cost/token distribution per tool. KPI row (Unique Tools, Total Calls, Est. Cost, MCP Servers), 3 charts (Tool Usage bar, Tool Cost Attribution bar, Tool Cost Over Time stacked area), MCP server breakdown cards (auto-detected via `mcp__` prefix, conditionally shown), sub-agent tracking section (via `/subagents/` path, conditionally shown), enhanced 6-column table (Tool, Type, Calls, Est. Cost, Tokens, %). Fetches 5 endpoints in parallel.
- **GitHub tab**: Billing (plan badge, minutes/storage/packages with progress bars, OS breakdown doughnut), contribution heatmap, commit/language/PR charts, PR Code Impact (additions/deletions/net/changedFiles KPIs + grouped bar by state), Actions Usage by Repository (horizontal bar + workflow table), code frequency per repo, repo table. Period-filterable: contributions KPI, commits chart, code stats. Non-filterable sections (billing, actions usage, repos table) show `gh-period-hint` badges when a period filter is active via `_setGhPeriodHint()`.
- **Project detail dialog**: Click any project in chart or table to open a modal with 6 KPIs (tokens, cost, sessions, messages, active time, net lines), daily tokens stacked bar chart, model distribution doughnut, top tools as tag pills, sessions table. JSON export to clipboard. `openProjectDetail()` fetches `/api/project-detail`, `closeProjectDetail()` via Escape/overlay click.
- **Projects search/filter**: Pill-shaped search field (magnifier icon + clear `×` button) above the Projects table. `loadProjects()` caches the fetched list in module-level `_projectsData` and delegates table rendering to `renderProjectsTable()`, which case-insensitively substring-filters by project name and feeds the result to `storeTableData` (sort state survives because column count is unchanged). A live `aria-live` count (`projectsMatch`/`noProjectsMatch` i18n) shows `n von total`; the clear button and Escape reset the query; the filter only narrows the **table** (the top-15 chart stays as a stable overview) and persists across period changes. Placeholder localized via new `data-i18n-placeholder` support in `applyTranslations()`. **No horizontal scrolling**: long project paths are shortened for display via `shortenProjectName()` (drops leading path segments — the tail is the distinguishing part; full name in the cell `title` tooltip and still the sort key), the name cell (`.project-name-cell`) is width-capped with ellipsis, and `#tab-projects`-scoped CSS tightens padding/font in steps at 1100/1000/800px so all 9 columns fit at once (verified 768–1440px).
- **Project merge UI**: "Zusammenführen" button in the projects filter bar opens a modal (`#project-merge-dialog`) for folding several project names (e.g. same repo renamed/moved, or synced from another device under a different path) into one canonical name. Checkbox list of all projects → target `<select>` (auto-populated from the checked sources, the kept name) → `POST /api/project-merge`. The dialog always operates on the **all-time** project list (`api('projects')` without a period query, cached in `_mergeProjects`), never the period-filtered table list — merging is global/historical, so projects outside the selected period must still be visible. A **"🪄 Vorschläge"** button (auto-surfaced when candidates exist) runs `computeMergeSuggestions(_mergeProjects)`: projects are bucketed by an **identity key** = the path with its first segment (the device/tool root: `claude`/`cursor`/`Downloads`/`WebstormProjects`/…) stripped, lowercased, trailing variant suffix removed (`foo-old`/`foo2`→`foo`). So `claude/mrxdown` ≡ `WebstormProjects/mrxdown` (key `mrxdown`) but `claude/dr/scraper` (`dr/scraper`) and `Downloads/fuck/off/scraper` (`fuck/off/scraper`) stay apart (they only share the leaf word). Buckets are **discrete** — deliberately no transitive union-find and no path-ancestor signal, the two things that made an earlier version collapse every project into one blob (a bare root `claude` is a prefix of all `claude/*`; one shared leaf word chains unrelated clusters). Groups need 2–6 members; single-segment keys must be ≥4 chars and non-generic. Each suggestion card's "Auswählen" pre-selects the group and sets the target to the highest-token member; the user still confirms via the main Merge button. An "Active merges" section lists each `alias → canonical` with an un-merge button (`DELETE /api/project-aliases`). `loadProjects()` also fetches `/api/project-aliases` into `_projectAliases`; merged canonical rows get a `.merged-badge` ("+n zusammengeführt", tooltip = alias names) via a new opt-in `render(td, row)` hook on `buildTableRows` cellDefs. Button hidden in demo mode and when `<2` projects; dialog closes on Escape/overlay click. Non-destructive: un-merge restores the original split. i18n keys `merge*`.
- **Claude API tab**: Anthropic Admin API usage/cost dashboard. Setup card when no key, budget feature (stored in metadata), 4 KPIs (total cost, tokens, avg cost/day, cache efficiency), 4 charts (daily costs stacked by model, daily tokens stacked by type, model doughnut, cumulative cost trend line), model table. Per-API-key section: horizontal stacked bar chart (cost per key by model), key table (tokens, input, output, cache %, calculated cost, last used), key timeline (stacked area chart, only shown when >1 key). Period filtering on daily data. Follows `loadGithub()` pattern with SWR cache + smooth refresh.
- **Device switcher**: Dropdown beside period filter to switch between devices or "All Devices" (aggregated). Hidden when ≤1 device. Device management in Settings: add/rename/delete devices, regenerate keys, show install command with OS auto-detection (`detectSyncOs()`). Device selection persisted in `localStorage`.
- **Tab persistence**: Active tab saved to `localStorage`, restored on page reload.
- **Mobile-responsive**: CSS breakpoints at 900px, 600px, 480px, 393px. Touch targets (44px min), hidden tab scrollbar with scroll mask, adaptive chart heights via `!important` overrides on `.chart-container`. `isMobile()` / `isNarrow()` helpers in `charts.js` adjust font sizes, point radii, label truncation, and legend visibility. Debounced `window.resize` handler calls `.resize()` on all `chartInstances`.
- **Material 3 Expressive motion system** (single concept: *a control room* — readouts settle into their slots with weight, indicator lights track activity, switching views swings a panel into focus). One timing system lives in `:root` of `public/css/style.css`: spring/emphasized easings (`--ease-spring` overshoot, `--ease-emphasized*`) for spatial moves, a flat easing (`--ease-flat`) reserved for opacity/color, graded durations (`--dur-quick` 140ms … `--dur-long` 480ms), `--stagger`, and state-layer tints. **Authentic MD3-Expressive motion-physics springs** are also available as `linear()` easings derived from the official spring tokens (unit-mass step-response, generated offline): `--ease-spatial-expressive` (k 380 / damping 0.8), `--ease-spatial-expressive-fast` (k 800 / 0.6, ~9% overshoot), `--ease-effects-expressive` (k 1600 / 1.0, critically damped for opacity/color). Use spatial springs for position/size/scale, the effects spring for opacity/color. The motion CSS lives in a single appended block at the end of `style.css`. Key pieces:
- **Progressive enhancement gate**: an inline `<head>` script sets `html.js`. All hidden-then-revealed entrance animations are scoped under `html.js`, so a script failure can never leave a card stuck at `opacity: 0` (the `backwards` fill-mode resolves to fully visible once an animation runs, and reduced-motion forces `opacity: 1`).
- **Staggered card entrance** (`md-drop`, the first-paint "catch"): `.kpi-card`/`.chart-box`/etc. drop in with a spring overshoot, offset by `--stagger` via `:nth-child` delays. Toggling a panel's `display` (tab switch) restarts these; SSE refreshes don't toggle display, so live updates stay calm.
- **Signature directional transition**: `switchTab()` (in `app.js`) computes travel direction from tab DOM order (`_tabIndex`/`_prevTabIndex`) and sets `data-nav-dir="fwd"|"back"` on `.content`; CSS swings the active panel in via `md-panel-in-fwd`/`md-panel-in-back`. While the panel swings, its cards ride along (animation suppressed) as quiet supporting cast — the stagger is reserved for first paint (no `data-nav-dir` set yet). First `switchTab()` call on init deliberately leaves `data-nav-dir` unset.
- **Reactive moment**: KPI cards tilt toward the cursor with a tracking light highlight, **gated to `(hover: hover) and (pointer: fine)`** in both CSS and JS (`initExpressiveMotion()`). rAF-throttled `pointermove` reads the rect once per frame and writes `--mx`/`--my`; the `.tracking` class applies a `perspective() rotateX/Y` transform that springs back on `pointerleave`. The cursor highlight is `::after` with `z-index: -1` + `isolation: isolate` so it sits above the card background but below the readout text. Touch/pen pointers opt out.
- **Value pop**: a `MutationObserver` per `.kpi-value` adds `.pop` (`md-value-pop`) when the text actually changes (ignores unchanged values and the `-` placeholder); `.kpi-value` is `display: inline-block` so the scale pops around the number.
- **Live indicator**: `.live-dot` is a breathing core (`md-core-breathe`) + a radiating ring (`md-ring-out` via `::after`); `.disconnected` stops both.
- **State layers / press / focus**: `.tab-btn`/`.period-btn`/`.btn-small`/`.rebuild-btn`/`.lang-btn` get a `::after` hover/press wash (`--state-hover`/`--state-press`), a `:active { scale }` press response, and a `:focus-visible` ring. The active tab is a backlit, spring-settling underline (`.tab-btn.active::before`).
- **Calm live updates**: the SSE handler in `app.js` adds `body.motion-quiet` around the debounced `loadTab()` (removed on the next `requestAnimationFrame`); CSS suppresses entrance animations on in-place DOM rebuilds so live data doesn't re-fly. **`body.motion-settled` (load-bearing)**: added once 1.6s after load (first-paint choreography done) and NEVER removed — it permanently disarms all entrance animations (`md-drop` on cards/chart-boxes/rows/sections). This exists because **re-applying an animation property restarts it**: the transient motion-quiet toggle alone made all 22 entrance elements replay `md-drop` the moment each quiet window ended (= the "cards flicker on every refresh" bug). Never build a suppress-then-unsuppress animation gate for persistent elements — gate permanently (motion-settled) or via a class toggled at render time on recreated elements (`uheat-anim` pattern). The tab-panel swing (`md-panel-in-*`) and KPI value pop are deliberately exempt — the only recurring motion.
- **Reduced motion**: a `@media (prefers-reduced-motion: reduce)` block neutralizes animations/transitions/transforms (incl. tilt and hover lift) and forces entrance elements to `opacity: 1`.
## Multi-User Mode
Activated by setting `MULTI_USER=true` in `.env`. Requires:
- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth App credentials
- `SESSION_SECRET` — random secret used for AES-256-GCM encryption of Anthropic admin keys in DB
- `BASE_URL` — public URL (e.g. `https://tracker.celox.io`)
Key differences from single-user:
- File watcher disabled (data comes via sync agent)
- All `/api/*` routes require session cookie auth
- Per-user data isolation via `user_id` column on messages
- Per-device isolation via `device_id` column on messages and rate_limit_events
- `AggregatorCache` provides per-user, per-device aggregator instances with incremental updates
- Global aggregator skipped at startup (no unnecessary load of all messages)
- Multi-device support: each device gets its own API key, install command, and sync agent
- Login overlay shown until GitHub OAuth completes
- Stats-cache endpoint disabled (no local `.claude/` directory)
## Demo Showcase
Non-authenticated visitors in multi-user mode see a fully clickable demo. The `public/js/demo-data.js` `DEMO_DATA` registry provides sample responses keyed by API endpoint path — the frontend `api()` helper intercepts requests in `state.demoMode` and serves from `DEMO_DATA` (deep-cloned). Function-typed entries (e.g. `project-detail`) receive parsed query params and synthesize a response dynamically. On a 401 for paths *not* in `DEMO_DATA`, `api()` returns `null` instead of forcing the login overlay — so partial failures gracefully no-op instead of breaking demo navigation. The Settings tab is hidden in demo mode (`showDemoBanner()` toggles `display: none` on the tab button + bounces `state.activeTab` to overview if it was settings).
Demo data covers every dashboard endpoint: `overview` (with `totalActiveMin`, `avgActiveMinPerDay`, `activeDays`, `rateLimitHits`), `daily`, `sessions` (with `id`, `totalTokens`, `activeMin`), `projects`, `models`, `tools`, `tool-stats` (built-in + MCP), `mcp-servers`, `subagent-stats`, `tool-cost-daily`, `rate-limits`, `plan-usage`, `hourly`, `hourly-weekday` (heatmap grid), `daily-by-model`, `hourly-by-model`, `daily-cost-breakdown`, `cumulative-cost`, `day-of-week`, `cache-efficiency`, `stop-reasons`, `session-efficiency`, `active-sessions`, `productivity`, `efficiency-trend`, `model-efficiency`, `session-depth`, `global-averages`, `achievements` (500 defs, ~35 unlocked), `github/stats` (365-day heatmap, 12 repos, PR stats), `github/billing`, `github/actions-usage`, `github/code-stats`, `github/code-frequency`, `anthropic/dashboard` (30 days, 2 API keys, model+key breakdown matching `lib/anthropic-api.js` schema with `byKey[keyId]` daily structure), `anthropic/budget`, `devices` (empty). `project-detail` is a function that filters `sessionsData` by project name and synthesizes daily/model/tools breakdowns on demand.
The `.demo-hero` section (CSS at `public/css/style.css`) replaces the old one-line banner with a real showcase: gradient hero with two-column grid (content left, stat cards right), feature pills, primary "Explore demo" CTA (smooth-scrolls to `#tabs`) + secondary "Sign in with GitHub" CTA. Fully responsive (breakpoints 900/600px). i18n keys: `demoHeroTitle`, `demoHeroSub`, `demoFeat*`, `demoCta*`, `demoStat*`, `demoBadge`, `demoHeroFoot`.
## Local (MacBook)
LaunchAgent `io.celox.token-tracker` runs the local dashboard on port 5010:
- `RunAtLoad: true` + `KeepAlive: true` — survives reboots and crashes
- Plist: `~/Library/LaunchAgents/io.celox.token-tracker.plist`
- Logs: `stdout.log` / `stderr.log` in project directory
- Dashboard: http://localhost:5010
- Local daily DB backups are enabled via the LaunchAgent plist `EnvironmentVariables` (`BACKUP_PATH=data/backups`, `BACKUP_INTERVAL_HOURS=24`) — the plist runs plain `node server.js` and does NOT load `.env`, so env config for the local instance belongs in the plist.
- **Data continuity**: `~/.claude` JSONL is a rolling window (Claude Code prunes old session files) — the SQLite DB is the long-term store. After a machine reset, `bash scripts/restore-from-server.sh` pulls a consistent snapshot from the VPS (full synced history), swaps it in and restarts; local JSONL re-parses on top (dedup by message id), achievements recompute via the backfill migration. `/api/rebuild` reloads the DB history BEFORE re-parsing JSONL (+ rate-limit events) — an older version re-parsed only JSONL and silently dropped everything past the retention window until restart.
- **The LaunchAgent is the ONLY local process manager** — never register `token-tracker` in the local PM2 daemon. A leftover local PM2 entry crash-looped against the LaunchAgent for port 5010 (EADDRINUSE loop, dozens of restarts, SSE/API requests dying mid-flight); removed 2026-07-03 via `pm2 delete token-tracker && pm2 save`. PM2 runs the tracker only on the VPS.
- Startup takes a few seconds up to ~30s (streams ~175k messages into the aggregator before binding the port) — after a restart, wait for `curl localhost:5010` to return 200 before diagnosing. Expected resident memory: ~130MB after the post-boot GC (10s after listen), transiently ~200MB under heavy API churn.
## Deployment
VPS deployment to tracker.celox.io:
- Port: 3007, PM2 process: `token-tracker` (started with `--node-args='--env-file=.env'`)
- Nginx reverse proxy with SSL (certbot)
- `scripts/deploy.sh` handles rsync + npm ci + PM2 restart
## README Screenshots
`public/screenshots/` holds the images embedded in `README.md` (+ the mobile row in `README_EN.md`/`README_DE.md`). Captured from the **live local dashboard** (real data, All-Time period, English UI, dark theme) with Playwright against `http://localhost:5010`:
- **Desktop 1280×800**, viewport shots (not `fullPage`): `01-overview`, `02-trends`, `03-trend-charts`, `04-sessions`, `05-projects`, `06-tools`, `07-models`, `08-insights`, `09-productivity`, `10-achievements`.
- **Mobile 393×852** (iPhone 16): `mobile-overview`, `mobile-trends`, `mobile-insights`, `mobile-productivity`, `mobile-achievements`.
- Procedure: resize → load → `setLang('en')` + `setPeriod('all')` → wait for the tab's data (the aggregator needs ~20–30 s after a restart) → `switchTab(...)` → scroll the target section to ~160 px (desktop) / ~290 px (mobile) below the viewport top so the sticky header doesn't clip it → screenshot. Renumbering means the README table must be updated **and** the stale files deleted — nothing else in the repo references them.
- A screenshot pass is a cheap full-UI review: the current set surfaced five real bugs (all-time header, German time units in the English UI, the projects chart ignoring the cache toggle, an unreadable cost axis, squeezed active-session cards on mobile). **Charts must honour `state.includeCache`** — a hard-coded value makes a chart contradict the table next to it.
## TODO
- **Plan Usage Limits**: `lib/plan-usage.js` and sync-agent support fetching claude.ai plan usage (session %, weekly all-models %, Sonnet-only %) — frontend section in Overview tab ready but hidden. Currently blocked: Claude Code OAuth token (`sk-ant-oat01-*`) lacks scopes for claude.ai web API (`/api/organizations/{org_id}/usage`). Needs official Anthropic Usage API endpoint or web-session-based auth. Code is in place and will activate automatically once a working token/endpoint is available.
## Documentation
Reference docs live in `docs/` and are **enforced by tests** (`test/docs.test.js`),
because documentation drifts silently — nothing breaks when a route is added and
the reference is not. The suite checks that every `/api` route appears in
`docs/API.md`, that the reference documents no route that no longer exists, that
every `process.env` variable appears in both `docs/CONFIGURATION.md` and
`.env.example`, that stated achievement and test counts match reality, that no
markdown link points at a missing file, that every `lib/` module is named in
`docs/ARCHITECTURE.md`, and that `CHANGELOG.md` has an entry for the current
`package.json` version.
| File | Contents |
|---|---|
| `docs/API.md` | All routes, their authentication, their parameters |
| `docs/ARCHITECTURE.md` | Data flow, module responsibilities, the decisions and why |
| `docs/METRICS.md` | What every number means — the written form of the in-app methodology dialog, including the definitions that were wrong and the measurements that proved it |
| `docs/CONFIGURATION.md` | Every environment variable, plus what is deliberately NOT configurable |
| `docs/metrik-audit-2026-08-30.md` | The audit that produced the cost and time corrections |
| `CONTRIBUTING.md` | Setup, ground rules, how to add an achievement without shipping an impossible one |
⚠️ **Numbers in prose drift worse than numbers in badges** because nobody looks
at them: README_DE claimed 333 tests when the suite had 411 and 700 achievements
when there were 1200, months after the English one had been updated. The badge
script (`npm run badges`) now rewrites the prose count from the same source as
the badge, and the German/English long-form READMEs are pinned to the same
section count so one cannot quietly fall behind the other.
## Badges
Every badge carrying a number is generated by `scripts/update-badges.js` into the
`<!-- BADGES:START -->` / `<!-- BADGES:END -->` block of all three READMEs. The
top two rows are the hero: **version** and **lines of code**, both `for-the-badge`
size, nothing above them. The generator derives version, LOC, tests,
achievements, categories, tiers, API routes, DB tables, lib modules, chart types,
doc pages, i18n keys, priced models and every dependency version (including
Chart.js, which is read out of the CDN tag in `index.html`); the GitHub rows are
shields.io dynamic badges that refresh themselves with no regeneration at all.
⚠️ Only badges **without a value** (language switcher, donate) may live outside
the block — `test/badges.test.js` fails otherwise, and it also pins the hero
order, alt text on every image, well-formed URLs, and `--check` idempotence.
Two real defects came out of writing those tests: `>=20.12` was emitted
unencoded into the URL, and the same `>` in the alt attribute terminated the tag
for any naive parser. ⚠️ Adding tests changes the line count — run
`npm run badges` **after** `git add`, not before, or the LOC badge lags one
commit behind.
## Frontend tests
`test/helpers/frontend.js` loads `i18n.js`/`charts.js`/`app.js` into a `vm`
sandbox with a stub DOM, which is how ~12k lines of browser code got their first
tests without introducing a build step. ⚠️ Top-level `const`/`let` are **not**
properties of the sandbox global (only `var` and function declarations are), so
`LANG`, `state`, `WEEKDAY_SHORT` and `currentLang` have to be read back via
`ctx.pick([...])`. `currentLang` defaults to **de**, so any test asserting an
English string must call `setLang('en')` first. Writing these found
`_formatDuration(undefined)` rendering **"NaNh NaNm"** in the sessions table —
`_formatActiveTime` had guarded its input, this one had not.
## Conventions
- **CommonJS** throughout backend (`require`/`module.exports`)
- **Timestamps**: ISO 8601 strings, dates as `YYYY-MM-DD` sliced from timestamps
- **Token counts**: Always integers, default 0
- **Costs**: Rounded to 2 decimals
- **Unused variables**: Prefix with `_` (ESLint configured for this)
- **German text**: Use proper umlauts (ü, ö, ä, ß), never ASCII substitutes (ue, oe, ae, ss)
- **Tests**: Use vitest globals (no imports needed), temp dirs via `fs.mkdtempSync()`. **Nothing may touch the real environment**: the API tests boot the server against a throwaway DB by setting `DB_PATH` + `CLAUDE_DIR` (both env-resolved in `config.js`) **before** requiring `lib/config`/`lib/db`/`lib/aggregator`/`server`, and seed it via `test/fixtures/history.js` (`buildHistory({days, perDay})` — a deterministic, index-derived multi-day history across 3 projects / 3 models / MCP + sub-agent messages). They previously booted on `data/tracker.db` and `~/.claude`, which made the suite 33 s long, machine-dependent (CI, with no data, failed on the achievements test) and let `POST /api/achievements/recompute` rewrite the developer's real achievements. Multi-user tests set `MULTI_USER=true` in env and clear the require cache. Modules whose config is read at require time (`config.js`, and anything reading `SESSION_SECRET`/`MULTI_USER` such as `anthropic-api.js`) are tested by setting `process.env` **before** a fresh `require`, with `delete require.cache[require.resolve(...)]` and env restored in `afterEach`. Prefer asserting **numbers over shapes** — `expect(body.messages).toBe(45 * 6)` catches an aggregation regression, `expect(Array.isArray(body))` does not. Coverage by file: `parser` (incl. rate-limit events, sub-agent path detection, line counting, offsets), `aggregator` (+ `aggregator-cache`, incl. calendar edge cases: previous-month clamping, Monday week start, future-dated messages, empty payloads), `db` (incl. devices, rate-limit events, project shares), `auth`, `pricing` (+ `pricing-fetcher`), `achievements` (incl. backfill determinism, atomic replace, tier-scaled sample gates), `backup` (incl. the 50 % shrink guard and same-second collisions), `sync`, `api`, `share-api`, `multi-user-api`, `watcher`, plus `export-html` (pure HTML/escaping/optional-tab rendering), `anthropic-api` (AES-256-GCM `encryptKey`/`decryptKey` round-trip + auth-tag tamper detection + admin-key storage/env-fallback resolution), and `config` (env defaults/overrides, `BASE_URL`, `CLAUDE_DIR`/`PROJECTS_DIR` and `DATA_DIR`/`DB_PATH` derivation). **`test/watcher.test.js` synthesizes chokidar events via `watcher.emit('add'|'change', file)`** instead of waiting for real filesystem notifications — deterministic and instant on CI; chokidar 4 normalizes `ignored` into an *array* of matchers, so the predicate is reached via `[].concat(options.ignored).find(x => typeof x === 'function')`. **`test/share-api.test.js` boots a second server with `SHARE_ADMIN_KEY`** and covers the only publicly reachable surface (token format, expiry, revocation, sanitized payload, CORS allowlist, per-IP rate limit — that last test must stay last, the limiter keeps server-side state). The last day of `buildHistory()` is anchored minutes before `now`, not at fixed hours: fixed morning slots land in the *future* when the suite runs early or in a UTC+14 timezone, and `getTrends()` correctly drops future messages, which silently emptied "today". CI runs UTC; the suite also passes in Berlin/Los Angeles/Kolkata (two `SAMPLE_MESSAGES`-based day-bucketing assertions are known to shift at UTC+14).
- **README badges**: the test-count and LOC badges at the top of `README.md`/`README_EN.md`/`README_DE.md` are **generated**, never hand-edited — `scripts/update-badges.js` rewrites the block between `<!-- BADGES:START -->` / `<!-- BADGES:END -->` from the real vitest JSON report and `git ls-files`. `.github/workflows/badges.yml` runs it on every push to `main` and commits the result (`[skip ci]`); `npm run badges` does the same locally. Editing the numbers by hand just gets overwritten on the next push.
- **ESLint**: Flat config (ESLint 9), only covers `lib/` and `server.js`
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

