create-pmos 0.134.0 → 0.135.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 (209) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +16 -1
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-capture.js +9 -1
  83. package/workspace/lib/notes-final.js +38 -13
  84. package/workspace/lib/notes-replay.js +125 -0
  85. package/workspace/lib/notes-screen.js +180 -0
  86. package/workspace/lib/notes-whisper.js +10 -3
  87. package/workspace/lib/outbox-store.js +10 -6
  88. package/workspace/lib/pmos-verbs.js +3 -1
  89. package/workspace/lib/proactive-cli.js +7 -0
  90. package/workspace/lib/proactive.js +15 -2
  91. package/workspace/lib/repo-script.js +36 -0
  92. package/workspace/lib/report-dashboards.js +5 -2
  93. package/workspace/lib/report-frame.js +122 -0
  94. package/workspace/lib/report-schedules.js +113 -0
  95. package/workspace/lib/route-modules.js +9 -2
  96. package/workspace/lib/routes-boards.js +734 -0
  97. package/workspace/lib/routes-brief-page.js +13 -4
  98. package/workspace/lib/routes-briefing.js +22 -11
  99. package/workspace/lib/routes-c3-init.js +4 -3
  100. package/workspace/lib/routes-c3-life.js +2 -1
  101. package/workspace/lib/routes-c3-reports.js +96 -13
  102. package/workspace/lib/routes-c3-strategy.js +4 -3
  103. package/workspace/lib/routes-c3-timeline.js +3 -2
  104. package/workspace/lib/routes-calendar-import.js +3 -2
  105. package/workspace/lib/routes-calendar-views.js +9 -4
  106. package/workspace/lib/routes-doc-health.js +2 -1
  107. package/workspace/lib/routes-doc-sheet.js +2 -1
  108. package/workspace/lib/routes-forecast.js +2 -1
  109. package/workspace/lib/routes-hooks.js +3 -2
  110. package/workspace/lib/routes-initiatives.js +2 -1
  111. package/workspace/lib/routes-intake.js +2 -1
  112. package/workspace/lib/routes-notes.js +55 -9
  113. package/workspace/lib/routes-outbox.js +23 -3
  114. package/workspace/lib/routes-pm-items.js +19 -6
  115. package/workspace/lib/routes-records.js +3 -2
  116. package/workspace/lib/routes-releases.js +2 -1
  117. package/workspace/lib/routes-report-facts.js +4 -3
  118. package/workspace/lib/routes-report-families.js +2 -1
  119. package/workspace/lib/routes-roadmap-ops.js +5 -4
  120. package/workspace/lib/routes-runs.js +6 -5
  121. package/workspace/lib/routes-settings.js +51 -0
  122. package/workspace/lib/routes-signals.js +3 -2
  123. package/workspace/lib/routes-stage-tools.js +220 -0
  124. package/workspace/lib/run-answers.js +2 -1
  125. package/workspace/lib/settings-store.js +226 -0
  126. package/workspace/lib/stage-tools.js +208 -0
  127. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  128. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  129. package/workspace/lib/work-evidence.js +73 -0
  130. package/workspace/package-lock.json +2 -2
  131. package/workspace/package.json +1 -1
  132. package/workspace/public/pm/briefing-deck.js +63 -17
  133. package/workspace/public/pm/briefing-model.js +36 -7
  134. package/workspace/public/pm/c3-ambient.js +100 -0
  135. package/workspace/public/pm/c3-brief-needs.js +26 -6
  136. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  137. package/workspace/public/pm/c3-brief-today.js +56 -21
  138. package/workspace/public/pm/c3-brief.js +2 -1
  139. package/workspace/public/pm/c3-calendar.js +161 -42
  140. package/workspace/public/pm/c3-calpop.js +5 -5
  141. package/workspace/public/pm/c3-demo-boards.js +649 -0
  142. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  143. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  144. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  145. package/workspace/public/pm/c3-demo-data.js +17 -27
  146. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  147. package/workspace/public/pm/c3-demo.js +2 -1
  148. package/workspace/public/pm/c3-dock.js +1 -1
  149. package/workspace/public/pm/c3-ib-docs.js +80 -0
  150. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  151. package/workspace/public/pm/c3-ib-items.js +201 -0
  152. package/workspace/public/pm/c3-ib-releases.js +51 -0
  153. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  154. package/workspace/public/pm/c3-ib-triage.js +178 -0
  155. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  156. package/workspace/public/pm/c3-ini-records.js +197 -0
  157. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  158. package/workspace/public/pm/c3-ini-ui.js +229 -0
  159. package/workspace/public/pm/c3-ini-work.js +139 -0
  160. package/workspace/public/pm/c3-initiatives.css +218 -0
  161. package/workspace/public/pm/c3-initiatives.js +741 -0
  162. package/workspace/public/pm/c3-plan.js +2 -2
  163. package/workspace/public/pm/c3-reports.css +47 -47
  164. package/workspace/public/pm/c3-reports.js +223 -185
  165. package/workspace/public/pm/c3-settings.css +74 -0
  166. package/workspace/public/pm/c3-settings.js +703 -0
  167. package/workspace/public/pm/c3-shell.js +102 -36
  168. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  169. package/workspace/public/pm/c3-terminal.js +2 -2
  170. package/workspace/public/pm/calendar-views.js +1 -1
  171. package/workspace/public/pm/components.css +319 -213
  172. package/workspace/public/pm/console3.css +99 -23
  173. package/workspace/public/pm/dest-notes.js +26 -4
  174. package/workspace/public/pm/notes-screen-capture.js +165 -0
  175. package/workspace/public/pm/notes-screen-view.js +98 -0
  176. package/workspace/public/pm/panel-trees.js +5 -2
  177. package/workspace/public/pm/pm-work-view.js +233 -146
  178. package/workspace/public/pm/shell-bell.js +3 -2
  179. package/workspace/public/pm/shell-recording.js +29 -8
  180. package/workspace/public/pm/tokens.css +2 -2
  181. package/workspace/public/pm/view-notes.js +46 -9
  182. package/workspace/public/pm/welcome.js +197 -89
  183. package/workspace/public/welcome.html +6 -4
  184. package/workspace/server.js +105 -6
  185. package/workspace/skills/day-check.skill +1 -1
  186. package/workspace/skills/jira-sync.skill +6 -4
  187. package/workspace/skills/morning-brief.skill +1 -1
  188. package/workspace/test-files.json +12 -3
  189. package/workspace/test-lists/c3-ib-items.json +9 -0
  190. package/workspace/test-lists/c3-ib-more.json +9 -0
  191. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  192. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  193. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  194. package/workspace/test-lists/console3-briefing.json +9 -0
  195. package/workspace/test-lists/console3-foundation.json +9 -0
  196. package/workspace/test-lists/console3-reports.json +3 -3
  197. package/workspace/test-lists/meeting-notes.json +10 -4
  198. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  199. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  200. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  201. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  202. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  203. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  204. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  205. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  206. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  207. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  208. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  209. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -0,0 +1,649 @@
