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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/docs.md +59 -6
- package/workspace/guide/initiatives-screen.md +8 -0
- package/workspace/guide/today.md +11 -3
- package/workspace/lib/calendar-memory.js +53 -5
- package/workspace/lib/doc-library.js +131 -0
- package/workspace/lib/goals.js +25 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +307 -0
- package/workspace/public/pm/dest-docs.js +144 -44
- package/workspace/public/pm/dest-today.js +4 -4
- package/workspace/public/pm/doc-graph.js +272 -0
- package/workspace/public/pm/initiative-sheet.js +24 -2
- package/workspace/public/pm/initiatives-copy.js +8 -0
- package/workspace/public/pm/today-agenda.js +29 -5
- package/workspace/public/pm/today-copy.js +11 -3
- package/workspace/public/pm/today-day.js +42 -18
- package/workspace/server.js +81 -6
- package/workspace/test-lists/docs-library-graph.json +5 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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)
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
el('
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|