~bigbes/lethe

e048bdf74cd7459076a067ae22038a83e4627b6a — Eugene Blikh a month ago 346e6a8
web: stats route with backend-driven chart primitives
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);
}