create-pmos 0.123.0 → 0.125.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/nine-stages.js +81 -0
- package/payload/scripts/workflow-state.js +2 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-one-home-test.js +131 -0
- package/workspace/fixtures/initiatives-api/list.json +171 -3
- package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
- package/workspace/fixtures/initiatives-api/sheet.json +57 -1
- package/workspace/fixtures/one-home-baseline.json +83 -0
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/notes.md +1 -0
- package/workspace/lib/engine/nine-stages.js +81 -0
- package/workspace/lib/engine/workflow-state.js +2 -0
- package/workspace/lib/facts-async.js +51 -0
- package/workspace/lib/item-steps.js +52 -0
- package/workspace/lib/jira-status.js +8 -3
- package/workspace/lib/jobs-to-be-done.js +52 -0
- package/workspace/lib/outbox-store.js +87 -0
- package/workspace/lib/pm-items.js +115 -0
- package/workspace/lib/route-modules.js +21 -0
- package/workspace/lib/routes-briefing.js +100 -0
- package/workspace/lib/routes-c3-init.js +53 -0
- package/workspace/lib/routes-c3-life.js +119 -0
- package/workspace/lib/routes-c3-strategy.js +169 -0
- package/workspace/lib/routes-c3-timeline.js +191 -0
- package/workspace/lib/routes-calendar-import.js +60 -0
- package/workspace/lib/routes-calendar-views.js +61 -0
- package/workspace/lib/routes-doc-health.js +79 -0
- package/workspace/lib/routes-doc-sheet.js +169 -0
- package/workspace/lib/routes-forecast.js +85 -0
- package/workspace/lib/routes-hooks.js +121 -0
- package/workspace/lib/routes-initiatives.js +3 -0
- package/workspace/lib/routes-intake.js +24 -0
- package/workspace/lib/routes-outbox.js +97 -0
- package/workspace/lib/routes-pm-items.js +216 -0
- package/workspace/lib/routes-records.js +61 -0
- package/workspace/lib/routes-releases.js +129 -0
- package/workspace/lib/routes-report-facts.js +84 -0
- package/workspace/lib/routes-report-families.js +32 -0
- package/workspace/lib/routes-roadmap-ops.js +141 -0
- package/workspace/lib/routes-runs.js +119 -0
- package/workspace/lib/routes-signals.js +50 -0
- package/workspace/lib/run-answers.js +69 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/c3.html +26 -0
- package/workspace/public/pm/briefing-deck.js +415 -0
- package/workspace/public/pm/briefing-model.js +176 -0
- package/workspace/public/pm/c3-breakdown.js +240 -0
- package/workspace/public/pm/c3-brief.js +278 -0
- package/workspace/public/pm/c3-cal-strategy.js +528 -0
- package/workspace/public/pm/c3-case.js +173 -0
- package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
- package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
- package/workspace/public/pm/c3-demo-cal2.js +66 -0
- package/workspace/public/pm/c3-demo-cal3.js +173 -0
- package/workspace/public/pm/c3-demo-data.js +560 -0
- package/workspace/public/pm/c3-demo-init.js +373 -0
- package/workspace/public/pm/c3-demo-life.js +77 -0
- package/workspace/public/pm/c3-demo-misc2.js +46 -0
- package/workspace/public/pm/c3-demo-plan2.js +30 -0
- package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
- package/workspace/public/pm/c3-demo-rep2.js +92 -0
- package/workspace/public/pm/c3-demo-term.js +153 -0
- package/workspace/public/pm/c3-demo-time.js +49 -0
- package/workspace/public/pm/c3-demo-work-ix.js +80 -0
- package/workspace/public/pm/c3-demo-work2.js +13 -0
- package/workspace/public/pm/c3-demo.js +113 -0
- package/workspace/public/pm/c3-doc-sheet.js +314 -0
- package/workspace/public/pm/c3-dock.js +180 -0
- package/workspace/public/pm/c3-docs.js +290 -0
- package/workspace/public/pm/c3-expand.js +96 -0
- package/workspace/public/pm/c3-init-extras.js +170 -0
- package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
- package/workspace/public/pm/c3-misc-panels.js +89 -0
- package/workspace/public/pm/c3-plan-panels.js +163 -0
- package/workspace/public/pm/c3-plan.js +192 -0
- package/workspace/public/pm/c3-roadmap.js +249 -0
- package/workspace/public/pm/c3-sheet.js +107 -0
- package/workspace/public/pm/c3-shell.js +637 -0
- package/workspace/public/pm/c3-signals.js +196 -0
- package/workspace/public/pm/c3-strategy-panels.js +45 -0
- package/workspace/public/pm/c3-swipe.js +138 -0
- package/workspace/public/pm/c3-terminal-data.js +26 -0
- package/workspace/public/pm/c3-terminal-edge.js +91 -0
- package/workspace/public/pm/c3-terminal-icons.js +41 -0
- package/workspace/public/pm/c3-terminal-results.js +141 -0
- package/workspace/public/pm/c3-terminal.js +52 -0
- package/workspace/public/pm/c3-tickets.js +163 -0
- package/workspace/public/pm/c3-timeline.js +201 -0
- package/workspace/public/pm/c3-toast.js +16 -0
- package/workspace/public/pm/calendar-views.js +1015 -0
- package/workspace/public/pm/console3-lift.md +220 -0
- package/workspace/public/pm/console3.css +1480 -0
- package/workspace/public/pm/dest-initiatives.js +16 -3
- package/workspace/public/pm/dest-mywork.js +8 -1
- package/workspace/public/pm/dest-notes.js +25 -3
- package/workspace/public/pm/dest-reports.js +47 -1
- package/workspace/public/pm/dest-today.js +55 -1
- package/workspace/public/pm/doc-health-panel.js +110 -0
- package/workspace/public/pm/doc-rail.js +9 -3
- package/workspace/public/pm/lifecycle-lens-model.js +228 -0
- package/workspace/public/pm/lifecycle-lens.js +261 -0
- package/workspace/public/pm/new-initiative-sheet.js +116 -21
- package/workspace/public/pm/outbox-panel.js +125 -0
- package/workspace/public/pm/pm-work-view.js +919 -0
- package/workspace/public/pm/releases-view.js +248 -0
- package/workspace/public/pm/report-families-view.js +503 -0
- package/workspace/public/pm/run-card.js +244 -0
- package/workspace/public/pm/sessions-panel.js +149 -0
- package/workspace/public/pm/shell-bell.js +162 -0
- package/workspace/public/pm/shell-model.js +83 -0
- package/workspace/public/pm/shell-phone.js +124 -0
- package/workspace/public/pm/shell-readaloud.js +53 -0
- package/workspace/public/pm/shell-recording.js +110 -0
- package/workspace/public/pm/shell-ticker.js +90 -0
- package/workspace/public/pm/slide-confirm.js +163 -0
- package/workspace/public/pm/timeline-forecast-view.js +164 -0
- package/workspace/public/pm/today-day.js +8 -2
- package/workspace/public/pm/tokens.css +1 -0
- package/workspace/server.js +5 -2
- package/workspace/test-files.json +8 -0
- package/workspace/test-lists/browser-suites-green.json +5 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/* pm/c3-docs.js — the Documents sub-view of Initiatives on /c3.html (console3.html: IDOCS and dcRow at lines 3652 to 3665 and 3790, docHealthCard 4105,
|
|
2
|
+
* the coverage matrix at 3849, the List and Graph toggle dvToggle 4191, the graph graphSVG and relHtml at 4160 to 4190).
|
|
3
|
+
* REAL: GET /api/docs/library (the repository's documents: files { path, title, category, date, initiative }, edges [from, to, kind] and the kinds) and
|
|
4
|
+
* POST /api/doc-health/check { ws, path } (the six health checks, recomputed from the text: { health: { passed, total, checks } }, read-only).
|
|
5
|
+
* - A real document's state is derived from its date only: Current, or Stale when it has not changed for 30 days. Draft and Missing need a record of
|
|
6
|
+
* what a lane requires, which the library does not carry, so they are not shown for real documents.
|
|
7
|
+
* - A real edge has no sync state; the page marks an edge "out of sync" when its source changed after the document it feeds (dates from the library)
|
|
8
|
+
* for the link, cites and anchor kinds, and "in sync" otherwise. No edge is marked conflict or missing from real data.
|
|
9
|
+
* - Health is asked for the first eight documents shown, one at a time, and is drawn as "N of M checks" on the row and in the health card.
|
|
10
|
+
* DEMO ONLY (optional fields the real library does not carry): per file ty, v, st, why, by, up, node (Goal, Decision, Data, Tickets) and col; a 4th
|
|
11
|
+
* edge element (state); the response's `cards` (Benchmark, Research themes). The 14 documents, the graph and the cards are the mockup's own.
|
|
12
|
+
* ROW AND SHEET: a row opens its case file in place and swipes (right: ask the agent for the update, left: later); its Open chip opens the document sheet (c3-doc-sheet.js:
|
|
13
|
+
* Read, Edit by hand with a why, Ask the agent, History, Health, Relations). The list itself writes nothing; an edit is written by the sheet through POST /api/doc-sheet/edit. */
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { pmGet, pmPost } from './http.js';
|
|
16
|
+
import { c3ic, c3ring } from './pm-work-view.js';
|
|
17
|
+
import { makeView, frame, hBars, card, emptyRow, dayLabel, short, rowShell, caseNode, markDone, say } from './c3-init-extras.js';
|
|
18
|
+
import { openDocSheet } from './c3-doc-sheet.js';
|
|
19
|
+
|
|
20
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
21
|
+
const sv = (tag, a, ...kids) => { const n = document.createElementNS(NS, tag); for (const k of Object.keys(a || {})) if (a[k] != null) n.setAttribute(k, a[k]); for (const c of kids) if (c) n.appendChild(c); return n; };
|
|
22
|
+
const svt = (x, y, t, a) => { const n = sv('text', { x, y, ...(a || {}) }); n.textContent = t; return n; };
|
|
23
|
+
|
|
24
|
+
const DST = { current: ['Current', 'var(--success)', 'check'], stale: ['Stale', 'var(--warning)', 'alert'], draft: ['Draft', 'var(--acc)', 'clock'], missing: ['Missing', 'var(--destructive)', 'plus'] };
|
|
25
|
+
const DTI = { PRD: 'doc', Rubric: 'scale', Research: 'notes', Benchmark: 'report', Design: 'img', Brief: 'flag', Specs: 'doc', Evals: 'scale', Decisions: 'scale', Explainers: 'info', Prototypes: 'img' };
|
|
26
|
+
const COLS = [['Spec', ['PRD', 'Brief', 'Spec']], ['Rubric', ['Rubric']], ['Research', ['Research']], ['Benchmark', ['Benchmark']], ['Design', ['Design']]];
|
|
27
|
+
const ESTATE = { ok: ['in sync', 'var(--ring)'], drift: ['out of sync', 'var(--warning)'], conflict: ['conflict', 'var(--destructive)'], missing: ['missing', 'var(--muted-foreground)'] };
|
|
28
|
+
const REAL_TYPE = { spec: 'Spec', eval: 'Rubric', res: 'Research', proto: 'Design' }; // the library's categories that are the mockup's document types
|
|
29
|
+
const REAL_COL = { dec: 0, guide: 0, know: 0, res: 1, spec: 2, ini: 2, eval: 3, proto: 3, exp: 3 };
|
|
30
|
+
const STALE_DAYS = 30;
|
|
31
|
+
const hcol = (h) => (h.total === 0 ? 'var(--muted-foreground)' : h.passed === h.total ? 'var(--success)' : h.total - h.passed === 1 ? 'var(--warning)' : 'var(--destructive)');
|
|
32
|
+
const dayNum = (iso) => Math.round(Date.parse(String(iso).slice(0, 10) + 'T00:00:00Z') / 86400000);
|
|
33
|
+
const todayIso = () => { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
34
|
+
|
|
35
|
+
/** The documents of a library response in the page's own shape. */
|
|
36
|
+
export function docsOf(lib, today) {
|
|
37
|
+
if (!lib || !lib.files) return [];
|
|
38
|
+
const catName = Object.fromEntries((lib.categories || []).map((c) => [c.key, c.name]));
|
|
39
|
+
return lib.files.map((f, i) => {
|
|
40
|
+
const real = !f.st;
|
|
41
|
+
const age = real && f.date && today ? dayNum(today) - dayNum(f.date) : 0;
|
|
42
|
+
return { i, path: f.path, name: f.title || f.path, ty: f.ty || REAL_TYPE[f.category] || catName[f.category] || f.category, cat: f.category, node: f.node || null,
|
|
43
|
+
col: f.col != null ? f.col : (REAL_COL[f.category] != null ? REAL_COL[f.category] : 3), ini: f.initiative || '', v: f.v || '', up: f.up || (f.date ? dayLabel(f.date) : ''), date: f.date || '',
|
|
44
|
+
st: f.st || (age > STALE_DAYS ? 'stale' : 'current'), why: f.why || '', real };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const isDoc = (d) => !d.node;
|
|
48
|
+
const needs = (d) => d.st === 'stale' || d.st === 'missing';
|
|
49
|
+
/** The count on the Documents button: documents that need attention. A real document counts only when it is stale and its initiative is in Now. */
|
|
50
|
+
export async function badge(ctx) {
|
|
51
|
+
const lib = await pmGet('/api/docs/library' + (ctx && ctx.ws ? '?ws=' + encodeURIComponent(ctx.ws) : ''));
|
|
52
|
+
if (!lib || !lib.ok) return null;
|
|
53
|
+
const now = new Set(((ctx && ctx.rows) || []).filter((r) => r.horizon === 'now').map((r) => r.id));
|
|
54
|
+
return docsOf(lib, todayIso()).filter((d) => isDoc(d) && d.ini && needs(d) && (!d.real || now.has(d.ini))).length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The document sheet for a document (the Open chip, the Documents graph, a related document). `ask` opens it on Ask the agent with that request. */
|
|
58
|
+
function openDoc(V, d, ask) {
|
|
59
|
+
if (!d || d.st === 'missing' || d.node) return;
|
|
60
|
+
const ui = openDocSheet({ ws: V.ctx.ws, doc: { path: d.path, name: d.name, ty: d.ty, v: d.v, st: d.st, ini: d.ini, iniName: iniName(V, d.ini) }, mode: ask ? 'ask' : 'read', prompt: ask || '',
|
|
61
|
+
relations: (doc, openOther) => relationsOf(V, d, openOther), onChanged: () => { delete V.health[d.path]; V.asking = false; load(V); } });
|
|
62
|
+
ui.setOpener((o) => openDoc(V, o));
|
|
63
|
+
}
|
|
64
|
+
const iniName = (V, id) => { const i = V.lib && V.lib.initiatives && V.lib.initiatives[id]; return i ? i.short || i.title : id || ''; };
|
|
65
|
+
|
|
66
|
+
/** The first thing to ask the agent about a document: the first failing check's own request, else a plain one. */
|
|
67
|
+
function askFor(V, d) {
|
|
68
|
+
const h = V.health[d.path]; const c = h && h.checks && h.checks.find((x) => x.pass === false && x.fix);
|
|
69
|
+
return c ? c.fix : d.st === 'draft' ? 'Finish the draft: fill the missing sections' : 'Update this document so it matches the current plan';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The case file of a document (console3.html genDoc, line 3787). "Have the agent draft the update" opens the sheet on Ask the agent: the agent proposes, the slide that accepts is in the sheet. */
|
|
73
|
+
function caseOf(V, d) {
|
|
74
|
+
const chg = [['doc', d.ty + (d.v ? ' ' + d.v : ''), 'version']];
|
|
75
|
+
if (d.why) chg.push(['info', d.why, 'agent']);
|
|
76
|
+
else if (d.st === 'stale') chg.push(['clock', 'Not changed for more than ' + STALE_DAYS + ' days', 'git']);
|
|
77
|
+
if (d.up) chg.push(['clock', 'Updated ' + d.up, '']);
|
|
78
|
+
const h = V.health[d.path]; if (h && h.total) chg.push([h.passed === h.total ? 'check' : 'alert', h.passed + ' of ' + h.total + ' health checks pass', 'health', h.passed === h.total ? 'ok' : 'warn']);
|
|
79
|
+
const opts = [];
|
|
80
|
+
if (d.st === 'stale' || d.st === 'missing' || d.st === 'draft') opts.push({ id: 'dr', l: d.st === 'draft' ? 'Review the agent draft' : 'Have the agent draft ' + (d.st === 'missing' ? 'it' : 'the update'), rec: 1, fx: [['check', 'You approve before anything is shared', 'ok'], ['lock', d.st === 'missing' ? 'Nothing is written until you accept' : 'Opens the document on Ask the agent', '']] });
|
|
81
|
+
opts.push({ id: 'lt', l: 'Later', fx: [] });
|
|
82
|
+
const sit = d.st === 'stale' ? 'This document no longer matches the plan. People may be reading an old version.' : d.st === 'missing' ? 'The initiative has no ' + String(d.ty).toLowerCase() + '. ' + (d.ty === 'Benchmark' ? 'It is optional but cheap.' : 'The gate needs it.') : d.why || (d.name + ' is ' + d.st + (d.up ? ', last changed ' + d.up : '') + '.');
|
|
83
|
+
return caseNode(V, 'doc:' + d.path, { sit, conf: d.st === 'current' ? 90 : 78, chg, opts, cta: 'Slide to confirm', go: d.st !== 'missing' ? ['open', d.path, 'Open the document'] : null }, {
|
|
84
|
+
onGo: () => openDoc(V, d),
|
|
85
|
+
onConfirm: (o) => {
|
|
86
|
+
if (o.id === 'lt') { markDone(V, 'doc:' + d.path, 'Parked for this week.'); return; }
|
|
87
|
+
if (d.st === 'missing') { markDone(V, 'doc:' + d.path, 'Draft started. It lands in Review as a diff you approve.'); return; }
|
|
88
|
+
openDoc(V, d, askFor(V, d));
|
|
89
|
+
} });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function docRow(V, d) {
|
|
93
|
+
const S = DST[d.st] || DST.current; const h = V.health[d.path];
|
|
94
|
+
const ini = V.lib.initiatives && V.lib.initiatives[d.ini];
|
|
95
|
+
/* the swipe (console3.html line 3881): right asks the agent for the update, left leaves it for later */
|
|
96
|
+
const sw = { labelRight: d.st === 'current' ? 'already current' : 'have the agent draft the update', labelLeft: 'later', iconRight: 'spark', iconLeft: 'moon',
|
|
97
|
+
onRight: () => { if (d.st === 'current' || d.st === 'missing') { say(d.st === 'current' ? 'Already current' : 'Open its case to start the draft'); return; } say('Opened on Ask the agent. The draft is a proposal you accept.'); openDoc(V, d, askFor(V, d)); },
|
|
98
|
+
onLeft: () => say('Later') };
|
|
99
|
+
return rowShell(V, { key: 'doc:' + d.path, attrs: { 'data-in-doc': d.path, 'data-id': d.path, 'data-dc': '1' }, icon: DTI[d.ty] || 'doc', iconColor: S[1], title: d.name, sw, xpContent: caseOf(V, d),
|
|
100
|
+
chips: [el('span', { class: 'chip2', style: 'color:' + S[1] }, c3ic(S[2], 'sm'), S[0]),
|
|
101
|
+
el('span', { class: 'chip2' }, d.ty),
|
|
102
|
+
d.v ? el('span', { class: 'chip2 mono' }, d.v) : null,
|
|
103
|
+
h && h.total ? el('span', { class: 'chip2', style: 'color:' + hcol(h), 'data-in-health': d.path }, c3ic('shield', 'sm'), h.passed + ' of ' + h.total + ' checks') : null,
|
|
104
|
+
d.up ? el('span', { class: 'chip2' }, c3ic('clock', 'sm'), d.up) : null,
|
|
105
|
+
ini ? el('span', { class: 'tag' }, V.lib.initiatives[d.ini].short || short(ini.title, 24)) : d.ini ? el('span', { class: 'tag' }, d.ini) : null,
|
|
106
|
+
d.st !== 'missing' ? el('button', { type: 'button', class: 'chip2 lk', 'data-doc-open': d.path, onclick: () => openDoc(V, d) }, c3ic('doc', 'sm'), 'Open') : null] });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── health: asked per document, one at a time ─────────────────────────────────────────────────────────────── */
|
|
110
|
+
function askHealth(V, docs) {
|
|
111
|
+
const todo = docs.filter((d) => !V.health[d.path] && !d.node && d.st !== 'missing').slice(0, docs.some((d) => !d.real) ? 14 : 8);
|
|
112
|
+
if (V.asking || !todo.length) return;
|
|
113
|
+
V.asking = true;
|
|
114
|
+
(async () => {
|
|
115
|
+
for (const d of todo) {
|
|
116
|
+
if (V.disposed) return;
|
|
117
|
+
const r = await pmPost('/api/doc-health/check', { ws: V.ctx.ws, path: d.path });
|
|
118
|
+
V.health[d.path] = r && r.ok && r.health ? { passed: r.health.passed, total: r.health.total, checks: r.health.checks } : { passed: 0, total: 0, checks: [], err: true };
|
|
119
|
+
V.render();
|
|
120
|
+
}
|
|
121
|
+
V.asking = false;
|
|
122
|
+
})();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function healthCard(V, docs) {
|
|
126
|
+
const hs = docs.map((d) => [d, V.health[d.path]]).filter(([, h]) => h && h.total);
|
|
127
|
+
if (!hs.length) return card('shield', 'Document health', 'can I trust it without rereading', el('p', { class: 'dim', style: 'font-size:var(--fs-2);margin:0', role: 'status' }, V.asking ? 'Checking the documents.' : 'No document has been checked yet.'));
|
|
128
|
+
const good = hs.filter(([, h]) => h.passed === h.total).length;
|
|
129
|
+
hs.sort((a, b) => a[1].passed / a[1].total - b[1].passed / b[1].total);
|
|
130
|
+
return card('shield', 'Document health', 'can I trust it without rereading',
|
|
131
|
+
el('div', { class: 'chips2' }, el('span', { style: 'display:inline-flex' }, c3ring(good, Math.max(1, hs.length), 44)), el('span', { class: 'dim', style: 'font-size:var(--fs-2)' }, good + ' of ' + hs.length + ' pass every check: fresh, matches decisions and data, complete, traced, readers current')),
|
|
132
|
+
hBars(hs.slice(0, 5).map(([d, h]) => [short(d.name, 28), h.passed / h.total, hcol(h), h.passed + ' of ' + h.total])));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function coverageCard(V, docs) {
|
|
136
|
+
const rows = (V.scope ? V.ctx.rows.filter((r) => r.id === V.scope) : V.ctx.rows).slice(0, 5);
|
|
137
|
+
if (!rows.length) return null;
|
|
138
|
+
const used = COLS.filter((c, ci) => ci < 2 || ci === 2 || docs.some((d) => c[1].includes(d.ty)));
|
|
139
|
+
const cell = (r, c, ci) => {
|
|
140
|
+
const L = docs.filter((d) => d.ini === r.id && c[1].includes(d.ty)); const req = ci < (r.lane === 'feature' ? 2 : 1);
|
|
141
|
+
const k = !L.length ? (req ? 'x' : 'o') : L.some((d) => d.st === 'current') ? '' : L.some((d) => d.st === 'stale' || d.st === 'draft') ? 'w' : (req ? 'x' : 'o');
|
|
142
|
+
return el('span', { class: 'c' }, el('i', { class: k }));
|
|
143
|
+
};
|
|
144
|
+
return card('shield', 'Coverage', 'features need spec and rubric first',
|
|
145
|
+
el('div', { class: 'dxm' + (used.length === 4 ? ' c4' : ''), 'data-in-coverage': '' }, el('span'), ...used.map((c) => el('span', { class: 'h' }, c[0].slice(0, 5))),
|
|
146
|
+
...rows.flatMap((r) => [el('span', null, short(r.title, 18) + ' ', el('span', { class: 'dim' }, r.lane || '')), ...used.map((c) => cell(r, c, COLS.indexOf(c)))])),
|
|
147
|
+
el('div', { class: 'pgl' }, el('span', { style: 'color:var(--success)' }, '● current'), el('span', { style: 'color:var(--warning)' }, '◯ stale or draft'), el('span', null, '◌ none'), el('span', { style: 'color:var(--destructive)' }, '● required, missing')));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── the graph ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
151
|
+
function edgesOf(lib, docs) {
|
|
152
|
+
const byIdx = new Map(docs.map((d) => [d.i, d]));
|
|
153
|
+
const edges = (lib.edges || []).map(([a, b, k, st]) => ({ a, b, k: lib.kinds[k] || String(k), st })).filter((e) => byIdx.has(e.a) && byIdx.has(e.b) && e.a !== e.b);
|
|
154
|
+
for (const e of edges) {
|
|
155
|
+
if (e.st) continue;
|
|
156
|
+
const A = byIdx.get(e.a); const B = byIdx.get(e.b);
|
|
157
|
+
e.st = ['link', 'cites', 'anchor'].includes(e.k) && A.date && B.date && A.date > B.date ? 'drift' : 'ok';
|
|
158
|
+
}
|
|
159
|
+
return edges;
|
|
160
|
+
}
|
|
161
|
+
function graphModel(V, docs) {
|
|
162
|
+
const lib = V.lib; const byIdx = new Map(docs.map((d) => [d.i, d]));
|
|
163
|
+
const edges = edgesOf(lib, docs);
|
|
164
|
+
const deg = new Map(); edges.forEach((e) => { deg.set(e.a, (deg.get(e.a) || 0) + 1); deg.set(e.b, (deg.get(e.b) || 0) + 1); });
|
|
165
|
+
const keep = new Set();
|
|
166
|
+
if (V.scope) docs.forEach((d) => { if (d.ini === V.scope) keep.add(d.i); });
|
|
167
|
+
else if (docs.some((d) => d.node)) docs.forEach((d) => keep.add(d.i));
|
|
168
|
+
else [...deg.entries()].filter(([i]) => byIdx.get(i).ini && byIdx.get(i).ini !== 'README').sort((x, y) => y[1] - x[1]).slice(0, 16).forEach(([i]) => keep.add(i));
|
|
169
|
+
edges.forEach((e) => { if (keep.has(e.a) && keep.size < 26) keep.add(e.b); if (keep.has(e.b) && keep.size < 26) keep.add(e.a); });
|
|
170
|
+
const nodes = [...keep].map((i) => byIdx.get(i)).filter(Boolean).slice(0, 26);
|
|
171
|
+
const ids = new Set(nodes.map((n) => n.i));
|
|
172
|
+
return { nodes, edges: edges.filter((e) => ids.has(e.a) && ids.has(e.b)) };
|
|
173
|
+
}
|
|
174
|
+
function downstream(edges, id) {
|
|
175
|
+
const seen = new Set(); const q = [id]; const out = [];
|
|
176
|
+
while (q.length) { const x = q.shift(); edges.forEach((e) => { if (e.a === x && !seen.has(e.b) && e.st !== 'conflict') { seen.add(e.b); out.push(e.b); q.push(e.b); } }); }
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
function graphSvg(V, G) {
|
|
180
|
+
const CW = 152; const NW = 124; const NH = 50; const top = 26; const cols = [[], [], [], [], []]; const sel = V.sel;
|
|
181
|
+
G.nodes.forEach((n) => cols[Math.min(4, Math.max(0, n.col))].push(n));
|
|
182
|
+
cols.forEach((c) => c.sort((a, b) => (a.ini || '').localeCompare(b.ini || '') || a.name.localeCompare(b.name)));
|
|
183
|
+
const mx = Math.max(1, ...cols.map((c) => c.length)); const H = top + mx * (NH + 10) + 10; const W = 760; const pos = {};
|
|
184
|
+
cols.forEach((c, ci) => { const gap = (H - top - 10) / Math.max(1, c.length); c.forEach((n, i) => { pos[n.i] = { x: 8 + ci * CW, y: top + i * gap + (gap - NH) / 2 }; }); });
|
|
185
|
+
const nb = new Set([sel]); G.edges.forEach((e) => { if (e.a === sel) nb.add(e.b); if (e.b === sel) nb.add(e.a); });
|
|
186
|
+
const s = sv('svg', { class: 'pg', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': 'Document relationship graph. Amber lines are out of sync, red lines conflict.', style: 'width:100%;min-width:760px;height:auto;display:block' });
|
|
187
|
+
['Goals and sources', 'Evidence', 'Specs', 'Rubric and design', 'Delivery'].forEach((t, i) => s.append(svt(8 + i * CW, 14, t)));
|
|
188
|
+
G.edges.forEach((e) => {
|
|
189
|
+
const p = pos[e.a]; const q = pos[e.b]; if (!p || !q) return;
|
|
190
|
+
const c = ESTATE[e.st][1]; const x1 = p.x + NW; const y1 = p.y + NH / 2; const x2 = q.x; const y2 = q.y + NH / 2; const m = (x1 + x2) / 2; const on = e.a === sel || e.b === sel; const op = on || sel == null ? 1 : .25;
|
|
191
|
+
s.append(sv('path', { d: `M${x1} ${y1} C${m} ${y1} ${m} ${y2} ${x2} ${y2}`, fill: 'none', stroke: c, 'stroke-width': on ? 2.5 : 1.4, opacity: op, 'stroke-dasharray': e.st === 'missing' ? '4 3' : null }), sv('path', { d: `M${x2} ${y2} l-6 -3.5 v7z`, fill: c, opacity: op }));
|
|
192
|
+
if (on) s.append(svt(m, (y1 + y2) / 2 - 5, e.k, { 'text-anchor': 'middle', style: 'fill:' + c }));
|
|
193
|
+
});
|
|
194
|
+
G.nodes.forEach((n) => {
|
|
195
|
+
const p = pos[n.i]; const c = n.node ? 'var(--muted-foreground)' : (DST[n.st] || DST.current)[1]; const on = n.i === sel;
|
|
196
|
+
const g = sv('g', { tabindex: '0', role: 'button', 'aria-label': 'Show the relations of ' + n.name, 'data-in-node': n.i, style: 'cursor:pointer', opacity: nb.has(n.i) || sel == null ? 1 : .35 },
|
|
197
|
+
sv('rect', { x: p.x, y: p.y, width: NW, height: NH, rx: 8, fill: 'var(--card)', stroke: c, 'stroke-width': on ? 3 : 1.5, 'stroke-dasharray': n.st === 'missing' ? '4 3' : null }),
|
|
198
|
+
svt(p.x + 8, p.y + 19, (n.node || n.ty) + (n.v ? ' ' + n.v : ''), { style: 'font-size:var(--fs-1);fill:var(--muted-foreground)' }), svt(p.x + 8, p.y + 37, short(n.name, 17), { class: 't' }));
|
|
199
|
+
const pick = () => { V.sel = n.i; V.render(); };
|
|
200
|
+
g.addEventListener('click', pick); g.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
|
|
201
|
+
s.append(g);
|
|
202
|
+
});
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
/** The Relations of a document (the sheet's sixth mode): every edge that touches it, from the whole library, not the graph's capped node set. */
|
|
206
|
+
function relationsOf(V, d, openOther) {
|
|
207
|
+
const docs = V.docs; const G = { nodes: docs, edges: edgesOf(V.lib, docs) };
|
|
208
|
+
return relations(d.i, G, docs, openOther);
|
|
209
|
+
}
|
|
210
|
+
function relations(sel, G, docs, openOther) {
|
|
211
|
+
const n = docs.find((d) => d.i === sel);
|
|
212
|
+
if (!n) return el('p', { class: 'dim', style: 'font-size:var(--fs-2);margin:0' }, 'Pick a node to see what feeds it and what it feeds.');
|
|
213
|
+
const by = new Map(docs.map((d) => [d.i, d]));
|
|
214
|
+
const inn = G.edges.filter((e) => e.b === n.i); const out = G.edges.filter((e) => e.a === n.i); const dn = downstream(G.edges, n.i); const dr = out.filter((e) => e.st === 'drift');
|
|
215
|
+
const line = (e, dir) => { const o = by.get(dir === 'in' ? e.a : e.b); const E = ESTATE[e.st];
|
|
216
|
+
return el('li', { class: e.st === 'ok' ? 'ok' : e.st === 'missing' ? '' : 'bad' }, c3ic(e.st === 'ok' ? 'check' : 'alert', 'sm'), el('span', null, o.name + ' · ', el('b', null, e.k), ' · ' + E[0] + ' ',
|
|
217
|
+
openOther && !o.node && o.st !== 'missing' ? el('button', { type: 'button', class: 'chip2 lk', 'data-doc-rel-open': o.path, onclick: () => openOther(o) }, c3ic('doc', 'sm'), 'Open') : null)); };
|
|
218
|
+
return el('div', { class: 'dpn' },
|
|
219
|
+
el('div', { class: 'chips2' }, el('b', null, n.name), el('span', { class: 'chip2' }, n.node || n.ty)),
|
|
220
|
+
inn.length ? el('div', null, el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Comes from'), el('ul', { class: 'chgl' }, inn.map((e) => line(e, 'in')))) : null,
|
|
221
|
+
out.length ? el('div', null, el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Feeds'), el('ul', { class: 'chgl' }, out.map((e) => line(e, 'out')))) : null,
|
|
222
|
+
el('div', { class: 'chips2', 'data-in-impact': '' }, el('span', { class: 'ki', style: 'color:' + (dr.length ? 'var(--warning)' : 'var(--success)') }, c3ic(dr.length ? 'alert' : 'check')),
|
|
223
|
+
el('span', { style: 'font-size:var(--fs-3)' }, 'If you change this, ', el('b', null, String(dn.length)), ' item' + (dn.length === 1 ? '' : 's') + ' downstream are affected' + (dr.length ? ', ' + dr.length + ' already out of sync' : '') + '.')));
|
|
224
|
+
}
|
|
225
|
+
function graphCards(V, docs) {
|
|
226
|
+
const G = graphModel(V, docs);
|
|
227
|
+
if (V.sel == null || !G.nodes.some((n) => n.i === V.sel)) { const hub = [...G.nodes].sort((a, b) => G.edges.filter((e) => e.a === b.i || e.b === b.i).length - G.edges.filter((e) => e.a === a.i || e.b === a.i).length)[0]; V.sel = hub ? hub.i : null; }
|
|
228
|
+
const c = { drift: 0, conflict: 0, missing: 0 }; G.edges.forEach((e) => { if (c[e.st] != null) c[e.st]++; });
|
|
229
|
+
if (!G.nodes.length) return [card('init', 'Document graph', 'tap a node to see what feeds it and what it feeds', el('p', { class: 'dim', style: 'font-size:var(--fs-2);margin:0', 'data-in-empty': '' }, 'No document has a recorded relation yet, so there is no graph to draw.'))];
|
|
230
|
+
return [
|
|
231
|
+
card('init', 'Document graph', 'tap a node to see what feeds it and what it feeds', el('div', { style: 'overflow-x:auto;max-width:100%' }, graphSvg(V, G)),
|
|
232
|
+
el('div', { class: 'pgl' }, el('span', { style: 'color:var(--destructive)' }, '━ conflict ' + c.conflict), el('span', { style: 'color:var(--warning)' }, '━ out of sync ' + c.drift), el('span', null, '━ in sync'), el('span', null, '┄ missing'))),
|
|
233
|
+
card('tree', 'Relations', '', relations(V.sel, G, docs, (d) => openDoc(V, d))),
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function load(V) {
|
|
238
|
+
Promise.all([pmGet('/api/docs/library?ws=' + encodeURIComponent(V.ctx.ws))]).then(([lib]) => {
|
|
239
|
+
if (V.disposed) return;
|
|
240
|
+
V.lib = lib && lib.ok ? lib : null; V.err = V.lib ? null : (lib && lib.error) || 'The documents could not be read.';
|
|
241
|
+
V.docs = V.lib ? docsOf(V.lib, todayIso()) : [];
|
|
242
|
+
V.render();
|
|
243
|
+
}, () => { V.err = 'The documents could not be read.'; V.render(); });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const LIST_MAX = 30;
|
|
247
|
+
function render(V) {
|
|
248
|
+
V.health = V.health || {}; V.view = V.view || 'list';
|
|
249
|
+
if (V.lib === undefined) {
|
|
250
|
+
frame(V, { head: { icon: 'doc', h: 'Reading the documents', s: 'A feature needs its spec and rubric before build. Discovery needs a brief.', ring: [0, 1] }, left: [el('section', { class: 'card sect' }, el('ul', { class: 'list' }, emptyRow('clock', 'Reading the documents.', { role: 'status' })))], right: [] });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!V.lib) {
|
|
254
|
+
frame(V, { head: { icon: 'doc', h: 'The documents could not be read', s: V.err, ring: [0, 1] }, left: [el('section', { class: 'card sect' }, el('ul', { class: 'list' }, emptyRow('alert', V.err)))], right: [] });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const all = V.docs.filter(isDoc);
|
|
258
|
+
const inScope = all.filter((d) => (V.scope ? d.ini === V.scope : !!d.ini && d.ini !== 'README'));
|
|
259
|
+
const O = { missing: 0, stale: 1, draft: 2, current: 3 };
|
|
260
|
+
const list = inScope.slice().sort((a, b) => O[a.st] - O[b.st] || String(b.date).localeCompare(String(a.date)));
|
|
261
|
+
const shown = V.all ? list : list.slice(0, LIST_MAX);
|
|
262
|
+
const cur = inScope.filter((d) => d.st === 'current').length; const need = inScope.filter(needs).length;
|
|
263
|
+
askHealth(V, shown);
|
|
264
|
+
const hs = shown.map((d) => V.health[d.path]).filter((h) => h && h.total); const good = hs.filter((h) => h.passed === h.total).length;
|
|
265
|
+
const head = { icon: 'doc', h: inScope.length + ' document' + (inScope.length === 1 ? '' : 's') + ', ' + need + ' need attention',
|
|
266
|
+
s: (hs.length ? 'Health: ' + good + ' of ' + hs.length + ' checked pass every check. ' : '') + 'A feature needs its spec and rubric before build. Discovery needs a brief.', ring: [cur, Math.max(1, inScope.length)] };
|
|
267
|
+
const toggle = el('div', { class: 'calbar', style: 'margin-bottom:8px' }, el('div', { class: 'seg', role: 'group', 'aria-label': 'Documents view' },
|
|
268
|
+
[['list', 'doc', 'List'], ['graph', 'init', 'Graph']].map(([k, ic, l]) => el('button', { type: 'button', 'aria-pressed': String(V.view === k), 'data-in-dview': k, onClick: () => { V.view = k; V.render(); } }, c3ic(ic, 'sm'), l))));
|
|
269
|
+
const left = [toggle]; const right = [];
|
|
270
|
+
if (V.view === 'graph') {
|
|
271
|
+
left.push(...graphCards(V, V.docs));
|
|
272
|
+
right.push(healthCard(V, shown), card('info', 'How to read it', '', el('ul', { class: 'chgl' },
|
|
273
|
+
el('li', null, c3ic('init', 'sm'), el('span', null, 'Arrows run from the source to what is built on it.')),
|
|
274
|
+
el('li', { class: 'bad' }, c3ic('alert', 'sm'), el('span', null, 'Red: a decision or data source disagrees with the document.')),
|
|
275
|
+
el('li', { class: 'bad' }, c3ic('alert', 'sm'), el('span', null, 'Amber: the source changed after the document last used it.')))));
|
|
276
|
+
} else {
|
|
277
|
+
left.push(el('section', { class: 'card sect', 'data-in-list': 'documents' }, el('div', { class: 'sh2' }, el('h2', null, c3ic('doc', 'sm'), 'Documents ', el('span', { class: 'bdg g' }, String(list.length)))),
|
|
278
|
+
el('ul', { class: 'list' }, list.length ? [...shown.map((d) => docRow(V, d)), list.length > shown.length ? el('li', { class: 'fold' }, el('button', { type: 'button', class: 'pill', 'data-in-showall': '', onClick: () => { V.all = true; V.render(); } }, 'Show all ' + list.length)) : null]
|
|
279
|
+
: [emptyRow('moon', V.scope ? 'No document belongs to this initiative.' : 'No document belongs to an initiative yet.', { 'data-in-empty': '' })])));
|
|
280
|
+
right.push(healthCard(V, shown), coverageCard(V, V.docs.filter(isDoc)));
|
|
281
|
+
const C = V.lib.cards || {};
|
|
282
|
+
if (C.benchmark) right.push(card('report', 'Benchmark', C.benchmark.sub, hBars(C.benchmark.rows)));
|
|
283
|
+
if (C.research) right.push(card('notes', 'Research themes', C.research.sub, hBars(C.research.rows)));
|
|
284
|
+
}
|
|
285
|
+
frame(V, { head, left, right: right.filter(Boolean) });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const view = makeView('documents', { init: load, render });
|
|
289
|
+
export const mount = view.mount;
|
|
290
|
+
export const dispose = view.dispose;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* pm/c3-expand.js — the mockup's in-place row expansion (the `.xp` pattern), as a primitive.
|
|
2
|
+
*
|
|
3
|
+
* The mockup (console3.html, document click handler line 2135 and the rows at 1289, 2345, 2349): a row `li.nrow[data-a=expand]`
|
|
4
|
+
* with tabindex=0 and aria-expanded holds a `div.xp[data-xp] > div.xi` that carries the case file. A click on the row toggles
|
|
5
|
+
* `.open` on the xp (grid-template-rows 0fr to 1fr, 0.32s) and flips aria-expanded; a click on anything inside the xp, or on a
|
|
6
|
+
* button or link of the row, does not toggle. The mockup lets any number of rows stay open at once (S.open[k] toggles on its own).
|
|
7
|
+
*
|
|
8
|
+
* makeXp(content) -> `div.xp > div.xi > content`, the collapsed panel
|
|
9
|
+
* attachExpand(row, xp, opts) -> { open(), close(), toggle(), isOpen(), detach() }
|
|
10
|
+
* opts.key a name for the row (state and grouping)
|
|
11
|
+
* opts.group an expandGroup(); with single:true opening this row closes the others in it
|
|
12
|
+
* opts.onToggle (open, row, xp) called after the state changed
|
|
13
|
+
* opts.initial start open
|
|
14
|
+
* expandGroup({ single }) -> a group (default single:false, as the mockup)
|
|
15
|
+
*
|
|
16
|
+
* Keyboard: Enter or Space on the row itself toggles (the mockup binds Enter on a focused row, line 2200). aria-expanded and
|
|
17
|
+
* aria-controls are kept on the row. A closed panel is `visibility:hidden` in console3.css (`.xp:not(.open)>.xi`), so its
|
|
18
|
+
* buttons are out of the tab order. Pure parts (Node-testable): initialOpen, toggleOpen, isOpenIn.
|
|
19
|
+
*/
|
|
20
|
+
export const initialOpen = () => ({ open: [] });
|
|
21
|
+
export const isOpenIn = (state, key) => state.open.includes(key);
|
|
22
|
+
|
|
23
|
+
/** Pure state change: toggle `key`; with single:true every other key is closed. Returns a new state. */
|
|
24
|
+
export function toggleOpen(state, key, { single = false, force } = {}) {
|
|
25
|
+
const has = isOpenIn(state, key);
|
|
26
|
+
const want = force === undefined ? !has : !!force;
|
|
27
|
+
if (want === has && !single) return state;
|
|
28
|
+
const rest = single ? [] : state.open.filter((k) => k !== key);
|
|
29
|
+
return { open: want ? [...rest, key] : rest };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function expandGroup({ single = false } = {}) {
|
|
33
|
+
const members = new Map(); // key -> { api }
|
|
34
|
+
let state = initialOpen();
|
|
35
|
+
return {
|
|
36
|
+
single,
|
|
37
|
+
members,
|
|
38
|
+
get state() { return state; },
|
|
39
|
+
set(key, open) {
|
|
40
|
+
state = toggleOpen(state, key, { single, force: open });
|
|
41
|
+
for (const [k, m] of members) m.sync(isOpenIn(state, k));
|
|
42
|
+
},
|
|
43
|
+
isOpen: (key) => isOpenIn(state, key),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let seq = 0;
|
|
48
|
+
export function makeXp(content) {
|
|
49
|
+
const xp = document.createElement('div');
|
|
50
|
+
xp.className = 'xp';
|
|
51
|
+
const xi = document.createElement('div');
|
|
52
|
+
xi.className = 'xi';
|
|
53
|
+
if (content) xi.append(...(Array.isArray(content) ? content : [content]));
|
|
54
|
+
xp.appendChild(xi);
|
|
55
|
+
return xp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const INNER = 'button,a,input,select,textarea,summary,.xp,[data-noexpand]';
|
|
59
|
+
|
|
60
|
+
export function attachExpand(row, xp, opts = {}) {
|
|
61
|
+
const key = opts.key || 'x' + (++seq);
|
|
62
|
+
const group = opts.group || null;
|
|
63
|
+
if (!xp.id) xp.id = 'xp-' + key + '-' + (++seq);
|
|
64
|
+
row.setAttribute('aria-expanded', 'false');
|
|
65
|
+
row.setAttribute('aria-controls', xp.id);
|
|
66
|
+
if (!row.hasAttribute('tabindex')) row.setAttribute('tabindex', '0');
|
|
67
|
+
row.setAttribute('data-expand', key);
|
|
68
|
+
|
|
69
|
+
let on = false;
|
|
70
|
+
const sync = (v) => {
|
|
71
|
+
if (v === on) return;
|
|
72
|
+
on = v;
|
|
73
|
+
xp.classList.toggle('open', on);
|
|
74
|
+
row.setAttribute('aria-expanded', String(on));
|
|
75
|
+
if (opts.onToggle) opts.onToggle(on, row, xp);
|
|
76
|
+
};
|
|
77
|
+
if (group) group.members.set(key, { sync });
|
|
78
|
+
const set = (v) => { if (group) group.set(key, v); else sync(v); };
|
|
79
|
+
const onClick = (e) => {
|
|
80
|
+
const inner = e.target.closest && e.target.closest(INNER);
|
|
81
|
+
if (inner && inner !== row && !inner.contains(row)) return; // only a control INSIDE the row; a row that itself sits in a fold (.xp) still toggles // a control inside the row keeps its own job; the case file (.xp) is read, not folded
|
|
82
|
+
set(!on);
|
|
83
|
+
};
|
|
84
|
+
const onKey = (e) => {
|
|
85
|
+
if (e.target !== row) return;
|
|
86
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(!on); }
|
|
87
|
+
else if (e.key === 'Escape' && on) { e.preventDefault(); e.stopPropagation(); set(false); }
|
|
88
|
+
};
|
|
89
|
+
row.addEventListener('click', onClick);
|
|
90
|
+
row.addEventListener('keydown', onKey);
|
|
91
|
+
if (opts.initial) set(true);
|
|
92
|
+
return {
|
|
93
|
+
open: () => set(true), close: () => set(false), toggle: () => set(!on), isOpen: () => on,
|
|
94
|
+
detach() { row.removeEventListener('click', onClick); row.removeEventListener('keydown', onKey); if (group) group.members.delete(key); },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* pm/c3-init-extras.js — what the five Initiatives sub-views of /c3.html (Roadmap, Breakdown, Tickets, Documents, Signals) share.
|
|
2
|
+
* The mockup (console3.html) draws every sub-view in one frame: the scope pills (All plus each initiative), the hero line `.lh` (icon,
|
|
3
|
+
* headline, sub line, ring), the Calendar and Work links, then two columns: left = the Plan lens and deck, then the view's own cards;
|
|
4
|
+
* right = the view's charts. This file draws that frame once (frame), makes a view out of a render function (makeView), and holds the
|
|
5
|
+
* small drawing helpers the views use (hBars, dayLabel, short). The badges on the sub-view buttons are computed by each view's
|
|
6
|
+
* `badge(ctx)` and drawn by c3-shell.js. Nothing here reads or writes the server. */
|
|
7
|
+
import { el } from './dom.js';
|
|
8
|
+
import { ensureComponentsCss } from './primitives.js';
|
|
9
|
+
import { ensureC3Css, c3ic, c3ring, c3Phone } from './pm-work-view.js';
|
|
10
|
+
import { attachExpand } from './c3-expand.js';
|
|
11
|
+
import { attachSwipe } from './c3-swipe.js';
|
|
12
|
+
import { renderCase } from './c3-case.js';
|
|
13
|
+
import { toast } from './c3-toast.js';
|
|
14
|
+
|
|
15
|
+
export const short = (t, n) => { t = String(t || ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
16
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
17
|
+
/** '2026-10-24' to '24 Oct' (the mockup's dlab); anything else comes back as it is. */
|
|
18
|
+
export const dayLabel = (v) => { const m = /^\d{4}-(\d{2})-(\d{2})/.exec(String(v || '')); return m ? Number(m[2]) + ' ' + MON[Number(m[1]) - 1] : String(v || ''); };
|
|
19
|
+
|
|
20
|
+
/** The mockup's hBars: rows of [label, fraction 0..1, colour, text]. */
|
|
21
|
+
export function hBars(rows) {
|
|
22
|
+
return el('div', { class: 'hbs' }, rows.map((r, i) => el('div', { class: 'hbr', style: '--c:' + r[2] },
|
|
23
|
+
el('div', { class: 'l' }, el('span', null, r[0]), el('i', null, r[3])),
|
|
24
|
+
el('div', { class: 'tk' }, el('u', { style: 'width:' + Math.max(2, Math.min(100, r[1] * 100)) + '%;animation-delay:' + (i * 90) + 'ms' })))));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A card: `<section class="card"><h3>icon Title <span class="dim">sub</span></h3> ...kids</section>` (the mockup's right-column card). */
|
|
28
|
+
export function card(icon, title, sub, ...kids) {
|
|
29
|
+
return el('section', { class: 'card' }, el('h3', null, c3ic(icon), title + (sub ? ' ' : ''), sub ? el('span', { class: 'dim', style: 'text-transform:none;letter-spacing:0' }, sub) : null), ...kids);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A list card: `section.card.sect > .sh2 (h2 icon Title bdg, right note) + ul.list`. */
|
|
33
|
+
export function listCard(icon, title, count, note, rows, attrs) {
|
|
34
|
+
return el('section', { class: 'card sect', ...(attrs || {}) },
|
|
35
|
+
el('div', { class: 'sh2' }, el('h2', null, c3ic(icon, 'sm'), title, count == null ? null : [' ', el('span', { class: 'bdg g' }, String(count))]), note ? el('span', { class: 'dim', style: 'font-size:var(--fs-2)' }, note) : null),
|
|
36
|
+
el('ul', { class: 'list' }, rows));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const emptyRow = (icon, text, attr) => el('li', { class: 'fold', ...(attr || {}) }, c3ic(icon || 'check', 'sm'), text);
|
|
40
|
+
|
|
41
|
+
/** The hero line. head = { icon, h, s, ring: [done, total] }. */
|
|
42
|
+
export function heroLine(head) {
|
|
43
|
+
return el('div', { class: 'lh', 'data-in-hero': '' }, el('span', { class: 'ki' }, c3ic(head.icon)),
|
|
44
|
+
el('div', { style: 'min-width:0' }, el('b', null, head.h), el('div', { class: 'dim' }, head.s)),
|
|
45
|
+
head.ring ? c3ring(head.ring[0], Math.max(1, head.ring[1]), 44, head.ring[0] + '/' + head.ring[1]) : null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The scope pills: All plus the first few initiatives of ctx.rows. Pressing one sets V.scope (an initiative id or null) and draws again. */
|
|
49
|
+
const PILLS = 5;
|
|
50
|
+
export function scopePills(V) {
|
|
51
|
+
const rows = V.ctx.rows || [];
|
|
52
|
+
return el('div', { class: 'chips2', 'data-in-scope': '' },
|
|
53
|
+
el('button', { type: 'button', class: 'pill', 'aria-pressed': String(!V.scope), onClick: () => { V.scope = null; V.render(); } }, 'All'),
|
|
54
|
+
...rows.slice(0, PILLS).map((r) => el('button', { type: 'button', class: 'pill', 'aria-pressed': String(V.scope === r.id), 'data-in-pill': r.id, title: r.title, onClick: () => { V.scope = r.id; V.render(); } }, short(r.title, 14))),
|
|
55
|
+
rows.length > PILLS ? el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, '+' + (rows.length - PILLS) + ' more in the list') : null);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Draw the frame into V.root. spec = { head, left: [nodes], right: [nodes], notice? }. */
|
|
59
|
+
export function frame(V, spec) {
|
|
60
|
+
V.root.replaceChildren(el('div', { class: 'calwrap' },
|
|
61
|
+
el('div', { class: 'calbar' }, scopePills(V)),
|
|
62
|
+
el('div', { class: 'calbody' },
|
|
63
|
+
heroLine(spec.head), V.ctx.links || null,
|
|
64
|
+
spec.notice ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), spec.notice) : null,
|
|
65
|
+
el('div', { class: 'brief sbb' }, el('div', { class: 'bcols' },
|
|
66
|
+
el('div', { class: 'bcol l' }, V.ctx.plan || null, ...(spec.left || [])),
|
|
67
|
+
el('div', { class: 'bcol r' }, ...(spec.right || [])))))));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A view made from { id, init(V), render(V) }. V = { ctx, scope, root, open, data, disposed, render() }. init starts the reads and calls V.render() when they land.
|
|
71
|
+
* Returns { mount(host, ctx), dispose(host) }, the two functions c3-shell.js calls. */
|
|
72
|
+
export function makeView(id, def) {
|
|
73
|
+
const views = new WeakMap();
|
|
74
|
+
function mount(host, ctx) {
|
|
75
|
+
let V = views.get(host);
|
|
76
|
+
if (V && V.ctx.ws !== ctx.ws) { dispose(host); V = null; }
|
|
77
|
+
if (V) { V.ctx = ctx; V.render(); if (host.firstChild !== V.root) host.replaceChildren(V.root); return; }
|
|
78
|
+
ensureComponentsCss();
|
|
79
|
+
ensureC3Css();
|
|
80
|
+
V = { ctx, scope: null, open: {}, data: {}, disposed: false, root: el('div', { class: 'c3', 'data-in-view': id }) };
|
|
81
|
+
V.render = () => { if (!V.disposed) def.render(V); };
|
|
82
|
+
V.stopPhone = c3Phone(V.root);
|
|
83
|
+
views.set(host, V);
|
|
84
|
+
host.replaceChildren(V.root);
|
|
85
|
+
V.render();
|
|
86
|
+
if (def.init) def.init(V);
|
|
87
|
+
}
|
|
88
|
+
function dispose(host) {
|
|
89
|
+
const V = views.get(host);
|
|
90
|
+
if (!V) return;
|
|
91
|
+
V.disposed = true;
|
|
92
|
+
if (V.dispose) V.dispose();
|
|
93
|
+
if (V.stopPhone) V.stopPhone();
|
|
94
|
+
views.delete(host);
|
|
95
|
+
if (V.root.parentNode === host) V.root.remove();
|
|
96
|
+
}
|
|
97
|
+
return { mount, dispose };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ── the row, as the mockup draws it: one object that opens in place, swipes and takes keys (console3.html rows 3763 to 3777, rowAct 2421, 3877) ── */
|
|
101
|
+
|
|
102
|
+
/** A line under the page: a toast, with an Undo for 8 seconds when `undo` is given. */
|
|
103
|
+
export function say(text, undo) {
|
|
104
|
+
toast(undo ? { text, actionLabel: 'Undo', onAction: undo, timeout: 8000 } : { text, timeout: 4000 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const typing = (n) => !!(n && n.closest && n.closest('input,textarea,select,[contenteditable=true]'));
|
|
108
|
+
|
|
109
|
+
/** Wire a list row: tap or Enter opens its case file in place (state kept in V.open[key] over every redraw), swipe and the arrow keys run
|
|
110
|
+
* sw.onRight / sw.onLeft, e and n are the mockup's keys for the same two, j and k walk the rows. sw = { onRight, onLeft, labelRight, labelLeft, iconRight, iconLeft }. */
|
|
111
|
+
export function wireRow(V, li, xp, key, sw) {
|
|
112
|
+
V.open = V.open || {};
|
|
113
|
+
attachExpand(li, xp, { key, initial: !!V.open[key], onToggle: (on) => { V.open[key] = on; } });
|
|
114
|
+
if (sw) attachSwipe(li, sw);
|
|
115
|
+
li.addEventListener('keydown', (e) => {
|
|
116
|
+
if (e.target !== li || e.metaKey || e.ctrlKey || e.altKey || typing(e.target)) return;
|
|
117
|
+
if (e.key === 'j' || e.key === 'k') {
|
|
118
|
+
const rows = [...V.root.querySelectorAll('li[data-expand]')].filter((r) => r.offsetWidth > 0);
|
|
119
|
+
const at = rows.indexOf(li); const to = rows[at + (e.key === 'j' ? 1 : -1)];
|
|
120
|
+
if (to) { e.preventDefault(); to.focus(); }
|
|
121
|
+
} else if (sw && e.key === 'e' && sw.onRight) { e.preventDefault(); sw.onRight(li, 'right'); }
|
|
122
|
+
else if (sw && e.key === 'n' && sw.onLeft) { e.preventDefault(); sw.onLeft(li, 'left'); }
|
|
123
|
+
});
|
|
124
|
+
return li;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The case file of a row: the shared primitive, bound to the view's state. `model` is the mockup's case ({ sit, conf, chg, opts, cta, unsure, ign, go }).
|
|
128
|
+
* The picked option is kept in V.pick[key]; once the PM has slid, V.done[key] = { label, undo } draws the result and an undo chip instead. */
|
|
129
|
+
export function caseNode(V, key, model, { onConfirm, onGo, outbox } = {}) {
|
|
130
|
+
V.pick = V.pick || {}; V.done = V.done || {};
|
|
131
|
+
const m = { ...model, id: key };
|
|
132
|
+
const d = V.done[key];
|
|
133
|
+
if (d) {
|
|
134
|
+
return el('div', { class: 'case', 'data-case': key, 'data-case-done': '', style: '--tc:var(--acc)' }, el('p', { class: 'sit' }, m.sit || ''),
|
|
135
|
+
el('div', { class: 'chips2' }, el('span', { class: 'st ok' }, c3ic('check', 'sm'), d.label),
|
|
136
|
+
d.undo ? el('button', { type: 'button', class: 'chip2 lk', 'data-case-undo': key, onclick: async () => { delete V.done[key]; try { await d.undo(); } catch { /* the caller reports its own errors */ } V.render(); } }, c3ic('undo', 'sm'), 'undo') : null));
|
|
137
|
+
}
|
|
138
|
+
return renderCase(m, { picked: V.pick[key], onPick: (id) => { V.pick[key] = id; }, onConfirm: (r) => { if (onConfirm) onConfirm(r.option, r); }, onGo, outbox: !!outbox });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Mark a case as done: the slide has been used. `undo` (optional) is what the undo chip runs. */
|
|
142
|
+
export function markDone(V, key, label, undo) { V.done = V.done || {}; V.done[key] = { label, undo }; V.render(); }
|
|
143
|
+
|
|
144
|
+
/** A list row skeleton: `li.nrow` with the kind glyph, the title (a span, so a press or a drag on it reaches the row), the chips and the case panel inside `.nb`. */
|
|
145
|
+
export function rowShell(V, { key, attrs, icon, iconColor, title, titleNode, chips, xpContent, sw, hint }) {
|
|
146
|
+
const li = el('li', { class: 'nrow', 'data-row': '', tabindex: '0', ...(attrs || {}) });
|
|
147
|
+
const xp = el('div', { class: 'xp' }, el('div', { class: 'xi' }, xpContent));
|
|
148
|
+
li.append(...[el('span', { class: 'ki', style: iconColor ? 'color:' + iconColor : null }, c3ic(icon || 'check')),
|
|
149
|
+
el('div', { class: 'nb' }, titleNode || el('span', { class: 'tt' }, title), el('div', { class: 'chips' }, (chips || []).filter(Boolean)), xp),
|
|
150
|
+
hint === false ? null : el('span', { class: 'sg', title: (sw && sw.labelRight ? 'Drag right: ' + sw.labelRight + '. ' : '') + (sw && sw.labelLeft ? 'Drag left: ' + sw.labelLeft + '.' : '') },
|
|
151
|
+
el('span', { class: 'g l' }, c3ic('moon')), el('span', { class: 'g r y' }, c3ic('check')))].filter(Boolean));
|
|
152
|
+
wireRow(V, li, xp, key, sw);
|
|
153
|
+
return li;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The mockup's `goinit` (console3.html line 2030): leave for the Initiatives tab with that initiative chosen. Presses the page's own tab, the Lifecycle button and the initiative's
|
|
157
|
+
* scope pill (the rows are read after the tab opens, so the pill is looked for for up to two seconds). A page without the shell finds no tab and does nothing. */
|
|
158
|
+
export function goInitiative(id) {
|
|
159
|
+
const tab = document.querySelector('[data-c3-tab="init"]'); if (!tab) return false;
|
|
160
|
+
tab.click();
|
|
161
|
+
const lc = document.querySelector('[data-c3-init="lifecycle"]'); if (lc) lc.click();
|
|
162
|
+
let n = 0;
|
|
163
|
+
const find = () => {
|
|
164
|
+
const p = [...document.querySelectorAll('[data-lc-pill]')].find((b) => b.getAttribute('data-lc-pill') === id);
|
|
165
|
+
if (p) { p.click(); return; }
|
|
166
|
+
if (++n < 20) setTimeout(find, 100);
|
|
167
|
+
};
|
|
168
|
+
setTimeout(find, 60);
|
|
169
|
+
return true;
|
|
170
|
+
}
|