@brimveyn/aimux 1.22.12 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +5 -0
  3. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  4. package/src/app.tsx +6 -0
  5. package/src/index.tsx +6 -0
  6. package/src/input/keymap/help-entries.ts +1 -0
  7. package/src/input/modes/bridge.ts +2 -1
  8. package/src/input/modes/transitions.ts +7 -3
  9. package/src/input/modes/types.ts +2 -1
  10. package/src/restart-daemon.ts +5 -0
  11. package/src/services/ai-usage/projection.ts +44 -0
  12. package/src/services/aimux-counters/index.ts +87 -0
  13. package/src/services/aimux-counters/observe.ts +39 -0
  14. package/src/services/aimux-counters/store.ts +150 -0
  15. package/src/services/aimux-counters/summary.ts +78 -0
  16. package/src/services/usage-history/cost.ts +149 -0
  17. package/src/services/usage-history/insights.ts +314 -0
  18. package/src/services/usage-history/rollup.ts +78 -6
  19. package/src/services/usage-history/stats.ts +66 -35
  20. package/src/services/usage-history/store.ts +128 -9
  21. package/src/settings/sections/about.ts +1 -0
  22. package/src/settings/sections/appearance.ts +1 -0
  23. package/src/settings/sections/automation.ts +1 -0
  24. package/src/settings/sections/commands.ts +1 -0
  25. package/src/settings/sections/editor.ts +1 -0
  26. package/src/settings/sections/experimental.ts +1 -0
  27. package/src/settings/sections/git.ts +1 -0
  28. package/src/settings/sections/integrations.ts +1 -0
  29. package/src/settings/sections/layout.ts +1 -0
  30. package/src/settings/sections/notifications.ts +1 -0
  31. package/src/settings/sections/setup.ts +1 -0
  32. package/src/settings/sections/status-bar.ts +1 -0
  33. package/src/settings/sections/workspace.ts +1 -0
  34. package/src/settings/types.ts +10 -0
  35. package/src/state/actions.ts +12 -1
  36. package/src/state/app-store.ts +12 -1
  37. package/src/state/reducers/modal-state.ts +12 -17
  38. package/src/state/reducers/stats-state.ts +56 -0
  39. package/src/state/stats-pages.ts +33 -0
  40. package/src/state/store.ts +5 -0
  41. package/src/state/types.ts +17 -4
  42. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  43. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  44. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  45. package/src/ui/components/settings/settings-view.tsx +6 -3
  46. package/src/ui/components/stats/aimux-page.tsx +245 -0
  47. package/src/ui/components/stats/chart.ts +157 -0
  48. package/src/ui/components/stats/day-facts.tsx +268 -0
  49. package/src/ui/components/stats/format.ts +98 -0
  50. package/src/ui/components/stats/heatmap.tsx +305 -0
  51. package/src/ui/components/stats/projects-page.tsx +293 -0
  52. package/src/ui/components/stats/quotas.tsx +210 -0
  53. package/src/ui/components/stats/shared.tsx +645 -0
  54. package/src/ui/components/stats/stats-view.tsx +153 -0
  55. package/src/ui/components/stats/usage-page.tsx +291 -0
  56. package/src/ui/components/stats/use-stats-data.ts +48 -0
  57. package/src/ui/root.tsx +7 -3
  58. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
