@brimveyn/aimux 1.22.12 → 1.23.1

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 (66) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/settings-actions.ts +24 -13
  3. package/src/app-runtime/side-effects.ts +5 -0
  4. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  5. package/src/app.tsx +6 -0
  6. package/src/index.tsx +6 -0
  7. package/src/input/keymap/help-entries.ts +1 -0
  8. package/src/input/modes/bridge.ts +2 -1
  9. package/src/input/modes/transitions.ts +7 -3
  10. package/src/input/modes/types.ts +2 -1
  11. package/src/restart-daemon.ts +5 -0
  12. package/src/services/ai-usage/projection.ts +44 -0
  13. package/src/services/aimux-counters/index.ts +87 -0
  14. package/src/services/aimux-counters/observe.ts +39 -0
  15. package/src/services/aimux-counters/store.ts +150 -0
  16. package/src/services/aimux-counters/summary.ts +78 -0
  17. package/src/services/usage-history/cost.ts +149 -0
  18. package/src/services/usage-history/insights.ts +314 -0
  19. package/src/services/usage-history/rollup.ts +78 -6
  20. package/src/services/usage-history/stats.ts +66 -35
  21. package/src/services/usage-history/store.ts +128 -9
  22. package/src/settings/search.ts +13 -9
  23. package/src/settings/sections/about.ts +1 -0
  24. package/src/settings/sections/appearance.ts +1 -0
  25. package/src/settings/sections/automation.ts +1 -0
  26. package/src/settings/sections/commands.ts +1 -0
  27. package/src/settings/sections/editor.ts +1 -0
  28. package/src/settings/sections/experimental.ts +1 -0
  29. package/src/settings/sections/git.ts +1 -0
  30. package/src/settings/sections/index.ts +23 -12
  31. package/src/settings/sections/integrations.ts +1 -0
  32. package/src/settings/sections/layout.ts +1 -0
  33. package/src/settings/sections/notifications.ts +1 -0
  34. package/src/settings/sections/setup.ts +1 -0
  35. package/src/settings/sections/status-bar.ts +1 -0
  36. package/src/settings/sections/workspace.ts +1 -0
  37. package/src/settings/types.ts +10 -0
  38. package/src/state/actions.ts +13 -3
  39. package/src/state/app-store.ts +12 -1
  40. package/src/state/reducers/modal-state.ts +12 -17
  41. package/src/state/reducers/settings-state.ts +29 -51
  42. package/src/state/reducers/stats-state.ts +56 -0
  43. package/src/state/stats-pages.ts +33 -0
  44. package/src/state/store.ts +5 -0
  45. package/src/state/types.ts +22 -7
  46. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  47. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  48. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  49. package/src/ui/components/settings/row-value.tsx +22 -3
  50. package/src/ui/components/settings/settings-footer.tsx +170 -0
  51. package/src/ui/components/settings/settings-row.tsx +109 -33
  52. package/src/ui/components/settings/settings-search-bar.tsx +61 -0
  53. package/src/ui/components/settings/settings-view.tsx +163 -77
  54. package/src/ui/components/stats/aimux-page.tsx +245 -0
  55. package/src/ui/components/stats/chart.ts +157 -0
  56. package/src/ui/components/stats/day-facts.tsx +268 -0
  57. package/src/ui/components/stats/format.ts +98 -0
  58. package/src/ui/components/stats/heatmap.tsx +305 -0
  59. package/src/ui/components/stats/projects-page.tsx +293 -0
  60. package/src/ui/components/stats/quotas.tsx +210 -0
  61. package/src/ui/components/stats/shared.tsx +654 -0
  62. package/src/ui/components/stats/stats-view.tsx +153 -0
  63. package/src/ui/components/stats/usage-page.tsx +291 -0
  64. package/src/ui/components/stats/use-stats-data.ts +48 -0
  65. package/src/ui/root.tsx +7 -3
  66. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The vertical bar chart as pure string building, like `table.ts`: an off-by-one
