create-pmos 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +117 -6
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +20 -4
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/* pm/panel-breakdown.js — the Breakdown: an initiative's items, inside today's initiative detail (pm-os-absorb Run 1).
|
|
2
|
+
*
|
|
3
|
+
* Two entry points, and no side effect at import:
|
|
4
|
+
* breakdownEntry(slug, rec, repo) one row in the initiative detail — "Breakdown · 7 of 12 ›" — that opens the tab.
|
|
5
|
+
* Exactly one control. Null in fixture mode, so the pinned screens do not change.
|
|
6
|
+
* mountBreakdown(host, tab) the tab body: the Tree or the Board, the item sheet, Undo, Hand to agent.
|
|
7
|
+
*
|
|
8
|
+
* Where the data comes from. GET /api/plan-items returns the initiative's Layout: the items folded from
|
|
9
|
+
* planning/runs/plan-items.jsonl, the stages of its profile and lane, the owed step per stage from the workflow ladder,
|
|
10
|
+
* the Board columns and the agent batches. The pane keeps the last Layout it read and draws from it; it stores nothing
|
|
11
|
+
* else (D33). Writes are optimistic: the change is drawn at once on a copy, POSTed, and the Layout is read again. A
|
|
12
|
+
* failed write shows the server's own words and reads again, so the screen never keeps a change that was not saved.
|
|
13
|
+
*
|
|
14
|
+
* When it reads again. data.js polls /api/sessions every 4 seconds and, while a Breakdown is open, asks for the
|
|
15
|
+
* plan-items signature of its workspace. A changed `items` signature (an agent's `pmos item`, another window) reads the
|
|
16
|
+
* Layout without the ladder, which is fast; a changed `evidence` signature (a commit, a run) reads it with the ladder, so
|
|
17
|
+
* the owed rows follow the workflow.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { el, setChildren } from './dom.js';
|
|
21
|
+
import { state, setState, FIXTURE, LEGACY_LAYOUT_FLAG, subscribe } from './state.js';
|
|
22
|
+
import { isMobile } from './mobile.js';
|
|
23
|
+
import { wsOf, watchLedgers } from './data.js';
|
|
24
|
+
import { currentProduct } from './board-read.js';
|
|
25
|
+
import { openTab } from './panel-initiatives.js';
|
|
26
|
+
import { getLayout, postEvents } from './breakdown-api.js';
|
|
27
|
+
import { COPY } from './breakdown-copy.js';
|
|
28
|
+
import { ensureComponentsCss, layer, meter, tabs, menu, toast, button, SEL } from './primitives.js';
|
|
29
|
+
import { ensureSprite } from './icons.js';
|
|
30
|
+
import { hold, cancel, isHeld, flush, overlay, anyHeld } from './undo-hold.js';
|
|
31
|
+
import { loadSortable, baseOptions, dragSet } from './bd-drag.js';
|
|
32
|
+
import { renderTree, bindTreeDrag } from './bd-tree.js';
|
|
33
|
+
import { renderBoard, bindBoardDrag, WIDE_BOARD } from './bd-board.js';
|
|
34
|
+
import { openItemSheet } from './item-sheet.js';
|
|
35
|
+
import { breakdownViews } from './registry.js';
|
|
36
|
+
import {
|
|
37
|
+
recount, moveLocal, setLocal, flagLocal, removeLocal, keepLocal, planLocal, orderBetween, endOrder, treeOrder,
|
|
38
|
+
} from './bd-model.js';
|
|
39
|
+
|
|
40
|
+
const COLLAPSE_KEY = 'pm.breakdown.collapsed.v1';
|
|
41
|
+
const STATE_LABEL = (s) => COPY.states[s] || s;
|
|
42
|
+
|
|
43
|
+
/* ── the entry row in the initiative detail ───────────────────────────────────────────────────────────────────────── */
|
|
44
|
+
|
|
45
|
+
const entries = new Map(); // `${ws}|${slug}` -> { state: 'idle'|'loading'|'ok'|'error', label, error }
|
|
46
|
+
let entryHooked = false;
|
|
47
|
+
|
|
48
|
+
function loadEntry(ws, slug, e) {
|
|
49
|
+
e.state = 'loading'; e.again = false;
|
|
50
|
+
getLayout(ws, slug, { ladder: false }).then((out) => {
|
|
51
|
+
if (out && out.ok !== false && out.progress) { e.state = 'ok'; e.label = out.progress.label; }
|
|
52
|
+
else { e.state = 'error'; e.error = (out && out.error) || 'could not read'; }
|
|
53
|
+
// The ledger changed while this read was in flight, so the read may not hold the change: read once more.
|
|
54
|
+
if (e.again) { if (slug === state.sel) { loadEntry(ws, slug, e); return; } e.state = 'idle'; }
|
|
55
|
+
setState((s) => ({ bdTick: (s.bdTick || 0) + 1 }));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hookEntries() {
|
|
60
|
+
if (entryHooked || typeof window === 'undefined') return;
|
|
61
|
+
entryHooked = true;
|
|
62
|
+
window.addEventListener('pmos:ledgers', (ev) => {
|
|
63
|
+
const changed = (ev && ev.detail && ev.detail.changed) || [];
|
|
64
|
+
for (const [key, e] of entries) {
|
|
65
|
+
const [ws, slug] = [key.slice(0, key.indexOf('|')), key.slice(key.indexOf('|') + 1)];
|
|
66
|
+
if (!changed.includes(ws)) continue;
|
|
67
|
+
// Not dropped while a read is in flight: that read may have started before the change (loadEntry reads again).
|
|
68
|
+
if (e.state === 'loading') { e.again = true; continue; }
|
|
69
|
+
if (slug === state.sel) loadEntry(ws, slug, e); else e.state = 'idle';
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Open the Breakdown tab of one initiative. The title is the one word; the tab's sub-line already names the slug. */
|
|
75
|
+
export function openBreakdown(ws, slug) {
|
|
76
|
+
openTab({ id: `breakdown:${ws}:${slug}`, kind: 'breakdown', ws, slug, title: COPY.breakdown, role: 'breakdown' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The detail's entry: one button, "Breakdown" with "7 of 12" or "no breakdown", that opens the tab. `repo` is the
|
|
81
|
+
* repository panel-detail resolved for the slug; the workspace is read from it with wsOf, as the detail reads it.
|
|
82
|
+
*/
|
|
83
|
+
export function breakdownEntry(slug, rec, repo) {
|
|
84
|
+
if (FIXTURE || !slug || !rec) return null;
|
|
85
|
+
const ws = wsOf(repo || currentProduct());
|
|
86
|
+
if (!ws) return null;
|
|
87
|
+
ensureComponentsCss();
|
|
88
|
+
hookEntries();
|
|
89
|
+
const key = ws + '|' + slug;
|
|
90
|
+
let e = entries.get(key);
|
|
91
|
+
if (!e) { e = { state: 'idle' }; entries.set(key, e); }
|
|
92
|
+
if (e.state === 'idle') loadEntry(ws, slug, e);
|
|
93
|
+
const meta = e.state === 'ok' ? e.label : e.state === 'error' ? e.error : '';
|
|
94
|
+
const btn = button({
|
|
95
|
+
variant: 'outline', block: true, iconEnd: 'chevron-right', onClick: () => openBreakdown(ws, slug),
|
|
96
|
+
label: [el('span', { class: 'bd-entry__title' }, COPY.breakdown), el('span', { class: 'bd-entry__meta', 'data-bd-entry-meta': '' }, meta)],
|
|
97
|
+
attrs: { class: 'bd-entry', 'data-bd-entry': '', 'data-ws': ws, 'data-slug': slug, 'aria-label': COPY.breakdown + (meta ? ', ' + meta : '') },
|
|
98
|
+
});
|
|
99
|
+
ensureSprite();
|
|
100
|
+
return LEGACY_LAYOUT_FLAG || isMobile() ? el('div', { class: 'bd-entry-wrap--legacy' }, btn) : btn;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── the pane ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
104
|
+
|
|
105
|
+
const panes = new WeakMap(); // host -> pane
|
|
106
|
+
const live = new Set();
|
|
107
|
+
let swept = false;
|
|
108
|
+
|
|
109
|
+
function readCollapsed(P) {
|
|
110
|
+
try { const all = JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '{}'); const v = all[P.ws + '|' + P.slug]; return Array.isArray(v) ? new Set(v) : null; }
|
|
111
|
+
catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
function writeCollapsed(P) {
|
|
114
|
+
try {
|
|
115
|
+
const all = JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '{}');
|
|
116
|
+
all[P.ws + '|' + P.slug] = [...P.collapsed];
|
|
117
|
+
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(all));
|
|
118
|
+
} catch { /* storage unavailable: the choice lasts for this page only */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The layout the pane draws: the last one read, with every held change applied. The server's counts, columns and stage
|
|
123
|
+
* lists are drawn as they came; only a held change (or an optimistic write, in write()) is re-counted here, and the next
|
|
124
|
+
* read replaces it.
|
|
125
|
+
*/
|
|
126
|
+
function view(P) {
|
|
127
|
+
if (!P.raw) return null;
|
|
128
|
+
const L = typeof structuredClone === 'function' ? structuredClone(P.raw) : JSON.parse(JSON.stringify(P.raw));
|
|
129
|
+
return anyHeld() ? recount(overlay(L)) : L;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function defaultOpen(L, s) {
|
|
133
|
+
return !!s.current || !!s.owed || s.proposed > 0 || (s.items || []).some((id) => L.items[id] && L.items[id].state !== 'done');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function load(P, { ladder }) {
|
|
137
|
+
const seq = ++P.seq;
|
|
138
|
+
const out = await getLayout(P.ws, P.slug, { ladder });
|
|
139
|
+
if (P.disposed) return;
|
|
140
|
+
if (out && out.ok !== false && out.ladderIncluded) {
|
|
141
|
+
P.owed = {};
|
|
142
|
+
for (const s of out.stages || []) P.owed[s.id] = { owed: s.owed || null, current: !!s.current, state: s.state || null };
|
|
143
|
+
// The owed rows are this read's, so the evidence signature that decides when to read them again is this read's too.
|
|
144
|
+
P.owedEvidence = out.sig ? out.sig.evidence : null;
|
|
145
|
+
// A derivation that failed gives no owed rows. The Tree then says so, instead of showing stages that owe nothing.
|
|
146
|
+
P.owedError = out.ladder && out.ladder.ok === false ? String(out.ladder.error || 'no reason was given') : null;
|
|
147
|
+
}
|
|
148
|
+
if (seq !== P.seq) return;
|
|
149
|
+
if (!out || out.ok === false) { P.error = (out && out.error) || 'could not read'; P.loaded = true; bump(P); return; }
|
|
150
|
+
if (!out.ladderIncluded) {
|
|
151
|
+
// the fast read leaves out what the workflow derives: keep it from the last full read
|
|
152
|
+
for (const s of out.stages || []) {
|
|
153
|
+
const k = (P.owed && P.owed[s.id]) || { owed: null, current: false, state: null };
|
|
154
|
+
s.owed = k.owed; s.current = k.current; s.state = k.state;
|
|
155
|
+
s.visible = s.visible || !!s.owed;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/* The signature the pane keeps decides when it reads again, so it may name only what this pane has read. `items` is
|
|
159
|
+
* this read's: the server takes it BEFORE it reads the ledger (lib/plan-items.js readLayout). `evidence` is the one
|
|
160
|
+
* the owed rows on screen were derived under: a fast read derives none, and taking its evidence signature would mark
|
|
161
|
+
* owed rows current that were never read again. */
|
|
162
|
+
P.raw = out; P.error = null; P.loaded = true;
|
|
163
|
+
P.sig = out.sig ? { ...out.sig, evidence: P.owedEvidence === undefined ? null : P.owedEvidence } : null;
|
|
164
|
+
bump(P);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function bump(P) {
|
|
168
|
+
P.ver++;
|
|
169
|
+
if (P.dragging) return;
|
|
170
|
+
render(P);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The context the Tree, the Board and the sheet draw with. */
|
|
174
|
+
function ctxOf(P, L) {
|
|
175
|
+
return { P, L, A: P.actions, wide: P.wide, cardMenus: P.cardMenus };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function focusKeyIn(P) {
|
|
179
|
+
const a = document.activeElement;
|
|
180
|
+
if (!a || !P.root.contains(a)) return null;
|
|
181
|
+
const k = a.closest('[data-fk]');
|
|
182
|
+
return k ? k.getAttribute('data-fk') : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function render(P) {
|
|
186
|
+
P.rendering = true;
|
|
187
|
+
try {
|
|
188
|
+
const fk = focusKeyIn(P);
|
|
189
|
+
const addFocused = P.addEl && document.activeElement === P.addEl.input;
|
|
190
|
+
const addSel = addFocused ? [P.addEl.input.selectionStart, P.addEl.input.selectionEnd] : null;
|
|
191
|
+
P.drags.destroy();
|
|
192
|
+
const L = view(P);
|
|
193
|
+
P.wide = (P.root.getBoundingClientRect().width || P.lastWidth || 0) >= WIDE_BOARD;
|
|
194
|
+
/* A wide Board with a fine pointer draws cards without the ⋯ button, as specimen A does (D1: the Board's 33-control
|
|
195
|
+
* budget; a ⋯ on every card is 50). Its cards keep the same menu, Hand to agent included, on a right-click and on
|
|
196
|
+
* Shift+F10 or the ContextMenu key (bd-board.js). A touch screen always gets the ⋯ button. */
|
|
197
|
+
P.cardMenus = !P.wide || P.coarse;
|
|
198
|
+
/* head: Tree | Board, then each registered view (registry.js, Runs 2 to 5: Timeline is Run 4's), then the meter */
|
|
199
|
+
const extra = breakdownViews();
|
|
200
|
+
P.viewsKey = viewsKey(extra);
|
|
201
|
+
if (P.view !== 'tree' && P.view !== 'board' && !extra.some((v) => v.id === P.view)) P.view = 'tree';
|
|
202
|
+
const switcher = tabs({
|
|
203
|
+
variant: 'segmented', ariaLabel: COPY.views, value: P.view,
|
|
204
|
+
items: [{ id: 'tree', label: COPY.tree }, { id: 'board', label: COPY.board }, ...extra.map((v) => ({ id: v.id, label: v.label || v.id }))],
|
|
205
|
+
onChange: (v) => P.actions.setView(v), attrs: { 'data-bd-view': P.view },
|
|
206
|
+
});
|
|
207
|
+
for (const b of switcher.querySelectorAll('[data-tab]')) b.setAttribute('data-fk', 'view:' + b.getAttribute('data-tab'));
|
|
208
|
+
const prog = L ? L.progress : null;
|
|
209
|
+
setChildren(P.headEl, [switcher, prog ? meter({ done: prog.done, total: prog.total, label: prog.label }) : null]);
|
|
210
|
+
/* body */
|
|
211
|
+
let kids;
|
|
212
|
+
if (!P.loaded) kids = [el('div', { class: 'bd-note' }, COPY.reading)];
|
|
213
|
+
else if (!L) kids = [el('div', { class: 'bd-note bd-note--error', role: 'alert' }, COPY.readFailed(P.error || ''))];
|
|
214
|
+
else {
|
|
215
|
+
if (!P.roveId || !L.items[P.roveId]) P.roveId = firstRow(P, L);
|
|
216
|
+
const ext = extra.find((v) => v.id === P.view);
|
|
217
|
+
kids = ext ? [extHost(P, ext, L)] : P.view === 'board' ? renderBoard(ctxOf(P, L)) : renderTree(ctxOf(P, L));
|
|
218
|
+
kids.unshift(...notesOf(P, L));
|
|
219
|
+
if (P.error) kids.unshift(el('div', { class: 'bd-note bd-note--error', role: 'alert' }, COPY.readFailed(P.error)));
|
|
220
|
+
}
|
|
221
|
+
setChildren(P.bodyEl, kids);
|
|
222
|
+
P.root.setAttribute('data-bd-view', P.view);
|
|
223
|
+
P.root.setAttribute('data-bd-progress', prog ? prog.label : '');
|
|
224
|
+
if (fk) {
|
|
225
|
+
const t = P.root.querySelector('[data-fk="' + (window.CSS && CSS.escape ? CSS.escape(fk) : fk) + '"]');
|
|
226
|
+
if (t && document.activeElement !== t) t.focus({ preventScroll: true });
|
|
227
|
+
}
|
|
228
|
+
if (addFocused && P.addEl && P.addEl.input.isConnected) {
|
|
229
|
+
P.addEl.input.focus({ preventScroll: true });
|
|
230
|
+
try { P.addEl.input.setSelectionRange(addSel[0], addSel[1]); } catch { /* not a text input */ }
|
|
231
|
+
}
|
|
232
|
+
P.renderedVer = P.ver;
|
|
233
|
+
if (P.sheet && P.sheet.open) P.sheet.refresh();
|
|
234
|
+
bindDrag(P);
|
|
235
|
+
} finally { P.rendering = false; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* What the rows cannot say, in words above them. Static text: neither note adds a control.
|
|
239
|
+
* - Owed steps that could not be derived. The Tree then draws none, and without this note a failure read as a stage
|
|
240
|
+
* that owes nothing. The engine's own word for the derivation is kept off the screen (glossary.test.js).
|
|
241
|
+
* - The ledger's own flags: a torn or schema-invalid line, an item planned twice, one event with two contents, a Keep
|
|
242
|
+
* with no effect, a predecessor loop. The fold skips those lines, so no row shows them. An unknown stage is left
|
|
243
|
+
* out: its item already shows under Other. */
|
|
244
|
+
const FLAGS_SHOWN = 3;
|
|
245
|
+
function notesOf(P, L) {
|
|
246
|
+
const out = [];
|
|
247
|
+
if (P.view === 'tree' && P.owedError) {
|
|
248
|
+
const why = P.owedError.replace(/^the ladder could not be derived:\s*/i, '').replace(/\bladders?\b/gi, 'workflow');
|
|
249
|
+
out.push(el('div', { class: 'bd-note bd-note--error', role: 'status', 'data-bd-owed-error': '' }, COPY.owedFailed(why)));
|
|
250
|
+
}
|
|
251
|
+
const flags = (L.flags || []).filter((f) => f && f.message && f.kind !== 'unknown-stage');
|
|
252
|
+
if (flags.length) {
|
|
253
|
+
const shown = flags.slice(0, FLAGS_SHOWN);
|
|
254
|
+
out.push(el('div', { class: 'bd-note bd-note--flags', 'data-bd-flags': String(flags.length) },
|
|
255
|
+
el('div', null, COPY.flagsHead(flags.length)),
|
|
256
|
+
el('ul', { class: 'bd-flags' }, shown.map((f) => el('li', { 'data-flag': f.kind }, f.message))),
|
|
257
|
+
flags.length > shown.length ? el('div', null, COPY.flagsMore(flags.length - shown.length)) : null));
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ── a registered view (registry.js registerBreakdownView): its own host, mounted on every render ───────────────────── */
|
|
263
|
+
|
|
264
|
+
const viewsKey = (list) => list.map((v) => v.id).join(',');
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The host a registered view draws into: one element per view id, kept while that view is shown. `mount(host, ctx)` is
|
|
268
|
+
* called on every render of the pane with the Layout of that render; the view rebuilds only what changed. ctx:
|
|
269
|
+
* ws, slug, layout the workspace, the initiative and the Layout the pane draws (held changes applied)
|
|
270
|
+
* coarse a coarse pointer
|
|
271
|
+
* setFields(id, f) one `set` event through the pane's own write (optimistic, POST /api/plan-items, read again)
|
|
272
|
+
* openItem(id) the item sheet
|
|
273
|
+
* hold(on) true while a gesture is live: the pane does not draw again until hold(false)
|
|
274
|
+
*/
|
|
275
|
+
function extHost(P, view, L) {
|
|
276
|
+
let host = P.extHosts.get(view.id);
|
|
277
|
+
if (!host) { host = el('div', { class: 'bd-view', 'data-bd-ext': view.id }); P.extHosts.set(view.id, host); }
|
|
278
|
+
const ctx = {
|
|
279
|
+
ws: P.ws, slug: P.slug, layout: L, coarse: P.coarse,
|
|
280
|
+
setFields: (id, fields) => P.actions.setFields(id, fields),
|
|
281
|
+
openItem: (id) => P.actions.openSheet(id),
|
|
282
|
+
hold: (on) => { P.dragging = !!on; if (!on && !P.disposed && P.renderedVer !== P.ver) render(P); },
|
|
283
|
+
};
|
|
284
|
+
try { view.mount(host, ctx); } catch (e) { console.error('[pm] Breakdown view ' + view.id + ' failed:', e); }
|
|
285
|
+
return host;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function disposeExt(P, id) {
|
|
289
|
+
const host = P.extHosts.get(id);
|
|
290
|
+
if (!host) return;
|
|
291
|
+
P.extHosts.delete(id);
|
|
292
|
+
P.dragging = false;
|
|
293
|
+
const view = breakdownViews().find((v) => v.id === id);
|
|
294
|
+
if (view && typeof view.dispose === 'function') { try { view.dispose(host); } catch (e) { console.error('[pm] Breakdown view ' + id + ' dispose failed:', e); } }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function bindDrag(P) {
|
|
298
|
+
if (!P.raw || P.disposed) return;
|
|
299
|
+
const ver = P.ver;
|
|
300
|
+
loadSortable().then((Sortable) => {
|
|
301
|
+
if (!Sortable || P.disposed || P.ver !== ver || P.drags.size) return;
|
|
302
|
+
const L = view(P);
|
|
303
|
+
if (!L) return;
|
|
304
|
+
const ctx = ctxOf(P, L);
|
|
305
|
+
if (P.view === 'board') bindBoardDrag(ctx, Sortable, P.drags, baseOptions);
|
|
306
|
+
else if (P.view === 'tree') bindTreeDrag(ctx, Sortable, P.drags, baseOptions);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function firstRow(P, L) {
|
|
311
|
+
for (const s of L.stages || []) {
|
|
312
|
+
if (!s.visible || !isExpanded(P, L, s)) continue;
|
|
313
|
+
const id = (s.items || []).find((x) => P.foldOpen.has(s.id) || L.items[x].state !== 'done');
|
|
314
|
+
if (id) return id;
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isExpanded(P, L, s) {
|
|
320
|
+
if (P.adding && P.adding.stage === s.id) return true;
|
|
321
|
+
if (P.collapsed) return !P.collapsed.has(s.id);
|
|
322
|
+
return defaultOpen(L, s);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ── writes ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
326
|
+
|
|
327
|
+
async function write(P, events, apply) {
|
|
328
|
+
if (apply && P.raw) { apply(P.raw); recount(P.raw); bump(P); }
|
|
329
|
+
const out = await postEvents(P.ws, events);
|
|
330
|
+
if (!out || out.ok === false) {
|
|
331
|
+
toast({ text: (out && out.error) || 'nothing was saved', tone: 'error' });
|
|
332
|
+
await load(P, { ladder: true });
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
/* The POST's own signature is not kept: it names the ledger after this write AND after any line another writer
|
|
336
|
+
* appended first, which this pane has not read. The read below brings the items and their signature together. A
|
|
337
|
+
* poll that lands before it reads once more, which is harmless. */
|
|
338
|
+
await load(P, { ladder: false });
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** After an Undo: draw again, and give the row back its focus (the toast's button that had it is gone). */
|
|
343
|
+
function undone(P, id) {
|
|
344
|
+
bump(P);
|
|
345
|
+
const t = P.root.querySelector('[data-fk="open:' + id + '"], [data-fk="card:' + id + '"]');
|
|
346
|
+
if (t && t.isConnected && !(P.sheet && P.sheet.open)) t.focus({ preventScroll: true });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function makeActions(P) {
|
|
350
|
+
const L = () => view(P);
|
|
351
|
+
const A = {
|
|
352
|
+
isExpanded: (s) => isExpanded(P, L(), s),
|
|
353
|
+
refresh: () => load(P, { ladder: false }),
|
|
354
|
+
rerender: () => bump(P),
|
|
355
|
+
setView(v) { if (P.view === v) return; disposeExt(P, P.view); P.view = v; P.roveId = null; bump(P); },
|
|
356
|
+
setBoardCol(c) { if (P.boardCol === c) return; P.boardCol = c; bump(P); },
|
|
357
|
+
toggleStage(id) {
|
|
358
|
+
const lay = L();
|
|
359
|
+
if (!P.collapsed) P.collapsed = new Set((lay.stages || []).filter((s) => !defaultOpen(lay, s)).map((s) => s.id));
|
|
360
|
+
if (P.collapsed.has(id)) P.collapsed.delete(id); else P.collapsed.add(id);
|
|
361
|
+
writeCollapsed(P);
|
|
362
|
+
bump(P);
|
|
363
|
+
},
|
|
364
|
+
toggleFold(id) { if (P.foldOpen.has(id)) P.foldOpen.delete(id); else P.foldOpen.add(id); bump(P); },
|
|
365
|
+
startAdd(stage, afterId) {
|
|
366
|
+
P.adding = { stage, afterId: afterId || null };
|
|
367
|
+
if (P.collapsed && P.collapsed.has(stage)) { P.collapsed.delete(stage); writeCollapsed(P); }
|
|
368
|
+
bump(P);
|
|
369
|
+
if (P.addEl) P.addEl.input.focus();
|
|
370
|
+
},
|
|
371
|
+
closeAdd(refocus) {
|
|
372
|
+
const was = P.adding;
|
|
373
|
+
P.adding = null; P.addEl = null;
|
|
374
|
+
bump(P);
|
|
375
|
+
if (refocus && was) {
|
|
376
|
+
const t = P.root.querySelector(was.afterId ? '[data-fk="open:' + was.afterId + '"]' : '[data-fk="add:' + was.stage + '"]');
|
|
377
|
+
if (t) t.focus();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
async addTitles(stage, titles, afterId) {
|
|
381
|
+
const lay = L();
|
|
382
|
+
if (!lay) return;
|
|
383
|
+
const list = (lay.stages.find((s) => s.id === stage) || { items: [] }).items || [];
|
|
384
|
+
let prev = null; let next = null;
|
|
385
|
+
if (afterId && lay.items[afterId]) {
|
|
386
|
+
prev = lay.items[afterId].order;
|
|
387
|
+
const i = list.indexOf(afterId);
|
|
388
|
+
const n = list.slice(i + 1).find((x) => lay.items[x]);
|
|
389
|
+
next = n ? lay.items[n].order : null;
|
|
390
|
+
}
|
|
391
|
+
const events = []; const tmp = [];
|
|
392
|
+
titles.forEach((title, k) => {
|
|
393
|
+
let order;
|
|
394
|
+
if (afterId) {
|
|
395
|
+
const step = next === null ? 1024 : (next - prev) / (titles.length + 1);
|
|
396
|
+
order = next === null ? prev + 1024 * (k + 1) : prev + step * (k + 1);
|
|
397
|
+
if (!(Math.abs(step) >= 1e-6)) order = undefined;
|
|
398
|
+
}
|
|
399
|
+
const ev = { ev: 'planned', initiative: P.slug, title, stage };
|
|
400
|
+
if (Number.isFinite(order)) ev.order = order;
|
|
401
|
+
events.push(ev);
|
|
402
|
+
tmp.push({ tmpId: P.slug + '.tmp' + (++P.tmpN), title, stage, order });
|
|
403
|
+
});
|
|
404
|
+
if (P.adding) P.adding.afterId = afterId ? tmp[tmp.length - 1].tmpId : null;
|
|
405
|
+
if (afterId) P.roveId = tmp[tmp.length - 1].tmpId;
|
|
406
|
+
const out = await write(P, events, (raw) => { for (const t of tmp) planLocal(raw, t); });
|
|
407
|
+
if (out && out.ok !== false && Array.isArray(out.written)) {
|
|
408
|
+
const ids = out.written.map((w) => w.item).filter(Boolean);
|
|
409
|
+
const k = tmp.findIndex((t) => t.tmpId === P.roveId);
|
|
410
|
+
if (k >= 0 && ids[k]) P.roveId = ids[k];
|
|
411
|
+
}
|
|
412
|
+
if (out && out.ok !== false && Array.isArray(out.written) && P.adding) {
|
|
413
|
+
const ids = out.written.map((w) => w.item).filter(Boolean);
|
|
414
|
+
if (P.adding.afterId && P.adding.afterId.includes('.tmp')) P.adding.afterId = ids[ids.length - 1] || null;
|
|
415
|
+
bump(P);
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
tick(id) {
|
|
419
|
+
const it = L() && L().items[id];
|
|
420
|
+
if (!it) return;
|
|
421
|
+
if (it.state !== 'done') { A.moveTo(id, 'done'); return; }
|
|
422
|
+
if (cancel('done:' + id)) { bump(P); return; }
|
|
423
|
+
write(P, [{ ev: 'moved', item: id, to: 'todo' }], (raw) => moveLocal(raw, id, 'todo'));
|
|
424
|
+
},
|
|
425
|
+
moveTo(id, to) {
|
|
426
|
+
const it = L() && L().items[id];
|
|
427
|
+
if (!it) { bump(P); return; }
|
|
428
|
+
if (it.state === to) { bump(P); return; }
|
|
429
|
+
if (to === 'done') {
|
|
430
|
+
hold({
|
|
431
|
+
key: 'done:' + id, ws: P.ws, text: COPY.markedDone,
|
|
432
|
+
events: [{ ev: 'moved', item: id, to: 'done' }],
|
|
433
|
+
apply: (lay) => moveLocal(lay, id, 'done'),
|
|
434
|
+
onDone: () => load(P, { ladder: false }), onUndo: () => undone(P, id),
|
|
435
|
+
});
|
|
436
|
+
bump(P);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (isHeld('done:' + id)) cancel('done:' + id);
|
|
440
|
+
write(P, [{ ev: 'moved', item: id, to }], (raw) => moveLocal(raw, id, to));
|
|
441
|
+
},
|
|
442
|
+
withdraw(id) {
|
|
443
|
+
const it = L() && L().items[id];
|
|
444
|
+
if (!it) return;
|
|
445
|
+
// A held Done is written first, so an Undo of the withdraw returns the item as Done, not as it was before the Done.
|
|
446
|
+
flush('done:' + id);
|
|
447
|
+
hold({
|
|
448
|
+
key: 'withdraw:' + id, ws: P.ws, text: COPY.withdrawn,
|
|
449
|
+
events: [{ ev: 'withdrawn', item: id }],
|
|
450
|
+
apply: (lay) => removeLocal(lay, id),
|
|
451
|
+
onDone: () => load(P, { ladder: false }), onUndo: () => undone(P, id),
|
|
452
|
+
});
|
|
453
|
+
bump(P);
|
|
454
|
+
},
|
|
455
|
+
setFields(id, fields) { return write(P, [{ ev: 'set', item: id, fields }], (raw) => setLocal(raw, id, fields)); },
|
|
456
|
+
flag(id, blocked, reason) {
|
|
457
|
+
return write(P, [blocked ? { ev: 'flagged', item: id, blocked: true, reason } : { ev: 'flagged', item: id, blocked: false }],
|
|
458
|
+
(raw) => flagLocal(raw, id, blocked, reason));
|
|
459
|
+
},
|
|
460
|
+
keepApplied(b) { if (P.raw) { keepLocal(P.raw, b.by, b.items); bump(P); } },
|
|
461
|
+
/** Keep all from the item sheet: the same `kept` line the Keep card writes, and only the page writes it. */
|
|
462
|
+
async keepBatch(b) {
|
|
463
|
+
const n = (b.items || []).length;
|
|
464
|
+
const out = await write(P, [{ ev: 'kept', initiative: P.slug, batch: b.by, items: b.items }], (raw) => keepLocal(raw, b.by, b.items));
|
|
465
|
+
if (out && out.ok !== false) toast({ text: COPY.kept(n) });
|
|
466
|
+
},
|
|
467
|
+
reorder(id, dir) {
|
|
468
|
+
const lay = L();
|
|
469
|
+
const it = lay && lay.items[id];
|
|
470
|
+
if (!it) return;
|
|
471
|
+
const stages = (lay.stages || []).filter((s) => s.visible);
|
|
472
|
+
const si = stages.findIndex((s) => s.id === it.stage);
|
|
473
|
+
const list = si >= 0 ? stages[si].items : [];
|
|
474
|
+
const i = list.indexOf(id);
|
|
475
|
+
if (dir < 0 && i > 0) {
|
|
476
|
+
const before = list[i - 1]; const beforeBefore = i > 1 ? list[i - 2] : null;
|
|
477
|
+
placeAt(P, id, it.stage, beforeBefore ? lay.items[beforeBefore].order : null, lay.items[before].order, list.filter((x) => x !== id), i - 1);
|
|
478
|
+
} else if (dir > 0 && i >= 0 && i < list.length - 1) {
|
|
479
|
+
const after = list[i + 1]; const afterAfter = i < list.length - 2 ? list[i + 2] : null;
|
|
480
|
+
placeAt(P, id, it.stage, lay.items[after].order, afterAfter ? lay.items[afterAfter].order : null, list.filter((x) => x !== id), i + 1);
|
|
481
|
+
} else if (dir < 0 && si > 0) {
|
|
482
|
+
const to = stages[si - 1];
|
|
483
|
+
write(P, [{ ev: 'set', item: id, fields: { stage: to.id, order: endOrder(lay, to.id) } }], (raw) => setLocal(raw, id, { stage: to.id, order: endOrder(raw, to.id) }));
|
|
484
|
+
} else if (dir > 0 && si >= 0 && si < stages.length - 1) {
|
|
485
|
+
const to = stages[si + 1];
|
|
486
|
+
const first = (to.items || [])[0];
|
|
487
|
+
const order = first ? lay.items[first].order - 1024 : 1024;
|
|
488
|
+
if (P.collapsed && P.collapsed.has(to.id)) { P.collapsed.delete(to.id); writeCollapsed(P); }
|
|
489
|
+
write(P, [{ ev: 'set', item: id, fields: { stage: to.id, order } }], (raw) => setLocal(raw, id, { stage: to.id, order }));
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
dropTree(evt) {
|
|
493
|
+
const id = evt.item && evt.item.getAttribute('data-item');
|
|
494
|
+
const toList = evt.to;
|
|
495
|
+
const stage = toList ? toList.getAttribute('data-stage-list') : null;
|
|
496
|
+
const lay = L();
|
|
497
|
+
if (!id || !lay || !lay.items[id] || !stage || (evt.from === evt.to && evt.oldIndex === evt.newIndex)) { bump(P); return; }
|
|
498
|
+
const rows = [...toList.querySelectorAll(':scope > .bd-trow')].map((r) => r.getAttribute('data-item'));
|
|
499
|
+
const k = rows.indexOf(id);
|
|
500
|
+
const prev = k > 0 ? lay.items[rows[k - 1]] : null;
|
|
501
|
+
const next = k < rows.length - 1 ? lay.items[rows[k + 1]] : null;
|
|
502
|
+
placeAt(P, id, stage, prev ? prev.order : null, next ? next.order : null, rows.filter((x) => x !== id), k);
|
|
503
|
+
},
|
|
504
|
+
dropHeader(id, stage) {
|
|
505
|
+
const lay = L();
|
|
506
|
+
if (!id || !lay || !lay.items[id]) { bump(P); return; }
|
|
507
|
+
if (lay.items[id].stage === stage) { bump(P); return; }
|
|
508
|
+
const order = endOrder(lay, stage);
|
|
509
|
+
write(P, [{ ev: 'set', item: id, fields: { stage, order } }], (raw) => setLocal(raw, id, { stage, order }));
|
|
510
|
+
},
|
|
511
|
+
openSheet(id, opts) {
|
|
512
|
+
if (P.sheet && P.sheet.open) P.sheet.close();
|
|
513
|
+
const ctx = {
|
|
514
|
+
ws: P.ws, slug: P.slug, view: () => view(P),
|
|
515
|
+
move: (i, to) => A.moveTo(i, to), setFields: (i, f) => A.setFields(i, f), flag: (i, b, r) => A.flag(i, b, r),
|
|
516
|
+
withdraw: (i) => A.withdraw(i), sessions: () => state.sessions || [],
|
|
517
|
+
attachEvidence: (i, evidence) => write(P, [{ ev: 'moved', item: i, to: 'done', evidence }], (raw) => {
|
|
518
|
+
const x = raw.items[i]; const k = String(evidence).indexOf(':');
|
|
519
|
+
if (x && k > 0) { x.evidence = { kind: String(evidence).slice(0, k), ref: String(evidence).slice(k + 1) }; x.noEvidence = false; }
|
|
520
|
+
}),
|
|
521
|
+
keep: (b) => A.keepBatch(b),
|
|
522
|
+
onClose: (i) => {
|
|
523
|
+
P.sheet = null;
|
|
524
|
+
const t = P.root.querySelector('[data-fk="open:' + i + '"], [data-fk="card:' + i + '"]');
|
|
525
|
+
if (t && t.isConnected) t.focus({ preventScroll: true });
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
P.sheet = openItemSheet(ctx, id, opts);
|
|
529
|
+
},
|
|
530
|
+
menu(anchor, id, place) {
|
|
531
|
+
const lay = L();
|
|
532
|
+
const it = lay && lay.items[id];
|
|
533
|
+
if (!it) return;
|
|
534
|
+
const items = [];
|
|
535
|
+
if (place === 'tree') items.push({ label: COPY.menu.open, onSelect: () => A.openSheet(id) });
|
|
536
|
+
for (const s of ['todo', 'doing', 'review', 'done']) if (s !== it.state) items.push({ label: COPY.menu.moveTo(STATE_LABEL(s)), onSelect: () => A.moveTo(id, s) });
|
|
537
|
+
if (place === 'tree') {
|
|
538
|
+
items.push({ sep: true });
|
|
539
|
+
items.push({ label: COPY.menu.moveUp, icon: 'arrow-up', onSelect: () => A.reorder(id, -1) });
|
|
540
|
+
items.push({ label: COPY.menu.moveDown, icon: 'arrow-down', onSelect: () => A.reorder(id, 1) });
|
|
541
|
+
}
|
|
542
|
+
items.push({ sep: true });
|
|
543
|
+
items.push({ label: COPY.handToAgent, icon: 'bot', onSelect: () => A.openSheet(id, { handoff: true }) });
|
|
544
|
+
items.push({ label: COPY.labels.withdraw, danger: true, onSelect: () => A.withdraw(id) });
|
|
545
|
+
menu({ anchor, items, ariaLabel: COPY.aria.more(it.title) });
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
return A;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Put `id` between two orders in `stage`. When the gap is too small, renumber the whole stage in one request. */
|
|
552
|
+
function placeAt(P, id, stage, prevOrder, nextOrder, others, index) {
|
|
553
|
+
const lay = view(P);
|
|
554
|
+
const it = lay.items[id];
|
|
555
|
+
const stageChange = it.stage !== stage;
|
|
556
|
+
const order = orderBetween(prevOrder, nextOrder);
|
|
557
|
+
if (order !== null) {
|
|
558
|
+
const fields = stageChange ? { stage, order } : { order };
|
|
559
|
+
write(P, [{ ev: 'set', item: id, fields }], (raw) => setLocal(raw, id, fields));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const ids = (lay.stages.find((s) => s.id === stage) || { items: [] }).items.filter((x) => x !== id);
|
|
563
|
+
const full = [...(others || ids)];
|
|
564
|
+
full.splice(Math.max(0, Math.min(index, full.length)), 0, id);
|
|
565
|
+
const events = full.map((x, i) => ({ ev: 'set', item: x, fields: x === id && stageChange ? { stage, order: (i + 1) * 1024 } : { order: (i + 1) * 1024 } }));
|
|
566
|
+
write(P, events, (raw) => { full.forEach((x, i) => setLocal(raw, x, x === id && stageChange ? { stage, order: (i + 1) * 1024 } : { order: (i + 1) * 1024 })); });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* ── keyboard on the pane body ────────────────────────────────────────────────────────────────────────────────────── */
|
|
570
|
+
|
|
571
|
+
function onKey(P, e) {
|
|
572
|
+
const t = e.target;
|
|
573
|
+
if (!t || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
|
|
574
|
+
if (P.view !== 'tree') return;
|
|
575
|
+
const fk = (t.closest('[data-fk]') || t).getAttribute('data-fk') || '';
|
|
576
|
+
const rowId = fk.startsWith('open:') ? fk.slice(5) : null;
|
|
577
|
+
const headId = fk.startsWith('stage:') ? fk.slice(6) : null;
|
|
578
|
+
if (!rowId && !headId) return;
|
|
579
|
+
const A = P.actions;
|
|
580
|
+
const handled = () => { e.preventDefault(); e.stopPropagation(); };
|
|
581
|
+
const roves = [...P.bodyEl.querySelectorAll('[data-rove]')];
|
|
582
|
+
const i = roves.indexOf(t);
|
|
583
|
+
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
584
|
+
if (rowId) { handled(); P.roveId = rowId; A.reorder(rowId, e.key === 'ArrowUp' ? -1 : 1); }
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
588
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'j' || e.key === 'k') {
|
|
589
|
+
handled();
|
|
590
|
+
const j = i + (e.key === 'ArrowUp' || e.key === 'k' ? -1 : 1);
|
|
591
|
+
const n = roves[Math.max(0, Math.min(roves.length - 1, j))];
|
|
592
|
+
if (n) {
|
|
593
|
+
for (const r of roves) if (r.getAttribute('data-rove') === 'row') r.setAttribute('tabindex', '-1');
|
|
594
|
+
if (n.getAttribute('data-rove') === 'row') { n.setAttribute('tabindex', '0'); P.roveId = (n.getAttribute('data-fk') || '').slice(5); }
|
|
595
|
+
n.focus();
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (e.key === ' ' && rowId) { handled(); A.tick(rowId); return; }
|
|
600
|
+
if (e.key === 'n' || e.key === 'N') {
|
|
601
|
+
handled();
|
|
602
|
+
if (rowId) { const lay = view(P); A.startAdd(lay.items[rowId].stage, rowId); } else A.startAdd(headId, null);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/* ── mount ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
607
|
+
|
|
608
|
+
function dispose(P) {
|
|
609
|
+
P.disposed = true;
|
|
610
|
+
for (const id of [...P.extHosts.keys()]) disposeExt(P, id);
|
|
611
|
+
P.drags.destroy();
|
|
612
|
+
if (P.unwatch) P.unwatch();
|
|
613
|
+
if (P.onLedgers) window.removeEventListener('pmos:ledgers', P.onLedgers);
|
|
614
|
+
if (P.resize) P.resize.disconnect();
|
|
615
|
+
if (P.sheet && P.sheet.open) P.sheet.close();
|
|
616
|
+
live.delete(P);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function sweep() {
|
|
620
|
+
const open = new Set((state.tabs || []).map((t) => t && t.id));
|
|
621
|
+
// an embedded pane (the initiative sheet's Breakdown tab, Run 2) is not a panel-4 tab: its owner calls unmountBreakdown
|
|
622
|
+
for (const P of [...live]) if (!P.embedded && !open.has(P.tab.id)) dispose(P);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function createPane(host, tab) {
|
|
626
|
+
const ws = tab.ws; const slug = tab.slug;
|
|
627
|
+
const headEl = el('div', { class: 'bd-head' });
|
|
628
|
+
const bodyEl = el('div', { class: 'bd-body', 'data-bd-body': '' });
|
|
629
|
+
const root = el('div', { class: 'bd-pane', 'data-bd-pane': '', 'data-ws': ws || '', 'data-slug': slug || '' }, headEl, bodyEl);
|
|
630
|
+
const P = {
|
|
631
|
+
host, tab, ws, slug, root, headEl, bodyEl, raw: null, sig: null, owed: null, error: null, loaded: false,
|
|
632
|
+
view: 'tree', boardCol: 'todo', foldOpen: new Set(), adding: null, addEl: null, roveId: null, tmpN: 0,
|
|
633
|
+
ver: 0, renderedVer: -1, seq: 0, drags: dragSet(), dragging: false, rendering: false, disposed: false, sheet: null,
|
|
634
|
+
wide: false, cardMenus: true, lastWidth: 0, extHosts: new Map(), viewsKey: '',
|
|
635
|
+
coarse: typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
|
|
636
|
+
};
|
|
637
|
+
P.collapsed = readCollapsed(P);
|
|
638
|
+
P.actions = makeActions(P);
|
|
639
|
+
bodyEl.addEventListener('keydown', (e) => onKey(P, e));
|
|
640
|
+
P.unwatch = watchLedgers(ws);
|
|
641
|
+
P.onLedgers = (e) => {
|
|
642
|
+
const d = e && e.detail;
|
|
643
|
+
if (!d || !(d.changed || []).includes(ws)) return;
|
|
644
|
+
const sig = d.ledgers && d.ledgers[ws];
|
|
645
|
+
if (!sig) return;
|
|
646
|
+
const evidenceMoved = sig.evidence && P.sig && sig.evidence !== P.sig.evidence;
|
|
647
|
+
const itemsMoved = !P.sig || sig.items !== P.sig.items;
|
|
648
|
+
if (evidenceMoved) load(P, { ladder: true });
|
|
649
|
+
else if (itemsMoved) load(P, { ladder: false });
|
|
650
|
+
};
|
|
651
|
+
window.addEventListener('pmos:ledgers', P.onLedgers);
|
|
652
|
+
if (typeof ResizeObserver === 'function') {
|
|
653
|
+
P.resize = new ResizeObserver((entries) => {
|
|
654
|
+
const w = entries[0] && entries[0].contentRect.width;
|
|
655
|
+
if (!w) return;
|
|
656
|
+
P.lastWidth = w;
|
|
657
|
+
if ((w >= WIDE_BOARD) !== P.wide && P.view === 'board') bump(P);
|
|
658
|
+
});
|
|
659
|
+
P.resize.observe(root);
|
|
660
|
+
}
|
|
661
|
+
return P;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The Breakdown tab's body. Called on every app render; it rebuilds only when the data or the view changed. The pane's
|
|
666
|
+
* state lives here, not in state.js.
|
|
667
|
+
*
|
|
668
|
+
* `embedded: true` (pm-os-absorb Runs 2 to 5) mounts the same pane inside another surface, such as the initiative sheet.
|
|
669
|
+
* `tab` is then `{ id, ws, slug }` with an id that is no panel-4 tab; closing tabs does not dispose the pane, and the
|
|
670
|
+
* owner calls unmountBreakdown(host) when it lets go of the host.
|
|
671
|
+
*/
|
|
672
|
+
export function mountBreakdown(host, tab, { embedded = false } = {}) {
|
|
673
|
+
if (FIXTURE) { if (!host.firstChild) host.appendChild(el('div', null, COPY.breakdown)); return; }
|
|
674
|
+
if (!swept) { swept = true; subscribe(sweep); }
|
|
675
|
+
let P = panes.get(host);
|
|
676
|
+
if (P && (P.disposed || P.tab.id !== tab.id)) { dispose(P); P = null; }
|
|
677
|
+
if (!P) {
|
|
678
|
+
ensureComponentsCss(); layer(); ensureSprite();
|
|
679
|
+
P = createPane(host, tab);
|
|
680
|
+
P.embedded = !!embedded;
|
|
681
|
+
panes.set(host, P); live.add(P);
|
|
682
|
+
if (host.firstChild !== P.root || host.childNodes.length !== 1) setChildren(host, [P.root]);
|
|
683
|
+
render(P);
|
|
684
|
+
load(P, { ladder: true });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (host.firstChild !== P.root || host.childNodes.length !== 1) setChildren(host, [P.root]);
|
|
688
|
+
// a view registered after the pane was drawn (the registry loads after boot) joins the switch on the next app render
|
|
689
|
+
if ((P.renderedVer !== P.ver || P.viewsKey !== viewsKey(breakdownViews())) && !P.dragging) render(P);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Release the pane mounted in `host` (its ledger watch, its listeners, an open sheet). For an embedded pane. */
|
|
693
|
+
export function unmountBreakdown(host) {
|
|
694
|
+
const P = panes.get(host);
|
|
695
|
+
if (!P) return;
|
|
696
|
+
panes.delete(host);
|
|
697
|
+
if (!P.disposed) dispose(P);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* for the browser suites: the pane of a tab id, read-only */
|
|
701
|
+
export function _paneOf(tabId) { for (const P of live) if (P.tab.id === tabId) return P; return null; }
|
|
702
|
+
export { SEL };
|