paseo-plugin-genui 0.0.0-stage → 0.1.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 (375) hide show
  1. package/LICENSE +202 -0
  2. package/OVERVIEW.md +21 -0
  3. package/README.md +77 -2
  4. package/assets/fonts/Excalifont-OFL.txt +109 -0
  5. package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +1 -0
  8. package/assets/screenshot-chat.png +0 -0
  9. package/assets/screenshot-gallery.png +0 -0
  10. package/assets/screenshot-playground.png +0 -0
  11. package/client/components/content.tsx +468 -0
  12. package/client/components/data.tsx +967 -0
  13. package/client/components/dev.tsx +998 -0
  14. package/client/components/diagram.tsx +513 -0
  15. package/client/components/inputs.tsx +994 -0
  16. package/client/components/layout.tsx +239 -0
  17. package/client/components/media.tsx +428 -0
  18. package/client/components/project.tsx +641 -0
  19. package/client/components/widgets.tsx +836 -0
  20. package/client/gallery.tsx +449 -0
  21. package/client/live-ui.tsx +525 -0
  22. package/client/loaders.ts +342 -0
  23. package/client/markdown.tsx +172 -0
  24. package/client/node-renderer.tsx +149 -0
  25. package/client/render.tsx +186 -0
  26. package/client/runtime.ts +103 -0
  27. package/client/settings.tsx +57 -0
  28. package/client/web.ts +186 -0
  29. package/gallery/Acceptance-baseline.ui +1 -0
  30. package/gallery/Acceptance-checklist.ui +14 -0
  31. package/gallery/Acceptance-evidence.ui +9 -0
  32. package/gallery/Acceptance.ui +1 -0
  33. package/gallery/Badge.ui +3 -0
  34. package/gallery/Benchmark-context.ui +8 -0
  35. package/gallery/Benchmark-hyperfine.ui +1 -0
  36. package/gallery/Benchmark-runs-list.ui +9 -0
  37. package/gallery/Benchmark-runs.ui +1 -0
  38. package/gallery/Benchmark-vitest.ui +1 -0
  39. package/gallery/Benchmark.ui +9 -0
  40. package/gallery/Board-bb-tasks.ui +1 -0
  41. package/gallery/Board-gh-issues.ui +1 -0
  42. package/gallery/Board-gh-prs.ui +1 -0
  43. package/gallery/Board.ui +12 -0
  44. package/gallery/Button-destructive.ui +4 -0
  45. package/gallery/Button.ui +5 -0
  46. package/gallery/Calendar.ui +15 -0
  47. package/gallery/CalendarHeatmap-short.ui +11 -0
  48. package/gallery/CalendarHeatmap.ui +4 -0
  49. package/gallery/Callout.ui +4 -0
  50. package/gallery/Card.ui +4 -0
  51. package/gallery/Carousel.ui +8 -0
  52. package/gallery/Changes-paths.ui +1 -0
  53. package/gallery/Changes-uncommitted.ui +1 -0
  54. package/gallery/Changes.ui +1 -0
  55. package/gallery/Chart-area.ui +3 -0
  56. package/gallery/Chart-funnel.ui +3 -0
  57. package/gallery/Chart-heatmap.ui +11 -0
  58. package/gallery/Chart-pie.ui +3 -0
  59. package/gallery/Chart-radar.ui +3 -0
  60. package/gallery/Chart-scatter.ui +4 -0
  61. package/gallery/Chart-stacked.ui +5 -0
  62. package/gallery/Chart.ui +3 -0
  63. package/gallery/Checklist.ui +6 -0
  64. package/gallery/Choice.ui +8 -0
  65. package/gallery/Code.ui +3 -0
  66. package/gallery/Commits-limit.ui +1 -0
  67. package/gallery/Commits.ui +1 -0
  68. package/gallery/Compare-conditions.ui +4 -0
  69. package/gallery/Compare-three.ui +7 -0
  70. package/gallery/Compare.ui +4 -0
  71. package/gallery/Coverage-go.ui +1 -0
  72. package/gallery/Coverage-lcov.ui +1 -0
  73. package/gallery/Coverage-python.ui +1 -0
  74. package/gallery/Coverage.ui +1 -0
  75. package/gallery/DateInput.ui +5 -0
  76. package/gallery/Decision-percent.ui +10 -0
  77. package/gallery/Decision.ui +12 -0
  78. package/gallery/Dependencies-audit.ui +1 -0
  79. package/gallery/Dependencies-both.ui +1 -0
  80. package/gallery/Dependencies-list.ui +7 -0
  81. package/gallery/Dependencies-pip.ui +1 -0
  82. package/gallery/Dependencies-pnpm.ui +1 -0
  83. package/gallery/Dependencies.ui +1 -0
  84. package/gallery/Details.ui +4 -0
  85. package/gallery/Diagram-flow.ui +16 -0
  86. package/gallery/Diagram-sequence.ui +15 -0
  87. package/gallery/Diagram.ui +16 -0
  88. package/gallery/Diff.ui +3 -0
  89. package/gallery/Divider.ui +5 -0
  90. package/gallery/EditableTable.ui +9 -0
  91. package/gallery/Entity-list.ui +6 -0
  92. package/gallery/Entity.ui +8 -0
  93. package/gallery/Environments.ui +1 -0
  94. package/gallery/Estimate-points.ui +6 -0
  95. package/gallery/Estimate.ui +8 -0
  96. package/gallery/File.ui +4 -0
  97. package/gallery/Findings-biome.ui +1 -0
  98. package/gallery/Findings-eslint.ui +1 -0
  99. package/gallery/Findings-oxlint.ui +1 -0
  100. package/gallery/Findings-review.ui +1 -0
  101. package/gallery/Findings-ruff.ui +1 -0
  102. package/gallery/Findings-sarif.ui +1 -0
  103. package/gallery/Findings-ship.ui +11 -0
  104. package/gallery/Findings-tsc.ui +1 -0
  105. package/gallery/Findings.ui +7 -0
  106. package/gallery/Flashcards.ui +3 -0
  107. package/gallery/FollowUps.ui +4 -0
  108. package/gallery/Form.ui +8 -0
  109. package/gallery/Gallery-links.ui +8 -0
  110. package/gallery/Gallery-strip.ui +14 -0
  111. package/gallery/Gallery.ui +10 -0
  112. package/gallery/Gauge.ui +7 -0
  113. package/gallery/Grid.ui +8 -0
  114. package/gallery/Hotspots-photo.ui +8 -0
  115. package/gallery/Hotspots.ui +11 -0
  116. package/gallery/Icon.ui +5 -0
  117. package/gallery/Image.ui +4 -0
  118. package/gallery/ImageSearch-aspects.ui +6 -0
  119. package/gallery/ImageSearch.ui +4 -0
  120. package/gallery/Input.ui +5 -0
  121. package/gallery/Json-api.ui +1 -0
  122. package/gallery/Json.ui +8 -0
  123. package/gallery/KeyValue.ui +3 -0
  124. package/gallery/List-dots.ui +8 -0
  125. package/gallery/List.ui +8 -0
  126. package/gallery/ListEditor.ui +5 -0
  127. package/gallery/Logs-jsonl.ui +1 -0
  128. package/gallery/Logs-lines.ui +11 -0
  129. package/gallery/Logs-python.ui +1 -0
  130. package/gallery/Logs.ui +1 -0
  131. package/gallery/Map-city.ui +8 -0
  132. package/gallery/Map.ui +8 -0
  133. package/gallery/Metric.ui +3 -0
  134. package/gallery/OptionCards-multi.ui +10 -0
  135. package/gallery/OptionCards.ui +10 -0
  136. package/gallery/Pipeline-checks.ui +1 -0
  137. package/gallery/Pipeline-passed.ui +1 -0
  138. package/gallery/Pipeline-running.ui +1 -0
  139. package/gallery/Pipeline-stages.ui +13 -0
  140. package/gallery/Pipeline.ui +1 -0
  141. package/gallery/Plan.ui +9 -0
  142. package/gallery/Progress.ui +4 -0
  143. package/gallery/Provenance-deploy.ui +6 -0
  144. package/gallery/Provenance-no-receipt.ui +1 -0
  145. package/gallery/Provenance.ui +1 -0
  146. package/gallery/PullRequest.ui +1 -0
  147. package/gallery/Quiz.ui +6 -0
  148. package/gallery/Radio.ui +5 -0
  149. package/gallery/Rating.ui +5 -0
  150. package/gallery/Roadmap-sprint.ui +8 -0
  151. package/gallery/Roadmap-year.ui +9 -0
  152. package/gallery/Roadmap.ui +12 -0
  153. package/gallery/Row.ui +3 -0
  154. package/gallery/SegmentedBar-status.ui +8 -0
  155. package/gallery/SegmentedBar.ui +8 -0
  156. package/gallery/Select.ui +5 -0
  157. package/gallery/Slider.ui +7 -0
  158. package/gallery/Snippet.ui +3 -0
  159. package/gallery/Sources-chips.ui +4 -0
  160. package/gallery/Sources.ui +8 -0
  161. package/gallery/Sparkline.ui +6 -0
  162. package/gallery/Stack.ui +7 -0
  163. package/gallery/StackTrace-aggregate.ui +1 -0
  164. package/gallery/StackTrace-browser.ui +1 -0
  165. package/gallery/StackTrace-go.ui +1 -0
  166. package/gallery/StackTrace-java.ui +1 -0
  167. package/gallery/StackTrace-python.ui +1 -0
  168. package/gallery/StackTrace-rust.ui +1 -0
  169. package/gallery/StackTrace.ui +1 -0
  170. package/gallery/Status.ui +9 -0
  171. package/gallery/Stepper.ui +5 -0
  172. package/gallery/Steps.ui +8 -0
  173. package/gallery/Swatches-long-names.ui +4 -0
  174. package/gallery/Swatches-picker.ui +5 -0
  175. package/gallery/Swatches.ui +10 -0
  176. package/gallery/Table.ui +7 -0
  177. package/gallery/Tabs.ui +8 -0
  178. package/gallery/Tests-junit.ui +1 -0
  179. package/gallery/Tests-vitest-junit.ui +1 -0
  180. package/gallery/Tests-vitest.ui +1 -0
  181. package/gallery/Tests.ui +9 -0
  182. package/gallery/Text.ui +4 -0
  183. package/gallery/TextArea.ui +5 -0
  184. package/gallery/Threads-ids.ui +1 -0
  185. package/gallery/Threads.ui +1 -0
  186. package/gallery/Timeline.ui +8 -0
  187. package/gallery/Timer.ui +4 -0
  188. package/gallery/Toggle.ui +5 -0
  189. package/gallery/Transcript-text.ui +1 -0
  190. package/gallery/Transcript-untimed.ui +1 -0
  191. package/gallery/Transcript-whisper.ui +1 -0
  192. package/gallery/Transcript.ui +1 -0
  193. package/gallery/Tree.ui +3 -0
  194. package/gallery/Treemap-du.ui +1 -0
  195. package/gallery/Treemap-esbuild.ui +1 -0
  196. package/gallery/Treemap-tree.ui +7 -0
  197. package/gallery/Treemap.ui +18 -0
  198. package/gallery/Walkthrough.ui +9 -0
  199. package/gallery/Waterfall-har.ui +1 -0
  200. package/gallery/Waterfall-trace.ui +1 -0
  201. package/gallery/Waterfall.ui +11 -0
  202. package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
  203. package/gallery/data/acceptance/coil-gate.json +153 -0
  204. package/gallery/data/acceptance/orbit-baseline.json +163 -0
  205. package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
  206. package/gallery/data/acceptance/shots/gate-light.png +0 -0
  207. package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
  208. package/gallery/data/activity.json +1 -0
  209. package/gallery/data/bb-tasks-orb.json +420 -0
  210. package/gallery/data/biome.json +229 -0
  211. package/gallery/data/compare/status-after.png +0 -0
  212. package/gallery/data/compare/status-before.png +0 -0
  213. package/gallery/data/compare/status-dark-2x.png +0 -0
  214. package/gallery/data/compare/status-rerender.png +0 -0
  215. package/gallery/data/coverage-py.json +1 -0
  216. package/gallery/data/coverage-summary.json +1 -0
  217. package/gallery/data/coverage.out +23 -0
  218. package/gallery/data/deploys.json +1 -0
  219. package/gallery/data/device-runs.json +285 -0
  220. package/gallery/data/du-node-modules.txt +359 -0
  221. package/gallery/data/esbuild-meta.json +292 -0
  222. package/gallery/data/eslint.json +266 -0
  223. package/gallery/data/eslint.sarif +343 -0
  224. package/gallery/data/findings-review.json +76 -0
  225. package/gallery/data/gh-issues-nextjs.json +293 -0
  226. package/gallery/data/gh-pr-checks.json +272 -0
  227. package/gallery/data/gh-prs-nextjs.json +419 -0
  228. package/gallery/data/gh-run-failed.json +1 -0
  229. package/gallery/data/gh-run-passed.json +1 -0
  230. package/gallery/data/gh-run-running.json +1 -0
  231. package/gallery/data/github-release.json +57 -0
  232. package/gallery/data/hyperfine-startup.json +187 -0
  233. package/gallery/data/lcov.info +234 -0
  234. package/gallery/data/logs/media-sync.log +127 -0
  235. package/gallery/data/logs/thumbnailer.jsonl +17 -0
  236. package/gallery/data/logs/vitest-run.log +89 -0
  237. package/gallery/data/npm-audit.json +672 -0
  238. package/gallery/data/npm-outdated.json +179 -0
  239. package/gallery/data/oxlint.json +156 -0
  240. package/gallery/data/pip-outdated.json +68 -0
  241. package/gallery/data/pnpm-outdated.json +58 -0
  242. package/gallery/data/provenance/devicectl-install.json +35 -0
  243. package/gallery/data/provenance/install-timeout.log +4 -0
  244. package/gallery/data/provenance/iphone-install.json +72 -0
  245. package/gallery/data/provenance/iphone-no-receipt.json +57 -0
  246. package/gallery/data/provenance/live-home.jpg +0 -0
  247. package/gallery/data/provenance/live-probe.txt +10 -0
  248. package/gallery/data/provenance/xcodebuild-2811.log +19 -0
  249. package/gallery/data/provenance/xcodebuild-4712.log +19 -0
  250. package/gallery/data/pytest-junit.xml +64 -0
  251. package/gallery/data/python-org-trace.json +35 -0
  252. package/gallery/data/python-org.har +31 -0
  253. package/gallery/data/retry.ts +13 -0
  254. package/gallery/data/ruff.json +261 -0
  255. package/gallery/data/src/durations.py +63 -0
  256. package/gallery/data/src/json-logic.ts +156 -0
  257. package/gallery/data/src/mapMath.ts +135 -0
  258. package/gallery/data/src/semver/bump.go +73 -0
  259. package/gallery/data/traces/browser-error.json +5 -0
  260. package/gallery/data/traces/go/main.go +27 -0
  261. package/gallery/data/traces/go-panic.txt +16 -0
  262. package/gallery/data/traces/java-caused-by.txt +15 -0
  263. package/gallery/data/traces/node-aggregate.txt +30 -0
  264. package/gallery/data/traces/node-cause.txt +21 -0
  265. package/gallery/data/traces/py/deploy.json +5 -0
  266. package/gallery/data/traces/py/deploy.py +17 -0
  267. package/gallery/data/traces/py/settings.py +19 -0
  268. package/gallery/data/traces/python-chain.txt +30 -0
  269. package/gallery/data/traces/rust/main.rs +11 -0
  270. package/gallery/data/traces/rust-panic.txt +16 -0
  271. package/gallery/data/traces/shop/cart.mjs +19 -0
  272. package/gallery/data/traces/shop/pricing.mjs +12 -0
  273. package/gallery/data/traces/shop/status.mjs +9 -0
  274. package/gallery/data/traces/web/app.js +11 -0
  275. package/gallery/data/traces/web/index.html +1 -0
  276. package/gallery/data/traces/web/search.js +10 -0
  277. package/gallery/data/transcript/call-noisy.wav +0 -0
  278. package/gallery/data/transcript/call.json +362 -0
  279. package/gallery/data/transcript/call.wav +0 -0
  280. package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
  281. package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
  282. package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
  283. package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
  284. package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
  285. package/gallery/data/tsc.txt +4 -0
  286. package/gallery/data/vitest-bench.json +119 -0
  287. package/gallery/data/vitest-junit.xml +133 -0
  288. package/gallery/data/vitest-report.json +612 -0
  289. package/gallery/images/bell.svg +10 -0
  290. package/gallery/images/bike.svg +47 -0
  291. package/gallery/images/footer-planet.webp +0 -0
  292. package/gallery/images/front-light.svg +13 -0
  293. package/gallery/images/golden-hour.webp +0 -0
  294. package/gallery/images/option-flowers.webp +0 -0
  295. package/gallery/images/option-hilltop.webp +0 -0
  296. package/gallery/images/option-meadow.webp +0 -0
  297. package/gallery/images/option-sky.webp +0 -0
  298. package/gallery/images/u-lock.svg +10 -0
  299. package/index.client.tsx +164 -0
  300. package/index.server.ts +216 -0
  301. package/package.json +50 -4
  302. package/paseo-plugin.json +14 -0
  303. package/server/data-source.ts +407 -0
  304. package/server/project.ts +981 -0
  305. package/server/web/address.ts +57 -0
  306. package/server/web/cache.ts +85 -0
  307. package/server/web/commons.ts +126 -0
  308. package/server/web/geocode.ts +40 -0
  309. package/server/web/image.ts +35 -0
  310. package/server/web/lookups.ts +248 -0
  311. package/server/web/net.ts +210 -0
  312. package/shared/core/acceptance.ts +744 -0
  313. package/shared/core/ast.ts +76 -0
  314. package/shared/core/benchmark.ts +743 -0
  315. package/shared/core/catalog.ts +733 -0
  316. package/shared/core/compare.ts +225 -0
  317. package/shared/core/coverage.ts +456 -0
  318. package/shared/core/data.ts +334 -0
  319. package/shared/core/decisions.ts +527 -0
  320. package/shared/core/dependencies.ts +604 -0
  321. package/shared/core/diagnostics.ts +24 -0
  322. package/shared/core/environments.ts +212 -0
  323. package/shared/core/evaluate.ts +737 -0
  324. package/shared/core/fields.ts +66 -0
  325. package/shared/core/findings.ts +1172 -0
  326. package/shared/core/form.ts +69 -0
  327. package/shared/core/index.ts +44 -0
  328. package/shared/core/junit.ts +252 -0
  329. package/shared/core/lexer.ts +275 -0
  330. package/shared/core/logs.ts +496 -0
  331. package/shared/core/markdown.ts +2374 -0
  332. package/shared/core/parser.ts +355 -0
  333. package/shared/core/perf-units.ts +228 -0
  334. package/shared/core/pipeline.ts +679 -0
  335. package/shared/core/project.ts +472 -0
  336. package/shared/core/provenance.ts +725 -0
  337. package/shared/core/stack-trace.ts +718 -0
  338. package/shared/core/streaming.ts +136 -0
  339. package/shared/core/test-results.ts +562 -0
  340. package/shared/core/thread-activity.ts +203 -0
  341. package/shared/core/tracking.ts +887 -0
  342. package/shared/core/transcript.ts +1364 -0
  343. package/shared/core/treemap.ts +446 -0
  344. package/shared/core/types.ts +70 -0
  345. package/shared/core/validate.ts +574 -0
  346. package/shared/core/waterfall.ts +536 -0
  347. package/shared/diagram/flowchart.ts +205 -0
  348. package/shared/diagram/layout.ts +440 -0
  349. package/shared/diagram/markdown.ts +135 -0
  350. package/shared/diagram/sequence.ts +122 -0
  351. package/shared/diagram/zoom.ts +114 -0
  352. package/shared/fallback.ts +120 -0
  353. package/shared/genui.test.ts +122 -0
  354. package/shared/instructions.ts +144 -0
  355. package/shared/logic/calendarMath.ts +130 -0
  356. package/shared/logic/chartMath.ts +289 -0
  357. package/shared/logic/choose-logic.ts +196 -0
  358. package/shared/logic/compare-logic.ts +270 -0
  359. package/shared/logic/content-model.ts +227 -0
  360. package/shared/logic/environments-logic.ts +53 -0
  361. package/shared/logic/explain-logic.ts +315 -0
  362. package/shared/logic/fieldLogic.ts +138 -0
  363. package/shared/logic/gallery-search.ts +71 -0
  364. package/shared/logic/icons.ts +322 -0
  365. package/shared/logic/json-logic.ts +153 -0
  366. package/shared/logic/mapMath.ts +133 -0
  367. package/shared/logic/project-logic.ts +39 -0
  368. package/shared/logic/references-model.ts +201 -0
  369. package/shared/logic/sketch.ts +165 -0
  370. package/shared/logic/supersession.ts +77 -0
  371. package/shared/logic/values.ts +133 -0
  372. package/shared/logic/widget-logic.ts +163 -0
  373. package/shared/rpc.ts +252 -0
  374. package/shared/segment.ts +81 -0
  375. package/shared/settings.ts +12 -0