3
+ * in the row thresholds draws bars that do not sit on the baseline, and only a
4
+ * string-level test catches that.
5
+ */
6
+
7
+ /**
8
+ * Two columns of bar, one of gap.
9
+ *
10
+ * A one-column bar packs twice the days in, but a bar you cannot point at is
11
+ * not worth the days: at that width there is no room to label which day is
12
+ * which, and every column looks like every other. Two columns is wide enough
13
+ * to carry a date ruler underneath and to read a single day off the chart.
14
+ *
15
+ * The cap is LOWER HALF BLOCK — the body's width, half its height — so a column
16
+ * can end part-way up a row. Twenty levels over a ten-row chart.
17
+ */
18
+ const SOLID = '\u{2588}'
19
+ const HALF = '\u{2584}'
20
+
21
+ export interface BarShape {
22
+ /** Blank columns between bars. Zero packs a distribution into one silhouette. */
23
+ gap: number
24
+ /** Columns of bar. */
25
+ width: number
26
+ }
27
+
28
+ const DEFAULT_SHAPE: BarShape = { gap: 1, width: 2 }
29
+ /** Bar plus gap: the columns one day occupies. */
30
+ export const CHART_STRIDE = DEFAULT_SHAPE.width + DEFAULT_SHAPE.gap
31
+
32
+ export function strideOf(shape: BarShape = DEFAULT_SHAPE): number {
33
+ return shape.width + shape.gap
34
+ }
35
+
36
+ /**
37
+ * The widest shape whose `count` bars fit in `room` columns.
38
+ *
39
+ * A series with a fixed number of bars — the 24 hours of a day — cannot drop
40
+ * columns to fit a narrow terminal the way a rolling window can, so it gives up
41
+ * the gap first and the second column of bar only after that. The last rung
42
+ * always fits something, even when it overflows: a squeezed chart beats none.
43
+ */
44
+ const NARROWEST: BarShape = { gap: 0, width: 1 }
45
+
46
+ export function fitShape(count: number, room: number): BarShape {
47
+ const ladder: BarShape[] = [DEFAULT_SHAPE, { gap: 0, width: 2 }, { gap: 1, width: 1 }, NARROWEST]
48
+ return (
49
+ ladder.find((shape) => count * shape.width + Math.max(0, count - 1) * shape.gap <= room) ??
50
+ NARROWEST
51
+ )
52
+ }
53
+
54
+ export interface ChartLines {
55
+ /** One label per row, top first, all the same width; blank on unlabelled rows. */
56
+ axis: string[]
57
+ /** One string per row, top first — bar columns separated by gaps. */
58
+ bars: string[]
59
+ /** The value the top of the chart stands for. */
60
+ niceMax: number
61
+ }
62
+
63
+ /** How many bars fit in `width` columns, capped at `max` days. */
64
+ export function chartColumns(width: number, max = 45): number {
65
+ return Math.max(4, Math.min(max, Math.floor(width / CHART_STRIDE)))
66
+ }
67
+
68
+ /**
69
+ * A ruler under the columns: each label sits under the bar it belongs to.
70
+ *
71
+ * Labels come in one per bar, blank where a bar goes unlabelled — the caller
72
+ * decides the interval, because how often a date is worth repeating depends on
73
+ * what the dates are. A label that would run into the one before it is dropped
74
+ * rather than overlapping.
75
+ */
76
+ export function buildRuler(labels: string[], stride = CHART_STRIDE): string {
77
+ const chars: string[] = Array.from({ length: labels.length * stride }, () => ' ')
78
+ let usedUpTo = 0
79
+ for (const [index, label] of labels.entries()) {
80
+ if (label === '') continue
81
+ // Pulled back from the right edge rather than dropped: the last bar is
82
+ // today, and today is the one date on the ruler worth reading.
83
+ const offset = Math.min(index * stride, chars.length - label.length)
84
+ if (offset < usedUpTo) continue
85
+ for (let position = 0; position < label.length; position++) {
86
+ chars[offset + position] = label[position] ?? ' '
87
+ }
88
+ usedUpTo = offset + label.length + 1
89
+ }
90
+ return chars.join('')
91
+ }
92
+
93
+ /**
94
+ * The smallest 1, 2 or 5 times a power of ten that covers `raw`.
95
+ *
96
+ * Without this the step is whatever `max / height` happens to be, and the axis
97
+ * reads 798K, 399K — numbers that look like measurements when they are only
98
+ * arithmetic. A reader should be able to tell a bar's value from the axis, which
99
+ * needs gridlines on numbers worth counting in.
100
+ */
101
+ function niceStep(raw: number): number {
102
+ if (raw <= 0) return 1
103
+ const magnitude = 10 ** Math.floor(Math.log10(raw))
104
+ for (const multiple of [1, 2, 5]) {
105
+ if (multiple * magnitude >= raw) return multiple * magnitude
106
+ }
107
+ return 10 * magnitude
108
+ }
109
+
110
+ /**
111
+ * Scale the chart so every axis label is a round number: the max rounds up to a
112
+ * multiple of the height, and each row is worth exactly `niceMax / height`.
113
+ *
114
+ * `format` is for series whose raw numbers are unreadable on an axis — millions
115
+ * of tokens want `2.4M`, not `2400000`, which would be wider than the chart.
116
+ */
117
+ export function buildChart(
118
+ values: number[],
119
+ height = 10,
120
+ format: (value: number) => string = String,
121
+ shape: BarShape = DEFAULT_SHAPE
122
+ ): ChartLines {
123
+ const body = SOLID.repeat(shape.width)
124
+ const cap = HALF.repeat(shape.width)
125
+ const blank = ' '.repeat(shape.width)
126
+ const spacer = ' '.repeat(shape.gap)
127
+ const max = Math.max(...values, 0)
128
+ // A floor of one: these are counts, so half a prompt is not a gridline.
129
+ const step = Math.max(1, niceStep(max / height))
130
+ const niceMax = step * height
131
+ const axisWidth = Math.max(
132
+ ...Array.from({ length: height }, (_, row) => format(step * (row + 1)).length)
133
+ )
134
+
135
+ const axis: string[] = []
136
+ const bars: string[] = []
137
+ for (let row = height; row >= 1; row--) {
138
+ const labelled = row === height || row % 2 === 0
139
+ axis.push(labelled ? format(step * row).padStart(axisWidth) : ' '.repeat(axisWidth))
140
+ bars.push(
141
+ values
142
+ .map((value) => {
143
+ // How much of *this* row the column fills, 0 to 1, rounded to the
144
+ // nearest half — the two levels the body and the cap can express.
145
+ const fill = Math.min(1, Math.max(0, (value / niceMax) * height - (row - 1)))
146
+ if (fill >= 0.75) return body
147
+ if (fill >= 0.25) return cap
148
+ // The bottom row keeps any non-zero day visible: a recorded day that
149
+ // renders as nothing reads as a gap in the data rather than as a
150
+ // quiet day.
151
+ return row === 1 && value > 0 ? cap : blank
152
+ })
153
+ .join(spacer)
154
+ )
155
+ }
156
+ return { axis, bars, niceMax }
157
+ }
@@ -0,0 +1,268 @@
1
+ import type { UsageDay } from '../../../services/usage-history/store'
2
+
3
+ import { dayCost, formatUsd } from '../../../services/usage-history/cost'
4
+ import { formatCompact } from '../../format-number'
5
+ import { useTheme } from '../../theme'
6
+ import { truncate } from '../../truncate'
7
+ import { formatClock, formatCount, formatDuration, shortenPath } from './format'
8
+
9
+ /**
10
+ * Everything recorded about one day, for the readout under the calendar.
11
+ *
12
+ * The heatmap encodes a single number as a shade; this is where the rest of the
13
+ * day lives. Rows that have nothing to say are omitted rather than shown as a
14
+ * dash — an old day whose transcripts have been pruned says what it still knows
15
+ * and nothing more.
16
+ */
17
+
18
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
19
+ const MONTHS = [
20
+ 'January',
21
+ 'February',
22
+ 'March',
23
+ 'April',
24
+ 'May',
25
+ 'June',
26
+ 'July',
27
+ 'August',
28
+ 'September',
29
+ 'October',
30
+ 'November',
31
+ 'December',
32
+ ]
33
+
34
+ /** `2026-03-14` as `Saturday 14 March` — the popover has room for the long form. */
35
+ function longDate(key: string): string {
36
+ const [year, month, day] = key.split('-').map(Number)
37
+ const date = new Date(year ?? 0, (month ?? 1) - 1, day ?? 1)
38
+ return `${WEEKDAYS[date.getDay()] ?? ''} ${day ?? ''} ${MONTHS[(month ?? 1) - 1] ?? ''}`
39
+ }
40
+
41
+ /** The largest entry of a `name -> number` map, with its share of the total. */
42
+ function top(counts: Record<string, number>): { name: string; share: number } | null {
43
+ let total = 0
44
+ let best: [string, number] | null = null
45
+ for (const entry of Object.entries(counts)) {
46
+ total += entry[1]
47
+ if (best === null || entry[1] > best[1]) best = entry
48
+ }
49
+ if (best === null || total <= 0) return null
50
+ return { name: best[0], share: Math.round((best[1] / total) * 100) }
51
+ }
52
+
53
+ /** First prompt to last, and the time actually inside a session — not their gap. */
54
+ function spanOf(day: UsageDay): string {
55
+ let first = Number.POSITIVE_INFINITY
56
+ let last = 0
57
+ let ms = 0
58
+ for (const session of Object.values(day.sessions)) {
59
+ first = Math.min(first, session.first)
60
+ last = Math.max(last, session.last)
61
+ ms += Math.max(0, session.last - session.first)
62
+ }
63
+ if (!Number.isFinite(first)) return ''
64
+ const clockOf = (epoch: number): string => {
65
+ const date = new Date(epoch)
66
+ return formatClock(date.getHours() * 60 + date.getMinutes())
67
+ }
68
+ return `${clockOf(first)} \u{2192} ${clockOf(last)} \u{00B7} ${formatDuration(ms)}`
69
+ }
70
+
71
+ export interface DayDetails {
72
+ rows: [string, string][]
73
+ title: string
74
+ }
75
+
76
+ /**
77
+ * A day always has a title, even with nothing behind it.
78
+ *
79
+ * The readout follows the pointer, so a day the calendar draws but never
80
+ * recorded — a quiet one inside the covered range — has to say so under its own
81
+ * date rather than blank the slot as the pointer crosses it.
82
+ */
83
+ export function dayDetails(key: string, day: UsageDay | undefined): DayDetails {
84
+ if (day === undefined) return { rows: [], title: longDate(key) }
85
+
86
+ const rows: [string, string][] = []
87
+ const add = (label: string, value: string): void => {
88
+ if (value !== '') rows.push([label, value])
89
+ }
90
+
91
+ add('Prompts', day.prompts > 0 ? formatCount(day.prompts) : '')
92
+
93
+ const busiest = day.hours.indexOf(Math.max(...day.hours, 0))
94
+ if (day.hours.length > 0 && (day.hours[busiest] ?? 0) > 0) {
95
+ add(
96
+ 'Busiest hour',
97
+ `${formatClock(busiest * 60)} \u{00B7} ${formatCount(day.hours[busiest] ?? 0)}`
98
+ )
99
+ }
100
+ add('Active', spanOf(day))
101
+
102
+ const sessions = Object.keys(day.sessions).length
103
+ add('Sessions', sessions > 0 ? formatCount(sessions) : '')
104
+ if (day.promptChars.count > 0) {
105
+ add('Prompt length', `${Math.round(day.promptChars.sum / day.promptChars.count)} chars average`)
106
+ }
107
+ if (day.turnMs.count > 0) {
108
+ add('Average turn', formatDuration(day.turnMs.sum / day.turnMs.count))
109
+ }
110
+
111
+ if (day.tokens.total > 0) {
112
+ add('Tokens', formatCompact(day.tokens.total))
113
+ add(
114
+ 'In / out',
115
+ `${formatCompact(day.tokens.input)} \u{00B7} ${formatCompact(day.tokens.output)}`
116
+ )
117
+ add('Cache read', formatCompact(day.tokens.cacheRead))
118
+ add('Cost', `${formatUsd(dayCost(day).total)} estimated`)
119
+ }
120
+
121
+ const model = top(day.models)
122
+ if (model !== null)
123
+ add('Top model', `${model.name.replace(/^claude-/, '')} \u{00B7} ${model.share}%`)
124
+ const project = top(day.projects)
125
+ if (project !== null)
126
+ add('Top project', `${shortenPath(project.name)} \u{00B7} ${project.share}%`)
127
+ const branch = top(day.branches)
128
+ if (branch !== null) add('Top branch', `${branch.name} \u{00B7} ${branch.share}%`)
129
+
130
+ return { rows, title: longDate(key) }
131
+ }
132
+
133
+ const SEPARATOR = ' \u{00B7} '
134
+
135
+ /**
136
+ * The day's facts as running lines rather than a table.
137
+ *
138
+ * The page already reads this way — the calendar's own summary and the token
139
+ * chart's caption are both `a · b · c` — and a readout that changes on every
140
+ * mouse move must not redraw as a block of shifting columns. Fourteen labelled
141
+ * numbers wrapped into three lines settle into the same shape whichever day
142
+ * they describe.
143
+ */
144
+ export function packFacts(rows: [string, string][], width: number, maxLines = 3): string[] {
145
+ const lines: string[] = []
146
+ let line = ''
147
+ for (const [label, value] of rows) {
148
+ const fact = `${label} ${value}`
149
+ if (line === '') {
150
+ line = fact
151
+ continue
152
+ }
153
+ // ponytail: the last line takes the remainder and truncates rather than
154
+ // dropping facts. Three lines hold a full day past about 90 columns, and
155
+ // the calendar itself needs more than that to be worth reading.
156
+ if (line.length + SEPARATOR.length + fact.length > width && lines.length < maxLines - 1) {
157
+ lines.push(line)
158
+ line = fact
159
+ continue
160
+ }
161
+ line += SEPARATOR + fact
162
+ }
163
+ if (line !== '') lines.push(line)
164
+ return lines
165
+ }
166
+
167
+ /** Rows reserved under the calendar: the date, then the facts. */
168
+ export const FACT_LINES = 3
169
+ export const DAY_FACTS_HEIGHT = 1 + FACT_LINES
170
+
171
+ /**
172
+ * The readout under the calendar, for when there is no room beside it.
173
+ *
174
+ * Running lines rather than columns: at this width a label/value grid would be
175
+ * mostly gap, and the height is fixed so nothing below shifts as the pointer
176
+ * crosses the year.
177
+ */
178
+ export function DayFacts({ details, width }: { details: DayDetails; width: number }) {
179
+ const t = useTheme()
180
+ const lines = details.rows.length === 0 ? ['nothing recorded'] : packFacts(details.rows, width)
181
+
182
+ return (
183
+ <box height={DAY_FACTS_HEIGHT} flexDirection="column" flexShrink={0}>
184
+ <text fg={t.text} selectable={false} wrapMode="none">
185
+ {details.title}
186
+ </text>
187
+ {lines.map((line) => (
188
+ <text key={line} fg={t.textMuted} selectable={false} wrapMode="none">
189
+ {truncate(line, width)}
190
+ </text>
191
+ ))}
192
+ </box>
193
+ )
194
+ }
195
+
196
+ /**
197
+ * Two columns, sized so the panel is no taller than the calendar it stands
198
+ * beside — seven rows of facts inside a border is nine, against the grid's
199
+ * eleven. One column would be fourteen rows and would set the height of the
200
+ * whole section.
201
+ */
202
+ const LABEL_WIDTH = 14
203
+ const VALUE_WIDTH = 22
204
+ /** Between the two columns: the longest value fills its width exactly. */
205
+ const COLUMN_GAP = 2
206
+ const COLUMN_WIDTH = LABEL_WIDTH + VALUE_WIDTH + COLUMN_GAP
207
+ /** Border and padding, both sides. */
208
+ const CHROME = 4
209
+ export const PANEL_WIDTH = COLUMN_WIDTH * 2 + CHROME
210
+ /** The calendar's height, so the panel's own rows can be padded up to it. */
211
+ const PANEL_ROWS = 7
212
+
213
+ function Column({ rows }: { rows: [string, string][] }) {
214
+ const t = useTheme()
215
+ return (
216
+ <box width={COLUMN_WIDTH} flexDirection="column" flexShrink={0}>
217
+ {rows.map(([label, value]) => (
218
+ <box key={`${label}:${value}`} flexDirection="row" flexShrink={0}>
219
+ <box width={LABEL_WIDTH} flexShrink={0}>
220
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
221
+ {label}
222
+ </text>
223
+ </box>
224
+ <text fg={t.text} selectable={false} wrapMode="none">
225
+ {truncate(value, VALUE_WIDTH)}
226
+ </text>
227
+ </box>
228
+ ))}
229
+ </box>
230
+ )
231
+ }
232
+
233
+ /**
234
+ * The readout beside the calendar.
235
+ *
236
+ * Beside rather than below or over it: the calendar is bounded by what has been
237
+ * recorded, so it rarely fills the pane, and the room to its right was going
238
+ * spare. Nothing is covered, nothing has to be dismissed, and the labels line
239
+ * up in a column instead of running together as prose.
240
+ *
241
+ * The date rides the border as a title, so no row has to double as a heading.
242
+ */
243
+ export function DayPanel({ details }: { details: DayDetails }) {
244
+ const t = useTheme()
245
+ // Padded to a constant height: a bare day and a full one must not resize the
246
+ // panel as the pointer moves between them.
247
+ const rows: [string, string][] =
248
+ details.rows.length === 0 ? [['', 'nothing recorded']] : details.rows
249
+ const perColumn = Math.max(PANEL_ROWS, Math.ceil(rows.length / 2))
250
+
251
+ return (
252
+ <box
253
+ border
254
+ borderStyle="rounded"
255
+ borderColor={t.border}
256
+ title={details.title}
257
+ paddingLeft={1}
258
+ paddingRight={1}
259
+ width={PANEL_WIDTH}
260
+ height={PANEL_ROWS + 2}
261
+ flexDirection="row"
262
+ flexShrink={0}
263
+ >
264
+ <Column rows={rows.slice(0, perColumn)} />
265
+ <Column rows={rows.slice(perColumn)} />
266
+ </box>
267
+ )
268
+ }
@@ -0,0 +1,98 @@
1
+ import { localDay } from '../../../services/usage-history/store'
2
+
3
+ /**
4
+ * Number and time formatting for the stats screen.
5
+ *
6
+ * Separate from `format-number.ts`, which is the shared token formatter: these
7
+ * are durations, clock times and dates that only this screen renders.
8
+ */
9
+
10
+ const MINUTES_IN_HOUR = 60
11
+ const MS_PER_SECOND = 1000
12
+ const MS_PER_MINUTE = 60 * MS_PER_SECOND
13
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE
14
+
15
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
16
+
17
+ /** Thin spaces rather than commas, matching the History page's existing totals. */
18
+ export function formatCount(value: number): string {
19
+ return Math.round(value).toLocaleString('en-US').replaceAll(',', ' ')
20
+ }
21
+
22
+ /** `9h 04` / `22 min` / `41 s`. Picks the largest unit that is not a rounding lie. */
23
+ export function formatDuration(ms: number): string {
24
+ if (ms <= 0) return '—'
25
+ if (ms < MS_PER_MINUTE) return `${Math.round(ms / MS_PER_SECOND)} s`
26
+ if (ms < MS_PER_HOUR) return `${Math.round(ms / MS_PER_MINUTE)} min`
27
+ const hours = Math.floor(ms / MS_PER_HOUR)
28
+ const minutes = Math.round((ms % MS_PER_HOUR) / MS_PER_MINUTE)
29
+ return `${hours}h ${String(minutes).padStart(2, '0')}`
30
+ }
31
+
32
+ /**
33
+ * A lifetime span as `78d 23h`.
34
+ *
35
+ * `formatDuration` counts in hours all the way up, which is right for a session
36
+ * and useless for a total: `1894h 58` is a number nobody reads as an amount of
37
+ * time. Two days is where the hour stops being the unit that means something.
38
+ */
39
+ export function formatSpan(ms: number): string {
40
+ const hours = ms / MS_PER_HOUR
41
+ if (hours < 48) return formatDuration(ms)
42
+ return `${Math.floor(hours / 24)}d ${Math.round(hours % 24)}h`
43
+ }
44
+
45
+ /** Minutes past local midnight as `09:12`. */
46
+ export function formatClock(minutes: number): string {
47
+ const hours = Math.floor(minutes / MINUTES_IN_HOUR)
48
+ return `${String(hours).padStart(2, '0')}:${String(minutes % MINUTES_IN_HOUR).padStart(2, '0')}`
49
+ }
50
+
51
+ /** The last two path segments — `Documents/aimux` says more than `aimux` and still fits. */
52
+ export function shortenPath(path: string): string {
53
+ return path
54
+ .split('/')
55
+ .filter((part) => part !== '')
56
+ .slice(-2)
57
+ .join('/')
58
+ }
59
+
60
+ /** `2026-03-14` as `Mar 14`. Empty in, empty out — a record with no day shows none. */
61
+ export function formatDayLabel(key: string): string {
62
+ if (key === '') return ''
63
+ const [, month, day] = key.split('-').map(Number)
64
+ const name = MONTHS[(month ?? 1) - 1]
65
+ if (name === undefined) return key
66
+ return `${name} ${day ?? ''}`
67
+ }
68
+
69
+ /**
70
+ * The ruler under a daily chart: a date every seventh bar, blank between.
71
+ *
72
+ * Weekly rather than daily so the labels have room to breathe instead of
73
+ * colliding into a smear, and counted back from the last bar because that bar is
74
+ * today — the one date on the ruler worth reading.
75
+ */
76
+ export function weeklyLabels(count: number, today: Date): string[] {
77
+ return Array.from({ length: count }, (_, index) => {
78
+ if ((count - 1 - index) % 7 !== 0) return ''
79
+ const date = new Date(today)
80
+ date.setDate(date.getDate() - (count - 1 - index))
81
+ return formatDayLabel(localDay(date))
82
+ })
83
+ }
84
+
85
+ export function formatPercent(part: number, whole: number): string {
86
+ if (whole <= 0) return '—'
87
+ return `${Math.round((part / whole) * 100)}%`
88
+ }
89
+
90
+ /** Keystrokes as a distance, at roughly the travel of one key press. */
91
+ const KEY_TRAVEL_MM = 0.8
92
+
93
+ export function formatFingerDistance(keys: number): string {
94
+ const metres = (keys * KEY_TRAVEL_MM) / 1000
95
+ if (metres >= 1000) return `${(metres / 1000).toFixed(1)} km`
96
+ if (metres >= 1) return `${metres.toFixed(0)} m`
97
+ return `${(metres * 100).toFixed(0)} cm`
98
+ }