@dropalltables/yacu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/yacu.js +1782 -0
  4. package/package.json +62 -0
  5. package/src/data/jsonl.ts +81 -0
  6. package/src/data/load.ts +52 -0
  7. package/src/data/pricing.test.ts +22 -0
  8. package/src/data/pricing.ts +49 -0
  9. package/src/data/sources/claude.test.ts +40 -0
  10. package/src/data/sources/claude.ts +118 -0
  11. package/src/data/sources/codex.ts +73 -0
  12. package/src/data/sources/cursor.test.ts +16 -0
  13. package/src/data/sources/cursor.ts +83 -0
  14. package/src/data/sources/gemini.test.ts +27 -0
  15. package/src/data/sources/gemini.ts +112 -0
  16. package/src/data/sources/grok.ts +60 -0
  17. package/src/data/sources/opencode.ts +53 -0
  18. package/src/data/types.ts +23 -0
  19. package/src/domain/aggregate.test.ts +62 -0
  20. package/src/domain/aggregate.ts +143 -0
  21. package/src/domain/dates.ts +24 -0
  22. package/src/domain/types.ts +48 -0
  23. package/src/index.tsx +15 -0
  24. package/src/tui/App.tsx +170 -0
  25. package/src/tui/ThemeContext.tsx +12 -0
  26. package/src/tui/chart.test.ts +48 -0
  27. package/src/tui/chart.ts +130 -0
  28. package/src/tui/components/Breakdown.tsx +85 -0
  29. package/src/tui/components/Chart.test.tsx +67 -0
  30. package/src/tui/components/Chart.tsx +159 -0
  31. package/src/tui/components/Footer.tsx +11 -0
  32. package/src/tui/components/Header.tsx +52 -0
  33. package/src/tui/components/PointerButton.tsx +40 -0
  34. package/src/tui/components/ScanBoot.test.tsx +30 -0
  35. package/src/tui/components/ScanBoot.tsx +30 -0
  36. package/src/tui/components/Segmented.test.tsx +32 -0
  37. package/src/tui/components/Segmented.tsx +59 -0
  38. package/src/tui/components/Summary.tsx +67 -0
  39. package/src/tui/components/Totals.tsx +27 -0
  40. package/src/tui/format.ts +44 -0
  41. package/src/tui/theme.ts +99 -0
  42. package/src/tui/usePointer.ts +11 -0
  43. package/tsconfig.json +15 -0