1
+ /* pm/c3-demo-boards.js — DEMO ONLY (/c3.html?demo=1): the Initiatives boards fixture (PRD c3-initiative-boards, S3, widened by
2
+ * console3-initiatives-parity). It answers every /api/boards GET and POST with the response shapes of routes-boards.js, from 5 boards
3
+ * and about 310 initiatives:
4
+ * IDPF (Build and deliver on, Jira IDPF) · Payments (on, Jira PAY) · Growth (on, Jira GRO) · Office move (off, Project) ·
5
+ * Hiring Q4 (off, Simple).
6
+ * Each initiative runs its own workflow: most run their board's, some picked another template (IDPF-21 Research only, GRO-02 Content
7
+ * and launch, and a share of the generated rows Quick fix or Research only). The work items are Jira items (on the Build and deliver
8
+ * boards; the demo gives them dates and owners, which the real mirror does not have) and PMOS epics and tasks.
9
+ * The rows are generated the way the restructure mockup's 10-data.js does it (the same seed, word lists and weights). Filtering,
10
+ * sorting, paging and facets are done here the way the server does them. A write changes the in-memory state only (a move, a
11
+ * workflow, board settings, a new board, an ask, a task): nothing reaches the server, and a reload starts again.
12
+ *
13
+ * Wiring: c3-demo.js imports installBoards and calls it in installDemo(), after its own wrapper, so this wrapper sits outermost and
14
+ * answers the POSTs before c3-demo.js's catch-all "{ ok: true }". Real mode never loads this file.
15
+ * Pure parts (exported for a Node test): createStore(), handle(store, method, path, params, body). */
16
+ import { WS_ID, TODAY } from './c3-demo-data.js';
17
+
18
+ const DAY = 86400000;
19
+ const CATS = ['Discover', 'Plan', 'Do', 'Learn'];
20
+ const st = (name, cat, hint) => ({ name, cat, hint: hint || '' });
21
+ /** The workflow templates an initiative can run (scripts/boards.js INI_TEMPLATES). */
22
+ export const INI_TEMPLATES = {
23
+ product: { name: 'Product', kind: 'product', use: 'Product work that ships: discovery through to evaluating what shipped.', stages: [st('Discover', 'Discover', 'Discovery and ideation'), st('Plan', 'Plan', 'Strategize, then prioritize, then plan'), st('Build & Deliver', 'Do', 'Tickets, risks, release'), st('Monitor & Evaluate', 'Learn', 'Metrics, outcome check, learnings')] },
24
+ fix: { name: 'Quick fix', kind: 'product', use: 'A small, known change: plan it, build it, ship it, check it.', stages: [st('Plan', 'Plan'), st('Build & Deliver', 'Do'), st('Evaluate', 'Learn')] },
25
+ research: { name: 'Research only', kind: 'product', use: 'Learn before committing. Nothing gets built in this initiative.', stages: [st('Discover', 'Discover'), st('Plan', 'Plan'), st('Evaluate', 'Learn')] },
26
+ content: { name: 'Content and launch', kind: 'project', use: 'Pages, campaigns and launches that a team or an agency produces.', stages: [st('Discover', 'Discover'), st('Plan', 'Plan'), st('Produce', 'Do'), st('Launch', 'Do'), st('Evaluate', 'Learn')] },
27
+ project: { name: 'Project', kind: 'project', use: 'Someone who only manages a project: plan it, run it, close it.', stages: [st('Plan', 'Plan', 'Scope, milestones, owners'), st('Execute', 'Do', 'Progress, risks, blockers'), st('Close', 'Learn', 'Outcome and retro')] },
28
+ simple: { name: 'Simple', kind: 'simple', use: 'A plain to-do flow for small or personal work.', stages: [st('To do', 'Plan'), st('Doing', 'Do'), st('Done', 'Learn')] },
29
+ };
30
+ const tplStages = (t) => INI_TEMPLATES[t].stages.map((s) => ({ ...s }));
31
+ const isBuildStage = (s) => /^(build|deliver)/i.test(String((s && s.name) || s || ''));
32
+ const templateBuilds = (k) => !!INI_TEMPLATES[k] && INI_TEMPLATES[k].stages.some(isBuildStage);
33
+ const T = (id, label) => ({ id, label });
34
+ /** The stage tools per template kind and category (scripts/boards.js TOOLS). */
35
+ const TOOLS = {
36
+ product: { Discover: [T('ideas', 'Ideas'), T('hypotheses', 'Hypotheses'), T('notes', 'Notes')], Plan: [T('prd', 'PRD'), T('rubric', 'Done-criteria'), T('breakdown', 'Breakdown'), T('placement', 'Roadmap place')], Do: [T('breakdown', 'Breakdown'), T('tickets', 'Tickets'), T('releases', 'Releases')], Learn: [T('measurements', 'Measurements'), T('evaluation', 'Evaluation'), T('history', 'History')] },
37
+ project: { Discover: [T('notes', 'Notes')], Plan: [T('brief', 'Brief'), T('breakdown', 'Breakdown'), T('timeline', 'Timeline')], Do: [T('breakdown', 'Breakdown'), T('tickets', 'Tickets'), T('timeline', 'Timeline')], Learn: [T('notes', 'Notes'), T('history', 'History')] },
38
+ simple: { Discover: [T('notes', 'Notes')], Plan: [T('notes', 'Notes')], Do: [T('breakdown', 'Breakdown')], Learn: [T('history', 'History')] },
39
+ };
40
+ const TASK_STATES = ['To do', 'In progress', 'In review', 'Done'];
41
+ const PROG = { 'To do': 0, 'In progress': 0.45, 'In review': 0.8, Done: 1 };
42
+ const KR = { '1.1': 'API p95 latency under 300 ms', '1.2': 'No Sev1 incidents', '1.3': 'Partner uptime 99.9%', '2.1': 'Activation to 45%', '2.2': 'First API call within one day', '2.3': 'WAU partners +20%', '3.1': 'Payments revenue +18%', '3.2': 'Net revenue retention 115%', '4.1': 'PCI scope reduced', '4.2': 'Chargebacks under 0.4%', '4.3': 'Payouts within 24 hours' };
43
+ const TITLE_WORDS = {
44
+ idpf: ['Audit log', 'SSO', 'Webhooks', 'Sandbox', 'Docs search', 'Key rotation', 'Org roles', 'Status page', 'SDK', 'Quota alerts', 'Data export', 'CLI', 'API versioning', 'Usage dashboard'],
45
+ pay: ['Invoices', 'Refunds', 'Payouts', 'Fraud rules', 'Retry policy', 'Rate cards', 'Chargeback flow', 'Settlement reports', '3DS', 'Wallets'],
46
+ growth: ['Onboarding emails', 'Referral', 'Trial limits', 'Pricing page', 'Activation checklist', 'In-app tips', 'Win-back emails', 'Mobile app'],
47
+ office: ['Desk plan', 'Network', 'Furniture', 'Movers', 'Signage', 'Parking', 'Security badges', 'Meeting rooms', 'Pantry'],
48
+ hiring: ['Recruiter', 'Interview loop', 'Offer process', 'Referral bonus', 'New-hire onboarding', 'Job posts', 'Sourcing'],
49
+ };
50
+ const hash = (s) => [...String(s)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 1000003, 7);
51
+ const base = (() => { const [y, m, d] = TODAY.split('-').map(Number); return new Date(y, m - 1, d).getTime(); })();
52
+ const iso = (t) => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
53
+ const D = (n) => iso(base + n * DAY);
54
+ const at = (n, h) => D(n) + 'T' + String(h || 9).padStart(2, '0') + ':00:00';
55
+ const shiftDay = (day, n) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day || '')); if (!m) return null; return iso(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() + n * DAY); };
56
+ const sameFlow = (a, b) => a.length === b.length && a.every((s, k) => s.name === b[k].name && s.cat === b[k].cat);
57
+
58
+ /** A fresh, deterministic store: boards, initiatives, asks, releases, work items and the ledger of events. */
59
+ export function createStore() {
60
+ const B = (id, name, full, template, owner, health, prefix, jira, offer, defTpl) => ({ ws: WS_ID, id, name, full, domain: id, template, workflow: tplStages(template), self: false,
61
+ build: !!jira, jira, tracker: jira ? 'Jira · ' + jira : null, sprints: !!jira, offer, defTpl, owner, health, prefix });
62
+ const BUILD_OFFER = ['product', 'fix', 'research', 'content'];
63
+ const boards = [
64
+ B('idpf', 'IDPF', 'Identity & Developer Platform', 'product', 'You', 'warn', 'IDPF', 'IDPF', BUILD_OFFER, 'product'),
65
+ B('pay', 'Payments', 'Payments platform', 'product', 'Eko Prasetyo', 'ok', 'PAY', 'PAY', BUILD_OFFER, 'product'),
66
+ B('growth', 'Growth', 'Activation and retention', 'product', 'You', 'ok', 'GRO', 'GRO', BUILD_OFFER, 'product'),
67
+ B('office', 'Office move', 'Jakarta office move 2027', 'project', 'Gita Rahma', 'bad', 'OFF', null, ['content', 'project', 'research', 'simple'], 'project'),
68
+ B('hiring', 'Hiring Q4', 'Q4 hiring plan', 'simple', 'You', 'ok', 'HIR', null, ['simple', 'project'], 'simple'),
69
+ ];
70
+ const I = (id, board, title, stage, owner, anchor, horizon, status, s, e, progress, lane, spec, template) => ({ id, board, title, stage, owner, anchor, horizon, status, start: D(s), end: D(e), progress, lane, spec: !!spec, warnings: [], featured: true, template: template || null, workflow: template ? tplStages(template) : null });
71
+ const rows = [
72
+ I('IDPF-12', 'idpf', 'Self-serve onboarding v2', 'Build & Deliver', 'You', 'KR 2.1 Activation to 45%', 'now', 'at risk', -40, 18, 0.62, 'feature', true),
73
+ I('IDPF-07', 'idpf', 'Partner API rate limits', 'Plan', 'You', 'KR 1.3 Partner uptime 99.9%', 'now', 'on track', -12, 45, 0.25, 'feature', false),
74
+ I('IDPF-21', 'idpf', 'Usage-based billing', 'Discover', 'You', 'KR 3.2 Net revenue retention 115%', 'next', 'on track', -5, 90, 0.1, 'discovery', false, 'research'),
75
+ I('PAY-03', 'pay', 'Card tokenisation', 'Build & Deliver', 'Eko Prasetyo', 'KR 4.1 PCI scope reduced', 'now', 'on track', -70, 3, 0.9, 'feature', true),
76
+ I('GRO-02', 'growth', 'Weekly partner digest', 'Discover', 'You', 'KR 2.3 WAU partners +20%', 'next', 'on track', 2, 60, 0, 'discovery', false, 'content'),
77
+ I('OFF-01', 'office', 'Fit-out contractor selection', 'Execute', 'Gita Rahma', 'Project milestone: lease signed', 'now', 'late', -30, -2, 0.7, 'chore', false),
78
+ I('HIR-04', 'hiring', 'Senior backend engineer', 'Doing', 'You', 'Hiring plan Q4', 'now', 'on track', -20, 30, 0.4, 'chore', false),
79
+ ];
80
+ rows.find((r) => r.id === 'IDPF-12').warnings = ['Skipped Plan'];
81
+ /* the mockup's generator (10-data.js): the same seed, weights and draws, so the boards hold the same mix */
82
+ const owners = ['You', 'Ana Putri', 'Eko Prasetyo', 'Budi Santoso', 'Chen Wei', 'Gita Rahma', ''];
83
+ const horizons = ['now', 'next', 'later', 'later', 'next'];
84
+ const statuses = ['on track', 'on track', 'on track', 'at risk', 'late', 'done'];
85
+ const weights = ['idpf', 'idpf', 'idpf', 'idpf', 'pay', 'pay', 'pay', 'growth', 'growth', 'office', 'hiring'];
86
+ const counters = { idpf: 30, pay: 10, growth: 5, office: 2, hiring: 5 };
87
+ const suffix = ['v2', 'cleanup', 'pilot', 'for enterprise', 'migration', 'phase 2', 'redesign', 'MVP', 'hardening', 'GA'];
88
+ const pools = { idpf: ['1.1', '1.2', '1.3', '2.1', '2.2', '3.2'], pay: ['3.1', '3.2', '4.1', '4.2', '4.3', '1.2'], growth: ['2.1', '2.2', '2.3', '3.2'] };
89
+ const lanes = ['feature', 'feature', 'feature', 'discovery', 'chore', 'incident'];
90
+ let seed = 7; const r = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
91
+ for (let i = 0; i < 305; i++) {
92
+ const pick = weights[Math.floor(r() * weights.length)];
93
+ const b = boards.find((x) => x.id === pick);
94
+ let st0 = b.workflow[Math.floor(r() * b.workflow.length)].name;
95
+ const s = Math.floor(r() * 200) - 120; const len = 20 + Math.floor(r() * 90);
96
+ const status = st0 === b.workflow[b.workflow.length - 1].name && r() > 0.5 ? 'done' : statuses[Math.floor(r() * statuses.length)];
97
+ const id = b.prefix + '-' + (++counters[b.id]);
98
+ r(); const sx = suffix[Math.floor(r() * suffix.length)];
99
+ const owner = owners[Math.floor(r() * owners.length)]; r(); r();
100
+ const horizon = horizons[Math.floor(r() * horizons.length)];
101
+ const progress = status === 'done' ? 1 : Math.round(r() * 100) / 100;
102
+ const x = hash(id);
103
+ const words = TITLE_WORDS[b.id];
104
+ const title = words[x % words.length] + ' ' + (['office', 'hiring'].includes(b.id) ? ['phase 1', 'phase 2', 'vendor', 'plan', 'review'][x % 5] : sx);
105
+ let anchor = pools[b.id] ? (() => { const k = pools[b.id][x % pools[b.id].length]; return 'KR ' + k + ' ' + KR[k]; })() : b.id === 'office' ? 'Project milestone: ' + ['Lease signed', 'Fit-out complete', 'Move weekend'][x % 3] : 'Hiring plan Q4';
106
+ if (x % 29 === 0) anchor = '';
107
+ const lane = ['office', 'hiring'].includes(b.id) ? 'chore' : lanes[x % lanes.length];
108
+ let start = s; let end = s + len;
109
+ let stat = status;
110
+ if (horizon !== 'now' && stat !== 'done' && stat !== 'on track') stat = x % 3 ? 'on track' : 'at risk';
111
+ /* dates that agree with the horizon (the mockup's shapeInitiatives): Now spans today, Next starts soon, Later starts later */
112
+ if (stat !== 'done' && stat !== 'late') {
113
+ if (horizon === 'now' && (end < 0 || start > 10)) { start = -(x % Math.max(1, len - 10)); end = start + len; }
114
+ else if (horizon === 'next' && (start < 5 || start > 80)) { start = 5 + (x % 60); end = start + len; }
115
+ else if (horizon === 'later' && start < 50) { start = 50 + (x % 90); end = start + len; }
116
+ }
117
+ if (stat === 'late' && end > 0) { end = -1 - (x % 9); start = end - len; }
118
+ if (stat === 'done' && end > 0) { end = -(x % 40) - 1; start = end - len; }
119
+ /* a share of the product-board rows picked another workflow (the mockup's perInitiativeWorkflows) */
120
+ let template = null;
121
+ if (b.build && x % 9 === 0) template = 'fix'; else if (b.build && x % 13 === 0) template = 'research';
122
+ if (template === 'fix') { if (st0 === 'Discover') st0 = 'Plan'; if (st0 === 'Monitor & Evaluate') st0 = 'Evaluate'; }
123
+ if (template === 'research') { if (st0 === 'Build & Deliver') st0 = 'Plan'; if (st0 === 'Monitor & Evaluate') st0 = 'Evaluate'; }
124
+ rows.push({ id, board: b.id, title, stage: st0, owner, anchor, horizon, status: stat, start: D(start), end: D(end), progress, lane, spec: lane === 'feature' && x % 3 !== 0, warnings: [], featured: false, template, workflow: template ? tplStages(template) : null });
125
+ }
126
+ const asks = [
127
+ { id: 'ask-1', board: 'idpf', line: 'SSO for enterprise trials', source: 'Lina Kusuma (Sales), Slack #sales-asks', at: at(-1, 16), state: 'open', draft: { lane: 'feature', confidence: 0.72, anchor_suggestion: 'KR 2.1 Activation to 45%', prior_rejections: [{ id: 'IDPF-5', title: 'SSO in trial', why: 'security review cost' }] } },
128
+ { id: 'ask-2', board: 'pay', line: 'Bulk CSV export of invoices', source: 'Sari Wijaya (Support), Jira Service Desk', at: at(-2, 9), state: 'open', draft: { lane: 'feature', confidence: 0.81, anchor_suggestion: 'KR 3.1 Payments revenue +18%', prior_rejections: [] } },
129
+ { id: 'ask-3', board: 'idpf', line: 'Webhook retries for Partner X', source: 'Raj Mehta (Partner), email', at: at(-4, 11), state: 'open', needMore: true, moreNote: 'Which webhooks fail, and how often?', draft: { lane: 'incident', confidence: 0.55, anchor_suggestion: 'KR 1.3 Partner uptime 99.9%', prior_rejections: [] } },
130
+ { id: 'ask-4', board: 'office', line: 'Parking for 40 cars at the new office', source: 'Gita Rahma (Ops), meeting note', at: at(-4, 15), state: 'open', draft: { lane: 'chore', confidence: 0.64, anchor_suggestion: 'Project milestone: Move weekend', prior_rejections: [] } },
131
+ ];
132
+ const releases = [
133
+ { id: 'rel-pay-14', board: 'pay', name: 'Tokenisation 1.4', date: D(7), status: 'Ready', scope: ['PAY-03'] },
134
+ { id: 'rel-idpf-31', board: 'idpf', name: 'IDPF 3.1 · Onboarding beta', date: D(22), status: 'At risk', scope: ['IDPF-12'] },
135
+ { id: 'rel-idpf-32', board: 'idpf', name: 'IDPF 3.2 · Rate limits', date: D(56), status: 'Planned', scope: ['IDPF-07'] },
136
+ { id: 'rel-idpf-30', board: 'idpf', name: 'IDPF 3.0 · Partner keys', date: D(-21), status: 'Released', scope: ['IDPF-07', 'IDPF-12'] },
137
+ ];
138
+ const events = [
139
+ { at: at(-5, 10), by: 'You', kind: 'move', board: 'idpf', id: 'IDPF-07', from: 'Discover', to: 'Plan', why: '', warnings: [] },
140
+ { at: at(-4, 9), by: 'You', kind: 'workflow', board: 'idpf', id: 'IDPF-21', template: 'research', stages: INI_TEMPLATES.research.stages.map((s) => s.name) },
141
+ { at: at(-3, 14), by: 'You', kind: 'move', board: 'idpf', id: 'IDPF-12', from: 'Discover', to: 'Build & Deliver', why: 'Beta needs a build now', warnings: ['Skipped Plan'] },
142
+ { at: at(-2, 11), by: 'Eko Prasetyo', kind: 'move', board: 'pay', id: 'PAY-03', from: 'Plan', to: 'Build & Deliver', why: '', warnings: [] },
143
+ { at: at(-1, 15), by: 'Gita Rahma', kind: 'move', board: 'office', id: 'OFF-01', from: 'Plan', to: 'Execute', why: '', warnings: [] },
144
+ { at: at(-1, 17), by: 'You', kind: 'move', board: 'hiring', id: 'HIR-04', from: 'To do', to: 'Doing', why: 'Two candidates at final round', warnings: [] },
145
+ ];
146
+ /* Jira items mirrored from the board's Jira project (Build and deliver boards). The real mirror has no dates or people; the demo
147
+ * gives them both so the Timeline has something to draw. s and e are day offsets from today. */
148
+ const J = (key, kind, ini, epic, title, status, owner, s, e) => ({ key, kind, ini, epic, title, status, owner, start: s == null ? null : D(s), end: e == null ? null : D(e) });
149
+ const jira = [
150
+ J('IDPF-1160', 'Epic', 'IDPF-12', null, 'Guided setup flow', 'In progress', 'Budi Santoso'),
151
+ J('IDPF-1170', 'Story', 'IDPF-12', 'IDPF-1160', 'Setup step 1: workspace name', 'Done', 'Budi Santoso', -38, -26),
152
+ J('IDPF-1175', 'Story', 'IDPF-12', 'IDPF-1160', 'Setup step 2: first API key', 'Done', 'Budi Santoso', -24, -10),
153
+ J('IDPF-1161', 'Epic', 'IDPF-12', null, 'Provisioning and beta', 'In progress', 'Ana Putri'),
154
+ J('IDPF-1182', 'Story', 'IDPF-12', 'IDPF-1161', 'Workspace provisioning API', 'In progress', 'Ana Putri', -12, 4),
155
+ J('IDPF-1186', 'Bug', 'IDPF-12', 'IDPF-1161', 'Provisioning retries on timeout', 'In review', 'Ana Putri', -6, 3),
156
+ J('IDPF-1190', 'Task', 'IDPF-12', 'IDPF-1161', 'QA test plan for beta', 'Blocked', 'Ana Putri', -5, 2),
157
+ J('IDPF-1201', 'Story', 'IDPF-12', 'IDPF-1161', 'Beta invite emails', 'To do', 'Chen Wei', 5, 12),
158
+ J('IDPF-1209', 'Epic', 'IDPF-07', null, 'Partner rate limiting', 'To do', 'Ana Putri'),
159
+ J('IDPF-1210', 'Task', 'IDPF-07', 'IDPF-1209', 'Spike: token bucket per partner', 'Done', 'Ana Putri', -14, -5),
160
+ J('IDPF-1214', 'Story', 'IDPF-07', 'IDPF-1209', '429 response with a Retry-After header', 'To do', 'Ana Putri', 14, 30),
161
+ J('IDPF-1215', 'Story', 'IDPF-07', 'IDPF-1209', 'Rate-limit dashboard for partners', 'To do', 'Budi Santoso', 20, 40),
162
+ J('PAY-400', 'Epic', 'PAY-03', null, 'Network tokens', 'In progress', 'Eko Prasetyo'),
163
+ J('PAY-401', 'Story', 'PAY-03', 'PAY-400', 'Network token provisioning', 'Done', 'Eko Prasetyo', -60, -30),
164
+ J('PAY-405', 'Story', 'PAY-03', 'PAY-400', 'Migrate stored cards', 'Done', 'Eko Prasetyo', -35, -8),
165
+ J('PAY-410', 'Task', 'PAY-03', 'PAY-400', 'Release notes 1.4', 'To do', 'You', 0, 3),
166
+ J('PAY-412', 'Bug', 'PAY-03', 'PAY-400', 'Remove card digits from a log line', 'In progress', 'Eko Prasetyo', -2, 2),
167
+ ];
168
+ /* PMOS epics and tasks (the team's tasks; home: Initiative · Breakdown). A task's epic may be a Jira epic or a PMOS epic. */
169
+ const K = (id, board, ini, title, owner, state, s, e, epic, kind) => ({ id, board, initiative: ini, title, owner, state: state || 'To do', start: D(s), due: D(e), epic: epic || null, kind: kind || 'Task', history: [] });
170
+ const tasks = [
171
+ K('t-idpf-1', 'idpf', 'IDPF-12', 'Agree the beta invite list with Sales', 'You', 'In progress', -4, 3, 'IDPF-1161'),
172
+ K('t-idpf-2', 'idpf', 'IDPF-12', 'Brief Support on the new setup flow', 'Sari Wijaya', 'To do', 6, 12),
173
+ K('t-idpf-3', 'idpf', 'IDPF-21', 'Write the interview guide for billing calls', 'You', 'In progress', -3, 4),
174
+ K('t-idpf-4', 'idpf', 'IDPF-21', 'Book five interviews with large API users', 'You', 'To do', 2, 14),
175
+ K('t-gro-1', 'growth', 'GRO-02', 'Pull partner admin login data', 'Chen Wei', 'To do', 3, 10),
176
+ K('t-gro-2', 'growth', 'GRO-02', 'Draft the first digest email', 'You', 'To do', 9, 16),
177
+ K('e-off-1', 'office', 'OFF-01', 'Bids and selection', 'Gita Rahma', 'To do', -30, -2, null, 'Epic'),
178
+ K('e-off-2', 'office', 'OFF-01', 'Contract', 'Tom Hartono', 'To do', -4, 10, null, 'Epic'),
179
+ K('t-off-1', 'office', 'OFF-01', 'Compare the three fit-out bids', 'Gita Rahma', 'Done', -25, -10, 'e-off-1'),
180
+ K('t-off-2', 'office', 'OFF-01', 'Site visits with two contractors', 'Gita Rahma', 'Done', -12, -4, 'e-off-1'),
181
+ K('t-off-3', 'office', 'OFF-01', 'Legal review of the contract', 'Tom Hartono', 'In progress', -3, 4, 'e-off-2'),
182
+ K('t-off-4', 'office', 'OFF-01', 'Sign the contract', 'Gita Rahma', 'To do', 4, 8, 'e-off-2'),
183
+ K('t-hir-1', 'hiring', 'HIR-04', 'Schedule the two final rounds', 'You', 'In progress', -4, 2),
184
+ K('t-hir-2', 'hiring', 'HIR-04', 'Reference checks for the finalists', 'Ana Putri', 'To do', 3, 10),
185
+ K('t-hir-3', 'hiring', 'HIR-04', 'Write the offer letter', 'You', 'To do', 9, 13),
186
+ ];
187
+ /* stage records (the stage-records routes' shapes: /api/stage-tools/records, rice, burndown), keyed initiative -> tool -> { data, at, by } */
188
+ const R = (data, n) => ({ data, at: at(n, 10), by: 'You' });
189
+ const records = {
190
+ 'IDPF-12': { problem: R({ text: 'New workspaces wait two days for a sales-assisted setup, and 38% of trials never make a first API call.' }, -30), rice: R({ reach: 1400, impact: 2, confidence: 80, effort: 4 }, -20),
191
+ launch: R({ items: [{ id: 'l1', text: 'Beta invite list agreed with Sales', done: true }, { id: 'l2', text: 'Support briefed on the new setup flow', done: false }, { id: 'l3', text: 'Rollback plan for provisioning', done: false }, { id: 'l4', text: 'Status page entry drafted', done: false }] }, -2),
192
+ kill: R({ items: [{ id: 'k1', text: 'Activation below 35% four weeks after beta', status: 'watching' }] }, -10) },
193
+ 'IDPF-07': { rice: R({ reach: 600, impact: 3, confidence: 70, effort: 3 }, -6) },
194
+ 'IDPF-21': { problem: R({ text: 'Large API users pay a flat fee and small ones overpay, so neither group expands. We do not yet know how they forecast spend.' }, -4) },
195
+ 'PAY-03': { rice: R({ reach: 900, impact: 2, confidence: 90, effort: 6 }, -50), launch: R({ items: [{ id: 'l1', text: 'PCI assessor sign-off', done: true }, { id: 'l2', text: 'Release notes 1.4', done: false }, { id: 'l3', text: 'Card digits removed from logs', done: false }] }, -1) },
196
+ 'GRO-02': { problem: R({ text: 'Partner admins only log in when something breaks, so they miss new features and usage trends.' }, -1) },
197
+ 'OFF-01': { launch: R({ items: [{ id: 'l1', text: 'Three bids compared', done: true }, { id: 'l2', text: 'Legal review of the contract', done: false }, { id: 'l3', text: 'Contract signed', done: false }] }, -3), learnings: R({ items: [{ id: 'n1', text: 'Ask for fixed-price bids first: two of three changed their price after the site visit.', date: D(-6) }] }, -6) },
198
+ };
199
+ return { boards, rows, asks, releases, events, jira, tasks, records, seq: 0 };
200
+ }
201
+
202
+ /* ── reads ── */
203
+ const boardOf = (S, id) => S.boards.find((x) => x.id === id);
204
+ const wfOf = (S, r) => r.workflow || boardOf(S, r.board).workflow;
205
+ const catIn = (wf, stage) => ((wf.find((s) => s.name === stage) || {}).cat) || 'Plan';
206
+ const catOfRow = (S, r) => catIn(wfOf(S, r), r.stage);
207
+ const tplEdited = (b) => !INI_TEMPLATES[b.template] || !sameFlow(tplStages(b.template), b.workflow);
208
+ const isOpen = (r) => r.status !== 'done';
209
+ const nextRelease = (S, bid) => S.releases.filter((x) => x.board === bid && x.date >= TODAY).sort((a, c) => (a.date < c.date ? -1 : 1))[0] || null;
210
+ const openAsks = (S, bid) => S.asks.filter((a) => a.board === bid && a.state === 'open').length;
211
+ function rowOut(S, r) {
212
+ const wf = wfOf(S, r);
213
+ return { id: r.id, title: r.title, lane: r.lane, stage: r.stage, cat: catIn(wf, r.stage), owner: r.owner || null, anchor: r.anchor, horizon: r.horizon, status: r.status, start: r.start, end: r.end, progress: r.progress,
214
+ warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })) };
215
+ }
216
+ function boardOut(S, b) {
217
+ const list = S.rows.filter((r) => r.board === b.id);
218
+ const byCat = Object.fromEntries(CATS.map((c) => [c, 0]));
219
+ for (const r of list) byCat[catOfRow(S, r)]++;
220
+ const rel = nextRelease(S, b.id);
221
+ return { ws: b.ws, id: b.id, name: b.name, full: b.full, domain: b.domain, template: b.template, templateEdited: tplEdited(b), workflow: b.workflow.map((s) => ({ ...s })), self: b.self, tracker: b.tracker, sprints: b.sprints,
222
+ build: b.build, buildDeliver: b.build, jira: b.jira, offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
223
+ counts: { total: list.length, open: list.filter(isOpen).length, byCat, risk: list.filter((r) => r.status === 'at risk').length, late: list.filter((r) => r.status === 'late').length },
224
+ asks: openAsks(S, b.id), nextRelease: rel ? { id: rel.id, name: rel.name, at: rel.date, date: rel.date, status: rel.status } : null };
225
+ }
226
+ const H = { now: 0, next: 1, later: 2 };
227
+ const SS = { late: 0, 'at risk': 1, 'on track': 2, done: 3 };
228
+ /** The saved view and the search, as the server applies them (the base the facets count). */
229
+ function viewRows(S, b, p) {
230
+ const q = String(p.q || '').trim().toLowerCase();
231
+ const view = p.view || 'now';
232
+ return S.rows.filter((r) => {
233
+ if (r.board !== b.id) return false;
234
+ if (view === 'now' && (!['now', 'next'].includes(r.horizon) || !isOpen(r))) return false;
235
+ if (view === 'mine' && (r.owner !== 'You' || !isOpen(r))) return false;
236
+ if (view === 'risk' && !['at risk', 'late'].includes(r.status)) return false;
237
+ if (view === 'noowner' && (r.owner || !isOpen(r))) return false;
238
+ if (q && !(r.id + ' ' + r.title + ' ' + r.anchor + ' ' + r.owner).toLowerCase().includes(q)) return false;
239
+ return true;
240
+ });
241
+ }
242
+ /** Then each named filter (a value or a comma list; 'none' for an empty owner or anchor), as scripts/boards.js filterRows does. */
243
+ function filterRows(S, b, p) {
244
+ return viewRows(S, b, p).filter((r) => {
245
+ const has = (want, v) => !want || String(want).split(',').map((x) => x.trim().toLowerCase()).includes(String(v || 'none').toLowerCase());
246
+ return has(p.stage, r.stage) && has(p.cat, catOfRow(S, r)) && has(p.status, r.status) && has(p.owner, r.owner) && has(p.horizon, r.horizon) && has(p.anchor, r.anchor);
247
+ });
248
+ }
249
+ function sortRows(S, list, sort) {
250
+ const desc = String(sort || '').startsWith('-'); const key = String(sort || 'horizon').replace(/^-/, '');
251
+ const num = (id) => Number(String(id).split('-').pop()) || 0;
252
+ const stageAt = (r) => { const wf = wfOf(S, r); return wf.findIndex((s) => s.name === r.stage) / Math.max(1, wf.length - 1); };
253
+ const val = { id: (r) => num(r.id), title: (r) => r.title.toLowerCase(), stage: stageAt, status: (r) => SS[r.status] ?? 9, progress: (r) => r.progress,
254
+ owner: (r) => (r.owner ? r.owner.toLowerCase() : '~'), horizon: (r) => (H[r.horizon] ?? 9) * 1e9 + Number(r.end.replace(/-/g, '')), rank: (r) => (H[r.horizon] ?? 9) * 1e9 + Number(r.end.replace(/-/g, '')), end: (r) => r.end, start: (r) => r.start }[key] || ((r) => num(r.id));
255
+ return list.slice().sort((a, c) => { const x = val(a); const y = val(c); return ((x < y ? -1 : x > y ? 1 : 0) * (desc ? -1 : 1)) || (c.featured - a.featured) || (num(a.id) - num(c.id)); });
256
+ }
257
+ const tally = (list, f) => { const o = {}; for (const r of list) { const k = f(r); o[k] = (o[k] || 0) + 1; } return o; };
258
+
259
+ /* ── work items: Jira items (Build and deliver boards) and PMOS tasks, epics rolled up ── */
260
+ const itemStatus = (s) => (/review/i.test(s) ? 'In review' : /progress|block/i.test(s) ? 'In progress' : /done|closed/i.test(s) ? 'Done' : 'To do');
261
+ const taskItem = (t) => ({ uid: t.id, key: '', kind: t.kind === 'Epic' ? 'Epic' : 'Task', src: 'pmos', title: t.title, status: t.state, blocked: false, owner: t.owner || null, ini: t.initiative, epic: t.epic || null, start: t.start || null, end: t.due || null, ref: t.id, board: t.board });
262
+ function rollup(items) {
263
+ for (const w of items) w.progress = PROG[w.status] || 0;
264
+ for (const e of items.filter((w) => w.kind === 'Epic')) {
265
+ const kids = items.filter((w) => w.epic && w.epic === e.uid);
266
+ if (!kids.length) continue;
267
+ e.progress = Math.round((kids.reduce((n, w) => n + w.progress, 0) / kids.length) * 100) / 100;
268
+ e.status = kids.every((w) => w.status === 'Done') ? 'Done' : kids.some((w) => w.status !== 'To do') ? 'In progress' : 'To do';
269
+ e.blocked = kids.some((w) => w.blocked);
270
+ const ds = kids.map((w) => w.start).filter(Boolean).sort(); const de = kids.map((w) => w.end).filter(Boolean).sort();
271
+ if (!e.start && ds.length) e.start = ds[0];
272
+ if (!e.end && de.length) e.end = de[de.length - 1];
273
+ }
274
+ return items;
275
+ }
276
+ function itemsOf(S, b) {
277
+ const ids = new Set(S.rows.filter((r) => r.board === b.id).map((r) => r.id));
278
+ const out = [];
279
+ if (b.build) for (const j of S.jira) if (ids.has(j.ini)) out.push({ uid: j.key, key: j.key, kind: j.kind, src: 'jira', title: j.title, status: itemStatus(j.status), blocked: /block/i.test(j.status), owner: j.owner, ini: j.ini, epic: j.epic, start: j.start, end: j.end, ref: null, board: b.id });
280
+ for (const t of S.tasks) if (t.board === b.id && ids.has(t.initiative)) out.push(taskItem(t));
281
+ return rollup(out);
282
+ }
283
+
284
+ /* ── the order rules: warnings, never a block (scripts/boards.js moveWarnings, on the initiative's own workflow) ── */
285
+ function orderWarnings(wf, r, from, to) {
286
+ const out = []; const fi = wf.findIndex((s) => s.name === from); const ti = wf.findIndex((s) => s.name === to);
287
+ if (fi >= 0 && ti > fi + 1) for (const s of wf.slice(fi + 1, ti)) out.push('Skipped ' + s.name);
288
+ if (catIn(wf, to) === 'Do' && r.lane === 'feature' && !r.spec) out.push('No spec yet');
289
+ if (!r.anchor) out.push('No anchor');
290
+ return out;
291
+ }
292
+ /** A workflow edit (names, categories, was): { ok, workflow, renamed } or { ok: false, error }. */
293
+ function checkStages(stagesIn) {
294
+ const stages = Array.isArray(stagesIn) ? stagesIn.map((s) => ({ name: String((s && s.name) || '').trim(), cat: s && s.cat, hint: (s && s.hint) || '', was: (s && s.was) || null })) : [];
295
+ if (!stages.length) return { ok: false, error: 'A workflow needs at least one stage.' };
296
+ if (stages.some((s) => !s.name)) return { ok: false, error: 'Every stage needs a name.' };
297
+ if (new Set(stages.map((s) => s.name.toLowerCase())).size !== stages.length) return { ok: false, error: 'Two stages have the same name.' };
298
+ if (stages.some((s) => !CATS.includes(s.cat))) return { ok: false, error: 'Each stage needs a category: Discover, Plan, Do or Learn.' };
299
+ return { ok: true, stages };
300
+ }
301
+
302
+ const fail = (status, error) => ({ status, body: { ok: false, error } });
303
+ const okb = (body) => ({ status: 200, body: { ok: true, ...body } });
304
+
305
+ /** One request. Returns { status, body } or undefined when the path is not a boards route. */
306
+ export function handle(S, method, path, p, body) {
307
+ if (path.startsWith('/api/stage-tools')) return handleTools(S, method, path, p, body || {});
308
+ if (!path.startsWith('/api/boards')) return handleMore(S, method, path, p);
309
+ const findBoard = (ws, id) => { const b = S.boards.find((x) => x.id === id); return b && (!ws || ws === b.ws) ? b : null; };
310
+ if (method === 'GET') {
311
+ if (path === '/api/boards') return okb({ today: TODAY, boards: S.boards.map((b) => boardOut(S, b)) });
312
+ const b = findBoard(p.ws, p.board);
313
+ if (!b && path !== '/api/boards/asks') return fail(404, 'No board "' + (p.board || '') + '" in this workspace.');
314
+ if (path === '/api/boards/board') {
315
+ const list = S.rows.filter((r) => r.board === b.id);
316
+ const byStage = {};
317
+ for (const r of list) byStage[r.stage] = (byStage[r.stage] || 0) + 1;
318
+ const rel = nextRelease(S, b.id);
319
+ const recent = S.events.filter((e) => e.board === b.id && e.kind === 'move').slice(-10).reverse().map((e) => ({ ...e, title: (S.rows.find((r) => r.id === e.id) || {}).title || '' }));
320
+ const bo = boardOut(S, b);
321
+ return okb({ board: bo, counts: { total: list.length, open: bo.counts.open, byStage, byStatus: tally(list, (r) => r.status), byCat: bo.counts.byCat }, asks: openAsks(S, b.id), nextRelease: bo.nextRelease, recent, release: rel });
322
+ }
323
+ if (path === '/api/boards/backlog') {
324
+ const list = sortRows(S, filterRows(S, b, p), p.sort);
325
+ const base0 = viewRows(S, b, p);
326
+ const offset = Math.max(0, Number(p.offset) || 0); const limit = Math.min(100, Math.max(1, Number(p.limit) || 25));
327
+ return okb({ view: p.view || 'now', total: list.length, offset, limit, rows: list.slice(offset, offset + limit).map((r) => rowOut(S, r)),
328
+ facets: { stage: tally(base0, (r) => r.stage), status: tally(base0, (r) => r.status), owner: tally(base0, (r) => r.owner || 'none'), horizon: tally(base0, (r) => r.horizon),
329
+ anchor: tally(base0, (r) => r.anchor || 'none'), cat: tally(base0, (r) => catOfRow(S, r)) } });
330
+ }
331
+ if (path === '/api/boards/initiative') {
332
+ const r = S.rows.find((x) => x.id === p.id && x.board === b.id);
333
+ if (!r) return fail(404, 'No initiative "' + (p.id || '') + '" on ' + b.name + '.');
334
+ const row = rowOut(S, r);
335
+ const kind = r.template ? INI_TEMPLATES[r.template].kind : (TOOLS[b.template] ? b.template : 'product');
336
+ const byCat = TOOLS[kind] || TOOLS.product;
337
+ const history = S.events.filter((e) => e.id === r.id).slice().reverse();
338
+ return okb({ row, board: boardOut(S, b), history, tools: byCat[row.cat] || [], toolsByCat: byCat });
339
+ }
340
+ if (path === '/api/boards/asks') {
341
+ const state = p.state || 'open';
342
+ return okb({ asks: S.asks.filter((a) => (!b || a.board === b.id) && (state === 'all' || a.state === state)).map((a) => ({ ...a })) });
343
+ }
344
+ if (path === '/api/boards/items') {
345
+ const list = S.rows.filter((r) => r.board === b.id);
346
+ return okb({ board: b.id, build: b.build, jira: b.jira, items: itemsOf(S, b),
347
+ initiatives: list.map((r) => { const o = rowOut(S, r); return { id: o.id, title: o.title, stage: o.stage, cat: o.cat, status: o.status, owner: o.owner, horizon: o.horizon, start: o.start, end: o.end, progress: o.progress, template: o.template, featured: r.featured }; }) });
348
+ }
349
+ if (path === '/api/boards/sprints') {
350
+ if (!b.tracker || !b.sprints) return okb({ source: 'none', readAt: null, sprints: [] });
351
+ const key = b.prefix;
352
+ const own = S.rows.filter((r) => r.board === b.id && catOfRow(S, r) === 'Do' && isOpen(r)).slice(0, 6);
353
+ const ST = [['To Do', 'todo'], ['In Progress', 'doing'], ['In Review', 'doing'], ['Done', 'done']];
354
+ const mirrored = S.jira.filter((j) => j.kind !== 'Epic' && own.concat(S.rows.filter((r) => r.featured)).some((r) => r.id === j.ini) && S.rows.find((r) => r.id === j.ini).board === b.id);
355
+ const fromJira = mirrored.map((j) => ({ key: j.key, summary: j.title, status: j.status, category: /done/i.test(j.status) ? 'done' : /to do/i.test(j.status) ? 'todo' : 'doing', initiative: j.ini }));
356
+ const issues = fromJira.concat(Array.from({ length: Math.max(0, 12 - fromJira.length) }, (_, k) => { const s = ST[hash(key + k) % 4]; const ini = own[k % Math.max(1, own.length)]; return { key: key + '-' + (1370 + k), summary: (ini ? ini.title : 'Sprint work') + ': part ' + (k + 1), status: s[0], category: s[1], initiative: ini ? ini.id : null }; }));
357
+ const counts = { todo: issues.filter((i) => i.category === 'todo').length, doing: issues.filter((i) => i.category === 'doing').length, done: issues.filter((i) => i.category === 'done').length };
358
+ const num = key === 'IDPF' ? 42 : 19;
359
+ const extra = (k, state, name, cat, n) => { const is = Array.from({ length: n }, (_, i) => ({ key: key + '-' + (1100 + k * 20 + i), summary: (own[i % Math.max(1, own.length)] || { title: 'Sprint work' }).title + ': part ' + (i + 1), status: cat === 'done' ? 'Done' : 'To Do', category: cat, initiative: (own[i % Math.max(1, own.length)] || {}).id || null })); return { name, state, issues: is, counts: { todo: cat === 'todo' ? n : 0, doing: 0, done: cat === 'done' ? n : 0 } }; };
360
+ return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [{ name: key + ' Sprint ' + num, state: 'active', issues, counts }, extra(1, 'future', key + ' Sprint ' + (num + 1), 'todo', 3), extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5)] });
361
+ }
362
+ return fail(404, 'No route ' + path);
363
+ }
364
+ /* POST */
365
+ const ws = body.ws;
366
+ if (path === '/api/boards/create') {
367
+ const name = String(body.name || '').trim();
368
+ if (!name) return fail(400, 'A board needs a name.');
369
+ if (!['product', 'project', 'simple'].includes(body.template)) return fail(400, 'Pick a template: product, project or simple.');
370
+ if (S.boards.some((x) => x.name.toLowerCase() === name.toLowerCase())) return fail(409, 'A board named "' + name + '" exists already in this workspace.');
371
+ let id = (body.domain || name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'board';
372
+ while (S.boards.some((x) => x.id === id)) id += '-2';
373
+ const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
374
+ const build = typeof bd === 'boolean' ? bd : true;
375
+ const allowed = Object.keys(INI_TEMPLATES).filter((k) => build || !templateBuilds(k));
376
+ const jira = build && body.jira ? String(body.jira).trim().toUpperCase() : null;
377
+ if (jira && !/^[A-Z][A-Z0-9_]{0,19}$/.test(jira)) return fail(400, 'jira is a Jira project key: a letter, then letters, digits or _ (at most 20)');
378
+ const defTpl = allowed.includes(body.defTpl) ? body.defTpl : allowed.includes(body.template) ? body.template : allowed[0];
379
+ const b = { ws: ws || WS_ID, id, name, full: name, domain: body.domain || id, template: body.template, workflow: tplStages(body.template), self: false, build, jira, tracker: jira ? 'Jira · ' + jira : null, sprints: !!jira,
380
+ offer: allowed, defTpl, owner: 'You', health: 'ok', prefix: name.replace(/[^A-Za-z]/g, '').slice(0, 3).toUpperCase() || 'NEW' };
381
+ S.boards.push(b);
382
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'board', board: id, name, template: body.template });
383
+ return okb({ board: boardOut(S, b) });
384
+ }
385
+ const b = findBoard(ws, body.board);
386
+ if (path === '/api/boards/assign') {
387
+ const r = S.rows.find((x) => x.id === body.id); const to = findBoard(ws, body.to);
388
+ if (!r || !to) return fail(404, !r ? 'No initiative "' + body.id + '".' : 'No board "' + body.to + '".');
389
+ r.board = to.id; r.template = null; r.workflow = null; if (!to.workflow.some((s) => s.name === r.stage)) r.stage = to.workflow[0].name;
390
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'assign', board: to.id, id: r.id, to: to.id });
391
+ return okb({});
392
+ }
393
+ if (!b) return fail(404, 'No board "' + (body.board || '') + '" in this workspace.');
394
+ if (path === '/api/boards/settings') {
395
+ if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive.');
396
+ const v = {};
397
+ const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
398
+ if (bd != null) { if (typeof bd !== 'boolean') return fail(400, 'buildDeliver is true or false'); v.build = bd; }
399
+ if (body.jira != null && body.jira !== '') { const j = String(body.jira).trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j)) return fail(400, 'jira is a Jira project key: a letter, then letters, digits or _ (at most 20)'); v.jira = j.toUpperCase(); }
400
+ const build = 'build' in v ? v.build : b.build;
401
+ if (body.offer != null) {
402
+ if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !INI_TEMPLATES[k])) return fail(400, 'offer is a list of templates: ' + Object.keys(INI_TEMPLATES).join(', '));
403
+ const blocked = body.offer.filter((k) => !build && templateBuilds(k));
404
+ if (blocked.length) return fail(400, blocked.join(', ') + ' need Build and deliver on');
405
+ v.offer = [...new Set(body.offer)];
406
+ }
407
+ if (body.defTpl != null) {
408
+ const offer = v.offer || b.offer.filter((k) => build || !templateBuilds(k));
409
+ if (!offer.includes(body.defTpl)) return fail(400, 'defTpl must be a template the board offers (' + offer.join(', ') + ')');
410
+ v.defTpl = body.defTpl;
411
+ }
412
+ if (!Object.keys(v).length) return fail(400, 'say what changes: buildDeliver, jira, offer or defTpl');
413
+ Object.assign(b, v);
414
+ if (!b.build) { b.offer = b.offer.filter((k) => !templateBuilds(k)); if (!b.offer.length) b.offer = ['simple']; if (!b.offer.includes(b.defTpl)) b.defTpl = b.offer[0]; }
415
+ if (b.build && !b.jira) b.jira = b.prefix;
416
+ b.tracker = b.build && b.jira ? 'Jira · ' + b.jira : null; b.sprints = !!(b.build && b.jira);
417
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'board', op: 'settings', board: b.id, ...v });
418
+ return okb({ board: boardOut(S, b) });
419
+ }
420
+ if (path === '/api/boards/workflow') {
421
+ if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive. Its stages cannot be edited.');
422
+ const ck = checkStages(body.stages); if (!ck.ok) return fail(400, ck.error);
423
+ const stages = ck.stages;
424
+ if (!b.build && stages.some(isBuildStage)) return fail(400, 'Build and Deliver stages need a board with Build and deliver on; ' + b.name + ' has it off');
425
+ const kept = new Map(stages.filter((s) => s.was && b.workflow.some((o) => o.name === s.was)).map((s) => [s.was, s.name]));
426
+ const removed = b.workflow.filter((o) => !kept.has(o.name)).map((o) => o.name);
427
+ const mr = body.moveRemoved || {};
428
+ const own = (r) => r.board === b.id && !r.workflow;
429
+ for (const name of removed) {
430
+ const n = S.rows.filter((r) => own(r) && r.stage === name).length;
431
+ if (n && !stages.some((s) => s.name === mr[name])) return fail(400, name + ' holds ' + n + ' initiatives. Say which stage they move to.');
432
+ }
433
+ let moved = 0;
434
+ for (const r of S.rows) {
435
+ if (!own(r)) continue;
436
+ if (kept.has(r.stage)) { r.stage = kept.get(r.stage); continue; }
437
+ if (removed.includes(r.stage)) { const to = mr[r.stage]; S.events.push({ at: at(0, 8), by: 'You', kind: 'move', board: b.id, id: r.id, from: r.stage, to, why: r.stage + ' was removed from the workflow', warnings: [] }); r.stage = to; moved++; }
438
+ }
439
+ b.workflow = stages.map((s) => ({ name: s.name, cat: s.cat, hint: s.hint }));
440
+ if (['product', 'project', 'simple'].includes(body.template)) b.template = body.template;
441
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'board-workflow', board: b.id, stages: b.workflow, moved: mr });
442
+ return okb({ board: boardOut(S, b), moved });
443
+ }
444
+ if (path === '/api/boards/ini-workflow') {
445
+ if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive.');
446
+ const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
447
+ if (!r) return fail(404, 'no initiative ' + (body.id || '') + ' on this board');
448
+ if (body.template != null && body.template !== '' && !b.offer.includes(body.template)) return fail(400, b.name + ' does not offer the template "' + body.template + '" (it offers ' + b.offer.join(', ') + ')');
449
+ const ck = checkStages(body.stages); if (!ck.ok) return fail(400, ck.error);
450
+ if (!b.build && ck.stages.some(isBuildStage)) return fail(400, 'Build and Deliver stages need a board with Build and deliver on; ' + b.name + ' has it off');
451
+ const now = wfOf(S, r);
452
+ const renamed = Object.fromEntries(ck.stages.filter((s) => s.was && now.some((o) => o.name === s.was) && s.was !== s.name).map((s) => [s.was, s.name]));
453
+ const keptCur = ck.stages.find((s) => s.was === r.stage) || (ck.stages.some((s) => s.name === r.stage && !s.was) ? null : null);
454
+ let moved = null; const from = r.stage;
455
+ if (keptCur) r.stage = keptCur.name;
456
+ else if (ck.stages.some((s) => s.name === r.stage)) { /* the same name again */ }
457
+ else {
458
+ if (!ck.stages.some((s) => s.name === body.moveTo)) return fail(400, 'the initiative is in "' + r.stage + '", which the new workflow removes: say which stage it moves to (moveTo)');
459
+ moved = { from, to: body.moveTo }; r.stage = body.moveTo;
460
+ }
461
+ r.workflow = ck.stages.map((s) => ({ name: s.name, cat: s.cat, hint: s.hint }));
462
+ r.template = body.template || null;
463
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'workflow', board: b.id, id: r.id, template: r.template, stages: r.workflow.map((s) => s.name), renamed });
464
+ if (moved) S.events.push({ at: at(0, 8), by: 'You', kind: 'move', board: b.id, id: r.id, from: moved.from, to: moved.to, why: moved.from + ' was removed from its workflow', warnings: [] });
465
+ r.warnings = orderWarnings(r.workflow, r, null, r.stage).filter((w) => !/^Skipped/.test(w));
466
+ return okb({ row: rowOut(S, r), moved });
467
+ }
468
+ if (path === '/api/boards/move') {
469
+ if (b.self) return fail(409, 'The PMOS board derives each stage from the records. A move is not recorded here.');
470
+ const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
471
+ if (!r) return fail(404, 'No initiative "' + (body.id || '') + '" on ' + b.name + '.');
472
+ const wf = wfOf(S, r);
473
+ if (!wf.some((s) => s.name === body.to)) return fail(400, 'No stage "' + (body.to || '') + '" in the workflow of ' + r.id + '.');
474
+ const from = r.stage; const warnings = from === body.to ? [] : orderWarnings(wf, r, from, body.to);
475
+ r.stage = body.to; r.warnings = warnings;
476
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'move', board: b.id, id: r.id, from, to: body.to, why: body.why || '', warnings });
477
+ return okb({ row: rowOut(S, r), warnings });
478
+ }
479
+ if (path === '/api/boards/asks/add') {
480
+ const line = String(body.line || '').trim(); if (!line) return fail(400, 'Write the ask in one line.');
481
+ const words = line.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
482
+ const similar = S.rows.find((r) => r.board === b.id && words.some((w) => r.title.toLowerCase().includes(w)));
483
+ const draft = { title: line, lane: /bug|error|fail|broken|retr/i.test(line) ? 'incident' : 'feature', confidence: 0.5, anchor_suggestion: null, prior_rejections: [], refused: false, refused_reason: null, stage: 'discover',
484
+ ...(similar ? { similar: { id: similar.id, title: similar.title, stage: similar.stage } } : {}) };
485
+ const ask = { id: 'ask-demo' + (++S.seq), board: b.id, line, source: body.source || 'Typed by you', at: at(0, 8), state: 'open', draft };
486
+ S.asks.push(ask);
487
+ return okb({ ask: { ...ask }, draft });
488
+ }
489
+ if (path === '/api/boards/asks/more') {
490
+ const a = S.asks.find((x) => x.id === body.ask && x.board === b.id && x.state === 'open');
491
+ if (!a) return fail(404, 'No open ask "' + (body.ask || '') + '" on ' + b.name + '.');
492
+ a.needMore = true; a.moreNote = String(body.note || '').trim();
493
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'ask-more', board: b.id, ask: a.id, note: a.moreNote });
494
+ return okb({});
495
+ }
496
+ if (path === '/api/boards/asks/accept') {
497
+ const a = S.asks.find((x) => x.id === body.ask && x.board === b.id && x.state === 'open');
498
+ if (!a) return fail(404, 'No open ask "' + (body.ask || '') + '" on ' + b.name + '.');
499
+ if (!String(body.anchor || '').trim()) return fail(400, 'An initiative needs an anchor: a key result or a health budget.');
500
+ const max = S.rows.filter((r) => r.board === b.id).reduce((m, r) => Math.max(m, Number(r.id.split('-').pop()) || 0), 0);
501
+ const tpl = b.defTpl;
502
+ const own = sameFlow(tplStages(tpl), b.workflow) ? null : tplStages(tpl);
503
+ const stage = (own || b.workflow)[0].name;
504
+ const r = { id: b.prefix + '-' + (max + 1), board: b.id, title: String(body.title || a.line).trim(), stage, owner: 'You', anchor: String(body.anchor).trim(), horizon: 'next', status: 'on track', start: TODAY, end: D(60), progress: 0,
505
+ lane: body.lane || 'feature', spec: false, warnings: [], featured: false, template: own ? tpl : null, workflow: own };
506
+ S.rows.push(r); a.state = 'accepted'; a.initiative = r.id;
507
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'ask-accept', board: b.id, ask: a.id, id: r.id, stage, to: stage });
508
+ return okb({ initiative: rowOut(S, r), stage });
509
+ }
510
+ if (path === '/api/boards/asks/decline') {
511
+ const a = S.asks.find((x) => x.id === body.ask && x.board === b.id && x.state === 'open');
512
+ if (!a) return fail(404, 'No open ask "' + (body.ask || '') + '" on ' + b.name + '.');
513
+ if (!String(body.why || '').trim()) return fail(400, 'Say why you decline it.');
514
+ a.state = 'declined'; a.why = String(body.why).trim();
515
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'ask-decline', board: b.id, ask: a.id, why: a.why });
516
+ return okb({});
517
+ }
518
+ if (path === '/api/boards/tasks/add') {
519
+ const title = String(body.title || '').trim();
520
+ if (!title) return fail(400, 'title is one line of 1 to 200 characters');
521
+ const r = S.rows.find((x) => x.id === body.initiative && x.board === b.id);
522
+ if (!r) return fail(404, 'no initiative ' + (body.initiative || '') + ' on this board');
523
+ const start = body.start || shiftDay(TODAY, 1); const due = body.due || shiftDay(start, 7);
524
+ const t = { id: 'task-demo' + (++S.seq), board: b.id, initiative: r.id, title, owner: body.owner || 'You', state: 'To do', start, due, epic: body.epic || null, kind: body.type === 'Epic' ? 'Epic' : 'Task', history: [] };
525
+ S.tasks.push(t);
526
+ return okb({ item: rollup([taskItem(t)])[0] });
527
+ }
528
+ if (path === '/api/boards/tasks/state' || path === '/api/boards/tasks/reschedule') {
529
+ const t = S.tasks.find((x) => x.id === body.task && x.board === b.id);
530
+ if (!t) return fail(404, 'no PMOS task ' + (body.task || '') + ' on this board');
531
+ if (path.endsWith('/state')) {
532
+ if (!TASK_STATES.includes(body.state)) return fail(400, 'state is one of ' + TASK_STATES.join(', '));
533
+ t.history.push({ at: at(0, 8), from: t.state, to: body.state }); t.state = body.state;
534
+ } else {
535
+ const n = Number(body.days);
536
+ if (!Number.isInteger(n) || n === 0 || Math.abs(n) > 730) return fail(400, 'days is a whole number of days, not 0, at most 730 either way');
537
+ t.history.push({ at: at(0, 8), start: t.start, due: t.due }); t.start = shiftDay(t.start, n); t.due = shiftDay(t.due, n);
538
+ }
539
+ return okb({ item: rollup([taskItem(t)])[0] });
540
+ }
541
+ return fail(404, 'No route ' + path);
542
+ }
543
+
544
+ /* ── views of other routes, for the Releases, Docs and initiative tabs (c3-ib-releases.js, c3-ib-docs.js, c3-ii-tabs.js) ──
545
+ * These answer only a request that carries `board=` (the tabs add it; the old page never does), so the old demo fixtures keep answering
546
+ * everything else: GET /api/releases, /api/docs/library, /api/my-work, /api/c3-init/breakdown, /api/doc and /api/doc-sheet/revisions.
547
+ * The rows are made from the boards' featured initiatives. IDPF-07 gets no documents on purpose, so the empty state can be seen. */
548
+ function handleMore(S, method, path, p) {
549
+ if (method !== 'GET') return undefined;
550
+ const feat = (bid) => S.rows.filter((r) => r.featured && (!bid || r.board === bid) && !['IDPF-07'].includes(r.id));
551
+ if (path === '/api/doc' || path === '/api/doc-sheet/revisions') {
552
+ if (!/^planning\/ib\//.test(p.path || '')) return undefined;
553
+ const id = (p.path.match(/ib\/([a-z]+-\d+)/i) || [])[1] || '';
554
+ const text = '## Summary\nA short demo document for ' + id.toUpperCase() + '.\n\n## Details\nThe detail lives in the source tool.\n';
555
+ if (path === '/api/doc') return { status: 200, text };
556
+ return okb({ path: p.path, revisions: [{ rev: 1, by: 'human:wawan', at: at(-3, 9) + '.000Z', why: 'First version', before_hash: '', after_hash: '' }] });
557
+ }
558
+ if (!p.board) return undefined;
559
+ if (path === '/api/releases') {
560
+ return okb({ board: { name: 'Demo board' }, releases: S.releases.filter((x) => x.board === p.board).map((x) => ({ id: x.id, name: x.name, type: 'both', initiative_ids: x.scope.slice(), deploy_at: x.date + 'T11:00:00', release_at: x.date + 'T12:00:00', accepted_at: x.status === 'Released' ? x.date + 'T12:30:00' : null, gapFlags: [], tags: { detected: [], reason: null }, summary: [], status: x.status, board: x.board })) });
561
+ }
562
+ if (path === '/api/docs/library') {
563
+ const files = [];
564
+ for (const r of feat(p.board)) {
565
+ const h = hash(r.id); const slug = r.id.toLowerCase();
566
+ if (r.spec) files.push({ path: 'planning/ib/' + slug + '-prd.md', title: r.title + ' PRD', category: 'spec', date: D(-(h % 20) - 1), initiative: r.id });
567
+ files.push({ path: 'planning/ib/' + slug + '-research.md', title: r.title + ' research', category: 'res', date: D(-(h % 50) - 5), initiative: r.id });
568
+ if (catOfRow(S, r) === 'Do') files.push({ path: 'planning/ib/' + slug + '-rubric.md', title: r.title + ' rubric', category: 'eval', date: D(-(h % 9) - 1), initiative: r.id });
569
+ }
570
+ return okb({ key: 'demo', files, edges: [], kinds: [], categories: [{ key: 'spec', name: 'Specs' }, { key: 'eval', name: 'Evals' }, { key: 'res', name: 'Research & plans' }], initiatives: {} });
571
+ }
572
+ return undefined;
573
+ }
574
+
575
+ /* ── the stage records (route contract of the stage-records change; demo answers only) ── */
576
+ const RICE_IMPACT = [0.25, 0.5, 1, 2, 3];
577
+ const riceScore = (d) => Math.round((d.reach * d.impact * (d.confidence / 100)) / Math.max(0.25, d.effort) * 10) / 10;
578
+ function checkRecord(tool, d) {
579
+ if (!d || typeof d !== 'object') return 'data is an object';
580
+ if (tool === 'problem') return typeof d.text === 'string' && d.text.length <= 4000 ? '' : 'text is at most 4000 characters';
581
+ if (tool === 'rice') return [d.reach, d.confidence, d.effort].every((n) => typeof n === 'number' && Number.isFinite(n)) && RICE_IMPACT.includes(d.impact) && d.effort > 0 && d.confidence >= 0 && d.confidence <= 100 ? '' : 'reach, impact (0.25, 0.5, 1, 2 or 3), confidence 0 to 100 and effort > 0';
582
+ const max = { launch: 40, kill: 20, learnings: 60 }[tool];
583
+ if (!max) return 'tool is problem, rice, launch, kill or learnings';
584
+ return Array.isArray(d.items) && d.items.length <= max && d.items.every((x) => x && typeof x.text === 'string') ? '' : 'items is a list of at most ' + max;
585
+ }
586
+ function handleTools(S, method, path, p, body) {
587
+ const known = (id) => S.rows.some((r) => r.id === id);
588
+ if (path === '/api/stage-tools/records') {
589
+ if (method === 'POST') {
590
+ if (!known(body.id)) return fail(404, 'No initiative "' + (body.id || '') + '".');
591
+ const e = checkRecord(body.tool, body.data);
592
+ if (e) return fail(400, e);
593
+ const rec = { data: JSON.parse(JSON.stringify(body.data)), at: new Date().toISOString(), by: 'You' };
594
+ (S.records[body.id] = S.records[body.id] || {})[body.tool] = rec;
595
+ return okb({ record: rec });
596
+ }
597
+ const own = S.records[p.id] || {};
598
+ const out = {};
599
+ for (const k of ['problem', 'rice', 'launch', 'kill', 'learnings']) {
600
+ const r = own[k];
601
+ out[k] = r ? { ...r, data: k === 'rice' ? { ...r.data, score: riceScore(r.data) } : r.data } : null;
602
+ }
603
+ return okb({ ws: p.ws || WS_ID, id: p.id, records: out });
604
+ }
605
+ if (path === '/api/stage-tools/rice' && method === 'GET') {
606
+ const ids = p.ids ? String(p.ids).split(',') : Object.keys(S.records);
607
+ const rows = ids.filter((id) => S.records[id] && S.records[id].rice).map((id) => ({ id, ...S.records[id].rice.data, score: riceScore(S.records[id].rice.data), at: S.records[id].rice.at }))
608
+ .sort((a, c) => c.score - a.score).map((r, k) => ({ ...r, rank: k + 1 }));
609
+ return okb({ rows });
610
+ }
611
+ if (path === '/api/stage-tools/burndown' && method === 'GET') {
612
+ const ids = String(p.ids || '').split(',').filter(Boolean);
613
+ const b = p.board ? S.boards.find((x) => x.id === p.board) : S.boards.find((x) => ids.some((id) => S.rows.some((r) => r.id === id && r.board === x.id)));
614
+ if (!b) return okb({ available: false, reason: 'No Jira sprint covers these initiatives.' });
615
+ if (!b.build) return okb({ available: false, reason: 'Jira not enabled on this board' });
616
+ const committed = b.id === 'idpf' ? 34 : b.id === 'pay' ? 21 : 18; const len = 14; const passed = 7;
617
+ const drop = [0, 2, 3, 3, 6, 8, 11, 13];
618
+ const series = Array.from({ length: len + 1 }, (_, k) => ({ date: D(k - passed), remaining: k <= passed ? committed - drop[k] : null, ideal: Math.round((committed * (1 - k / len)) * 10) / 10 }));
619
+ const left = committed - drop[passed];
620
+ return okb({ available: true, sprint: { name: b.prefix + ' Sprint ' + (b.id === 'idpf' ? 42 : 19), state: 'active', start: D(-passed), end: D(len - passed) }, committed, done: committed - left, left, daysLeft: len - passed,
621
+ needPerDay: Math.round((left / (len - passed)) * 10) / 10, series, tickets: 12, unpointed: 1, readAt: TODAY + 'T08:10:00' });
622
+ }
623
+ return fail(404, 'No route ' + path);
624
+ }
625
+
626
+ let installed = false;
627
+ /** Wrap window.fetch once: a /api/boards request is answered from the store; anything else goes on unchanged. */
628
+ export function installBoards() {
629
+ if (installed || typeof window === 'undefined') return;
630
+ installed = true;
631
+ const S = createStore();
632
+ window.__c3boards = S; // the browser suites read the store to check what a write changed
633
+ const prev = window.fetch;
634
+ window.fetch = (input, init) => {
635
+ try {
636
+ const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
637
+ const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
638
+ const u = new URL(raw, location.origin);
639
+ if (u.origin === location.origin && (u.pathname.startsWith('/api/boards') || u.pathname.startsWith('/api/stage-tools') || u.searchParams.has('board') || u.pathname.startsWith('/api/doc'))) {
640
+ let b = {};
641
+ if (method !== 'GET') { try { b = init && init.body ? JSON.parse(init.body) : {}; } catch { b = {}; } }
642
+ const out = handle(S, method, u.pathname, Object.fromEntries(u.searchParams), b);
643
+ if (out && out.text != null) return Promise.resolve(new Response(out.text, { status: out.status, headers: { 'content-type': 'text/markdown; charset=utf-8' } }));
644
+ if (out) return Promise.resolve(new Response(JSON.stringify(out.body), { status: out.status, headers: { 'content-type': 'application/json' } }));
645
+ }
646
+ } catch { /* an unreadable request goes on as it is */ }
647
+ return prev(input, init);
648
+ };
649
+ }