@brimveyn/aimux 1.22.11 → 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,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
|
+
}
|