From 64906fe7d57c57b977a6cde05e45cbfcc52a1bf4 Mon Sep 17 00:00:00 2001 From: handlecusion Date: Fri, 29 May 2026 12:29:38 +0900 Subject: [PATCH 1/2] Add a 2D/3D toggle to the combined usage card Merge the Token Usage stats and the usage graph into one card that leads the dashboard, with a 2D/3D toggle (persisted in localStorage): - 2D keeps the 30d stacked-by-agent bar chart. - 3D restores the full-year contribution calendar (ContributionGraph3D), shown with the same token totals. The stats stay visible in both views. The 2D and 3D chart areas share one fixed height, and the 2D viewBox height matches its CSS height, so toggling never reflows the card and the bars are not distorted. Restore the near-flat BASE_HEIGHT so inactive days read as a packed floor (the original look) instead of separated cubes. --- src/App.tsx | 52 +++++++++++++--- src/components/ContributionGraph3D.tsx | 5 +- src/components/TokenUsageCard.tsx | 40 ++++++------ src/components/UsageBarGraph2D.tsx | 86 ++++++++++++++++++++++---- src/styles.css | 47 +++++++++++++- 5 files changed, 191 insertions(+), 39 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d943062..410fd82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Panel } from './components/Panel' import { HeaderBar } from './components/HeaderBar' -import { TokenUsageCard } from './components/TokenUsageCard' import { StreaksCard } from './components/StreaksCard' import { SettingsPanel } from './components/SettingsPanel' import { AgentLimitsCard } from './components/AgentLimitsCard' import { DashboardTabs } from './components/DashboardTabs' -import { UsageBarGraph2D } from './components/UsageBarGraph2D' +import { UsageBarGraph2D, UsageView } from './components/UsageBarGraph2D' +import { buildGrid } from './lib/grid' import { useGraphStream } from './hooks/useGraphStream' import { useAgentUsage } from './hooks/useAgentUsage' import { computeStats } from './lib/stats' @@ -19,6 +19,7 @@ import { getTheme, THEMES, ThemeName } from './lib/themes' import { getClientStyle } from './lib/clients' const THEME_KEY = 'tokcat:theme:v1' +const USAGE_VIEW_KEY = 'tokcat:usageview:v1' function loadTheme(): ThemeName { try { @@ -28,6 +29,14 @@ function loadTheme(): ThemeName { return 'Blue' } +function loadUsageView(): UsageView { + try { + const raw = localStorage.getItem(USAGE_VIEW_KEY) + if (raw === '2d' || raw === '3d') return raw + } catch {} + return '2d' +} + function defaultYear(): string { return String(new Date().getFullYear()) } @@ -44,6 +53,7 @@ export default function App() { : false ) const [activeTab, setActiveTab] = useState('overview') + const [usageView, setUsageView] = useState(() => loadUsageView()) const [settings, setSettings] = useState(() => loadSettings()) const [settingsOpen, setSettingsOpen] = useState(false) @@ -58,6 +68,10 @@ export default function App() { try { localStorage.setItem(THEME_KEY, theme) } catch {} }, [theme]) + useEffect(() => { + try { localStorage.setItem(USAGE_VIEW_KEY, usageView) } catch {} + }, [usageView]) + useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return const mql = window.matchMedia('(prefers-color-scheme: dark)') @@ -188,6 +202,18 @@ export default function App() { return computeStats(payload, activeClientSet) }, [activeClientSet, payload]) + // Calendar grids for the 3D usage view, one per visible card. Built from the + // same per-day token totals the stats already aggregate, so 3D and 2D show + // identical data for the selected year. + const overviewGrid = useMemo( + () => buildGrid(year, overviewStats?.perDayMap ?? new Map()), + [year, overviewStats], + ) + const activeGrid = useMemo( + () => buildGrid(year, activeStats?.perDayMap ?? new Map()), + [year, activeStats], + ) + // Live tokens-per-minute + per-(client, agent, model) breakdown, pushed // by the backend's JSONL tailer every ~5s. No client-side diffing — the // tailer parses only growth since the last poll, so values stay accurate @@ -319,14 +345,20 @@ export default function App() { {activeTab === 'overview' ? (
- - + -
diff --git a/src/components/ContributionGraph3D.tsx b/src/components/ContributionGraph3D.tsx index 11c5a37..47b54c3 100644 --- a/src/components/ContributionGraph3D.tsx +++ b/src/components/ContributionGraph3D.tsx @@ -16,7 +16,10 @@ interface Props { const CELL = 1 const GAP = 0.15 const STEP = CELL + GAP -const BASE_HEIGHT = 0.35 +// Near-flat base so inactive days read as a packed floor and only active days +// rise as bars (the original look). A taller base turns every tile into a +// separated cube, which reads as a scattered grid. +const BASE_HEIGHT = 0.05 const MAX_HEIGHT = 4.0 // Per-face shading: top is lighter than sides for a stylized 3D tile look. diff --git a/src/components/TokenUsageCard.tsx b/src/components/TokenUsageCard.tsx index f9502eb..bb41a88 100644 --- a/src/components/TokenUsageCard.tsx +++ b/src/components/TokenUsageCard.tsx @@ -2,28 +2,32 @@ import React from 'react' import type { Stats } from '../lib/types' import { formatCost, formatMMDD, formatMonthDay, humanizeTokens } from '../lib/format' -export function TokenUsageCard({ stats }: { stats: Stats }) { +export function TokenUsageCard({ stats, bare = false }: { stats: Stats; bare?: boolean }) { const range = `${formatMMDD(stats.dateRange.start)} → ${formatMMDD(stats.dateRange.end)}` + const grid = ( +
+
+
{formatCost(stats.totalCost)}
+
Total
+
{range}
+
+
+
{humanizeTokens(stats.totalTokens)}
+
Tokens
+
{stats.activeDays} active days
+
+
+
{stats.bestDay ? formatCost(stats.bestDay.cost) : '$0.00'}
+
Best day
+
{stats.bestDay ? formatMonthDay(stats.bestDay.date) : '—'}
+
+
+ ) + if (bare) return grid return (

Token Usage

-
-
-
{formatCost(stats.totalCost)}
-
Total
-
{range}
-
-
-
{humanizeTokens(stats.totalTokens)}
-
Tokens
-
{stats.activeDays} active days
-
-
-
{stats.bestDay ? formatCost(stats.bestDay.cost) : '$0.00'}
-
Best day
-
{stats.bestDay ? formatMonthDay(stats.bestDay.date) : '—'}
-
-
+ {grid}
) } diff --git a/src/components/UsageBarGraph2D.tsx b/src/components/UsageBarGraph2D.tsx index 291fe67..f72e89a 100644 --- a/src/components/UsageBarGraph2D.tsx +++ b/src/components/UsageBarGraph2D.tsx @@ -1,13 +1,26 @@ import React, { useMemo, useState } from 'react' import { getClientStyle } from '../lib/clients' import { addDays, formatCost, formatMonthDay, isoDate, parseISODate } from '../lib/format' -import type { Contribution, TokenBreakdown, UsagePayload } from '../lib/types' +import type { Contribution, Stats, TokenBreakdown, UsagePayload } from '../lib/types' +import type { GridLayout } from '../lib/grid' +import { ContributionGraph3D } from './ContributionGraph3D' +import { TokenUsageCard } from './TokenUsageCard' + +export type UsageView = '2d' | '3d' interface Props { payload: UsagePayload clientIds: string[] title: string subtitle?: string + view: UsageView + onViewChange: (view: UsageView) => void + grid: GridLayout + graphLight: string + graphDark: string + accent: string + /** When provided, the card leads with these token-usage totals (shown in both 2D and 3D). */ + stats?: Stats } interface Segment { @@ -70,8 +83,21 @@ function exactTokens(tokens: number): string { return tokens.toLocaleString('en-US') } -export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props) { +export function UsageBarGraph2D({ + payload, + clientIds, + title, + subtitle, + view, + onViewChange, + grid, + graphLight, + graphDark, + accent, + stats, +}: Props) { const [hover, setHover] = useState(null) + const headSubtitle = stats && view === '3d' ? 'Full year' : subtitle const bars = useMemo(() => { const allowed = new Set(clientIds) const byDate = new Map() @@ -94,7 +120,10 @@ export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props) const maxTokens = Math.max(1, ...bars.map(b => b.totalTokens)) const width = 520 - const height = 164 + // viewBox height matches the rendered CSS height (.bar2d-svg) so there is no + // vertical distortion, and equals the 3D canvas height so toggling 2D/3D + // never changes the card height. + const height = 240 const top = 14 const bottom = 24 const chartHeight = height - top - bottom @@ -127,18 +156,52 @@ export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props)

{title}

- {subtitle &&
{subtitle}
} + {headSubtitle &&
{headSubtitle}
}
-
- {activeClients.slice(0, 5).map(style => ( - - - {style.displayName.replace(/\s+(CLI|Code|IDE)$/i, '')} - - ))} +
+
+ + +
+
+ {activeClients.slice(0, 5).map(style => ( + + + {style.displayName.replace(/\s+(CLI|Code|IDE)$/i, '')} + + ))} +
+ {stats && ( +
+ +
+ )} + + {view === '3d' ? ( + + ) : (
setHover(null)}> @@ -226,6 +289,7 @@ export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props)
)}
+ )} ) } diff --git a/src/styles.css b/src/styles.css index 92b1278..bb37899 100644 --- a/src/styles.css +++ b/src/styles.css @@ -466,6 +466,16 @@ html, body, #root { backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); } +/* Bare: no card chrome — used inside the combined Token-Usage + graph card. */ +.usage-row-card.is-bare { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} .usage-cell { min-width: 0; } @@ -673,12 +683,45 @@ html, body, #root { height: 7px; border-radius: 999px; } +.bar2d-head-right { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} +.bar2d-viewtoggle { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 0.5px solid var(--control-border); + background: var(--control-bg); + border-radius: 7px; +} +.bar2d-viewbtn { + border: 0; + background: transparent; + color: var(--text-muted); + font-size: 10px; + font-weight: 650; + line-height: 1.4; + padding: 2px 9px; + border-radius: 5px; + cursor: pointer; +} +.bar2d-viewbtn.is-active { + background: var(--blue); + color: #fff; +} +.bar2d-stats { + margin: 2px 0 12px; +} .bar2d-chart { position: relative; } .bar2d-svg { width: 100%; - height: 170px; + height: 240px; display: block; overflow: visible; } @@ -827,7 +870,7 @@ html, body, #root { } .graph-3d-wrap { width: 100%; - height: 380px; + height: 240px; position: relative; } .graph-3d-wrap > div:first-child { From 876ec48d949cb758755ed72f50271ce8df837230 Mon Sep 17 00:00:00 2001 From: handlecusion Date: Fri, 29 May 2026 12:29:42 +0900 Subject: [PATCH 2/2] Release Tokcat 0.1.25 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9ee2db0..9beabb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tokcat", - "version": "0.1.24", + "version": "0.1.25", "private": true, "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bedd054..b0494c5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4289,7 +4289,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokcat" -version = "0.1.24" +version = "0.1.25" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 06b03d5..2be958a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tokcat" -version = "0.1.24" +version = "0.1.25" edition = "2021" description = "Native macOS menubar dashboard for local AI token usage" authors = ["handlecusion"] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3bddd86..e303774 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Tokcat", - "version": "0.1.24", + "version": "0.1.25", "identifier": "com.handlecusion.tokcat", "build": { "beforeDevCommand": "npm run dev:vite",