@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.
- package/package.json +2 -2
- package/src/app-runtime/settings-actions.ts +24 -13
- 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/search.ts +13 -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/index.ts +23 -12
- 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 +13 -3
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/settings-state.ts +29 -51
- 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 +22 -7
- 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/row-value.tsx +22 -3
- package/src/ui/components/settings/settings-footer.tsx +170 -0
- package/src/ui/components/settings/settings-row.tsx +109 -33
- package/src/ui/components/settings/settings-search-bar.tsx +61 -0
- package/src/ui/components/settings/settings-view.tsx +163 -77
- 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 +654 -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
|
@@ -3,7 +3,8 @@ import type { ScrollBoxRenderable } from '@opentui/core'
|
|
|
3
3
|
import { useTerminalDimensions } from '@opentui/react'
|
|
4
4
|
import { memo, useCallback, useMemo, useRef } from 'react'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { filterSettingRows, type SettingSearchHit } from '../../../settings/search'
|
|
7
|
+
import { getSection } from '../../../settings/sections'
|
|
7
8
|
import { useSettingsStore } from '../../../settings/settings-store'
|
|
8
9
|
import { useAppStore } from '../../../state/app-store'
|
|
9
10
|
import { clampBarWidth } from '../../../state/bars'
|
|
@@ -11,81 +12,144 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref
|
|
|
11
12
|
import { useScrollActiveIntoView } from '../../hooks/use-scroll-active-into-view'
|
|
12
13
|
import { useTheme } from '../../theme'
|
|
13
14
|
import { ListItem } from '../primitives/list-item'
|
|
14
|
-
import {
|
|
15
|
+
import { Muted, Rule, Section, TileRow } from '../stats/shared'
|
|
16
|
+
import { SettingsFooter } from './settings-footer'
|
|
17
|
+
import { CONFIG_FILE_MARK, SettingsRow, TOUCHED_MARK } from './settings-row'
|
|
18
|
+
import { SettingsSearchBar } from './settings-search-bar'
|
|
15
19
|
|
|
16
20
|
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
21
|
+
|
|
22
|
+
const SETTINGS_GLYPH = '\u{2699}'
|
|
23
|
+
|
|
24
|
+
/** One padding column either side, the same inset the stats pages sit in. */
|
|
25
|
+
const PAGE_PAD = 1
|
|
26
|
+
|
|
27
|
+
/** A section and the rows it owns, each keeping the index the cursor holds. */
|
|
28
|
+
interface Group {
|
|
29
|
+
sectionId: string
|
|
30
|
+
label: string
|
|
31
|
+
glyph: string
|
|
32
|
+
note: string
|
|
33
|
+
/** `[cursor index, hit]`, in screen order. */
|
|
34
|
+
rows: [number, SettingSearchHit][]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function groupBySection(hits: SettingSearchHit[]): Group[] {
|
|
38
|
+
const groups: Group[] = []
|
|
39
|
+
for (const [index, hit] of hits.entries()) {
|
|
40
|
+
const last = groups.at(-1)
|
|
41
|
+
if (last?.sectionId === hit.sectionId) {
|
|
42
|
+
last.rows.push([index, hit])
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
const section = getSection(hit.sectionId)
|
|
46
|
+
groups.push({
|
|
47
|
+
glyph: section?.glyph ?? ' ',
|
|
48
|
+
label: hit.sectionLabel,
|
|
49
|
+
note: section?.description ?? '',
|
|
50
|
+
rows: [[index, hit]],
|
|
51
|
+
sectionId: hit.sectionId,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
return groups
|
|
55
|
+
}
|
|
56
|
+
|
|
17
57
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
58
|
+
* The settings screen.
|
|
59
|
+
*
|
|
60
|
+
* Same shape as the stats screen — a nav column exactly where the left bar
|
|
61
|
+
* stands, so opening it does not shift the column under the cursor, and a
|
|
62
|
+
* bordered content pane where the terminal would be, showing one section at a
|
|
63
|
+
* time.
|
|
21
64
|
*
|
|
22
|
-
*
|
|
23
|
-
* the
|
|
24
|
-
*
|
|
65
|
+
* One section at a time, but still one cursor: the pane is a view of whatever
|
|
66
|
+
* section the cursor is standing in, so `j` off the end of a section simply
|
|
67
|
+
* lands in the next one and the pane follows. That is what lets a row afford
|
|
68
|
+
* three lines — the setting, what it does, and a gap — where fifty rows in one
|
|
69
|
+
* scroll could only afford one, and what keeps the nav column from being a
|
|
70
|
+
* place you have to move into and back out of.
|
|
25
71
|
*/
|
|
26
|
-
const CONTENT_WIDTH_RATIO = 0.45
|
|
27
|
-
const CONTENT_MIN_WIDTH = 56
|
|
28
|
-
|
|
29
72
|
export const SettingsView = memo(function SettingsView() {
|
|
30
73
|
const t = useTheme()
|
|
31
74
|
// The cursor is all this component needs from the app state. Rows read their
|
|
32
75
|
// own values, so nothing here re-renders at the rate the terminals print.
|
|
33
|
-
const
|
|
34
|
-
// The section list stands exactly where the left bar stands, so opening the
|
|
35
|
-
// screen does not shift the column under the cursor. Its configured width, not
|
|
36
|
-
// `getBarWidth`: a hidden bar still says how wide the sidebar is.
|
|
37
|
-
const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
|
|
76
|
+
const rowIndex = useAppStore((s) => s.settings.rowIndex)
|
|
38
77
|
// Which rows exist can depend on the projects — Setup has one per project — and
|
|
39
78
|
// building a Setup row reads its script off disk. So the list is rebuilt when
|
|
40
|
-
// the
|
|
41
|
-
//
|
|
42
|
-
//
|
|
79
|
+
// the projects change and after any write; not on every render, which at the
|
|
80
|
+
// rate this store changes would mean reading files at the rate the terminals
|
|
81
|
+
// print.
|
|
43
82
|
const projects = useAppStore((s) => s.projects)
|
|
44
83
|
const revision = useSettingsStore((s) => s.revision)
|
|
45
|
-
const
|
|
46
|
-
|
|
84
|
+
const touchedCount = useSettingsStore((s) => s.touched.size)
|
|
85
|
+
const configCount = useSettingsStore((s) => s.fromConfigFile.size)
|
|
86
|
+
// The section list stands exactly where the left bar stands. Its configured
|
|
87
|
+
// width, not `getBarWidth`: a hidden bar still says how wide the sidebar is.
|
|
88
|
+
const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
|
|
89
|
+
const dimensions = useTerminalDimensions()
|
|
90
|
+
const hits = useMemo(
|
|
91
|
+
// No query: this is the whole list, and the same function the search filters
|
|
92
|
+
// with — so the index a hit carries is the index this cursor holds.
|
|
93
|
+
() => filterSettingRows(projects, null),
|
|
47
94
|
// `revision` is an invalidation key, not an input: it says a value the builder
|
|
48
95
|
// read from disk may have changed under it.
|
|
49
96
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
-
[
|
|
51
|
-
)
|
|
52
|
-
const dimensions = useTerminalDimensions()
|
|
53
|
-
const contentMaxWidth = Math.max(
|
|
54
|
-
CONTENT_MIN_WIDTH,
|
|
55
|
-
Math.round(dimensions.width * CONTENT_WIDTH_RATIO)
|
|
97
|
+
[projects, revision]
|
|
56
98
|
)
|
|
99
|
+
const groups = useMemo(() => groupBySection(hits), [hits])
|
|
57
100
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
101
|
+
const selected = hits[rowIndex]
|
|
102
|
+
const current = groups.find((group) => group.sectionId === selected?.sectionId)
|
|
58
103
|
|
|
59
104
|
useScrollActiveIntoView({
|
|
60
|
-
activeId:
|
|
105
|
+
activeId: selected?.row.id ?? null,
|
|
61
106
|
idPrefix: 'setting-row-',
|
|
62
107
|
scrollRef,
|
|
63
108
|
visible: true,
|
|
64
109
|
})
|
|
65
110
|
|
|
66
|
-
const handleSectionClick = useCallback((index: number) => {
|
|
67
|
-
const sectionId = SETTING_SECTIONS[index]?.id
|
|
68
|
-
if (sectionId == null) return
|
|
69
|
-
dispatchGlobal({ sectionId, type: 'settings-select-section' })
|
|
70
|
-
}, [])
|
|
71
|
-
|
|
72
111
|
// One click selects the row and changes it — a checkbox you have to select
|
|
73
112
|
// first and then click again is not a checkbox.
|
|
74
|
-
const handleRowClick = useCallback((
|
|
75
|
-
dispatchGlobal({ rowIndex, type: 'settings-select-row' })
|
|
113
|
+
const handleRowClick = useCallback((index: number) => {
|
|
114
|
+
dispatchGlobal({ rowIndex: index, type: 'settings-select-row' })
|
|
76
115
|
runSideEffectGlobal({ type: 'activate-settings-row' })
|
|
77
116
|
}, [])
|
|
78
117
|
|
|
118
|
+
const handleSectionClick = useCallback(
|
|
119
|
+
(index: number) => {
|
|
120
|
+
const first = groups[index]?.rows[0]?.[0]
|
|
121
|
+
if (first === undefined) return
|
|
122
|
+
dispatchGlobal({ rowIndex: first, type: 'settings-select-row' })
|
|
123
|
+
},
|
|
124
|
+
[groups]
|
|
125
|
+
)
|
|
126
|
+
|
|
79
127
|
const handleClose = useCallback(() => {
|
|
80
128
|
dispatchGlobal({ type: 'exit-settings' })
|
|
81
129
|
}, [])
|
|
82
130
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
131
|
+
// The headline row doubles as the legend for the two marks the rows wear —
|
|
132
|
+
// the counts are the only reason anyone would need to know what they mean.
|
|
133
|
+
const tiles = useMemo(
|
|
134
|
+
() =>
|
|
135
|
+
[
|
|
136
|
+
{ glyph: SETTINGS_GLYPH, label: 'settings', value: String(hits.length) },
|
|
137
|
+
// A mark no row is wearing needs no legend, and a count of zero is not
|
|
138
|
+
// news — so the tile only exists while the mark does.
|
|
139
|
+
touchedCount === 0
|
|
140
|
+
? null
|
|
141
|
+
: { glyph: TOUCHED_MARK, label: 'changed', value: String(touchedCount) },
|
|
142
|
+
configCount === 0
|
|
143
|
+
? null
|
|
144
|
+
: { glyph: CONFIG_FILE_MARK, label: 'from config', value: String(configCount) },
|
|
145
|
+
].filter((tile) => tile !== null),
|
|
146
|
+
[hits.length, touchedCount, configCount]
|
|
147
|
+
)
|
|
148
|
+
|
|
86
149
|
// Same 1-cell seam the bar draws between itself and the terminal, so the two
|
|
87
150
|
// views line up to the column.
|
|
88
151
|
const navContentWidth = Math.max(1, navWidth - 1)
|
|
152
|
+
const usable = Math.max(24, dimensions.width - navWidth - 2 - PAGE_PAD * 2)
|
|
89
153
|
|
|
90
154
|
return (
|
|
91
155
|
<box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
@@ -94,22 +158,23 @@ export const SettingsView = memo(function SettingsView() {
|
|
|
94
158
|
<box paddingLeft={1} paddingRight={1}>
|
|
95
159
|
<text fg={t.textMuted}>Settings</text>
|
|
96
160
|
</box>
|
|
161
|
+
{/* A jump list, not a column you move into: it says which section the
|
|
162
|
+
cursor is in and takes it somewhere. Nothing here holds focus,
|
|
163
|
+
which is why the screen needs no notion of which pane has it. */}
|
|
97
164
|
<box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
|
|
98
|
-
{
|
|
165
|
+
{groups.map((group, index) => (
|
|
99
166
|
<ListItem
|
|
100
|
-
key={
|
|
101
|
-
active={
|
|
167
|
+
key={group.sectionId}
|
|
168
|
+
active={group.sectionId === current?.sectionId}
|
|
102
169
|
index={index}
|
|
103
170
|
onClickIndex={handleSectionClick}
|
|
104
171
|
title={
|
|
105
|
-
<text fg={
|
|
106
|
-
{
|
|
172
|
+
<text fg={group.sectionId === current?.sectionId ? t.text : t.textMuted}>
|
|
173
|
+
{`${group.glyph} ${group.label}`}
|
|
107
174
|
</text>
|
|
108
175
|
}
|
|
109
176
|
trailing={
|
|
110
|
-
|
|
111
|
-
<text fg={t.primary}>›</text>
|
|
112
|
-
) : undefined
|
|
177
|
+
group.sectionId === current?.sectionId ? <text fg={t.primary}>›</text> : undefined
|
|
113
178
|
}
|
|
114
179
|
/>
|
|
115
180
|
))}
|
|
@@ -123,46 +188,67 @@ export const SettingsView = memo(function SettingsView() {
|
|
|
123
188
|
<box width={1} flexShrink={0} backgroundColor={t.border} />
|
|
124
189
|
</box>
|
|
125
190
|
{/* The pane the terminal would be in, with the same border — the content
|
|
126
|
-
keeps the inset it had
|
|
191
|
+
keeps the inset it had instead of jumping to the edge of the screen. */}
|
|
127
192
|
<box
|
|
128
193
|
border
|
|
129
|
-
borderColor={
|
|
130
|
-
title={
|
|
194
|
+
borderColor={t.borderActive}
|
|
195
|
+
title={`${SETTINGS_GLYPH} Settings`}
|
|
131
196
|
padding={0}
|
|
132
197
|
flexDirection="column"
|
|
133
198
|
flexGrow={1}
|
|
134
199
|
backgroundColor={t.background}
|
|
135
200
|
>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
{/* Outside the scroll: the way to find a setting should not be a thing
|
|
202
|
+
you have to scroll back up to. */}
|
|
203
|
+
<box flexShrink={0} paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD} paddingTop={1}>
|
|
204
|
+
<SettingsSearchBar width={usable} />
|
|
205
|
+
</box>
|
|
206
|
+
<scrollbox
|
|
207
|
+
ref={scrollRef}
|
|
208
|
+
scrollY
|
|
209
|
+
flexGrow={1}
|
|
210
|
+
flexShrink={1}
|
|
211
|
+
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
212
|
+
>
|
|
213
|
+
<box flexDirection="column" paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
|
|
214
|
+
<TileRow tiles={tiles} usable={usable} />
|
|
215
|
+
<box paddingTop={1} paddingBottom={1} flexShrink={0}>
|
|
216
|
+
<Rule width={usable} />
|
|
140
217
|
</box>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
ref={scrollRef}
|
|
144
|
-
scrollY
|
|
145
|
-
flexGrow={1}
|
|
146
|
-
flexShrink={1}
|
|
147
|
-
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
148
|
-
>
|
|
149
|
-
{rows.length === 0 ? (
|
|
150
|
-
<box paddingLeft={1}>
|
|
151
|
-
<text fg={t.textMuted}>Nothing to configure here yet.</text>
|
|
152
|
-
</box>
|
|
218
|
+
{current === undefined ? (
|
|
219
|
+
<Muted>Nothing to configure here yet.</Muted>
|
|
153
220
|
) : (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
221
|
+
<Section
|
|
222
|
+
glyph={current.glyph}
|
|
223
|
+
title={current.label}
|
|
224
|
+
// Pinned to the same right edge every note on the screen lands on.
|
|
225
|
+
note={String(current.rows.length)}
|
|
226
|
+
width={usable}
|
|
227
|
+
>
|
|
228
|
+
{current.note === '' ? null : (
|
|
229
|
+
<box width={usable} paddingBottom={1}>
|
|
230
|
+
<text fg={t.textMuted} selectable={false}>
|
|
231
|
+
{current.note}
|
|
232
|
+
</text>
|
|
233
|
+
</box>
|
|
234
|
+
)}
|
|
235
|
+
<box flexDirection="column" gap={1}>
|
|
236
|
+
{current.rows.map(([index, hit]) => (
|
|
237
|
+
<SettingsRow
|
|
238
|
+
key={hit.row.id}
|
|
239
|
+
row={hit.row}
|
|
240
|
+
index={index}
|
|
241
|
+
active={index === rowIndex}
|
|
242
|
+
onSelect={handleRowClick}
|
|
243
|
+
width={usable}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</box>
|
|
247
|
+
</Section>
|
|
163
248
|
)}
|
|
164
|
-
</
|
|
165
|
-
</
|
|
249
|
+
</box>
|
|
250
|
+
</scrollbox>
|
|
251
|
+
<SettingsFooter row={selected?.row ?? null} width={usable} pad={PAGE_PAD} />
|
|
166
252
|
</box>
|
|
167
253
|
</box>
|
|
168
254
|
)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { CounterDays } from '../../../services/aimux-counters/store'
|
|
2
|
+
import type { StatsData } from './use-stats-data'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
lastCounterDays,
|
|
6
|
+
peakOf,
|
|
7
|
+
summarizeCounter,
|
|
8
|
+
sumOf,
|
|
9
|
+
} from '../../../services/aimux-counters/summary'
|
|
10
|
+
import { formatCompact } from '../../format-number'
|
|
11
|
+
import { chartColumns } from './chart'
|
|
12
|
+
import {
|
|
13
|
+
formatCount,
|
|
14
|
+
formatDayLabel,
|
|
15
|
+
formatDuration,
|
|
16
|
+
formatFingerDistance,
|
|
17
|
+
formatSpan,
|
|
18
|
+
weeklyLabels,
|
|
19
|
+
} from './format'
|
|
20
|
+
import {
|
|
21
|
+
FactGrid,
|
|
22
|
+
GLYPH,
|
|
23
|
+
Muted,
|
|
24
|
+
pageLayout,
|
|
25
|
+
PageNotice,
|
|
26
|
+
type PageTile,
|
|
27
|
+
recordsOf,
|
|
28
|
+
RecordsSection,
|
|
29
|
+
Section,
|
|
30
|
+
StatsPage,
|
|
31
|
+
TwoColumn,
|
|
32
|
+
VBarChart,
|
|
33
|
+
} from './shared'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* aimux — what the editor itself has seen.
|
|
37
|
+
*
|
|
38
|
+
* Two daily series and a block of totals. The series are the point: "12 hours
|
|
39
|
+
* today" against "6 hours on an average day" is a comparison a reader has to do
|
|
40
|
+
* in their head, and a chart of the last few weeks does it for them.
|
|
41
|
+
*
|
|
42
|
+
* Everything here is a count and nothing else — no key identity, no content, and
|
|
43
|
+
* nothing that leaves the machine.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const MS_PER_MINUTE = 60_000
|
|
47
|
+
/** Two charts side by side, short enough that the totals below stay on screen. */
|
|
48
|
+
const CHART_HEIGHT = 7
|
|
49
|
+
|
|
50
|
+
/** The axis is in minutes so the gridlines land on 30, 60, 120 — round clock numbers. */
|
|
51
|
+
function formatMinutes(minutes: number): string {
|
|
52
|
+
return formatDuration(minutes * MS_PER_MINUTE)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Named rather than inlined so the series is a call's result, not an array built in render. */
|
|
56
|
+
function uptimeMinutes(counters: CounterDays, count: number, today: Date): number[] {
|
|
57
|
+
return lastCounterDays(counters, count, today, 'uptimeMs').map((ms) => ms / MS_PER_MINUTE)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The lifetime totals, as label/value pairs.
|
|
62
|
+
*
|
|
63
|
+
* Counts of unrelated things: a bar between "workspaces" and "lines scrolled"
|
|
64
|
+
* would encode a comparison nobody is making, so they are simply listed.
|
|
65
|
+
*/
|
|
66
|
+
function builtFacts(counters: CounterDays): [string, string][] {
|
|
67
|
+
const vertical = sumOf(counters, 'splitsVertical')
|
|
68
|
+
const splits = vertical + sumOf(counters, 'splitsHorizontal')
|
|
69
|
+
return [
|
|
70
|
+
['Workspaces', formatCount(sumOf(counters, 'workspacesCreated'))],
|
|
71
|
+
['Runs', formatCount(sumOf(counters, 'runsStarted'))],
|
|
72
|
+
['Tabs', formatCount(sumOf(counters, 'tabsOpened'))],
|
|
73
|
+
[
|
|
74
|
+
'Splits',
|
|
75
|
+
splits === 0
|
|
76
|
+
? '0'
|
|
77
|
+
: `${formatCount(splits)} \u{00B7} ${Math.round((vertical / splits) * 100)}% vertical`,
|
|
78
|
+
],
|
|
79
|
+
['Snippets', formatCount(sumOf(counters, 'snippetsFired'))],
|
|
80
|
+
['Lines scrolled', formatCompact(sumOf(counters, 'scrollLines'))],
|
|
81
|
+
['Daemon restarts', formatCount(sumOf(counters, 'daemonRestarts'))],
|
|
82
|
+
['Days counted', formatCount(Object.keys(counters).length)],
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function aimuxTiles(uptimeMs: number, keys: number, runs: number): PageTile[] {
|
|
87
|
+
return [
|
|
88
|
+
{ glyph: GLYPH.clock, label: 'In aimux', value: formatSpan(uptimeMs) },
|
|
89
|
+
{ glyph: GLYPH.keyboard, label: 'Keys', value: formatCount(keys) },
|
|
90
|
+
{ glyph: GLYPH.distance, label: 'Distance', value: formatFingerDistance(keys) },
|
|
91
|
+
{ glyph: GLYPH.aimux, label: 'Runs', value: formatCount(runs) },
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function AimuxPage({ data, width }: { data: StatsData; width: number }) {
|
|
96
|
+
const { counters, today, todayDate } = data
|
|
97
|
+
|
|
98
|
+
// First, before the derivations it would make pointless.
|
|
99
|
+
if (Object.keys(counters).length === 0) {
|
|
100
|
+
return (
|
|
101
|
+
<PageNotice>nothing counted yet — this starts the first time aimux runs with it</PageNotice>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { split, usable } = pageLayout(width)
|
|
106
|
+
const { leftWidth, rightWidth } = split
|
|
107
|
+
|
|
108
|
+
const uptime = summarizeCounter(counters, 'uptimeMs', today)
|
|
109
|
+
const keys = summarizeCounter(counters, 'keys', today)
|
|
110
|
+
const tabs = summarizeCounter(counters, 'tabsOpened', today)
|
|
111
|
+
const longestRun = peakOf(counters, 'longestRunMs')
|
|
112
|
+
const dailyUptime = uptime.days === 0 ? 0 : uptime.total / uptime.days
|
|
113
|
+
|
|
114
|
+
const uptimeDays = chartColumns(leftWidth - 9)
|
|
115
|
+
const uptimeSeries = uptimeMinutes(counters, uptimeDays, todayDate)
|
|
116
|
+
const uptimeLabels = weeklyLabels(uptimeDays, todayDate)
|
|
117
|
+
|
|
118
|
+
const keyDays = chartColumns(rightWidth - 8)
|
|
119
|
+
const keySeries = lastCounterDays(counters, keyDays, todayDate, 'keys')
|
|
120
|
+
const keyLabels = weeklyLabels(keyDays, todayDate)
|
|
121
|
+
|
|
122
|
+
const built = builtFacts(counters)
|
|
123
|
+
|
|
124
|
+
// Only records that exist. A row of `—` teaches nothing and reads as broken.
|
|
125
|
+
const records = recordsOf([
|
|
126
|
+
longestRun.value === 0
|
|
127
|
+
? null
|
|
128
|
+
: {
|
|
129
|
+
label: 'Longest run',
|
|
130
|
+
value: formatDuration(longestRun.value),
|
|
131
|
+
when: formatDayLabel(longestRun.day),
|
|
132
|
+
},
|
|
133
|
+
uptime.best.value === 0
|
|
134
|
+
? null
|
|
135
|
+
: {
|
|
136
|
+
label: 'Longest day',
|
|
137
|
+
value: formatDuration(uptime.best.value),
|
|
138
|
+
when: formatDayLabel(uptime.best.day),
|
|
139
|
+
},
|
|
140
|
+
keys.best.value === 0
|
|
141
|
+
? null
|
|
142
|
+
: {
|
|
143
|
+
label: 'Most keystrokes',
|
|
144
|
+
value: formatCount(keys.best.value),
|
|
145
|
+
when: formatDayLabel(keys.best.day),
|
|
146
|
+
},
|
|
147
|
+
tabs.best.value === 0
|
|
148
|
+
? null
|
|
149
|
+
: {
|
|
150
|
+
label: 'Most tabs',
|
|
151
|
+
value: `${formatCount(tabs.best.value)} opened`,
|
|
152
|
+
when: formatDayLabel(tabs.best.day),
|
|
153
|
+
},
|
|
154
|
+
keys.total === 0
|
|
155
|
+
? null
|
|
156
|
+
: {
|
|
157
|
+
label: 'Finger mileage',
|
|
158
|
+
value: formatFingerDistance(keys.total),
|
|
159
|
+
when: 'at 0.8 mm a key',
|
|
160
|
+
},
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
const left = (
|
|
164
|
+
<Section
|
|
165
|
+
glyph={GLYPH.clock}
|
|
166
|
+
title="Time a day"
|
|
167
|
+
note={uptime.days === 0 ? '' : `${formatDuration(dailyUptime)} on an average day`}
|
|
168
|
+
width={leftWidth}
|
|
169
|
+
>
|
|
170
|
+
{uptime.total === 0 ? (
|
|
171
|
+
<Muted>counted from the next run onward</Muted>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<VBarChart
|
|
175
|
+
caption={`last ${uptimeDays} days`}
|
|
176
|
+
format={formatMinutes}
|
|
177
|
+
height={CHART_HEIGHT}
|
|
178
|
+
labels={uptimeLabels}
|
|
179
|
+
values={uptimeSeries}
|
|
180
|
+
/>
|
|
181
|
+
<Muted>
|
|
182
|
+
{[
|
|
183
|
+
`today ${formatDuration(uptime.today)}`,
|
|
184
|
+
`best ${formatDuration(uptime.best.value)}`,
|
|
185
|
+
`longest run ${formatDuration(longestRun.value)}`,
|
|
186
|
+
].join(' \u{00B7} ')}
|
|
187
|
+
</Muted>
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
</Section>
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
const right = (
|
|
194
|
+
<Section
|
|
195
|
+
glyph={GLYPH.keyboard}
|
|
196
|
+
title="Keys a day"
|
|
197
|
+
note={keys.today === 0 ? '' : `${formatCount(keys.today)} today`}
|
|
198
|
+
width={rightWidth}
|
|
199
|
+
>
|
|
200
|
+
{keys.total === 0 ? (
|
|
201
|
+
<Muted>counted from the next run onward</Muted>
|
|
202
|
+
) : (
|
|
203
|
+
<>
|
|
204
|
+
<VBarChart
|
|
205
|
+
caption={`last ${keyDays} days`}
|
|
206
|
+
format={formatCompact}
|
|
207
|
+
height={CHART_HEIGHT}
|
|
208
|
+
labels={keyLabels}
|
|
209
|
+
values={keySeries}
|
|
210
|
+
/>
|
|
211
|
+
<Muted>
|
|
212
|
+
{[
|
|
213
|
+
`${formatCompact(keys.total)} all time`,
|
|
214
|
+
`best ${formatCount(keys.best.value)}`,
|
|
215
|
+
`${formatFingerDistance(keys.total)} of travel`,
|
|
216
|
+
].join(' \u{00B7} ')}
|
|
217
|
+
</Muted>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</Section>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<StatsPage
|
|
225
|
+
tiles={aimuxTiles(uptime.total, keys.total, sumOf(counters, 'runsStarted'))}
|
|
226
|
+
usable={usable}
|
|
227
|
+
>
|
|
228
|
+
<TwoColumn split={split}>
|
|
229
|
+
{left}
|
|
230
|
+
{right}
|
|
231
|
+
</TwoColumn>
|
|
232
|
+
|
|
233
|
+
<Section glyph={GLYPH.aimux} title="What you built" note="all time" width={usable}>
|
|
234
|
+
<FactGrid columns={split.twoUp ? 2 : 1} facts={built} width={usable} />
|
|
235
|
+
</Section>
|
|
236
|
+
|
|
237
|
+
<RecordsSection
|
|
238
|
+
empty="nothing to beat yet — records appear as aimux is used"
|
|
239
|
+
records={records}
|
|
240
|
+
width={usable}
|
|
241
|
+
/>
|
|
242
|
+
<Muted>A count and nothing else — no key identity is recorded.</Muted>
|
|
243
|
+
</StatsPage>
|
|
244
|
+
)
|
|
245
|
+
}
|