@bongos/core 1.19.963 → 1.19.965
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/.bongos-core.json +70 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +41 -0
- package/docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md +5 -0
- package/docs/adr/README.md +2 -2
- package/docs/api/openapi.json +47 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +134 -128
- package/docs/copy-registry.json +212 -158
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-forest-lib.js +229 -0
- package/modules/hall-ui/public/goal-forest-render.js +151 -0
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +9 -1
- package/modules/hall-ui/public/goals.css +26 -0
- package/modules/hall-ui/public/goals.html +19 -0
- package/modules/lifecycle/goal-forest.js +135 -0
- package/modules/lifecycle/routes/goals.js +8 -8
- package/modules/provisioning/starter-bundles.js +26 -2
- package/modules/public-landing/public/projects.html +17 -4
- package/modules/ui-design/kit/serve.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +29 -6
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +48 -4
- package/tests/goal_category_ui.mjs +2 -0
- package/tests/goal_criterion_confirm_ui.mjs +2 -0
- package/tests/goal_forest.mjs +160 -0
- package/tests/goal_forest_lib.mjs +153 -0
- package/tests/goal_forest_page.mjs +196 -0
- package/tests/goal_invite_consent.mjs +1 -1
- package/tests/helpers.mjs +7 -3
- package/tests/projects_hub_module_picker.mjs +79 -4
- package/tests/provisioning_starter_bundles.mjs +25 -1
|
@@ -2400,5 +2400,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2400
2400
|
landed since 1.19.961 with no explicit bump. run 35761137402. (task 1002620)
|
|
2401
2401
|
1.19.963 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2402
2402
|
landed since 1.19.962 with no explicit bump. run 35770645163. (task 1002620)
|
|
2403
|
+
1.19.964 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2404
|
+
landed since 1.19.963 with no explicit bump. run 35787848112. (task 1002620)
|
|
2405
|
+
1.19.965 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2406
|
+
landed since 1.19.964 with no explicit bump. run 35790478196. (task 1002620)
|
|
2403
2407
|
---------------------------------------------------------------------------
|
|
2404
2408
|
```
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// goal-forest-lib.js — the per-goal dependency forest's model and layout, pure
|
|
2
|
+
// (task 1003480, goal 1000089, ADR 0227 §6 item 4 and its §10.5 amendment).
|
|
3
|
+
//
|
|
4
|
+
// NOT A ZOOM OF A GLOBAL MAP (ADR 0227 §1). This draws the tasks INSIDE one
|
|
5
|
+
// already-open goal that depend on each other, at full resolution — there is
|
|
6
|
+
// no aggregation to do, because a per-goal forest is small by measurement
|
|
7
|
+
// (§10.5: largest 127 tasks, median 9). goal-map-lib.js earns its clustering
|
|
8
|
+
// and stand-alone-list machinery because ~half of ~70 GOALS have no edge; here
|
|
9
|
+
// every task this file is ever handed already has one (the server omits the
|
|
10
|
+
// rest, §3's own finding), so there is no isolated set to split out and no
|
|
11
|
+
// shelf-packing needed for many small independent clusters — one layered
|
|
12
|
+
// picture is the whole page.
|
|
13
|
+
//
|
|
14
|
+
// THE LAYERING IS THE SAME IDEA as the goal map's: a task sits one column
|
|
15
|
+
// right of the furthest task it depends on, deterministic (no random seed),
|
|
16
|
+
// so two disjoint chains in one goal share the picture without overlapping —
|
|
17
|
+
// they simply occupy different rows within whichever columns their own depths
|
|
18
|
+
// land on.
|
|
19
|
+
//
|
|
20
|
+
// THE TABLE IS THE PAGE, THE PICTURE IS THE ENHANCEMENT (ADR 0227 §5) — every
|
|
21
|
+
// fact here is also a tableRows() row, and the page ships the table by default.
|
|
22
|
+
//
|
|
23
|
+
// Pure — no window, no DOM, no fetch — and Node-require-safe (the
|
|
24
|
+
// goal-map-lib.js / goals-lib.js precedent) so tests/goal_forest_lib.mjs runs
|
|
25
|
+
// the real layout instead of agreeing with a copy of it.
|
|
26
|
+
(function (root, factory) {
|
|
27
|
+
var mod = factory();
|
|
28
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = mod;
|
|
29
|
+
else root.OTBGoalForestLib = mod;
|
|
30
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
var NODE_W = 168;
|
|
34
|
+
var NODE_H = 52;
|
|
35
|
+
var GAP_X = 52;
|
|
36
|
+
var GAP_Y = 14;
|
|
37
|
+
var PAD = 16;
|
|
38
|
+
var TITLE_CHARS = 24;
|
|
39
|
+
|
|
40
|
+
function num(id) { return Number(id); }
|
|
41
|
+
function byNumericId(a, b) { return num(a) - num(b); }
|
|
42
|
+
function clip(text, max) {
|
|
43
|
+
var t = String(text == null ? '' : text);
|
|
44
|
+
return t.length > max ? t.slice(0, max - 1).replace(/\s+$/, '') + '…' : t;
|
|
45
|
+
}
|
|
46
|
+
function plural(n, one, many) { return n + ' ' + (n === 1 ? one : (many || one + 's')); }
|
|
47
|
+
|
|
48
|
+
// analyse — nodes by id, who each task waits on and who needs it, and the
|
|
49
|
+
// components (for the table's grouping and the summary sentence only — the
|
|
50
|
+
// layout itself does not need them, unlike the goal map's shelf-packing).
|
|
51
|
+
function analyse(payload) {
|
|
52
|
+
var rawNodes = (payload && Array.isArray(payload.nodes)) ? payload.nodes : [];
|
|
53
|
+
var rawEdges = (payload && Array.isArray(payload.edges)) ? payload.edges : [];
|
|
54
|
+
var byId = {};
|
|
55
|
+
var order = [];
|
|
56
|
+
rawNodes.forEach(function (n) {
|
|
57
|
+
var id = String(n.task_id);
|
|
58
|
+
if (byId[id]) return;
|
|
59
|
+
byId[id] = Object.assign({}, n, { id: id });
|
|
60
|
+
order.push(id);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
var waits = {};
|
|
64
|
+
var needed = {};
|
|
65
|
+
var edges = [];
|
|
66
|
+
var seen = {};
|
|
67
|
+
rawEdges.forEach(function (e) {
|
|
68
|
+
var from = String(e.from);
|
|
69
|
+
var to = String(e.to);
|
|
70
|
+
if (from === to || !byId[from] || !byId[to] || seen[from + '>' + to]) return;
|
|
71
|
+
seen[from + '>' + to] = true;
|
|
72
|
+
edges.push({ from: from, to: to });
|
|
73
|
+
(waits[from] = waits[from] || []).push(to);
|
|
74
|
+
(needed[to] = needed[to] || []).push(from);
|
|
75
|
+
});
|
|
76
|
+
Object.keys(waits).forEach(function (k) { waits[k].sort(byNumericId); });
|
|
77
|
+
Object.keys(needed).forEach(function (k) { needed[k].sort(byNumericId); });
|
|
78
|
+
|
|
79
|
+
var parent = {};
|
|
80
|
+
function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
|
|
81
|
+
function union(a, b) { var ra = find(a); var rb = find(b); if (ra !== rb) parent[rb] = ra; }
|
|
82
|
+
order.forEach(function (id) { parent[id] = id; });
|
|
83
|
+
edges.forEach(function (e) { union(e.from, e.to); });
|
|
84
|
+
var groups = {};
|
|
85
|
+
order.forEach(function (id) { (groups[find(id)] = groups[find(id)] || []).push(id); });
|
|
86
|
+
var chains = Object.keys(groups).map(function (k) { return groups[k].sort(byNumericId); });
|
|
87
|
+
chains.sort(function (a, b) { return (b.length - a.length) || byNumericId(a[0], b[0]); });
|
|
88
|
+
var chainOf = {};
|
|
89
|
+
chains.forEach(function (ids, i) { ids.forEach(function (id) { chainOf[id] = { index: i + 1, size: ids.length }; }); });
|
|
90
|
+
|
|
91
|
+
return { order: order, byId: byId, edges: edges, waits: waits, needed: needed, chains: chains, chainOf: chainOf };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A task sits one column right of the furthest task it depends on. A cycle
|
|
95
|
+
// (illegal by DB constraint for task deps, but this file trusts nothing it
|
|
96
|
+
// did not check itself) cannot advance past it, matching goal-map-lib.js.
|
|
97
|
+
function layerOf(ids, waits) {
|
|
98
|
+
var depth = {};
|
|
99
|
+
var state = {};
|
|
100
|
+
function visit(id) {
|
|
101
|
+
if (state[id] === 2) return depth[id];
|
|
102
|
+
if (state[id] === 1) return -1;
|
|
103
|
+
state[id] = 1;
|
|
104
|
+
var d = 0;
|
|
105
|
+
(waits[id] || []).forEach(function (dep) {
|
|
106
|
+
var dd = visit(dep);
|
|
107
|
+
if (dd >= 0) d = Math.max(d, dd + 1);
|
|
108
|
+
});
|
|
109
|
+
state[id] = 2;
|
|
110
|
+
depth[id] = d;
|
|
111
|
+
return d;
|
|
112
|
+
}
|
|
113
|
+
ids.forEach(visit);
|
|
114
|
+
return depth;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function orderColumns(columns, neighbours) {
|
|
118
|
+
var pos = {};
|
|
119
|
+
columns.forEach(function (col) { col.forEach(function (id, i) { pos[id] = i; }); });
|
|
120
|
+
var layerOfId = {};
|
|
121
|
+
columns.forEach(function (col, li) { col.forEach(function (id) { layerOfId[id] = li; }); });
|
|
122
|
+
for (var sweep = 0; sweep < 4; sweep++) {
|
|
123
|
+
var down = sweep % 2 === 0;
|
|
124
|
+
var from = down ? 1 : columns.length - 2;
|
|
125
|
+
var to = down ? columns.length : -1;
|
|
126
|
+
for (var li = from; li !== to; li += down ? 1 : -1) {
|
|
127
|
+
var col = columns[li];
|
|
128
|
+
var key = {};
|
|
129
|
+
col.forEach(function (id) {
|
|
130
|
+
var ns = (neighbours[id] || []).filter(function (n) { return down ? layerOfId[n] < li : layerOfId[n] > li; });
|
|
131
|
+
key[id] = ns.length ? ns.reduce(function (s, n) { return s + pos[n]; }, 0) / ns.length : pos[id];
|
|
132
|
+
});
|
|
133
|
+
col.sort(function (a, b) { return (key[a] - key[b]) || byNumericId(a, b); });
|
|
134
|
+
col.forEach(function (id, i) { pos[id] = i; });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function edgePath(dep, dependent) {
|
|
140
|
+
var x1 = dep.x + NODE_W;
|
|
141
|
+
var y1 = dep.y + NODE_H / 2;
|
|
142
|
+
var x2 = dependent.x;
|
|
143
|
+
var y2 = dependent.y + NODE_H / 2;
|
|
144
|
+
var dx = x2 > x1 ? (x2 - x1) / 2 : 44;
|
|
145
|
+
return 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// layout — ONE layered picture over every node `analyse` returned (they all
|
|
149
|
+
// carry an edge by construction — the server never hands this file a task
|
|
150
|
+
// with none). Returns the svg's own size; the page scrolls it inside its own
|
|
151
|
+
// box and NEVER lets the page body scroll sideways (ADR 0227 §5.2).
|
|
152
|
+
function layout(a) {
|
|
153
|
+
var depth = layerOf(a.order, a.waits);
|
|
154
|
+
var neighbours = {};
|
|
155
|
+
a.order.forEach(function (id) {
|
|
156
|
+
neighbours[id] = (a.waits[id] || []).concat(a.needed[id] || []);
|
|
157
|
+
});
|
|
158
|
+
var columns = [];
|
|
159
|
+
a.order.forEach(function (id) { (columns[depth[id]] = columns[depth[id]] || []).push(id); });
|
|
160
|
+
columns = columns.map(function (c) { return (c || []).sort(byNumericId); });
|
|
161
|
+
orderColumns(columns, neighbours);
|
|
162
|
+
|
|
163
|
+
var rows = columns.reduce(function (m, c) { return Math.max(m, c.length); }, 1);
|
|
164
|
+
var width = PAD * 2 + columns.length * NODE_W + Math.max(0, columns.length - 1) * GAP_X;
|
|
165
|
+
var height = PAD * 2 + rows * NODE_H + Math.max(0, rows - 1) * GAP_Y;
|
|
166
|
+
var abs = {};
|
|
167
|
+
var nodes = [];
|
|
168
|
+
columns.forEach(function (col, li) {
|
|
169
|
+
var colH = col.length * NODE_H + (col.length - 1) * GAP_Y;
|
|
170
|
+
var top = PAD + Math.round((height - PAD * 2 - colH) / 2);
|
|
171
|
+
col.forEach(function (id, i) {
|
|
172
|
+
var p = { x: PAD + li * (NODE_W + GAP_X), y: top + i * (NODE_H + GAP_Y) };
|
|
173
|
+
abs[id] = p;
|
|
174
|
+
nodes.push({ id: id, x: p.x, y: p.y, w: NODE_W, h: NODE_H });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
var edges = a.edges.map(function (e) {
|
|
179
|
+
return { from: e.from, to: e.to, d: edgePath(abs[e.to], abs[e.from]) };
|
|
180
|
+
});
|
|
181
|
+
return { width: width, height: height, nodes: nodes, edges: edges, nodeW: NODE_W, nodeH: NODE_H };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
var PRIORITY_LABEL = { 1: 'P1', 2: 'P2', 3: 'P3', 4: 'P4', 5: 'P5' };
|
|
185
|
+
function priorityLabel(p) { return p != null && PRIORITY_LABEL[p] ? PRIORITY_LABEL[p] : '—'; }
|
|
186
|
+
|
|
187
|
+
// tableRows — the primary surface. One row per task, both directions of its
|
|
188
|
+
// dependency by name.
|
|
189
|
+
function tableRows(a) {
|
|
190
|
+
function named(ids) {
|
|
191
|
+
return (ids || []).map(function (id) { return { id: id, title: a.byId[id] ? a.byId[id].title : ('task ' + id) }; });
|
|
192
|
+
}
|
|
193
|
+
var rows = a.order.map(function (id) {
|
|
194
|
+
var n = a.byId[id];
|
|
195
|
+
var ch = a.chainOf[id];
|
|
196
|
+
return {
|
|
197
|
+
id: id,
|
|
198
|
+
title: n.title || ('Task ' + id),
|
|
199
|
+
status: n.status || '',
|
|
200
|
+
priority: priorityLabel(n.priority),
|
|
201
|
+
waits: named(a.waits[id]),
|
|
202
|
+
needed: named(a.needed[id]),
|
|
203
|
+
chain: ch ? ch.index : 0,
|
|
204
|
+
chainSize: ch ? ch.size : 1,
|
|
205
|
+
chainLabel: ch && ch.size > 1 ? 'Chain ' + ch.index + ' · ' + plural(ch.size, 'task') : 'Its own pair',
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
rows.sort(function (x, y) { return (x.chain - y.chain) || byNumericId(x.id, y.id); });
|
|
209
|
+
return rows;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// describe — states the shape out loud, in numbers only. No `|| a.chains.length`
|
|
213
|
+
// fallback: every id in a.order arrived with at least one surviving edge (the
|
|
214
|
+
// server's own contract, restated at the top of this file), so a chain of size
|
|
215
|
+
// 1 cannot occur here — a fallback that could say so would be lying about data
|
|
216
|
+
// this file is never actually handed.
|
|
217
|
+
function describe(a) {
|
|
218
|
+
var n = a.order.length;
|
|
219
|
+
if (!n) return 'No task in this goal depends on another task in this goal.';
|
|
220
|
+
var multi = a.chains.filter(function (c) { return c.length > 1; }).length;
|
|
221
|
+
return plural(n, 'task') + ' in this goal ' + (n === 1 ? 'has' : 'have') + ' a dependency on another task in this goal, '
|
|
222
|
+
+ 'in ' + plural(multi, 'chain') + '.';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
analyse: analyse, layout: layout, tableRows: tableRows, describe: describe,
|
|
227
|
+
clip: clip, plural: plural, priorityLabel: priorityLabel, TITLE_CHARS: TITLE_CHARS,
|
|
228
|
+
};
|
|
229
|
+
}));
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// goal-forest-render.js — draws the per-goal task dependency forest on the
|
|
2
|
+
// Goals page (task 1003480, goal 1000089, ADR 0227 §6 item 4 and its §10.5
|
|
3
|
+
// amendment). Carved out of goals-render.js on arrival: that file is at the
|
|
4
|
+
// 1,500-line fitness ratchet with "neither half has much room" (its own
|
|
5
|
+
// header), so a new self-contained feature is its own file — the same
|
|
6
|
+
// separation goal-map.js already keeps from goals-render.js, one file down.
|
|
7
|
+
//
|
|
8
|
+
// NOT a band column, unlike Completion/Members/Conflicts on the same detail
|
|
9
|
+
// page: a node-link picture needs real width, and this is bounded small BY
|
|
10
|
+
// DESIGN (ADR 0227 §3's own finding — the tasks a forest omits are exactly
|
|
11
|
+
// the ones with nothing to connect them to), so there is no cap-and-"view
|
|
12
|
+
// all" split here. What renders is always the whole forest returned by GET
|
|
13
|
+
// /goals/:id/dependency-forest. The model + layout are goal-forest-lib.js
|
|
14
|
+
// (pure); this file only draws what it returns, with createElementNS +
|
|
15
|
+
// textContent — the atlas / goal-map.js idiom — so a task title never
|
|
16
|
+
// becomes markup.
|
|
17
|
+
//
|
|
18
|
+
// A factory, the goals-render.js precedent, because `state` keeps changing
|
|
19
|
+
// after load: `createGoalForestRender(ctx)` takes the SAME `{ API, state,
|
|
20
|
+
// sameId, getJSON }` slice of ctx goals-page.js already builds, and returns
|
|
21
|
+
// `{ loadDependencyForest, renderDependencyForest }` for goals-render.js to
|
|
22
|
+
// call from renderDetail() and for tests/goal_forest_page.mjs to call directly.
|
|
23
|
+
(function () {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
27
|
+
|
|
28
|
+
window.OTBGoalForestRender = function createGoalForestRender(ctx) {
|
|
29
|
+
const { API, state, sameId, getJSON } = ctx;
|
|
30
|
+
const { escapeHtml, $ } = window.OTB;
|
|
31
|
+
const lib = window.OTBGoalForestLib;
|
|
32
|
+
|
|
33
|
+
function setCount(sel, n) {
|
|
34
|
+
const el = $(sel);
|
|
35
|
+
if (!el) return;
|
|
36
|
+
el.hidden = false;
|
|
37
|
+
el.textContent = String(n);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function svgEl(tag, attrs, parent) {
|
|
41
|
+
const el = document.createElementNS(SVGNS, tag);
|
|
42
|
+
Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
|
|
43
|
+
if (parent) parent.appendChild(el);
|
|
44
|
+
return el;
|
|
45
|
+
}
|
|
46
|
+
function text(parent, cls, x, y, content) {
|
|
47
|
+
const t = svgEl('text', { class: cls, x, y }, parent);
|
|
48
|
+
t.textContent = content;
|
|
49
|
+
return t;
|
|
50
|
+
}
|
|
51
|
+
function drawForest(a, lay) {
|
|
52
|
+
const svg = svgEl('svg', {
|
|
53
|
+
class: 'gf-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
|
|
54
|
+
role: 'img', 'aria-labelledby': 'goal-deps-h', focusable: 'false',
|
|
55
|
+
});
|
|
56
|
+
const defs = svgEl('defs', {}, svg);
|
|
57
|
+
const marker = svgEl('marker', { id: 'gf-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
|
|
58
|
+
svgEl('path', { class: 'gf-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
|
|
59
|
+
|
|
60
|
+
const edges = svgEl('g', { class: 'gf-edges' }, svg);
|
|
61
|
+
lay.edges.forEach((e) => {
|
|
62
|
+
const path = svgEl('path', { class: 'gf-edge', d: e.d, 'marker-end': 'url(#gf-arrow)' }, edges);
|
|
63
|
+
const dependent = a.byId[e.from];
|
|
64
|
+
const dependency = a.byId[e.to];
|
|
65
|
+
svgEl('title', {}, path).textContent = `#${e.from} ${dependent ? dependent.title : ''} waits on #${e.to} ${dependency ? dependency.title : ''}`;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const nodes = svgEl('g', { class: 'gf-nodes' }, svg);
|
|
69
|
+
lay.nodes.forEach((p) => {
|
|
70
|
+
const n = a.byId[p.id];
|
|
71
|
+
const g = svgEl('g', { class: 'gf-node', transform: `translate(${p.x} ${p.y})` }, nodes);
|
|
72
|
+
svgEl('title', {}, g).textContent = `#${p.id} ${n.title || ''} — ${n.status || ''} — waits on ${n.depends_on || 0}, needed by ${n.depended_on_by || 0}`;
|
|
73
|
+
svgEl('rect', { class: 'gf-card', width: p.w, height: p.h, rx: 8 }, g);
|
|
74
|
+
text(g, 'gf-name', 16, 21, `#${p.id} ${lib.clip(n.title || '', lib.TITLE_CHARS)}`);
|
|
75
|
+
text(g, 'gf-sub', 16, 37, `${lib.priorityLabel(n.priority)} · ${n.status || ''}`);
|
|
76
|
+
});
|
|
77
|
+
return svg;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderDependencyForest() {
|
|
81
|
+
const lede = $('#goal-deps-lede');
|
|
82
|
+
const body = $('#goal-deps-body');
|
|
83
|
+
if (!lede || !body) return;
|
|
84
|
+
if (state.forestError) {
|
|
85
|
+
lede.textContent = 'Couldn’t load this goal’s task dependencies right now.';
|
|
86
|
+
body.innerHTML = '';
|
|
87
|
+
const countPill = $('#goal-deps-count');
|
|
88
|
+
if (countPill) countPill.hidden = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!state.forest) {
|
|
92
|
+
lede.textContent = 'Reading…';
|
|
93
|
+
body.innerHTML = '';
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const a = lib.analyse(state.forest);
|
|
97
|
+
const countPill = $('#goal-deps-count');
|
|
98
|
+
if (!a.order.length) {
|
|
99
|
+
if (countPill) countPill.hidden = true;
|
|
100
|
+
lede.textContent = 'No task in this goal depends on another task in this goal.';
|
|
101
|
+
body.innerHTML = '';
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setCount('#goal-deps-count', a.order.length);
|
|
105
|
+
lede.innerHTML = `${escapeHtml(lib.describe(a))} An arrow runs from the task that has to ship first to the task that waits on it. `
|
|
106
|
+
+ 'Every task here, and every dependency, is also in the table below.';
|
|
107
|
+
const rows = lib.tableRows(a);
|
|
108
|
+
const taskHref = (id) => `#/task/${encodeURIComponent(id)}`;
|
|
109
|
+
const depList = (list) => (list.length
|
|
110
|
+
? `<ul class="gf-deps">${list.map((x) => `<li><a href="${taskHref(x.id)}">#${escapeHtml(x.id)} ${escapeHtml(x.title)}</a></li>`).join('')}</ul>`
|
|
111
|
+
: '<span class="gf-none">—</span>');
|
|
112
|
+
const rowHtml = (r) => `<tr>
|
|
113
|
+
<td><a class="lb-name" href="${taskHref(r.id)}">#${escapeHtml(r.id)} ${escapeHtml(r.title)}</a></td>
|
|
114
|
+
<td>${escapeHtml(r.status)}</td>
|
|
115
|
+
<td>${escapeHtml(r.priority)}</td>
|
|
116
|
+
<td>${depList(r.waits)}</td>
|
|
117
|
+
<td>${depList(r.needed)}</td>
|
|
118
|
+
<td>${escapeHtml(r.chainLabel)}</td>
|
|
119
|
+
</tr>`;
|
|
120
|
+
body.innerHTML = `
|
|
121
|
+
<figure class="gf-figure">
|
|
122
|
+
<div class="gf-scroll" tabindex="0" role="region" aria-label="Task dependency forest — scrolls sideways inside this box"></div>
|
|
123
|
+
</figure>
|
|
124
|
+
<table class="ledger gf-table">
|
|
125
|
+
<thead><tr><th>Task</th><th>Status</th><th>Priority</th><th>Waits on</th><th>Needed by</th><th>Chain</th></tr></thead>
|
|
126
|
+
<tbody>${rows.map(rowHtml).join('')}</tbody>
|
|
127
|
+
</table>`;
|
|
128
|
+
body.querySelector('.gf-scroll').appendChild(drawForest(a, lib.layout(a)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadDependencyForest(goalId) {
|
|
132
|
+
state.forest = null;
|
|
133
|
+
state.forestError = false;
|
|
134
|
+
renderDependencyForest();
|
|
135
|
+
try {
|
|
136
|
+
const data = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}/dependency-forest`);
|
|
137
|
+
if (!sameId(state.current, goalId)) return;
|
|
138
|
+
state.forest = data;
|
|
139
|
+
renderDependencyForest();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (!sameId(state.current, goalId)) return;
|
|
142
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
143
|
+
console.error('[goals] dependency forest load failed', err);
|
|
144
|
+
state.forestError = true;
|
|
145
|
+
renderDependencyForest();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { loadDependencyForest, renderDependencyForest };
|
|
150
|
+
};
|
|
151
|
+
})();
|
|
@@ -86,6 +86,12 @@
|
|
|
86
86
|
// active" sort costs no extra request. A goal with no tasks is absent here
|
|
87
87
|
// and falls back to its own goals.updated_at (see goalActivityMs).
|
|
88
88
|
activity: new Map(),
|
|
89
|
+
// task 1003480 — the open goal's per-task dependency forest (GET
|
|
90
|
+
// /goals/:id/dependency-forest), the ADR 0227 §6 item 4 drill-down. null
|
|
91
|
+
// while loading; forestError distinguishes "still loading" from "the read
|
|
92
|
+
// failed" so the section can say which rather than looking permanently empty.
|
|
93
|
+
forest: null,
|
|
94
|
+
forestError: false,
|
|
89
95
|
};
|
|
90
96
|
|
|
91
97
|
async function getJSON(path) {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
const { DONE_STATUSES, CUT_STATUSES, isTodoStatus, ring, memberName,
|
|
30
30
|
readinessOf, EMPTY_ROLLUP, goalRollLine, matchesTodoSearch,
|
|
31
31
|
msOf, taskActivityMs, subtitleAttr, subtitleLine, applyGoalProse } = window.OTBGoalsLib;
|
|
32
|
+
const forestRender = window.OTBGoalForestRender(ctx);
|
|
32
33
|
|
|
33
34
|
const CONFLICT_CAP = 3;
|
|
34
35
|
// task 1828 — the band's Completion / Members / Conflicts columns each cap
|
|
@@ -622,6 +623,12 @@
|
|
|
622
623
|
const back = $('#goal-conflicts-back');
|
|
623
624
|
if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
|
|
624
625
|
}
|
|
626
|
+
// task 1003480 — the per-goal task dependency forest is drawn by its own
|
|
627
|
+
// module now (goal-forest-render.js, carved out to keep this file under the
|
|
628
|
+
// ratchet); `forestRender` below is that module's factory, given the same
|
|
629
|
+
// ctx slice it needs.
|
|
630
|
+
const { loadDependencyForest, renderDependencyForest } = forestRender;
|
|
631
|
+
|
|
625
632
|
const isPending = (c) => !c.satisfied && !!c.pending_review;
|
|
626
633
|
// criterionlessTaskCount: task 1003157's DONE WHEN item 2 — the count of this
|
|
627
634
|
// goal's tasks linked to no criterion, rendered beside the criteria rollup so
|
|
@@ -901,6 +908,7 @@
|
|
|
901
908
|
renderMembers();
|
|
902
909
|
|
|
903
910
|
loadGoalTasks(g.id);
|
|
911
|
+
loadDependencyForest(g.id);
|
|
904
912
|
// task 1953 — my own pending request (any viewer) + the goal's pending queue
|
|
905
913
|
// (managers only); async, re-renders Actions + the pending panel on arrival.
|
|
906
914
|
loadGoalMembership(g.id, canManage);
|
|
@@ -1461,6 +1469,6 @@
|
|
|
1461
1469
|
return d;
|
|
1462
1470
|
}
|
|
1463
1471
|
|
|
1464
|
-
return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
|
|
1472
|
+
return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
|
|
1465
1473
|
};
|
|
1466
1474
|
})();
|
|
@@ -483,3 +483,29 @@
|
|
|
483
483
|
.arch-panel__foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
484
484
|
.arch-panel__foot input { flex: 1; min-width: 200px; }
|
|
485
485
|
.arch-panel__err { margin-top: 8px; font-size: 12.5px; color: var(--danger); }
|
|
486
|
+
|
|
487
|
+
/* task 1003480 — the per-goal task dependency forest (ADR 0227 §6 item 4). The
|
|
488
|
+
TABLE is the section (below); this is only the node-link figure, drawn with
|
|
489
|
+
createElementNS (goal-map.css's sibling — a plain card + edge, no category
|
|
490
|
+
colour channel here, since a task carries a priority, not a category).
|
|
491
|
+
The figure scrolls INSIDE its own box and names BOTH overflow axes (task
|
|
492
|
+
1004130) so the page itself never scrolls sideways. */
|
|
493
|
+
.gf-figure { margin: 0 0 14px; }
|
|
494
|
+
.gf-scroll {
|
|
495
|
+
overflow-x: auto;
|
|
496
|
+
overflow-y: hidden;
|
|
497
|
+
max-width: 100%;
|
|
498
|
+
border: var(--line) solid var(--rule-soft);
|
|
499
|
+
border-radius: var(--radius);
|
|
500
|
+
background: color-mix(in srgb, var(--ink) 3%, var(--bg-card));
|
|
501
|
+
}
|
|
502
|
+
.gf-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
503
|
+
.gf-svg { display: block; }
|
|
504
|
+
.gf-edge { fill: none; stroke: var(--ink-faint); stroke-width: 1.5; opacity: 0.85; }
|
|
505
|
+
.gf-arrowhead { fill: var(--ink-faint); }
|
|
506
|
+
.gf-card { fill: var(--bg-card); stroke: var(--rule); stroke-width: 1; }
|
|
507
|
+
.gf-name { font: 600 12.5px var(--font-body); fill: var(--ink); }
|
|
508
|
+
.gf-sub { font: 500 11px var(--font-body); fill: var(--ink-faint); }
|
|
509
|
+
.gf-table .gf-deps { margin: 0; padding: 0; list-style: none; min-width: 12rem; }
|
|
510
|
+
.gf-table .gf-deps li { font-size: 12.5px; line-height: 1.4; }
|
|
511
|
+
.gf-none { color: var(--ink-faint); }
|
|
@@ -130,6 +130,18 @@
|
|
|
130
130
|
</div>
|
|
131
131
|
</div>
|
|
132
132
|
|
|
133
|
+
<!-- task 1003480 (ADR 0227 §6 item 4 + §10.5): the per-goal task
|
|
134
|
+
dependency forest. Its own full-width section, not a band
|
|
135
|
+
column — a node-link picture needs real width, and (unlike
|
|
136
|
+
Completion/Members/Conflicts) this is bounded small BY DESIGN
|
|
137
|
+
(ADR 0227 §3's own finding), so there is no "view all" split:
|
|
138
|
+
what renders here is the whole forest, every time. -->
|
|
139
|
+
<div class="scroll" style="margin-top:20px" id="goal-deps-scroll">
|
|
140
|
+
<h2 class="scroll__h" id="goal-deps-h">Task dependencies<span class="count-pill" id="goal-deps-count" hidden></span></h2>
|
|
141
|
+
<p class="scroll__lede" id="goal-deps-lede">Reading…</p>
|
|
142
|
+
<div id="goal-deps-body"></div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
133
145
|
<div class="scroll" style="margin-top:20px">
|
|
134
146
|
<div class="goal-work__head">
|
|
135
147
|
<h2 class="scroll__h">Tasks</h2>
|
|
@@ -234,6 +246,13 @@
|
|
|
234
246
|
<script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
|
|
235
247
|
<script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
|
|
236
248
|
<script src="/builders/goals-lib.js?v=2026-08-21-goalslib1"></script>
|
|
249
|
+
<!-- task 1003480: the per-goal dependency forest's pure model + layout —
|
|
250
|
+
goal-map-lib.js's sibling, loaded before the renderer that draws it. -->
|
|
251
|
+
<script src="/builders/goal-forest-lib.js"></script>
|
|
252
|
+
<!-- task 1003480: the forest's own drawing module — goal-map.js's sibling,
|
|
253
|
+
carved out of goals-render.js to keep that file under the size ratchet.
|
|
254
|
+
Loaded before goals-render.js, which calls window.OTBGoalForestRender(ctx). -->
|
|
255
|
+
<script src="/builders/goal-forest-render.js"></script>
|
|
237
256
|
<!-- task 1003206: the page RENDERERS, the second half of the carve goals-lib.js
|
|
238
257
|
began — goals-lib took the pure helpers, this takes the view painters.
|
|
239
258
|
Loaded BEFORE goals-page.js, which calls window.OTBGoalsRender(ctx). -->
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-forest.js — the per-goal dependency forest: the DRILL-
|
|
4
|
+
// DOWN behind GET /goals/:id/dependency-forest (task 1003480, goal 1000089,
|
|
5
|
+
// ADR 0227 §6 item 4 and its §10.5 amendment).
|
|
6
|
+
//
|
|
7
|
+
// NOT A ZOOM OF THE GOAL MAP (ADR 0227 §1). goal-graph.js draws goal-to-goal
|
|
8
|
+
// edges, aggregated; this draws the TASK-to-task edges INSIDE one goal, at full
|
|
9
|
+
// resolution — queried only when that one goal is opened, never as a slice of a
|
|
10
|
+
// global read. A dependency whose two tasks sit in DIFFERENT goals belongs to
|
|
11
|
+
// the goal map instead (goal-graph.js's own header says exactly the reverse of
|
|
12
|
+
// this filter), so the two views partition the corpus rather than overlapping.
|
|
13
|
+
//
|
|
14
|
+
// SCOPED, NOT SAMPLED. ADR 0227 §3 measured this as covering 3–40% of the
|
|
15
|
+
// corpus and never 100%, and §10.5 confirmed it against the real database
|
|
16
|
+
// (23.61% of tasks carry an edge) — that is the finding, not a gap. The tasks
|
|
17
|
+
// left out are exactly the ones with nothing to connect them to, so this file
|
|
18
|
+
// returns ONLY the tasks that have an edge within this goal; a goal whose tasks
|
|
19
|
+
// carry none returns an empty forest and the page says so plainly.
|
|
20
|
+
//
|
|
21
|
+
// A private goal's own gate has already run by the time this is asked for — a
|
|
22
|
+
// forest is requested from an already-open goal detail page, never cold — so
|
|
23
|
+
// this file re-derives no visibility of its own.
|
|
24
|
+
|
|
25
|
+
const api = require('../../src/module-api');
|
|
26
|
+
const { pool } = api;
|
|
27
|
+
|
|
28
|
+
// goalForestRows — the two raw reads. `edges`: one row per task-level dependency
|
|
29
|
+
// whose BOTH endpoints sit in `goalId` (the ADR 0227 §10.5 measurement's own
|
|
30
|
+
// filter — see scripts/gds/spike-task-map.js's per-goal grouping). `tasks`: the
|
|
31
|
+
// facts for exactly the task ids that appear in at least one such edge — never
|
|
32
|
+
// the goal's whole task list, which the page already holds from GET
|
|
33
|
+
// /tasks?goal_id=.
|
|
34
|
+
async function goalForestRows(goalId, deps = {}) {
|
|
35
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
36
|
+
const edges = await activePool.query(
|
|
37
|
+
`SELECT ft.id::text AS from_task_id,
|
|
38
|
+
tt.id::text AS to_task_id
|
|
39
|
+
FROM dependencies d
|
|
40
|
+
JOIN tasks ft ON d.from_kind = 'task' AND ft.id = d.from_id
|
|
41
|
+
JOIN tasks tt ON d.to_kind = 'task' AND tt.id = d.to_id
|
|
42
|
+
WHERE ft.goal_id = $1 AND tt.goal_id = $1
|
|
43
|
+
ORDER BY ft.id, tt.id`,
|
|
44
|
+
[goalId]
|
|
45
|
+
);
|
|
46
|
+
const ids = new Set();
|
|
47
|
+
for (const r of edges.rows) { ids.add(r.from_task_id); ids.add(r.to_task_id); }
|
|
48
|
+
if (!ids.size) return { edges: [], tasks: [] };
|
|
49
|
+
const tasks = await activePool.query(
|
|
50
|
+
`SELECT id::text, title, status, priority
|
|
51
|
+
FROM tasks
|
|
52
|
+
WHERE id = ANY($1::bigint[])
|
|
53
|
+
ORDER BY id`,
|
|
54
|
+
[[...ids].map(Number)]
|
|
55
|
+
);
|
|
56
|
+
return { edges: edges.rows, tasks: tasks.rows };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// shapeForest — the wire shape. Pure, so a test pins it with no database.
|
|
60
|
+
//
|
|
61
|
+
// nodes: [{ task_id, title, status, priority, depends_on, depended_on_by }]
|
|
62
|
+
// edges: [{ from, to }] — `from` waits on `to`
|
|
63
|
+
// counts: { tasks, edges }
|
|
64
|
+
//
|
|
65
|
+
// An edge whose endpoint task is missing from `tasks` (a stale row racing a
|
|
66
|
+
// delete) is dropped, not drawn to nowhere — the same rule goal-graph.js's
|
|
67
|
+
// shapeGraph applies for the same reason.
|
|
68
|
+
function shapeForest({ edges = [], tasks = [] } = {}) {
|
|
69
|
+
const known = new Set(tasks.map((t) => String(t.id)));
|
|
70
|
+
const kept = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const e of edges) {
|
|
73
|
+
const from = String(e.from_task_id);
|
|
74
|
+
const to = String(e.to_task_id);
|
|
75
|
+
if (from === to || !known.has(from) || !known.has(to)) continue;
|
|
76
|
+
const key = `${from}>${to}`;
|
|
77
|
+
if (seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
kept.push({ from, to });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const waitsOn = new Map();
|
|
83
|
+
const blocks = new Map();
|
|
84
|
+
for (const e of kept) {
|
|
85
|
+
if (!waitsOn.has(e.from)) waitsOn.set(e.from, new Set());
|
|
86
|
+
waitsOn.get(e.from).add(e.to);
|
|
87
|
+
if (!blocks.has(e.to)) blocks.set(e.to, new Set());
|
|
88
|
+
blocks.get(e.to).add(e.from);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Only tasks touched by a KEPT edge become nodes — a task whose only edge was
|
|
92
|
+
// dropped above (stale endpoint) must not appear as a lone, edge-less node.
|
|
93
|
+
const touched = new Set();
|
|
94
|
+
kept.forEach((e) => { touched.add(e.from); touched.add(e.to); });
|
|
95
|
+
const nodes = tasks.filter((t) => touched.has(String(t.id))).map((t) => {
|
|
96
|
+
const id = String(t.id);
|
|
97
|
+
return {
|
|
98
|
+
task_id: id,
|
|
99
|
+
title: t.title,
|
|
100
|
+
status: t.status,
|
|
101
|
+
priority: t.priority != null ? Number(t.priority) : null,
|
|
102
|
+
depends_on: (waitsOn.get(id) || new Set()).size,
|
|
103
|
+
depended_on_by: (blocks.get(id) || new Set()).size,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return { nodes, edges: kept, counts: { tasks: nodes.length, edges: kept.length } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// goalForest — read + shape.
|
|
111
|
+
async function goalForest(goalId, deps = {}) {
|
|
112
|
+
const rows = await goalForestRows(goalId, deps);
|
|
113
|
+
return shapeForest(rows);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// goalForestHandler — GET /goals/:id/dependency-forest. A factory (the
|
|
117
|
+
// goal-category.js / goal-graph.js precedent) so a test drives it with a stub
|
|
118
|
+
// read and no database.
|
|
119
|
+
function goalForestHandler({ getGoal, read = goalForest, parseId, log = console } = {}) {
|
|
120
|
+
return async function getGoalForest(req, res) {
|
|
121
|
+
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
122
|
+
if (id === null) return;
|
|
123
|
+
try {
|
|
124
|
+
const goal = await getGoal(id);
|
|
125
|
+
if (!goal) return res.fail('goal_not_found', 404);
|
|
126
|
+
const forest = await read(id);
|
|
127
|
+
return res.json({ ok: true, goal_id: Number(id), ...forest });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
log.error('[gds] GET /goals/:id/dependency-forest', err);
|
|
130
|
+
return res.fail('forest_failed', { status: 500, message: 'internal error' });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { goalForest, goalForestRows, shapeForest, goalForestHandler };
|