From e048bdf74cd7459076a067ae22038a83e4627b6a Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 08:46:59 +0300 Subject: [PATCH] web: stats route with backend-driven chart primitives --- web/src/api/adapters.test.ts | 74 ++++++- web/src/api/adapters.ts | 112 ++++++++++ web/src/features/stats/useStats.ts | 17 ++ web/src/primitives/Heatmap.tsx | 50 +++++ web/src/primitives/HorizontalBars.tsx | 63 ++++++ web/src/primitives/HourBars.tsx | 51 +++++ web/src/primitives/StackedBars.tsx | 72 ++++++ web/src/primitives/index.ts | 4 + web/src/routes/stats.tsx | 301 +++++++++++++++++++++++++- web/src/styles/stats.css | 80 +++++++ 10 files changed, 815 insertions(+), 9 deletions(-) create mode 100644 web/src/features/stats/useStats.ts create mode 100644 web/src/primitives/Heatmap.tsx create mode 100644 web/src/primitives/HorizontalBars.tsx create mode 100644 web/src/primitives/HourBars.tsx create mode 100644 web/src/primitives/StackedBars.tsx create mode 100644 web/src/styles/stats.css diff --git a/web/src/api/adapters.test.ts b/web/src/api/adapters.test.ts index a7d7b9f2716861eb33b7e681ea20144c1abcaa13..3a9dc24f63804dd32a919256ff4f47075cd2aa0d 100644 --- a/web/src/api/adapters.test.ts +++ b/web/src/api/adapters.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { adaptSession, adaptProject } from './adapters' -import type { SessionDTO, ProjectDTO } from './adapters' +import { adaptSession, adaptProject, adaptStats } from './adapters' +import type { SessionDTO, ProjectDTO, StatsDTO } from './adapters' function makeDTO(overrides: Partial = {}): SessionDTO { return { @@ -108,3 +108,73 @@ describe('adaptProject', () => { expect(p.tools).toEqual(['claude-code', 'opencode']) }) }) + +// ── adaptStats ─────────────────────────────────────────────────────────────── + +function makeStatsDTO(overrides: Partial = {}): StatsDTO { + return { + per_tool: [], + daily: [], + heatmap: [], + top_cwd: [], + hour_of_day: [], + host_split: [], + ...overrides, + } +} + +describe('adaptStats', () => { + it('empty arrays are preserved (no fabrication)', () => { + const s = adaptStats(makeStatsDTO()) + expect(s.perTool).toEqual([]) + expect(s.daily).toEqual([]) + expect(s.heatmap).toEqual([]) + expect(s.topCwd).toEqual([]) + expect(s.hourOfDay).toEqual([]) + expect(s.hostSplit).toEqual([]) + }) + + it('date_unix=1700000000 → ISO "2023-11-14T22:13:20.000Z" in heatmap', () => { + const s = adaptStats(makeStatsDTO({ + heatmap: [{ date_unix: 1700000000, count: 5 }], + })) + expect(s.heatmap[0].date).toBe('2023-11-14T22:13:20.000Z') + expect(s.heatmap[0].count).toBe(5) + }) + + it('date_unix=1700000000 → ISO in daily', () => { + const s = adaptStats(makeStatsDTO({ + daily: [{ date_unix: 1700000000, per_tool: { 'claude-code': 3 } }], + })) + expect(s.daily[0].date).toBe('2023-11-14T22:13:20.000Z') + expect(s.daily[0].perTool).toEqual({ 'claude-code': 3 }) + }) + + it('top-level snake_case → camelCase conversion', () => { + const s = adaptStats(makeStatsDTO({ + per_tool: [{ tool: 'claude-code', sessions: 2, turns: 10, tokens_in: 100, tokens_out: 200, daily_sparkline: [1, 2, 3] }], + top_cwd: [{ cwd: '/home/user/code', count: 7 }], + hour_of_day: [{ hour: 14, count: 42 }], + host_split: [{ host: 'laptop', count: 100 }], + })) + expect(s.perTool[0].tool).toBe('claude-code') + expect(s.topCwd[0].cwd).toBe('/home/user/code') + expect(s.hourOfDay[0].hour).toBe(14) + expect(s.hostSplit[0].host).toBe('laptop') + }) + + it('ToolRollup daily_sparkline passes through unchanged as dailySparkline', () => { + const sparkline = [0, 5, 10, 3, 7] + const s = adaptStats(makeStatsDTO({ + per_tool: [{ + tool: 'claude-code', + sessions: 1, + turns: 5, + tokens_in: 10, + tokens_out: 20, + daily_sparkline: sparkline, + }], + })) + expect(s.perTool[0].dailySparkline).toEqual(sparkline) + }) +}) diff --git a/web/src/api/adapters.ts b/web/src/api/adapters.ts index 1fc1368f7a8bfd47aa589be2041496215ed1b2b3..e593981fef10cae5a4cdd88676477259f649210c 100644 --- a/web/src/api/adapters.ts +++ b/web/src/api/adapters.ts @@ -89,3 +89,115 @@ export function adaptProject(d: ProjectDTO): Project { topTool: d.top_tool, } } + +// ── Stats ───────────────────────────────────────────────────────────────────── + +export interface ToolRollupDTO { + tool: string + sessions: number + turns: number + tokens_in: number + tokens_out: number + daily_sparkline: number[] +} + +export interface DailyBucketDTO { + date_unix: number + per_tool: Record +} + +export interface HeatmapCellDTO { + date_unix: number + count: number +} + +export interface CwdRowDTO { + cwd: string + count: number +} + +export interface HourBucketDTO { + hour: number + count: number +} + +export interface HostRowDTO { + host: string + count: number +} + +export interface StatsDTO { + per_tool: ToolRollupDTO[] + daily: DailyBucketDTO[] + heatmap: HeatmapCellDTO[] + top_cwd: CwdRowDTO[] + hour_of_day: HourBucketDTO[] + host_split: HostRowDTO[] +} + +export interface ToolRollup { + tool: string + sessions: number + turns: number + tokensIn: number + tokensOut: number + dailySparkline: number[] +} + +export interface DailyBucket { + date: string // ISO 8601 + perTool: Record +} + +export interface HeatmapCell { + date: string // ISO 8601 + count: number +} + +export interface CwdRow { + cwd: string + count: number +} + +export interface HourBucket { + hour: number + count: number +} + +export interface HostRow { + host: string + count: number +} + +export interface Stats { + perTool: ToolRollup[] + daily: DailyBucket[] + heatmap: HeatmapCell[] + topCwd: CwdRow[] + hourOfDay: HourBucket[] + hostSplit: HostRow[] +} + +export function adaptStats(d: StatsDTO): Stats { + return { + perTool: d.per_tool.map(r => ({ + tool: r.tool, + sessions: r.sessions, + turns: r.turns, + tokensIn: r.tokens_in, + tokensOut: r.tokens_out, + dailySparkline: r.daily_sparkline, + })), + daily: d.daily.map(b => ({ + date: new Date(b.date_unix * 1000).toISOString(), + perTool: b.per_tool, + })), + heatmap: d.heatmap.map(c => ({ + date: new Date(c.date_unix * 1000).toISOString(), + count: c.count, + })), + topCwd: d.top_cwd, + hourOfDay: d.hour_of_day, + hostSplit: d.host_split, + } +} diff --git a/web/src/features/stats/useStats.ts b/web/src/features/stats/useStats.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecd2209d344abd99805a0f87c5fcef08e19b6f0c --- /dev/null +++ b/web/src/features/stats/useStats.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import type { UseQueryResult } from '@tanstack/react-query' +import { apiFetch } from '../../api/client' +import { adaptStats } from '../../api/adapters' +import type { Stats, StatsDTO } from '../../api/adapters' + +export function useStats(range: '7d' | '30d' | '90d' | 'all'): UseQueryResult { + return useQuery({ + queryKey: ['stats', range], + queryFn: async () => { + const params = new URLSearchParams() + params.set('range', range) + const data = await apiFetch(`/api/v1/stats?${params.toString()}`) + return adaptStats(data) + }, + }) +} diff --git a/web/src/primitives/Heatmap.tsx b/web/src/primitives/Heatmap.tsx new file mode 100644 index 0000000000000000000000000000000000000000..712998a5bd7e58263ddc7c47ed031506410aa875 --- /dev/null +++ b/web/src/primitives/Heatmap.tsx @@ -0,0 +1,50 @@ +import type React from 'react' +import { EmptyState } from './EmptyState' + +interface HeatmapProps { + cells: { count: number }[] + max: number +} + +// 12 weeks × 7 days = 84 cells. Layout: columns = weeks (left→right), rows = days (top→bottom). +const WEEKS = 12 +const DAYS = 7 +const CELL_W = 18 +const CELL_H = 9 +const GAP_X = 2 +const GAP_Y = 2 + +export function Heatmap({ cells, max }: HeatmapProps): React.JSX.Element { + if (cells.length !== 84) { + return + } + + const svgW = WEEKS * (CELL_W + GAP_X) + const svgH = DAYS * (CELL_H + GAP_Y) + const safeMax = max > 0 ? max : 1 + + return ( + + {cells.map((cell, i) => { + const week = Math.floor(i / DAYS) + const day = i % DAYS + const intensity = 0.1 + (cell.count / safeMax) * 0.85 + return ( + + ) + })} + + ) +} diff --git a/web/src/primitives/HorizontalBars.tsx b/web/src/primitives/HorizontalBars.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4cb0c24ae4f5fea4fce5117584365d53877cdb72 --- /dev/null +++ b/web/src/primitives/HorizontalBars.tsx @@ -0,0 +1,63 @@ +import type React from 'react' +import { Link } from '@tanstack/react-router' + +interface HorizontalBarsRow { + label: string + count: number + href?: string +} + +interface HorizontalBarsProps { + rows: HorizontalBarsRow[] + max: number +} + +export function HorizontalBars({ rows, max }: HorizontalBarsProps): React.JSX.Element { + const safeMax = max > 0 ? max : 1 + + return ( +
+ {rows.map((row, i) => { + const ratio = row.count / safeMax + const label = row.href != null ? ( + + {row.label} + + ) : ( + {row.label} + ) + + return ( +
+ {label} + +
+ + {row.count} +
+ ) + })} +
+ ) +} diff --git a/web/src/primitives/HourBars.tsx b/web/src/primitives/HourBars.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d10210ed2c1c10539f1f6be18074d6ee236d6fe9 --- /dev/null +++ b/web/src/primitives/HourBars.tsx @@ -0,0 +1,51 @@ +import type React from 'react' +import { EmptyState } from './EmptyState' + +interface HourBarsProps { + hours: { hour: number; count: number }[] +} + +export function HourBars({ hours }: HourBarsProps): React.JSX.Element { + if (hours.length !== 24) { + return + } + + const maxCount = Math.max(...hours.map(h => h.count), 1) + + return ( + <> + + {hours.map(({ hour, count }) => { + const barH = Math.max(2, (count / maxCount) * 68) + const x = hour * 10 + 1 + const isOffPeak = hour < 9 || hour > 19 + return ( + + ) + })} + +
+ 00 + 06 + 12 + 18 + 24 +
+ + ) +} diff --git a/web/src/primitives/StackedBars.tsx b/web/src/primitives/StackedBars.tsx new file mode 100644 index 0000000000000000000000000000000000000000..abc219cdb1d3cc7469d5a3a3baeaf35538a406fa --- /dev/null +++ b/web/src/primitives/StackedBars.tsx @@ -0,0 +1,72 @@ +import type React from 'react' + +interface StackedBarsProps { + days: { tools: Record }[] + toolColor: (tool: string) => string + w?: number + h?: number +} + +export function StackedBars({ days, toolColor, w = 600, h = 130 }: StackedBarsProps): React.JSX.Element { + const n = days.length + if (n === 0) { + return ( + + ) + } + + // Find the maximum total across all days for scaling. + const totals = days.map(d => Object.values(d.tools).reduce((a, b) => a + b, 0)) + const maxTotal = Math.max(...totals, 1) + + const barW = Math.max(1, (w / n) - 2) + + return ( + + {/* Y axis grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map(frac => { + const y = h - frac * h + return ( + + ) + })} + {days.map((day, i) => { + const x = i * (w / n) + 1 + const tools = Object.entries(day.tools).sort((a, b) => a[0].localeCompare(b[0])) + let yBottom = h + return ( + + {tools.map(([tool, count]) => { + if (count <= 0) return null + const barH = (count / maxTotal) * h + yBottom -= barH + return ( + + ) + })} + + ) + })} + + ) +} diff --git a/web/src/primitives/index.ts b/web/src/primitives/index.ts index eb1daad1971c0f0d464614ab6ac12f410f240e24..7a13b437bd00a9376b737adec35f3a0f3789fbdd 100644 --- a/web/src/primitives/index.ts +++ b/web/src/primitives/index.ts @@ -4,3 +4,7 @@ export { Spark } from './Spark' export { StatusDot } from './StatusDot' export { EmptyState } from './EmptyState' export { Sub } from './Sub' +export { StackedBars } from './StackedBars' +export { Heatmap } from './Heatmap' +export { HorizontalBars } from './HorizontalBars' +export { HourBars } from './HourBars' diff --git a/web/src/routes/stats.tsx b/web/src/routes/stats.tsx index 348b4ce3c79a6f25dbc1b01b7f9cc0be0c502b00..487bdbb0a2e8f4cb30b710b2a3a8adde9b9892fb 100644 --- a/web/src/routes/stats.tsx +++ b/web/src/routes/stats.tsx @@ -1,20 +1,307 @@ -import { createFileRoute } from '@tanstack/react-router' import React from 'react' -import { EmptyState, Tag } from '../primitives' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { SubBar } from '../shell/SubBar' +import { Sub, EmptyState, Spark, ToolDot, StackedBars, Heatmap, HorizontalBars, HourBars } from '../primitives' +import { useStats } from '../features/stats/useStats' +import { AuthError, APIError } from '../api/client' +import '../styles/stats.css' + +// ── Color palette ────────────────────────────────────────────────────────────── + +const TOOL_COLORS: Record = { + 'claude-code': '#c96442', + opencode: '#3b6e3b', + crush: '#7a4ea8', + pi: '#b8902a', + kimi: '#2a6e9c', +} + +// Stable fallback palette for unknown tool names. +const FALLBACK_COLORS = [ + '#4e7a6b', '#7a4e6b', '#6b7a4e', '#4e6b7a', '#7a6b4e', '#6b4e7a', '#4e7a6b', +] + +function toolColor(tool: string): string { + if (TOOL_COLORS[tool]) return TOOL_COLORS[tool] + // Simple hash mod palette for unknown tools. + let hash = 0 + for (let i = 0; i < tool.length; i++) { + hash = ((hash << 5) - hash + tool.charCodeAt(i)) | 0 + } + return FALLBACK_COLORS[Math.abs(hash) % FALLBACK_COLORS.length] +} + +// ── Route ────────────────────────────────────────────────────────────────────── + +type StatsSearch = { + range: '7d' | '30d' | '90d' | 'all' +} + +const VALID_RANGE = new Set(['7d', '30d', '90d', 'all']) export const Route = createFileRoute('/stats')({ + validateSearch: (search: Record): StatsSearch => { + const range = + typeof search['range'] === 'string' && VALID_RANGE.has(search['range']) + ? (search['range'] as StatsSearch['range']) + : '30d' + return { range } + }, component: StatsRoute, }) +// ── Range button group ───────────────────────────────────────────────────────── + +function RangeButtonGroup({ + value, + onChange, +}: { + value: StatsSearch['range'] + onChange: (v: StatsSearch['range']) => void +}): React.JSX.Element { + const options = ['7d', '30d', '90d', 'all'] as const + return ( +
+ {options.map(o => ( + + ))} +
+ ) +} + +// ── Main component ───────────────────────────────────────────────────────────── + function StatsRoute(): React.JSX.Element { + const navigate = useNavigate() + const search = Route.useSearch() + const range = search.range + + const { data: stats, isLoading, error } = useStats(range) + + function handleRangeChange(next: StatsSearch['range']) { + void navigate({ to: '/stats', search: { range: next } }) + } + + const subBar = ( + + stats + + + + ) + + if (isLoading) { + return ( + <> + {subBar} +
+ loading… +
+ + ) + } + + if (error != null) { + if (error instanceof AuthError) { + return ( +
+
+
not authenticated
+
Sign in to view your stats.
+
+
+ ) + } + const detail = error instanceof APIError ? error.message : String(error) + return ( +
+
+
error
+
{detail}
+
+
+ ) + } + + const s = stats! + + // ── Per-tool rollup card ──────────────────────────────────────────────────── + const perToolCard = ( +
+
per tool · last {range}
+
+ {s.perTool.length === 0 ? ( + + ) : ( + s.perTool.map((r, i) => ( +
+ + + {r.tool} + + {r.turns.toLocaleString()} turns + + {Math.round((r.tokensIn + r.tokensOut) / 1000)}k tok + + + + + {r.sessions} ses +
+ )) + )} +
+
+ ) + + // ── Daily stacked bars card ───────────────────────────────────────────────── + // Project daily DailyBucket[] into { tools: Record }[] for StackedBars. + const dailyDays = s.daily.map(b => ({ tools: b.perTool })) + const dailyCard = ( +
+
+ turns/day · stacked by tool + + + {s.perTool.slice(0, 4).map(r => ( + + + {r.tool} + + ))} + +
+
+ {dailyDays.length === 0 ? ( + + ) : ( + <> + +
+ {dailyDays.length}d ago + today +
+ + )} +
+
+ ) + + // ── Heatmap card ──────────────────────────────────────────────────────────── + const heatmapMax = Math.max(...s.heatmap.map(c => c.count), 1) + const heatmapCard = ( +
+
activity · 12 weeks
+
+ +
+ less + {[0.15, 0.35, 0.55, 0.8, 1].map(o => ( + + ))} + more +
+
+
+ ) + + // ── Top cwd card ──────────────────────────────────────────────────────────── + const topCwdMax = s.topCwd.length > 0 ? Math.max(...s.topCwd.map(r => r.count)) : 1 + const topCwdRows = s.topCwd.map(r => ({ + label: r.cwd, + count: r.count, + href: `/project/${encodeURIComponent(r.cwd)}`, + })) + const topCwdCard = ( +
+
top cwd
+
+ {s.topCwd.length === 0 ? ( + + ) : ( + + )} +
+
+ ) + + // ── Hour-of-day card ──────────────────────────────────────────────────────── + const hourCard = ( +
+
turns by hour
+
+ {s.hourOfDay.length === 0 ? ( + + ) : ( + + )} +
+
+ ) + + // ── Host split card ───────────────────────────────────────────────────────── + const hostMax = s.hostSplit.length > 0 ? Math.max(...s.hostSplit.map(r => r.count)) : 1 + const hostRows = s.hostSplit.map(r => ({ label: r.host, count: r.count })) + const hostCard = ( +
+
by host
+
+ {s.hostSplit.length === 0 ? ( + + ) : ( + + )} +
+
+ ) + return ( <> - - stats - -
- + {subBar} +
+
+ {perToolCard} + {dailyCard} + {heatmapCard} + {topCwdCard} + {hourCard} + {hostCard} +
) diff --git a/web/src/styles/stats.css b/web/src/styles/stats.css new file mode 100644 index 0000000000000000000000000000000000000000..a0925c47c47bb67dc2659e7e19492d2cac7d44bf --- /dev/null +++ b/web/src/styles/stats.css @@ -0,0 +1,80 @@ +/* Stats route — card grid */ + +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 14px; + overflow: auto; +} + +.stats-grid .full { + grid-column: 1 / -1; +} + +/* Card base (mirrors prototype.css) */ +.card { + border: 1px solid var(--rule-2); + border-radius: 4px; + background: var(--paper-4); +} + +.card .card-head { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + padding: 8px 10px 6px; + border-bottom: 1px solid var(--rule-2); + display: flex; + align-items: center; + gap: 8px; +} + +.card .card-body { + padding: 10px; +} + +/* Per-tool rollup rows */ +.per-tool-row { + display: grid; + gap: 10px; + align-items: center; + padding: 6px 12px; + border-bottom: 1px solid var(--rule-2); + font-size: 11.5px; +} + +.per-tool-row:last-child { + border-bottom: none; +} + +/* Range button group — reuse the same .since-btn-group / .since-btn pattern */ +.range-btn-group { + display: flex; + gap: 2px; +} + +.range-btn { + padding: 2px 8px; + font-size: 10.5px; + font-family: var(--mono); + border: 1px solid var(--rule); + background: transparent; + color: var(--ink-3); + cursor: pointer; + border-radius: 3px; + line-height: 1.6; +} + +.range-btn:hover { + background: var(--paper-2); + color: var(--ink); +} + +.range-btn.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-ink); +}