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<string, string> = {
'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<string>(['7d', '30d', '90d', 'all'])
export const Route = createFileRoute('/stats')({
validateSearch: (search: Record<string, unknown>): 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 (
<div className="range-btn-group">
{options.map(o => (
<button
key={o}
className={'range-btn' + (value === o ? ' active' : '')}
onClick={() => onChange(o)}
type="button"
>
{o}
</button>
))}
</div>
)
}
// ── 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 = (
<SubBar>
<span className="mono muted">stats</span>
<span style={{ flex: 1 }} />
<RangeButtonGroup value={range} onChange={handleRangeChange} />
</SubBar>
)
if (isLoading) {
return (
<>
{subBar}
<div className="body body-pad">
<Sub>loading…</Sub>
</div>
</>
)
}
if (error != null) {
if (error instanceof AuthError) {
return (
<div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
<div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
<div className="uppercase-mono" style={{ marginBottom: 8 }}>not authenticated</div>
<div className="muted">Sign in to view your stats.</div>
</div>
</div>
)
}
const detail = error instanceof APIError ? error.message : String(error)
return (
<div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
<div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
<div className="uppercase-mono" style={{ marginBottom: 8 }}>error</div>
<div className="muted">{detail}</div>
</div>
</div>
)
}
const s = stats!
// ── Per-tool rollup card ────────────────────────────────────────────────────
const perToolCard = (
<div className="full">
<div className="uppercase-mono" style={{ marginBottom: 6 }}>per tool · last {range}</div>
<div className="card" style={{ padding: 0 }}>
{s.perTool.length === 0 ? (
<EmptyState glyph="∅" copy="no tool activity in this range" />
) : (
s.perTool.map((r, i) => (
<div
key={r.tool}
className="per-tool-row"
style={{
gridTemplateColumns: '140px 70px 70px 1fr 60px',
background: i % 2 ? 'var(--paper-3)' : 'var(--paper-4)',
}}
>
<span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<ToolDot tool={r.tool} />
<span className="mono">{r.tool}</span>
</span>
<span className="right mono">{r.turns.toLocaleString()} turns</span>
<span className="right mono muted">
{Math.round((r.tokensIn + r.tokensOut) / 1000)}k tok
</span>
<span style={{ paddingLeft: 12 }}>
<Spark
points={r.dailySparkline.map(Number)}
w={100}
h={16}
accent={i === 0}
/>
</span>
<span className="right mono muted">{r.sessions} ses</span>
</div>
))
)}
</div>
</div>
)
// ── Daily stacked bars card ─────────────────────────────────────────────────
// Project daily DailyBucket[] into { tools: Record<string, number> }[] for StackedBars.
const dailyDays = s.daily.map(b => ({ tools: b.perTool }))
const dailyCard = (
<div className="card full">
<div className="card-head">
<span>turns/day · stacked by tool</span>
<span style={{ flex: 1 }} />
<span style={{ display: 'flex', gap: 10, fontSize: 10 }}>
{s.perTool.slice(0, 4).map(r => (
<span key={r.tool} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
className="tooldot"
style={{ background: toolColor(r.tool), width: 8, height: 8, borderRadius: '50%', display: 'inline-block' }}
/>
{r.tool}
</span>
))}
</span>
</div>
<div className="card-body">
{dailyDays.length === 0 ? (
<EmptyState glyph="∅" copy="no daily activity in this range" />
) : (
<>
<StackedBars days={dailyDays} toolColor={toolColor} h={130} />
<div
className="flex"
style={{ justifyContent: 'space-between', marginTop: 4, fontSize: 9.5 }}
>
<span className="mono muted">{dailyDays.length}d ago</span>
<span className="mono muted">today</span>
</div>
</>
)}
</div>
</div>
)
// ── Heatmap card ────────────────────────────────────────────────────────────
const heatmapMax = Math.max(...s.heatmap.map(c => c.count), 1)
const heatmapCard = (
<div className="card">
<div className="card-head">activity · 12 weeks</div>
<div className="card-body">
<Heatmap cells={s.heatmap} max={heatmapMax} />
<div className="flex muted mono" style={{ marginTop: 6, fontSize: 9.5, gap: 4, alignItems: 'center' }}>
<span>less</span>
{[0.15, 0.35, 0.55, 0.8, 1].map(o => (
<span
key={o}
style={{
width: 10,
height: 10,
background: 'var(--accent)',
opacity: o,
borderRadius: 1,
display: 'inline-block',
}}
/>
))}
<span>more</span>
</div>
</div>
</div>
)
// ── 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 = (
<div className="card">
<div className="card-head">top cwd</div>
<div className="card-body">
{s.topCwd.length === 0 ? (
<EmptyState glyph="∅" copy="no cwd data in this range" />
) : (
<HorizontalBars rows={topCwdRows} max={topCwdMax} />
)}
</div>
</div>
)
// ── Hour-of-day card ────────────────────────────────────────────────────────
const hourCard = (
<div className="card">
<div className="card-head">turns by hour</div>
<div className="card-body">
{s.hourOfDay.length === 0 ? (
<EmptyState glyph="∅" copy="no hour data in this range" />
) : (
<HourBars hours={s.hourOfDay} />
)}
</div>
</div>
)
// ── 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 = (
<div className="card">
<div className="card-head">by host</div>
<div className="card-body">
{s.hostSplit.length === 0 ? (
<EmptyState glyph="∅" copy="no host data in this range" />
) : (
<HorizontalBars rows={hostRows} max={hostMax} />
)}
</div>
</div>
)
return (
<>
{subBar}
<div className="body" style={{ overflow: 'auto' }}>
<div className="stats-grid">
{perToolCard}
{dailyCard}
{heatmapCard}
{topCwdCard}
{hourCard}
{hostCard}
</div>
</div>
</>
)
}