create-pmos 0.65.0 → 0.65.2

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.
@@ -0,0 +1,272 @@
1
+ /* pm/doc-graph.js — the library of every repository file and the links the files write, drawn two ways (initiative
2
+ * docs-library-graph, PRD R-G1 to R-G5 and R-N1 to R-N5).
3
+ *
4
+ * getLibrary(ws) GET /api/docs/library once per workspace (kept for a short while), indexed
5
+ * fileGraph(lib, path, { onOpen }) one file in the middle: the files that link to it on the left, the files it
6
+ * links to on the right, at most 7 a side; a press on a file centres the graph on
7
+ * it, Back returns; List all N links lists every link by kind
8
+ * initiativeGraph(lib, paths, …) an initiative's files as filled dots and the outside files they link to as hollow
9
+ * dots (at most 14, the most-connected first); a press marks a dot's lines
10
+ *
11
+ * Every line is a link a file writes (lib/kg.js on the server): a markdown link, a path in the header, the initiative, the
12
+ * anchor, a decision number. Nothing is inferred. The layouts are computed from the files and links alone, so the same
13
+ * input draws the same picture. The pictures are an SVG of lines behind real buttons, placed with --rp-x and --rp-y. On a
14
+ * phone neither picture is drawn: the file graph shows its list, and the initiative sheet shows its own list.
15
+ */
16
+
17
+ import { el } from './dom.js';
18
+ import { button, narrowViewport } from './primitives.js';
19
+ import { pmGet } from './http.js';
20
+
21
+ const NS = 'http://www.w3.org/2000/svg';
22
+ const MAX_SIDE = 7;
23
+ const MAX_OUTSIDE = 14;
24
+ const LIST_CAP = 12;
25
+ const KEEP_MS = 30000;
26
+
27
+ export const GROUP_OUT = [['initiative', 'Its initiative'], ['header', 'Named in its header'], ['link', 'Links to'], ['anchor', 'Anchored to'], ['cites', 'Decisions it cites']];
28
+ export const GROUP_IN = [['initiative', 'Its initiative'], ['header', 'Named by'], ['link', 'Linked from'], ['anchor', 'Anchors'], ['cites', 'Cited by']];
29
+ const ROLE = { spec: 'PRD', ini: 'Initiative record', exp: 'Explainer', proto: 'Prototype' };
30
+
31
+ /* ── the library ─────────────────────────────────────────────────────────────────────────────────────────────────── */
32
+
33
+ const libs = new Map(); // ws -> { at, promise }
34
+
35
+ /** The workspace's library, indexed, or null when it could not be read. One request per workspace while it is fresh. */
36
+ export function getLibrary(ws, { fresh = false } = {}) {
37
+ const hit = libs.get(ws);
38
+ if (hit && !fresh && Date.now() - hit.at < KEEP_MS) return hit.promise;
39
+ const promise = pmGet('/api/docs/library?ws=' + encodeURIComponent(ws)).then((r) => (r && r.ok ? indexed(r) : null));
40
+ libs.set(ws, { at: Date.now(), promise });
41
+ promise.then((v) => { if (!v) libs.delete(ws); });
42
+ return promise;
43
+ }
44
+
45
+ /** The response with lookups: byPath, out and inn (per file: [[neighbour, kind]]), and each file's initiative. */
46
+ export function indexed(r) {
47
+ const byPath = new Map(r.files.map((f, i) => [f.path, i]));
48
+ const out = r.files.map(() => []);
49
+ const inn = r.files.map(() => []);
50
+ for (const [a, b, k] of r.edges) { out[a].push([b, r.kinds[k]]); inn[b].push([a, r.kinds[k]]); }
51
+ const ini = (f) => (f.initiative && r.initiatives[f.initiative]) ? { id: f.initiative, ...r.initiatives[f.initiative] } : null;
52
+ return { ...r, byPath, out, inn, ini };
53
+ }
54
+
55
+ const KIND_RANK = { initiative: 0, header: 1, link: 2, anchor: 3, cites: 4 };
56
+ /** One entry per neighbour (its strongest kind), cites last, then the same initiative first, then newest first. */
57
+ function neighbours(lib, i) {
58
+ const me = lib.files[i].initiative;
59
+ const uniq = (xs) => {
60
+ const m = new Map();
61
+ for (const x of xs) if (x[0] !== i && (!m.has(x[0]) || KIND_RANK[x[1]] < KIND_RANK[m.get(x[0])[1]])) m.set(x[0], x);
62
+ return [...m.values()].sort((a, b) => {
63
+ const A = lib.files[a[0]]; const B = lib.files[b[0]];
64
+ return (a[1] === 'cites') - (b[1] === 'cites')
65
+ || (B.initiative && B.initiative === me) - (A.initiative && A.initiative === me)
66
+ || String(B.date || '').localeCompare(String(A.date || ''))
67
+ || A.path.localeCompare(B.path);
68
+ });
69
+ };
70
+ return { out: uniq(lib.out[i]), inn: uniq(lib.inn[i]) };
71
+ }
72
+
73
+ const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '…' : t);
74
+ const svg = (tag, attrs = {}) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, String(v)); return n; };
75
+ const place = (node, vars) => { for (const [k, v] of Object.entries(vars)) node.style.setProperty('--rp-' + k, String(Math.round(v * 100) / 100)); return node; };
76
+
77
+ /* ── one file's links ────────────────────────────────────────────────────────────────────────────────────────────── */
78
+
79
+ /**
80
+ * The links of one file. `onOpen(path, title)` opens a file in the reader. Returns an element that redraws itself: a press
81
+ * on a neighbour centres the graph on it, Back returns. On a phone the list is shown in place of the picture.
82
+ */
83
+ export function fileGraph(lib, path, { onOpen } = {}) {
84
+ const start = lib.byPath.get(path);
85
+ const wrap = el('div', { class: 'rp-graph', 'data-graph': path });
86
+ if (start === undefined) { wrap.appendChild(el('p', { class: 'rp-graph__hint' }, 'This file is not in the library yet. It shows after its first commit.')); return wrap; }
87
+ const stack = [start];
88
+ const state = { list: false, more: new Set() };
89
+ const go = (j) => { stack.push(j); state.list = false; state.more.clear(); draw(); };
90
+ const draw = () => {
91
+ const i = stack[stack.length - 1];
92
+ const f = lib.files[i];
93
+ const { out, inn } = neighbours(lib, i);
94
+ const narrow = narrowViewport();
95
+ wrap.setAttribute('data-graph-focus', f.path);
96
+ const head = el('div', { class: 'rp-graph__head' },
97
+ stack.length > 1 ? button({ label: 'Back', icon: 'arrow-left', variant: 'ghost', size: 'sm', attrs: { 'data-graph-act': 'back' }, onClick: () => { stack.pop(); state.list = false; draw(); } }) : null,
98
+ el('span', { class: 'rp-graph__title' }, el('strong', null, clip(f.title, 80)), ` · ${inn.length === 1 ? '1 file links' : inn.length + ' files link'} here, it links to ${out.length}`),
99
+ onOpen ? button({ label: 'Open file', variant: 'outline', size: 'sm', attrs: { 'data-graph-act': 'open' }, onClick: () => onOpen(f.path, f.title) }) : null);
100
+ const kids = [head];
101
+ if (!inn.length && !out.length) {
102
+ kids.push(el('p', { class: 'rp-graph__hint' }, 'No file links to this one, and it links to none. Only links written in the files are shown.'));
103
+ wrap.replaceChildren(...kids);
104
+ return;
105
+ }
106
+ if (!narrow) kids.push(picture(lib, i, inn, out, go));
107
+ const total = inn.length + out.length;
108
+ if (!narrow) {
109
+ kids.push(button({ label: state.list ? 'Hide the list' : `List all ${total} links`, variant: 'ghost', size: 'sm',
110
+ attrs: { 'data-graph-act': 'list', 'aria-expanded': state.list ? 'true' : 'false' }, onClick: () => { state.list = !state.list; draw(); } }));
111
+ }
112
+ if (narrow || state.list) kids.push(lists(lib, inn, out, go, state, draw));
113
+ kids.push(el('p', { class: 'rp-graph__hint' }, 'Only links written in the files count: a link, a path in the header, the initiative, or a decision number like D65. A dashed line is a decision it cites. Press a file to see its own links.'));
114
+ wrap.replaceChildren(...kids);
115
+ };
116
+ draw();
117
+ return wrap;
118
+ }
119
+
120
+ /** The picture: who links here (left), this file (middle), what it links to (right). */
121
+ function picture(lib, i, inn, out, go) {
122
+ const L = inn.slice(0, MAX_SIDE); const R = out.slice(0, MAX_SIDE);
123
+ const nL = L.length + (inn.length > MAX_SIDE ? 1 : 0); const nR = R.length + (out.length > MAX_SIDE ? 1 : 0);
124
+ const W = 820; const RH = 34; const NW = 250; const CW = 220;
125
+ const H = Math.max(nL, nR, 1) * RH + 16; const cx = W / 2; const cy = H / 2;
126
+ const yOf = (k, n) => cy + (k - (n - 1) / 2) * RH;
127
+ const pic = place(el('div', { class: 'rp-graph__pic', role: 'group', 'aria-label': `${lib.files[i].title}: ${inn.length} files link here, it links to ${out.length}` }), { ar: W / H });
128
+ const lines = svg('svg', { class: 'rp-graph__lines', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
129
+ const curve = (x1, y1, x2, y2, kind) => svg('path', { class: 'rp-graph__edge' + (kind === 'cites' ? ' rp-graph__edge--cites' : ''), d: `M${x1},${y1} C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}` });
130
+ L.forEach(([, k], n) => lines.appendChild(curve(NW, yOf(n, nL), cx - CW / 2, cy, k)));
131
+ R.forEach(([, k], n) => lines.appendChild(curve(cx + CW / 2, cy, W - NW, yOf(n, nR), k)));
132
+ pic.appendChild(lines);
133
+ const node = (j, kind, x, y, side) => place(button({ label: clip(lib.files[j].title, 40), variant: 'outline', size: 'sm',
134
+ attrs: { class: 'rp-graph__node rp-graph__node--' + side + (kind === 'cites' ? ' rp-graph__node--cites' : ''), title: `${lib.files[j].title}\n${lib.files[j].path}`,
135
+ 'data-graph-node': lib.files[j].path, 'data-graph-side': side, 'data-graph-kind': kind }, onClick: () => go(j) }), { x: (x / W) * 100, y: (y / H) * 100, w: (NW / W) * 100 });
136
+ L.forEach(([j, k], n) => pic.appendChild(node(j, k, 0, yOf(n, nL), 'in')));
137
+ R.forEach(([j, k], n) => pic.appendChild(node(j, k, W - NW, yOf(n, nR), 'out')));
138
+ const more = (n, x, y, side) => place(el('span', { class: 'rp-graph__more rp-graph__more--' + side }, `+ ${n} more in the list`), { x: (x / W) * 100, y: (y / H) * 100, w: (NW / W) * 100 });
139
+ if (inn.length > MAX_SIDE) pic.appendChild(more(inn.length - MAX_SIDE, 0, yOf(L.length, nL), 'in'));
140
+ if (out.length > MAX_SIDE) pic.appendChild(more(out.length - MAX_SIDE, W - NW, yOf(R.length, nR), 'out'));
141
+ pic.appendChild(place(el('span', { class: 'rp-graph__centre', title: lib.files[i].path }, clip(lib.files[i].title, 34)), { x: ((cx - CW / 2) / W) * 100, y: (cy / H) * 100, w: (CW / W) * 100 }));
142
+ pic.appendChild(el('span', { class: 'rp-graph__cap rp-graph__cap--in' }, 'Links here'));
143
+ pic.appendChild(el('span', { class: 'rp-graph__cap rp-graph__cap--out' }, 'It links to'));
144
+ return pic;
145
+ }
146
+
147
+ /** Every link, both directions, grouped by kind. A group longer than 12 shows its first 12 and a Show all. */
148
+ function lists(lib, inn, out, go, state, draw) {
149
+ const col = (xs, groups, label, side) => el('div', { class: 'rp-graph__col', 'data-graph-list': side },
150
+ el('span', { class: 'rp-graph__label' }, label),
151
+ groups.map(([kind, name]) => {
152
+ const g = xs.filter((x) => x[1] === kind);
153
+ if (!g.length) return null;
154
+ const key = side + kind;
155
+ const shown = state.more.has(key) ? g : g.slice(0, LIST_CAP);
156
+ return el('div', { class: 'rp-graph__group' },
157
+ el('span', { class: 'rp-graph__label' }, `${name} · ${g.length}`),
158
+ shown.map(([j]) => button({ label: lib.files[j].title, variant: 'ghost', size: 'sm', attrs: { class: 'rp-graph__item', title: lib.files[j].path, 'data-graph-item': lib.files[j].path }, onClick: () => go(j) })),
159
+ g.length > shown.length ? button({ label: `Show all ${g.length}`, variant: 'ghost', size: 'sm', onClick: () => { state.more.add(key); draw(); } }) : null);
160
+ }));
161
+ return el('div', { class: 'rp-graph__lists' }, col(inn, GROUP_IN, 'Links here', 'in'), col(out, GROUP_OUT, 'It links to', 'out'));
162
+ }
163
+
164
+ /* ── an initiative's files ───────────────────────────────────────────────────────────────────────────────────────── */
165
+
166
+ /** The short label of a dot: a decision's number, the role of an initiative's file, or else its title, shortened. */
167
+ export function roleOf(f) {
168
+ const d = /^(D\d+)\b/.exec(f.title);
169
+ if (d) return d[1];
170
+ if (f.category === 'eval') return /rubric/i.test(f.title + ' ' + f.path) ? 'Rubric' : /verdict/i.test(f.path) ? 'Verdict' : 'Eval';
171
+ return ROLE[f.category] || clip(f.title.replace(/^[^:]{1,24}:\s+/, ''), 22);
172
+ }
173
+
174
+ /**
175
+ * The positions of an initiative's network: its files (inner) and the outside files they link to (outer, at most 14).
176
+ * Pure and deterministic: the same files and links give the same positions. Exported for the tests.
177
+ */
178
+ export function networkLayout(lib, paths, { W = 820, H = 460 } = {}) {
179
+ const inner = [...new Set(paths.map((p) => lib.byPath.get(p)).filter((x) => x !== undefined))].sort((a, b) => lib.files[a].path.localeCompare(lib.files[b].path));
180
+ const isIn = new Set(inner);
181
+ const score = new Map();
182
+ for (const i of inner) for (const [j] of [...lib.out[i], ...lib.inn[i]]) if (!isIn.has(j)) score.set(j, (score.get(j) || 0) + 1);
183
+ const outside = [...score.entries()].sort((a, b) => b[1] - a[1] || String(lib.files[b[0]].date || '').localeCompare(String(lib.files[a[0]].date || '')) || lib.files[a[0]].path.localeCompare(lib.files[b[0]].path));
184
+ const outer = outside.slice(0, MAX_OUTSIDE).map((x) => x[0]);
185
+ const ids = [...inner, ...outer];
186
+ const on = new Set(ids);
187
+ const cx = W / 2; const cy = H / 2;
188
+ const pos = new Map();
189
+ ids.forEach((i, k) => {
190
+ const mine = isIn.has(i); const n = mine ? inner.length : outer.length; const kk = mine ? k : k - inner.length;
191
+ const a = (kk / Math.max(n, 1)) * Math.PI * 2 + (mine ? 0 : 0.3); const r = mine ? 70 : 190;
192
+ pos.set(i, { x: cx + r * Math.cos(a) * 1.6, y: cy + r * Math.sin(a), vx: 0, vy: 0 });
193
+ });
194
+ const edges = [];
195
+ const seen = new Set();
196
+ for (const i of ids) for (const [j, kind] of lib.out[i]) {
197
+ if (!on.has(j) || i === j || !(isIn.has(i) || isIn.has(j))) continue;
198
+ const key = i + '>' + j;
199
+ if (seen.has(key)) continue;
200
+ seen.add(key); edges.push([i, j, kind]);
201
+ }
202
+ for (let it = 0; it < 500; it++) {
203
+ for (let a = 0; a < ids.length; a++) for (let b = a + 1; b < ids.length; b++) {
204
+ const A = pos.get(ids[a]); const B = pos.get(ids[b]);
205
+ let dx = A.x - B.x; let dy = (A.y - B.y) * 1.6;
206
+ const d2 = Math.max(dx * dx + dy * dy, 25); const f = 26000 / d2; const d = Math.sqrt(d2);
207
+ dx /= d; dy /= d; A.vx += dx * f; A.vy += dy * f; B.vx -= dx * f; B.vy -= dy * f;
208
+ }
209
+ for (const [i, j] of edges) {
210
+ const A = pos.get(i); const B = pos.get(j);
211
+ const dx = B.x - A.x; const dy = B.y - A.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - 150) * 0.02;
212
+ A.vx += (dx / d) * f; A.vy += (dy / d) * f; B.vx -= (dx / d) * f; B.vy -= (dy / d) * f;
213
+ }
214
+ for (const i of ids) {
215
+ const P = pos.get(i); const g = isIn.has(i) ? 0.02 : 0.005;
216
+ P.vx += (cx - P.x) * g; P.vy += (cy - P.y) * g * 1.4;
217
+ P.x += Math.max(-20, Math.min(20, P.vx * 0.5)); P.y += Math.max(-20, Math.min(20, P.vy * 0.5));
218
+ P.vx *= 0.5; P.vy *= 0.5;
219
+ P.x = Math.max(30, Math.min(W - 160, P.x)); P.y = Math.max(22, Math.min(H - 22, P.y));
220
+ }
221
+ }
222
+ const at = new Map(ids.map((i) => [i, { x: Math.round(pos.get(i).x * 10) / 10, y: Math.round(pos.get(i).y * 10) / 10 }]));
223
+ return { W, H, inner, outer, left: outside.length - outer.length, edges, at };
224
+ }
225
+
226
+ /**
227
+ * An initiative's files as a network. `onOpen(path, title)` opens a file. Returns null on a phone: the caller shows its
228
+ * list instead (R-N5).
229
+ */
230
+ export function initiativeGraph(lib, paths, { onOpen } = {}) {
231
+ if (narrowViewport()) return null;
232
+ const L = networkLayout(lib, paths);
233
+ const isIn = new Set(L.inner);
234
+ const wrap = el('div', { class: 'rp-net', 'data-net': '' });
235
+ if (!L.inner.length) { wrap.appendChild(el('p', { class: 'rp-graph__hint' }, 'None of this initiative’s files is in the library yet. They show after their first commit.')); return wrap; }
236
+ let sel = null;
237
+ const draw = () => {
238
+ const pic = place(el('div', { class: 'rp-net__pic', role: 'group', 'aria-label': `${L.inner.length} files of this initiative and ${L.outer.length} files outside it that they link to` }), { ar: L.W / L.H });
239
+ const lines = svg('svg', { class: 'rp-net__lines', viewBox: `0 0 ${L.W} ${L.H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
240
+ for (const [i, j, kind] of L.edges) {
241
+ const A = L.at.get(i); const B = L.at.get(j);
242
+ const cls = ['rp-net__edge', kind === 'cites' && 'rp-net__edge--cites', isIn.has(i) && isIn.has(j) && 'rp-net__edge--in', sel !== null && (sel === i || sel === j) && 'rp-net__edge--hot'].filter(Boolean).join(' ');
243
+ lines.appendChild(svg('line', { class: cls, x1: A.x, y1: A.y, x2: B.x, y2: B.y, 'data-net-edge': `${lib.files[i].path}>${lib.files[j].path}` }));
244
+ }
245
+ pic.appendChild(lines);
246
+ for (const i of [...L.inner, ...L.outer]) {
247
+ const f = lib.files[i]; const mine = isIn.has(i); const P = L.at.get(i);
248
+ const label = mine ? roleOf(f) : ((/^(D\d+)\b/.exec(f.title) || [])[1] || clip(f.title, 22));
249
+ pic.appendChild(place(button({ label: [el('span', { class: 'rp-net__dot' + (mine ? ' rp-net__dot--in' : ''), 'aria-hidden': 'true' }), el('span', { class: 'rp-net__label' }, label)],
250
+ variant: 'ghost', size: 'sm',
251
+ attrs: { class: 'rp-net__node' + (mine ? ' rp-net__node--in' : '') + (sel === i ? ' rp-net__node--sel' : ''), title: `${f.title}\n${f.path}`, 'aria-label': f.title, 'aria-pressed': sel === i ? 'true' : 'false',
252
+ 'data-net-node': f.path, 'data-net-side': mine ? 'in' : 'out' },
253
+ onClick: () => { sel = sel === i ? null : i; draw(); } }), { x: (P.x / L.W) * 100, y: (P.y / L.H) * 100 }));
254
+ }
255
+ const legend = el('div', { class: 'rp-net__legend' },
256
+ el('span', null, el('span', { class: 'rp-net__dot rp-net__dot--in', 'aria-hidden': 'true' }), 'This initiative'),
257
+ el('span', null, el('span', { class: 'rp-net__dot', 'aria-hidden': 'true' }), 'Outside it'),
258
+ el('span', null, el('span', { class: 'rp-net__dash', 'aria-hidden': 'true' }), 'Cites a decision'),
259
+ L.left ? el('span', { 'data-net-left': String(L.left) }, `${L.left} more outside files are left out. Open a file in Docs to see all its links.`) : null);
260
+ const kids = [pic, legend];
261
+ if (sel !== null) {
262
+ const f = lib.files[sel];
263
+ const ini = lib.ini(f);
264
+ kids.push(el('div', { class: 'rp-net__sel', 'data-net-sel': f.path },
265
+ el('span', { class: 'rp-net__sel-text' }, el('strong', null, f.title), el('span', { class: 'rp-net__path' }, f.path + (ini && !isIn.has(sel) ? ' · ' + ini.short : ''))),
266
+ onOpen ? button({ label: 'Open file', variant: 'outline', size: 'sm', attrs: { 'data-net-act': 'open' }, onClick: () => onOpen(f.path, f.title) }) : null));
267
+ }
268
+ wrap.replaceChildren(...kids);
269
+ };
270
+ draw();
271
+ return wrap;
272
+ }
@@ -3,7 +3,8 @@
3
3
  * About 70% of the width on a desktop and the whole screen on a phone. Top to bottom:
4
4
  * line 1 the title, the horizon, the health reason (only when not on track), and a meta line: lane · anchor · id
5
5
  * line 2 the stage stepper, then "Waiting on you: approval [Open gate ▸]" or "Waiting on agent: build [Hand to agent ▸]"
6
- * tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files, History,
6
+ * tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files (List · Graph:
7
+ * the graph is doc-graph.js, initiative docs-library-graph), History,
7
8
  * Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
8
9
  * People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
9
10
  * ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
@@ -22,6 +23,7 @@ import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
22
23
  import { mountJira, unmountJira } from './sheet-jira.js';
23
24
  import { initiativePeople } from './people-link.js';
24
25
  import { openDoc } from './panel-initiatives.js';
26
+ import { getLibrary, initiativeGraph } from './doc-graph.js';
25
27
  import { openNewSession } from './panel-newsess.js';
26
28
  import { wsOf, readDoc, saveDoc } from './data.js';
27
29
  import { state, setState } from './state.js';
@@ -67,6 +69,8 @@ export function openSheet(repo, slug) {
67
69
  ensureSprite();
68
70
  let data = null;
69
71
  let tab = 'breakdown';
72
+ let fview = 'list'; // the Files tab: 'list' or 'graph' (docs-library-graph R-N1)
73
+ let lib = null; // the library, read on the first press on Graph
70
74
  let closed = false;
71
75
  const tabId = 'initiative:' + slug;
72
76
  const crumb = el('div', { class: 'in-sheet__crumb' });
@@ -277,9 +281,27 @@ export function openSheet(repo, slug) {
277
281
  onOpen: () => { s.close(); openDoc(slug, f.path, f.label, null, f.source === 'git' ? { readonly: true } : undefined); },
278
282
  attrs: { 'data-in-file': f.kind, 'data-in-file-source': f.source || 'record' }, openAttrs: { 'data-in-file-open': f.kind },
279
283
  }));
280
- panel.replaceChildren(files.length ? el('div', { class: 'in-files' }, groups.map(([src, list]) => el('section', { class: 'in-files__group', 'data-in-files': src },
284
+ const listView = () => (files.length ? el('div', { class: 'in-files' }, groups.map(([src, list]) => el('section', { class: 'in-files__group', 'data-in-files': src },
281
285
  el('h3', { class: 'in-files__title' }, COPY.sheet.fileSource[src] || src),
282
286
  el('div', { class: 'in-sheet__rows', role: 'list' }, list.map(fileRow))))) : empty({ text: COPY.sheet.noFiles }));
287
+ // List · Graph (docs-library-graph R-N1): the graph is this initiative's markdown files (the ones its record names
288
+ // and the ones the library's index attributes to it; a file git changed for it is evidence, not one of its files)
289
+ // and the outside files they link to. On a phone initiativeGraph draws nothing and the list shows (R-N5).
290
+ const paths = files.filter((f) => f.kind !== 'note' && (f.source || 'record') !== 'git' && /\.md$/i.test(f.path || '')).map((f) => f.path);
291
+ const seg = tabs({ variant: 'segmented', ariaLabel: COPY.sheet.filesAs, value: fview,
292
+ items: [{ id: 'list', label: COPY.sheet.filesList }, { id: 'graph', label: COPY.sheet.filesGraph }], attrs: { 'data-in-files-view': '' },
293
+ onChange: (id) => { if (id === fview) return; fview = id; drawPanel(); const t = panel.querySelector('[data-in-files-view] [data-tab="' + id + '"]'); if (t) t.focus({ preventScroll: true }); } });
294
+ const bar = el('div', { class: 'in-files__bar' }, seg, el('span', { class: 'in-files__hint' }, fview === 'graph' ? COPY.sheet.graphHint : COPY.sheet.listHint));
295
+ if (fview === 'list' || !paths.length) { panel.replaceChildren(bar, listView()); return; }
296
+ const open = (p, title) => { s.close(); openDoc(slug, p, title); };
297
+ const graphNow = () => { const g = lib ? initiativeGraph(lib, paths, { onOpen: open }) : null; return g || el('div', null, el('p', { class: 'in-note' }, COPY.sheet.graphPhone), listView()); };
298
+ if (lib) { panel.replaceChildren(bar, graphNow()); return; }
299
+ panel.replaceChildren(bar, el('p', { class: 'in-note' }, COPY.sheet.graphReading));
300
+ getLibrary(ws).then((l) => {
301
+ if (tab !== 'files' || fview !== 'graph') return;
302
+ if (!l) { panel.replaceChildren(bar, el('p', { class: 'in-note' }, COPY.sheet.graphError), listView()); return; }
303
+ lib = l; drawPanel();
304
+ });
283
305
  return;
284
306
  }
285
307
  const hist = data.history || [];
@@ -134,6 +134,14 @@ export const COPY = {
134
134
  openGate: 'Open gate',
135
135
  handToAgent: 'Hand to agent',
136
136
  noFiles: 'No files yet.',
137
+ filesAs: 'Show files as',
138
+ filesList: 'List',
139
+ filesGraph: 'Graph',
140
+ listHint: 'Every file this initiative owns.',
141
+ graphHint: 'This initiative’s files, and the files outside it that they link to.',
142
+ graphReading: 'Reading the links…',
143
+ graphError: 'The links could not be read. The list is below.',
144
+ graphPhone: 'On a small screen the files show as a list.',
137
145
  fileSource: { record: 'Named by the record', 'knowledge-graph': 'Linked documents', git: 'Changed for it in git' },
138
146
  commits: (n) => (n === 1 ? '1 commit' : `${n} commits`),
139
147
  shared: 'shared',
@@ -10,13 +10,14 @@
10
10
  * One read feeds it: GET /api/proactive/agenda?month=YYYY-MM. The server derives every answer (how full, the key
11
11
  * meeting, readiness, no notes); this file only draws them. A day cell is a button: it picks that day. A week number
12
12
  * picks the week. The lens switch says which answer the cells show; the agenda under the month always shows them all.
13
- * Nothing here writes: the calendar memory changes only through the morning-brief session's brief.
13
+ * The calendar memory changes only through the morning-brief session's brief. The one write here is the PM's key-meeting
14
+ * mark (POST /api/proactive/agenda/mark): this time or every time, key or not key, over what the calendar says.
14
15
  */
15
16
 
16
17
  import { peopleLinks } from './people-link.js';
17
18
  import { el } from './dom.js';
18
- import { button, iconButton, badge, tabs } from './primitives.js';
19
- import { pmGet } from './http.js';
19
+ import { button, iconButton, badge, tabs, toast } from './primitives.js';
20
+ import { pmGet, pmPost } from './http.js';
20
21
  import { COPY } from './today-copy.js';
21
22
 
22
23
  const A = COPY.day.ag;
@@ -74,10 +75,12 @@ function current() {
74
75
  return c;
75
76
  }
76
77
 
78
+ // a short hash of the days, so a change that keeps the length (a key mark set or cleared) still redraws
79
+ const hash = (t) => { let h = 0; for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0; return h; };
77
80
  export function agendaSig() {
78
81
  const c = ag.month ? cache.get(ag.month) : cache.get('now');
79
82
  return JSON.stringify([ag, c ? [c.error, c.data && c.data.now ? String(c.data.now).slice(0, 16) : null, c.data && Object.keys(c.data.days || {}).length, c.data && c.data.memory] : null])
80
- + (c && c.data ? JSON.stringify(c.data.days).length : 0);
83
+ + (c && c.data ? hash(JSON.stringify(c.data.days)) : 0);
81
84
  }
82
85
 
83
86
  /* ── finding a meeting across the months read ─────────────────────────────────────────────────────────────────── */
@@ -270,6 +273,26 @@ function whyLines(e) {
270
273
  return out;
271
274
  }
272
275
 
276
+ async function setMark(e, scope, on) {
277
+ const r = await pmPost('/api/proactive/agenda/mark', { title: e.title, start: e.start, scope, on });
278
+ if (!r || r.ok === false) { toast({ text: A.key.failed((r && r.error) || 'the workspace did not answer'), tone: 'destructive' }); return; }
279
+ for (const k of [...cache.keys()]) loadMonth(k === 'now' ? undefined : k);
280
+ }
281
+
282
+ /** The key-meeting line of a meeting: what the PM marked, and the presses that change it. */
283
+ function keyBlock(e) {
284
+ const K = A.key;
285
+ const b = (label, scope, on, id) => button({ label, variant: 'outline', size: 'sm', attrs: { 'data-ag-mark': id }, onClick: () => setMark(e, scope, on) });
286
+ const presses = e.mark ? [b(K.clear, e.mark, null, 'clear')]
287
+ : e.imp ? [b(K.unmark, 'one', false, 'not-one'), e.repeats ? b(K.unmarkAll, 'series', false, 'not-series') : null]
288
+ : [b(K.mark, 'one', true, 'one'), e.repeats ? b(K.markAll, 'series', true, 'series') : null];
289
+ const said = e.mark ? K[(e.imp ? 'marked' : 'not') + e.mark] : null;
290
+ return el('div', { class: 'td-day__phase', 'data-ag': 'key', 'data-ag-key': e.imp ? 'true' : 'false' },
291
+ e.imp ? el('h4', null, A.star + ' ' + K.title) : null,
292
+ said ? el('p', { class: 'td-day__note' }, said) : null,
293
+ el('div', { class: 'td-day__acts' }, ...presses));
294
+ }
295
+
273
296
  /** The series of a meeting: how often it met, what is to come, and the last time, folded. Null when unknown. */
274
297
  export function memoryBlock(id) {
275
298
  const f = findEvent(id);
@@ -280,7 +303,8 @@ export function memoryBlock(id) {
280
303
  return el('div', { class: 'td-day__notes', 'data-ag': 'memory' },
281
304
  el('h4', null, A.means),
282
305
  el('p', null, A.kindWord[f.e.kind] + '. ' + (s.count > 1 ? A.series(s.past, s.coming) : A.once)),
283
- ...whyLines(f.e).map((t) => el('p', null, t)),
306
+ ...(f.e.mark ? [] : whyLines(f.e)).map((t) => el('p', null, t)),
307
+ keyBlock(f.e),
284
308
  last ? el('details', { class: 'td-ag__last', 'data-ag': 'last' }, el('summary', null, A.lastTime(dayLabel(last.date))),
285
309
  last.notes ? el('p', null, last.notes) : el('p', { class: 'td-day__note' }, A.noLast),
286
310
  last.open && last.open.length ? el('ul', { class: 'td-day__chk' }, ...last.open.map((x) => el('li', null, x))) : null) : null);
@@ -136,16 +136,17 @@ export const COPY = {
136
136
  unreached: 'The workspace did not answer.',
137
137
  refused: (why) => `Nothing changed. ${why}`,
138
138
  oldServer: 'This workspace server has no day run yet. Only your goals show.',
139
- panels: { summary: 'Agent results', calendar: 'Calendar', agenda: 'Agenda', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', waiting: 'Also waiting for you', detail: 'Detail' },
139
+ panels: { summary: 'Agent results', calendar: 'Calendar', agenda: 'Agenda', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', detail: 'Detail' },
140
140
  summary: (c) => [c.review ? `${c.review} ${c.review === 1 ? 'draft waits' : 'drafts wait'} for you` : null, c.done ? `${c.done} done` : null,
141
141
  c.working ? `${c.working} working` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' · ') || 'No agent job yet.',
142
142
  summaryAll: (n) => `All ${n} agent ${n === 1 ? 'job has' : 'jobs have'} a result.`,
143
- waitingHint: 'Outcome checks, reports and sessions from your repositories, outside today\'s jobs.',
144
143
  // one day for every repository: a goal or a place can carry a repository; the Show switch narrows the day to it
145
144
  repo: { label: 'Repository', none: 'No repository',
146
145
  goalHint: 'Its jobs show under this repository. With no repository, the goal is yours and shows under every repository.',
147
146
  placeHint: 'Repository: its mentions and jobs show under that repository. With none, they show under every repository.',
148
- only: (name) => `Showing ${name} and your items with no repository. The calendar always shows.` },
147
+ only: (name) => `Showing ${name} and your items with no repository. The calendar always shows.`,
148
+ hiddenGoals: (n) => `${n} ${n === 1 ? 'goal belongs' : 'goals belong'} to another repository.`,
149
+ showAll: 'Show all repositories' },
149
150
  now: (at) => `now ${at}`,
150
151
  noCalendar: 'No events read for today.',
151
152
  noJobs: 'No jobs yet. Press Prepare my day. Each job shows here; select one to see what it needs.',
@@ -329,6 +330,13 @@ export const COPY = {
329
330
  kindWord: { one: 'One-to-one', onboard: 'Onboarding', group: 'Group meeting' },
330
331
  why: { present: 'You present.', decide: 'You decide.', external: 'People from outside the company join.', exec: 'Leadership joins.', many: (n) => `${n} people join.`, routine: 'It repeats, and nothing in it is for you.' },
331
332
  means: 'What it means',
333
+ key: {
334
+ title: 'Key meeting',
335
+ markedone: 'You marked this time as key.', markedseries: 'You marked it as key every time.',
336
+ notone: 'You marked this time as not key.', notseries: 'You marked it as not key every time.',
337
+ mark: 'Mark as key', markAll: 'Key every time', unmark: 'Not key', unmarkAll: 'Never key', clear: 'Remove my mark',
338
+ failed: (why) => `The mark was not saved: ${why}`,
339
+ },
332
340
  before: 'Before this meeting',
333
341
  prepLine: (done, need) => `${done} of ${need} prep steps done.`,
334
342
  readyNote: { ok: 'Your prep is done.', warn: 'Some prep is left, and there is time for it.', risk: 'The free time before it is short for the prep left.' },
@@ -142,7 +142,9 @@ function model(d, view) {
142
142
  const today = d && d.today;
143
143
  const brief = d && d.brief && (!today || d.brief.date === today) ? d.brief : null;
144
144
  const plan = (d && d.plan) || null;
145
- const goals = plan ? plan.goals || [] : [];
145
+ // a goal with no repository of its own takes the one its initiative is in (goal_repos, from the server)
146
+ const goalRepos = (d && d.goal_repos) || {};
147
+ const goals = (plan ? plan.goals || [] : []).map((g) => (g.repo || !goalRepos[g.id] ? g : { ...g, repo: goalRepos[g.id] }));
146
148
  const goalNo = new Map(goals.map((g, i) => [g.id, i + 1]));
147
149
  const run = (d && d.run) || null;
148
150
  const tasks = run && Array.isArray(run.tasks) ? run.tasks : [];
@@ -178,7 +180,7 @@ function model(d, view) {
178
180
  const shownIds = new Set(shownJobs.map((j) => j.task && j.task.id).filter(Boolean));
179
181
  const shownTasks = narrow ? tasks.filter((t) => shownIds.has(t.id)) : tasks;
180
182
  return {
181
- d, brief, plan, goals: goals.filter((g) => inView(g.repo)), allGoals: goals, goalNo, run, tasks: shownTasks, jobs: shownJobs, events, now, settings, scopes, level,
183
+ d, brief, plan, goals: goals.filter((g) => inView(g.repo)), allGoals: goals, hiddenGoals: goals.filter((g) => !inView(g.repo)).length, goalNo, run, tasks: shownTasks, jobs: shownJobs, events, now, settings, scopes, level,
182
184
  digests: digests.filter((g) => inView(g.repo)), places, placeRepo, repos, repoName, here: v.here || null, narrow,
183
185
  stage: run ? (d.stage || deriveStage(run, brief)) : null,
184
186
  mentions: shownJobs.filter((j) => j.item && j.item.mention),
@@ -419,13 +421,31 @@ function jobRow(ctx, j) {
419
421
  dot: j.isNew && !seen.has(j.id) ? el('span', { class: 'td-day__newdot', 'data-day': 'new', role: 'img', 'aria-label': D.newItem }) : null });
420
422
  }
421
423
 
424
+ /* What the repositories owe the PM outside today's jobs (outcome checks, report drafts, sessions) are jobs too: each one is a
425
+ * row of Needs you, and pressing it shows its card, with the card's own buttons, in the detail column. The Agenda has no
426
+ * separate section for them. */
427
+ function owedItems(owed) {
428
+ if (!owed || !owed.list) return [];
429
+ return [...owed.list.children].filter((n) => n.getAttribute && n.getAttribute('data-today-id')).map((n) => {
430
+ const cause = n.querySelector('[data-today-cause]');
431
+ const ev = n.querySelector('.td-card__evidence');
432
+ const text = ((cause || n).textContent || '').trim().replace(/\s+/g, ' ');
433
+ return { id: n.getAttribute('data-today-id'), node: n, title: text, meta: ev ? ev.textContent.trim().replace(/\s+/g, ' ') : '' };
434
+ });
435
+ }
436
+
437
+ function owedRow(ctx, o) {
438
+ return rowBtn(ctx, 'owed', o.id, { lead: '·', title: o.title, meta: o.meta, trail: pill(D.status2.you, 'warn') });
439
+ }
440
+
422
441
  function jobsPanel(ctx) {
423
442
  const { m } = ctx;
424
- if (!m.jobs.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
425
- const groups = ['needs', 'working', 'done', 'worth'].map((g) => [g, m.jobs.filter((j) => j.group === g)]).filter(([, l]) => l.length);
426
- return panel('jobs', D.jobsCount(m.needs, m.done), ...groups.map(([g, l]) => el('div', { class: 'td-day__group', 'data-day-group': g },
427
- el('div', { class: 'td-day__gh' }, D.groups[g], pill(String(l.length), '')),
428
- el('ul', { class: 'td-day__rows' }, ...l.map((j) => jobRow(ctx, j))))));
443
+ const owed = ctx.owed;
444
+ if (!m.jobs.length && !owed.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
445
+ const groups = ['needs', 'working', 'done', 'worth'].map((g) => [g, m.jobs.filter((j) => j.group === g)]).filter(([g, l]) => l.length || (g === 'needs' && owed.length));
446
+ return panel('jobs', D.jobsCount(m.needs + owed.length, m.done), ...groups.map(([g, l]) => el('div', { class: 'td-day__group', 'data-day-group': g },
447
+ el('div', { class: 'td-day__gh' }, D.groups[g], pill(String(l.length + (g === 'needs' ? owed.length : 0)), '')),
448
+ el('ul', { class: 'td-day__rows', 'data-day-list': 'jobs-' + g }, ...l.map((j) => jobRow(ctx, j)), ...(g === 'needs' ? owed.map((o) => owedRow(ctx, o)) : [])))));
429
449
  }
430
450
 
431
451
  /* The coordinator's view of the run: what every agent job came back with, in one place. The server runs the jobs as one
@@ -455,14 +475,12 @@ function summaryPanel(ctx) {
455
475
 
456
476
  /* What waits for the PM outside today's jobs (the Today cards: outcome checks, reports, sessions). Today hands its list
457
477
  * in, so the cards keep their own controls and the open day shows one layout. */
458
- function waitingPanel(owed) {
459
- if (!owed || !owed.list) return null;
460
- return panel('waiting', owed.count ? String(owed.count) : null, note(D.waitingHint), owed.list, owed.more || null);
461
- }
462
-
463
478
  function goalsPanel(ctx) {
464
479
  const { m } = ctx;
465
- return panel('goals', D.goalsCount(m.allGoals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), goalAdd(ctx));
480
+ // a goal of another repository is not lost: the panel says how many the Show switch hides, with the way to them
481
+ const hidden = m.hiddenGoals ? el('p', { class: 'td-day__note', 'data-day': 'goals-hidden' }, D.repo.hiddenGoals(m.hiddenGoals) + ' ',
482
+ button({ label: D.repo.showAll, variant: 'ghost', size: 'sm', attrs: { 'data-day': 'show-all' }, onClick: () => ctx.showAll() })) : null;
483
+ return panel('goals', D.goalsCount(m.allGoals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), hidden, goalAdd(ctx));
466
484
  }
467
485
 
468
486
  function mentionStatus(j) {
@@ -778,9 +796,10 @@ function sourcesDetail(ctx) {
778
796
  return [dhead(D.kind.settings, D.sources), note(D.sourcesIntro), box, el('div', { class: 'td-day__slist' }, ...rows)];
779
797
  }
780
798
 
781
- function defaultSel(m) {
799
+ function defaultSel(m, owed) {
782
800
  const j = m.jobs.find((x) => x.group === 'needs');
783
801
  if (j) return { type: 'job', id: j.id };
802
+ if (owed && owed.length) return { type: 'owed', id: owed[0].id };
784
803
  const e = m.events.find((x) => !x.ended);
785
804
  if (e) return { type: 'event', id: e.id };
786
805
  return { type: 'step', id: String(Math.max(0, Math.min(3, stepIndex(m.stage)))) };
@@ -799,10 +818,11 @@ function detail(ctx) {
799
818
  if (s.type === 'step') return stepDetail(ctx, Math.max(0, Math.min(3, Number(s.id) || 0)));
800
819
  if (s.type === 'sources') return sourcesDetail(ctx);
801
820
  if (s.type === 'meeting') return meetingDetail(ctx, s.id, { dhead });
821
+ if (s.type === 'owed') { const o = ctx.owed.find((x) => x.id === s.id); if (o) o.node.hidden = false; return o ? [dhead(D.groups.needs), el('div', { class: 'td-day__owed', 'data-day': 'owed' }, o.node)] : null; }
802
822
  return null;
803
823
  };
804
824
  kids = find(sel);
805
- if (!kids) { sel = defaultSel(m); kids = find(sel); }
825
+ if (!kids) { sel = defaultSel(m, ctx.owed); kids = find(sel); }
806
826
  return el('aside', { class: 'td-day__detail', 'aria-label': D.panels.detail, 'data-day': 'detail', 'data-day-detail': sel.type, 'data-day-detail-id': sel.id }, kids);
807
827
  }
808
828
 
@@ -847,9 +867,13 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
847
867
  const sel = ui.sel;
848
868
  const ctx = {
849
869
  ws, m, sel, openIni, reload: () => load(ws), rerender: () => rerender(), select, repoPicker,
850
- cur: (type, id) => { const s = sel || defaultSel(m); return s.type === type && s.id === String(id) ? 'true' : 'false'; },
870
+ showAll: () => { if (setState) setState({ todayScope: 'all' }); },
871
+ cur: (type, id) => { const s = sel || defaultSel(m, ctx.owed); return s.type === type && s.id === String(id) ? 'true' : 'false'; },
872
+ owed: owedItems(owed),
851
873
  };
852
- ctx.sel = sel || defaultSel(m);
874
+ ctx.sel = sel || defaultSel(m, ctx.owed);
875
+ // the open day takes what the repositories owe into its jobs: dest-today draws no second list under it
876
+ if (owed) owed.taken = true;
853
877
  if (ui.scroll) {
854
878
  ui.scroll = false;
855
879
  // on a narrow screen the detail is under the list: bring it into view once it is on the page
@@ -866,7 +890,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
866
890
  c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
867
891
  m.d && m.d.legacy ? note(D.oldServer) : null,
868
892
  el('div', { class: 'td-day__cols' },
869
- el('div', { class: 'td-day__left' }, agenda(ctx), summaryPanel(ctx), goalsPanel(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
893
+ el('div', { class: 'td-day__left' }, agenda(ctx), summaryPanel(ctx), goalsPanel(ctx), jobsPanel(ctx), mentionsPanel(ctx)),
870
894
  detail(ctx)));
871
895
  }
872
896