create-pmos 0.127.0 → 0.128.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 +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/c3-reports-test.js +314 -0
- package/workspace/fixtures/one-home-baseline.json +29 -9
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/reports-dashboards.md +57 -0
- package/workspace/lib/report-dashboards.js +194 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +108 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-cal-strategy.js +2 -1
- package/workspace/public/pm/c3-demo-rep3.js +97 -0
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-reports.css +62 -0
- package/workspace/public/pm/c3-reports.js +454 -0
- package/workspace/public/pm/c3-shell.js +3 -1
- package/workspace/test-lists/console3-reports.json +6 -0
- package/workspace/test-lists/local-test-kit.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.128.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T03:06:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T03:06:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T03:06:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* c3-reports-test.js — the console3 Reports dashboards in a browser, demo mode (initiative console3-reports, PRD R1 to R7).
|
|
4
|
+
*
|
|
5
|
+
* WHERE. The suite first loads /c3.html?demo=1&tab=rep. When the shell mounts the dashboards there ([data-rp3] in the Reports pane),
|
|
6
|
+
* it runs against the shell. Until c3-shell.js has the mount line, it serves a small harness page from this process (page.route, no
|
|
7
|
+
* file in public/) that installs demo mode and mounts mountReports with the same ctx the shell passes ({ ws, pmGet, drafts }, drafts
|
|
8
|
+
* doing what repTab does). The suite prints which one it used.
|
|
9
|
+
*
|
|
10
|
+
* tabs Dashboards, Library and Drafts; Dashboards first
|
|
11
|
+
* scopes Portfolio draws its seven default reports from the Calendar's builders; Board and Initiative show a chooser, and a
|
|
12
|
+
* report shows only the rows of the chosen board or initiative; a scope's add list holds only reports that fit it
|
|
13
|
+
* cache the data route is read once per scope (switching back reads nothing)
|
|
14
|
+
* edit up and down, Remove with Undo, Add from the report library, Reset to default; each change POSTs { key, reports }
|
|
15
|
+
* and a failed POST is toasted while the page keeps the change
|
|
16
|
+
* reload a stored layout is there after a reload
|
|
17
|
+
* share the frame (Headline, Sections, Needs your call, Sources) for Leadership and for Team; Save as draft POSTs ONE draft;
|
|
18
|
+
* the schedule control is disabled and says why
|
|
19
|
+
* library one row per report with data-home, never on the dashboard; a widget carries data-ref-row and opens its case in place
|
|
20
|
+
* drafts the Drafts tab shows the report families
|
|
21
|
+
* phone 400px: one column, no horizontal page scroll
|
|
22
|
+
* errors no script error and no console error
|
|
23
|
+
*/
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { spawn } = require('child_process');
|
|
26
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
27
|
+
const T = require('./test-run').configure({ name: 'c3-reports' });
|
|
28
|
+
|
|
29
|
+
const BASE = T.BASE;
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
const until = async (fn, ms = 10000, step = 100) => {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); }
|
|
34
|
+
};
|
|
35
|
+
const results = [];
|
|
36
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
|
|
37
|
+
let srv = null; let browser = null;
|
|
38
|
+
const finish = async (code) => {
|
|
39
|
+
if (browser) await browser.close().catch(() => {});
|
|
40
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
41
|
+
T.killTmux();
|
|
42
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
43
|
+
console.log(`\n${results.length - bad}/${results.length} c3-reports assertions passed`);
|
|
44
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
45
|
+
};
|
|
46
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
47
|
+
|
|
48
|
+
const HARNESS = '/__c3-reports-harness.html';
|
|
49
|
+
const HARNESS_HTML = `<!doctype html><html lang="en" data-pm-page-theme="light"><head><meta charset="utf-8" />
|
|
50
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" /><meta name="pmos-nonce" content="" />
|
|
51
|
+
<title>Reports harness</title><link rel="stylesheet" href="/pm/tokens.css" />
|
|
52
|
+
<style>html,body{margin:0;background:var(--background);color:var(--foreground)} #c3-root{padding:16px}</style></head>
|
|
53
|
+
<body><div id="c3-root" class="c3 c3pg" data-pm-theme="light"><div data-c3-pane="rep" id="host"></div></div>
|
|
54
|
+
<script type="module">
|
|
55
|
+
const demo = await import('/pm/c3-demo.js');
|
|
56
|
+
demo.installDemo(); if (window.__c3demoReady) await window.__c3demoReady;
|
|
57
|
+
const { el } = await import('/pm/dom.js');
|
|
58
|
+
const { ensureC3Css, c3Phone } = await import('/pm/pm-work-view.js');
|
|
59
|
+
const { pmGet } = await import('/pm/http.js');
|
|
60
|
+
const { mountReports } = await import('/pm/c3-reports.js');
|
|
61
|
+
const { mountReportFamilies } = await import('/pm/report-families-view.js');
|
|
62
|
+
const { mountOutbox } = await import('/pm/outbox-panel.js');
|
|
63
|
+
ensureC3Css(); c3Phone(document.getElementById('c3-root'));
|
|
64
|
+
const ws = demo.DEMO_WS;
|
|
65
|
+
const drafts = (pane) => {
|
|
66
|
+
const fam = el('div', { 'data-c3pg': 'families-host' }); const out = el('div', { class: 'brief', 'data-c3pg': 'outbox-host' });
|
|
67
|
+
pane.append(fam, out);
|
|
68
|
+
pmGet('/api/report-families/facts?ws=' + encodeURIComponent(ws)).then((j) => {
|
|
69
|
+
mountReportFamilies(fam, { reportFacts: j && j.ok ? j.facts : {}, plan: null, portfolio: null });
|
|
70
|
+
mountOutbox(out, {});
|
|
71
|
+
const sync = () => { out.hidden = !fam.querySelector('[data-family="communications"][aria-pressed="true"]'); };
|
|
72
|
+
sync(); new MutationObserver(sync).observe(fam, { subtree: true, attributes: true, attributeFilter: ['aria-pressed'] });
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
mountReports(document.getElementById('host'), { ws, pmGet, drafts });
|
|
76
|
+
document.getElementById('c3-root').setAttribute('data-c3-demo', '1');
|
|
77
|
+
</script></body></html>`;
|
|
78
|
+
|
|
79
|
+
const D = '[data-rp3-pane="dash"]';
|
|
80
|
+
const order = (page) => page.$$eval(D + ' [data-rp3-w]', (a) => a.map((n) => n.getAttribute('data-rp3-w')));
|
|
81
|
+
const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'maturity'];
|
|
82
|
+
|
|
83
|
+
(async () => {
|
|
84
|
+
const exe = findBrowser();
|
|
85
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-reports suite cannot run'); return finish(1); }
|
|
86
|
+
let chromium;
|
|
87
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-reports suite cannot run'); return finish(1); }
|
|
88
|
+
|
|
89
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
90
|
+
await T.waitForServer(BASE);
|
|
91
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
92
|
+
|
|
93
|
+
const errors = [];
|
|
94
|
+
const watch = (page) => {
|
|
95
|
+
page.on('pageerror', (e) => errors.push('pageerror: ' + String((e && e.message) || e)));
|
|
96
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
|
97
|
+
page.on('dialog', (d) => d.dismiss().catch(() => {}));
|
|
98
|
+
};
|
|
99
|
+
/* the shell when it mounts the dashboards, else the harness (see the header) */
|
|
100
|
+
let mode = null;
|
|
101
|
+
const open = async (page) => {
|
|
102
|
+
await page.route(BASE + HARNESS, (r) => r.fulfill({ status: 200, contentType: 'text/html', body: HARNESS_HTML }));
|
|
103
|
+
if (mode !== 'harness') {
|
|
104
|
+
await page.goto(BASE + '/c3.html?demo=1&tab=rep');
|
|
105
|
+
const inShell = await until(() => page.$('[data-c3-pane="rep"] [data-rp3]'), mode === 'shell' ? 15000 : 4000);
|
|
106
|
+
if (inShell) { mode = 'shell'; return true; }
|
|
107
|
+
}
|
|
108
|
+
mode = 'harness';
|
|
109
|
+
await page.goto(BASE + HARNESS);
|
|
110
|
+
return !!(await until(() => page.$('[data-c3-pane="rep"] [data-rp3]'), 15000));
|
|
111
|
+
};
|
|
112
|
+
const loaded = (page, n) => until(() => page.$$eval(D + ' [data-rp3-w]', (a, k) => a.length === k && a.every((w) => w.querySelector('section.card') && !w.querySelector('[data-rp3-loading]')), n));
|
|
113
|
+
/* counts this page's reads of the data route and its POSTs, from the outermost fetch wrapper (demo answers never reach the network) */
|
|
114
|
+
const spy = (page) => page.evaluate(() => {
|
|
115
|
+
if (window.__rp3spy) { window.__rp3spy.reads = []; window.__rp3spy.posts = []; return; }
|
|
116
|
+
const S = window.__rp3spy = { reads: [], posts: [], fail: null };
|
|
117
|
+
const f = window.fetch;
|
|
118
|
+
window.fetch = (input, init) => {
|
|
119
|
+
const u = new URL(typeof input === 'string' ? input : input.url, location.origin);
|
|
120
|
+
const m = String((init && init.method) || 'GET').toUpperCase();
|
|
121
|
+
if (m === 'GET' && u.pathname === '/api/report-dashboards/data') S.reads.push(u.searchParams.get('scope') + '|' + (u.searchParams.get('id') || ''));
|
|
122
|
+
if (m === 'POST') { let b = null; try { b = JSON.parse(init.body); } catch { b = null; } S.posts.push({ path: u.pathname, body: b }); if (S.fail && u.pathname === S.fail) return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'the store is read-only' }), { status: 500, headers: { 'content-type': 'application/json' } })); }
|
|
123
|
+
return f(input, init);
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
const posts = (page, p) => page.evaluate((x) => window.__rp3spy.posts.filter((q) => q.path === x), p);
|
|
127
|
+
|
|
128
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
129
|
+
const page = await ctx.newPage();
|
|
130
|
+
watch(page);
|
|
131
|
+
const up = await open(page);
|
|
132
|
+
ok('the Reports dashboards come up in demo mode (' + mode + ')', up);
|
|
133
|
+
if (!up) return finish(1);
|
|
134
|
+
|
|
135
|
+
/* ── tabs ── */
|
|
136
|
+
const tabs = await page.$$eval('[data-rp3] [role="tab"]', (a) => a.map((b) => [b.getAttribute('data-rp3-tab'), b.textContent.trim(), b.getAttribute('aria-selected')]));
|
|
137
|
+
ok('three tabs: Dashboards, Library, Drafts, with Dashboards selected', tabs.length === 3 && tabs[0][0] === 'dash' && /^Dashboards/.test(tabs[0][1]) && tabs[0][2] === 'true' && /^Library/.test(tabs[1][1]) && /^Drafts/.test(tabs[2][1]), JSON.stringify(tabs));
|
|
138
|
+
|
|
139
|
+
/* ── portfolio ── */
|
|
140
|
+
ok('Portfolio draws its seven default reports, each a builder card', !!(await loaded(page, 7)), JSON.stringify(await order(page)));
|
|
141
|
+
ok('the portfolio order is the default order', JSON.stringify(await order(page)) === JSON.stringify(DEF_P), JSON.stringify(await order(page)));
|
|
142
|
+
const kinds = await page.$$eval(D + ' [data-rp3-w]', (a) => a.map((w) => { const c = w.querySelector('section.card'); return w.getAttribute('data-rp3-w') + ':' + (c && c.getAttribute('data-cs')); }));
|
|
143
|
+
ok('each widget is the Calendar builder of its report (data-cs matches, delivery included)', kinds.every((k) => { const [a, b] = k.split(':'); return a === b; }), JSON.stringify(kinds));
|
|
144
|
+
ok('no widget failed to draw', !(await page.$(D + ' [data-rp3-fail]')));
|
|
145
|
+
ok('the dashboard says it shows the default layout', /7 reports, the default layout/.test(await page.textContent(D + ' [data-rp3-count]')));
|
|
146
|
+
const pRows = await page.$$eval(D + ' [data-rp3-w="delivery"] [data-cs-row]', (a) => a.map((g) => g.getAttribute('data-cs-row')));
|
|
147
|
+
ok('portfolio delivery holds initiatives and tasks', pRows.includes('checkout') && pRows.includes('sso') && pRows.some((r) => r.startsWith('t-')), JSON.stringify(pRows));
|
|
148
|
+
ok('no scope chooser on Portfolio', !(await page.$(D + ' [data-rp3-pick]')));
|
|
149
|
+
|
|
150
|
+
/* ── scopes, the chooser, and one read per scope ── */
|
|
151
|
+
await spy(page);
|
|
152
|
+
await page.click(D + ' [data-rp3-scope="board"]');
|
|
153
|
+
ok('Board shows a chooser of the boards', !!(await until(async () => (await page.$$eval(D + ' [data-rp3-pick="board"] option', (a) => a.map((o) => o.value))).join(',') === 'shop,enterprise')));
|
|
154
|
+
ok('Board draws its five default reports', !!(await loaded(page, 5)), JSON.stringify(await order(page)));
|
|
155
|
+
const bRows = await page.$$eval(D + ' [data-rp3-w="delivery"] [data-cs-row]', (a) => a.map((g) => g.getAttribute('data-cs-row')));
|
|
156
|
+
ok('Board Shop: delivery shows only the Shop initiatives', bRows.length > 0 && bRows.every((r) => ['checkout', 'search', 'returns', 'onb'].includes(r)), JSON.stringify(bRows));
|
|
157
|
+
ok('the title names the board', /Board: Shop/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
158
|
+
await page.selectOption(D + ' [data-rp3-pick="board"]', 'enterprise');
|
|
159
|
+
await until(async () => /Enterprise/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
160
|
+
await loaded(page, 5);
|
|
161
|
+
const eRows = await page.$$eval(D + ' [data-rp3-w="delivery"] [data-cs-row]', (a) => a.map((g) => g.getAttribute('data-cs-row')));
|
|
162
|
+
ok('Board Enterprise: delivery shows only SSO', JSON.stringify(eRows) === '["sso"]', JSON.stringify(eRows));
|
|
163
|
+
await page.click(D + ' [data-rp3-scope="initiative"]');
|
|
164
|
+
ok('Initiative shows a chooser of the five initiatives', !!(await until(async () => (await page.$$(D + ' [data-rp3-pick="initiative"] option')).length === 5)));
|
|
165
|
+
await page.selectOption(D + ' [data-rp3-pick="initiative"]', 'sso');
|
|
166
|
+
await until(async () => /Initiative: SSO/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
167
|
+
ok('Initiative draws its four default reports', !!(await loaded(page, 4)), JSON.stringify(await order(page)));
|
|
168
|
+
const sigs = await page.$$eval(D + ' [data-rp3-w="signals"] [data-cs-sig]', (a) => a.map((g) => g.getAttribute('data-cs-sig')).sort());
|
|
169
|
+
ok('Initiative SSO: early signals are only the ones about SSO', JSON.stringify(sigs) === '["dep","ent"]', JSON.stringify(sigs));
|
|
170
|
+
await page.selectOption(D + ' [data-rp3-pick="initiative"]', 'onb');
|
|
171
|
+
await until(async () => /onboarding/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
172
|
+
await loaded(page, 4);
|
|
173
|
+
ok('a scope with nothing for a report draws that report\'s own empty card', !!(await page.$(D + ' [data-rp3-w="signals"] [data-cs-empty="true"]')));
|
|
174
|
+
await page.click(D + ' [data-rp3-scope="portfolio"]'); await loaded(page, 7);
|
|
175
|
+
await page.click(D + ' [data-rp3-scope="board"]'); await loaded(page, 5);
|
|
176
|
+
const reads = await page.evaluate(() => window.__rp3spy.reads);
|
|
177
|
+
ok('the data route is read once per scope and id, and not again within the minute', JSON.stringify(reads) === JSON.stringify(['board|shop', 'board|enterprise', 'initiative|checkout', 'initiative|sso', 'initiative|onb']), JSON.stringify(reads));
|
|
178
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
179
|
+
const freeB = await page.$$eval(D + ' [data-rp3-free]', (a) => a.map((n) => n.getAttribute('data-rp3-free')));
|
|
180
|
+
ok('the Board add list holds only reports that fit a board (no north star, no maturity)', !freeB.includes('north') && !freeB.includes('maturity'), JSON.stringify(freeB));
|
|
181
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
182
|
+
await page.click(D + ' [data-rp3-scope="portfolio"]'); await loaded(page, 7);
|
|
183
|
+
|
|
184
|
+
/* ── edit ── */
|
|
185
|
+
await spy(page);
|
|
186
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
187
|
+
ok('Edit dashboard turns edit mode on, with arrows and Remove on each widget', await page.getAttribute(D + ' [data-rp3-edit]', 'aria-pressed') === 'true' && (await page.$$(D + ' [data-rp3-rm]')).length === 7 && (await page.$$(D + ' [data-rp3-up]')).length === 7);
|
|
188
|
+
ok('the first widget cannot move up, the last cannot move down', await page.$eval(D + ' [data-rp3-up="north"]', (b) => b.disabled) && await page.$eval(D + ' [data-rp3-down="maturity"]', (b) => b.disabled));
|
|
189
|
+
ok('Reset to default is off while the scope shows its default', await page.$eval(D + ' [data-rp3-reset]', (b) => b.disabled));
|
|
190
|
+
await page.click(D + ' [data-rp3-up="outcomes"]');
|
|
191
|
+
ok('up moves a report up', !!(await until(async () => JSON.stringify(await order(page)) === JSON.stringify(['outcomes', 'north', 'signals', 'delivery', 'bets', 'synergy', 'maturity']))), JSON.stringify(await order(page)));
|
|
192
|
+
let P = await posts(page, '/api/report-dashboards/layout');
|
|
193
|
+
ok('the move POSTs { key: portfolio, reports } with the new order', P.length === 1 && P[0].body.key === 'portfolio' && JSON.stringify(P[0].body.reports) === JSON.stringify(['outcomes', 'north', 'signals', 'delivery', 'bets', 'synergy', 'maturity']), JSON.stringify(P));
|
|
194
|
+
await page.click(D + ' [data-rp3-down="north"]');
|
|
195
|
+
ok('down moves a report down', !!(await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity')), JSON.stringify(await order(page)));
|
|
196
|
+
ok('the dashboard now says it shows your layout', /your layout/.test(await page.textContent(D + ' [data-rp3-count]')));
|
|
197
|
+
await page.click(D + ' [data-rp3-rm="signals"]');
|
|
198
|
+
ok('Remove takes the report off the dashboard', !!(await until(async () => !(await order(page)).includes('signals'))));
|
|
199
|
+
const tt = await until(() => page.$('[data-c3-toast]'));
|
|
200
|
+
ok('Remove shows a toast that says the report stays in the library, with Undo', !!tt && /stays in the library/.test(await tt.textContent()) && !!(await tt.$('[data-c3-toast-action]')));
|
|
201
|
+
ok('the removed report is offered under Add from the report library', !!(await until(() => page.$(D + ' [data-rp3-add="signals"]'))));
|
|
202
|
+
await page.click('[data-c3-toast-action]');
|
|
203
|
+
ok('Undo puts the report back where it was', !!(await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity')), JSON.stringify(await order(page)));
|
|
204
|
+
await page.click(D + ' [data-rp3-rm="maturity"]');
|
|
205
|
+
await until(async () => !(await order(page)).includes('maturity'));
|
|
206
|
+
await page.click(D + ' [data-rp3-add="maturity"]');
|
|
207
|
+
ok('Add puts a library report at the end of the dashboard', !!(await until(async () => (await order(page)).slice(-1)[0] === 'maturity')));
|
|
208
|
+
P = await posts(page, '/api/report-dashboards/layout');
|
|
209
|
+
ok('every change POSTed its layout (move, move, remove, undo, remove, add)', P.length === 6 && P.every((q) => q.body.key === 'portfolio' && Array.isArray(q.body.reports)), String(P.length));
|
|
210
|
+
ok('Reset to default is on once the scope has its own layout', !(await page.$eval(D + ' [data-rp3-reset]', (b) => b.disabled)));
|
|
211
|
+
// a failed POST: the page keeps the change and says why
|
|
212
|
+
await page.evaluate(() => { window.__rp3spy.fail = '/api/report-dashboards/layout'; });
|
|
213
|
+
await page.click(D + ' [data-rp3-down="outcomes"]');
|
|
214
|
+
ok('a failed POST keeps the change on the page', !!(await until(async () => (await order(page)).join(',') === 'signals,outcomes,north,delivery,bets,synergy,maturity')), JSON.stringify(await order(page)));
|
|
215
|
+
ok('a failed POST is toasted with its reason', !!(await until(async () => { const t = await page.$('[data-c3-toast]'); return t && /not saved: the store is read-only/.test(await t.textContent()); })));
|
|
216
|
+
await page.evaluate(() => { window.__rp3spy.fail = null; });
|
|
217
|
+
await page.click(D + ' [data-rp3-up="outcomes"]');
|
|
218
|
+
await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity');
|
|
219
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
220
|
+
ok('Done editing leaves edit mode', await page.getAttribute(D + ' [data-rp3-edit]', 'aria-pressed') === 'false' && !(await page.$(D + ' [data-rp3-rm]')));
|
|
221
|
+
|
|
222
|
+
/* ── the layout survives a reload ── */
|
|
223
|
+
await open(page); await loaded(page, 7);
|
|
224
|
+
ok('a stored layout is there after a reload', (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity', JSON.stringify(await order(page)));
|
|
225
|
+
await spy(page);
|
|
226
|
+
await page.click(D + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-reset]');
|
|
227
|
+
ok('Reset to default returns the default order', !!(await until(async () => JSON.stringify(await order(page)) === JSON.stringify(DEF_P))), JSON.stringify(await order(page)));
|
|
228
|
+
P = await posts(page, '/api/report-dashboards/layout');
|
|
229
|
+
ok('Reset POSTs reports: null', P.length === 1 && P[0].body.reports === null && P[0].body.key === 'portfolio', JSON.stringify(P));
|
|
230
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
231
|
+
// a board layout is kept per key
|
|
232
|
+
await page.click(D + ' [data-rp3-scope="board"]'); await loaded(page, 5);
|
|
233
|
+
await page.click(D + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-rm="synergy"]'); await until(async () => (await order(page)).length === 4);
|
|
234
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
235
|
+
P = await posts(page, '/api/report-dashboards/layout');
|
|
236
|
+
ok('a board layout is POSTed under board:<id>', P.length === 2 && P[1].body.key === 'board:shop', JSON.stringify(P.map((q) => q.body.key)));
|
|
237
|
+
await page.click(D + ' [data-rp3-scope="portfolio"]'); await loaded(page, 7);
|
|
238
|
+
ok('the portfolio keeps its own layout when a board changes', JSON.stringify(await order(page)) === JSON.stringify(DEF_P));
|
|
239
|
+
|
|
240
|
+
/* ── share ── */
|
|
241
|
+
await spy(page);
|
|
242
|
+
await page.click(D + ' [data-rp3-share]');
|
|
243
|
+
const sheet = await until(() => page.$('[data-rp3-sheet] [data-rp3-frame="Headline"]'));
|
|
244
|
+
ok('Share opens a sheet with the report frame', !!sheet);
|
|
245
|
+
const frames = await page.$$eval('[data-rp3-sheet] [data-rp3-frame] h4', (a) => a.map((h) => h.textContent));
|
|
246
|
+
ok('the frame has Headline, Sections, Needs your call, Sources', JSON.stringify(frames) === JSON.stringify(['Headline', 'Sections', 'Needs your call', 'Sources']), JSON.stringify(frames));
|
|
247
|
+
ok('Leadership: one sentence per report, no numbers', await page.$eval('[data-rp3-sheet] [data-rp3-aud]', (n) => n.getAttribute('data-rp3-aud')) === 'Leadership'
|
|
248
|
+
&& (await page.$$('[data-rp3-sheet] [data-rp3-frame="Sections"] h5')).length === 7 && !(await page.$('[data-rp3-sheet] [data-rp3-nums]')));
|
|
249
|
+
const need = await page.textContent('[data-rp3-sheet] [data-rp3-frame="Needs your call"]');
|
|
250
|
+
ok('Needs your call lists what the reports flag', /Conversion is off course/.test(need) && /Checkout v2 is delayed/.test(need), need.slice(0, 200));
|
|
251
|
+
await page.click('[data-rp3-sheet] [data-rp3-audbtn="Team"]');
|
|
252
|
+
ok('Team adds the numbers behind each sentence', !!(await until(async () => (await page.$$('[data-rp3-sheet] [data-rp3-nums]')).length >= 5)));
|
|
253
|
+
ok('the Team numbers come from the records (Conversion now 3.1%)', /Conversion: now 3\.1%, should be 3\.75%, target 4%/.test(await page.textContent('[data-rp3-sheet] [data-rp3-nums="outcomes"]')));
|
|
254
|
+
const sched = await page.$('[data-rp3-sheet] [data-rp3-sched]');
|
|
255
|
+
ok('the schedule control is disabled', !!sched && await sched.evaluate((s) => s.disabled));
|
|
256
|
+
ok('the schedule says sending on a schedule is not available, and why', /not available yet, because nothing is sent outward/.test(await page.textContent('[data-rp3-sheet] [data-rp3-schedule]')));
|
|
257
|
+
await page.click('[data-rp3-sheet] [data-rp3-draft]');
|
|
258
|
+
ok('Save as draft says one draft is in the outbox and nothing was sent', !!(await until(async () => /one draft in the outbox\. Nothing was sent/.test(await page.textContent('[data-rp3-sheet] [data-rp3-share-msg]')))));
|
|
259
|
+
ok('Save as draft is off once the draft is saved', await page.$eval('[data-rp3-sheet] [data-rp3-draft]', (b) => b.disabled));
|
|
260
|
+
P = await posts(page, '/api/outbox/draft');
|
|
261
|
+
const B = P[0] && P[0].body;
|
|
262
|
+
ok('ONE draft POST, to a known channel, for the chosen audience, its claim lines labelled', P.length === 1 && B.channel === 'customer-note' && B.recipient === 'Team' && /^Portfolio for Team/.test(B.body)
|
|
263
|
+
&& B.body.split('\n').filter((l) => /^- /.test(l)).every((l) => /\[reported\]$/.test(l)), JSON.stringify(B).slice(0, 300));
|
|
264
|
+
ok('nothing else was POSTed from the sheet (no send, no schedule)', (await page.evaluate(() => window.__rp3spy.posts.length)) === 1);
|
|
265
|
+
await page.keyboard.press('Escape');
|
|
266
|
+
ok('Escape closes the sheet', !!(await until(async () => !(await page.$('[data-rp3-sheet]')))));
|
|
267
|
+
|
|
268
|
+
/* ── library and one home ── */
|
|
269
|
+
ok('the dashboard has no data-home row; every widget carries data-ref-row', !(await page.$(D + ' [data-home]')) && (await page.$$(D + ' article[data-ref-row]')).length === 7);
|
|
270
|
+
await page.click(D + ' [data-rp3-about="outcomes"]');
|
|
271
|
+
ok('a widget opens its case in place (question, source, where used)', !!(await until(async () => { const x = await page.$(D + ' [data-rp3-w="outcomes"] .xp.open'); return x && /Used on: Portfolio/.test(await x.textContent()); })));
|
|
272
|
+
await page.click(D + ' [data-rp3-golib="outcomes"]');
|
|
273
|
+
ok('Open in the library goes to the Library tab', !!(await until(async () => await page.getAttribute('[data-rp3-tab="lib"]', 'aria-selected') === 'true')));
|
|
274
|
+
const L = '[data-rp3-pane="lib"]';
|
|
275
|
+
const libRows = await page.$$eval(L + ' [data-home]', (a) => a.map((r) => r.getAttribute('data-home')));
|
|
276
|
+
ok('the library has one row per report, each its home', libRows.length === 7 && new Set(libRows).size === 7, JSON.stringify(libRows));
|
|
277
|
+
ok('library rows are not references', !(await page.$(L + ' [data-ref-row]')));
|
|
278
|
+
const lrow = await page.textContent(L + ' [data-home="maturity"]');
|
|
279
|
+
ok('a library row shows the question, the source and where it is used', /Where does each pillar stand/.test(lrow) && /Pillar maturity records/.test(lrow) && /Used on: Portfolio/.test(lrow), lrow.slice(0, 200));
|
|
280
|
+
ok('the row opened from the widget is open, with its card', !!(await until(() => page.$(L + ' [data-home="outcomes"][aria-expanded="true"] [data-cs="outcomes"]'))));
|
|
281
|
+
await page.click(L + ' [data-home="bets"]');
|
|
282
|
+
ok('a library row opens the report\'s card in place', !!(await until(() => page.$(L + ' [data-home="bets"] .xp.open [data-cs="bets"]'))));
|
|
283
|
+
ok('a board layout shows in where it is used', /Board dashboards by default/.test(await page.textContent(L + ' [data-home="synergy"]')));
|
|
284
|
+
|
|
285
|
+
/* ── drafts ── */
|
|
286
|
+
await page.click('[data-rp3-tab="drafts"]');
|
|
287
|
+
ok('the Drafts tab shows the report families', !!(await until(() => page.$('[data-rp3-pane="drafts"] [data-report-families] [data-family]'), 15000)));
|
|
288
|
+
ok('the dashboards are hidden while Drafts is open', await page.$eval(D, (n) => n.hidden));
|
|
289
|
+
await page.click('[data-rp3-tab="dash"]');
|
|
290
|
+
|
|
291
|
+
/* ── phone ── */
|
|
292
|
+
const ph = await browser.newContext({ viewport: { width: 400, height: 860 }, hasTouch: true, isMobile: true });
|
|
293
|
+
const pp = await ph.newPage(); watch(pp);
|
|
294
|
+
await open(pp); await loaded(pp, 7);
|
|
295
|
+
const noScroll = (pg) => pg.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1);
|
|
296
|
+
ok('400px: no horizontal page scroll on the dashboard', await noScroll(pp), String(await pp.evaluate(() => document.documentElement.scrollWidth)));
|
|
297
|
+
const lefts = await pp.$$eval(D + ' [data-rp3-w]', (a) => [...new Set(a.map((w) => Math.round(w.getBoundingClientRect().left)))]);
|
|
298
|
+
ok('400px: the widgets stack in one column', lefts.length === 1, JSON.stringify(lefts));
|
|
299
|
+
await pp.click(D + ' [data-rp3-scope="initiative"]'); await loaded(pp, 4);
|
|
300
|
+
await pp.click(D + ' [data-rp3-edit]');
|
|
301
|
+
ok('400px: Initiative scope in edit mode has no horizontal scroll', await noScroll(pp));
|
|
302
|
+
await pp.click(D + ' [data-rp3-edit]');
|
|
303
|
+
await pp.click(D + ' [data-rp3-share]'); await until(() => pp.$('[data-rp3-sheet] [data-rp3-frame]'));
|
|
304
|
+
ok('400px: the share sheet has no horizontal scroll', await noScroll(pp));
|
|
305
|
+
await pp.keyboard.press('Escape');
|
|
306
|
+
await pp.click('[data-rp3-tab="lib"]'); await pp.click('[data-rp3-pane="lib"] [data-home="delivery"]');
|
|
307
|
+
await until(() => pp.$('[data-rp3-pane="lib"] [data-home="delivery"] .xp.open [data-cs="delivery"]'));
|
|
308
|
+
ok('400px: the library with an open row has no horizontal scroll', await noScroll(pp));
|
|
309
|
+
await ph.close();
|
|
310
|
+
|
|
311
|
+
await sleep(300); // a late error from a closing view would land here
|
|
312
|
+
ok('no script error and no console error', errors.length === 0, errors.slice(0, 4).join(' | '));
|
|
313
|
+
return finish();
|
|
314
|
+
})();
|
|
@@ -36,6 +36,20 @@
|
|
|
36
36
|
"work"
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
|
+
{
|
|
40
|
+
"title": "cut to 3 flows and hold 24 oct",
|
|
41
|
+
"tabs": [
|
|
42
|
+
"init",
|
|
43
|
+
"rep"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"title": "freeze scope after today's decision",
|
|
48
|
+
"tabs": [
|
|
49
|
+
"init",
|
|
50
|
+
"rep"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
39
53
|
{
|
|
40
54
|
"title": "pick interview slots with 3 customers",
|
|
41
55
|
"tabs": [
|
|
@@ -43,6 +57,13 @@
|
|
|
43
57
|
"work"
|
|
44
58
|
]
|
|
45
59
|
},
|
|
60
|
+
{
|
|
61
|
+
"title": "pull returns forward a quarter if interview 5 agrees",
|
|
62
|
+
"tabs": [
|
|
63
|
+
"init",
|
|
64
|
+
"rep"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
46
67
|
{
|
|
47
68
|
"title": "review the 13:00 interview guide",
|
|
48
69
|
"tabs": [
|
|
@@ -50,6 +71,13 @@
|
|
|
50
71
|
"work"
|
|
51
72
|
]
|
|
52
73
|
},
|
|
74
|
+
{
|
|
75
|
+
"title": "run the address-step experiment, freeze scope",
|
|
76
|
+
"tabs": [
|
|
77
|
+
"init",
|
|
78
|
+
"rep"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
53
81
|
{
|
|
54
82
|
"title": "say yes to sam: ship the logging change",
|
|
55
83
|
"tabs": [
|
|
@@ -68,15 +96,7 @@
|
|
|
68
96
|
"title": "sso is live with 3 customers",
|
|
69
97
|
"tabs": [
|
|
70
98
|
"brief",
|
|
71
|
-
"init"
|
|
72
|
-
"rep"
|
|
73
|
-
]
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
"title": "the board update is 70% ready",
|
|
77
|
-
"tabs": [
|
|
78
|
-
"init",
|
|
79
|
-
"rep"
|
|
99
|
+
"init"
|
|
80
100
|
]
|
|
81
101
|
}
|
|
82
102
|
]
|
package/workspace/guide/index.md
CHANGED
|
@@ -62,3 +62,4 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
62
62
|
- [Board](board.md): one Kanban for the items of every initiative, its filters, Release, and `pmos board`.
|
|
63
63
|
- [Integrated work](integrated-work.md): how `pmos verify-work` turns a merged commit into Done, and what "Done · your call · not merged" means.
|
|
64
64
|
- [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
|
|
65
|
+
- [Reports: dashboards of reports](reports-dashboards.md): the console Reports page, its dashboards at portfolio, board and initiative scope, how to edit and share one as a draft, and the Drafts tab.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Reports: dashboards of reports"
|
|
4
|
+
description: "How the console Reports page shows dashboards at portfolio, board and initiative scope, how to edit one, how to share one as a draft, and where the report drafts went."
|
|
5
|
+
timestamp: 2026-10-11
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Reports: dashboards of reports
|
|
9
|
+
|
|
10
|
+
A report answers one question. A dashboard is a set of reports for one scope. Open the Reports tab of the console to see them.
|
|
11
|
+
|
|
12
|
+
## The three tabs
|
|
13
|
+
|
|
14
|
+
- **Dashboards.** The reports of one scope, drawn from the repository's records.
|
|
15
|
+
- **Library.** Every report the workspace can draw, with the question it answers and where it is used.
|
|
16
|
+
- **Drafts.** The four report families, the claims check and the outbox. They work as before.
|
|
17
|
+
|
|
18
|
+
## Choose a scope
|
|
19
|
+
|
|
20
|
+
Press **Portfolio**, **Board** or **Initiative**.
|
|
21
|
+
|
|
22
|
+
- **Portfolio** covers every initiative.
|
|
23
|
+
- **Board** covers the initiatives of one board. A board is a domain. Choose it from the list beside the scope.
|
|
24
|
+
- **Initiative** covers one initiative. Choose it from the list.
|
|
25
|
+
|
|
26
|
+
A report shows only the rows that belong to the scope. A report with no rows in the scope shows an empty card. The card says what is missing and what fills it. The workspace never invents a number.
|
|
27
|
+
|
|
28
|
+
## The reports
|
|
29
|
+
|
|
30
|
+
| Report | The question it answers |
|
|
31
|
+
|---|---|
|
|
32
|
+
| North star | Is the north-star metric on its plan? |
|
|
33
|
+
| Quarter outcomes | Where is each key result, where should it be, and where will it land? |
|
|
34
|
+
| Early signals | What is drifting before the numbers say so? |
|
|
35
|
+
| Delivery against promise | Which promised dates will hold? |
|
|
36
|
+
| Maturity by pillar | Where does each pillar stand against its target? |
|
|
37
|
+
| Bets and their checkpoints | When does each bet persevere or pivot? |
|
|
38
|
+
| Synergy and coverage | Do the bets add up to the key results? |
|
|
39
|
+
|
|
40
|
+
The Calendar page no longer carries these. They moved here.
|
|
41
|
+
|
|
42
|
+
## Edit a dashboard
|
|
43
|
+
|
|
44
|
+
1. Press **Edit dashboard**.
|
|
45
|
+
2. Use the arrows to move a report. Press **Remove** to take one off. The report stays in the library, and **Undo** brings it back.
|
|
46
|
+
3. Choose a report under **Add from the report library**.
|
|
47
|
+
4. Press **Done editing**.
|
|
48
|
+
|
|
49
|
+
The workspace keeps your layout for each scope in its runtime state. It does not write to the repository. **Reset to default** returns a scope to its first layout.
|
|
50
|
+
|
|
51
|
+
## Share a dashboard
|
|
52
|
+
|
|
53
|
+
Press **Share**. A sheet shows the dashboard as a report: a headline, one section per report, what needs your call, and the sources. Choose **Leadership** for one sentence per report. Choose **Team** to add the numbers behind each sentence.
|
|
54
|
+
|
|
55
|
+
**Save as draft** puts one draft in the outbox. Nothing is sent. With no channel connected, the outbox says so.
|
|
56
|
+
|
|
57
|
+
Scheduled sending is not available yet. The schedule control is off, and the sheet says why.
|