@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.
- 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,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
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -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 }
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
| '
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
|
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
|
-
{
|
|
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
|
|
21
|
+
const openQuotas = useCallback(
|
|
22
22
|
(e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
|
23
23
|
e.preventDefault()
|
|
24
24
|
e.stopPropagation()
|
|
25
|
-
dispatchGlobal({ type: 'open-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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={
|
|
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
|
+
}
|