@@ -0,0 +1,170 @@
1
+ import { useEffect, useMemo, useState } from "react"
2
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
3
+ import { buildDashboard } from "../domain/aggregate"
4
+ import { SOURCE_ORDER, type BreakdownMode, type Metric, type RangeDays, type SourceId, type UsageDataset } from "../domain/types"
5
+ import { loadUsageDataset } from "../data/load"
6
+ import { Header } from "./components/Header"
7
+ import { Summary } from "./components/Summary"
8
+ import { Chart } from "./components/Chart"
9
+ import { Totals } from "./components/Totals"
10
+ import { Breakdown } from "./components/Breakdown"
11
+ import { Footer } from "./components/Footer"
12
+ import { ScanBoot } from "./components/ScanBoot"
13
+ import { ThemeProvider } from "./ThemeContext"
14
+ import { useTerminalTheme } from "./theme"
15
+
16
+ const RANGES: RangeDays[] = [1, 7, 30, 90]
17
+ const COMPLETED_SCAN_HOLD_MS = 800
18
+
19
+ export function App() {
20
+ const renderer = useRenderer()
21
+ const theme = useTerminalTheme(renderer)
22
+ const { width, height } = useTerminalDimensions()
23
+ const [dataset, setDataset] = useState<UsageDataset | null>(null)
24
+ const [metric, setMetric] = useState<Metric>("cost")
25
+ const [range, setRange] = useState<RangeDays>(30)
26
+ const [breakdown, setBreakdown] = useState<BreakdownMode>("model")
27
+ const [visibleSources, setVisibleSources] = useState<Set<SourceId>>(() => new Set(SOURCE_ORDER))
28
+ const [selectedDay, setSelectedDay] = useState<string | null>(null)
29
+ const [loading, setLoading] = useState(true)
30
+ const [error, setError] = useState<string | null>(null)
31
+ const [scanCompleted, setScanCompleted] = useState(0)
32
+
33
+ const refresh = async () => {
34
+ const initialScan = dataset == null
35
+ setLoading(true)
36
+ setError(null)
37
+ if (initialScan) setScanCompleted(0)
38
+ try {
39
+ const nextDataset = await loadUsageDataset((progress) => {
40
+ if (initialScan) setScanCompleted(progress.completed)
41
+ })
42
+ if (initialScan) await Bun.sleep(COMPLETED_SCAN_HOLD_MS)
43
+ setDataset(nextDataset)
44
+ } catch (cause) {
45
+ setError(cause instanceof Error ? cause.message : String(cause))
46
+ } finally {
47
+ setLoading(false)
48
+ }
49
+ }
50
+
51
+ useEffect(() => { void refresh() }, [])
52
+
53
+ const changeRange = (next: RangeDays) => {
54
+ setRange(next)
55
+ setSelectedDay(null)
56
+ }
57
+
58
+ const selectDay = (day: string | null) => {
59
+ setSelectedDay(day)
60
+ if (day != null) setBreakdown("day")
61
+ }
62
+
63
+ const toggleSource = (source: SourceId) => {
64
+ setVisibleSources((current) => {
65
+ const next = new Set(current)
66
+ if (next.has(source)) next.delete(source)
67
+ else next.add(source)
68
+ return next
69
+ })
70
+ }
71
+
72
+ useKeyboard((key) => {
73
+ if (key.name === "q" || key.name === "escape") renderer.destroy()
74
+ else if (key.name === "c") setMetric((value) => value === "cost" ? "tokens" : "cost")
75
+ else if (key.name === "b") setBreakdown((value) => value === "model" ? "day" : "model")
76
+ else if (key.name === "r") void refresh()
77
+ else if (["1", "2", "3", "4"].includes(key.name)) changeRange(RANGES[Number(key.name) - 1]!)
78
+ else if (key.name === "left" || key.name === "right") {
79
+ const index = RANGES.indexOf(range)
80
+ const next = key.name === "left" ? Math.max(0, index - 1) : Math.min(RANGES.length - 1, index + 1)
81
+ changeRange(RANGES[next]!)
82
+ }
83
+ })
84
+
85
+ const dashboard = useMemo(() => dataset == null ? null : buildDashboard(dataset, range, metric), [dataset, range, metric])
86
+ const compact = width < 100
87
+ const wide = width >= 112
88
+ const contentWidth = Math.max(40, width - 4)
89
+ const summaryWidth = wide ? Math.min(45, Math.floor(contentWidth * 0.34)) : contentWidth
90
+ const chartWidth = wide ? contentWidth - summaryWidth - 3 : contentWidth
91
+
92
+ return (
93
+ <ThemeProvider theme={theme}>
94
+ <box width="100%" height="100%" flexDirection="column" backgroundColor={theme.bg}>
95
+ {dashboard == null ? (
96
+ <box flexGrow={1} width="100%" height="100%" paddingX={2} paddingTop={1}>
97
+ {error == null
98
+ ? <ScanBoot completed={scanCompleted} total={SOURCE_ORDER.length} width={contentWidth} />
99
+ : <text fg={theme.error}>{`Error: ${error}`}</text>}
100
+ </box>
101
+ ) : (
102
+ <scrollbox
103
+ flexGrow={1}
104
+ width="100%"
105
+ paddingX={2}
106
+ paddingTop={1}
107
+ scrollY
108
+ viewportCulling
109
+ scrollbarOptions={{
110
+ trackOptions: { backgroundColor: theme.bg, foregroundColor: theme.muted },
111
+ arrowOptions: { backgroundColor: theme.bg, foregroundColor: theme.muted },
112
+ }}
113
+ >
114
+ <box flexDirection="column" width="100%" gap={2}>
115
+ <Header
116
+ metric={metric}
117
+ range={range}
118
+ days={dashboard.days}
119
+ compact={compact}
120
+ onMetricChange={setMetric}
121
+ onRangeChange={changeRange}
122
+ onRefresh={() => void refresh()}
123
+ />
124
+ {dashboard.records.length === 0 ? (
125
+ <text fg={theme.muted}>No local usage found</text>
126
+ ) : (
127
+ <>
128
+ <box flexDirection={wide ? "row" : "column"} width="100%" gap={3}>
129
+ <box width={wide ? summaryWidth : "100%"}>
130
+ <Summary
131
+ dashboard={dashboard}
132
+ metric={metric}
133
+ visibleSources={visibleSources}
134
+ onToggleSource={toggleSource}
135
+ />
136
+ </box>
137
+ <box width={wide ? chartWidth : "100%"}>
138
+ <Chart
139
+ dashboard={dashboard}
140
+ metric={metric}
141
+ width={chartWidth}
142
+ height={wide ? 11 : 8}
143
+ visibleSources={visibleSources}
144
+ selectedDay={selectedDay}
145
+ onSelectDay={selectDay}
146
+ />
147
+ </box>
148
+ </box>
149
+ <Totals dashboard={dashboard} compact={compact} />
150
+ <Breakdown
151
+ dashboard={dashboard}
152
+ mode={breakdown}
153
+ metric={metric}
154
+ width={contentWidth}
155
+ selectedDay={selectedDay}
156
+ onModeChange={setBreakdown}
157
+ onSelectDay={selectDay}
158
+ />
159
+ </>
160
+ )}
161
+ {dataset?.errors.length ? <text fg={theme.error}>{dataset.errors.join(" · ")}</text> : null}
162
+ <box height={1} />
163
+ </box>
164
+ </scrollbox>
165
+ )}
166
+ {dashboard == null ? null : <Footer loading={loading} />}
167
+ </box>
168
+ </ThemeProvider>
169
+ )
170
+ }
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext, type ReactNode } from "react"
2
+ import { terminalTheme, type Theme } from "./theme"
3
+
4
+ const ThemeContext = createContext<Theme>(terminalTheme("dark"))
5
+
6
+ export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
7
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
8
+ }
9
+
10
+ export function useTheme(): Theme {
11
+ return useContext(ThemeContext)
12
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { SourceId } from "../domain/types"
3
+ import { chartColumnForDay, dayIndexAtColumn, renderBrailleChart } from "./chart"
4
+
5
+ const emptySeries = (): Record<SourceId, number[]> => ({
6
+ claude: [0, 0, 0],
7
+ codex: [0, 0, 0],
8
+ grok: [0, 0, 0],
9
+ opencode: [0, 0, 0],
10
+ cursor: [0, 0, 0],
11
+ gemini: [0, 0, 0],
12
+ })
13
+
14
+ describe("renderBrailleChart", () => {
15
+ test("renders the requested cell dimensions", () => {
16
+ const series = emptySeries()
17
+ series.claude = [0, 5, 10]
18
+
19
+ const chart = renderBrailleChart(series, 12, 4)
20
+
21
+ expect(chart.max).toBe(10)
22
+ expect(chart.rows).toHaveLength(4)
23
+ expect(chart.rows.every((row) => row.reduce((length, part) => length + part.text.length, 0) === 12)).toBeTrue()
24
+ expect(chart.rows.flatMap((row) => row).some((part) => part.text.trim().length > 0)).toBeTrue()
25
+ })
26
+
27
+ test("handles empty data", () => {
28
+ const chart = renderBrailleChart(emptySeries(), 8, 3)
29
+
30
+ expect(chart.max).toBe(0)
31
+ expect(chart.rows).toHaveLength(3)
32
+ })
33
+
34
+ test("maps pointer columns to days", () => {
35
+ expect(dayIndexAtColumn(0, 15, 30)).toBe(0)
36
+ expect(dayIndexAtColumn(14, 15, 30)).toBe(29)
37
+ expect(chartColumnForDay(29, 15, 30)).toBe(14)
38
+ })
39
+
40
+ test("highlights the active column", () => {
41
+ const series = emptySeries()
42
+ series.codex = [1, 2, 3]
43
+
44
+ const chart = renderBrailleChart(series, 8, 3, 4, "#123456")
45
+
46
+ expect(chart.rows.every((row) => row.some((part) => part.background === "#123456"))).toBeTrue()
47
+ })
48
+ })
@@ -0,0 +1,130 @@
1
+ import type { ColorInput } from "@opentui/core"
2
+ import type { SourceId } from "../domain/types"
3
+ import { terminalTheme, type Theme } from "./theme"
4
+
5
+ export type ChartSegment = { text: string; color: ColorInput; background?: ColorInput }
6
+ export type ChartRow = ChartSegment[]
7
+
8
+ type Cell = { bits: number; source: SourceId | null }
9
+
10
+ const DOTS = [
11
+ [1, 8],
12
+ [2, 16],
13
+ [4, 32],
14
+ [64, 128],
15
+ ] as const
16
+
17
+ export function renderBrailleChart(
18
+ series: Record<SourceId, number[]>,
19
+ width: number,
20
+ height: number,
21
+ highlightColumn?: number,
22
+ highlightColor?: ColorInput,
23
+ highlightForeground?: ColorInput,
24
+ palette: Theme = terminalTheme("dark"),
25
+ ): { rows: ChartRow[]; max: number } {
26
+ const safeWidth = Math.max(4, width)
27
+ const safeHeight = Math.max(3, height)
28
+ const pixelWidth = safeWidth * 2
29
+ const pixelHeight = safeHeight * 4
30
+ const cells: Cell[][] = Array.from({ length: safeHeight }, () =>
31
+ Array.from({ length: safeWidth }, () => ({ bits: 0, source: null })),
32
+ )
33
+ const entries = (Object.entries(series) as Array<[SourceId, number[]]>)
34
+ .filter(([, values]) => values.some((value) => value > 0))
35
+ .sort(([, a], [, b]) => sum(a) - sum(b))
36
+ const max = Math.max(0, ...entries.flatMap(([, values]) => values))
37
+
38
+ if (max > 0) {
39
+ for (const [source, values] of entries) {
40
+ const points = values.map((value, index) => ({
41
+ x: values.length <= 1 ? 0 : Math.round((index / (values.length - 1)) * (pixelWidth - 1)),
42
+ y: Math.round((1 - value / max) * (pixelHeight - 1)),
43
+ }))
44
+ if (points.length === 1) plot(cells, points[0]!.x, points[0]!.y, source)
45
+ for (let index = 1; index < points.length; index++) {
46
+ drawLine(cells, points[index - 1]!, points[index]!, source)
47
+ }
48
+ }
49
+ }
50
+
51
+ return {
52
+ max,
53
+ rows: cells.map((row) => groupSegments(row, highlightColumn, highlightColor, highlightForeground, palette)),
54
+ }
55
+ }
56
+
57
+ function sum(values: number[]): number {
58
+ return values.reduce((total, value) => total + value, 0)
59
+ }
60
+
61
+ function drawLine(
62
+ cells: Cell[][],
63
+ from: { x: number; y: number },
64
+ to: { x: number; y: number },
65
+ source: SourceId,
66
+ ): void {
67
+ let x = from.x
68
+ let y = from.y
69
+ const dx = Math.abs(to.x - from.x)
70
+ const sx = from.x < to.x ? 1 : -1
71
+ const dy = -Math.abs(to.y - from.y)
72
+ const sy = from.y < to.y ? 1 : -1
73
+ let error = dx + dy
74
+
75
+ while (true) {
76
+ plot(cells, x, y, source)
77
+ if (x === to.x && y === to.y) return
78
+ const doubled = error * 2
79
+ if (doubled >= dy) {
80
+ error += dy
81
+ x += sx
82
+ }
83
+ if (doubled <= dx) {
84
+ error += dx
85
+ y += sy
86
+ }
87
+ }
88
+ }
89
+
90
+ function plot(cells: Cell[][], pixelX: number, pixelY: number, source: SourceId): void {
91
+ const row = Math.floor(pixelY / 4)
92
+ const column = Math.floor(pixelX / 2)
93
+ const cell = cells[row]?.[column]
94
+ if (cell == null) return
95
+ cell.bits |= DOTS[pixelY % 4]![pixelX % 2]!
96
+ cell.source = source
97
+ }
98
+
99
+ function groupSegments(
100
+ cells: Cell[],
101
+ highlightColumn?: number,
102
+ highlightColor?: ColorInput,
103
+ highlightForeground?: ColorInput,
104
+ palette: Theme = terminalTheme("dark"),
105
+ ): ChartSegment[] {
106
+ const segments: ChartSegment[] = []
107
+ for (const [column, cell] of cells.entries()) {
108
+ const text = cell.bits === 0 ? " " : String.fromCodePoint(0x2800 + cell.bits)
109
+ const color = column === highlightColumn && highlightForeground != null
110
+ ? highlightForeground
111
+ : cell.source == null ? palette.faint : palette.sources[cell.source]
112
+ const background = column === highlightColumn ? highlightColor : undefined
113
+ const previous = segments.at(-1)
114
+ if (previous?.color === color && previous.background === background) previous.text += text
115
+ else segments.push({ text, color, background })
116
+ }
117
+ return segments
118
+ }
119
+
120
+ export function dayIndexAtColumn(column: number, width: number, dayCount: number): number {
121
+ if (dayCount <= 1 || width <= 1) return 0
122
+ const bounded = Math.max(0, Math.min(width - 1, column))
123
+ return Math.round((bounded / (width - 1)) * (dayCount - 1))
124
+ }
125
+
126
+ export function chartColumnForDay(index: number, width: number, dayCount: number): number {
127
+ if (dayCount <= 1 || width <= 1) return 0
128
+ const bounded = Math.max(0, Math.min(dayCount - 1, index))
129
+ return Math.round((bounded / (dayCount - 1)) * (width - 1))
130
+ }
@@ -0,0 +1,85 @@
1
+ import { useState } from "react"
2
+ import type { MouseEvent } from "@opentui/core"
3
+ import type { Dashboard } from "../../domain/aggregate"
4
+ import type { BreakdownMode, Metric } from "../../domain/types"
5
+ import { SOURCE_META } from "../../domain/types"
6
+ import { Segmented } from "./Segmented"
7
+ import { formatMetric, percent, truncate } from "../format"
8
+ import { useTheme } from "../ThemeContext"
9
+ import { usePointer } from "../usePointer"
10
+
11
+ export function Breakdown({
12
+ dashboard,
13
+ mode,
14
+ metric,
15
+ width,
16
+ selectedDay,
17
+ onModeChange,
18
+ onSelectDay,
19
+ }: {
20
+ dashboard: Dashboard
21
+ mode: BreakdownMode
22
+ metric: Metric
23
+ width: number
24
+ selectedDay: string | null
25
+ onModeChange: (mode: BreakdownMode) => void
26
+ onSelectDay: (day: string | null) => void
27
+ }) {
28
+ const theme = useTheme()
29
+ const [hovered, setHovered] = useState<string | null>(null)
30
+ const { pointerOver, pointerOut } = usePointer()
31
+ const rows = mode === "model" ? dashboard.models : dashboard.daily
32
+ const nameWidth = Math.max(18, width - 42)
33
+ return (
34
+ <box flexDirection="column" width="100%" gap={1}>
35
+ <box flexDirection="row" justifyContent="space-between">
36
+ <text fg={theme.text}><strong>Breakdown</strong></text>
37
+ <Segmented
38
+ selected={mode}
39
+ onChange={onModeChange}
40
+ options={[{ value: "model", label: "Model" }, { value: "day", label: "Day" }]}
41
+ />
42
+ </box>
43
+ <box flexDirection="row">
44
+ <text fg={theme.muted} width={nameWidth}>{mode === "model" ? "Model" : "Day"}</text>
45
+ <text fg={theme.muted} width={15}>{metric === "cost" ? "Cost" : "Tokens"}</text>
46
+ <text fg={theme.muted} width={12}>Share</text>
47
+ <text fg={theme.muted}>Tokens</text>
48
+ </box>
49
+ {rows.slice(0, 18).map((row) => {
50
+ const value = metric === "cost" ? row.costUsd : row.processedTokens
51
+ const color = row.source == null ? theme.text : theme.sources[row.source]
52
+ const mark = row.source == null ? " " : SOURCE_META[row.source].mark
53
+ const interactive = mode === "day"
54
+ const selected = interactive && selectedDay === row.key
55
+ const hot = interactive && hovered === row.key
56
+ const select = interactive ? (event: MouseEvent) => {
57
+ if (event.button !== 0) return
58
+ event.stopPropagation()
59
+ onSelectDay(selected ? null : row.key)
60
+ } : undefined
61
+ return (
62
+ <box
63
+ key={row.key}
64
+ flexDirection="row"
65
+ backgroundColor={selected ? theme.selected : hot ? theme.hover : theme.bg}
66
+ onMouseDown={select}
67
+ onMouseOver={interactive ? () => {
68
+ setHovered(row.key)
69
+ pointerOver()
70
+ } : undefined}
71
+ onMouseOut={interactive ? () => {
72
+ setHovered(null)
73
+ pointerOut()
74
+ } : undefined}
75
+ >
76
+ <text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : color} width={nameWidth}>{`${mark} ${truncate(row.label, nameWidth - 2)}`}</text>
77
+ <text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : theme.text} width={15}>{formatMetric(value, metric)}</text>
78
+ <text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : theme.muted} width={12}>{percent(row.share)}</text>
79
+ <text selectable={!interactive} fg={selected ? theme.onSelected : hot ? theme.onHover : theme.muted}>{formatMetric(row.processedTokens, "tokens")}</text>
80
+ </box>
81
+ )
82
+ })}
83
+ </box>
84
+ )
85
+ }
@@ -0,0 +1,67 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { testRender } from "@opentui/react/test-utils"
3
+ import type { TestRendererSetup } from "@opentui/core/testing"
4
+ import { act } from "react"
5
+ import { buildDashboard } from "../../domain/aggregate"
6
+ import { localDate } from "../../domain/dates"
7
+ import { SOURCE_ORDER, type UsageDataset } from "../../domain/types"
8
+ import { terminalTheme } from "../theme"
9
+ import { Chart } from "./Chart"
10
+
11
+ let setup: TestRendererSetup | null = null
12
+
13
+ afterEach(() => {
14
+ act(() => { setup?.renderer.destroy() })
15
+ setup = null
16
+ })
17
+
18
+ describe("Chart", () => {
19
+ test("shows point cost on hover and selects the day on click", async () => {
20
+ const today = localDate(new Date())
21
+ const dataset: UsageDataset = {
22
+ scannedAt: new Date(),
23
+ errors: [],
24
+ sessions: [{ id: "codex:1", source: "codex", date: today }],
25
+ records: [{
26
+ date: today,
27
+ source: "codex",
28
+ model: "gpt-5-codex",
29
+ sessionId: "codex:1",
30
+ inputTokens: 100,
31
+ outputTokens: 10,
32
+ cacheCreationTokens: 0,
33
+ cacheReadTokens: 0,
34
+ costUsd: 2,
35
+ cacheSavingsUsd: 0,
36
+ }],
37
+ }
38
+ const dashboard = buildDashboard(dataset, 7, "cost")
39
+ let selected: string | null = null
40
+ setup = await testRender(
41
+ <Chart
42
+ dashboard={dashboard}
43
+ metric="cost"
44
+ width={40}
45
+ height={4}
46
+ visibleSources={new Set(SOURCE_ORDER)}
47
+ selectedDay={selected}
48
+ onSelectDay={(day) => { selected = day }}
49
+ />,
50
+ { width: 40, height: 8, autoFocus: false },
51
+ )
52
+ await setup.renderOnce()
53
+
54
+ await act(async () => { await setup!.mockMouse.moveTo(37, 2) })
55
+ await setup.flush()
56
+
57
+ expect(setup.captureCharFrame()).toContain("$2.00")
58
+ const highlightedSpans = setup.captureSpans().lines.flatMap((line) => line.spans)
59
+ .filter((span) => span.bg.equals(terminalTheme("dark").selected))
60
+ expect(highlightedSpans.length).toBeGreaterThanOrEqual(4)
61
+
62
+ await act(async () => { await setup!.mockMouse.click(37, 2) })
63
+
64
+ expect(String(selected)).toBe(today)
65
+ expect(setup.renderer.getSelection()).toBeNull()
66
+ })
67
+ })
@@ -0,0 +1,159 @@
1
+ import { useMemo, useState } from "react"
2
+ import type { MouseEvent } from "@opentui/core"
3
+ import type { Dashboard } from "../../domain/aggregate"
4
+ import { shortDate } from "../../domain/dates"
5
+ import { SOURCE_META, SOURCE_ORDER, type Metric, type SourceId } from "../../domain/types"
6
+ import { chartColumnForDay, dayIndexAtColumn, renderBrailleChart } from "../chart"
7
+ import { formatAxis, formatMetric } from "../format"
8
+ import { useTheme } from "../ThemeContext"
9
+ import { usePointer } from "../usePointer"
10
+
11
+ export function Chart({
12
+ dashboard,
13
+ metric,
14
+ width,
15
+ visibleSources,
16
+ selectedDay,
17
+ onSelectDay,
18
+ height = 11,
19
+ }: {
20
+ dashboard: Dashboard
21
+ metric: Metric
22
+ width: number
23
+ visibleSources: Set<SourceId>
24
+ selectedDay: string | null
25
+ onSelectDay: (day: string | null) => void
26
+ height?: number
27
+ }) {
28
+ const theme = useTheme()
29
+ const labelWidth = 9
30
+ const graphWidth = Math.max(8, width - labelWidth - 1)
31
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
32
+ const { pointerOver, pointerOut } = usePointer("crosshair")
33
+ const selectedIndex = selectedDay == null ? -1 : dashboard.days.indexOf(selectedDay)
34
+ const activeIndex = hoveredIndex ?? (selectedIndex >= 0 ? selectedIndex : null)
35
+ const highlightColumn = activeIndex == null
36
+ ? undefined
37
+ : chartColumnForDay(activeIndex, graphWidth, dashboard.days.length)
38
+
39
+ const visibleSeries = useMemo(() => Object.fromEntries(
40
+ SOURCE_ORDER.map((source) => [
41
+ source,
42
+ visibleSources.has(source)
43
+ ? dashboard.series[source]
44
+ : dashboard.series[source].map(() => 0),
45
+ ]),
46
+ ) as Record<SourceId, number[]>, [dashboard.series, visibleSources])
47
+ const chart = renderBrailleChart(
48
+ visibleSeries,
49
+ graphWidth,
50
+ height,
51
+ highlightColumn,
52
+ theme.selected,
53
+ theme.onSelected,
54
+ theme,
55
+ )
56
+
57
+ const indexFromEvent = (event: MouseEvent): number => {
58
+ const start = event.currentTarget?.screenX ?? event.x
59
+ return dayIndexAtColumn(event.x - start, graphWidth, dashboard.days.length)
60
+ }
61
+
62
+ const move = (event: MouseEvent) => setHoveredIndex(indexFromEvent(event))
63
+ const select = (event: MouseEvent) => {
64
+ if (event.button !== 0) return
65
+ event.stopPropagation()
66
+ const index = indexFromEvent(event)
67
+ const day = dashboard.days[index] ?? null
68
+ onSelectDay(day === selectedDay ? null : day)
69
+ }
70
+
71
+ return (
72
+ <box flexDirection="column" width="100%">
73
+ <text fg={theme.text}>{`Daily ${metric}`}</text>
74
+ <box height={1}>
75
+ {activeIndex == null ? null : (
76
+ <ChartPoint
77
+ dashboard={dashboard}
78
+ metric={metric}
79
+ index={activeIndex}
80
+ visibleSources={visibleSources}
81
+ pinned={hoveredIndex == null && selectedIndex === activeIndex}
82
+ />
83
+ )}
84
+ </box>
85
+ <box flexDirection="row" height={height}>
86
+ <box flexDirection="column" width={labelWidth}>
87
+ {chart.rows.map((_, index) => {
88
+ const ratio = 1 - index / Math.max(1, chart.rows.length - 1)
89
+ const show = index === 0 || index === Math.floor(chart.rows.length / 2) || index === chart.rows.length - 1
90
+ const label = show ? formatAxis(chart.max * ratio, metric) : ""
91
+ return <text key={index} fg={theme.muted} height={1}>{label.padStart(labelWidth - 1)}</text>
92
+ })}
93
+ </box>
94
+ <box
95
+ focusable
96
+ flexDirection="column"
97
+ width={graphWidth}
98
+ height={height}
99
+ onMouseMove={move}
100
+ onMouseDown={select}
101
+ onMouseOver={pointerOver}
102
+ onMouseOut={() => {
103
+ setHoveredIndex(null)
104
+ pointerOut()
105
+ }}
106
+ >
107
+ {chart.rows.map((segments, index) => (
108
+ <text key={index} height={1} selectable={false}>
109
+ {segments.map((segment, segmentIndex) => (
110
+ <span key={segmentIndex} fg={segment.color} bg={segment.background}>
111
+ {segment.text}
112
+ </span>
113
+ ))}
114
+ </text>
115
+ ))}
116
+ </box>
117
+ </box>
118
+ <box flexDirection="row" marginLeft={labelWidth} justifyContent="space-between" width={graphWidth}>
119
+ <text fg={theme.muted}>{shortDate(dashboard.days[0] ?? "")}</text>
120
+ <text fg={theme.muted}>{shortDate(dashboard.days[Math.floor(dashboard.days.length / 2)] ?? "")}</text>
121
+ <text fg={theme.muted}>{shortDate(dashboard.days.at(-1) ?? "")}</text>
122
+ </box>
123
+ </box>
124
+ )
125
+ }
126
+
127
+ function ChartPoint({
128
+ dashboard,
129
+ metric,
130
+ index,
131
+ visibleSources,
132
+ pinned,
133
+ }: {
134
+ dashboard: Dashboard
135
+ metric: Metric
136
+ index: number
137
+ visibleSources: Set<SourceId>
138
+ pinned: boolean
139
+ }) {
140
+ const theme = useTheme()
141
+ const values = SOURCE_ORDER
142
+ .filter((source) => visibleSources.has(source))
143
+ .map((source) => ({ source, value: dashboard.series[source][index] ?? 0 }))
144
+ .filter(({ value }) => value > 0)
145
+ const total = values.reduce((sum, { value }) => sum + value, 0)
146
+
147
+ return (
148
+ <text height={1}>
149
+ <span fg={theme.text}>{shortDate(dashboard.days[index] ?? "")}</span>
150
+ <span fg={theme.muted}>{` ${formatMetric(total, metric)}`}</span>
151
+ {values.map(({ source, value }) => (
152
+ <span key={source} fg={theme.sources[source]}>
153
+ {` ${SOURCE_META[source].mark} ${formatMetric(value, metric)}`}
154
+ </span>
155
+ ))}
156
+ {pinned ? <span fg={theme.muted}>{" pinned"}</span> : null}
157
+ </text>
158
+ )
159
+ }
@@ -0,0 +1,11 @@
1
+ import { useTheme } from "../ThemeContext"
2
+
3
+ export function Footer({ loading }: { loading: boolean }) {
4
+ const theme = useTheme()
5
+ return (
6
+ <box width="100%" flexDirection="row" justifyContent="space-between" backgroundColor={theme.panel} paddingX={1}>
7
+ <text fg={theme.muted}>c cost/tokens 1-4 range b breakdown r refresh q quit</text>
8
+ <text fg={loading ? theme.accent : theme.muted}>{loading ? "loading" : "local"}</text>
9
+ </box>
10
+ )
11
+ }