@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
@@ -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 { getSectionRows, SETTING_SECTIONS } from '../../../settings/sections'
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 { SettingsRow } from './settings-row'
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
- * Share of the window the settings themselves are allowed. Without a cap the
19
- * value sits on the far edge of a wide terminal, a hundred columns from the label
20
- * it belongs to, and the pair stops reading as a pair.
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
- * A share of the window rather than a fixed column count, so it holds whatever
23
- * the window is; the floor is there for the narrow ones, where the pane is
24
- * smaller than the cap anyway and this stops binding.
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 settings = useAppStore((s) => s.settings)
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 section changes, when the projects change, and after any write; not on
41
- // every render, which at the rate this store changes would mean reading files
42
- // at the rate the terminals print.
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 rows = useMemo(
46
- () => getSectionRows(settings.sectionId, projects),
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
- [settings.sectionId, projects, revision]
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: settings.pane === 'rows' ? (rows[settings.rowIndex]?.id ?? null) : null,
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((rowIndex: number) => {
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
- const section = SETTING_SECTIONS.find((s) => s.id === settings.sectionId)
84
- const sectionLabel = section?.label ?? 'Settings'
85
- const sectionNote = section?.description
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
- {SETTING_SECTIONS.map((section, index) => (
165
+ {groups.map((group, index) => (
99
166
  <ListItem
100
- key={section.id}
101
- active={section.id === settings.sectionId}
167
+ key={group.sectionId}
168
+ active={group.sectionId === current?.sectionId}
102
169
  index={index}
103
170
  onClickIndex={handleSectionClick}
104
171
  title={
105
- <text fg={section.id === settings.sectionId ? t.text : t.textMuted}>
106
- {section.label}
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
- section.id === settings.sectionId && settings.pane === 'nav' ? (
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, instead of jumping to the edge of the screen. */}
191
+ keeps the inset it had instead of jumping to the edge of the screen. */}
127
192
  <box
128
193
  border
129
- borderColor={settings.pane === 'rows' ? t.borderActive : t.border}
130
- title={sectionLabel}
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
- <box flexDirection="column" flexGrow={1} flexShrink={1} maxWidth={contentMaxWidth}>
137
- {sectionNote != null && sectionNote !== '' ? (
138
- <box flexShrink={0} paddingLeft={1} paddingRight={1}>
139
- <text fg={t.textMuted}>{sectionNote}</text>
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
- ) : null}
142
- <scrollbox
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
- rows.map((row, index) => (
155
- <SettingsRow
156
- key={row.id}
157
- row={row}
158
- index={index}
159
- active={settings.pane === 'rows' && index === settings.rowIndex}
160
- onSelect={handleRowClick}
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
- </scrollbox>
165
- </box>
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
+ }