@brimveyn/aimux 1.22.12 → 1.23.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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +5 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app.tsx +6 -0
- package/src/index.tsx +6 -0
- package/src/input/keymap/help-entries.ts +1 -0
- package/src/input/modes/bridge.ts +2 -1
- package/src/input/modes/transitions.ts +7 -3
- package/src/input/modes/types.ts +2 -1
- package/src/restart-daemon.ts +5 -0
- package/src/services/ai-usage/projection.ts +44 -0
- package/src/services/aimux-counters/index.ts +87 -0
- package/src/services/aimux-counters/observe.ts +39 -0
- package/src/services/aimux-counters/store.ts +150 -0
- package/src/services/aimux-counters/summary.ts +78 -0
- package/src/services/usage-history/cost.ts +149 -0
- package/src/services/usage-history/insights.ts +314 -0
- package/src/services/usage-history/rollup.ts +78 -6
- package/src/services/usage-history/stats.ts +66 -35
- package/src/services/usage-history/store.ts +128 -9
- package/src/settings/sections/about.ts +1 -0
- package/src/settings/sections/appearance.ts +1 -0
- package/src/settings/sections/automation.ts +1 -0
- package/src/settings/sections/commands.ts +1 -0
- package/src/settings/sections/editor.ts +1 -0
- package/src/settings/sections/experimental.ts +1 -0
- package/src/settings/sections/git.ts +1 -0
- package/src/settings/sections/integrations.ts +1 -0
- package/src/settings/sections/layout.ts +1 -0
- package/src/settings/sections/notifications.ts +1 -0
- package/src/settings/sections/setup.ts +1 -0
- package/src/settings/sections/status-bar.ts +1 -0
- package/src/settings/sections/workspace.ts +1 -0
- package/src/settings/types.ts +10 -0
- package/src/state/actions.ts +12 -1
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/stats-state.ts +56 -0
- package/src/state/stats-pages.ts +33 -0
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +17 -4
- package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
- package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
- package/src/ui/components/settings/settings-view.tsx +6 -3
- package/src/ui/components/stats/aimux-page.tsx +245 -0
- package/src/ui/components/stats/chart.ts +157 -0
- package/src/ui/components/stats/day-facts.tsx +268 -0
- package/src/ui/components/stats/format.ts +98 -0
- package/src/ui/components/stats/heatmap.tsx +305 -0
- package/src/ui/components/stats/projects-page.tsx +293 -0
- package/src/ui/components/stats/quotas.tsx +210 -0
- package/src/ui/components/stats/shared.tsx +645 -0
- package/src/ui/components/stats/stats-view.tsx +153 -0
- package/src/ui/components/stats/usage-page.tsx +291 -0
- package/src/ui/components/stats/use-stats-data.ts +48 -0
- package/src/ui/root.tsx +7 -3
- package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { BoxRenderable, MouseEvent, RGBA } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildHeatmap,
|
|
7
|
+
coveredWeeks,
|
|
8
|
+
type HeatmapCell,
|
|
9
|
+
mixColor,
|
|
10
|
+
monthLabels,
|
|
11
|
+
promptCounts,
|
|
12
|
+
} from '../../../services/usage-history/stats'
|
|
13
|
+
import { localDay, type UsageDays } from '../../../services/usage-history/store'
|
|
14
|
+
import { useTheme } from '../../theme'
|
|
15
|
+
import { dayDetails, DayFacts, DayPanel, PANEL_WIDTH } from './day-facts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The activity calendar.
|
|
19
|
+
*
|
|
20
|
+
* A cell is `md-square_rounded`, a filled rounded square that fills its
|
|
21
|
+
* terminal cell. Unicode has no filled rounded square — `▢` U+25A2 is an
|
|
22
|
+
* outline — so this is a nerd-font glyph, like the status bar's round separator.
|
|
23
|
+
*
|
|
24
|
+
* One hue, light to dark, because the only thing a cell encodes is magnitude.
|
|
25
|
+
* A hue per month grouped the calendar prettily but spent the colour channel on
|
|
26
|
+
* something the month labels already say, and it borrowed the status colours,
|
|
27
|
+
* which on this page mean a quota is running out.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** nf-md-square_rounded. Needs a nerd font, and measures one cell wide. */
|
|
31
|
+
const CELL = '\u{F14FB}'
|
|
32
|
+
/** The glyph plus its gap — the stride one day occupies. */
|
|
33
|
+
const CELL_WIDTH = 2
|
|
34
|
+
const BLANK = ' '.repeat(CELL_WIDTH)
|
|
35
|
+
const LABEL_WIDTH = 5
|
|
36
|
+
|
|
37
|
+
const ROW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
|
|
38
|
+
/** A constant, not an inline attribute: a JSX string does not process `\u` escapes. */
|
|
39
|
+
const CHEVRON = '\u{00BB}'
|
|
40
|
+
const RULE = '\u{2500}'
|
|
41
|
+
|
|
42
|
+
/** Rows the grid draws above the first weekday: the month header and its rule. */
|
|
43
|
+
const HEADER_ROWS = 2
|
|
44
|
+
/** The box's own border and padding on the left. */
|
|
45
|
+
const BOX_INSET = 2
|
|
46
|
+
/** Between the calendar and the day panel beside it. */
|
|
47
|
+
const PANEL_GAP = 2
|
|
48
|
+
type Ramp = (RGBA | string)[]
|
|
49
|
+
|
|
50
|
+
/** Light to dark on one hue, over the neutral an unrecorded day keeps. */
|
|
51
|
+
function rampOf(empty: string, hue: string): Ramp {
|
|
52
|
+
return [
|
|
53
|
+
empty,
|
|
54
|
+
mixColor(empty, hue, 0.35),
|
|
55
|
+
mixColor(empty, hue, 0.6),
|
|
56
|
+
mixColor(empty, hue, 0.82),
|
|
57
|
+
hue,
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useHeatmapRamp(): Ramp {
|
|
62
|
+
const t = useTheme()
|
|
63
|
+
// borderSubtle rather than a background token: backgrounds resolve to alpha 0
|
|
64
|
+
// in transparent mode, which would erase the empty days entirely.
|
|
65
|
+
return useMemo(() => rampOf(t.borderSubtle, t.primary), [t.borderSubtle, t.primary])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function HeatmapLegend({ ramp }: { ramp: Ramp }) {
|
|
69
|
+
const t = useTheme()
|
|
70
|
+
return (
|
|
71
|
+
<box flexDirection="row" flexShrink={0}>
|
|
72
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
73
|
+
{'Less '}
|
|
74
|
+
</text>
|
|
75
|
+
{ramp.map((color, index) => (
|
|
76
|
+
<text key={String(color) + String(index)} fg={color} selectable={false} wrapMode="none">
|
|
77
|
+
{`${CELL} `}
|
|
78
|
+
</text>
|
|
79
|
+
))}
|
|
80
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
81
|
+
More
|
|
82
|
+
</text>
|
|
83
|
+
</box>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Gutter({ label }: { label: string }) {
|
|
88
|
+
const t = useTheme()
|
|
89
|
+
return (
|
|
90
|
+
<box width={LABEL_WIDTH} flexShrink={0}>
|
|
91
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
92
|
+
{label}
|
|
93
|
+
</text>
|
|
94
|
+
</box>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function HeatRow({
|
|
99
|
+
cells,
|
|
100
|
+
label,
|
|
101
|
+
ramp,
|
|
102
|
+
selected,
|
|
103
|
+
}: {
|
|
104
|
+
cells: HeatmapCell[]
|
|
105
|
+
label: string
|
|
106
|
+
ramp: Ramp
|
|
107
|
+
selected: number
|
|
108
|
+
}) {
|
|
109
|
+
const t = useTheme()
|
|
110
|
+
|
|
111
|
+
// One <text> per colour run, not per cell: a year is 371 of them.
|
|
112
|
+
const runs: { color: RGBA | string; start: number; text: string }[] = []
|
|
113
|
+
for (const [index, cell] of cells.entries()) {
|
|
114
|
+
let color: RGBA | string = ''
|
|
115
|
+
if (cell.day !== '') {
|
|
116
|
+
color = index === selected ? t.text : (ramp[cell.level] ?? t.borderSubtle)
|
|
117
|
+
}
|
|
118
|
+
const glyph = cell.day === '' ? BLANK : `${CELL} `
|
|
119
|
+
const last = runs.at(-1)
|
|
120
|
+
// The selected cell always starts its own run, so it keeps its own colour.
|
|
121
|
+
if (last !== undefined && last.color === color && index !== selected) last.text += glyph
|
|
122
|
+
else runs.push({ color, start: index, text: glyph })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<box flexDirection="row" flexShrink={0}>
|
|
127
|
+
<Gutter label={label} />
|
|
128
|
+
{runs.map((run) =>
|
|
129
|
+
run.color === '' ? (
|
|
130
|
+
<text key={run.start} selectable={false} wrapMode="none">
|
|
131
|
+
{run.text}
|
|
132
|
+
</text>
|
|
133
|
+
) : (
|
|
134
|
+
<text key={run.start} fg={run.color} selectable={false} wrapMode="none">
|
|
135
|
+
{run.text}
|
|
136
|
+
</text>
|
|
137
|
+
)
|
|
138
|
+
)}
|
|
139
|
+
</box>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Month names over the columns they occupy. */
|
|
144
|
+
function MonthHeader({ grid, weeks }: { grid: HeatmapCell[][]; weeks: number }) {
|
|
145
|
+
const t = useTheme()
|
|
146
|
+
const labels = monthLabels(grid, weeks, CELL_WIDTH)
|
|
147
|
+
|
|
148
|
+
let cursor = 0
|
|
149
|
+
const parts: string[] = []
|
|
150
|
+
for (const label of labels) {
|
|
151
|
+
parts.push(' '.repeat(Math.max(0, label.offset - cursor)), label.name)
|
|
152
|
+
cursor = label.offset + label.name.length
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<box flexDirection="row" flexShrink={0}>
|
|
157
|
+
<Gutter label={CHEVRON} />
|
|
158
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
159
|
+
{parts.join('')}
|
|
160
|
+
</text>
|
|
161
|
+
</box>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface Selection {
|
|
166
|
+
column: number
|
|
167
|
+
key: string
|
|
168
|
+
row: number
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* How many weeks the grid draws in `width` columns.
|
|
173
|
+
*
|
|
174
|
+
* Bounded by what is recorded: empty months predating the first rollup read as
|
|
175
|
+
* broken, not as history.
|
|
176
|
+
*/
|
|
177
|
+
function weeksFor(days: UsageDays, today: Date, width: number): number {
|
|
178
|
+
// Less the day-name column and the box's own border and padding, so the grid
|
|
179
|
+
// sizes to what is actually left inside it.
|
|
180
|
+
const usable = Math.max(CELL_WIDTH, width - LABEL_WIDTH - 4)
|
|
181
|
+
return coveredWeeks(days, today, Math.max(4, Math.min(53, Math.floor(usable / CELL_WIDTH))))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The columns the calendar's box actually occupies.
|
|
186
|
+
*
|
|
187
|
+
* The section's legend is pinned to this rather than to the pane, so it sits
|
|
188
|
+
* over the grid's own top-right corner: the calendar is as wide as the history
|
|
189
|
+
* is long, and a legend flush with the far edge of the page belongs to nothing
|
|
190
|
+
* the eye can see.
|
|
191
|
+
*/
|
|
192
|
+
export function heatmapWidth(days: UsageDays, today: Date, width: number): number {
|
|
193
|
+
return BOX_INSET * 2 + LABEL_WIDTH + weeksFor(days, today, width) * CELL_WIDTH
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function Heatmap({
|
|
197
|
+
days,
|
|
198
|
+
ramp,
|
|
199
|
+
summary,
|
|
200
|
+
today,
|
|
201
|
+
width,
|
|
202
|
+
}: {
|
|
203
|
+
days: UsageDays
|
|
204
|
+
ramp: Ramp
|
|
205
|
+
/** What the whole calendar says, above the day the pointer is on. */
|
|
206
|
+
summary?: string
|
|
207
|
+
today: Date
|
|
208
|
+
width: number
|
|
209
|
+
}) {
|
|
210
|
+
const t = useTheme()
|
|
211
|
+
const boxRef = useRef<BoxRenderable | null>(null)
|
|
212
|
+
const [selected, setSelected] = useState<Selection | null>(null)
|
|
213
|
+
|
|
214
|
+
const weeks = weeksFor(days, today, width)
|
|
215
|
+
const grid = buildHeatmap(promptCounts(days), weeks, today)
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The grid is a fixed pitch, so where the pointer is is arithmetic rather
|
|
219
|
+
* than a hit test: one handler beats a handler on each of 371 cells, and the
|
|
220
|
+
* cells stay batched into colour runs for rendering.
|
|
221
|
+
*
|
|
222
|
+
* Movement rather than clicks. Reading a day was a mode — open the popover,
|
|
223
|
+
* read it, dismiss it — for numbers that take a second to scan, and the box
|
|
224
|
+
* covered the month it was describing. Following the pointer has no state to
|
|
225
|
+
* open or to get stuck in, and scanning a week is a sweep rather than a dozen
|
|
226
|
+
* clicks. Mouse-down runs the same path so the readout still answers a click,
|
|
227
|
+
* which is what a terminal without motion reporting will send.
|
|
228
|
+
*/
|
|
229
|
+
const handlePoint = useCallback(
|
|
230
|
+
(event: MouseEvent) => {
|
|
231
|
+
const box = boxRef.current
|
|
232
|
+
if (box === null) return
|
|
233
|
+
const row = event.y - box.y - HEADER_ROWS - 1
|
|
234
|
+
const column = Math.floor((event.x - box.x - BOX_INSET - LABEL_WIDTH) / CELL_WIDTH)
|
|
235
|
+
const key = grid[row]?.[column]?.day
|
|
236
|
+
// A cell with no day behind it — the future, or before the first rollup —
|
|
237
|
+
// leaves the last one up rather than blanking the slot as the pointer
|
|
238
|
+
// crosses it.
|
|
239
|
+
if (key == null || key === '') return
|
|
240
|
+
setSelected((current) => (current?.key === key ? current : { column, key, row }))
|
|
241
|
+
},
|
|
242
|
+
[grid]
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
// Today until the pointer says otherwise: the slot is worth its rows from the
|
|
246
|
+
// moment the page opens, and today is the day a reader wants first.
|
|
247
|
+
const shown = selected?.key ?? localDay(today)
|
|
248
|
+
const details = useMemo(() => dayDetails(shown, days[shown]), [days, shown])
|
|
249
|
+
|
|
250
|
+
// The calendar is bounded by what has been recorded, so it usually leaves
|
|
251
|
+
// room to its right. The panel goes there when it fits, and drops to running
|
|
252
|
+
// lines underneath when it does not.
|
|
253
|
+
const beside = width - heatmapWidth(days, today, width) - PANEL_GAP >= PANEL_WIDTH
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<box flexDirection="column" flexShrink={0} alignSelf="flex-start">
|
|
257
|
+
<box flexDirection="row" flexShrink={0}>
|
|
258
|
+
<box
|
|
259
|
+
ref={boxRef}
|
|
260
|
+
border
|
|
261
|
+
borderStyle="rounded"
|
|
262
|
+
borderColor={t.border}
|
|
263
|
+
paddingLeft={1}
|
|
264
|
+
paddingRight={1}
|
|
265
|
+
flexDirection="column"
|
|
266
|
+
flexShrink={0}
|
|
267
|
+
onMouseDown={handlePoint}
|
|
268
|
+
onMouseMove={handlePoint}
|
|
269
|
+
>
|
|
270
|
+
<MonthHeader grid={grid} weeks={weeks} />
|
|
271
|
+
<box flexDirection="row" flexShrink={0}>
|
|
272
|
+
<Gutter label="" />
|
|
273
|
+
<text fg={t.borderSubtle} selectable={false} wrapMode="none">
|
|
274
|
+
{RULE.repeat(weeks * CELL_WIDTH)}
|
|
275
|
+
</text>
|
|
276
|
+
</box>
|
|
277
|
+
{grid.map((cells, index) => (
|
|
278
|
+
<HeatRow
|
|
279
|
+
key={ROW_LABELS[index]}
|
|
280
|
+
cells={cells}
|
|
281
|
+
label={ROW_LABELS[index] ?? ''}
|
|
282
|
+
ramp={ramp}
|
|
283
|
+
selected={selected?.row === index ? selected.column : -1}
|
|
284
|
+
/>
|
|
285
|
+
))}
|
|
286
|
+
</box>
|
|
287
|
+
{beside ? (
|
|
288
|
+
<>
|
|
289
|
+
<box width={PANEL_GAP} flexShrink={0} />
|
|
290
|
+
<DayPanel details={details} />
|
|
291
|
+
</>
|
|
292
|
+
) : null}
|
|
293
|
+
</box>
|
|
294
|
+
{summary == null || summary === '' ? null : (
|
|
295
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
296
|
+
{summary}
|
|
297
|
+
</text>
|
|
298
|
+
)}
|
|
299
|
+
{/* Below only when it could not go beside. The date line is the only
|
|
300
|
+
bright text down here, so the year and the day never read as one
|
|
301
|
+
paragraph. */}
|
|
302
|
+
{beside ? null : <DayFacts details={details} width={width} />}
|
|
303
|
+
</box>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { dayCost, formatUsd } from '../../../services/usage-history/cost'
|
|
2
|
+
import {
|
|
3
|
+
lastDays,
|
|
4
|
+
lateNightPrompts,
|
|
5
|
+
meanOf,
|
|
6
|
+
peakDay,
|
|
7
|
+
projectTotals,
|
|
8
|
+
SESSION_BUCKETS,
|
|
9
|
+
sessionLengths,
|
|
10
|
+
sessionStats,
|
|
11
|
+
streaks,
|
|
12
|
+
totalMean,
|
|
13
|
+
} from '../../../services/usage-history/insights'
|
|
14
|
+
import { summarizeDays } from '../../../services/usage-history/stats'
|
|
15
|
+
import { formatCompact } from '../../format-number'
|
|
16
|
+
import { chartColumns } from './chart'
|
|
17
|
+
import { formatCount, formatDayLabel, formatDuration, shortenPath, weeklyLabels } from './format'
|
|
18
|
+
import {
|
|
19
|
+
BarRow,
|
|
20
|
+
GLYPH,
|
|
21
|
+
Muted,
|
|
22
|
+
pageLayout,
|
|
23
|
+
PageNotice,
|
|
24
|
+
type PageTile,
|
|
25
|
+
recordsOf,
|
|
26
|
+
RecordsSection,
|
|
27
|
+
Section,
|
|
28
|
+
StatsPage,
|
|
29
|
+
TwoColumn,
|
|
30
|
+
VBarChart,
|
|
31
|
+
} from './shared'
|
|
32
|
+
import { isEmpty, type StatsData } from './use-stats-data'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Projects — where the work went and what a session looks like.
|
|
36
|
+
*
|
|
37
|
+
* Usage answers "how much"; this page answers "on what, and in what shape". The
|
|
38
|
+
* session-length bars are the reason it exists: a median says where the middle
|
|
39
|
+
* is, and the middle is the least interesting thing about a day made of one long
|
|
40
|
+
* session or of twenty two-minute ones.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Tall enough to read a week off, short enough to sit beside a list of bars. */
|
|
44
|
+
const CHART_HEIGHT = 7
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The median session length, or why it is zero.
|
|
48
|
+
*
|
|
49
|
+
* `formatDuration` renders nothing-at-all as an em dash, which is right for a
|
|
50
|
+
* missing measurement and wrong here: with thousands of single-prompt sessions
|
|
51
|
+
* the median really is zero, and "—" would report the most interesting fact on
|
|
52
|
+
* the page as an absence of data.
|
|
53
|
+
*/
|
|
54
|
+
function medianLabel(count: number, medianMs: number): string {
|
|
55
|
+
if (count === 0) return '\u{2014}'
|
|
56
|
+
return medianMs <= 0 ? '< 1 min' : formatDuration(medianMs)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function projectTiles(
|
|
60
|
+
projects: number,
|
|
61
|
+
branches: number,
|
|
62
|
+
sessions: number,
|
|
63
|
+
typical: string
|
|
64
|
+
): PageTile[] {
|
|
65
|
+
return [
|
|
66
|
+
{ glyph: GLYPH.projects, label: 'Projects', value: formatCount(projects) },
|
|
67
|
+
{ glyph: GLYPH.branches, label: 'Branches', value: formatCount(branches) },
|
|
68
|
+
{ glyph: GLYPH.sessions, label: 'Sessions', value: formatCount(sessions) },
|
|
69
|
+
{ glyph: GLYPH.clock, label: 'Typical', value: typical },
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ProjectsPage({ data, width }: { data: StatsData; width: number }) {
|
|
74
|
+
// First, before the derivations it would make pointless.
|
|
75
|
+
if (isEmpty(data.claude)) {
|
|
76
|
+
return <PageNotice>no history yet — the first rollup runs in the background</PageNotice>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { split, usable } = pageLayout(width)
|
|
80
|
+
const { leftWidth, rightWidth } = split
|
|
81
|
+
|
|
82
|
+
const projects = projectTotals(data.claude, 8)
|
|
83
|
+
const summary = summarizeDays(data.claude)
|
|
84
|
+
const sessions = sessionStats(data.claude)
|
|
85
|
+
const lengths = sessionLengths(data.claude)
|
|
86
|
+
const promptChars = totalMean(data.claude, (day) => day.promptChars)
|
|
87
|
+
const turns = totalMean(data.claude, (day) => day.turnMs)
|
|
88
|
+
const streak = streaks(data.claude, data.todayDate)
|
|
89
|
+
|
|
90
|
+
const projectMax = projects.entries[0]?.[1] ?? 0
|
|
91
|
+
const branchMax = summary.branches[0]?.[1] ?? 0
|
|
92
|
+
const lengthMax = Math.max(...lengths, 0)
|
|
93
|
+
const perDay = sessions.days === 0 ? 0 : sessions.count / sessions.days
|
|
94
|
+
|
|
95
|
+
const chartDays = chartColumns(rightWidth - 8)
|
|
96
|
+
const daily = lastDays(
|
|
97
|
+
data.claude,
|
|
98
|
+
chartDays,
|
|
99
|
+
data.todayDate,
|
|
100
|
+
(day) => Object.keys(day.sessions).length
|
|
101
|
+
)
|
|
102
|
+
const chartLabels = weeklyLabels(chartDays, data.todayDate)
|
|
103
|
+
|
|
104
|
+
const busiest = peakDay(data.claude, (day) => day.prompts)
|
|
105
|
+
const richest = peakDay(data.claude, (day) => day.tokens.total)
|
|
106
|
+
const priciest = peakDay(data.claude, (day) => dayCost(day).total)
|
|
107
|
+
const lateNight = lateNightPrompts(data.claude)
|
|
108
|
+
|
|
109
|
+
// Only records that exist. A row of `—` teaches nothing and reads as broken.
|
|
110
|
+
const records = recordsOf([
|
|
111
|
+
busiest.value === 0
|
|
112
|
+
? null
|
|
113
|
+
: {
|
|
114
|
+
label: 'Busiest day',
|
|
115
|
+
value: `${formatCount(busiest.value)} prompts`,
|
|
116
|
+
when: formatDayLabel(busiest.day),
|
|
117
|
+
},
|
|
118
|
+
streak.longest === 0
|
|
119
|
+
? null
|
|
120
|
+
: { label: 'Longest streak', value: `${streak.longest} days`, when: '' },
|
|
121
|
+
sessions.longestMs === 0
|
|
122
|
+
? null
|
|
123
|
+
: {
|
|
124
|
+
label: 'Longest session',
|
|
125
|
+
value: formatDuration(sessions.longestMs),
|
|
126
|
+
when: formatDayLabel(sessions.longestDay),
|
|
127
|
+
},
|
|
128
|
+
richest.value === 0
|
|
129
|
+
? null
|
|
130
|
+
: {
|
|
131
|
+
label: 'Most tokens',
|
|
132
|
+
value: `${formatCompact(richest.value)} in a day`,
|
|
133
|
+
when: formatDayLabel(richest.day),
|
|
134
|
+
},
|
|
135
|
+
priciest.value === 0
|
|
136
|
+
? null
|
|
137
|
+
: {
|
|
138
|
+
label: 'Priciest day',
|
|
139
|
+
value: `${formatUsd(priciest.value)} est.`,
|
|
140
|
+
when: formatDayLabel(priciest.day),
|
|
141
|
+
},
|
|
142
|
+
promptChars.max === 0
|
|
143
|
+
? null
|
|
144
|
+
: { label: 'Longest prompt', value: `${formatCount(promptChars.max)} chars`, when: '' },
|
|
145
|
+
lateNight === 0
|
|
146
|
+
? null
|
|
147
|
+
: {
|
|
148
|
+
label: 'Late nights',
|
|
149
|
+
value: `${formatCount(lateNight)} prompts`,
|
|
150
|
+
when: 'between 02:00 and 05:00',
|
|
151
|
+
},
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
const left = (
|
|
155
|
+
<>
|
|
156
|
+
<Section
|
|
157
|
+
glyph={GLYPH.projects}
|
|
158
|
+
title="Projects"
|
|
159
|
+
note={projects.entries.length === 0 ? '' : `${formatCount(projects.total)} prompts`}
|
|
160
|
+
width={leftWidth}
|
|
161
|
+
>
|
|
162
|
+
{projects.entries.length === 0 ? (
|
|
163
|
+
<Muted>recorded from the next rollup onward</Muted>
|
|
164
|
+
) : (
|
|
165
|
+
projects.entries.map(([path, value]) => (
|
|
166
|
+
<BarRow
|
|
167
|
+
key={path}
|
|
168
|
+
label={shortenPath(path)}
|
|
169
|
+
max={projectMax}
|
|
170
|
+
value={value}
|
|
171
|
+
valueText={formatCount(value)}
|
|
172
|
+
width={leftWidth}
|
|
173
|
+
/>
|
|
174
|
+
))
|
|
175
|
+
)}
|
|
176
|
+
</Section>
|
|
177
|
+
|
|
178
|
+
<Section
|
|
179
|
+
glyph={GLYPH.sessions}
|
|
180
|
+
title="Session length"
|
|
181
|
+
note={
|
|
182
|
+
sessions.count === 0 ? '' : `median ${medianLabel(sessions.count, sessions.medianMs)}`
|
|
183
|
+
}
|
|
184
|
+
width={leftWidth}
|
|
185
|
+
>
|
|
186
|
+
{sessions.count === 0 ? (
|
|
187
|
+
<Muted>recorded from the next rollup onward</Muted>
|
|
188
|
+
) : (
|
|
189
|
+
<>
|
|
190
|
+
{SESSION_BUCKETS.map((label, index) => (
|
|
191
|
+
<BarRow
|
|
192
|
+
key={label}
|
|
193
|
+
label={label}
|
|
194
|
+
max={lengthMax}
|
|
195
|
+
value={lengths[index] ?? 0}
|
|
196
|
+
valueText={formatCount(lengths[index] ?? 0)}
|
|
197
|
+
width={leftWidth}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
<Muted>
|
|
201
|
+
{[
|
|
202
|
+
`longest ${formatDuration(sessions.longestMs)}`,
|
|
203
|
+
sessions.singlePrompt === 0
|
|
204
|
+
? ''
|
|
205
|
+
: `${formatCount(sessions.singlePrompt)} one-prompt`,
|
|
206
|
+
turns.count === 0 ? '' : `average turn ${formatDuration(meanOf(turns))}`,
|
|
207
|
+
]
|
|
208
|
+
.filter((part) => part !== '')
|
|
209
|
+
.join(' \u{00B7} ')}
|
|
210
|
+
</Muted>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</Section>
|
|
214
|
+
</>
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const right = (
|
|
218
|
+
<>
|
|
219
|
+
<Section
|
|
220
|
+
glyph={GLYPH.branches}
|
|
221
|
+
title="Branches"
|
|
222
|
+
note={summary.branchTotal === 0 ? '' : `${formatCompact(summary.branchTotal)} tokens`}
|
|
223
|
+
width={rightWidth}
|
|
224
|
+
>
|
|
225
|
+
{summary.branches.length === 0 ? (
|
|
226
|
+
<Muted>no branch attribution recorded</Muted>
|
|
227
|
+
) : (
|
|
228
|
+
summary.branches.map(([branch, value]) => (
|
|
229
|
+
<BarRow
|
|
230
|
+
key={branch}
|
|
231
|
+
label={branch}
|
|
232
|
+
max={branchMax}
|
|
233
|
+
value={value}
|
|
234
|
+
valueText={formatCompact(value)}
|
|
235
|
+
width={rightWidth}
|
|
236
|
+
/>
|
|
237
|
+
))
|
|
238
|
+
)}
|
|
239
|
+
</Section>
|
|
240
|
+
|
|
241
|
+
<Section
|
|
242
|
+
glyph={GLYPH.calendar}
|
|
243
|
+
title="Sessions a day"
|
|
244
|
+
note={sessions.count === 0 ? '' : `${perDay.toFixed(1)} on an active day`}
|
|
245
|
+
width={rightWidth}
|
|
246
|
+
>
|
|
247
|
+
{sessions.count === 0 ? (
|
|
248
|
+
<Muted>recorded from the next rollup onward</Muted>
|
|
249
|
+
) : (
|
|
250
|
+
<>
|
|
251
|
+
<VBarChart
|
|
252
|
+
caption={`last ${chartDays} days`}
|
|
253
|
+
height={CHART_HEIGHT}
|
|
254
|
+
labels={chartLabels}
|
|
255
|
+
values={daily}
|
|
256
|
+
/>
|
|
257
|
+
<Muted>
|
|
258
|
+
{[
|
|
259
|
+
`${formatCount(sessions.count)} over ${formatCount(sessions.days)} days`,
|
|
260
|
+
promptChars.count === 0 ? '' : `${formatCount(meanOf(promptChars))} chars a prompt`,
|
|
261
|
+
]
|
|
262
|
+
.filter((part) => part !== '')
|
|
263
|
+
.join(' \u{00B7} ')}
|
|
264
|
+
</Muted>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
</Section>
|
|
268
|
+
</>
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<StatsPage
|
|
273
|
+
tiles={projectTiles(
|
|
274
|
+
projects.entries.length,
|
|
275
|
+
summary.branches.length,
|
|
276
|
+
sessions.count,
|
|
277
|
+
medianLabel(sessions.count, sessions.medianMs)
|
|
278
|
+
)}
|
|
279
|
+
usable={usable}
|
|
280
|
+
>
|
|
281
|
+
<TwoColumn split={split}>
|
|
282
|
+
{left}
|
|
283
|
+
{right}
|
|
284
|
+
</TwoColumn>
|
|
285
|
+
|
|
286
|
+
<RecordsSection
|
|
287
|
+
empty="nothing to beat yet — records appear as history accumulates"
|
|
288
|
+
records={records}
|
|
289
|
+
width={usable}
|
|
290
|
+
/>
|
|
291
|
+
</StatsPage>
|
|
292
|
+
)
|
|
293
|
+
}
|