Skip to content

Commit 2bb857d

Browse files
Arylmeraclaudegithub-actions[bot]
authored
Release v4.1.5 (#122)
* ci: enforce develop as only PR source into main Fails any PR opened against main whose head branch is not develop, backing up the new branch protection (which can't filter PR source branch natively). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(topbar): add freshness indicator next to prompt cursor Shows time since the last `td:data` event (fired on every MOCK_DATA rebuild). Healthy refresh pipeline (10s scan + SSE, 15s polling fallback) keeps the label at "just now" / "Ns ago". Past 30s the indicator flips to a "stale" warn-colored hint — that's the only state where it actually matters, since anything older means both SSE and the polling fallback went silent (backend dead, OS sleep, wedged connection). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(icons): regenerate Windows .ico with BMP entries for small sizes Previous icon.ico stored every size as PNG-encoded entries (16→256). Windows shell renders small PNG-in-ICO inconsistently — taskbar and Explorer fell back to the generic document icon. Regenerated from icon.png with BMP/DIB entries for 16/24/32/48/64/128 and PNG only at 256, matching standard Windows icon conventions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(widget): prevent double-spawn of widget window on launch Two paths used to race during startup: a synchronous `spawn_widget` call gated on the persisted `widget_open` flag, and the async reconciler whose first interval tick fires immediately. Tauri's `get_webview_window` only registers the widget after `build()` finishes on the main thread, so both paths cleared the existence guard and built two windows. - Drop the redundant startup spawn; the reconciler's immediate first tick handles restore through one code path. - Add an `AtomicBool` gate inside `spawn_widget` so any future concurrent caller (tray click + reconciler + prefs toggle) cannot slip past the existence check while a build is in flight. * chore(release): bump version to 4.0.9 (#74) Develop carries 8 commits since v4.0.8 (widget double-spawn fix, .ico regen, freshness indicator, PR-source enforcement). Bump workspace + tauri.conf to 4.0.9 so the auto-tag job on the develop→main merge cuts v4.0.9 and triggers the release pipeline. * feat(design): polish pass — accent budget, density, glass legibility (#76) Acting on the design review: Quick wins - Drop accent tab from KPI labels (keep on card heads) to stay within the accent budget; ~8 tabs on Overview was over the 10% ceiling. - Range tabs unified with the rest of the chrome: mono 10px UPPERCASE 0.08em instead of Inter 11px 0.04em. - Empty states + notes go solid (iron-border-2); the only dashed lines in the system were inconsistent with everything else. - Active row glyph: left -2px -> 2px, no more left-edge collision. - Focus-visible outlines on nav, range tabs, and sort headers. - Tips drop the 3px colored stripe (system rule was 'no side-stripe ever') for a 1px border + tinted bg. Same hierarchy, less drama. - 'token sink' nav label shortened to 'sink' for rhythm; route slug intact. Medium - Hero metric 48px -> clamp(28px, 4vw, 36px); the SaaS-hero relic crashed the type rhythm next to 28px strip-num. - Terminal prompt drops the redundant --range= --tab= readout; cursor freezes when data-fresh is active so it stops competing with the dot. - Token Sink rows get a --pct accent gradient behind them; replaces the vestigial 'distribution' column. The 'heatmap' name now matches the rendering. - Settings rows + theme swatches: 8px / 6px / 4px radii -> 0. The system has sharp corners; settings was an island. - Project cells split: nickname in Inter italic, slug in mono caption for a subtle scan hierarchy in otherwise uniform mono tables. Larger - theme-dim added (panel #13181F, bg #0F1318, text #C7CFD8) for late- night reading; rounds out the cockpit-finish set. - Custom DateInput popover replaces native <input type=date>; portaled into .dir-a-root with position:fixed so it escapes topbar overflow. Arrow keys step day/week, Enter commits, Esc closes. Follow-ups from review - Light-theme glass topbar bumped to 75% panel-mix and dropped the contrast(105%) filter; the dark muddy blob over light themes was caused by the saturate/contrast bump amplifying the OS wallpaper. - .a-card-head gets gap+wrap so the prompts page meta stops colliding with the title (the search input's margin-left:auto was negating space-between for the preceding siblings). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): bump version to 4.0.10 (#77) Develop carries one design-polish commit since v4.0.9 (accent budget, density, glass legibility — #76). Bump workspace + tauri.conf so the auto-tag job on the develop→main merge cuts v4.0.10 and triggers the release pipeline. * fix(widget): single-instance lock + hash-routed widget URL (#79) Two failure modes caused the "1 widget + 1 error page" (and later "2 widgets + 1 error page") behavior users were seeing: 1. No single-instance enforcement. Each running token-dashboard-app.exe picks a free port, restores `widget_open=true` from the shared DB, and spawns its own widget. Stale processes from earlier launches compound the problem — N processes = N widget windows. 2. Widget URL pointed at `/web/widget.html` (ServeDir nest). On some setups the nested path 404s while the bare `/` route works, which surfaced as a Chromium error page in the second widget window. - Add tauri-plugin-single-instance as the first plugin in the builder. Second-instance launches focus the existing main window and exit. - Route widget through `${base_url}/#widget` (same as main `/` route) and detect widget mode via the URL fragment in entry.jsx, applying the `td-widget-body` body class at mount time so widget-only CSS still takes effect. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): bump version to 4.0.11 (#80) * Release v4.0.10 (#78) (#82) * ci: enforce develop as only PR source into main Fails any PR opened against main whose head branch is not develop, backing up the new branch protection (which can't filter PR source branch natively). * feat(topbar): add freshness indicator next to prompt cursor Shows time since the last `td:data` event (fired on every MOCK_DATA rebuild). Healthy refresh pipeline (10s scan + SSE, 15s polling fallback) keeps the label at "just now" / "Ns ago". Past 30s the indicator flips to a "stale" warn-colored hint — that's the only state where it actually matters, since anything older means both SSE and the polling fallback went silent (backend dead, OS sleep, wedged connection). * fix(icons): regenerate Windows .ico with BMP entries for small sizes Previous icon.ico stored every size as PNG-encoded entries (16→256). Windows shell renders small PNG-in-ICO inconsistently — taskbar and Explorer fell back to the generic document icon. Regenerated from icon.png with BMP/DIB entries for 16/24/32/48/64/128 and PNG only at 256, matching standard Windows icon conventions. * fix(widget): prevent double-spawn of widget window on launch Two paths used to race during startup: a synchronous `spawn_widget` call gated on the persisted `widget_open` flag, and the async reconciler whose first interval tick fires immediately. Tauri's `get_webview_window` only registers the widget after `build()` finishes on the main thread, so both paths cleared the existence guard and built two windows. - Drop the redundant startup spawn; the reconciler's immediate first tick handles restore through one code path. - Add an `AtomicBool` gate inside `spawn_widget` so any future concurrent caller (tray click + reconciler + prefs toggle) cannot slip past the existence check while a build is in flight. * chore(release): bump version to 4.0.9 (#74) Develop carries 8 commits since v4.0.8 (widget double-spawn fix, .ico regen, freshness indicator, PR-source enforcement). Bump workspace + tauri.conf to 4.0.9 so the auto-tag job on the develop→main merge cuts v4.0.9 and triggers the release pipeline. * feat(design): polish pass — accent budget, density, glass legibility (#76) Acting on the design review: Quick wins - Drop accent tab from KPI labels (keep on card heads) to stay within the accent budget; ~8 tabs on Overview was over the 10% ceiling. - Range tabs unified with the rest of the chrome: mono 10px UPPERCASE 0.08em instead of Inter 11px 0.04em. - Empty states + notes go solid (iron-border-2); the only dashed lines in the system were inconsistent with everything else. - Active row glyph: left -2px -> 2px, no more left-edge collision. - Focus-visible outlines on nav, range tabs, and sort headers. - Tips drop the 3px colored stripe (system rule was 'no side-stripe ever') for a 1px border + tinted bg. Same hierarchy, less drama. - 'token sink' nav label shortened to 'sink' for rhythm; route slug intact. Medium - Hero metric 48px -> clamp(28px, 4vw, 36px); the SaaS-hero relic crashed the type rhythm next to 28px strip-num. - Terminal prompt drops the redundant --range= --tab= readout; cursor freezes when data-fresh is active so it stops competing with the dot. - Token Sink rows get a --pct accent gradient behind them; replaces the vestigial 'distribution' column. The 'heatmap' name now matches the rendering. - Settings rows + theme swatches: 8px / 6px / 4px radii -> 0. The system has sharp corners; settings was an island. - Project cells split: nickname in Inter italic, slug in mono caption for a subtle scan hierarchy in otherwise uniform mono tables. Larger - theme-dim added (panel #13181F, bg #0F1318, text #C7CFD8) for late- night reading; rounds out the cockpit-finish set. - Custom DateInput popover replaces native <input type=date>; portaled into .dir-a-root with position:fixed so it escapes topbar overflow. Arrow keys step day/week, Enter commits, Esc closes. Follow-ups from review - Light-theme glass topbar bumped to 75% panel-mix and dropped the contrast(105%) filter; the dark muddy blob over light themes was caused by the saturate/contrast bump amplifying the OS wallpaper. - .a-card-head gets gap+wrap so the prompts page meta stops colliding with the title (the search input's margin-left:auto was negating space-between for the preceding siblings). * chore(release): bump version to 4.0.10 (#77) Develop carries one design-polish commit since v4.0.9 (accent budget, density, glass legibility — #76). Bump workspace + tauri.conf so the auto-tag job on the develop→main merge cuts v4.0.10 and triggers the release pipeline. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * ci(release-tauri): retry DMG bundle on macOS (#83) bundle_dmg.sh intermittently fails on macos-14 (Apple Silicon) when hdiutil races with Spotlight indexing — observed on run 25875894906 where macos-arm64 failed while macos-x64 succeeded on the same image. Split the macOS step: build .app first, then retry --bundles dmg up to 3 times with a 15s backoff. Linux/Windows keep the single-shot path. * ci(release-tauri): honor workflow_dispatch tag input (#85) The release/winget/homebrew jobs gated on tag-push or auto-tag only, so dispatching the workflow with the documented `tag` input built artifacts but skipped the release attach + downstream tap updates. Extend the conditions and tag resolution to fall through to `inputs.tag` when triggered via workflow_dispatch. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(limits): clear stale reset stamp when sync omits header (#87) When Anthropic omits the unified-5h / weekly reset headers (typical once the window has expired with no active usage), the OAuth sync left the previous future-pointing timestamp in `plan`. The Overview card then read it back via `compute_limits_from_server`, derived an anchor from it in `server_window`, and the UI showed a live "resets in Xh" countdown to a window that had already elapsed. - Mirror None to the store so a missing header clears the row. - Belt-and-suspenders in `compute_limits_from_server`: drop any reset stamp that is already in the past before handing it to `server_window`. - Tighten `server_window` anchor gating so a zero-util sync with no future reset renders idle instead of "active". Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): bump version to 4.0.12 (#88) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * ci: auto-sync main into develop after release (#90) Squash-merging develop into main leaves develop's commits unreachable from main, so the merge-base never advances and every version bump on develop reconflicts with main's prior bump on the same Cargo.toml line. This workflow opens a sync PR from main into develop on every push to main, advancing the merge-base and eliminating the recurring release conflict. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(sse): forward scan_complete hint to loadDelta (#91) The SSE listener was passing a synthetic `{ scan: true }` hint into loadDelta on every scan_complete event, discarding the rich sessions/projects/days/models arrays the backend emits. Without those arrays, pickEntries skipped every "days"-triggered registry slot (overviewToday, overviewYday, daily, hourlyRaw, ...), so the main window's TODAY · LIVE card, yesterday delta, and daily charts froze at the values fetched during the initial page load. The widget window appeared fresh only because it was usually opened later, after activity had already accrued, and re-ran a full loadAll on mount. Forward the parsed payload (which already carries the hint via Object.assign in the SSE listener) so pickEntries can route refetches to the right slots on every scan tick. * feat: cache hit-rate trend card on Overview (#92) * feat(core): cache hit-rate trend module Adds cache_stats::cache_trend computing per-day cache hit rate and 7d/30d token-weighted averages from the messages table. * feat(cli): GET /api/cache-stats endpoint Returns CacheTrend (per-day hit rate + 7d/30d averages). * feat(ui): cache hit-rate trend card on Overview Consumes /api/cache-stats. Renders 7d/30d/latest KPIs plus a StripSpark of daily hit rates over the last 30 days. * style(core): rustfmt cache_stats * feat: burn-rate projection card on Overview (#93) * feat(core): burn-rate projection module Aggregates last N days of token-priced spend, projects days-until-monthly-budget-exhausted accounting for month-to-date spend. Returns daily series for sparkline. * feat(cli): GET /api/burn-rate endpoint * feat(ui): burn-rate card on Overview Consumes /api/burn-rate. Shows avg-cost-per-day, projected days-until-monthly-budget-exhausted, exhaustion date, plus a daily-spend sparkline. * feat: monthly budget threshold alerts (50/80/100%) (#94) * feat(core): monthly budget threshold alerts Detects 50/80/100% crossings against the configured monthly budget. Persists fired state per-month so each threshold fires once; rolls over automatically on month change. Mutes honoured. * feat(cli): /api/budget-alerts + /api/budget-alerts/config GET /api/budget-alerts triggers a check and returns the AlertResult (mtd_cost, percent, newly_crossed). GET/POST /api/budget-alerts/config reads or updates thresholds + muted. * feat(ui): budget threshold banner on Overview Renders a tone-coded banner above BudgetBanner when /api/budget-alerts returns newly_crossed thresholds. Tone: warn at 50-80%, bad at 100%. * docs: add docs/todo/ roadmap plans (01-15) (#95) Self-contained implementation plans for the queued feature roadmap. Each plan follows the writing-plans skill format: file structure, TDD tasks with bite-sized steps, self-review notes. README indexes them with a suggested order. * feat(ui): Budget tab with threshold picker + budget editor (#97) Adds a new top-level Budget tab between Overview and Prompts. Phase 1 ships the page shell, threshold picker (50/80/100 default with mute toggles), and budget editor (daily/weekly/monthly inputs with on-pace daily preview). Both cards use existing endpoints (/api/budget, /api/budget-alerts/config) so no backend changes are needed for this phase. Per-project allocation and history land in follow-ups. * feat: OS notifications for budget threshold crossings (#96) * feat(cli): emit budget_alert SSE event after scan When run_scan_and_broadcast completes, also runs core::budget_alerts::check and broadcasts a budget_alert event if any thresholds were newly crossed. The Tauri shell subscribes to this in a follow-up commit to fire OS notifications. * feat(tauri): OS notifications for budget threshold crossings Adds tauri-plugin-notification, grants notification:default capability, and subscribes to /api/stream for budget_alert events. Each newly-crossed threshold fires one native toast (Windows: standard, macOS: prompts on first run, Linux: requires a notification daemon). * docs: platform behavior for budget notifications * feat: Budget tab phase 2 — per-project allocation (#98) * feat(core): per-project budget allocations Adds preferences::{get,set,list}_project_budget for storing per-project monthly caps in the plan table (keyed budget_project_<slug>_usd). Adds budget_projects::allocations returning MTD spend per project joined with caps + utilisation %, sorted by descending cost. Caps survive a project having zero spend this month (still listed). * feat(cli): /api/budget/projects GET + POST GET returns ProjectAllocation rows; POST { slug, amount } sets or clears a per-project monthly cap. Also drops an unused rusqlite::params import in budget_projects.rs. * feat(ui): per-project allocation card on Budget tab New ProjectAllocation component renders a sortable table of MTD spend per project with inline cap editing. Caps drive a tone-coded utilisation column (good/warn/bad) and a horizontal share bar. Caps without spend still render so newly-added projects are visible. * feat: Budget tab phase 3 — burn-rate panel + history table (#99) * feat(core): per-month budget history * feat(cli): /api/budget/history endpoint GET returns trailing-N-month spend per month with current-budget percent and max threshold reached. Default months=6, clamped 1-36. * feat(ui): burn-rate panel + history table on Budget tab BurnRatePanel: 7/30/60/90 window switcher, KPIs (avg/day, days-left, exhaustion date, on-pace), custom SVG chart with on-pace guideline. BudgetHistoryTable: 3/6/12/24-month switcher, tone-coded percent + max-threshold-fired columns, hint about current-budget application. * feat(ui): compact burn-rate + overlay on Today + gate budgets to API (#100) * feat(ui): compact burn-rate card + overlay on Today + gate budgets to API BurnRateCard on Overview drops its big sparkline; the daily series now overlays on the TopStrip's today/hourly chart as a dashed warn-colored line with shared y-axis. New a-strip-legend shows what each line represents. BudgetEditor on the Budget tab gates the daily/weekly/monthly inputs behind plan == 'api' (subscription plans pay a flat fee, dollar caps don't apply); shows a hint pointing to Settings → Plan otherwise. StripSpark gains optional overlayData + overlayAccent props. * feat(ui): align Burn rate panel to AreaChart design Drops the bespoke PanelChart SVG in burn-rate-panel.jsx and reuses the shared AreaChart component from charts.jsx for consistent typography, gradient fill, hatch pattern, and peak/trough annotations matching the Overview's Cache×cost chart. AreaChart gains optional guidelineY + guidelineLabel + guidelineAccent props so the on-pace line renders inline. Legacy a-burn-* CSS classes removed. * feat(core): subscription burn-rate projects against weekly token cap On subscription plans (Pro/Max/Team/etc.) the burn-rate card now answers 'when will I hit my weekly cap?' instead of just showing —. burn_rate::burn_rate dispatches on the active plan: API plan keeps the USD-monthly-budget projection; everything else pulls limits::compute_limits, derives a sonnet-equivalent tokens/day rate from the active weekly window, divides remaining headroom by that rate, then clamps to days-until-reset so we never project past the boundary. New cap_mode field (weekly_tokens / usd_monthly / none) lets the UI relabel its KPIs and hide the USD on-pace guideline on subscription where it doesn't apply. * style: rustfmt burn_rate * fix(core): burn-rate falls back to USD budget when weekly cap path can't project Subscription users with a configured monthly budget but no pricing.json weekly cap (or idle weekly window) were seeing days_remaining = None and a 'weekly cap not configured' subtitle even though we had everything needed for a USD projection. Now the projection cascades: subscription tries weekly tokens first; if that yields nothing AND budgets.monthly is set, fall through to the same USD math the API plan uses. Keeps the card useful instead of empty. * fix(core): subscription burn-rate counts down to weekly reset when no cap projectable Subscription users with no projectable weekly token cap (Max plan in pricing.json lacks one) were falling through to USD-monthly math that projected weeks or months past the actual weekly-reset boundary. Now the cascade is: WeeklyTokens (cap projection) → WeeklyReset (days until current window resets — the binding constraint for subscription users) → UsdMonthly (only for API plan). Adds CapMode::WeeklyReset; KPIs relabel 'hits zero' → 'window resets' and 'days left' counts down to the reset timestamp from compute_limits. * fix(ui): no red tone on weekly_reset countdown Subscription users in weekly_reset mode are counting down to an automatic window refresh, not to a constraint hit. Painting days-left red when it drops below 3 implied urgency where there is none. Skip the tone class for weekly_reset; keep it for weekly_tokens (real cap exhaustion) and usd_monthly (real budget hit). * fix(ui): show hours instead of fractional days when burn-rate days_left < 48h * fix(ui): style checkboxes in threshold picker to match design system * feat: subscription burn rate uses pct_used for OAuth source + fix kpi double border subscription_days_remaining adds a percent-math path: when the weekly window has no hardcoded cap (limits source=oauth, common on Pro/Max/Team), it still projects from Anthropic's pct_used / hours_since_anchor rate to detect 'you'll throttle before the next reset' even without knowing the absolute token cap. This is what subscription users actually want — early-exhaustion warning regardless of pricing.json data. Also drops the .a-kpi-row border on the compact BurnRateCard so it stops stacking with the card + per-cell borders into a doubled frame. * feat(settings): surface threshold picker + gate BudgetCard to API plan Settings now mirrors the plan-aware behaviour of the Budget tab: dollar Budgets card only renders on the API plan, and the budget-alert ThresholdPicker is added to the 'Limits & alerts' group. Picker writes to /api/budget-alerts/config the same way the Budget-tab picker does, so changes stay in sync regardless of which tab the user is on. * fix(ui): auto-refresh dashboard at local midnight All 'today' counters (overview KPIs, prompts today, widget today tile, etc.) are anchored to isoDaysAgo(0) = local midnight on the request side, so they were correct AT load time but stayed stuck on yesterday's values when a session spanned the day boundary. Now schedules a loadAll() at the next local midnight (+500ms slack) and re-arms itself recursively, so every today-anchored value resets in sync. * feat: tool / MCP cost attribution (#101) * feat(core): tool/MCP cost attribution * feat(cli): GET /api/tool-costs endpoint * feat(ui): tool cost column on Top tools + MCP servers card TopToolsCard adds a sortable 'cost' column from /api/tool-costs, defaults sort to descending cost (highest burners first), and shows a small error dot when the tool had failures. New McpServersCard renders a strip of MCP servers with their attributed cost and call count, rolled up from the same endpoint. * fix(core): tool_costs SQL was O(N²) — bucket sibling counts in one pass The correlated subquery (SELECT COUNT(*) FROM tool_calls WHERE message_uuid = m.uuid) per row hung /api/tool-costs on real-size histories. Pre-aggregating sibling counts into a HashMap with one GROUP BY pass turns the join into O(N). * feat(ui): MCP tab on Token sink page Adds a fourth tab to the Sink/Work page alongside projects/skills/sessions. Sources from /api/tool-costs — pulls mcp_servers for per-server call/cost rollup, enriches with distinct-tool count and aggregate result_tokens by walking tools[] grouped by mcp_server. Sorted by cost descending; errors column tints red when non-zero. Note explains the attribution model so users don't read the cost as exact. * feat: cache mix card shows hit + churn, includes cache_create in denominator Old formula (cache_read / (input + cache_read)) saturated at 100% because Claude Code aggressively caches and input_tokens is tiny vs cache_read. The chart was flat-line useless. New formula divides by all input-side tokens (input + cache_read + cache_create_5m + cache_create_1h) so cache-seeding days visibly drop the rate. Adds churn_rate (cache_create share of total) so the UI can show when fresh entries are being seeded vs replayed. Card renamed Cache mix, gets hit + churn KPIs and a two-line overlay sparkline. * style: rephrase cache_stats doc to satisfy clippy::doc_lazy_continuation The multi-line formula had + as the first character on continuation lines, which clippy parsed as malformed list items. Inlined the formula prose without leading + so the lines aren't seen as a list. * fix(ui): MCP servers as table, side-by-side with compact Cache mix Replaces the MCP chip strip with a proper table (server / calls / cost) so it scans like the other Sink-style cards. Puts both McpServersCard and CacheTrendCard in the same a-card-row so they share a horizontal slot. CacheTrendCard gets a compact variant: smaller KPI font, slimmer sparkline (22px), tighter padding. * fix(ui): drop area fill on Cache mix sparkline so both lines read cleanly * fix(ui): proper Cache mix chart with area fill + Y axis + trailing dot Replaces the StripSpark+overlay combo with a custom mini-chart: hit series gets the green area fill + line, churn stays a dashed orange line (no fill so it doesn't compete visually), 0/50/100% Y-axis ticks on the left, and a trailing dot at the latest-day hit value so 'where am I now' reads at a glance. * feat(ui): cache tab on Sink + split 'today' KPI to fix overflow New 'cache' view on the Token sink page renders the daily token-mix breakdown: date, hit %, churn %, fresh input, cache reads, cache writes. Sourced from D.cacheStats.days, sorted newest-first by default. Hit % is tone-coded (good>=90, warn>=70, else bad) so days with poor reuse stand out. Cache mix card splits the cramped 'today' KPI into 'hit today' + 'churn today' so neither overflows the cell. * feat(ui): top-level Cache tab compiling cache info Adds /cache route between Budget and Prompts in the topbar. Includes a fuller Cache mix card (KPIs + larger chart + total reads/writes/input) plus the daily breakdown table that used to live as a Sink sub-tab. The Sink sub-tab stays for now (harmless duplication). Top-level placement matches the user's mental model of 'cache' as a primary lens alongside budget, prompts, sessions. * fix(ui): Cache page chart uses AreaChart for design consistency Replaces the bespoke inline SVG with the shared AreaChart component. Adds two new props to AreaChart: overlaySeries (optional dashed second line with shared y-axis) and yMax (fixed ceiling — useful for 0..1 ratio data where a hard 100% reads better than the data peak). Cache page now matches the typography, gradient, and hatch pattern of the Cache×cost chart. * fix(ui): remove Cache mix + MCP servers from Overview (now on dedicated tabs) * feat(ui): yTicks + yFormat on AreaChart; cache page renders 0/25/50/75/100% axis * feat: drill into per-session cache breakdown by clicking a day Cache page rows now expand on click. Sub-table fetches /api/cache-stats/sessions?date=YYYY-MM-DD and surfaces per-session hit/churn ratios sorted by descending cache-write tokens — exactly the ordering you want when hunting the day's worst churn offender. Hit-rate tone-coded per-session (good/warn/bad) matches the day-row coloring. * style: rustfmt cli * feat(ui): paginate large tables on Sink + Cache (20 rows/page) Adds usePaginated hook + PageNav footer to sortable.jsx. Applied to all 5 Sink tables (projects, skills, mcp, cache, sessions) and the Cache page daily breakdown. PageNav only renders when totalPages > 1, so small lists stay clean. Page index clamps if the row count shrinks. * fix(ui): missing usePaginated in CacheTable on Sink (bulk replace pattern missed it) * feat(ui): clicking session id in Cache drill-down navigates to Sessions tab Sessions route already reads #/sessions/<id> from the hash and selects that session, so we just wrap the truncated id in an anchor pointing there. No new state plumbing needed. * feat(ui): paginate per-project allocation table on Budget tab * fix(ui): move usePaginated before early returns in ProjectAllocation React error #310: usePaginated calls useState internally, but I'd placed the call AFTER the early-return-for-loading branches. When rows arrived, the hook count jumped and React threw. Pinned the hook to the top of the component so the call count is stable across loading/empty/populated render states. * feat(cli): clear USD budgets when plan switches to non-api POST /api/plan with a subscription plan id (max/pro/team/etc.) now wipes budget_daily_usd, budget_weekly_usd, and budget_monthly_usd via preferences::set_budget(_, None). Prevents the History card from continuing to attribute '% of $X budget' against months where the dollar cap doesn't apply. User can re-enter values after switching back to API. * fix(core): budget_history hides stored USD budget on non-api plans Even after we gated the editor + auto-cleared on plan switch, a user who'd entered a budget before the gate still saw it in History because the value lived in the DB. Now budget_history checks the active plan and returns budget_at_time = None on subscription, so % and threshold columns blank instead of attributing against an inapplicable cap. * feat: multi-machine sync (plan 11) (#103) Read-only HTTP sync between Token Dashboard installs. Each viewer machine pulls a JSON snapshot from configured hosts and merges rows into its local DB, deduplicated by messages.uuid. Core: - remote_sources module: schema + CRUD (label/base_url/bearer/enabled). Bearer redacted from API list/get; pub get_with_bearer for the sync driver only. - sync_snapshot module: build() serialises messages + tool_calls newer than since, merge() does INSERT-OR-IGNORE with a NOT EXISTS guard for tool_calls (no UNIQUE constraint there). prompt_text deliberately omitted from the wire format for privacy. CLI: - GET /api/sync/snapshot — host endpoint. Gated by TOKEN_DASHBOARD_SYNC_TOKEN env var (503 if unset, 401 if mismatched). Bearer never round-trips through prefs. - GET/POST /api/remote-sources — list / add. - DELETE /api/remote-sources/:id - POST /api/remote-sources/:id/toggle - POST /api/remote-sources/:id/sync — drives the pull/merge for one source, stamps last_sync_at + last_error. UI: - New RemoteSourcesCard in Settings → Data. Add form (label / url / bearer), per-row sync/toggle/delete buttons, status column shows last sync age + error message. Implements docs/todo/11-multi-machine-sync.md. Auto-sync on scan is deferred — manual sync only for v1. * feat: detect stuck tool-call loops + surface in Tips & Sessions (#105) Implements the plan in docs/todo/09-retry-loop-detection.md. - core::loop_detector walks tool_calls ordered by (session_id, timestamp, id) and groups consecutive identical (tool_name, target) rows per session. Runs of length >= min_run are returned as StuckRun with count, error count, and time span. Three unit tests cover the happy path, the min_run gate, and per-session grouping. - GET /api/loops?min_run=3&days=30 exposes the detector. Params are clamped (min_run 2..=1000, days 1..=365). OpenAPI updated. - tips::stuck_loop_tips emits a single Tip naming the worst offender when any session has >=4 consecutive identical calls in the last week. Dismissible like the other rules. - Sessions UI fetches /api/loops once and renders a 🔁 chip next to the tags for affected rows, with tool x count tooltip. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat: cost-per-tag summary + dedicated Tags page (#106) Aggregates token spend by user-applied session tag and surfaces it as a new top-level tab so the existing tag editor pays off as ROI per feature. - core: tag_aggregates() + tag_session_counts() in queries (per-(tag, model) tokens plus distinct-session counts; split so multi-model sessions don't double-count) - cli: GET /api/tags-summary folds aggregates with pricing into TagSummaryRow {tag, sessions, total_tokens, cost_usd, first_seen, last_seen}, sorted desc by cost - ui: new Tags page (KPI strip + sortable cost/tokens/dates table); click a tag to open Sessions filtered to it via sessionStorage handoff (hash router can't carry query strings) - tests: 4 core integration tests + 2 cli endpoint tests Closes plan docs/todo/03-cost-per-feature-tagging.md (schema/editor/sync were already in tree from earlier tagging work). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat: prompt verbosity detector (#06) (#102) * feat(core): prompt verbosity ranking Surfaces user prompts with high chars-in / tokens-out ratio — the long-prompt-tiny-reply pattern that flags wasted verbosity. Mixed units (chars vs tokens) is intentional: stable across English and code, no in-tree tokenizer needed. * feat: verbosity endpoint + prompts tab Adds GET /api/verbosity?min_chars=&top= and a "Wasted" tab on the prompts page that lists long prompts with tiny replies, sorted by ratio. Threshold is user-tunable from the UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat: 3σ session-cost anomaly detection (todo 13) (#104) * feat(core): 3σ session-cost anomaly detector Per-project rolling mean+stdev over a configurable window; flags any session whose cost-z exceeds k (default 30d, k=3.0). Min 5 sessions per project to avoid pathological alerts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: anomaly detection endpoint + Tips rule + Overview card GET /api/anomalies?days&k (clamped 1-365 / >=0.5) exposes the detector. Tips engine surfaces the worst offender as a dismissable "anomaly" Tip. Overview gets an AnomalyCard above RecentSessions listing flagged sessions with cost, z-score, baseline mean, and a click-through to the session detail. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat: auto-tag git projects + standalone remote-setup help window (#107) Auto-tagging - new auto_tags module derives a project tag from any messages.project_slug containing -git- (e.g. token-dashboard, Token-Dashboard -> token-dashboard, worktree suffixes collapse to the parent). scanner.rs runs backfill_all on every scan, gated by a new session_auto_tag_log table so removed tags don't get reapplied. Remote-sources setup window - Setup instructions button on the Remote machines card opens a separate Tauri webview (label setup-help, #setup-help route), styled like the widget popup, with theme + storage sync so it follows the main window's active theme. Falls back to an inline modal in non-Tauri contexts. - Content covers host-side TOKEN_DASHBOARD_SYNC_TOKEN export, viewer-side add flow, plus a full technical walkthrough of the ureq GET + INSERT OR IGNORE merge path with code snippet. - Adds open_setup_help Rust command + capabilities update (core:webview:allow-create-webview-window, allow-show, allow-set-focus, setup-help in the windows array). UX polish - Prompts tab: Expensive/Wasted toggle uses the existing a-pill-btn class; min-chars input aligned via a-prompt-search styling. - Tips page: empty-state message when the rule engine hasn't flagged anything in the window. - Tags page: tag rows render as pill chips matching the Sessions chip style with accent border + dot prefix. - Sessions table: scroll height bumped from 5 to 10 rows; sticky header gets z-index 5 and an opaque thead backdrop so glass themes don't bleed through; tags column gains 40px left padding for visual separation from cost; chips are now nowrap pills with a 4px accent dot prefix. - Tag editor: native datalist replaced with a custom popup combobox (focus to open, type to filter, click to pick) populated from D.tags, with usage counts. Opaque #14171a base (or #fafafa on light themes) + isolation: isolate so the dropdown stays solid in glass themes. * Budget tab grouping + Cap UI polish (folds in auto-tags branch) (#109) * feat: auto-tag git projects + standalone remote-setup help window Auto-tagging - new auto_tags module derives a project tag from any messages.project_slug containing -git- (e.g. token-dashboard, Token-Dashboard -> token-dashboard, worktree suffixes collapse to the parent). scanner.rs runs backfill_all on every scan, gated by a new session_auto_tag_log table so removed tags don't get reapplied. Remote-sources setup window - Setup instructions button on the Remote machines card opens a separate Tauri webview (label setup-help, #setup-help route), styled like the widget popup, with theme + storage sync so it follows the main window's active theme. Falls back to an inline modal in non-Tauri contexts. - Content covers host-side TOKEN_DASHBOARD_SYNC_TOKEN export, viewer-side add flow, plus a full technical walkthrough of the ureq GET + INSERT OR IGNORE merge path with code snippet. - Adds open_setup_help Rust command + capabilities update (core:webview:allow-create-webview-window, allow-show, allow-set-focus, setup-help in the windows array). UX polish - Prompts tab: Expensive/Wasted toggle uses the existing a-pill-btn class; min-chars input aligned via a-prompt-search styling. - Tips page: empty-state message when the rule engine hasn't flagged anything in the window. - Tags page: tag rows render as pill chips matching the Sessions chip style with accent border + dot prefix. - Sessions table: scroll height bumped from 5 to 10 rows; sticky header gets z-index 5 and an opaque thead backdrop so glass themes don't bleed through; tags column gains 40px left padding for visual separation from cost; chips are now nowrap pills with a 4px accent dot prefix. - Tag editor: native datalist replaced with a custom popup combobox (focus to open, type to filter, click to pick) populated from D.tags, with usage counts. Opaque #14171a base (or #fafafa on light themes) + isolation: isolate so the dropdown stays solid in glass themes. * feat(budget): group projects by canonical git name + refine Cap column - crates/token-dashboard-core/src/budget_projects.rs: * new canonical_project_key()/project_name_segment() helpers strip --claude-worktrees-... suffixes and walk past -git-/-Github- path markers so worktrees, casing variants, and clones on different machines collapse to one row keyed by the project name. * ProjectAllocation gains display_name (case-preserved) and member_count. * Cap lookup falls back through any legacy raw-slug cap whose canonical key matches, so existing caps still apply without migration. * 3 new tests cover the worktree collapse, the legacy-cap fallback, and the helper edge cases. - frontend/src/routes/budget/project-allocation.jsx: * render display_name with a x<N> member-count badge when several raw slugs collapsed into one row; raw slug stays in the title attribute for traceability. * cap input picks up the themed `.a-text-input .a-cap-input` classes so it no longer shows browser-default white styling. - frontend/styles.css: * `.a-col-cap` column gets a fixed 140px width and the link-button becomes a 100px dashed-box click target so "set..." is no longer cramped and reads as an actionable affordance. * cap input sized to the column with tabular-friendly padding. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): bump version to 4.1.1 Picks up Budget tab grouping + Cap UI polish (#109) since v4.1.0. Also resyncs tauri.conf.json (was stale at 4.0.11) and Cargo crate versions (still at 4.0.12 on develop after the v4.1.0 squash-merge). * feat(ui): animated KPI count-up + card stagger + page transitions (#111) Adds subtle motion to the Overview dashboard: - New CountUp component (frontend/src/components/count-up.jsx) tweens numeric values 0 -> target over 600ms with cubic ease-out, gated by prefers-reduced-motion. - TopStrip today/burn-rate, KPI row (range/plus/input/output/cache-hit), and BurnRate avg/day now use CountUp instead of static formatted strings. - Cards under .a-route fade-up on mount with 40ms stagger via CSS vars on nth-child; KPIs within a row stagger 60ms each. - Tab switches re-mount the route under a keyed .a-page-enter wrapper so each navigation gets a 220ms fade-in. - All animations are no-ops under prefers-reduced-motion. Chart SVGs are intentionally untouched — composite-layer rasterization under animated parents made line charts look low-res, so chart-level animations were reverted; only the surrounding cards animate. * chore: workspace lints, rustfmt/clippy config, CHANGELOG, supply-chain CI - Centralise lint policy in root Cargo.toml [workspace.lints] (warn on dbg_macro, todo, unimplemented, unsafe_op_in_unsafe_fn, unreachable_pub); all crates inherit via [lints] workspace = true. - Add rustfmt.toml (stable-only opts) and clippy.toml (cognitive-complexity 30, too-many-arguments 8). - Seed CHANGELOG.md from existing v4 tags. - Add deny.toml and wire cargo-deny + cargo-audit jobs into rust.yml. - Add docs/code-quality-audit.md capturing the full audit + phased plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tauri): set concrete Content Security Policy Replaces "csp": null with a restrictive policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' (inline styles still used by the React frontend); connect-src 'self' http://127.0.0.1:* http://localhost:* for the embedded server + SSE; img-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'self'. The frontend ships as a local bundle so a tight CSP is achievable without whitelisting external CDNs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cli): split lib.rs into focused modules (2960 -> 19 lines) Behaviour-preserving carve-up of the cli crate's god-object lib.rs. All 151 tests + clippy + fmt remain green; the existing integration suite at crates/token-dashboard-cli/tests/endpoints.rs (1070 lines) is the safety net. New modules under crates/token-dashboard-cli/src/: - lib.rs (19) re-exports only: app, AppState, spawn_scan_loop, spawn_startup_oauth_sync. - state.rs (49) AppState + RangeQs. - errors.rs (69) ApiError, IntoResponse impl, blocking helpers. - util.rs (143) round4/round6, current_iso_z, days_to_ymd, clamp_limit, pragma_columns, push_csv_row, deserialize_double_option, unix_compact_stamp. - scan.rs (155) scan handler, run_scan_and_broadcast, scan loop. - oauth.rs (276) Anthropic limits-sync OAuth pipeline. - sse.rs (79) /api/stream SSE handler. - routes.rs (2390) HTTP handlers + Router builder. Single homogeneous concern; can be sub-split per-domain in a follow-up. Also replaces 6 .parse().unwrap() calls on literal MIME / cache-control strings with HeaderValue::from_static. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ui): trim git project names across all project columns Mirrors the Budget tab's project-name segmentation (worktree suffix + -git-/-Github- marker stripping from token_dashboard_core::budget_projects::project_name_segment) in a new frontend helper `displayProject(slug)` and applies it to every table that still showed the raw slug: - Prompts: expensive list project column. - Sessions: list project column, search filter, sort key, detail header. - Overview: anomalies table + Recent sessions table. - Token sink (work.jsx): project column + sort key. - Cache: per-session project column. Original slug stays visible via the cell title attribute for full disambiguation on hover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ui): trim project name in Token Sink projects table The ProjectsTable in work.jsx renders /api/projects rows where `name` is still the raw slug (the backend doesn't apply the budget grouping on this endpoint). Run displayProject() over the nickname and the sort key; keep the original slug visible via the cell title attribute and the existing slug subtitle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(deny): ignore tauri-transitive unmaintained advisories cargo-deny on develop was failing on 17 unmaintained advisories pulled in through tauri 2.x (rust-unic family via urlpattern, gtk-rs on Linux, proc-macro-error). None have a safe upgrade yet. Also allow CDLA-Permissive-2.0 for webpki-roots and enable allow-wildcard-paths for internal workspace path deps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): bump version to 4.1.2 Patch release on top of 4.1.1. Highlights: - Workspace lints, rustfmt/clippy config, and CHANGELOG checked in. - frontend/package-lock.json tracked. - token-dashboard-cli lib.rs split into focused modules (2960 -> 19 lines). - Long git project names trimmed across Overview, Token Sink, Budget. - cargo-deny + cargo-audit wired into CI (deny.toml ignores the tauri-2.x transitive rust-unic / gtk-rs / proc-macro-error advisories). - Tauri webview ships a concrete Content Security Policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audit): bump to v4.1.2 and mark C1 resolved cli/src/lib.rs god object was split into routes/oauth/scan/sse/state/ util/errors; tauri main.rs shrank to 845 lines. Other findings stand pending re-verification. * ci: drop redundant cargo-audit job cargo-deny already runs check advisories against the same RustSec DB and honors deny.toml's ignore list (RUSTSEC-2024-0370 proc-macro-error, gtk-rs and unic-* transitive deps). The standalone cargo-audit job ignored deny.toml and re-failed on the same advisories. * feat(core): add model_efficiency leaderboard module Per-model cost-per-accepted-edit ranking. An accepted edit is a successful (is_error=0) Edit/Write/NotebookEdit tool call attributed to its parent assistant message; the message's full token cost is divided evenly across its accepted edits. Exposes leaderboard() and leaderboard_with_pricing() for callers that need to layer pricing overrides. Rows sort cheapest-first; models with zero accepted edits sink to the bottom. Adds 4 tests (87 -> 91 in core). * feat(budget): subscription-aware threshold alerts + remote-sync auto-pull Budget alerts now branch by plan: - API plan: existing monthly USD threshold logic unchanged. - Subscription plan (Pro/Max/Team/...): monthly USD alerts suppressed. Watches server-synced weekly + 5h utilization independently, each with its own fired list keyed by the current `resets_at` so thresholds re-fire automatically on a fresh window. Shared thresholds and mutes. SSE broadcasts now tag each `budget_alert` event with `window` ("monthly" | "weekly" | "five_hour"); the Tauri OS-notification handler formats title/body per window. Overview banner renders weekly + 5h banners side-by-side in subscription mode. Also wires the viewer-side remote-source pull loop into the cli binary (default cadence: 5 min), with a Settings hint clarifying that auto-pull is active and "Sync now" still works for immediate refresh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(api,ui): wire /api/model_efficiency endpoint and leaderboard card - New axum route GET /api/model_efficiency?days=N (days clamped 1..=365) delegating to core::model_efficiency::leaderboard_with_pricing so pricing overrides apply. - api-client.js: register modelEfficiency under the 'models' trigger so it refreshes on the same SSE signal as the existing models card. - overview.jsx: render ModelLeaderboard alongside ProjectsTable and ModelsCard, with an empty-state for users without recent edits. * style: cargo fmt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): pull_remote_once test violated UNIQUE base_url + fmt drift The pull_remote_once_round_trips test added a second remote_sources row with the same base_url to exercise the bad-bearer path, which trips the UNIQUE constraint on remote_sources.base_url. Delete the good row first, then re-add with the bad token. Also pick up cargo fmt drift in sync_snapshot.rs and tauri/main.rs that was already failing CI's fmt --check job. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(todo): mark TODO 11 multi-machine sync tasks complete All 12 spec checkboxes (3 tasks x 4 steps) reflect what's now landed on develop: host-side /api/sync/snapshot endpoint with Bearer auth, viewer- side remote_sources CRUD + pull/merge, settings UI with auto-pull scheduler at 5-minute cadence. Outstanding from the spec's Self-Review Notes: bearer tokens still live in the SQLite remote_sources.bearer column rather than the OS keyring, and the NAT/Tailscale workaround documented in the setup-help modal has not been mirrored into README.md. Neither blocks the feature. * fix(brew): make cask arch-aware so checksum matches downloaded DMG (#113) The homebrew release job hashed only the arm64 DMG but the cask URL served the x64 DMG, so Intel installs failed with a SHA-256 mismatch. Make the cask architecture-aware (aarch64/x64) and inject a sha256 for each arch from its matching artifact. https://claude.ai/code/session_011wZ5vtGJvZKVirFjA2YKfo Co-authored-by: Claude <noreply@anthropic.com> * chore(release): bump version to 4.1.3 (#114) Patch release on top of 4.1.2. Highlights: - Model-efficiency leaderboard (/api/model_efficiency + Overview card). - Subscription-aware budget threshold alerts with remote-sync auto-pull. - Multi-machine sync groundwork (remote_sync, sync_snapshot). - Homebrew cask made architecture-aware so brew install/upgrade no longer fails with a SHA-256 mismatch on Intel Macs. https://claude.ai/code/session_011wZ5vtGJvZKVirFjA2YKfo Co-authored-by: Claude <noreply@anthropic.com> * fix(limits): persist rotated OAuth tokens after refresh Anthropic rotates the refresh token on every use and invalidates the prior one. The expired-token refresh path minted a new access token but discarded the rotated refresh token and never wrote it back, so the next sync sent a dead refresh token, failed, and forced a manual `claude` re-login — the loop the user hit returning to their desk. Write the rotated accessToken/refreshToken/expiresAt back to ~/.claude/.credentials.json (Linux/Windows), preserving other fields. Best-effort and silent; macOS skips the write-back to avoid a Keychain write prompt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(limits): surface OAuth refresh failures for diagnosis The expired-token refresh path masked every failure as a generic "token expired" hint, so a dead refresh token and a malformed request looked identical. Log the underlying reason to stderr and include the OAuth error body (e.g. invalid_grant vs invalid_request) in the AccessDenied variant. No token material is logged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(overview): scale today spark independently of burn overlay The "today · hourly" line and the "burn · 7d daily" overlay shared one y-axis max. Daily totals dwarf any single hour, so the shared scale crushed the hourly line into a flat baseline sliver. Scale each series to its own max so today fills the chart height and its shape reads. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): 4.1.4 Bump workspace crates + tauri.conf from 4.1.3 to 4.1.4 and record the OAuth token-refresh persistence fix and the Overview spark rescale. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci(sync): make main->develop sync conflict-proof The sync workflow opened a PR off main into develop, which always conflicted on the CHANGELOG/version lines a squashed release rewrites — so the PR sat unmergeable and develop drifted. Build the sync branch from develop instead and merge main with -X ours: develop is a content-superset of main, so its side wins the conflict and the merge commit re-establishes main as an ancestor (stopping future churn). Document the required "Allow GitHub Actions to create and approve pull requests" repo setting; without it gh pr create fails outright. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(themes): add Terminal, Cockpit, and Grimdark special themes (#120) Adds three "special" themes that go beyond the flat instrument finishes: each remaps the token palette, swaps in vendored display fonts, adds themed ornament (card sigils/brackets, banner strips), and runs an animated full-bleed ambient canvas behind the chrome. - Vendor 5 display faces (VT323, Share Tech Mono, Orbitron, Cinzel, Cormorant Garamond) as woff2 in vendor/fonts + fonts.css (no CDN; CSP font-src 'self'). - Append .theme-{terminal,cockpit,grimdark} blocks + shared .a-ambient container to styles.css; register the themes (mode "special") with a new "Special" row in the Settings theme picker. - AmbientLayer canvas component (phosphor noise / star drift / embers) with reduced-motion gating and pause-on-hidden. - Per-theme banner strips, a cockpit HUD wired to real metrics (cache hit / spend / payload + link freshness), and a cockpit corner-bracket DOM decorator (cards are authored inline). - Themed-copy table + wiring for the brand prompt, nav labels, version meta, session table columns, and card titles (fallbacks keep the other themes unchanged). - "Reduce motion (special themes)" toggle in Settings that idles the ambient canvas and freezes banner/scanline/blip animations. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * docs(readme): add stack badges reflecting actual stack (#121) Replace the mismatched mockup badges (SolidJS, TypeScript) with badges for the real stack: Rust 1.82, Tauri 2.0, React 18.3, esbuild 0.24, MIT. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(heatmap): aggregate activity server-side over full window The activity heatmap was built client-side from /api/sessions, which is capped at limit=50 and ordered most-recent-first. On a heavy day the 50 newest sessions are all from today, so every other weekday dropped out and the heatmap showed a single day. It also plotted each session's whole turn count at its start hour. Add a dedicated /api/activity endpoint backed by a SQL aggregate that counts user turns by local weekday/hour over a trailing window with no row cap, and have the frontend consume it directly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tips): add dismiss button to tip cards Dismiss hides a tip via the existing /api/tips/dismiss endpoint (14-day window). Merged cards carry every underlying key so dismissing the group clears all of them; optimistic removal reverts if the request fails. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(release): 4.1.5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 942935b commit 2bb857d

40 files changed

Lines changed: 1351 additions & 48 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ human-curated highlights.
1010

1111
## [Unreleased]
1212

13+
## [4.1.5] - 2026-05-24
14+
15+
### Added
16+
- Three new special themes: Terminal, Cockpit, and Grimdark.
17+
- Dismiss button on tip cards.
18+
19+
### Fixed
20+
- Activity heatmap now aggregates server-side over the full selected window.
21+
1322
## [4.1.4] - 2026-05-24
1423

1524
### Fixed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
[![License](https://img.shields.io/github/license/Arylmera/Token-Dashboard?style=for-the-badge)](LICENSE)
99
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Arylmera-40DCA5?style=for-the-badge&logo=buymeacoffee&logoColor=black)](https://www.buymeacoffee.com/Arylmera)
1010

11+
![Rust](https://img.shields.io/badge/Rust-1.82-000000?logo=rust&logoColor=white)
12+
![Tauri](https://img.shields.io/badge/Tauri-2.0-24C8DB?logo=tauri&logoColor=white)
13+
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react&logoColor=black)
14+
![esbuild](https://img.shields.io/badge/esbuild-0.24-FFCF00?logo=esbuild&logoColor=black)
15+
![License](https://img.shields.io/badge/License-MIT-3DA639?logo=opensourceinitiative&logoColor=white)
16+
1117
![Token Dashboard overview](docs/images/dashboard-wide.png)
1218

1319
## Install

crates/token-dashboard-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "token-dashboard-cli"
3-
version = "4.1.4"
3+
version = "4.1.5"
44
edition.workspace = true
55
license.workspace = true
66
repository.workspace = true

crates/token-dashboard-cli/openapi.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@
9797
"cache_create_1h_tokens": { "type": "integer", "format": "int64" }
9898
}
9999
},
100+
"HeatmapCell": {
101+
"type": "object",
102+
"description": "One activity bucket. dow follows strftime('%w') (0=Sun..6=Sat) in the machine's local timezone; hour is 0-23. Only non-empty buckets are returned.",
103+
"properties": {
104+
"dow": { "type": "integer", "format": "int64" },
105+
"hour": { "type": "integer", "format": "int64" },
106+
"turns": { "type": "integer", "format": "int64" }
107+
}
108+
},
100109
"PhaseSplit": {
101110
"type": "object",
102111
"properties": {
@@ -389,6 +398,16 @@
389398
"responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/HourlySlot" } } } } } }
390399
}
391400
},
401+
"/api/activity": {
402+
"get": {
403+
"tags": ["read"],
404+
"summary": "Turn counts bucketed by local weekday + hour (activity heatmap)",
405+
"parameters": [
406+
{ "name": "days", "in": "query", "schema": { "type": "integer", "default": 7, "minimum": 1 } }
407+
],
408+
"responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/HeatmapCell" } } } } } }
409+
}
410+
},
392411
"/api/phase-split": {
393412
"get": {
394413
"tags": ["read"],

crates/token-dashboard-cli/src/routes.rs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ use token_dashboard_core::{
2626
limits::LimitsSnapshot,
2727
list_sources, preferences,
2828
queries::{
29-
add_session_tag, all_tags, daily_token_breakdown, dismiss_tip, expensive_prompts,
30-
first_prompts, get_plan, hourly_breakdown, model_breakdown, normalise_tag, overview_totals,
31-
phase_split, project_summary, recent_sessions, remove_session_tag, session_model_usage,
32-
session_tags, session_turns, set_plan, skill_breakdown, tag_aggregates, tag_session_counts,
33-
tool_token_breakdown, DailyRow, ExpensivePromptRow, ModelRow, OverviewTotals, ProjectRow,
34-
SessionRow, SessionTurn, SkillRow, TagRow, ToolRow,
29+
activity_heatmap, add_session_tag, all_tags, daily_token_breakdown, dismiss_tip,
30+
expensive_prompts, first_prompts, get_plan, hourly_breakdown, model_breakdown,
31+
normalise_tag, overview_totals, phase_split, project_summary, recent_sessions,
32+
remove_session_tag, session_model_usage, session_tags, session_turns, set_plan,
33+
skill_breakdown, tag_aggregates, tag_session_counts, tool_token_breakdown, DailyRow,
34+
ExpensivePromptRow, HeatmapCell, ModelRow, OverviewTotals, ProjectRow, SessionRow,
35+
SessionTurn, SkillRow, TagRow, ToolRow,
3536
},
3637
scan_dir, Pricing, ScanStats, Source, Usage,
3738
};
@@ -541,6 +542,28 @@ pub(crate) async fn hourly(
541542
Ok(Json(slots))
542543
}
543544

545+
#[derive(Deserialize)]
546+
pub(crate) struct ActivityQs {
547+
pub(crate) days: Option<i64>,
548+
pub(crate) provider: Option<String>,
549+
}
550+
551+
/// Activity heatmap: turn counts bucketed by local weekday and hour over the
552+
/// last `days` days (default 7). Aggregated in SQL with no session-row cap,
553+
/// so it reflects the whole window rather than the most recent N sessions.
554+
pub(crate) async fn activity(
555+
State(s): State<AppState>,
556+
Query(q): Query<ActivityQs>,
557+
) -> Result<Json<Vec<HeatmapCell>>, ApiError> {
558+
let path = s.db_path.clone();
559+
let days = q.days.unwrap_or(7).max(1);
560+
let provider = q.provider.clone();
561+
let rows = blocking(move || activity_heatmap(path.as_ref(), days, provider.as_deref()))
562+
.await?
563+
.0;
564+
Ok(Json(rows))
565+
}
566+
544567
#[derive(Serialize, Default, Clone, Copy)]
545568
pub(crate) struct PhaseBin {
546569
pub(crate) turns: i64,
@@ -2319,6 +2342,7 @@ pub fn app(state: AppState) -> Router {
23192342
.route("/api/tags", get(tags))
23202343
.route("/api/tags-summary", get(tags_summary))
23212344
.route("/api/hourly", get(hourly))
2345+
.route("/api/activity", get(activity))
23222346
.route("/api/phase-split", get(phase_split_endpoint))
23232347
.route("/api/prompts", get(prompts))
23242348
.route("/api/skills", get(skills))

crates/token-dashboard-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "token-dashboard-core"
3-
version = "4.1.4"
3+
version = "4.1.5"
44
edition.workspace = true
55
license.workspace = true
66
repository.workspace = true

crates/token-dashboard-core/src/queries.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,51 @@ pub fn hourly_breakdown<P: AsRef<Path>>(
395395
rows.collect()
396396
}
397397

398+
/// One (weekday, hour) bucket of the activity heatmap. `dow` follows
399+
/// SQLite's `strftime('%w')` convention (0 = Sunday … 6 = Saturday) and
400+
/// `hour` is 0–23. Both are computed in the machine's local timezone so
401+
/// the heatmap matches the user's wall clock.
402+
#[derive(Debug, Clone, Serialize, Deserialize)]
403+
pub struct HeatmapCell {
404+
pub dow: i64,
405+
pub hour: i64,
406+
pub turns: i64,
407+
}
408+
409+
/// User-prompt ("turn") counts bucketed by local weekday and hour over the
410+
/// last `days` days. A turn is one `type='user'` message — the same unit
411+
/// `recent_sessions` reports. Aggregated entirely in SQL with no row cap so
412+
/// the heatmap reflects the whole window, not just the most recent sessions.
413+
pub fn activity_heatmap<P: AsRef<Path>>(
414+
db: P,
415+
days: i64,
416+
provider: Option<&str>,
417+
) -> rusqlite::Result<Vec<HeatmapCell>> {
418+
let c = open_ro(db)?;
419+
let cutoff = format!("-{} days", days.max(1));
420+
let (prov, prov_args) = provider_clause(provider);
421+
let sql = format!(
422+
"SELECT CAST(strftime('%w', timestamp, 'localtime') AS INTEGER) AS dow, \
423+
CAST(strftime('%H', timestamp, 'localtime') AS INTEGER) AS hour, \
424+
COUNT(*) AS turns \
425+
FROM messages \
426+
WHERE type='user' AND timestamp IS NOT NULL \
427+
AND timestamp >= datetime('now', ?){prov} \
428+
GROUP BY dow, hour"
429+
);
430+
let mut stmt = c.prepare(&sql)?;
431+
let mut args: Vec<String> = vec![cutoff];
432+
args.extend(prov_args);
433+
let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), |r| {
434+
Ok(HeatmapCell {
435+
dow: r.get(0)?,
436+
hour: r.get(1)?,
437+
turns: r.get(2)?,
438+
})
439+
})?;
440+
rows.collect()
441+
}
442+
398443
pub const PLAN_TOOLS: &[&str] = &[
399444
"Read",
400445
"Grep",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! Verifies that `queries::activity_heatmap` aggregates user-turn counts by
2+
//! local weekday/hour over the trailing window, counting only `type='user'`
3+
//! messages and excluding anything older than the window. Timestamps are
4+
//! seeded relative to `now` so the test is independent of the wall clock,
5+
//! and assertions avoid pinning specific weekday/hour buckets (which shift
6+
//! with the machine timezone) — they check totals and the type/window
7+
//! filters instead.
8+
9+
use rusqlite::Connection;
10+
use tempfile::TempDir;
11+
use token_dashboard_core::init_db;
12+
use token_dashboard_core::queries::activity_heatmap;
13+
14+
fn seed(c: &Connection, uuid: &str, mtype: &str, ts_expr: &str) {
15+
// ts_expr is a SQL datetime() expression so timestamps stay relative to
16+
// the test's "now" and always land inside (or outside) the window.
17+
c.execute(
18+
&format!(
19+
"INSERT INTO messages (uuid, session_id, project_slug, type, timestamp, \
20+
input_tokens, output_tokens, cache_read_tokens, cache_create_5m_tokens, cache_create_1h_tokens) \
21+
VALUES (?, 's1', 'p1', ?, {ts_expr}, 0, 0, 0, 0, 0)"
22+
),
23+
rusqlite::params![uuid, mtype],
24+
)
25+
.unwrap();
26+
}
27+
28+
#[test]
29+
fn counts_only_recent_user_turns() {
30+
let tmp = TempDir::new().unwrap();
31+
let db = tmp.path().join("td.db");
32+
init_db(&db).unwrap();
33+
let c = Connection::open(&db).unwrap();
34+
35+
// Three user turns inside the 7-day window, at two distinct moments.
36+
seed(&c, "u1", "user", "datetime('now','-2 days')");
37+
seed(&c, "u2", "user", "datetime('now','-2 days')");
38+
seed(&c, "u3", "user", "datetime('now','-5 days')");
39+
// Assistant message inside the window — must be ignored (not a turn).
40+
seed(&c, "a1", "assistant", "datetime('now','-1 days')");
41+
// User turn outside the window — must be excluded.
42+
seed(&c, "old", "user", "datetime('now','-10 days')");
43+
44+
let cells = activity_heatmap(&db, 7, None).unwrap();
45+
let total: i64 = cells.iter().map(|c| c.turns).sum();
46+
assert_eq!(total, 3, "only the 3 in-window user turns count");
47+
48+
for cell in &cells {
49+
assert!(
50+
(0..=6).contains(&cell.dow),
51+
"dow in 0..=6, got {}",
52+
cell.dow
53+
);
54+
assert!(
55+
(0..=23).contains(&cell.hour),
56+
"hour in 0..=23, got {}",
57+
cell.hour
58+
);
59+
assert!(cell.turns > 0, "no zero-count rows emitted");
60+
}
61+
}
62+
63+
#[test]
64+
fn empty_db_yields_no_cells() {
65+
let tmp = TempDir::new().unwrap();
66+
let db = tmp.path().join("td.db");
67+
init_db(&db).unwrap();
68+
let cells = activity_heatmap(&db, 7, None).unwrap();
69+
assert!(cells.is_empty());
70+
}

crates/token-dashboard-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "token-dashboard-tauri"
3-
version = "4.1.4"
3+
version = "4.1.5"
44
edition.workspace = true
55
license.workspace = true
66
repository.workspace = true

crates/token-dashboard-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Token Dashboard",
4-
"version": "4.1.4",
4+
"version": "4.1.5",
55
"identifier": "com.arylmera.token-dashboard",
66
"build": {
77
"frontendDist": "frontend-stub"

0 commit comments

Comments
 (0)