M web/src/api/adapters.test.ts => web/src/api/adapters.test.ts +72 -2
@@ 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> = {}): SessionDTO {
return {
@@ 108,3 108,73 @@ describe('adaptProject', () => {
expect(p.tools).toEqual(['claude-code', 'opencode'])
})
})
+
+// ── adaptStats ───────────────────────────────────────────────────────────────
+
+function makeStatsDTO(overrides: Partial<StatsDTO> = {}): 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)
+ })
+})
M web/src/api/adapters.ts => web/src/api/adapters.ts +112 -0
@@ 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<string, number>
+}
+
+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<string, number>
+}
+
+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,
+ }
+}
A web/src/features/stats/useStats.ts => web/src/features/stats/useStats.ts +17 -0
@@ 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<Stats> {
+ return useQuery({
+ queryKey: ['stats', range],
+ queryFn: async () => {
+ const params = new URLSearchParams()
+ params.set('range', range)
+ const data = await apiFetch<StatsDTO>(`/api/v1/stats?${params.toString()}`)
+ return adaptStats(data)
+ },
+ })
+}
A web/src/primitives/Heatmap.tsx => web/src/primitives/Heatmap.tsx +50 -0
@@ 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 <EmptyState glyph="∅" copy="no activity yet" />
+ }
+
+ const svgW = WEEKS * (CELL_W + GAP_X)
+ const svgH = DAYS * (CELL_H + GAP_Y)
+ const safeMax = max > 0 ? max : 1
+
+ return (
+ <svg
+ viewBox={`0 0 ${svgW} ${svgH}`}
+ style={{ display: 'block', width: '100%', height: svgH }}
+ >
+ {cells.map((cell, i) => {
+ const week = Math.floor(i / DAYS)
+ const day = i % DAYS
+ const intensity = 0.1 + (cell.count / safeMax) * 0.85
+ return (
+ <rect
+ key={i}
+ x={week * (CELL_W + GAP_X)}
+ y={day * (CELL_H + GAP_Y)}
+ width={CELL_W}
+ height={CELL_H}
+ fill="var(--accent)"
+ opacity={intensity}
+ rx={1.5}
+ />
+ )
+ })}
+ </svg>
+ )
+}
A web/src/primitives/HorizontalBars.tsx => web/src/primitives/HorizontalBars.tsx +63 -0
@@ 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 (
+ <div>
+ {rows.map((row, i) => {
+ const ratio = row.count / safeMax
+ const label = row.href != null ? (
+ <Link
+ to={row.href}
+ className="mono truncate"
+ style={{ fontSize: 11, color: 'var(--accent-ink)', textDecoration: 'none' }}
+ >
+ {row.label}
+ </Link>
+ ) : (
+ <span className="mono truncate" style={{ fontSize: 11 }}>{row.label}</span>
+ )
+
+ return (
+ <div
+ key={i}
+ style={{
+ display: 'grid',
+ gridTemplateColumns: '1fr 90px 30px',
+ gap: 8,
+ padding: '4px 0',
+ borderBottom: i < rows.length - 1 ? '1px dotted var(--rule-2)' : 'none',
+ alignItems: 'center',
+ }}
+ >
+ {label}
+ <span style={{ height: 5, background: 'var(--paper-2)', borderRadius: 2 }}>
+ <div
+ style={{
+ width: `${ratio * 100}%`,
+ height: '100%',
+ background: 'var(--accent)',
+ borderRadius: 2,
+ }}
+ />
+ </span>
+ <span className="right mono muted" style={{ fontSize: 11 }}>{row.count}</span>
+ </div>
+ )
+ })}
+ </div>
+ )
+}
A web/src/primitives/HourBars.tsx => web/src/primitives/HourBars.tsx +51 -0
@@ 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 <EmptyState glyph="∅" copy="no activity yet" />
+ }
+
+ const maxCount = Math.max(...hours.map(h => h.count), 1)
+
+ return (
+ <>
+ <svg
+ viewBox="0 0 240 70"
+ style={{ display: 'block', width: '100%', height: 70 }}
+ preserveAspectRatio="none"
+ >
+ {hours.map(({ hour, count }) => {
+ const barH = Math.max(2, (count / maxCount) * 68)
+ const x = hour * 10 + 1
+ const isOffPeak = hour < 9 || hour > 19
+ return (
+ <rect
+ key={hour}
+ x={x}
+ y={70 - barH}
+ width={8}
+ height={barH}
+ fill="var(--accent)"
+ opacity={isOffPeak ? 0.45 : 0.85}
+ />
+ )
+ })}
+ </svg>
+ <div
+ className="flex muted mono"
+ style={{ justifyContent: 'space-between', fontSize: 9.5, marginTop: 4 }}
+ >
+ <span>00</span>
+ <span>06</span>
+ <span>12</span>
+ <span>18</span>
+ <span>24</span>
+ </div>
+ </>
+ )
+}
A web/src/primitives/StackedBars.tsx => web/src/primitives/StackedBars.tsx +72 -0
@@ 0,0 1,72 @@
+import type React from 'react'
+
+interface StackedBarsProps {
+ days: { tools: Record<string, number> }[]
+ 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 (
+ <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block', width: '100%' }} />
+ )
+ }
+
+ // 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 (
+ <svg
+ viewBox={`0 0 ${w} ${h}`}
+ style={{ display: 'block', width: '100%', height: h }}
+ preserveAspectRatio="none"
+ >
+ {/* Y axis grid lines */}
+ {[0, 0.25, 0.5, 0.75, 1].map(frac => {
+ const y = h - frac * h
+ return (
+ <line
+ key={frac}
+ x1={0}
+ x2={w}
+ y1={y}
+ y2={y}
+ stroke="var(--rule-2)"
+ strokeWidth={0.5}
+ />
+ )
+ })}
+ {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 (
+ <g key={i}>
+ {tools.map(([tool, count]) => {
+ if (count <= 0) return null
+ const barH = (count / maxTotal) * h
+ yBottom -= barH
+ return (
+ <rect
+ key={tool}
+ x={x}
+ y={yBottom}
+ width={barW}
+ height={barH}
+ fill={toolColor(tool)}
+ opacity={0.85}
+ />
+ )
+ })}
+ </g>
+ )
+ })}
+ </svg>
+ )
+}
M web/src/primitives/index.ts => web/src/primitives/index.ts +4 -0
@@ 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'
M web/src/routes/stats.tsx => web/src/routes/stats.tsx +294 -7
@@ 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<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>
- <Tag kind="neutral">stats</Tag>
- </SubBar>
- <div className="body body-pad">
- <EmptyState glyph="∅" copy="coming in a later task" />
+ {subBar}
+ <div className="body" style={{ overflow: 'auto' }}>
+ <div className="stats-grid">
+ {perToolCard}
+ {dailyCard}
+ {heatmapCard}
+ {topCwdCard}
+ {hourCard}
+ {hostCard}
+ </div>
</div>
</>
)
A web/src/styles/stats.css => web/src/styles/stats.css +80 -0
@@ 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);
+}