@@ -0,0 +1,527 @@
1
+ // The pure logic of the Decisions components (Plan, Decision, Estimate): reading
2
+ // their props, the message an approved Plan sends, a Decision's weighted
3
+ // ranking and an Estimate's three-point (PERT) totals. Shared by the renderers
4
+ // in ui/components/decisions.tsx and the markdown fallback in core/markdown.ts.
5
+
6
+ import { isPlainObject } from "./evaluate";
7
+ import type { Value } from "./types";
8
+
9
+ /** An item's fields by key; a non-object item stands for its main field (a bare string is a title). */
10
+ type Fields = { readonly [key: string]: Value | undefined };
11
+
12
+ /** A scalar as trimmed text; anything else is empty. */
13
+ function str(value: unknown): string {
14
+ if (typeof value === "string") return value.trim();
15
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
16
+ if (typeof value === "boolean") return String(value);
17
+ return "";
18
+ }
19
+
20
+ /** A finite number, also from numeric text ("2.5"); null otherwise. */
21
+ export function readNumber(value: unknown): number | null {
22
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
23
+ if (typeof value === "string" && /^\s*-?(\d+(\.\d*)?|\.\d+)\s*$/.test(value)) return Number(value);
24
+ return null;
25
+ }
26
+
27
+ /** A number without float noise, at most `digits` decimals: 2.5, 1.33, 12. */
28
+ export function round(value: number, digits = 2): number {
29
+ const factor = 10 ** digits;
30
+ return Math.round(value * factor) / factor;
31
+ }
32
+
33
+ const NUMBER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
34
+
35
+ /** One decimal at most, grouped: 1,250.5. */
36
+ export function formatAmount(value: number): string {
37
+ return NUMBER.format(value === 0 ? 0 : value);
38
+ }
39
+
40
+ /** "1, 2 and 4"; runs of three or more read as ranges: "1–3, 5 and 7–9". */
41
+ export function numberList(numbers: readonly number[]): string {
42
+ const parts: string[] = [];
43
+ for (let index = 0; index < numbers.length; ) {
44
+ let end = index;
45
+ while (end + 1 < numbers.length && numbers[end + 1] === numbers[end] + 1) end++;
46
+ if (end - index >= 2) {
47
+ parts.push(`${numbers[index]}–${numbers[end]}`);
48
+ index = end + 1;
49
+ } else {
50
+ parts.push(String(numbers[index]));
51
+ index++;
52
+ }
53
+ }
54
+ return joinAnd(parts);
55
+ }
56
+
57
+ /** "a", "a and b", "a, b and c". */
58
+ export function joinAnd(parts: readonly string[]): string {
59
+ if (parts.length <= 1) return parts.join("");
60
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Plan
65
+
66
+ export type Risk = "low" | "medium" | "high";
67
+
68
+ export interface PlanFile {
69
+ readonly path: string;
70
+ readonly line: number | null;
71
+ }
72
+
73
+ /** A parsed estimate: an amount in a unit ("" for a bare number). */
74
+ export interface Quantity {
75
+ readonly value: number;
76
+ readonly unit: string;
77
+ }
78
+
79
+ export interface PlanStep {
80
+ readonly title: string;
81
+ readonly detail: string | null;
82
+ readonly files: readonly PlanFile[];
83
+ readonly risk: Risk | null;
84
+ /** The estimate as written ("2h", "half a day"); null when none. */
85
+ readonly estimate: string | null;
86
+ /** The estimate as an amount, when it is one. */
87
+ readonly amount: Quantity | null;
88
+ readonly optional: boolean;
89
+ }
90
+
91
+ const RISKS: Record<string, Risk> = { low: "low", medium: "medium", med: "medium", moderate: "medium", high: "high" };
92
+
93
+ /** `path`, `path:12`, `path:12:3`, or `{path, line?}`. */
94
+ function planFile(value: Value): PlanFile | null {
95
+ if (isPlainObject(value)) {
96
+ const fields: Fields = value;
97
+ const path = str(fields.path ?? fields.file);
98
+ if (path === "") return null;
99
+ const line = readNumber(fields.line);
100
+ return { path, line: line !== null && line >= 1 ? Math.floor(line) : null };
101
+ }
102
+ const raw = str(value);
103
+ if (raw === "") return null;
104
+ const match = /^(.*?):(\d+)(?::\d+)?$/.exec(raw);
105
+ return match !== null && match[1] !== "" ? { path: match[1], line: Number(match[2]) } : { path: raw, line: null };
106
+ }
107
+
108
+ /** Time units in minutes, with a working day of 8 h and a week of 5 days. */
109
+ const TIME_MINUTES: Record<string, number> = { m: 1, h: 60, d: 8 * 60, w: 5 * 8 * 60 };
110
+
111
+ const UNIT_ALIASES: [RegExp, string][] = [
112
+ [/^(m|min|mins|minutes?)$/i, "m"],
113
+ [/^(h|hr|hrs|hours?)$/i, "h"],
114
+ [/^(d|days?)$/i, "d"],
115
+ [/^(w|wk|wks|weeks?)$/i, "w"],
116
+ [/^(pts?|points?|sp)$/i, "pts"],
117
+ ];
118
+
119
+ /** A unit word as one of m, h, d, w, pts; other words stay as written. */
120
+ export function normalizeUnit(unit: string): string {
121
+ const trimmed = unit.trim();
122
+ for (const [pattern, name] of UNIT_ALIASES) if (pattern.test(trimmed)) return name;
123
+ return trimmed;
124
+ }
125
+
126
+ /** "2h", "1.5 d", "~30 min", "3 pts", 4 → a Quantity; ranges and prose → null. */
127
+ export function parseQuantity(value: unknown): Quantity | null {
128
+ if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? { value, unit: "" } : null;
129
+ if (typeof value !== "string") return null;
130
+ const match = /^\s*~?\s*(\d+(?:\.\d+)?|\.\d+)\s*([a-z]*)\s*$/i.exec(value);
131
+ if (match === null) return null;
132
+ const unit = normalizeUnit(match[2]);
133
+ if (match[2] !== "" && !(unit in TIME_MINUTES) && unit !== "pts") return null;
134
+ return { value: Number(match[1]), unit };
135
+ }
136
+
137
+ /** "2.5 h", "8 pts", "1 pt", "3". */
138
+ export function formatQuantity(quantity: Quantity): string {
139
+ const amount = formatAmount(quantity.value);
140
+ if (quantity.unit === "") return amount;
141
+ if (quantity.unit === "pts") return `${amount} ${quantity.value === 1 ? "pt" : "pts"}`;
142
+ return `${amount} ${quantity.unit === "m" ? "min" : quantity.unit}`;
143
+ }
144
+
145
+ export interface PlanRead {
146
+ readonly steps: readonly PlanStep[];
147
+ readonly error: string | null;
148
+ }
149
+
150
+ /** Steps from a list of `{title, detail?, files?, risk?, estimate?, optional?}` or titles; untitled items (still streaming) are left out. */
151
+ export function readPlan(value: Value | undefined): PlanRead {
152
+ if (!Array.isArray(value)) return { steps: [], error: "Plan needs a list of steps" };
153
+ const steps = value.flatMap((item): PlanStep[] => {
154
+ const fields: Fields = isPlainObject(item) ? item : { title: item };
155
+ const title = str(fields.title ?? fields.name ?? fields.step);
156
+ if (title === "") return [];
157
+ const files = (Array.isArray(fields.files) ? fields.files : fields.files === undefined ? [] : [fields.files])
158
+ .map(planFile)
159
+ .filter((file): file is PlanFile => file !== null);
160
+ const estimate = str(fields.estimate) || null;
161
+ return [
162
+ {
163
+ title,
164
+ detail: str(fields.detail ?? fields.description) || null,
165
+ files,
166
+ risk: RISKS[str(fields.risk).toLowerCase()] ?? null,
167
+ estimate,
168
+ amount: estimate === null ? null : parseQuantity(fields.estimate),
169
+ optional: fields.optional === true,
170
+ },
171
+ ];
172
+ });
173
+ return { steps, error: null };
174
+ }
175
+
176
+ /** Which steps start checked: all but the optional ones. */
177
+ export function initialChecks(steps: readonly PlanStep[]): boolean[] {
178
+ return steps.map((step) => !step.optional);
179
+ }
180
+
181
+ export interface EstimateTotal {
182
+ /** The sum, or null when the estimates can't be added (mixed points and time) or none is a number. */
183
+ readonly total: Quantity | null;
184
+ /** Steps counted whose estimate isn't a number ("half a day"). */
185
+ readonly unparsed: number;
186
+ readonly mixed: boolean;
187
+ }
188
+
189
+ /**
190
+ * The sum of the estimates of the `included` steps. Time units add up in the
191
+ * largest unit present (a day is 8 h, a week 5 days); points and bare
192
+ * numbers add only to their own kind.
193
+ */
194
+ export function planTotal(steps: readonly PlanStep[], included: readonly boolean[]): EstimateTotal {
195
+ const counted = steps.filter((step, index) => included[index] !== false && step.estimate !== null);
196
+ const amounts = counted.flatMap((step) => (step.amount === null ? [] : [step.amount]));
197
+ const unparsed = counted.length - amounts.length;
198
+ if (amounts.length === 0) return { total: null, unparsed, mixed: false };
199
+ const timed = amounts.every((amount) => amount.unit in TIME_MINUTES);
200
+ const units = new Set(amounts.map((amount) => amount.unit));
201
+ if (!timed && units.size > 1) return { total: null, unparsed, mixed: true };
202
+ if (!timed) {
203
+ return { total: { value: round(amounts.reduce((sum, a) => sum + a.value, 0)), unit: amounts[0].unit }, unparsed, mixed: false };
204
+ }
205
+ const largest = ["w", "d", "h", "m"].find((unit) => units.has(unit)) ?? "h";
206
+ const minutes = amounts.reduce((sum, a) => sum + a.value * TIME_MINUTES[a.unit], 0);
207
+ return { total: { value: round(minutes / TIME_MINUTES[largest]), unit: largest }, unparsed, mixed: false };
208
+ }
209
+
210
+ /**
211
+ * The one message approving the plan sends:
212
+ * - all steps checked, no note: "Go ahead with the plan."
213
+ * - some unchecked: "Go ahead with steps 1, 2 and 4. Skip 3 (Migrate the old rows)."
214
+ * - a note adds " Note: …"; nothing checked with a note: "Don't start the plan yet. Note: …"
215
+ * Empty when nothing is checked and there is no note (nothing to send).
216
+ */
217
+ export function planMessage(steps: readonly PlanStep[], checked: readonly boolean[], note: string): string {
218
+ const trimmed = note.trim();
219
+ const noteText = trimmed === "" ? "" : ` Note: ${trimmed}`;
220
+ const kept = steps.flatMap((_, index) => (checked[index] !== false ? [index + 1] : []));
221
+ const skipped = steps.flatMap((step, index) => (checked[index] === false ? [{ number: index + 1, title: step.title }] : []));
222
+ if (kept.length === 0) return trimmed === "" ? "" : `Don't start the plan yet.${noteText}`;
223
+ if (skipped.length === 0) return `Go ahead with the plan.${noteText}`;
224
+ const go = `Go ahead with ${kept.length === 1 ? "step" : "steps"} ${numberList(kept)}.`;
225
+ const skip = `Skip ${joinAnd(skipped.map((step) => `${step.number} (${step.title})`))}.`;
226
+ return `${go} ${skip}${noteText}`;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Decision
231
+
232
+ export interface Criterion {
233
+ readonly name: string;
234
+ readonly weight: number;
235
+ readonly note: string | null;
236
+ }
237
+
238
+ export interface DecisionOption {
239
+ readonly name: string;
240
+ /** One per criterion; null where the option has no (usable) score. */
241
+ readonly scores: readonly (number | null)[];
242
+ readonly pros: readonly string[];
243
+ readonly cons: readonly string[];
244
+ readonly note: string | null;
245
+ }
246
+
247
+ export interface DecisionModel {
248
+ readonly criteria: readonly Criterion[];
249
+ readonly options: readonly DecisionOption[];
250
+ /** The top of the score scale: 5, or 10 / 100 when the scores use those. */
251
+ readonly scale: number;
252
+ /** Input problems the component shows below the matrix. */
253
+ readonly issues: readonly string[];
254
+ }
255
+
256
+ export type DecisionRead = { readonly ok: true; readonly model: DecisionModel } | { readonly ok: false; readonly error: string };
257
+
258
+ function textList(value: Value | undefined): string[] {
259
+ if (typeof value === "string") return value.trim() === "" ? [] : [value.trim()];
260
+ return (Array.isArray(value) ? value : []).map(str).filter((item) => item !== "");
261
+ }
262
+
263
+ /** Criteria (names or `{name, weight?, note?}`) and options (`{name, scores: [..] | {criterion: score}, pros?, cons?, note?}`). */
264
+ export function readDecision(options: Value | undefined, criteria: Value | undefined): DecisionRead {
265
+ if (!Array.isArray(criteria)) return { ok: false, error: "Decision needs a list of criteria" };
266
+ if (!Array.isArray(options)) return { ok: false, error: "Decision needs a list of options" };
267
+ const issues: string[] = [];
268
+ const readCriteria = criteria.flatMap((item): Criterion[] => {
269
+ const fields: Fields = isPlainObject(item) ? item : { name: item };
270
+ const name = str(fields.name ?? fields.label);
271
+ if (name === "") return [];
272
+ const raw = fields.weight;
273
+ const weight = raw === undefined || raw === null ? 1 : readNumber(raw);
274
+ if (weight === null || weight < 0) issues.push(`Weight ${str(raw) || "?"} for ${name} isn't a number of 0 or more; using 1.`);
275
+ return [{ name, weight: weight === null || weight < 0 ? 1 : weight, note: str(fields.note) || null }];
276
+ });
277
+ if (readCriteria.length === 0) return { ok: false, error: "Decision needs at least one criterion" };
278
+ const byName = new Map(readCriteria.map((criterion, index) => [criterion.name.toLowerCase(), index]));
279
+ const raws: { option: Omit<DecisionOption, "scores">; scores: (number | null)[] }[] = [];
280
+ for (const item of options) {
281
+ const fields: Fields = isPlainObject(item) ? item : { name: item };
282
+ const name = str(fields.name ?? fields.title ?? fields.label);
283
+ if (name === "") continue;
284
+ const scores: (number | null)[] = readCriteria.map(() => null);
285
+ const given = fields.scores;
286
+ const take = (index: number, value: unknown) => {
287
+ if (value === undefined || value === null) return;
288
+ const score = readNumber(value);
289
+ if (score === null || score < 0) issues.push(`${name}: score ${str(value) || "?"} for ${readCriteria[index].name} isn't a number of 0 or more.`);
290
+ else scores[index] = score;
291
+ };
292
+ if (Array.isArray(given)) {
293
+ given.slice(0, readCriteria.length).forEach((value, index) => take(index, value));
294
+ if (given.length > readCriteria.length) issues.push(`${name} has ${given.length} scores for ${readCriteria.length} criteria; the extra ones are ignored.`);
295
+ } else if (given !== undefined && isPlainObject(given)) {
296
+ for (const [key, value] of Object.entries(given)) {
297
+ const index = byName.get(key.trim().toLowerCase());
298
+ if (index === undefined) issues.push(`${name} scores “${key}”, which isn't a criterion.`);
299
+ else take(index, value);
300
+ }
301
+ }
302
+ raws.push({
303
+ option: { name, pros: textList(fields.pros), cons: textList(fields.cons), note: str(fields.note) || null },
304
+ scores,
305
+ });
306
+ }
307
+ const top = Math.max(0, ...raws.flatMap((raw) => raw.scores.filter((score): score is number => score !== null)));
308
+ const scale = top <= 5 ? 5 : top <= 10 ? 10 : 100;
309
+ if (top > 100) issues.push("Scores above 100 are counted as 100.");
310
+ const missing = raws.reduce((count, raw) => count + raw.scores.filter((score) => score === null).length, 0);
311
+ if (missing > 0) issues.push(`${missing === 1 ? "One score is" : `${missing} scores are`} missing and ${missing === 1 ? "counts" : "count"} as 0.`);
312
+ return {
313
+ ok: true,
314
+ model: {
315
+ criteria: readCriteria,
316
+ options: raws.map(({ option, scores }) => ({ ...option, scores: scores.map((score) => (score === null ? null : Math.min(score, scale))) })),
317
+ scale,
318
+ issues,
319
+ },
320
+ };
321
+ }
322
+
323
+ export interface Ranked {
324
+ /** The option's position in `model.options`. */
325
+ readonly index: number;
326
+ /** Weighted mean score on the model's scale: Σ wᵢ·sᵢ / Σ wᵢ (a missing score counts as 0). */
327
+ readonly total: number;
328
+ }
329
+
330
+ /** Options by weighted total, best first; ties keep the given order. All weights 0 → every total 0. */
331
+ export function rankOptions(model: DecisionModel, weights: readonly number[]): Ranked[] {
332
+ const sum = weights.reduce((total, weight) => total + Math.max(0, weight), 0);
333
+ return model.options
334
+ .map((option, index) => ({
335
+ index,
336
+ total:
337
+ sum === 0
338
+ ? 0
339
+ : option.scores.reduce<number>((total, score, criterion) => total + Math.max(0, weights[criterion] ?? 0) * (score ?? 0), 0) / sum,
340
+ }))
341
+ .sort((a, b) => b.total - a.total || a.index - b.index);
342
+ }
343
+
344
+ /** How the leader stands: ahead by `margin` (on the score scale), tied, or alone. */
345
+ export function leaderMargin(ranked: readonly Ranked[]): { margin: number; tied: number[] } | null {
346
+ if (ranked.length === 0) return null;
347
+ const top = ranked[0].total;
348
+ // Totals equal to two decimals are a tie: a difference the matrix can't show isn't a win.
349
+ const tied = ranked.slice(1).filter((entry) => round(entry.total) === round(top)).map((entry) => entry.index);
350
+ return { margin: ranked.length > 1 ? top - ranked[1].total : 0, tied };
351
+ }
352
+
353
+ /** The ± step of the weight controls: 1 for whole weights up to 10, 5 or 10 for percent-like ones, 0.05 for fractions. */
354
+ export function weightStep(weights: readonly number[]): number {
355
+ const top = Math.max(0, ...weights);
356
+ if (weights.every(Number.isInteger)) return top <= 10 ? 1 : top <= 50 ? 5 : 10;
357
+ return top <= 1 ? 0.05 : 0.5;
358
+ }
359
+
360
+ /** The largest weight the controls allow: room to double the heaviest given weight, at least ten steps. */
361
+ export function weightMax(weights: readonly number[]): number {
362
+ const step = weightStep(weights);
363
+ return Math.max(step * 10, round(Math.max(0, ...weights) * 2, 4));
364
+ }
365
+
366
+ /** A weight moved by `steps` steps, kept on the step grid within [0, max]. */
367
+ export function nudgeWeight(weight: number, steps: number, step: number, max: number): number {
368
+ return round(Math.min(max, Math.max(0, Math.round(weight / step + steps) * step)), 4);
369
+ }
370
+
371
+ /**
372
+ * What "Go with <option>" sends: "Go with BullMQ." and, when the user moved
373
+ * any weight, the weights they settled on, so the agent knows why.
374
+ */
375
+ export function decisionMessage(model: DecisionModel, option: number, weights: readonly number[]): string {
376
+ const go = `Go with ${model.options[option]?.name ?? ""}.`;
377
+ const changed = model.criteria.some((criterion, index) => weights[index] !== criterion.weight);
378
+ if (!changed) return go;
379
+ return `${go} My weights: ${model.criteria.map((criterion, index) => `${criterion.name} ${round(weights[index] ?? 0, 4)}`).join(", ")}.`;
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Estimate
384
+
385
+ export interface EstimateTask {
386
+ readonly title: string;
387
+ readonly low: number | null;
388
+ readonly likely: number | null;
389
+ readonly high: number | null;
390
+ }
391
+
392
+ export interface TaskEstimate {
393
+ readonly task: EstimateTask;
394
+ /** Why the row can't be counted; null when it is valid. */
395
+ readonly error: string | null;
396
+ /** The likely value used: as given, else the midpoint of low and high. */
397
+ readonly likely: number;
398
+ /** PERT mean: (low + 4·likely + high) / 6. */
399
+ readonly mean: number;
400
+ /** PERT standard deviation: (high − low) / 6. */
401
+ readonly sd: number;
402
+ }
403
+
404
+ /** z-scores of the standard normal distribution at 80 % and 90 %. */
405
+ export const Z80 = 0.8416212335729143;
406
+ export const Z90 = 1.2815515655446004;
407
+
408
+ /** Tasks from `{title, low, high, likely?}` (numbers or numeric text); untitled items get "Task n". */
409
+ export function readEstimate(value: Value | undefined): { tasks: EstimateTask[]; error: string | null } {
410
+ if (!Array.isArray(value)) return { tasks: [], error: "Estimate needs a list of tasks" };
411
+ const tasks = value.flatMap((item, index): EstimateTask[] => {
412
+ if (!isPlainObject(item)) {
413
+ const title = str(item);
414
+ return title === "" ? [] : [{ title, low: null, likely: null, high: null }];
415
+ }
416
+ return [
417
+ {
418
+ title: str(item.title ?? item.name ?? item.task) || `Task ${index + 1}`,
419
+ low: readNumber(item.low ?? item.min ?? item.optimistic),
420
+ likely: readNumber(item.likely ?? item.mostLikely ?? item.expected),
421
+ high: readNumber(item.high ?? item.max ?? item.pessimistic),
422
+ },
423
+ ];
424
+ });
425
+ return { tasks, error: null };
426
+ }
427
+
428
+ /** One task's PERT figures, or why it can't be counted. */
429
+ export function estimateTask(task: EstimateTask): TaskEstimate {
430
+ const { low, high } = task;
431
+ const fail = (error: string): TaskEstimate => ({ task, error, likely: 0, mean: 0, sd: 0 });
432
+ if (low === null && high === null) return fail("needs low and high");
433
+ if (low === null) return fail("needs low");
434
+ if (high === null) return fail("needs high");
435
+ if (low < 0 || high < 0 || (task.likely !== null && task.likely < 0)) return fail("estimates can't be negative");
436
+ if (low > high) return fail(`low ${formatAmount(low)} is above high ${formatAmount(high)}`);
437
+ const likely = task.likely ?? (low + high) / 2;
438
+ if (likely < low || likely > high) return fail(`likely ${formatAmount(likely)} is outside ${formatAmount(low)}–${formatAmount(high)}`);
439
+ return { task, error: null, likely, mean: (low + 4 * likely + high) / 6, sd: (high - low) / 6 };
440
+ }
441
+
442
+ export interface EstimateTotals {
443
+ /** Valid tasks counted. */
444
+ readonly count: number;
445
+ /** Invalid tasks left out. */
446
+ readonly excluded: number;
447
+ readonly low: number;
448
+ readonly high: number;
449
+ readonly mean: number;
450
+ readonly sd: number;
451
+ readonly p50: number;
452
+ readonly p80: number;
453
+ readonly p90: number;
454
+ }
455
+
456
+ /**
457
+ * The project total of the valid tasks. Treating tasks as independent, the
458
+ * means add and so do the variances (σ² = Σ σᵢ²). By the central limit
459
+ * theorem the sum is close to normal, so P(x) = mean + z(x)·σ: P50 is the
460
+ * mean, P80 adds 0.84 σ, P90 1.28 σ. The normal tail can pass the sum of
461
+ * the highs (few tasks, likely near high), which no outcome can exceed, so
462
+ * the percentiles are clamped to [Σ low, Σ high].
463
+ */
464
+ export function estimateTotals(estimates: readonly TaskEstimate[]): EstimateTotals {
465
+ const valid = estimates.filter((estimate) => estimate.error === null);
466
+ const low = valid.reduce((sum, e) => sum + (e.task.low ?? 0), 0);
467
+ const high = valid.reduce((sum, e) => sum + (e.task.high ?? 0), 0);
468
+ const mean = valid.reduce((sum, e) => sum + e.mean, 0);
469
+ const sd = Math.sqrt(valid.reduce((sum, e) => sum + e.sd * e.sd, 0));
470
+ const clamp = (value: number) => Math.min(high, Math.max(low, value));
471
+ return {
472
+ count: valid.length,
473
+ excluded: estimates.length - valid.length,
474
+ low,
475
+ high,
476
+ mean,
477
+ sd,
478
+ p50: clamp(mean),
479
+ p80: clamp(mean + Z80 * sd),
480
+ p90: clamp(mean + Z90 * sd),
481
+ };
482
+ }
483
+
484
+ /** Edits the user made inline, by task index. */
485
+ export type EstimateEdits = Readonly<Record<string, Partial<Record<"low" | "likely" | "high", number | null>>>>;
486
+
487
+ /** The tasks with the user's edits applied; an edit to null clears a likely (back to the midpoint). */
488
+ export function applyEdits(tasks: readonly EstimateTask[], edits: EstimateEdits): EstimateTask[] {
489
+ return tasks.map((task, index) => {
490
+ const edit = edits[String(index)];
491
+ if (edit === undefined) return task;
492
+ return {
493
+ ...task,
494
+ low: edit.low !== undefined ? edit.low : task.low,
495
+ likely: edit.likely !== undefined ? edit.likely : task.likely,
496
+ high: edit.high !== undefined ? edit.high : task.high,
497
+ };
498
+ });
499
+ }
500
+
501
+ /** Stored edits as written by the component, or null for anything else. */
502
+ export function acceptEdits(stored: unknown): EstimateEdits | null {
503
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return null;
504
+ for (const edit of Object.values(stored)) {
505
+ if (typeof edit !== "object" || edit === null || Array.isArray(edit)) return null;
506
+ for (const [key, value] of Object.entries(edit)) {
507
+ if (!["low", "likely", "high"].includes(key) || (value !== null && readNumber(value) === null)) return null;
508
+ }
509
+ }
510
+ return stored as EstimateEdits;
511
+ }
512
+
513
+ const UNIT_LABELS: Record<string, string> = { m: "min", h: "h", d: "d", w: "w", pts: "pts" };
514
+
515
+ /** An Estimate's unit: h, d (default), w, pts, or any word as written. */
516
+ export function estimateUnit(value: unknown): string {
517
+ const raw = str(value);
518
+ if (raw === "") return "d";
519
+ const unit = normalizeUnit(raw);
520
+ return UNIT_LABELS[unit] ?? unit;
521
+ }
522
+
523
+ /** "3.5 d", "8 pts", "1 pt". */
524
+ export function formatWithUnit(value: number, unit: string): string {
525
+ if (unit === "pts") return `${formatAmount(value)} ${round(value, 1) === 1 ? "pt" : "pts"}`;
526
+ return `${formatAmount(value)} ${unit}`;
527
+ }