beadcyte 0.4.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 (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,54 @@
1
+ /* Loaded via <link> in index.html. Component-scoped rules live in App.vue's
2
+ <style>. Global reset / tokens live here so future components inherit them
3
+ without importing anything. */
4
+
5
+ * {
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ html,
10
+ body {
11
+ height: 100%;
12
+ }
13
+
14
+ /* ── Icons (@lucide/vue) ────────────────────────────────────────────────
15
+ Every lucide component renders a full <svg> carrying its own width/height
16
+ attributes, stroke="currentColor" and fill="none". CSS beats presentation
17
+ attributes in the cascade, so sizing is set here rather than passed as a
18
+ :size prop at each call site — one place to retune, and a named style
19
+ only has to touch tokens.
20
+
21
+ Colour needs no rule at all: stroke="currentColor" means an icon takes the
22
+ `color` of whatever it sits in.
23
+
24
+ Sizing is in em so an icon tracks the type size of its context: a chip's
25
+ icon shrinks with the chip. Call sites that need a different ratio set it
26
+ on their own class (.prio-icon, .type-icon), not with a pixel prop.
27
+
28
+ This works because a top-level <svg> in HTML takes its geometry from CSS.
29
+ A NESTED <svg> — which is how the same components are used inside
30
+ BeadsGantt's chart, positioned with x/y attributes — does NOT: it keeps
31
+ its width/height attributes and ignores CSS. In there, size is passed as
32
+ :size at the call site instead. See .gantt-icon in that component. */
33
+
34
+ .icon {
35
+ width: 1.15em;
36
+ height: 1.15em;
37
+ stroke-width: 2;
38
+ flex: none;
39
+ }
40
+
41
+ /* Visually hidden but still announced. Global rather than in a component:
42
+ OptionsMenu's form labels use it, and it previously lived in App.vue's
43
+ stylesheet — a cross-component dependency that only worked because that
44
+ stylesheet isn't scoped. */
45
+ .sr-only {
46
+ position: absolute;
47
+ width: 1px;
48
+ height: 1px;
49
+ margin: -1px;
50
+ padding: 0;
51
+ overflow: hidden;
52
+ clip-path: inset(50%);
53
+ white-space: nowrap;
54
+ }
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265 210" fill-rule="evenodd">
2
+ <!-- An SVG favicon can carry its own theme response, which a PNG cannot:
3
+ browser tab strips are dark in dark mode and a black glyph vanishes
4
+ there. Values are the resolved --c-text ends of the token ladder. -->
5
+ <style>
6
+ path { fill: #1a1f29; }
7
+ @media (prefers-color-scheme: dark) {
8
+ path { fill: #e5e7eb; }
9
+ }
10
+ </style>
11
+ <path d="M 179.4 3.4 C 175.4 5.2, 171.2 10.3, 170.4 14.5 C 169.6 18.7, 171.5 24.7, 174.6 27.5 L 176.9 29.7 175 36.6 C 174 40.4, 172.6 45.3, 172.1 47.6 C 171.1 51.2, 170.5 51.8, 167.1 52.9 C 159.4 55.1, 154.5 64.1, 156.9 71.2 C 157.9 74.2, 157.8 74.7, 155.1 77.4 L 152.2 80.3 154.3 83.2 C 158.4 88.8, 156.2 97.4, 150 100 C 143.1 102.9, 136.1 98.4, 135.3 90.6 C 134.8 86.4, 135 85.8, 138.3 82.5 C 141.2 79.6, 142.5 79, 145.9 79 C 148.2 79, 150 78.6, 150 78.1 C 150 77.6, 147.9 76.2, 145.2 74.9 C 141.5 73, 138.9 72.5, 133 72.5 C 119.9 72.5, 108.8 79.4, 103.8 90.8 C 100.9 97.4, 100.3 107.5, 102.4 113.7 L 104.1 118.5 101 121.3 C 98.1 123.9, 97.8 124, 95 122.5 C 89.1 119.5, 82.1 121, 77 126.5 C 74.8 128.8, 74.5 130.1, 74.5 136.1 L 74.4 143.1 66.5 150.1 C 59.9 155.8, 58.1 156.9, 56 156.4 C 46.6 154.1, 42 155.1, 37.1 160.4 C 33.9 163.8, 33.2 171.2, 35.7 176 C 38.1 180.7, 45.1 184.3, 50.3 183.7 C 58.4 182.6, 63.2 176.7, 62.8 168.1 L 62.6 162.6 71.2 155.2 C 79 148.6, 80.1 148, 82.1 149 C 83.4 149.7, 86.7 150, 90.1 149.8 C 94.9 149.5, 96.6 148.8, 99 146.5 C 102.5 143.2, 104.4 137.2, 103.5 132.8 C 102.9 130.4, 103.3 129.3, 105.5 127 L 108.3 124.1 112.7 128 C 118.7 133.2, 125.4 135.4, 133.9 134.8 C 142.6 134.1, 150 130.8, 155.1 125.3 C 161.5 118.4, 163.5 113, 163.5 103 C 163.5 95.5, 163.2 93.9, 160.7 89.6 L 157.9 84.6 160.3 82.3 C 162.4 80.1, 163 80, 167.2 80.9 C 176.8 83.1, 184.8 77.7, 186 68.2 L 186.5 63.9 199.2 57.3 L 211.9 50.8 216.5 53 C 226.8 58, 238 51.5, 238 40.5 C 238 29, 227.2 23, 215.4 28 C 212.4 29.3, 211.9 29.1, 206 25.4 C 199.9 21.5, 199.8 21.4, 199.6 16.8 C 199.2 6.6, 188.3 -0.6, 179.4 3.4 M 186.4 10.5 C 184.9 14.3, 189.3 17.7, 191.6 14.5 C 192.5 13.2, 192.6 12.2, 191.9 10.9 C 190.7 8.6, 187.2 8.4, 186.4 10.5 M 120.5 24.7 C 106.3 26.7, 86 35.2, 70.1 45.8 C 61.9 51.3, 61.1 51.6, 58.9 50.4 C 53.8 47.6, 45.7 49.4, 41.1 54.4 C 38.3 57.3, 37 66.3, 38.8 69.7 C 39.7 71.4, 39.5 72.1, 37.3 74.1 C 35.9 75.4, 32.3 79.4, 29.3 83 L 23.9 89.5 18 89.5 C 11.7 89.5, 8.5 91.1, 4.6 96.3 C 1.9 100, 1.8 107.9, 4.6 111.7 C 7.8 116.1, 14.7 119.2, 19.6 118.4 C 23.7 117.8, 23.7 117.8, 29 124.4 C 34.3 131.2, 51.9 147.8, 56 150 C 58 151.1, 58.6 150.9, 60.9 148.6 L 63.5 146 61.2 144.4 C 50.1 136.6, 28.4 113.8, 29.6 111.1 C 32.1 105.3, 32.4 102, 30.7 98.5 L 29 95 36.7 86.2 C 40.9 81.4, 44.4 77.4, 44.5 77.2 C 44.6 77.1, 45.6 77.3, 46.6 77.7 C 48.2 78.4, 48.5 79.7, 48.6 86.5 C 48.7 93.3, 48.5 94.7, 46.9 95.8 C 43.3 98.3, 41.8 102.7, 43 106.5 C 45.5 114.1, 53.7 116.4, 59.2 111 C 61.7 108.5, 62.9 108, 67.1 108 C 71.3 108, 72 108.3, 72 110 C 72 111, 72.5 114.1, 73.1 116.8 L 74.1 121.7 78.8 119.4 C 81.4 118.1, 84.6 116.8, 86 116.6 L 88.6 116.1 87.6 109 C 84.6 87.6, 98.4 66.6, 119.7 60 C 127.5 57.6, 134.4 57.5, 142.5 59.6 C 145.8 60.4, 149.5 61.4, 150.6 61.6 C 152.3 62.1, 152.9 61.6, 153.4 59.6 C 153.7 58.2, 155.4 55.4, 157.1 53.5 C 158.9 51.5, 160.1 49.8, 159.9 49.6 C 158.1 48.3, 148.4 45, 143.6 44.1 C 129.9 41.6, 118.9 43.1, 106 49.2 C 89.2 57.2, 75.9 74.7, 72.9 92.5 L 71.6 100 66.9 100 C 62.9 100, 61.6 99.5, 59.1 96.9 C 56.2 94, 56 93.4, 56 86.2 C 56.1 78.7, 56.1 78.5, 59.3 76.5 C 64.7 73.1, 67.2 68.3, 66.8 62.5 L 66.4 57.4 76.1 51.2 C 106.6 31.6, 132.2 27, 159.8 36.2 C 164.9 37.9, 169.3 39.1, 169.5 38.8 C 169.7 38.6, 170.1 36.9, 170.2 35.1 C 170.5 31.9, 170.3 31.7, 165 29.8 C 151 24.9, 133.6 22.9, 120.5 24.7 M 189 30.5 L 183.6 32.8 180.9 43.2 L 178.2 53.5 180.3 55.2 C 181.5 56.2, 182.7 56.9, 183 56.9 C 183.3 56.9, 189.1 54.1, 196 50.7 L 208.5 44.5 208.8 40 L 209.1 35.5 202.7 31.7 C 199.2 29.7, 195.9 28.1, 195.4 28.1 C 194.9 28.2, 192 29.3, 189 30.5 M 226.2 32.7 C 223.6 33.7, 225.6 39, 228.7 39 C 229.4 39, 230.4 38.1, 231 37.1 C 231.7 35.6, 231.6 34.8, 230.4 33.6 C 228.8 31.9, 228.3 31.8, 226.2 32.7 M 53.7 57.4 C 53.4 58.2, 53.9 59.9, 54.9 61.1 C 57.3 64.1, 60.3 63, 59.8 59.3 C 59.6 57.1, 58.9 56.4, 56.9 56.2 C 55.2 56, 54 56.5, 53.7 57.4 M 204.8 58.8 C 203.2 59.8, 202 60.8, 202 61.1 C 202 61.4, 204.1 63.2, 206.7 65.1 C 211.9 68.8, 224 80.7, 231.4 89.3 C 235.7 94.3, 236 95, 234.9 97.4 C 233.8 99.7, 233.1 100, 228.2 100 C 223.6 100, 222.3 99.5, 219.3 97 C 214.9 93.1, 211.1 93.1, 206.7 97 C 203.4 99.9, 203.1 99.9, 190.9 100.2 L 178.5 100.5 177.9 108.2 C 176.6 123.6, 165.9 138.5, 151.3 145.2 C 139.3 150.7, 123.1 150.2, 110.8 143.9 L 107 141.9 106 144.6 C 105.4 146.1, 103.3 148.7, 101.4 150.5 L 97.9 153.8 103.2 156.8 C 122.6 167.8, 144.9 167.1, 165.2 154.9 L 169 152.6 171.9 155.4 C 173.8 157.3, 174.8 159.2, 174.8 160.9 C 174.7 162.4, 175.1 164.4, 175.6 165.3 C 176.1 166.2, 185.3 175.8, 195.9 186.5 C 216.5 207.2, 218.7 208.7, 225.3 206.9 C 229.4 205.8, 233.7 201.4, 234.9 197.2 C 236.9 190.3, 235.2 187.9, 214.1 166.8 C 196 148.6, 194.1 147, 190.9 147 C 188.3 147, 186.6 146.2, 184.3 143.8 L 181.2 140.5 185.1 134 C 189.4 126.9, 193 116.7, 193 111.4 L 193 108 198.4 108 C 202.6 108, 204.2 108.4, 205.2 109.8 C 208.8 115, 214.6 115.2, 219.9 110.5 C 223.2 107.5, 224.4 107, 228.7 107 C 233.1 107, 233.7 107.2, 234.6 109.9 C 235.6 112.4, 235.4 113.2, 233.2 116.1 C 229.2 121.4, 219.2 131.5, 210.3 139.2 L 202.1 146.2 204.5 148.8 C 206.7 151.2, 207.1 151.3, 209.5 150 C 213.3 148, 231.7 130.3, 237 123.6 C 241.4 118.1, 241.6 118, 245.5 118.6 C 254.2 119.8, 263 112.5, 263 104 C 263 95.6, 254.7 88.3, 246.3 89.4 C 242.2 89.9, 242.1 89.9, 235.6 82.4 C 226.5 72, 209.6 56.6, 207.8 57.1 C 207.7 57.1, 206.3 57.9, 204.8 58.8 M 172.6 60.5 C 171.9 62.5, 173.6 66, 175.3 66 C 177.7 66, 179.2 64.2, 178.8 61.9 C 178.4 59, 173.6 57.9, 172.6 60.5 M 185.4 80.2 C 184.3 81.3, 181.6 83.1, 179.2 84.2 C 176.9 85.2, 175 86.2, 175 86.3 C 175 86.4, 175.6 88.5, 176.4 91 L 177.7 95.5 185.1 95.1 C 189.2 94.9, 192.7 94.6, 192.8 94.5 C 193.4 94, 188.8 79, 188 78.5 C 187.6 78.2, 186.4 79, 185.4 80.2 M 19.1 97.3 C 16.6 100.4, 21.1 104.4, 23.9 101.5 C 25.1 100.3, 25.2 99.6, 24.4 98.2 C 22.9 96, 20.6 95.6, 19.1 97.3 M 250.7 96.6 C 249.4 97.9, 250 101.8, 251.5 102.4 C 254.2 103.4, 256.1 101.9, 255.8 99.1 C 255.5 96.5, 252.3 95, 250.7 96.6 M 90.4 130.5 C 90 131.4, 90.4 132.8, 91.3 133.7 C 93.4 135.8, 96.2 134.6, 95.8 131.8 C 95.4 129, 91.3 128, 90.4 130.5 M 73.2 159.3 C 71.1 161.5, 71.1 161.7, 72.7 163.1 C 80.5 169.6, 96.6 177.2, 109.8 180.7 C 121.7 183.8, 138.9 184.2, 151.5 181.5 C 159.6 179.8, 174 174.4, 174 173.1 C 174 172.7, 173 171.4, 171.7 170.2 L 169.4 168 164.3 170 C 153.9 173.9, 144.9 175.5, 132 175.5 C 115.5 175.5, 108.4 173.6, 90 164.3 C 82 160.3, 75.5 157, 75.4 157 C 75.4 157, 74.4 158, 73.2 159.3 M 50 163.9 C 50 166.3, 52.5 168.4, 54.5 167.6 C 55.3 167.3, 56 165.9, 56 164.5 C 56 162.4, 55.5 162, 53 162 C 50.7 162, 50 162.5, 50 163.9" />
12
+ </svg>
@@ -0,0 +1,53 @@
1
+ // avatar.ts — deterministic initials + colour for an assignee handle.
2
+ //
3
+ // Pure so the "same handle always looks the same" guarantee is testable
4
+ // without rendering a card.
5
+
6
+ /**
7
+ * Up to two initials for a handle. Splits on the separators that show up in
8
+ * real handles (space, dot, dash, underscore) and falls back to the first
9
+ * two characters when there's nothing to split on — "tstout" reads better as
10
+ * TS than as T.
11
+ */
12
+ export function initialsOf(handle: string): string {
13
+ const cleaned = handle.trim();
14
+ if (!cleaned) return "?";
15
+ const parts = cleaned.split(/[\s._-]+/).filter(Boolean);
16
+ if (parts.length >= 2) {
17
+ return (parts[0][0] + parts[1][0]).toUpperCase();
18
+ }
19
+ return cleaned.slice(0, 2).toUpperCase();
20
+ }
21
+
22
+ /**
23
+ * Stable hue for a handle: the same person is the same colour in every
24
+ * session and every view, without a colour table to maintain. Saturation and
25
+ * lightness are fixed so no handle can land on something unreadable — only
26
+ * the hue varies.
27
+ */
28
+ export function hueFor(handle: string): number {
29
+ let h = 0;
30
+ for (let i = 0; i < handle.length; i++) {
31
+ // djb2-ish; |0 keeps it in int32 so the result can't drift with length.
32
+ h = (h * 33 + handle.charCodeAt(i)) | 0;
33
+ }
34
+ return Math.abs(h) % 360;
35
+ }
36
+
37
+ /**
38
+ * Chip background/foreground pair for a handle.
39
+ *
40
+ * The hue is per-person and computed, so this can't be a static token — but
41
+ * it still has to follow the theme, hence a light-dark() pair per value. On
42
+ * a light ground the chip fills pale and inks dark; on a dark ground it
43
+ * inverts. Authored in OKLCH for the same reason the tokens are: only
44
+ * lightness moves between the two, and in OKLCH that stays an even step
45
+ * whatever the hue.
46
+ */
47
+ export function colorsFor(handle: string): { bg: string; fg: string } {
48
+ const h = hueFor(handle);
49
+ return {
50
+ bg: `light-dark(oklch(90% 0.055 ${h}), oklch(32% 0.070 ${h}))`,
51
+ fg: `light-dark(oklch(38% 0.110 ${h}), oklch(86% 0.090 ${h}))`,
52
+ };
53
+ }
@@ -0,0 +1,240 @@
1
+ // bead-detail.ts — pure helpers for building tooltip content + external URLs.
2
+ //
3
+ // Separated from BeadsGantt.vue so we can unit-test the mapping without
4
+ // spinning up a component. All inputs are plain data: a bead, its schedule
5
+ // entry (if any), and an estimator result (if any); output is a flat object
6
+ // the tooltip component renders directly.
7
+
8
+ import { describeEstimateSource } from "../estimator.mjs";
9
+ import type { Bead } from "./store";
10
+ import type { ScheduleEntry } from "./view-model";
11
+ import { formatDays } from "../format.mjs";
12
+ import { shipEvidence, shipRecordProblem } from "../ship.mjs";
13
+ import type { Provenance } from "../provenance.mjs";
14
+
15
+ export interface EstimateResult {
16
+ days: number;
17
+ p90: number | null;
18
+ source: string;
19
+ }
20
+
21
+ /**
22
+ * A scheduled window, as dates rather than day offsets.
23
+ *
24
+ * The scheduler works in whole days from `startOfDay(now)` — negative for
25
+ * work already underway — and those raw offsets used to go straight to the
26
+ * tooltip, which rendered "+7d -> +8d". Nobody plans against an offset
27
+ * whose origin isn't stated, so the conversion happens here, once, where it
28
+ * can be tested.
29
+ */
30
+ export interface ScheduledWindow {
31
+ /** Days from today, as the scheduler produced them. Kept for the chart. */
32
+ startDay: number;
33
+ endDay: number;
34
+ startDate: Date;
35
+ /**
36
+ * Projected finish. Only a projection when `beyondHorizon` is false — see
37
+ * below.
38
+ */
39
+ endDate: Date;
40
+ /** Whole days from start to finish, minimum 1 (a same-day bead is a day). */
41
+ spanDays: number;
42
+ /** The scheduler placed this start in the past, from `started_at`. */
43
+ inFlight: boolean;
44
+ /** The work inside the span, in days (bp-m0g); null from an older entry. */
45
+ workDays: number | null;
46
+ /** Waiting inside the span: before the work (unclaimed) and after (review). */
47
+ waitBeforeDays: number;
48
+ waitAfterDays: number;
49
+ /**
50
+ * The finish was CLAMPED to the scheduling horizon rather than projected.
51
+ * scheduleGreedy caps `end_day` at the horizon for anything still running
52
+ * when it arrives, so the value means "no sooner than this" — and because
53
+ * the horizon follows the window preset, the same bead reports a
54
+ * different date at ±30d and ±180d. Presenting a clamped value as a firm
55
+ * date is the one thing this type exists to prevent.
56
+ *
57
+ * The test is `>=`, so a bead that genuinely finishes exactly ON the
58
+ * horizon also reads as a bound. That is a deliberate false positive:
59
+ * `Math.min(horizonDay, earliest + est.days)` makes the two cases
60
+ * identical from outside the scheduler, and over-caution is the safe
61
+ * direction when the alternative is inventing confidence.
62
+ */
63
+ beyondHorizon: boolean;
64
+ }
65
+
66
+ /**
67
+ * The ship evidence on a bead, flattened for rendering. One field rather
68
+ * than a loose `mrUrl`, so a component cannot show the MR while being
69
+ * unaware that a commit is also recorded.
70
+ */
71
+ export interface ShipRecord {
72
+ mrUrl: string | null;
73
+ commit: string | null;
74
+ /** First 8 characters — what a UI should print. */
75
+ shortCommit: string | null;
76
+ /**
77
+ * How the evidence was obtained. `reconstructed` means it was recovered
78
+ * after the fact — a SHA read out of a `Closes <id>` trailer is a fact,
79
+ * but the claim that it is *the* closing commit is an inference, and this
80
+ * gates velocity, the estimator and the trend.
81
+ */
82
+ provenance: Provenance;
83
+ }
84
+
85
+ export interface BeadDetail {
86
+ id: string;
87
+ title: string;
88
+ status: string;
89
+ priority: number;
90
+ type: string;
91
+ assignee: string | null;
92
+ syntheticAssignee: boolean;
93
+ labels: string[];
94
+ dependencyCount: number;
95
+ blockingDeps: string[];
96
+ ship: ShipRecord | null;
97
+ /**
98
+ * Why a ship record that exists does not count as evidence — an empty
99
+ * object, or a `commit` that is not a SHA. Null when there is no record at
100
+ * all, or when the record is good. A UI that collapses those two nulls
101
+ * into one is hiding the case a person can actually fix.
102
+ */
103
+ shipProblem: string | null;
104
+ estimateSource: string | null;
105
+ estimateDays: number | null;
106
+ scheduled: ScheduledWindow | null;
107
+ }
108
+
109
+ /**
110
+ * Assemble the tooltip payload. Pure — every field is a snapshot of the
111
+ * bead + schedule + estimate state at call time.
112
+ */
113
+ export function buildBeadDetail(
114
+ bead: Bead,
115
+ schedule: ScheduleEntry | undefined,
116
+ estimate: EstimateResult | undefined,
117
+ opts: {
118
+ /** Day zero for the scheduler's offsets. Defaults to today. */
119
+ now?: Date;
120
+ /**
121
+ * Days from today at which the scheduler stopped projecting. A finish
122
+ * on or past it is a bound, not a date. Omit and nothing is treated as
123
+ * clamped, which is the right default for callers that have no horizon
124
+ * — better to lose the caveat than to invent one.
125
+ */
126
+ horizonDay?: number;
127
+ } = {},
128
+ ): BeadDetail {
129
+ const blockingDeps = (bead.dependencies ?? [])
130
+ .filter((d) => d.type === "blocks")
131
+ .map((d) => d.depends_on_id);
132
+ const evidence = shipEvidence(bead);
133
+ const ship: ShipRecord | null = evidence
134
+ ? {
135
+ mrUrl: evidence.mrUrl,
136
+ commit: evidence.commit,
137
+ shortCommit: evidence.shortCommit,
138
+ provenance: evidence.provenance,
139
+ }
140
+ : null;
141
+
142
+ return {
143
+ id: bead.id,
144
+ title: bead.title,
145
+ status: bead.status,
146
+ priority: bead.priority ?? 4,
147
+ type: bead.issue_type,
148
+ assignee: schedule?.assignee ?? bead.assignee ?? null,
149
+ syntheticAssignee: !!schedule?.synthetic_assignee,
150
+ labels: bead.labels ?? [],
151
+ dependencyCount: blockingDeps.length,
152
+ blockingDeps,
153
+ ship,
154
+ shipProblem: shipRecordProblem(bead),
155
+ estimateSource: estimate?.source ?? null,
156
+ estimateDays: estimate?.days ?? null,
157
+ scheduled: schedule
158
+ ? scheduledWindow(schedule, opts.now, opts.horizonDay)
159
+ : null,
160
+ };
161
+ }
162
+
163
+ /** Midnight local, so a day offset lands on a calendar day not a time. */
164
+ function startOfDay(d: Date): Date {
165
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
166
+ }
167
+
168
+ function addDays(from: Date, days: number): Date {
169
+ const out = new Date(from);
170
+ out.setDate(out.getDate() + Math.round(days));
171
+ return out;
172
+ }
173
+
174
+ function scheduledWindow(
175
+ schedule: ScheduleEntry,
176
+ now: Date | undefined,
177
+ horizonDay: number | undefined,
178
+ ): ScheduledWindow {
179
+ const day0 = startOfDay(now ?? new Date());
180
+ const startDay = schedule.start_day;
181
+ const endDay = schedule.end_day;
182
+ return {
183
+ startDay,
184
+ endDay,
185
+ startDate: addDays(day0, startDay),
186
+ endDate: addDays(day0, endDay),
187
+ // A bead that starts and finishes on the same day still took a day.
188
+ spanDays: Math.max(1, Math.round(endDay - startDay)),
189
+ inFlight: startDay < 0,
190
+ workDays:
191
+ schedule.work_start_day !== undefined && schedule.work_end_day !== undefined
192
+ ? Math.max(0, schedule.work_end_day - schedule.work_start_day)
193
+ : null,
194
+ waitBeforeDays: Math.max(0, schedule.wait_before_days ?? 0),
195
+ waitAfterDays: Math.max(0, schedule.wait_after_days ?? 0),
196
+ beyondHorizon: horizonDay !== undefined && endDay >= horizonDay,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * An estimate as a person would say it: hours below a day, days above.
202
+ *
203
+ * Not `days.toFixed(1)`, which was throwing away most of the model. Once
204
+ * bp-67g.24 removed the estimator's half-day floor the estimates spread
205
+ * across 22 distinct values on a 577-bead repo, and rounding to a tenth of
206
+ * a day collapsed them back to 10 — "0.1d" for both 3.5 and 3.6 hours, and
207
+ * "0.2d" for everything from 4.5 to 5.7. Sub-day work wants sub-day units.
208
+ *
209
+ * Reuses economics.ts's formatter so an estimate and a recorded duration
210
+ * are spoken the same way in the same drawer.
211
+ */
212
+ export function formatEstimate(days: number): string {
213
+ return formatDays(days);
214
+ }
215
+
216
+ // ── explanations (bp-67g.47) ───────────────────────────────────────────────
217
+ // Next to the shapes they describe, so a change to how the finish is clamped
218
+ // or the estimate sourced cannot leave a stale sentence behind in a component.
219
+
220
+ /** Why the drawer says "Projected finish" or "Finishes after" for this window. */
221
+ export function explainFinish(w: ScheduledWindow): string {
222
+ if (w.beyondHorizon) {
223
+ return "The scheduler's plan for this bead ran past the end of the scheduling window, so the date shown is the window's end — a bound (no sooner than this), not a projection. Widen the window preset and the date moves.";
224
+ }
225
+ const waits = w.waitAfterDays > 0 || w.waitBeforeDays > 0
226
+ ? " The date includes this project's median waiting — unclaimed time before the work when nobody has claimed it yet, review time after — from its own history; the bar shows only the work."
227
+ : " No waiting is added: this project has no measured wait yet, so the landing is floored at its median lead time instead.";
228
+ return (w.inFlight
229
+ ? "Where the scheduler's plan has this bead landing. It is already under way: the work is the estimate less the time since it started, packed into its assignee's daily work budget."
230
+ : "Where the scheduler's plan has this bead landing: everyone's ready work in priority order, each person's beads packed into their daily work budget — several short ones fit in a day — within their calibrated cap.") + waits;
231
+ }
232
+
233
+ /** The "Starts" row. */
234
+ export const EXPLAIN_STARTS =
235
+ "The day the scheduler's plan has this bead starting: once every blocker is done and its assignee has a free slot under their cap. A plan, not a commitment anyone made.";
236
+
237
+ /** What the estimate's source string means; null for a shape this build does not write. */
238
+ export function explainEstimate(detail: BeadDetail): string | null {
239
+ return detail.estimateSource ? describeEstimateSource(detail.estimateSource) : null;
240
+ }
@@ -0,0 +1,41 @@
1
+ // changelog-view.ts — what the in-app changelog viewer shows (bp-67g.46).
2
+ //
3
+ // The file is CHANGELOG.md, inlined into the bundle at build time by a Vite
4
+ // raw import from the same source tree as the running code. That settles two
5
+ // of the bead's open questions at once:
6
+ //
7
+ // HOW IT REACHES THE BROWSER — no endpoint. The bundle carries it, so a
8
+ // published install has its own copy (CHANGELOG.md is in package.json
9
+ // "files") and reading it needs no network.
10
+ // NEWER THAN THE RUNNING BUILD — impossible by construction. Every entry
11
+ // in an inlined file shipped with the build that shows it, so nothing
12
+ // can be marked as newer. What the viewer says instead is the running
13
+ // version and what "Unreleased" means: shipped since the last tagged
14
+ // release, and in this build.
15
+ //
16
+ // Pure: the parsing is testable without a bundle.
17
+
18
+ /** The file from its first release heading on; the generated preamble is for git, not for the app. */
19
+ export function changelogBody(md: string): string {
20
+ const i = md.search(/^## /m);
21
+ return i < 0 ? md : md.slice(i);
22
+ }
23
+
24
+ export interface ChangelogSummary {
25
+ /** The newest tagged release in the file, or null when none has been cut. */
26
+ latestRelease: string | null;
27
+ hasUnreleased: boolean;
28
+ /** The sentence the viewer prints under its title. */
29
+ note: string;
30
+ }
31
+
32
+ export function describeChangelog(md: string, runningVersion: string): ChangelogSummary {
33
+ const hasUnreleased = /^## \[Unreleased\]/m.test(md);
34
+ const m = /^## \[(\d[^\]]*)\]/m.exec(md);
35
+ const latestRelease = m ? m[1] : null;
36
+ const v = runningVersion && runningVersion !== "unknown" ? `v${runningVersion}` : "this build";
37
+ const parts = [`Everything here is in ${v}: the list ships with the build that shows it.`];
38
+ if (hasUnreleased) parts.push("Unreleased is what has shipped since the last tagged release.");
39
+ if (!latestRelease) parts.push("No release has been tagged yet.");
40
+ return { latestRelease, hasUnreleased, note: parts.join(" ") };
41
+ }