@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,645 @@
|
|
|
1
|
+
import type { BorderSides, RGBA } from '@opentui/core'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../../theme'
|
|
5
|
+
import { truncate } from '../../truncate'
|
|
6
|
+
import { type BarShape, buildChart, buildRuler, strideOf } from './chart'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The stats screen's building blocks.
|
|
10
|
+
*
|
|
11
|
+
* Every page is the same grid: a headline row of tiles, a rule, then sections
|
|
12
|
+
* measured against the page's own width rather than against their content. A
|
|
13
|
+
* section's note lands on the same column on every page, and that shared right
|
|
14
|
+
* edge is what makes a screen read as one page instead of as blocks that happen
|
|
15
|
+
* to sit near each other.
|
|
16
|
+
*
|
|
17
|
+
* There are no tables. A table earns its place when several unrelated numbers
|
|
18
|
+
* have to be read against each other; a row of totals nobody compares is a
|
|
19
|
+
* border drawn around four numbers, and those numbers sit better on the headline
|
|
20
|
+
* row, in a fact grid, or in the chart that shows how they got there.
|
|
21
|
+
*
|
|
22
|
+
* Colour follows one rule throughout: **text wears text tokens**. Values and
|
|
23
|
+
* labels stay in `text`/`textMuted`, and a coloured mark beside them carries the
|
|
24
|
+
* meaning. The only coloured glyphs are the sequential heatmap ramp (one hue,
|
|
25
|
+
* light to dark) and the reserved status colours on quota bars.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Section glyphs, as constants rather than inline attributes: a JSX string
|
|
30
|
+
* attribute does not process `\u` escapes, so `glyph="\u{25D4}"` renders the
|
|
31
|
+
* backslash. All single-cell, text-presentation, present in the base fonts.
|
|
32
|
+
*/
|
|
33
|
+
export const GLYPH = {
|
|
34
|
+
aimux: '\u{2318}',
|
|
35
|
+
branches: '\u{2442}',
|
|
36
|
+
burn: '\u{25B3}',
|
|
37
|
+
calendar: '\u{25A4}',
|
|
38
|
+
clock: '\u{25F4}',
|
|
39
|
+
cost: '\u{0024}',
|
|
40
|
+
distance: '\u{2192}',
|
|
41
|
+
keyboard: '\u{2328}',
|
|
42
|
+
models: '\u{25C8}',
|
|
43
|
+
projects: '\u{2632}',
|
|
44
|
+
quota: '\u{25D4}',
|
|
45
|
+
records: '\u{2605}',
|
|
46
|
+
sessions: '\u{29C9}',
|
|
47
|
+
streak: '\u{25B2}',
|
|
48
|
+
tokens: '\u{25A6}',
|
|
49
|
+
week: '\u{25A6}',
|
|
50
|
+
} as const
|
|
51
|
+
|
|
52
|
+
const RULE = '\u{2500}'
|
|
53
|
+
|
|
54
|
+
/** A horizontal rule, exactly `width` cells. The page's only separator. */
|
|
55
|
+
export function Rule({ width }: { width: number }) {
|
|
56
|
+
const t = useTheme()
|
|
57
|
+
return (
|
|
58
|
+
<text fg={t.borderSubtle} selectable={false} wrapMode="none">
|
|
59
|
+
{RULE.repeat(Math.max(0, width))}
|
|
60
|
+
</text>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A section: glyph and title on the left, a note pinned to the right edge of the
|
|
66
|
+
* column, a rule under both.
|
|
67
|
+
*
|
|
68
|
+
* The rule is what does the separating — every section is the same width and
|
|
69
|
+
* every note lands on the same column, so the page reads as a grid rather than
|
|
70
|
+
* as blocks that happen to sit near each other.
|
|
71
|
+
*/
|
|
72
|
+
export function Section({
|
|
73
|
+
children,
|
|
74
|
+
glyph,
|
|
75
|
+
note,
|
|
76
|
+
rule = true,
|
|
77
|
+
title,
|
|
78
|
+
width,
|
|
79
|
+
}: {
|
|
80
|
+
children: ReactNode
|
|
81
|
+
glyph: string
|
|
82
|
+
/** A string, or a component when the note is itself a legend. */
|
|
83
|
+
note?: ReactNode
|
|
84
|
+
/** Off when the content draws its own box — a rule over a border is two lines saying one thing. */
|
|
85
|
+
rule?: boolean
|
|
86
|
+
title: string
|
|
87
|
+
width: number
|
|
88
|
+
}) {
|
|
89
|
+
const t = useTheme()
|
|
90
|
+
return (
|
|
91
|
+
<box flexDirection="column" flexShrink={0} paddingBottom={1}>
|
|
92
|
+
<box width={width} flexDirection="row" flexShrink={0}>
|
|
93
|
+
<text fg={t.primary} selectable={false} wrapMode="none">
|
|
94
|
+
{`${glyph} `}
|
|
95
|
+
</text>
|
|
96
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
97
|
+
{title}
|
|
98
|
+
</text>
|
|
99
|
+
{note != null && note !== '' ? (
|
|
100
|
+
<box flexGrow={1} flexDirection="row" justifyContent="flex-end">
|
|
101
|
+
{typeof note === 'string' ? (
|
|
102
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
103
|
+
{note}
|
|
104
|
+
</text>
|
|
105
|
+
) : (
|
|
106
|
+
note
|
|
107
|
+
)}
|
|
108
|
+
</box>
|
|
109
|
+
) : null}
|
|
110
|
+
</box>
|
|
111
|
+
{rule ? <Rule width={width} /> : null}
|
|
112
|
+
{children}
|
|
113
|
+
</box>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function Muted({ children }: { children: string }) {
|
|
118
|
+
const t = useTheme()
|
|
119
|
+
return (
|
|
120
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
121
|
+
{children}
|
|
122
|
+
</text>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The page's grid: one padding column either side, two content columns, and a
|
|
128
|
+
* three-cell gutter carrying the vertical rule.
|
|
129
|
+
*
|
|
130
|
+
* Private on purpose. Every page gets its padding from `StatsPage` and its
|
|
131
|
+
* widths from `pageLayout`, so there is one place that knows this number and no
|
|
132
|
+
* way for a page to disagree with the others about it.
|
|
133
|
+
*/
|
|
134
|
+
const PAGE_PAD = 1
|
|
135
|
+
/** The spacer, the rule, and the right column's own padding. */
|
|
136
|
+
const GUTTER = 3
|
|
137
|
+
/** Below this the two columns collide, and a collided section is worse than a tall page. */
|
|
138
|
+
const TWO_COLUMN_MIN = 92
|
|
139
|
+
|
|
140
|
+
export interface Split {
|
|
141
|
+
leftWidth: number
|
|
142
|
+
rightWidth: number
|
|
143
|
+
twoUp: boolean
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The two content columns of `usable`, or one column's worth twice over when narrow. */
|
|
147
|
+
function splitWidths(usable: number): Split {
|
|
148
|
+
const twoUp = usable >= TWO_COLUMN_MIN
|
|
149
|
+
const leftWidth = twoUp ? Math.floor((usable - GUTTER) / 2) : usable
|
|
150
|
+
return { leftWidth, rightWidth: twoUp ? usable - GUTTER - leftWidth : usable, twoUp }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface PageLayout {
|
|
154
|
+
split: Split
|
|
155
|
+
/** Columns inside the page's own padding — what every section is measured against. */
|
|
156
|
+
usable: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The widths a page lays itself out against.
|
|
161
|
+
*
|
|
162
|
+
* One function rather than three copies of the same three lines: the pages only
|
|
163
|
+
* read as one screen for as long as they agree on this arithmetic, and three
|
|
164
|
+
* copies is three chances to disagree.
|
|
165
|
+
*/
|
|
166
|
+
export function pageLayout(width: number): PageLayout {
|
|
167
|
+
const usable = Math.max(24, width - PAGE_PAD * 2)
|
|
168
|
+
return { split: splitWidths(usable), usable }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Module scope: a fresh array every render would repaint the divider each frame. */
|
|
172
|
+
const BORDER_LEFT: BorderSides[] = ['left']
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Two stacks of sections side by side, divided by a rule.
|
|
176
|
+
*
|
|
177
|
+
* The rule is drawn as a border rather than as a guessed number of rows, so it
|
|
178
|
+
* runs the full height of whichever column ends up taller.
|
|
179
|
+
*/
|
|
180
|
+
export function TwoColumn({ children, split }: { children: [ReactNode, ReactNode]; split: Split }) {
|
|
181
|
+
const t = useTheme()
|
|
182
|
+
if (!split.twoUp) {
|
|
183
|
+
return (
|
|
184
|
+
<box flexDirection="column" flexShrink={0}>
|
|
185
|
+
{children[0]}
|
|
186
|
+
{children[1]}
|
|
187
|
+
</box>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
return (
|
|
191
|
+
<box flexDirection="row" flexShrink={0}>
|
|
192
|
+
<box width={split.leftWidth} flexDirection="column" flexShrink={0}>
|
|
193
|
+
{children[0]}
|
|
194
|
+
</box>
|
|
195
|
+
<box width={1} flexShrink={0} />
|
|
196
|
+
<box
|
|
197
|
+
border={BORDER_LEFT}
|
|
198
|
+
borderColor={t.borderSubtle}
|
|
199
|
+
paddingLeft={1}
|
|
200
|
+
width={split.rightWidth + 2}
|
|
201
|
+
flexDirection="column"
|
|
202
|
+
flexShrink={0}
|
|
203
|
+
>
|
|
204
|
+
{children[1]}
|
|
205
|
+
</box>
|
|
206
|
+
</box>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Widest a fact's label is allowed to get before the value starts drifting away from it. */
|
|
211
|
+
const FACT_LABEL_MAX = 20
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Label/value pairs in columns, for the counts a chart cannot carry.
|
|
215
|
+
*
|
|
216
|
+
* Heterogeneous totals — workspaces, tabs, daemon restarts — share no scale, so
|
|
217
|
+
* a bar between them would encode a comparison nobody is making. What they need
|
|
218
|
+
* is to be readable side by side, which is a column of labels and a column of
|
|
219
|
+
* numbers and nothing else.
|
|
220
|
+
*/
|
|
221
|
+
export function FactGrid({
|
|
222
|
+
columns = 2,
|
|
223
|
+
facts,
|
|
224
|
+
width,
|
|
225
|
+
}: {
|
|
226
|
+
columns?: number
|
|
227
|
+
facts: [string, string][]
|
|
228
|
+
width: number
|
|
229
|
+
}) {
|
|
230
|
+
const t = useTheme()
|
|
231
|
+
const perColumn = Math.ceil(facts.length / columns)
|
|
232
|
+
const columnWidth = Math.floor(width / columns)
|
|
233
|
+
const labelWidth = Math.max(8, Math.min(FACT_LABEL_MAX, Math.floor(columnWidth / 2)))
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<box flexDirection="row" flexShrink={0}>
|
|
237
|
+
{Array.from({ length: columns }, (_, column) => (
|
|
238
|
+
<box
|
|
239
|
+
key={column}
|
|
240
|
+
width={columnWidth}
|
|
241
|
+
flexDirection="column"
|
|
242
|
+
flexShrink={0}
|
|
243
|
+
overflow="hidden"
|
|
244
|
+
>
|
|
245
|
+
{facts.slice(column * perColumn, (column + 1) * perColumn).map(([label, value]) => (
|
|
246
|
+
<box key={label} flexDirection="row" flexShrink={0}>
|
|
247
|
+
<box width={labelWidth} flexShrink={0}>
|
|
248
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
249
|
+
{truncate(label, labelWidth - 1)}
|
|
250
|
+
</text>
|
|
251
|
+
</box>
|
|
252
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
253
|
+
{truncate(value, columnWidth - labelWidth)}
|
|
254
|
+
</text>
|
|
255
|
+
</box>
|
|
256
|
+
))}
|
|
257
|
+
</box>
|
|
258
|
+
))}
|
|
259
|
+
</box>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface StatRecord {
|
|
264
|
+
label: string
|
|
265
|
+
value: string
|
|
266
|
+
/** Blank for a record no single day owns, like a streak or a lifetime total. */
|
|
267
|
+
when: string
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Drops the records that were not set.
|
|
272
|
+
*
|
|
273
|
+
* A page lists every record it could hold and marks the ones with nothing behind
|
|
274
|
+
* them `null`, rather than pushing into an array through a closure: what the
|
|
275
|
+
* section can contain then reads as one list instead of as a run of `if`s.
|
|
276
|
+
*/
|
|
277
|
+
export function recordsOf(entries: (StatRecord | null)[]): StatRecord[] {
|
|
278
|
+
return entries.filter((entry): entry is StatRecord => entry !== null)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* One record: what it is, what it was, and when.
|
|
283
|
+
*
|
|
284
|
+
* The same three-part geometry as a bar row — label on the left, the number in
|
|
285
|
+
* the middle, the page's right edge on the right — so a records section lines up
|
|
286
|
+
* with the bars above it instead of reading as a table someone pasted in.
|
|
287
|
+
*/
|
|
288
|
+
function RecordRow({
|
|
289
|
+
label,
|
|
290
|
+
value,
|
|
291
|
+
when,
|
|
292
|
+
width,
|
|
293
|
+
}: {
|
|
294
|
+
label: string
|
|
295
|
+
value: string
|
|
296
|
+
when: string
|
|
297
|
+
width: number
|
|
298
|
+
}) {
|
|
299
|
+
const t = useTheme()
|
|
300
|
+
const labels = Math.max(10, Math.min(24, Math.round(width / 3)))
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<box width={width} flexDirection="row" flexShrink={0}>
|
|
304
|
+
<box width={labels} flexShrink={0}>
|
|
305
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
306
|
+
{truncate(label, labels - 1)}
|
|
307
|
+
</text>
|
|
308
|
+
</box>
|
|
309
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
310
|
+
{value}
|
|
311
|
+
</text>
|
|
312
|
+
{when === '' ? null : (
|
|
313
|
+
<box flexGrow={1} flexDirection="row" justifyContent="flex-end">
|
|
314
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
315
|
+
{when}
|
|
316
|
+
</text>
|
|
317
|
+
</box>
|
|
318
|
+
)}
|
|
319
|
+
</box>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A solid bar half a cell tall, resting on a hairline track.
|
|
325
|
+
*
|
|
326
|
+
* Half height, not full: a full block fills its cell top to bottom, so stacked
|
|
327
|
+
* rows touch and seven bars merge into one shape with steps in it — you can see
|
|
328
|
+
* the outline of the data but not where one bar ends and the next begins. The
|
|
329
|
+
* empty top half of each cell is the gap between rows, for free.
|
|
330
|
+
*
|
|
331
|
+
* Solid rather than segmented, though. A repeated part-*width* block like `▍`
|
|
332
|
+
* puts a gap at the same place in every cell, and stacked those gaps line up
|
|
333
|
+
* into vertical stripes that win the eye.
|
|
334
|
+
*/
|
|
335
|
+
const BAR_FILL = '\u{2584}'
|
|
336
|
+
const BAR_TRACK = '\u{2581}'
|
|
337
|
+
|
|
338
|
+
export function Bar({
|
|
339
|
+
color,
|
|
340
|
+
max,
|
|
341
|
+
segments = 24,
|
|
342
|
+
value,
|
|
343
|
+
}: {
|
|
344
|
+
color: RGBA | string
|
|
345
|
+
max: number
|
|
346
|
+
segments?: number
|
|
347
|
+
value: number
|
|
348
|
+
}) {
|
|
349
|
+
const t = useTheme()
|
|
350
|
+
const filled =
|
|
351
|
+
max <= 0
|
|
352
|
+
? 0
|
|
353
|
+
: Math.min(segments, Math.max(value > 0 ? 1 : 0, Math.round((value / max) * segments)))
|
|
354
|
+
|
|
355
|
+
// Each half is dropped when it is empty rather than rendered as `''`: opentui
|
|
356
|
+
// floors a text node at one column (`Math.max(1, widthColsMax)`), so an empty
|
|
357
|
+
// node is a column wide. A full bar would push its value one column right and
|
|
358
|
+
// off the end of the row, and an empty one would indent its own track.
|
|
359
|
+
return (
|
|
360
|
+
<box flexDirection="row" flexShrink={0}>
|
|
361
|
+
{filled === 0 ? null : (
|
|
362
|
+
<text fg={color} selectable={false} wrapMode="none">
|
|
363
|
+
{BAR_FILL.repeat(filled)}
|
|
364
|
+
</text>
|
|
365
|
+
)}
|
|
366
|
+
{filled >= segments ? null : (
|
|
367
|
+
<text fg={t.borderSubtle} selectable={false} wrapMode="none">
|
|
368
|
+
{BAR_TRACK.repeat(segments - filled)}
|
|
369
|
+
</text>
|
|
370
|
+
)}
|
|
371
|
+
</box>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A headline tile: coloured glyph, `Label ~ value`, and optionally a tick gauge
|
|
377
|
+
* beneath. Three of these across the top of a page are its opening line.
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* A headline number in its slot on the row.
|
|
381
|
+
*
|
|
382
|
+
* Deliberately without a gauge. A bar has to be a fraction of something real —
|
|
383
|
+
* a quota, a share of a total — and "today against your best day ever" is not
|
|
384
|
+
* that: the record is not a target, so the bar would encode a comparison nobody
|
|
385
|
+
* is making and read as progress toward nothing.
|
|
386
|
+
*/
|
|
387
|
+
function StatTile({
|
|
388
|
+
glyph,
|
|
389
|
+
label,
|
|
390
|
+
value,
|
|
391
|
+
width,
|
|
392
|
+
}: {
|
|
393
|
+
glyph: string
|
|
394
|
+
label: string
|
|
395
|
+
value: string
|
|
396
|
+
/** The tile's slot on the row — equal slots are what line the tiles up. */
|
|
397
|
+
width: number
|
|
398
|
+
}) {
|
|
399
|
+
const t = useTheme()
|
|
400
|
+
return (
|
|
401
|
+
<box width={width} flexShrink={0} flexDirection="row">
|
|
402
|
+
<text fg={t.primary} selectable={false} wrapMode="none">
|
|
403
|
+
{`${glyph} `}
|
|
404
|
+
</text>
|
|
405
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
406
|
+
{`${label} `}
|
|
407
|
+
</text>
|
|
408
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
409
|
+
{value}
|
|
410
|
+
</text>
|
|
411
|
+
</box>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface PageTile {
|
|
416
|
+
glyph: string
|
|
417
|
+
label: string
|
|
418
|
+
value: string
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Every page's frame: the headline row, the rule under it, and the padding they
|
|
423
|
+
* both sit in.
|
|
424
|
+
*
|
|
425
|
+
* The pages differ in what they measure and agree on how they are laid out, so
|
|
426
|
+
* the layout lives here. Left to each page, the agreement held only for as long
|
|
427
|
+
* as nobody edited one of them — which is how the three drifted apart the first
|
|
428
|
+
* time.
|
|
429
|
+
*/
|
|
430
|
+
export function StatsPage({
|
|
431
|
+
children,
|
|
432
|
+
tiles,
|
|
433
|
+
usable,
|
|
434
|
+
}: {
|
|
435
|
+
children: ReactNode
|
|
436
|
+
tiles: PageTile[]
|
|
437
|
+
usable: number
|
|
438
|
+
}) {
|
|
439
|
+
const tileWidth = Math.floor(usable / Math.max(1, tiles.length))
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
<box flexDirection="column" paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
|
|
443
|
+
<box flexDirection="row" flexShrink={0}>
|
|
444
|
+
{tiles.map((tile, index) => (
|
|
445
|
+
<StatTile
|
|
446
|
+
key={tile.label}
|
|
447
|
+
glyph={tile.glyph}
|
|
448
|
+
label={tile.label}
|
|
449
|
+
value={tile.value}
|
|
450
|
+
// The last slot takes whatever the division left over, so the row
|
|
451
|
+
// ends on the page's right edge rather than a column or two short.
|
|
452
|
+
width={index === tiles.length - 1 ? usable - tileWidth * (tiles.length - 1) : tileWidth}
|
|
453
|
+
/>
|
|
454
|
+
))}
|
|
455
|
+
</box>
|
|
456
|
+
<box paddingTop={1} paddingBottom={1} flexShrink={0}>
|
|
457
|
+
<Rule width={usable} />
|
|
458
|
+
</box>
|
|
459
|
+
{children}
|
|
460
|
+
</box>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** A whole page that has nothing to show yet, in the page's own padding. */
|
|
465
|
+
export function PageNotice({ children }: { children: string }) {
|
|
466
|
+
return (
|
|
467
|
+
<box paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
|
|
468
|
+
<Muted>{children}</Muted>
|
|
469
|
+
</box>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const AXIS_WALL = '\u{2502}'
|
|
474
|
+
const AXIS_FOOT = '\u{2514}'
|
|
475
|
+
const AXIS_FLOOR = '\u{2500}'
|
|
476
|
+
/** The wall and the space between it and the first column. */
|
|
477
|
+
const AXIS_GUTTER = 2
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* A column chart: an integer axis down the left, a rule under the columns, and
|
|
481
|
+
* a caption beneath that.
|
|
482
|
+
*
|
|
483
|
+
* The L of wall and floor is what makes the columns read as measured against
|
|
484
|
+
* something. Without it a column chart is a row of blocks floating on the
|
|
485
|
+
* surface, and the eye has nothing to judge a height against. Both are drawn
|
|
486
|
+
* one shade off the surface, so they frame the data without competing with it.
|
|
487
|
+
*/
|
|
488
|
+
export function VBarChart({
|
|
489
|
+
bar,
|
|
490
|
+
caption,
|
|
491
|
+
format,
|
|
492
|
+
height = 10,
|
|
493
|
+
labels,
|
|
494
|
+
values,
|
|
495
|
+
}: {
|
|
496
|
+
/** Bar width and gap. Defaults to the two-wide, one-gap day column. */
|
|
497
|
+
bar?: BarShape
|
|
498
|
+
caption?: string
|
|
499
|
+
format?: (value: number) => string
|
|
500
|
+
height?: number
|
|
501
|
+
/** One per bar, blank where a bar goes unlabelled. */
|
|
502
|
+
labels?: string[]
|
|
503
|
+
values: number[]
|
|
504
|
+
}) {
|
|
505
|
+
const t = useTheme()
|
|
506
|
+
const chart = buildChart(values, height, format, bar)
|
|
507
|
+
const chartWidth = chart.bars[0]?.length ?? 0
|
|
508
|
+
const axisWidth = chart.axis[0]?.length ?? 0
|
|
509
|
+
const captionPad =
|
|
510
|
+
axisWidth + AXIS_GUTTER + Math.max(0, Math.floor((chartWidth - (caption?.length ?? 0)) / 2))
|
|
511
|
+
|
|
512
|
+
return (
|
|
513
|
+
<box flexDirection="column" flexShrink={0}>
|
|
514
|
+
{chart.bars.map((line, index) => (
|
|
515
|
+
<box key={`${String(index)}:${line}`} flexDirection="row" flexShrink={0}>
|
|
516
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
517
|
+
{chart.axis[index] ?? ''}
|
|
518
|
+
</text>
|
|
519
|
+
<text fg={t.borderSubtle} selectable={false} wrapMode="none">
|
|
520
|
+
{`${AXIS_WALL} `}
|
|
521
|
+
</text>
|
|
522
|
+
<text fg={t.primary} selectable={false} wrapMode="none">
|
|
523
|
+
{line}
|
|
524
|
+
</text>
|
|
525
|
+
</box>
|
|
526
|
+
))}
|
|
527
|
+
<box flexDirection="row" flexShrink={0}>
|
|
528
|
+
<text fg={t.borderSubtle} selectable={false} wrapMode="none">
|
|
529
|
+
{`${' '.repeat(axisWidth)}${AXIS_FOOT}${AXIS_FLOOR.repeat(chartWidth + 1)}`}
|
|
530
|
+
</text>
|
|
531
|
+
</box>
|
|
532
|
+
{labels === undefined ? null : (
|
|
533
|
+
<box paddingLeft={axisWidth + AXIS_GUTTER} flexShrink={0}>
|
|
534
|
+
<Muted>{buildRuler(labels, strideOf(bar))}</Muted>
|
|
535
|
+
</box>
|
|
536
|
+
)}
|
|
537
|
+
{caption === undefined ? null : (
|
|
538
|
+
<box paddingLeft={captionPad} flexShrink={0}>
|
|
539
|
+
<Muted>{caption}</Muted>
|
|
540
|
+
</box>
|
|
541
|
+
)}
|
|
542
|
+
</box>
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The column every bar row's value ends on. */
|
|
547
|
+
const VALUE_WIDTH = 10
|
|
548
|
+
/**
|
|
549
|
+
* Longest a bar is allowed to get, however wide the terminal.
|
|
550
|
+
*
|
|
551
|
+
* Past this the bar stops carrying information and starts carrying distance:
|
|
552
|
+
* comparing two lengths gets harder as they grow, and on a wide terminal a row
|
|
553
|
+
* that fills its column separates its own label from its own value.
|
|
554
|
+
*/
|
|
555
|
+
const BAR_MAX = 32
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* A labelled magnitude bar, scaled against the largest value in its group.
|
|
559
|
+
*
|
|
560
|
+
* The same tick mark as the quota gauges, so one mark means one thing across
|
|
561
|
+
* the screen: a proportion. It carries the value at its tip and nothing else —
|
|
562
|
+
* a second number per row would be read against a different denominator than
|
|
563
|
+
* the bar (share of the total, where the bar is scaled to the largest), which
|
|
564
|
+
* is two scales in one row.
|
|
565
|
+
*/
|
|
566
|
+
export function BarRow({
|
|
567
|
+
barWidth = 12,
|
|
568
|
+
label,
|
|
569
|
+
labelWidth = 14,
|
|
570
|
+
max,
|
|
571
|
+
value,
|
|
572
|
+
valueText,
|
|
573
|
+
width,
|
|
574
|
+
}: {
|
|
575
|
+
barWidth?: number
|
|
576
|
+
label: string
|
|
577
|
+
labelWidth?: number
|
|
578
|
+
max: number
|
|
579
|
+
value: number
|
|
580
|
+
valueText: string
|
|
581
|
+
/** Fill exactly this many columns — the bar takes whatever the label and value leave. */
|
|
582
|
+
width?: number
|
|
583
|
+
}) {
|
|
584
|
+
const t = useTheme()
|
|
585
|
+
const labels = width === undefined ? labelWidth : Math.max(6, Math.min(20, Math.round(width / 3)))
|
|
586
|
+
const room = width === undefined ? barWidth : Math.max(4, width - labels - VALUE_WIDTH)
|
|
587
|
+
const bars = Math.min(BAR_MAX, room)
|
|
588
|
+
// Whatever the cap leaves over goes between the bar and its value, so the
|
|
589
|
+
// values stay on the column's right edge and every row still ends together.
|
|
590
|
+
const slack = room - bars
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<box flexDirection="row" flexShrink={0}>
|
|
594
|
+
<box width={labels} flexShrink={0}>
|
|
595
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
596
|
+
{truncate(label, labels - 1)}
|
|
597
|
+
</text>
|
|
598
|
+
</box>
|
|
599
|
+
<Bar color={t.primary} max={max} segments={bars} value={value} />
|
|
600
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
601
|
+
{valueText.padStart(VALUE_WIDTH + slack)}
|
|
602
|
+
</text>
|
|
603
|
+
</box>
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The records a page closes on.
|
|
609
|
+
*
|
|
610
|
+
* A records page of its own was mostly empty and every row on it belonged to
|
|
611
|
+
* data another page already showed, so each page keeps its own — which only
|
|
612
|
+
* works if they all draw it the same way.
|
|
613
|
+
*/
|
|
614
|
+
export function RecordsSection({
|
|
615
|
+
empty,
|
|
616
|
+
records,
|
|
617
|
+
width,
|
|
618
|
+
}: {
|
|
619
|
+
empty: string
|
|
620
|
+
records: StatRecord[]
|
|
621
|
+
width: number
|
|
622
|
+
}) {
|
|
623
|
+
return (
|
|
624
|
+
<Section
|
|
625
|
+
glyph={GLYPH.records}
|
|
626
|
+
title="Records"
|
|
627
|
+
note={records.length === 0 ? '' : `${records.length} set`}
|
|
628
|
+
width={width}
|
|
629
|
+
>
|
|
630
|
+
{records.length === 0 ? (
|
|
631
|
+
<Muted>{empty}</Muted>
|
|
632
|
+
) : (
|
|
633
|
+
records.map((record) => (
|
|
634
|
+
<RecordRow
|
|
635
|
+
key={record.label}
|
|
636
|
+
label={record.label}
|
|
637
|
+
value={record.value}
|
|
638
|
+
when={record.when}
|
|
639
|
+
width={width}
|
|
640
|
+
/>
|
|
641
|
+
))
|
|
642
|
+
)}
|
|
643
|
+
</Section>
|
|
644
|
+
)
|
|
645
|
+
}
|