import React from 'react' 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 ( <> {subBar}
{perToolCard} {dailyCard} {heatmapCard} {topCwdCard} {hourCard} {hostCard}
) }