@@ -0,0 +1,56 @@
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, StatsUIState } from '../types'
3
+
4
+ import { STATS_PAGES } from '../stats-pages'
5
+
6
+ export function emptyStatsUI(): StatsUIState {
7
+ return { pageIndex: 0, scrollTop: 0 }
8
+ }
9
+
10
+ function withStats(state: AppState, patch: Partial<StatsUIState>): AppState {
11
+ return { ...state, stats: { ...state.stats, ...patch } }
12
+ }
13
+
14
+ function clamp(value: number, max: number): number {
15
+ if (max < 0) return 0
16
+ return Math.max(0, Math.min(max, value))
17
+ }
18
+
19
+ export function reduceStatsState(state: AppState, action: AppAction): AppState | null {
20
+ switch (action.type) {
21
+ case 'enter-stats':
22
+ if (state.focusMode === 'stats') return state
23
+ // The page is remembered across a close/open within the session; the
24
+ // scroll inside it is not — reopening lands you at the top of it.
25
+ return { ...state, focusMode: 'stats', stats: { ...state.stats, scrollTop: 0 } }
26
+ case 'exit-stats':
27
+ if (state.focusMode !== 'stats') return state
28
+ return { ...state, focusMode: 'navigation' }
29
+ case 'stats-move-page': {
30
+ const pageIndex = clamp(state.stats.pageIndex + action.delta, STATS_PAGES.length - 1)
31
+ if (pageIndex === state.stats.pageIndex) return state
32
+ // A new page is a different length; carrying the old offset into it lands
33
+ // the viewport somewhere the user never scrolled to.
34
+ return withStats(state, { pageIndex, scrollTop: 0 })
35
+ }
36
+ case 'stats-select-page': {
37
+ const pageIndex = clamp(action.pageIndex, STATS_PAGES.length - 1)
38
+ if (pageIndex === state.stats.pageIndex) return state
39
+ return withStats(state, { pageIndex, scrollTop: 0 })
40
+ }
41
+ case 'stats-scroll':
42
+ // No upper clamp here: the reducer does not know how tall the rendered
43
+ // page is. The scrollbox is what bounds it, and `stats-scroll-settled`
44
+ // brings its answer back — without that round trip this offset climbs for
45
+ // as long as the key is held and the way back up is a dead zone the same
46
+ // length.
47
+ return withStats(state, { scrollTop: Math.max(0, state.stats.scrollTop + action.delta) })
48
+ case 'stats-scroll-settled': {
49
+ const scrollTop = Math.max(0, action.scrollTop)
50
+ if (scrollTop === state.stats.scrollTop) return state
51
+ return withStats(state, { scrollTop })
52
+ }
53
+ default:
54
+ return null
55
+ }
56
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The stats screen's pages, in nav order.
3
+ *
4
+ * Lives in the state layer rather than beside the view because the reducer is
5
+ * what clamps the page cursor: the list of pages is a bound, and a bound the
6
+ * reducer cannot see is a bound it cannot enforce.
7
+ *
8
+ * Three pages, not one per topic. Quotas, token cost and the calendar all answer
9
+ * "how much am I using this", so they share a page; everything else earns its
10
+ * own by being a different question.
11
+ *
12
+ * Records used to be a fourth. A page of eight one-line trivia rows was mostly
13
+ * empty, and every row was a record *of* something another page already covers —
14
+ * so each one now closes the page whose data it comes from.
15
+ */
16
+
17
+ export interface StatsPage {
18
+ glyph: string
19
+ id: StatsPageId
20
+ label: string
21
+ }
22
+
23
+ export type StatsPageId = 'aimux' | 'projects' | 'usage'
24
+
25
+ export const STATS_PAGES: readonly StatsPage[] = [
26
+ { glyph: '\u{25D4}', id: 'usage', label: 'Usage' },
27
+ { glyph: '\u{2632}', id: 'projects', label: 'Projects' },
28
+ { glyph: '\u{2318}', id: 'aimux', label: 'aimux' },
29
+ ]
30
+
31
+ export function statsPageAt(index: number): StatsPage {
32
+ return STATS_PAGES[index] ?? STATS_PAGES[0] ?? { glyph: '\u{25D4}', id: 'usage', label: 'Usage' }
33
+ }
@@ -8,6 +8,7 @@ import { emptyModal, reduceModalState } from './reducers/modal-state'
8
8
  import { reduceMultiRepoState } from './reducers/multi-repo-state'
9
9
  import { reduceProjectState } from './reducers/project-state'
10
10
  import { emptySettingsUI, reduceSettingsState } from './reducers/settings-state'
11
+ import { emptyStatsUI, reduceStatsState } from './reducers/stats-state'
11
12
  import { reduceTabState } from './reducers/tab-state'
12
13
  import { reduceUIState } from './reducers/ui-state'
13
14
  import { filterSnippets } from './selectors'
@@ -139,6 +140,7 @@ export function createInitialState(
139
140
  projectStatuses: {},
140
141
  settings: emptySettingsUI(),
141
142
  snippets,
143
+ stats: emptyStatsUI(),
142
144
  tabGroupMap: {},
143
145
  tabs: [],
144
146
  workspaceActivity: {},
@@ -174,6 +176,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
174
176
  const settingsState = reduceSettingsState(state, action)
175
177
  if (settingsState) return settingsState
176
178
 
179
+ const statsState = reduceStatsState(state, action)
180
+ if (statsState) return statsState
181
+
177
182
  switch (action.type) {
178
183
  case 'set-snippets':
179
184
  return { ...state, snippets: action.snippets }
@@ -69,6 +69,7 @@ export type FocusMode =
69
69
  | 'command-edit'
70
70
  | 'git'
71
71
  | 'settings'
72
+ | 'stats'
72
73
 
73
74
  export type ModalType =
74
75
  | 'new-tab'
@@ -85,7 +86,7 @@ export type ModalType =
85
86
  | 'split-picker'
86
87
  | 'git-commit'
87
88
  | 'update-available'
88
- | 'ai-usage'
89
+ | 'quotas'
89
90
  | 'workspace-move'
90
91
  | 'workspace-move-confirm'
91
92
  | 'workspace-delete-confirm'
@@ -580,8 +581,9 @@ export interface ModalUpdateAvailable extends ModalBase {
580
581
  latestVersion: string
581
582
  }
582
583
 
583
- export interface ModalAIUsage extends ModalBase {
584
- type: 'ai-usage'
584
+ /** The status bar's usage indicator, expanded. Carries nothing: it is a readout. */
585
+ export interface ModalQuotas extends ModalBase {
586
+ type: 'quotas'
585
587
  }
586
588
 
587
589
  export interface ModalWorkspaceMove extends ModalBase {
@@ -686,7 +688,7 @@ export type ModalState =
686
688
  | ModalSnippetEditor
687
689
  | ModalGitCommit
688
690
  | ModalUpdateAvailable
689
- | ModalAIUsage
691
+ | ModalQuotas
690
692
  | ModalWorkspaceMove
691
693
  | ModalWorkspaceMoveConfirm
692
694
  | ModalWorkspaceDeleteConfirm
@@ -738,6 +740,16 @@ export interface SettingsUIState {
738
740
  rowIndex: number
739
741
  }
740
742
 
743
+ /**
744
+ * Where the cursor is on the stats screen. Nothing measured lives here — the
745
+ * numbers are read from disk by the pages that render them.
746
+ */
747
+ export interface StatsUIState {
748
+ pageIndex: number
749
+ /** Rows scrolled from the top of the current page. Reset when the page changes. */
750
+ scrollTop: number
751
+ }
752
+
741
753
  export interface AppState {
742
754
  tabs: TabSession[]
743
755
  activeTabId: string | null
@@ -759,6 +771,7 @@ export interface AppState {
759
771
  autoCommit: AutoCommitState
760
772
  multiRepo: MultiRepoState
761
773
  settings: SettingsUIState
774
+ stats: StatsUIState
762
775
  /**
763
776
  * Commits each workspace's branch is ahead/behind the ref it forked from,
764
777
  * keyed by workspace id. Ephemeral (polled); not persisted to the catalog.
@@ -37,6 +37,19 @@ const HEADER_TITLE = 'Projects'
37
37
  * the one button that opens the settings.
38
38
  */
39
39
  const SETTINGS_GLYPH = '⚙'
40
+ /**
41
+ * U+25A4, chosen on the same rule as the gear above: one cell, text presentation,
42
+ * present in the base fonts. A ▁▄█ mini bar chart reads better but is three cells
43
+ * wide, which pushes this row past a narrow sidebar.
44
+ */
45
+ const STATS_GLYPH = '▤'
46
+ const SETTINGS_LABEL = `${SETTINGS_GLYPH} Settings`
47
+ const STATS_LABEL = `${STATS_GLYPH} Stats`
48
+ /** The two entries and the gap between them, so a renamed label re-measures itself. */
49
+ const FOOTER_GAP = 2
50
+ const FOOTER_FULL_WIDTH = SETTINGS_LABEL.length + FOOTER_GAP + STATS_LABEL.length
51
+ /** The row's own left and right padding. */
52
+ const FOOTER_PAD = 2
40
53
 
41
54
  interface DragState {
42
55
  id: string
@@ -183,7 +196,19 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
183
196
  dispatchGlobal({ type: 'enter-settings' })
184
197
  }, [])
185
198
 
199
+ // Stats sits beside it for the same reason, and because the two are the only
200
+ // full-screen views the panes step aside for that a mouse can reach at all.
201
+ const handleOpenStats = useCallback((e: OtuiMouseEvent) => {
202
+ e.stopPropagation()
203
+ e.preventDefault()
204
+ dispatchGlobal({ type: 'enter-stats' })
205
+ }, [])
206
+
186
207
  const rule = RULE.repeat(Math.max(1, contentWidth))
208
+ // The bar clamps down to 18 columns, narrower than both labels together, so
209
+ // below that the second entry drops to its glyph rather than being sliced
210
+ // mid-word by the overflow.
211
+ const statsLabel = contentWidth - FOOTER_PAD >= FOOTER_FULL_WIDTH ? STATS_LABEL : STATS_GLYPH
187
212
 
188
213
  return (
189
214
  // Drag and release are handled here, not on the row that started them:
@@ -293,9 +318,15 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
293
318
  {rule}
294
319
  </text>
295
320
  </box>
321
+ {/* Each entry is its own <text>, with a spacer box between them: one string
322
+ holding both would make the whole footer a single click target. */}
296
323
  <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
297
324
  <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
298
- {`${SETTINGS_GLYPH} Settings`}
325
+ {SETTINGS_LABEL}
326
+ </text>
327
+ <box width={FOOTER_GAP} flexShrink={1} />
328
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenStats}>
329
+ {statsLabel}
299
330
  </text>
300
331
  </box>
301
332
  </box>
@@ -0,0 +1,42 @@
1
+ import { QuotaWindows } from '../../stats/quotas'
2
+ import { ModalShell } from '../shared/modal-shell'
3
+
4
+ /**
5
+ * The quota windows, and nothing else.
6
+ *
7
+ * What the status-bar indicator is a summary of: clicking it asks "how much is
8
+ * left", which is one block of the stats screen. Opening the whole screen for
9
+ * that made the reader find the answer among four other sections and then find
10
+ * their way back. The screen is still there, behind the Stats button.
11
+ */
12
+
13
+ /**
14
+ * Wider than the shell's usual sizes, and deliberately so: at 56 the reset times
15
+ * end a column from the border and the block reads as cramped. This one is a
16
+ * readout, not a list of choices — it can afford the air.
17
+ */
18
+ const WIDTH = 76
19
+ /** The shell's border and its one column of padding, both sides. */
20
+ const CHROME = 4
21
+
22
+ export function QuotasModal() {
23
+ // Read per render like the stats page does: the store pushes a new snapshot
24
+ // and the relative times ("1m ago") are measured against now, not against
25
+ // when the modal happened to open.
26
+ const now = new Date()
27
+
28
+ return (
29
+ <ModalShell
30
+ title="Quotas"
31
+ subtitle="live"
32
+ keybindsModeId="modal.quotas"
33
+ width={WIDTH}
34
+ listGap={1}
35
+ >
36
+ {/* No projection here. `empty ~Fri 13:02` is worth the width on a page
37
+ being read; on a glance at what is left it is a second timestamp to
38
+ parse next to the one that matters. */}
39
+ <QuotaWindows now={now} projection={false} width={WIDTH - CHROME} />
40
+ </ModalShell>
41
+ )
42
+ }
@@ -18,11 +18,11 @@ export function AIUsageIndicator() {
18
18
  const enabled = useAIUsageStore((s) => s.enabled)
19
19
  const snapshots = useAIUsageStore((s) => s.snapshots)
20
20
 
21
- const openModal = useCallback(
21
+ const openQuotas = useCallback(
22
22
  (e: { preventDefault: () => void; stopPropagation: () => void }) => {
23
23
  e.preventDefault()
24
24
  e.stopPropagation()
25
- dispatchGlobal({ type: 'open-ai-usage-modal' })
25
+ dispatchGlobal({ type: 'open-quotas-modal' })
26
26
  },
27
27
  []
28
28
  )
@@ -36,7 +36,7 @@ export function AIUsageIndicator() {
36
36
 
37
37
  if (entries.length === 0) {
38
38
  return (
39
- <box flexDirection="row" onMouseDown={openModal}>
39
+ <box flexDirection="row" onMouseDown={openQuotas}>
40
40
  <text fg={t.textMuted} selectable={false}>
41
41
 
42
42
  </text>
@@ -45,7 +45,7 @@ export function AIUsageIndicator() {
45
45
  }
46
46
 
47
47
  return (
48
- <box flexDirection="row" gap={2} onMouseDown={openModal}>
48
+ <box flexDirection="row" gap={2} onMouseDown={openQuotas}>
49
49
  {entries.map(({ snap, tool }) => {
50
50
  if (!snap) return null
51
51
 
@@ -14,6 +14,7 @@ import { ListItem } from '../primitives/list-item'
14
14
  import { SettingsRow } from './settings-row'
15
15
 
16
16
  const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
17
+
17
18
  /**
18
19
  * Share of the window the settings themselves are allowed. Without a cap the
19
20
  * value sits on the far edge of a wide terminal, a hundred columns from the label
@@ -81,7 +82,9 @@ export const SettingsView = memo(function SettingsView() {
81
82
  }, [])
82
83
 
83
84
  const section = SETTING_SECTIONS.find((s) => s.id === settings.sectionId)
84
- const sectionLabel = section?.label ?? 'Settings'
85
+ // The glyph rides with the label: a section that could not be found has
86
+ // neither, and the fallback title stands on its own.
87
+ const sectionTitle = section === undefined ? 'Settings' : `${section.glyph} ${section.label}`
85
88
  const sectionNote = section?.description
86
89
  // Same 1-cell seam the bar draws between itself and the terminal, so the two
87
90
  // views line up to the column.
@@ -103,7 +106,7 @@ export const SettingsView = memo(function SettingsView() {
103
106
  onClickIndex={handleSectionClick}
104
107
  title={
105
108
  <text fg={section.id === settings.sectionId ? t.text : t.textMuted}>
106
- {section.label}
109
+ {`${section.glyph} ${section.label}`}
107
110
  </text>
108
111
  }
109
112
  trailing={
@@ -127,7 +130,7 @@ export const SettingsView = memo(function SettingsView() {
127
130
  <box
128
131
  border
129
132
  borderColor={settings.pane === 'rows' ? t.borderActive : t.border}
130
- title={sectionLabel}
133
+ title={sectionTitle}
131
134
  padding={0}
132
135
  flexDirection="column"
133
136
  flexGrow={1}
@@ -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
+ }