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", 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 {