rvlib-shipkit 0.38.1 → 1.0.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 (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,274 @@
1
+ // THE ONE RUN CHART: every check run a bar at its true start and length,
2
+ // parallel ones stacked in lanes, each invocation a band behind its bars, now
3
+ // on the right. The live strip under the header draws it, folded over a
4
+ // rolling window and unfolded over the whole record, and nothing else draws a
5
+ // run. Geometry is timeline-geometry.ts, view state chart-view.ts; this file
6
+ // only draws.
7
+ import { observer } from 'mobx-react-lite'
8
+ import { useEffect, useLayoutEffect, useRef } from 'react'
9
+ import type { RepoLane } from '../../ledger/live-timeline.ts'
10
+ import type { TimelineBar } from '../../ledger/run-timeline.ts'
11
+ import type { ChartTip, ChartView } from './chart-view.ts'
12
+ import { durationTone, fmtMs } from './dur.ts'
13
+ import { bandLabels, gapSlices, ROWS, ticks, xOf, type GapSlice, type PlacedBar } from './timeline-geometry.ts'
14
+ import { CACHED } from '../../core/outcome-words.ts'
15
+
16
+ /** who started a run, in words. A starter this build does not name reads as
17
+ * the word the record holds */
18
+ export const BY_WORD: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'daily pass', save: 'save trigger', unknown: 'before the record' }
19
+ export const byWords = (by: string): string => BY_WORD[by] ?? by
20
+
21
+ /** the one tone a bar wears: running in the accent, then the verdict, a
22
+ * cached grey, and a pass that ran in the duration tone the hub uses */
23
+ export const barTone = (b: TimelineBar): string =>
24
+ b.running === true ? 'run' : b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : b.replayed || b.status === 'skip' ? 'rep' : durationTone(b.ms) ?? 'plain'
25
+
26
+ /** what a bar did, in words: `passed in 900ms`, `running, 4 200ms so far` */
27
+ export const verdictWords = (b: TimelineBar, now?: number): string =>
28
+ b.running === true
29
+ ? `running, ${fmtMs((now ?? b.end) - b.start)} so far`
30
+ : b.replayed ? `${CACHED}, nothing ran` : `${b.status === 'pass' ? 'passed' : b.status === 'fail' ? 'failed' : b.status === 'warn' ? 'warned' : 'skipped'} in ${fmtMs(b.ms)}`
31
+
32
+ export const clockWords = (t: number): string => new Date(t).toLocaleTimeString([], { hour12: false })
33
+
34
+ /** what hovering an idle slice adds under its words: when it began, and
35
+ * when it ended or that it still grows */
36
+ export const gapTipWords = (g: GapSlice): string => (g.live ? `since ${clockWords(g.t0)}, nothing ran` : `${clockWords(g.t0)} to ${clockWords(g.t1)}, nothing ran`)
37
+
38
+ /** a point on the viewport: the tooltip is fixed there, out of every box
39
+ * of the strip that clips */
40
+ export type TipAt = { x: number; y: number }
41
+
42
+ /** what hovering a bar says: its check, its verdict with its duration or its
43
+ * elapsed time, when it started, the run and who started it */
44
+ export const barTip = (b: PlacedBar, now: number, at: TipAt): ChartTip => ({
45
+ ...at,
46
+ head: b.id,
47
+ tone: b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : 'idle',
48
+ lines: [verdictWords(b, now), `${clockWords(b.start)} · ${b.label} · ${byWords(b.by)}`],
49
+ })
50
+
51
+ /** right of the pointer, or left of it past the middle of the window, so a
52
+ * tooltip over the running bars at the right edge stays on screen */
53
+ export const tipStyle = (t: TipAt, viewportW: number): { left: number; top: number } | { right: number; top: number } =>
54
+ t.x > viewportW / 2 ? { right: viewportW - t.x, top: t.y } : { left: t.x, top: t.y }
55
+
56
+ /** under the element the pointer entered, at the pointer's x */
57
+ const tipAtEvent = (e: React.MouseEvent<Element>): TipAt => ({ x: e.clientX, y: e.currentTarget.getBoundingClientRect().bottom + 4 })
58
+
59
+ const Tip = observer(({ view }: { view: ChartView }) => {
60
+ const t = view.hover
61
+ if (t == null) return null
62
+ return (
63
+ <div className="tlaxtip" style={tipStyle(t, globalThis.innerWidth ?? Number.POSITIVE_INFINITY)}>
64
+ <b className={`tl ${t.tone}`}>{t.head}</b>
65
+ {t.lines.map((l) => <span key={l}>{l}</span>)}
66
+ </div>
67
+ )
68
+ })
69
+
70
+ export type ChartProps = {
71
+ view: ChartView
72
+ compact?: boolean
73
+ /** other repos with something in flight, one collapsed lane each */
74
+ repos?: RepoLane[]
75
+ /** keep now in sight: while the reader has not scrolled away, the view
76
+ * follows the right edge as the window slides */
77
+ follow?: boolean
78
+ onBar: (b: PlacedBar) => void
79
+ onRepo?: (r: RepoLane) => void
80
+ /** the zoom control, floating over the top right corner: shown while the
81
+ * pointer is on the chart and while it is zoomed away from home */
82
+ zoomable?: boolean
83
+ }
84
+
85
+ /** the key that brings a chart that stopped following back to now */
86
+ export const FOLLOW_KEY = 'k'
87
+
88
+ /** how far a press moves before it is a pan rather than a click */
89
+ export const DRAG_PX = 4
90
+
91
+ /** a press that moved this far is a pan, and never also a click */
92
+ export const isPan = (x0: number, x: number): boolean => Math.abs(x - x0) >= DRAG_PX
93
+
94
+ export const TimelineChart = observer(({ view, compact = false, repos = [], follow = false, onBar, onRepo, zoomable = false }: ChartProps) => {
95
+ const cap = view.cap
96
+ const ref = useRef<HTMLDivElement>(null)
97
+ /** where to scroll once the next zoom has rendered */
98
+ const pending = useRef<number | null>(null)
99
+ const placed = useRef(false)
100
+ /** a pan in progress: a tooltip fixed to the window would stay behind
101
+ * while the bars move under it, so none opens */
102
+ const panning = useRef(false)
103
+ const hover = (t: ChartTip | null): void => {
104
+ if (t == null || !panning.current) view.setHover(t)
105
+ }
106
+ useEffect(() => {
107
+ const el = ref.current
108
+ if (el == null) return
109
+ view.setViewW(el.clientWidth)
110
+ const ro = new ResizeObserver(() => view.setViewW(el.clientWidth))
111
+ ro.observe(el)
112
+ // a pan away from the right edge stops following, a pan back to it
113
+ // follows again (chart-view.ts scrolled)
114
+ const onScroll = (): void => { if (follow) view.scrolled(el.scrollLeft, el.clientWidth) }
115
+ el.addEventListener('scroll', onScroll)
116
+ // a vertical wheel zooms, so it must be able to stop the page scroll:
117
+ // react attaches wheel listeners passive, which cannot
118
+ const onWheel = (e: WheelEvent): void => {
119
+ if (compact || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
120
+ e.preventDefault()
121
+ pending.current = view.wheel(e.deltaY, e.clientX - el.getBoundingClientRect().left, el.scrollLeft)
122
+ }
123
+ el.addEventListener('wheel', onWheel, { passive: false })
124
+ // the pan is a drag: the chart shows no scrollbar. A drag past a few
125
+ // px is a pan and never also a click on the bar it started on
126
+ let drag: { x: number; left: number; moved: boolean } | null = null
127
+ let swallow = false
128
+ const onDown = (e: PointerEvent): void => { if (!compact && e.button === 0) drag = { x: e.clientX, left: el.scrollLeft, moved: false } }
129
+ const onMove = (e: PointerEvent): void => {
130
+ if (drag == null) return
131
+ if (!drag.moved && !isPan(drag.x, e.clientX)) return
132
+ if (!drag.moved) {
133
+ drag.moved = true
134
+ panning.current = true
135
+ view.setHover(null)
136
+ }
137
+ el.classList.add('dragging')
138
+ el.scrollLeft = drag.left - (e.clientX - drag.x)
139
+ }
140
+ const onUp = (): void => {
141
+ swallow = drag?.moved === true
142
+ drag = null
143
+ panning.current = false
144
+ el.classList.remove('dragging')
145
+ }
146
+ const onClick = (e: MouseEvent): void => {
147
+ if (!swallow) return
148
+ swallow = false
149
+ e.stopPropagation()
150
+ e.preventDefault()
151
+ }
152
+ el.addEventListener('pointerdown', onDown)
153
+ window.addEventListener('pointermove', onMove)
154
+ window.addEventListener('pointerup', onUp)
155
+ el.addEventListener('click', onClick, true)
156
+ return () => {
157
+ ro.disconnect()
158
+ el.removeEventListener('wheel', onWheel)
159
+ el.removeEventListener('scroll', onScroll)
160
+ el.removeEventListener('pointerdown', onDown)
161
+ window.removeEventListener('pointermove', onMove)
162
+ window.removeEventListener('pointerup', onUp)
163
+ el.removeEventListener('click', onClick, true)
164
+ }
165
+ }, [view, compact, follow])
166
+ const l = view.layout
167
+ useLayoutEffect(() => {
168
+ const el = ref.current
169
+ if (el == null || l == null) return
170
+ if (pending.current != null) {
171
+ el.scrollLeft = pending.current
172
+ pending.current = null
173
+ } else if (!placed.current || (follow && view.follow)) {
174
+ // following: now is the right edge, and that is where the eye is
175
+ el.scrollLeft = el.scrollWidth
176
+ placed.current = true
177
+ }
178
+ })
179
+ const r = compact ? ROWS.compact : ROWS.full
180
+ const lanes = (l?.lanes ?? 0) + repos.length
181
+ const lanesH = lanes * (r.lane + r.gap)
182
+ const top = r.band + r.conc
183
+ const h = top + Math.max(r.lane, lanesH) + r.axis
184
+ const laneY = (n: number): number => top + n * (r.lane + r.gap)
185
+ // the concurrency band: the count on one scale that always fits the cap
186
+ const concBase = r.band + r.conc - 2
187
+ const concUnit = (r.conc - 4) / Math.max(1, l?.concMax ?? 0, cap ?? 0)
188
+ const now = l?.axis.now ?? 0
189
+ const zoomBy = (f: number): void => {
190
+ const el = ref.current
191
+ if (el != null) pending.current = view.zoomBy(f, el.clientWidth / 2, el.scrollLeft)
192
+ }
193
+ const chart = (
194
+ <div className={`tlax${compact ? ' compact' : ''}`} ref={ref}>
195
+ {l == null ? <div style={{ height: h }} /> : (
196
+ <div className="tlaxin" style={{ width: l.axis.width }}>
197
+ <svg className="tlsvg" width={l.axis.width} height={h}>
198
+ {l.bands.map((b) => (
199
+ <rect key={b.runId} className={`tlband${b.reds > 0 ? ' red' : ''}${b.running ? ' run' : ''}`} x={b.x} y={0} width={Math.max(1, b.w)} height={top + lanesH} />
200
+ ))}
201
+ {compact ? null : bandLabels(l.bands).map((b) => (
202
+ <text key={b.runId} className="tlbandlbl" x={b.x} y={11}
203
+ onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: b.full, tone: 'idle', lines: [] })}
204
+ onMouseLeave={() => hover(null)}>{b.text}</text>
205
+ ))}
206
+ {compact ? null : (
207
+ <g className="tlconcg">
208
+ {l.conc.map((c) => (
209
+ <rect key={c.x} className={`tlconc${c.over ? ' over' : ''}`} x={c.x} y={concBase - c.n * concUnit} width={Math.max(1, c.w)} height={c.n * concUnit}
210
+ onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: `${c.n} in flight`, tone: c.over ? 'warn' : 'idle', lines: cap == null ? [] : [`cap ${cap}${c.over ? `, ${c.n - cap} over it` : ''}`] })}
211
+ onMouseLeave={() => hover(null)} />
212
+ ))}
213
+ {cap == null ? null : (
214
+ <>
215
+ <line className="tlcap" x1={0} x2={l.axis.width} y1={concBase - cap * concUnit} y2={concBase - cap * concUnit} />
216
+ <text className="tlcaplbl" x={l.axis.width - 4} y={concBase - cap * concUnit - 2} textAnchor="end">cap {cap}</text>
217
+ </>
218
+ )}
219
+ </g>
220
+ )}
221
+ {compact ? null : gapSlices(l.axis).map((g) => (
222
+ <g key={g.t0} className={`tlgap${g.live ? ' live' : ''}`}
223
+ onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: g.full, tone: 'idle', lines: [gapTipWords(g)] })}
224
+ onMouseLeave={() => hover(null)}>
225
+ <rect x={g.x} y={0} width={g.w} height={h} />
226
+ {g.text == null ? null : <text x={g.x + g.w / 2} y={top + Math.max(r.lane, lanesH) / 2 + 3} textAnchor="middle">{g.text}</text>}
227
+ </g>
228
+ ))}
229
+ {compact ? null : ticks(l.axis).map((k) => (
230
+ <g key={k.t} className="tltick">
231
+ <line x1={k.x} x2={k.x} y1={top + lanesH} y2={top + lanesH + 4} />
232
+ <text x={k.x} y={h - 5} textAnchor="middle">{clockWords(k.t)}</text>
233
+ </g>
234
+ ))}
235
+ {l.bars.map((b) => (
236
+ <rect key={`${b.runId}:${b.id}:${b.start}`} className={`tlb ${barTone(b)}`} x={b.x} y={laneY(b.lane)} width={Math.max(2, b.w)} height={r.lane}
237
+ onMouseEnter={(e) => hover(barTip(b, now, tipAtEvent(e)))}
238
+ onMouseLeave={() => hover(null)}
239
+ onClick={() => onBar(b)} />
240
+ ))}
241
+ {repos.map((rl, k) => {
242
+ const x = xOf(l.axis, Math.max(rl.start, l.axis.segs[0]?.t0 ?? rl.start))
243
+ const lane = l.lanes + k
244
+ return (
245
+ <rect key={rl.repo} className="tlb repo" x={x} y={laneY(lane)} width={Math.max(2, l.axis.nowX - x)} height={r.lane}
246
+ onMouseEnter={(e) => hover({ ...tipAtEvent(e), head: rl.repo, tone: 'idle', lines: [`${rl.running} running: ${rl.checks.slice(0, 4).join(', ')}${rl.checks.length > 4 ? ', …' : ''}`, `${rl.label} · ${byWords(rl.by)}`, onRepo == null ? '' : 'click opens its hub when it serves'].filter((x) => x !== '') })}
247
+ onMouseLeave={() => hover(null)}
248
+ onClick={() => onRepo?.(rl)} />
249
+ )
250
+ })}
251
+ <line className="tlnow" x1={l.axis.nowX} x2={l.axis.nowX} y1={0} y2={top + lanesH} />
252
+ </svg>
253
+ <Tip view={view} />
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ if (!zoomable && !follow) return chart
259
+ return (
260
+ <div className={`tlchart${view.zoomed ? ' zoomed' : ''}`}>
261
+ {chart}
262
+ <span className="tlfloat">
263
+ {follow && !view.follow
264
+ ? <button className="tllive" onClick={() => view.attach()} title={`the view stays where it is; back to now and follow it (${FOLLOW_KEY})`}>● live</button>
265
+ : null}
266
+ {zoomable ? <span className="seg tlzoom">
267
+ <button onClick={() => zoomBy(1 / 2)} disabled={view.level <= 1} title="zoom out">−</button>
268
+ <button onClick={() => zoomBy(1 / view.level)} disabled={view.level <= 1} title="fit the whole record">fit</button>
269
+ <button onClick={() => zoomBy(2)} disabled={view.level >= view.maxZoom} title="zoom in (or the wheel over the chart)">+</button>
270
+ </span> : null}
271
+ </span>
272
+ </div>
273
+ )
274
+ })
@@ -0,0 +1,314 @@
1
+ // The horizontal run timeline, as geometry: one time axis with now on the
2
+ // right, every bar at its true start and length, parallel bars stacked in
3
+ // lanes, and the zoom math. Pure, so a test pins every position and the view
4
+ // only draws what this returns. The live strip is its one user, with two
5
+ // windows: folded, a rolling window that ends now; unfolded, the whole record.
6
+ //
7
+ // ONE rule bends a plain linear axis over the record: an idle stretch longer
8
+ // than GAP_MS is folded to a fixed GAP_PX slice that says how long it was,
9
+ // the trailing one (last run to now) included, whose words grow with the
10
+ // clock. Runs are seconds long and hours apart, and a linear axis over twelve
11
+ // of them draws every bar as the same hairline. Inside a stretch of activity
12
+ // the scale is linear and the same for every stretch, so two bars side by
13
+ // side compare by width.
14
+ import type { TimelineBar, TimelineInvocation } from '../../ledger/run-timeline.ts'
15
+
16
+ /** an idle stretch longer than this is folded into a break */
17
+ export const GAP_MS = 60_000
18
+ /** how wide a folded idle slice is drawn: room for `idle 29m 55s` */
19
+ export const GAP_PX = 80
20
+ /** the margin at either end of the axis */
21
+ export const PAD_PX = 8
22
+ /** the zoom range. 1 fits the whole record in the view */
23
+ export const ZOOM_MIN = 1
24
+ /** the finest scale the zoom reaches, in px per ms: a millisecond is never
25
+ * worth more than this */
26
+ export const MAX_PX_PER_MS = 0.5
27
+ /** ticks are never closer than this */
28
+ export const TICK_MIN_PX = 72
29
+
30
+ export type Span = { start: number; end: number }
31
+
32
+ /** the rows of a chart, in px: band labels, the concurrency band, the lanes,
33
+ * the time axis. Compact is the folded strip: thin lanes and nothing else */
34
+ export const ROWS = {
35
+ full: { band: 16, conc: 20, lane: 8, gap: 2, axis: 18 },
36
+ compact: { band: 0, conc: 0, lane: 3, gap: 1, axis: 0 },
37
+ } as const
38
+
39
+ /** how tall a chart with `lanes` lanes draws */
40
+ export const chartHeight = (lanes: number, compact = false): number => {
41
+ const r = compact ? ROWS.compact : ROWS.full
42
+ return r.band + r.conc + Math.max(r.lane, lanes * (r.lane + r.gap)) + r.axis
43
+ }
44
+
45
+ /** a stretch of activity drawn linearly, from x0 */
46
+ export type Segment = { t0: number; t1: number; x0: number }
47
+ /** a folded idle stretch, drawn at x, GAP_PX wide */
48
+ export type Gap = { x: number; t0: number; t1: number }
49
+
50
+ export type Axis = { segs: Segment[]; gaps: Gap[]; pxPerMs: number; width: number; now: number; nowX: number }
51
+
52
+ /** the stretches of activity: every span merged with the next one when the
53
+ * idle time between them is short enough to draw as it is, and the last one
54
+ * carried to now when now is close */
55
+ export function stretches(spans: Span[], now: number, gapMs = GAP_MS): Span[] {
56
+ const sorted = spans.filter((s) => s.end >= s.start).sort((a, b) => a.start - b.start)
57
+ const out: Span[] = []
58
+ for (const s of sorted) {
59
+ const last = out.at(-1)
60
+ if (last != null && s.start - last.end <= gapMs) last.end = Math.max(last.end, s.end)
61
+ else out.push({ ...s })
62
+ }
63
+ const last = out.at(-1)
64
+ if (last == null) return [{ start: now, end: now }]
65
+ if (now - last.end <= gapMs) last.end = Math.max(last.end, now)
66
+ return out
67
+ }
68
+
69
+ /** a rolling window: one linear stretch from `from` to now, no breaks */
70
+ export function windowAxis(from: number, now: number, pxPerMs: number): Axis {
71
+ const seg = { t0: from, t1: now, x0: PAD_PX }
72
+ const nowX = PAD_PX + (now - from) * pxPerMs
73
+ return { segs: [seg], gaps: [], pxPerMs, width: nowX + PAD_PX, now, nowX }
74
+ }
75
+
76
+ /** the scale that fits a rolling window in `viewW` */
77
+ export const fitWindow = (from: number, now: number, viewW: number): number => Math.max(1, viewW - 2 * PAD_PX) / Math.max(1, now - from)
78
+
79
+ /** the scale that fits every stretch and every break in `viewW` */
80
+ export function fitScale(spans: Span[], now: number, viewW: number): number {
81
+ const st = stretches(spans, now)
82
+ const active = st.reduce((a, s) => a + (s.end - s.start), 0)
83
+ const breaks = st.length - 1 + (now > st.at(-1)!.end ? 1 : 0)
84
+ const room = Math.max(1, viewW - 2 * PAD_PX - breaks * GAP_PX)
85
+ return active <= 0 ? MAX_PX_PER_MS : Math.min(MAX_PX_PER_MS, room / active)
86
+ }
87
+
88
+ /** the axis at a given scale */
89
+ export function buildAxis(spans: Span[], now: number, pxPerMs: number): Axis {
90
+ const st = stretches(spans, now)
91
+ const segs: Segment[] = []
92
+ const gaps: Gap[] = []
93
+ let x = PAD_PX
94
+ st.forEach((s, i) => {
95
+ if (i > 0) {
96
+ gaps.push({ x, t0: st[i - 1]!.end, t1: s.start })
97
+ x += GAP_PX
98
+ }
99
+ segs.push({ t0: s.start, t1: s.end, x0: x })
100
+ x += (s.end - s.start) * pxPerMs
101
+ })
102
+ const lastEnd = st.at(-1)!.end
103
+ if (now > lastEnd) {
104
+ // the trailing slice: fixed width, its words grow with the clock
105
+ gaps.push({ x, t0: lastEnd, t1: now })
106
+ x += GAP_PX
107
+ }
108
+ return { segs, gaps, pxPerMs, width: x + PAD_PX, now, nowX: x }
109
+ }
110
+
111
+ /** where a moment is drawn. A moment inside a break sits on the break's
112
+ * proportional point, so the inverse below round trips everywhere */
113
+ export function xOf(a: Axis, t: number): number {
114
+ for (const s of a.segs) if (t >= s.t0 && t <= s.t1) return s.x0 + (t - s.t0) * a.pxPerMs
115
+ for (const g of a.gaps) if (t > g.t0 && t < g.t1) return g.x + ((t - g.t0) / (g.t1 - g.t0)) * GAP_PX
116
+ const first = a.segs[0]
117
+ if (first != null && t < first.t0) return PAD_PX
118
+ return a.nowX
119
+ }
120
+
121
+ /** which moment is drawn at x */
122
+ export function tOf(a: Axis, x: number): number {
123
+ for (const s of a.segs) {
124
+ const x1 = s.x0 + (s.t1 - s.t0) * a.pxPerMs
125
+ if (x >= s.x0 && x <= x1) return s.t0 + (x - s.x0) / a.pxPerMs
126
+ }
127
+ for (const g of a.gaps) if (x > g.x && x < g.x + GAP_PX) return g.t0 + ((x - g.x) / GAP_PX) * (g.t1 - g.t0)
128
+ return x < PAD_PX ? (a.segs[0]?.t0 ?? a.now) : a.now
129
+ }
130
+
131
+ /** each bar's lane: the first lane free by the time it starts, so bars that
132
+ * ran at once stack and bars that ran one after another share a lane */
133
+ export function packLanes(bars: Span[], cap = Number.POSITIVE_INFINITY): { lanes: number[]; count: number; hidden: number } {
134
+ const order = bars.map((b, i) => i).sort((i, j) => bars[i]!.start - bars[j]!.start || bars[i]!.end - bars[j]!.end)
135
+ const ends: number[] = []
136
+ // -1 = past the cap: counted, never drawn over another bar
137
+ const lanes: number[] = new Array(bars.length).fill(-1)
138
+ let hidden = 0
139
+ for (const i of order) {
140
+ const b = bars[i]!
141
+ let lane = ends.findIndex((e) => e <= b.start)
142
+ if (lane === -1) {
143
+ if (ends.length >= cap) { hidden++; continue }
144
+ lane = ends.length
145
+ ends.push(b.end)
146
+ } else ends[lane] = b.end
147
+ lanes[i] = lane
148
+ }
149
+ return { lanes, count: ends.length, hidden }
150
+ }
151
+
152
+ /** the zoom after one wheel step, clamped to the range this record allows */
153
+ export function zoomStep(zoom: number, deltaY: number, maxZoom: number): number {
154
+ return Math.min(maxZoom, Math.max(ZOOM_MIN, zoom * Math.exp(-deltaY * 0.002)))
155
+ }
156
+
157
+ /** the most a record can be zoomed: until a millisecond is MAX_PX_PER_MS */
158
+ export const maxZoom = (fit: number): number => Math.max(ZOOM_MIN, MAX_PX_PER_MS / fit)
159
+
160
+ /** where to scroll after a zoom so the moment under the pointer stays under
161
+ * it. `cx` is the pointer's offset inside the viewport */
162
+ export function anchorScroll(before: Axis, after: Axis, scrollLeft: number, cx: number): number {
163
+ const t = tOf(before, scrollLeft + cx)
164
+ return Math.max(0, xOf(after, t) - cx)
165
+ }
166
+
167
+ const STEPS = [100, 250, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000, 3_600_000]
168
+
169
+ /** the tick step for a scale: the smallest round step at least TICK_MIN_PX wide */
170
+ export const tickStep = (pxPerMs: number): number => STEPS.find((s) => s * pxPerMs >= TICK_MIN_PX) ?? STEPS.at(-1)!
171
+
172
+ /** the ticks along every stretch, at round moments of the clock */
173
+ export function ticks(a: Axis): { x: number; t: number }[] {
174
+ const step = tickStep(a.pxPerMs)
175
+ const out: { x: number; t: number }[] = []
176
+ for (const s of a.segs) {
177
+ for (let t = Math.ceil(s.t0 / step) * step; t <= s.t1; t += step) out.push({ x: xOf(a, t), t })
178
+ }
179
+ return out
180
+ }
181
+
182
+ /** an idle stretch, in short units: `42s`, `4m 12s`, `3h 5m`, `2d 4h`.
183
+ * Floored, so a stretch that grows with the clock turns over once a second,
184
+ * and seconds are dropped past an hour */
185
+ export function idleWords(ms: number): string {
186
+ const s = Math.max(0, Math.floor(ms / 1_000))
187
+ if (s < 60) return `${s}s`
188
+ const m = Math.floor(s / 60)
189
+ if (m < 60) return `${m}m ${s % 60}s`
190
+ const h = Math.floor(m / 60)
191
+ if (h < 48) return `${h}h ${m % 60}m`
192
+ return `${Math.floor(h / 24)}d ${h % 24}h`
193
+ }
194
+
195
+ /** a folded idle stretch as drawn: its slice, the words inside it (null when
196
+ * not even the bare duration fits, the tooltip then says it), the whole
197
+ * words, and whether it is the trailing one, growing until the next run */
198
+ export type GapSlice = { x: number; w: number; t0: number; t1: number; text: string | null; full: string; live: boolean }
199
+
200
+ /** every idle slice of an axis: `idle 4m 12s` when it fits, `4m 12s` when
201
+ * only the duration does, no words when not even that */
202
+ export function gapSlices(a: Axis, charPx = 6, padPx = 3): GapSlice[] {
203
+ const room = Math.floor((GAP_PX - 2 * padPx) / charPx)
204
+ return a.gaps.map((g) => {
205
+ const bare = idleWords(g.t1 - g.t0)
206
+ const full = `idle ${bare}`
207
+ const text = full.length <= room ? full : bare.length <= room ? bare : null
208
+ return { x: g.x, w: GAP_PX, t0: g.t0, t1: g.t1, text, full, live: g.t1 === a.now }
209
+ })
210
+ }
211
+
212
+ export type PlacedBar = TimelineBar & { x: number; w: number; lane: number; runId: string; label: string; by: string }
213
+ export type PlacedBand = { runId: string; label: string; x: number; w: number; reds: number; running: boolean }
214
+
215
+ /** one step of the concurrency band: `n` checks in flight from x for w px */
216
+ export type ConcStep = { x: number; w: number; n: number; over: boolean }
217
+
218
+ export type Layout = {
219
+ axis: Axis
220
+ bars: PlacedBar[]
221
+ bands: PlacedBand[]
222
+ lanes: number
223
+ hidden: number
224
+ /** how many checks were in flight at each moment, as steps */
225
+ conc: ConcStep[]
226
+ /** the most in flight at once in view */
227
+ concMax: number
228
+ }
229
+
230
+ /** how many bars are in flight at each moment, as a step function: the
231
+ * count holds from each point to the next. An end and a start at the same
232
+ * moment do not stack, and a bar of no length (a replay) is never in flight */
233
+ export function concurrency(bars: Span[]): { t: number; n: number }[] {
234
+ const ev: [number, number][] = []
235
+ for (const b of bars) if (b.end > b.start) ev.push([b.start, 1], [b.end, -1])
236
+ ev.sort((a, b) => a[0] - b[0] || a[1] - b[1])
237
+ const out: { t: number; n: number }[] = []
238
+ let n = 0
239
+ for (const [t, d] of ev) {
240
+ n += d
241
+ const last = out.at(-1)
242
+ if (last != null && last.t === t) last.n = n
243
+ else out.push({ t, n })
244
+ }
245
+ return out
246
+ }
247
+
248
+ /** the stretches where the count ran past the cap */
249
+ export function overCap(series: { t: number; n: number }[], cap: number): Span[] {
250
+ const out: Span[] = []
251
+ series.forEach((p, i) => {
252
+ const next = series[i + 1]
253
+ if (p.n > cap && next != null) {
254
+ const last = out.at(-1)
255
+ if (last != null && last.end === p.t) last.end = next.t
256
+ else out.push({ start: p.t, end: next.t })
257
+ }
258
+ })
259
+ return out
260
+ }
261
+
262
+ /** the label each band shows: whole when it fits before the next band's
263
+ * label, cut with an ellipsis when only part fits, none when not even a
264
+ * few letters do. The full label stays on hover */
265
+ export function bandLabels(bands: { runId: string; label: string; x: number; w: number }[], charPx = 6, padPx = 3): { runId: string; x: number; text: string; full: string }[] {
266
+ const sorted = [...bands].sort((a, b) => a.x - b.x || a.runId.localeCompare(b.runId))
267
+ const out: { runId: string; x: number; text: string; full: string }[] = []
268
+ sorted.forEach((b, i) => {
269
+ const next = sorted.slice(i + 1).find((o) => o.x > b.x)
270
+ const blocked = sorted.slice(i + 1).some((o) => o.x === b.x)
271
+ if (blocked) return
272
+ const room = Math.min(b.w, (next?.x ?? Number.POSITIVE_INFINITY) - b.x) - 2 * padPx
273
+ const fits = Math.floor(room / charPx)
274
+ if (fits >= b.label.length) out.push({ runId: b.runId, x: b.x + padPx, text: b.label, full: b.label })
275
+ else if (fits >= 4) out.push({ runId: b.runId, x: b.x + padPx, text: `${b.label.slice(0, fits - 1)}…`, full: b.label })
276
+ })
277
+ return out
278
+ }
279
+
280
+ /** every invocation laid on one axis: a band per invocation, and its bars in
281
+ * lanes shared across the whole axis. With `from`, the axis is the rolling
282
+ * window from there to now, and what lies wholly before it is not drawn. A
283
+ * bar still running ends at now. `laneCap` bounds the lanes, for a strip of
284
+ * a fixed height */
285
+ export function layoutTimeline(invs: TimelineInvocation[], now: number, pxPerMs: number, o: { from?: number; laneCap?: number; cap?: number } = {}): Layout {
286
+ const from = o.from
287
+ const inView = (end: number): boolean => from == null || end >= from
288
+ const endOf = (e: number, running?: boolean): number => (running === true ? now : e)
289
+ const shown = invs.filter((i) => inView(endOf(i.ended, i.running)))
290
+ const axis = from == null ? buildAxis(shown.map((i) => ({ start: i.started, end: endOf(i.ended, i.running) })), now, pxPerMs) : windowAxis(from, now, pxPerMs)
291
+ const flat = shown.flatMap((i) => i.bars.map((b) => ({ ...b, end: endOf(b.end, b.running), runId: i.runId, label: i.label, by: i.by }))).filter((b) => inView(b.end))
292
+ const { lanes, count, hidden } = packLanes(flat, o.laneCap)
293
+ const clip = (t: number): number => (from == null ? t : Math.max(from, t))
294
+ const bars = flat.flatMap((b, i) => {
295
+ if (lanes[i]! < 0) return []
296
+ const x = xOf(axis, clip(b.start))
297
+ return [{ ...b, x, w: xOf(axis, clip(b.end)) - x, lane: lanes[i]! }]
298
+ })
299
+ const bands = shown.map((i) => {
300
+ const x = xOf(axis, clip(i.started))
301
+ return { runId: i.runId, label: i.label, x, w: xOf(axis, clip(endOf(i.ended, i.running))) - x, reds: i.reds, running: i.running === true }
302
+ })
303
+ // every bar counts, the ones past the lane cap included: the band says
304
+ // how many ran, the lanes only what fits
305
+ const series = concurrency(flat.map((b) => ({ start: clip(b.start), end: b.end })))
306
+ const conc: ConcStep[] = []
307
+ series.forEach((p, i) => {
308
+ const next = series[i + 1]
309
+ if (next == null || p.n === 0) return
310
+ const x = xOf(axis, p.t)
311
+ conc.push({ x, w: xOf(axis, next.t) - x, n: p.n, over: o.cap != null && p.n > o.cap })
312
+ })
313
+ return { axis, bars, bands, lanes: count, hidden, conc, concMax: Math.max(0, ...series.map((p) => p.n)) }
314
+ }
@@ -0,0 +1,62 @@
1
+ // The runs page, as it holds the record: what the server shaped from the run
2
+ // record (ledger/run-timeline.ts), and which invocations the reader opened.
3
+ // Loaded when the page opens and again after a run ends, so it is never a
4
+ // poll the rest of the page pays for. The live strip's unfolded chart reads
5
+ // the same record from here for the runs older than its own window.
6
+ import { testId } from '../../collections/collection-types.ts'
7
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
8
+ import type { Timeline } from '../../ledger/run-timeline.ts'
9
+ import type { HubSt } from './hub-st.ts'
10
+
11
+ export type { Timeline }
12
+
13
+ export class TimelineSvc {
14
+ data: Timeline | null = null
15
+ error = ''
16
+ loading = false
17
+ /** what the reader opened and closed. The newest invocation is open until
18
+ * the reader closes it, every other one is shut until opened */
19
+ opened = new Set<string>()
20
+ closed = new Set<string>()
21
+ constructor(private st: HubSt) {
22
+ makeAutoObservable<this, 'st'>(this, { st: false, data: observableRef })
23
+ }
24
+
25
+ isOpen(runId: string, index: number): boolean {
26
+ return this.opened.has(runId) || (index === 0 && !this.closed.has(runId))
27
+ }
28
+
29
+ toggle(runId: string, index: number): void {
30
+ if (this.isOpen(runId, index)) {
31
+ this.opened.delete(runId)
32
+ this.closed.add(runId)
33
+ } else {
34
+ this.closed.delete(runId)
35
+ this.opened.add(runId)
36
+ }
37
+ }
38
+
39
+ /** the check page, from a bar or a row */
40
+ openCheck(checkId: string): void {
41
+ this.st.cols.select('checks', `check:${checkId}`)
42
+ }
43
+
44
+ /** a test file's page, from a row of the to fix list */
45
+ openTest(file: string): void {
46
+ this.st.cols.select('tests', testId(file))
47
+ }
48
+
49
+ async load(): Promise<void> {
50
+ runInAction(() => { this.loading = true })
51
+ try {
52
+ const r = await fetch('/api/timeline.json')
53
+ if (!r.ok) throw new Error(await r.text())
54
+ const t = (await r.json()) as Timeline
55
+ runInAction(() => { this.data = t; this.error = '' })
56
+ } catch (e) {
57
+ runInAction(() => { this.error = (e as Error).message })
58
+ } finally {
59
+ runInAction(() => { this.loading = false })
60
+ }
61
+ }
62
+ }