create-pmos 0.255.0 → 0.256.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/briefing-premium-metrics-test.js +186 -0
- package/workspace/guide/briefing-premium.md +2 -1
- package/workspace/lib/board-metrics-premium.js +129 -0
- package/workspace/lib/routes-board-premium.js +25 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/premium-board.js +87 -2
- package/workspace/public/pm/premium.css +55 -0
- package/workspace/public/premium-board.html +6 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/briefing-premium.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.256.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-18T01:25:47Z"}
|
|
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-18T01:25:47Z"}
|
|
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-18T01:25:47Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* briefing-premium-metrics-test.js — the Metrics card of the premium Board (initiative briefing-premium-metrics; planning/prd/briefing-premium-metrics-prd.md M1 to M10).
|
|
4
|
+
* Serves the real page from the real server. The Board's answer and the card's answer are served from fixtures (the card's model is built by
|
|
5
|
+
* lib/board-metrics-premium.js from made-up runs, commits and initiatives); the last blocks ask the real route and compare the card with the mockup.
|
|
6
|
+
* C1 place and absence · C2 tiles · C3 reading a week · C4 dialog and agent · C5 pending and failure · C6 floors · C7 the real route · C8 fidelity */
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawn } = require('child_process');
|
|
10
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
11
|
+
const T = require('./test-run').configure({ name: 'briefing-premium-metrics' });
|
|
12
|
+
const BP = require('./lib/board-premium.js');
|
|
13
|
+
const MP = require('./lib/board-metrics-premium.js');
|
|
14
|
+
const F = require('./fixtures/board-premium-facts.js');
|
|
15
|
+
|
|
16
|
+
const BASE = T.BASE;
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
const until = async (fn, ms = 10000, step = 100) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
|
|
19
|
+
const results = [];
|
|
20
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
|
|
21
|
+
let srv = null; let browser = null;
|
|
22
|
+
const finish = async (code) => {
|
|
23
|
+
if (browser) await browser.close().catch(() => {});
|
|
24
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
25
|
+
T.killTmux();
|
|
26
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
27
|
+
console.log(`\n${results.length - bad}/${results.length} briefing-premium-metrics assertions passed`);
|
|
28
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
29
|
+
};
|
|
30
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
31
|
+
|
|
32
|
+
const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
|
|
33
|
+
const NOW = Date.now(), W = MP.weeksTo(NOW);
|
|
34
|
+
const runsIn = (wk, count, corr) => Array.from({ length: count }, (_, k) => ({ created_at: new Date(W[wk].from + (k + 1) * 60000).toISOString(), human_corrections: k < corr ? 1 : 0 }));
|
|
35
|
+
const I = (id, o) => Object.assign({ id, accepted: false, stopped: false, steps: [], last_activity: new Date(NOW - 3 * 86400000).toISOString() }, o);
|
|
36
|
+
const metrics = (o) => MP.build(Object.assign({ now: NOW, repo: 'idpf', runs: [].concat(runsIn(5, 10, 4), runsIn(6, 12, 3), runsIn(7, 10, 1)), commits: [W[6].from + 1, W[6].from + 2, W[7].from + 1, W[7].from + 2, W[7].from + 3], inits: [I('a'), I('b', { steps: [{ status: 'blocked' }] }), I('c', { last_activity: new Date(NOW - 45 * 86400000).toISOString() })], missing: [] }, o));
|
|
37
|
+
const ready = (model) => ({ status: 200, body: { ok: true, state: 'ready', model, as_of: Date.now() } });
|
|
38
|
+
|
|
39
|
+
(async () => {
|
|
40
|
+
const exe = findBrowser();
|
|
41
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the suite cannot run'); return finish(1); }
|
|
42
|
+
let chromium;
|
|
43
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed'); return finish(1); }
|
|
44
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: path.resolve(__dirname, '..') }) });
|
|
45
|
+
await T.waitForServer(BASE);
|
|
46
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
47
|
+
|
|
48
|
+
const open = async ({ w = 1280, h = 900, theme = 'light', answers = null } = {}) => {
|
|
49
|
+
const ctx = await browser.newContext({ viewport: { width: w, height: h } });
|
|
50
|
+
await ctx.addInitScript(() => { try { localStorage.setItem('pmos.premium.openAs', 'popup'); } catch { /* none */ } });
|
|
51
|
+
const page = await ctx.newPage();
|
|
52
|
+
const errors = [], posts = []; let calls = 0; let thread = { ok: true, turns: [], working: false };
|
|
53
|
+
page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
|
|
54
|
+
await page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: BP.build(F.facts()), as_of: Date.now() }) }));
|
|
55
|
+
await page.route('**/api/board-metrics-premium*', (r) => { calls++; const a = answers ? answers(calls) : ready(metrics()); return r.fulfill({ status: a.status, contentType: 'application/json', body: JSON.stringify(a.body) }); });
|
|
56
|
+
await page.route('**/api/core-docs/**', (r) => r.fulfill({ contentType: 'application/json', body: '{"ok":true}' }));
|
|
57
|
+
await page.route('**/api/briefing-premium/**', (r) => { const rq = r.request(); if (rq.method() === 'GET') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(thread) }); posts.push({ path: new URL(rq.url()).pathname, body: JSON.parse(rq.postData() || '{}') }); return r.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); });
|
|
58
|
+
await page.goto(BASE + '/premium-board.html?theme=' + theme);
|
|
59
|
+
await until(() => page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 20000);
|
|
60
|
+
return { ctx, page, errors, posts, calls: () => calls, setThread: (t) => { thread = t; } };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* C1 */
|
|
64
|
+
const A = await open(), p = A.page;
|
|
65
|
+
const shown = await until(() => p.evaluate(() => !document.querySelector('#cMet').hidden && document.querySelectorAll('#metBody .mt1').length === 4));
|
|
66
|
+
ok('C1 a tenant board draws the Metrics card with four tiles', shown);
|
|
67
|
+
if (!shown) return finish(1);
|
|
68
|
+
const place = await p.evaluate(() => ({ above: document.querySelector('#cMet').getBoundingClientRect().bottom <= document.querySelector('#cIni').getBoundingClientRect().top, cap: document.querySelector('#metCap').textContent, h: document.querySelector('#hMet').textContent }));
|
|
69
|
+
ok('C1 the card sits above the Initiatives card and says which repository it counts', place.above && place.cap === 'counted from idpf' && place.h === 'Metrics', JSON.stringify(place));
|
|
70
|
+
{
|
|
71
|
+
const R = await open({ answers: () => ({ status: 200, body: { ok: true, state: 'none', why: 'no card' } }) });
|
|
72
|
+
await sleep(800);
|
|
73
|
+
ok('C1 the home workspace\'s board draws no card', await R.page.evaluate(() => document.querySelector('#cMet').hidden), '');
|
|
74
|
+
await R.ctx.close();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* C2 */
|
|
78
|
+
const tiles = await p.evaluate(() => [...document.querySelectorAll('#metBody .mt1')].map((t) => ({ l: t.querySelector('span').textContent, v: t.querySelector('.mval').textContent, c: t.querySelector('.mwk').textContent, chart: !!t.querySelector('svg'), cls: t.className, chg: (t.querySelector('.mchg') || {}).textContent || '' })));
|
|
79
|
+
ok('C2 the first tile is the latest week with its run count, and how it changed', tiles[0].l === 'Corrections per 100 runs' && tiles[0].v === '10.0' && /^week to .+, 10 runs$/.test(tiles[0].c) && tiles[0].chg === '▼ Down 15.0 vs last week' && tiles[0].chart && /\bok\b/.test(tiles[0].cls), JSON.stringify(tiles[0]));
|
|
80
|
+
ok('C2 merges, blocked and idle are counted; blocked and idle have no chart and say no history is kept', tiles[1].v === '3' && tiles[1].chart && tiles[2].v === '1' && tiles[2].c === 'of 3 open. No history is kept' && !tiles[2].chart && tiles[3].v === '1' && !tiles[3].chart && /\bbad\b/.test(tiles[2].cls) && /\bwarn\b/.test(tiles[3].cls), JSON.stringify(tiles));
|
|
81
|
+
const per = await p.evaluate(() => [...document.querySelectorAll('#perTabs button')].map((b) => b.textContent + ':' + b.getAttribute('aria-pressed') + ':' + (b.getAttribute('aria-disabled') || '')).join('|'));
|
|
82
|
+
ok('C2 only Week is available; Day and Month are disabled', per === 'Day:false:true|Week:true:|Month:false:true', per);
|
|
83
|
+
ok('C2 no target, on-target chip, dashed line or demo label is drawn', await p.evaluate(() => !document.querySelector('#cMet .mchip, #cMet .tg, #cMet .tgl') && !/target|demo/i.test(document.querySelector('#cMet').textContent)));
|
|
84
|
+
{
|
|
85
|
+
const gap = metrics({ runs: null, commits: null, inits: null });
|
|
86
|
+
const R = await open({ answers: () => ready(gap) });
|
|
87
|
+
const t = await until(() => R.page.evaluate(() => [...document.querySelectorAll('#metBody .mt1')].map((x) => x.querySelector('.mval').textContent + '/' + x.querySelector('.mwk').textContent)));
|
|
88
|
+
ok('C2 a source that cannot be read says Not read and why, with no number and no chart', t.length === 4 && t.every((x) => x.startsWith('Not read/')) && /agent-runs\.jsonl could not be read in idpf/.test(t[0]) && !(await R.page.evaluate(() => document.querySelector('#metBody svg'))), JSON.stringify(t));
|
|
89
|
+
await R.ctx.close();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* C3 */
|
|
93
|
+
const box = await p.locator('#metBody .mch').first().locator('svg').boundingBox();
|
|
94
|
+
const at = async (frac) => { await p.mouse.move(box.x + box.width * frac, box.y + box.height / 2); await sleep(150); return p.evaluate(() => ({ v: document.querySelector('#metBody .mch .mval').textContent, c: document.querySelector('#metBody .mch .mwk').textContent, hv: document.querySelector('#metBody .mch').classList.contains('hv') })); };
|
|
95
|
+
const first = await at(0.02);
|
|
96
|
+
ok('C3 moving over the first week reads "No runs" for a week without enough runs', first.v === 'No runs' && /^Week to /.test(first.c) && first.hv, JSON.stringify(first));
|
|
97
|
+
const mid = await at(0.841);
|
|
98
|
+
ok('C3 moving over a counted week reads its value and run count', /^\d+\.\d$/.test(mid.v) && / · 12 runs$/.test(mid.c), JSON.stringify(mid));
|
|
99
|
+
await p.mouse.move(5, 5); await sleep(200);
|
|
100
|
+
const back = await p.evaluate(() => ({ v: document.querySelector('#metBody .mch .mval').textContent, hv: document.querySelector('#metBody .mch').classList.contains('hv') }));
|
|
101
|
+
ok('C3 leaving the chart returns to the latest week', back.v === '10.0' && !back.hv, JSON.stringify(back));
|
|
102
|
+
await p.locator('#metBody .mch').first().focus(); await p.keyboard.press('ArrowLeft'); await sleep(100);
|
|
103
|
+
ok('C3 the arrow keys read the week before', await p.evaluate(() => document.querySelector('#metBody .mch .mval').textContent === '25.0' || /^\d+\.\d$/.test(document.querySelector('#metBody .mch .mval').textContent)));
|
|
104
|
+
await p.locator('#metBody .mch').first().blur();
|
|
105
|
+
|
|
106
|
+
/* C4 */
|
|
107
|
+
await p.locator('#metBody .mt1').first().click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
108
|
+
const dlg = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, sub: document.querySelector('#dSub').textContent, grid: [...document.querySelectorAll('#oMain .kv2 span')].map((s) => s.textContent).join('|'), vals: document.querySelectorAll('#oMain .vtb div').length, def: document.querySelector('#oMain .lede').textContent, ask: document.querySelector('#askF').hidden }));
|
|
109
|
+
ok('C4 a tile opens its definition, its values by week and the repository it counted, with the read-only agent beside it', dlg.title === 'Corrections per 100 runs' && dlg.sub === 'idpf' && /Now\|10\.0/.test(dlg.grid) && /Better when\|Lower/.test(dlg.grid) && /Source\|idpf/.test(dlg.grid) && dlg.vals === 8 && /per 100 agent runs/.test(dlg.def) && dlg.ask === false, JSON.stringify(dlg));
|
|
110
|
+
ok('C4 the dialog holds no press that records anything', await p.evaluate(() => !document.querySelector('#oMain button, #oMain .dmcta')));
|
|
111
|
+
A.setThread({ ok: true, working: false, turns: [] }); await p.fill('#askIn', 'Why did it fall?'); const n0 = A.posts.length; await p.press('#askIn', 'Enter');
|
|
112
|
+
ok('C4 a question posts the metric\'s key and the text', await until(() => A.posts.length > n0 && A.posts[A.posts.length - 1].path === '/api/briefing-premium/ask' && A.posts[A.posts.length - 1].body.key === 'board:metric-corrections' && A.posts[A.posts.length - 1].body.text === 'Why did it fall?'), JSON.stringify(A.posts.slice(-1)));
|
|
113
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
114
|
+
await p.locator('#metBody .mt1').nth(2).click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(400);
|
|
115
|
+
ok('C4 a tile without a chart opens its definition too', await p.evaluate(() => document.querySelector('#dTitle').textContent === 'Blocked' && /blocked step/.test(document.querySelector('#oMain .lede').textContent) && document.querySelectorAll('#oMain .vtb div').length === 0));
|
|
116
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
117
|
+
await p.click('#perTabs [data-per="day"]', { force: true }); ok('C4 a disabled period says why and changes nothing', await until(() => p.evaluate(() => /by week only/.test(document.querySelector('#toast').textContent))) && await p.evaluate(() => document.querySelector('#perTabs [data-per="week"]').getAttribute('aria-pressed') === 'true'));
|
|
118
|
+
ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
|
|
119
|
+
await A.ctx.close();
|
|
120
|
+
|
|
121
|
+
/* C5 */
|
|
122
|
+
{
|
|
123
|
+
const R = await open({ answers: (c) => (c < 2 ? ready(metrics({ inits: null, initsPending: true })) : ready(metrics())) });
|
|
124
|
+
const idle = () => R.page.evaluate(() => { const t = document.querySelectorAll('#metBody .mt1')[2]; return t ? t.querySelector('.mval').textContent : ''; });
|
|
125
|
+
ok('C5 while the workflow is derived Blocked and Idle say Reading, and the page asks again until they are filled', await until(async () => /^Reading/.test(await idle())) && await until(async () => (await idle()) === '1', 9000) && R.calls() >= 2, String(R.calls()));
|
|
126
|
+
await R.ctx.close();
|
|
127
|
+
const F2 = await open({ answers: () => ({ status: 500, body: { ok: false, state: 'error', error: 'the repository could not be read' } }) });
|
|
128
|
+
ok('C5 a failed read says why and draws no tile', await until(() => F2.page.evaluate(() => !document.querySelector('#cMet').hidden && /The reason: the repository could not be read/.test(document.querySelector('#metHint').textContent) && document.querySelectorAll('#metBody .mt1').length === 0)));
|
|
129
|
+
await F2.ctx.close();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* C6 */
|
|
133
|
+
for (const theme of ['light', 'dark']) {
|
|
134
|
+
const R = await open({ w: 390, h: 844, theme }), q = R.page;
|
|
135
|
+
await until(() => q.evaluate(() => document.querySelectorAll('#metBody .mt1').length === 4));
|
|
136
|
+
const m = await q.evaluate(() => ({ over: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#metBody .mt1')].filter((b) => b.getBoundingClientRect().height < 44).length }));
|
|
137
|
+
ok(`C6 at 390 px (${theme}) the card does not scroll sideways and every tile is 44 px or more`, !m.over && m.small === 0, JSON.stringify(m));
|
|
138
|
+
await R.ctx.close();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* C7 the real route */
|
|
142
|
+
{
|
|
143
|
+
const home = await fetch(BASE + '/api/board-metrics-premium').then((r) => r.json());
|
|
144
|
+
ok('C7 the real route gives the home workspace no card', home.ok === true && home.state === 'none', JSON.stringify(home));
|
|
145
|
+
const post = await fetch(BASE + '/api/board-metrics-premium', { method: 'POST', headers: await T.pressHeaders(BASE), body: '{}' }).then((r) => r.status);
|
|
146
|
+
ok('C7 the route is read-only', post === 405, String(post));
|
|
147
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
|
|
148
|
+
pg.on('pageerror', (e) => errs.push(String(e)));
|
|
149
|
+
await pg.goto(BASE + '/premium-board.html');
|
|
150
|
+
await until(() => pg.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 40000); await sleep(1500);
|
|
151
|
+
ok('C7 the real PMOS board draws no Metrics card and no script error', await pg.evaluate(() => document.querySelector('#cMet').hidden) && errs.length === 0, errs.join(' | '));
|
|
152
|
+
await ctx.close();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* C8 fidelity against the mockup */
|
|
156
|
+
if (!fs.existsSync(MOCKUP)) ok('C8 the mockup is in the repository', false, MOCKUP);
|
|
157
|
+
else {
|
|
158
|
+
const SEL = [['card', '#cMet'], ['heading', '#hMet'], ['hint', '#cMet .mhint'], ['per', '#perTabs'], ['note', '#cMet .note'], ['t1', '#cMet .mt1:nth-child(1)'], ['t1val', '#cMet .mt1:nth-child(1) .mval'], ['t1cap', '#cMet .mt1:nth-child(1) .mwk'], ['t1chg', '#cMet .mt1:nth-child(1) .mchg'], ['t1svg', '#cMet .mt1:nth-child(1) svg'], ['t1line', '#cMet .mt1:nth-child(1) .ln2'], ['t1dot', '#cMet .mt1:nth-child(1) .dt'], ['t1foot', '#cMet .mt1:nth-child(1) .mfoot'], ['t3', '#cMet .mt1:nth-child(3)'], ['t3val', '#cMet .mt1:nth-child(3) .mval'], ['t3cap', '#cMet .mt1:nth-child(3) .mwk'], ['t4', '#cMet .mt1:nth-child(4)'], ['t4lab', '#cMet .mt1:nth-child(4) span']];
|
|
159
|
+
const SKIP_W = new Set(['hint', 'note', 't1val', 't1cap', 't1chg', 't1foot', 't3val', 't3cap', 't4lab', 'heading', 'per']);
|
|
160
|
+
const SKIP_L = new Set(['t1val', 't1cap', 't1chg', 't1foot', 't3val', 't3cap', 't4lab', 'hint', 'note', 'per']);
|
|
161
|
+
const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'stroke', 'strokeWidth', 'fill', 'marginTop'];
|
|
162
|
+
const grab = (pg) => pg.evaluate(([pairs, props]) => { const o = {}; for (const [n, s] of pairs) { const e = document.querySelector('main.stage:not([hidden]) ' + s); if (!e) { o[n] = null; continue; } const cs = getComputedStyle(e), r = e.getBoundingClientRect(), v = { left: Math.round(r.left), width: Math.round(r.width) }; props.forEach((k) => { v[k] = cs[k]; }); o[n] = v; } return o; }, [SEL, PROPS]);
|
|
163
|
+
for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
|
|
164
|
+
const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
|
|
165
|
+
await m.goto('file://' + MOCKUP);
|
|
166
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
|
|
167
|
+
await m.evaluate((t) => { document.documentElement.dataset.theme = t; document.querySelector('.pl[data-p="init"]').click(); }, th);
|
|
168
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#cMet .mt1').length > 0));
|
|
169
|
+
await m.evaluate(() => { drawBoardPage(BOARDS.find((b) => b.real)); }); // the mockup's one board that is read from a repository: four tiles, none with a target
|
|
170
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#cMet .mt1').length === 4 && !!document.querySelector('#cMet .note'))); await sleep(900);
|
|
171
|
+
const R = await open({ w, h, theme: th }); await until(() => R.page.evaluate(() => document.querySelectorAll('#metBody .mt1').length === 4)); await sleep(500);
|
|
172
|
+
const a = await grab(m), c = await grab(R.page), diffs = [];
|
|
173
|
+
for (const [n] of SEL) {
|
|
174
|
+
if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
|
|
175
|
+
for (const k of Object.keys(a[n])) {
|
|
176
|
+
const geom = k === 'left' || k === 'width';
|
|
177
|
+
if (geom && ((k === 'width' && SKIP_W.has(n)) || (k === 'left' && SKIP_L.has(n)))) continue;
|
|
178
|
+
if (geom ? Math.abs(a[n][k] - c[n][k]) > 2 : a[n][k] !== c[n][k]) diffs.push(`${n}.${k} ${a[n][k]} | ${c[n][k]}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
ok(`C8 styles and geometry match the mockup at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
|
|
182
|
+
await m.context().close(); await R.ctx.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return finish();
|
|
186
|
+
})().catch((e) => { console.error(e); finish(1); });
|
|
@@ -142,11 +142,12 @@ Board is the third place on the rail, at `/premium-board.html`. It reads what th
|
|
|
142
142
|
- **By stage** has one column for each stage of the board's workflow with the board's own count and its first five initiatives.
|
|
143
143
|
- **Timeline** draws a bar for each initiative that has both a start and an end date, with the days left or over, and counts the rest. With no dates on record it says so and draws nothing.
|
|
144
144
|
- **Sprint** appears when an agent session has read Jira for the board. Otherwise it says there is none. **Coming up** shows the next release, from the releases ledger.
|
|
145
|
+
- **Metrics** appears above the Initiatives card on the board of any workspace except the home one (PMOS itself), and counts from that workspace's own repository. It has four tiles. **Corrections per 100 runs** is the corrections logged on the week's runs in `planning/runs/agent-runs.jsonl`; a week with fewer than 10 runs is left blank. **Merged a week** counts the commits on the first-parent line of the trunk. **Blocked** and **Idle for a month** come from the workflow engine and keep no history, so they have no chart. Move over a chart, or use the arrow keys, to read a week. A tile that cannot be counted says **Not read** and why. Press a tile for its definition and values, with the read-only agent beside it. There are no targets, and Day and Month are off. It was tested only on made-up repositories, not on a real IDPF or microsite repository.
|
|
145
146
|
- **The crumb** at the top names the board. With more than one board a menu switches between them, and the sparkle opens the board in the dialog with the read-only agent.
|
|
146
147
|
- **Core documents** lists the board's documents with their state, cadence and writer, a summary (drafts to review, not started, up to date), a progress bar, a filter and a search. **Draft it** starts the existing read-only run that writes a draft and sends nothing; **Draft the N not started** does that for each document that can be drafted. **Set it up** and **Open** go to the console. Issue, Drop and Add a document are not on this page.
|
|
147
148
|
- **Opening an initiative.** A click on an initiative opens its page (see "Initiative page"). The **Page / Popup** switch in the header changes that: Popup opens the initiative's dialog instead, which shows its record as a grid and the path of its file, with the read-only agent beside it. The choice is kept in this browser. The dialog holds no press: moving an initiative or starting work on it is not built here.
|
|
148
149
|
|
|
149
|
-
Not drawn, because PMOS holds no record for them: the Metrics
|
|
150
|
+
Not drawn, because PMOS holds no record for them: the Metrics card on the PMOS board, the mockup's demo measures (Activation, Partner uptime, API p95), targets, and the mockup's demo boards.
|
|
150
151
|
|
|
151
152
|
|
|
152
153
|
## Initiative page
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/board-metrics-premium.js — the Metrics card of a tenant's premium Board (initiative briefing-premium-metrics).
|
|
3
|
+
*
|
|
4
|
+
* build(facts) → model PURE. facts = { now, repo, runs[]|null, commits[]|null (ms), inits[]|null, initsPending, missing[] }
|
|
5
|
+
* read({ root, engine, now }) → model the card of one workspace's repository: its run log, its trunk's history, its workflow state.
|
|
6
|
+
* weeksTo(now, n) → [{ from, to, label }] PURE. The n weeks (Monday to Sunday, UTC) that end with the week of `now`.
|
|
7
|
+
*
|
|
8
|
+
* THE RULE THE FILE FOLLOWS: a number is made only from a record of the repository it names. A source that cannot be read is "Not read", a
|
|
9
|
+
* week with too few runs is blank, and no target exists, so no tile is "on target" or "off target". Blocked and Idle keep no history and have no chart. */
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFile } = require('child_process');
|
|
13
|
+
const BP = require('./briefing-premium.js');
|
|
14
|
+
|
|
15
|
+
const WEEKS = 8, MIN_RUNS = 10, DAY = 86400000, IDLE_DAYS = 30;
|
|
16
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
17
|
+
const day = (t) => { const d = new Date(t); return d.getUTCDate() + ' ' + MON[d.getUTCMonth()]; };
|
|
18
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
19
|
+
const startOfWeek = (t) => { const d = new Date(t); const dow = (d.getUTCDay() + 6) % 7; return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - dow); };
|
|
20
|
+
|
|
21
|
+
function weeksTo(now, n = WEEKS) {
|
|
22
|
+
const last = startOfWeek(now), out = [];
|
|
23
|
+
for (let k = n - 1; k >= 0; k--) { const from = last - k * 7 * DAY, to = from + 7 * DAY; out.push({ from, to, label: day(Math.min(to - 1, now)) }); }
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFS = {
|
|
28
|
+
corrections: 'Human corrections per 100 agent runs in the week: the corrections logged on the week\'s runs, divided by those runs. Lower is better. A week with fewer than ' + MIN_RUNS + ' runs is left blank.',
|
|
29
|
+
merges: 'Commits on the first-parent line of the trunk in the week. A merged pull request is one commit there.',
|
|
30
|
+
blocked: 'Open initiatives that the workflow engine reports with a blocked step. It keeps no history.',
|
|
31
|
+
idle: 'Open initiatives with no activity in the last ' + IDLE_DAYS + ' days, or none on record. It keeps no history.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 'Down 12.4 pts vs last week' for two values, the mockup's wording; null when either is missing. */
|
|
35
|
+
function change(prev, cur, dec) {
|
|
36
|
+
if (prev == null || cur == null) return null;
|
|
37
|
+
const d = cur - prev, a = Math.abs(d), txt = a.toFixed(dec);
|
|
38
|
+
return { d, good: d < 0, txt: (+txt === 0 ? 'No change' : (d > 0 ? 'Up ' : 'Down ') + txt) + ' vs last week' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function build(f) {
|
|
42
|
+
const now = f.now || Date.now(), W = weeksTo(now), missing = (f.missing || []).slice();
|
|
43
|
+
const repo = f.repo || '';
|
|
44
|
+
const tiles = [];
|
|
45
|
+
|
|
46
|
+
// corrections per 100 runs
|
|
47
|
+
if (f.runs == null) tiles.push({ key: 'corrections', label: 'Corrections per 100 runs', absent: 'Not read', why: 'planning/runs/agent-runs.jsonl could not be read in ' + repo + '.', def: DEFS.corrections });
|
|
48
|
+
else {
|
|
49
|
+
const per = W.map((w) => {
|
|
50
|
+
const rs = f.runs.filter((r) => { const t = Date.parse(r.created_at); return Number.isFinite(t) && t >= w.from && t < w.to && Number.isFinite(Number(r.human_corrections)); });
|
|
51
|
+
return { n: rs.length, v: rs.length >= MIN_RUNS ? Math.round(rs.reduce((a, r) => a + Number(r.human_corrections), 0) / rs.length * 1000) / 10 : null };
|
|
52
|
+
});
|
|
53
|
+
const ser = per.map((p) => p.v), li = ser.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0).pop();
|
|
54
|
+
if (li == null) tiles.push({ key: 'corrections', label: 'Corrections per 100 runs', absent: 'No weeks yet', why: 'No week of the last ' + WEEKS + ' has ' + MIN_RUNS + ' runs with a logged correction count. ' + f.runs.length + ' ' + plural(f.runs.length, 'run is', 'runs are') + ' on record in all.', def: DEFS.corrections });
|
|
55
|
+
else {
|
|
56
|
+
const ch = li > 0 ? change(ser[li - 1], ser[li], 1) : null;
|
|
57
|
+
tiles.push({ key: 'corrections', label: 'Corrections per 100 runs', value: ser[li].toFixed(1), text: 'week to ' + W[li].label + ', ' + per[li].n + ' runs', kind: ch ? (ch.d === 0 ? '' : ch.good ? 'ok' : 'warn') : '', ser, wk: W.map((w) => w.label), nt: per.map((p) => (p.n ? p.n + ' ' + plural(p.n, 'run', 'runs') : '')), dec: 1, suf: '', change: ch, def: DEFS.corrections, lowBetter: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// merges a week
|
|
62
|
+
if (f.commits == null) tiles.push({ key: 'merges', label: 'Merged a week', absent: 'Not read', why: 'The history of the trunk could not be read in ' + repo + '.', def: DEFS.merges });
|
|
63
|
+
else {
|
|
64
|
+
const ser = W.map((w) => f.commits.filter((t) => t >= w.from && t < w.to).length), li = ser.length - 1;
|
|
65
|
+
tiles.push({ key: 'merges', label: 'Merged a week', value: String(ser[li]), text: 'to the trunk, week to ' + W[li].label, kind: '', ser, wk: W.map((w) => w.label), nt: ser.map(() => ''), dec: 0, suf: '', change: li > 0 ? (() => { const d = ser[li] - ser[li - 1]; return { d, good: d > 0, txt: (d === 0 ? 'No change' : (d > 0 ? 'Up ' : 'Down ') + Math.abs(d)) + ' vs last week' }; })() : null, def: DEFS.merges, lowBetter: false });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// blocked, idle
|
|
69
|
+
const waiting = (key, label, def) => ({ key, label, absent: f.initsPending ? 'Reading' : 'Not read', why: f.initsPending ? 'The workflow engine is deriving the initiatives of ' + repo + '. This tile fills in when it ends.' : 'The workflow engine could not be run in ' + repo + '.', def, pending: !!f.initsPending });
|
|
70
|
+
if (!f.inits) { tiles.push(waiting('blocked', 'Blocked', DEFS.blocked), waiting('idle', 'Idle for a month', DEFS.idle)); }
|
|
71
|
+
else {
|
|
72
|
+
const open = f.inits.filter((i) => !i.accepted && !i.stopped);
|
|
73
|
+
const blocked = open.filter((i) => (i.steps || []).some((s) => s.status === 'blocked')).length;
|
|
74
|
+
const idle = open.filter((i) => { const t = Date.parse(i.last_activity); return !Number.isFinite(t) || now - t > IDLE_DAYS * DAY; }).length;
|
|
75
|
+
tiles.push(
|
|
76
|
+
{ key: 'blocked', label: 'Blocked', value: String(blocked), text: 'of ' + open.length + ' open. No history is kept', kind: blocked ? 'bad' : '', def: DEFS.blocked, lowBetter: true },
|
|
77
|
+
{ key: 'idle', label: 'Idle for a month', value: String(idle), text: 'of ' + open.length + ' open. No history is kept', kind: idle ? 'warn' : '', def: DEFS.idle, lowBetter: true },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// one row per tile for the read-only agent (key board:metric-<tile>), in the shape the Board's own rows have
|
|
82
|
+
const rows = tiles.map((t) => {
|
|
83
|
+
const say = t.absent ? t.label + ' is not shown: ' + t.why : t.label + ' is ' + t.value + ', ' + t.text + '.' + (t.change ? ' ' + t.change.txt + '.' : '');
|
|
84
|
+
const facts = [['Measure', t.label], ['Now', t.absent ? t.absent : t.value], ['Counted from', repo], ['Better when', t.lowBetter ? 'Lower' : t.lowBetter === false ? 'Higher' : '']]
|
|
85
|
+
.concat(t.ser ? t.ser.map((v, j) => [t.wk[j], v == null ? 'No runs' : String(v)]) : []).filter((x) => x[1] !== '');
|
|
86
|
+
t.rowKey = 'board:metric-' + t.key;
|
|
87
|
+
return { key: t.rowKey, id: null, title: t.label, sub: 'Metric · ' + repo, dialog: { say, facts, files: [] } };
|
|
88
|
+
});
|
|
89
|
+
const look = tiles.filter((t) => t.kind === 'warn' || t.kind === 'bad').length;
|
|
90
|
+
return {
|
|
91
|
+
state: 'ready', pending: !!f.initsPending, repo,
|
|
92
|
+
header: missing.length ? 'not read: ' + missing.join(', ') : 'counted from ' + repo,
|
|
93
|
+
hint: (look ? look + ' of ' + tiles.length + ' need a look. ' : '') + 'Each chart is one measure over the last ' + WEEKS + ' weeks. Press a measure for its definition and values. A week with fewer than ' + MIN_RUNS + ' runs is left blank.',
|
|
94
|
+
note: 'Counted from ' + repo + ': corrections from planning/runs/agent-runs.jsonl, merges from the history of its trunk, blocked and idle from its workflow state, in weeks to ' + W[W.length - 1].label + '. A week with fewer than ' + MIN_RUNS + ' runs is blank. Blocked and idle keep no history.',
|
|
95
|
+
tiles, rows,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ── the reader ────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
100
|
+
const git = (args) => new Promise((res) => execFile('git', args, { maxBuffer: 64 * 1024 * 1024, timeout: 60000 }, (e, out) => res(e ? null : String(out))));
|
|
101
|
+
const kept = new Map(); // root → { key, inits, inflight }
|
|
102
|
+
|
|
103
|
+
async function readCommits(root, now) {
|
|
104
|
+
const since = new Date(weeksTo(now)[0].from).toISOString();
|
|
105
|
+
const ref = (await git(['-C', root, 'rev-parse', '--verify', '-q', 'origin/main'])) ? 'origin/main' : 'HEAD';
|
|
106
|
+
const out = await git(['-C', root, 'log', '--first-parent', '--since=' + since, '--format=%ct', ref]);
|
|
107
|
+
return out === null ? null : out.split('\n').filter(Boolean).map((x) => Number(x) * 1000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readRuns(root) {
|
|
111
|
+
try { return fs.readFileSync(path.join(root, 'planning', 'runs', 'agent-runs.jsonl'), 'utf8').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The card of one repository. Runs and history are read at once; the workflow state is slow, so it is derived in the background and kept until the repository changes. */
|
|
115
|
+
async function read({ root, engine, now = Date.now() }) {
|
|
116
|
+
const k = kept.get(root) || { key: null, inits: null, inflight: false }; kept.set(root, k);
|
|
117
|
+
const key = await BP.repoKey(root);
|
|
118
|
+
if (!k.inflight && engine && (!k.inits || k.key !== key)) {
|
|
119
|
+
k.inflight = true;
|
|
120
|
+
BP.readInitiatives({ root, engine }).then((inits) => { if (inits) { k.inits = inits; k.key = key; } }).catch(() => { }).finally(() => { k.inflight = false; });
|
|
121
|
+
}
|
|
122
|
+
const repo = path.basename(root);
|
|
123
|
+
const model = build({ now, repo, runs: readRuns(root), commits: await readCommits(root, now), inits: k.inits, initsPending: !!engine && !k.inits && k.inflight });
|
|
124
|
+
return model;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const forget = () => kept.clear();
|
|
128
|
+
|
|
129
|
+
module.exports = { build, read, weeksTo, forget, MIN_RUNS };
|
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
* this route answers 202 too and the page asks again: no request waits for the read. Read-only: the one press of the page (Draft it) goes to
|
|
8
8
|
* POST /api/core-docs/draft from the page. */
|
|
9
9
|
const BoardPremium = require('./board-premium.js');
|
|
10
|
+
const BoardP = BoardPremium;
|
|
10
11
|
const boards = require('./routes-boards.js');
|
|
11
12
|
const coreDocs = require('./routes-core-docs.js');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const Metrics = require('./board-metrics-premium.js');
|
|
16
|
+
const { ENGINE_PATHS } = require('./console-state.js');
|
|
12
17
|
|
|
13
18
|
/** GET `pathAndQuery` from a route module's own handler, as the Work page's route does, and return { status, body }. */
|
|
14
19
|
async function via(mod, pathAndQuery, req, ctx) {
|
|
@@ -20,9 +25,28 @@ async function via(mod, pathAndQuery, req, ctx) {
|
|
|
20
25
|
}
|
|
21
26
|
const qs = (o) => '?' + Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
22
27
|
|
|
28
|
+
/** GET /api/board-metrics-premium?ws=<id> — the Metrics card of a tenant's Board (initiative briefing-premium-metrics). Read-only, and apart from the Board's own
|
|
29
|
+
* route so the Board never waits for it. The home workspace has no card: { ok, state: 'none' }. */
|
|
30
|
+
async function metrics(req, res, url, ctx) {
|
|
31
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
32
|
+
try {
|
|
33
|
+
const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null);
|
|
34
|
+
if (!w || !w.root) { ctx.json(res, { ok: false, state: 'error', error: 'that workspace is not registered' }, 404); return true; }
|
|
35
|
+
const home = ctx.homeRoot || ctx.selfRoot || null;
|
|
36
|
+
if (home && path.resolve(w.root) === path.resolve(home)) { ctx.json(res, { ok: true, state: 'none', why: 'The board of this repository has no Metrics card.' }); return true; }
|
|
37
|
+
const engine = ENGINE_PATHS.find((p) => fs.existsSync(p)) || null;
|
|
38
|
+
const model = await Metrics.read({ root: w.root, engine, now: Date.now() });
|
|
39
|
+
// the read-only agent finds a tile's row in the Board's model
|
|
40
|
+
const bm = BoardP.peekModel(); if (bm && Array.isArray(bm.rows)) bm.rows = bm.rows.filter((r) => !/^board:metric-/.test(r.key)).concat(model.rows);
|
|
41
|
+
ctx.json(res, { ok: true, state: 'ready', model, as_of: Date.now() });
|
|
42
|
+
} catch (e) { ctx.json(res, { ok: false, state: 'error', error: String((e && e.message) || e).slice(0, 240) }, 500); }
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
module.exports = {
|
|
24
|
-
prefixes: ['/api/board-premium'],
|
|
47
|
+
prefixes: ['/api/board-premium', '/api/board-metrics-premium'],
|
|
25
48
|
handle: async (req, res, url, ctx) => {
|
|
49
|
+
if (url.pathname === '/api/board-metrics-premium') return metrics(req, res, url, ctx);
|
|
26
50
|
if (url.pathname !== '/api/board-premium') return false;
|
|
27
51
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
28
52
|
const fail = (error) => { ctx.json(res, { ok: false, state: 'error', error: String(error).slice(0, 240) }, 500); return true; };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.256.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.256.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Coming up cards, Core documents, the dock and the standard dialog. The one press is Draft it on a core document, through POST /api/core-docs/draft.
|
|
4
4
|
* Nothing here moves an initiative or issues a document. */
|
|
5
5
|
import { pmPost } from './http.js';
|
|
6
|
-
import { $, $$, esc, openDlg, closeDlg, toast, loadThread, onScroll, wireRail, logHtml, turnA, ptx, pillOf, CHECK, store, crumbHtml, wireCrumb } from './premium-shell.js';
|
|
6
|
+
import { $, $$, esc, openDlg, closeDlg, toast, loadThread, onScroll, wireRail, logHtml, turnA, ptx, pillOf, CHECK, store, crumbHtml, wireCrumb, kv, H } from './premium-shell.js';
|
|
7
7
|
|
|
8
8
|
const MODE_KEY = 'pmos.premium.openAs';
|
|
9
9
|
let M = null; // the model
|
|
@@ -173,8 +173,93 @@ async function load() {
|
|
|
173
173
|
return;
|
|
174
174
|
}
|
|
175
175
|
$('#brief').textContent = '';
|
|
176
|
-
M = r.model; draw();
|
|
176
|
+
const first = !M; M = r.model; draw();
|
|
177
|
+
if (first && M.board.ws) startMetrics(M.board.ws); // the Metrics card of a tenant's board reads apart from the board
|
|
177
178
|
if (M.docs.rows.some((d) => d.running)) setTimeout(load, 5000); // a core-document run is going: ask again until it ends
|
|
178
179
|
}
|
|
179
180
|
$('#brief').textContent = 'Reading the board.';
|
|
180
181
|
load();
|
|
182
|
+
|
|
183
|
+
/* ── the Metrics card of a tenant's board (initiative briefing-premium-metrics) ─────────────────────────────────
|
|
184
|
+
* Reads GET /api/board-metrics-premium?ws= apart from the board's own answer and draws lib/board-metrics-premium.js's tiles: a number and a chart only
|
|
185
|
+
* where a record counted one, "Not read" where it could not be. A tile opens the standard dialog. Nothing here writes. */
|
|
186
|
+
let MM = null, metTimer = null; // the Metrics card's model and its re-read timer
|
|
187
|
+
const MET_GEO = { W: 220, H: 60, P: 5 };
|
|
188
|
+
const metGeo = (m) => { const n = m.ser.length, { W, H: Ht, P } = MET_GEO, vs = m.ser.filter((v) => v != null), lo = Math.min(...vs), hi = Math.max(...vs), sp = hi - lo || 1; return { n, W, H: Ht, P, X: (j) => P + j * (W - 2 * P) / (n - 1), Y: (v) => P + (1 - (v - lo) / sp) * (Ht - 2 * P) }; };
|
|
189
|
+
const metFmt = (m, v) => (+v).toFixed(m.dec) + (m.suf || '');
|
|
190
|
+
|
|
191
|
+
function metTileHtml(t, i) {
|
|
192
|
+
if (t.absent) return `<div class="mt1" data-m="${i}" data-op="m:${i}" tabindex="0" role="button" aria-label="${esc(t.label)}: ${esc(t.absent)}. ${esc(t.why)}"><span>${esc(t.label)}</span><b class="mval">${esc(t.absent)}</b><em class="mwk">${esc(t.why)}</em></div>`;
|
|
193
|
+
const chg = t.change ? `<span class="mchg ${t.change.d === 0 ? '' : t.change.good ? 'good' : 'bad'}">${t.change.d === 0 ? '' : t.change.d > 0 ? '▲ ' : '▼ '}${esc(t.change.txt)}</span>` : '';
|
|
194
|
+
if (!t.ser) return `<div class="mt1 ${t.kind}" data-m="${i}" data-op="m:${i}" tabindex="0" role="button" aria-label="${esc(t.label)}, ${esc(t.value)}, ${esc(t.text)}"><span>${esc(t.label)}</span><b class="mval">${esc(t.value)}</b><em class="mwk">${esc(t.text)}</em></div>`;
|
|
195
|
+
const g = metGeo(t); let ln = '', op = false;
|
|
196
|
+
t.ser.forEach((v, j) => { if (v == null) { op = false; return; } ln += (op ? 'L' : 'M') + g.X(j).toFixed(1) + ',' + g.Y(v).toFixed(1); op = true; });
|
|
197
|
+
const f = Math.max(0, t.ser.findIndex((v) => v != null)), gap = t.ser.slice(f).some((v) => v == null), n1 = g.n - 1, lastJ = t.ser.reduce((a, v, j) => (v != null ? j : a), f);
|
|
198
|
+
return `<div class="mt1 mch ${t.kind}" data-m="${i}" data-op="m:${i}" tabindex="0" role="button" aria-label="${esc(t.label)} over eight weeks, now ${esc(t.value)}. Use the arrow keys to read each week."><span>${esc(t.label)}</span><b class="mval">${esc(t.value)}</b><em class="mwk">${esc(t.text)}</em>${chg}`
|
|
199
|
+
+ `<svg viewBox="0 0 ${g.W} ${g.H}">${f > 0 ? `<line class="gp" x1="${g.X(0)}" x2="${g.X(f)}" y1="${g.H - g.P}" y2="${g.H - g.P}"/>` : ''}${gap ? '' : `<path class="ar" d="${ln}L${g.X(n1)},${g.H}L${g.X(f)},${g.H}Z"/>`}<path class="ln2" d="${ln}"/><line class="cx" x1="0" x2="0" y1="0" y2="${g.H}"/><circle class="dt" cx="${g.X(lastJ)}" cy="${g.Y(t.ser[lastJ])}" r="3.5"/></svg>`
|
|
200
|
+
+ `<span class="mfoot"><i>${esc(t.wk[0])}</i><i>${esc(t.wk[t.wk.length - 1])}</i></span></div>`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Read one week of a chart tile: the guide line and dot move, the value and caption change. */
|
|
204
|
+
function metChartAt(el, j) {
|
|
205
|
+
const t = MM.tiles[+el.dataset.m]; if (!t || !t.ser) return;
|
|
206
|
+
const g = metGeo(t); j = j == null ? t.ser.reduce((a, v, k) => (v != null ? k : a), 0) : Math.max(0, Math.min(g.n - 1, j));
|
|
207
|
+
const v = t.ser[j], has = v != null, x = g.X(j), y = has ? g.Y(v) : 0, now = j === t.ser.reduce((a, w, k) => (w != null ? k : a), 0);
|
|
208
|
+
const cx = $('.cx', el), dt = $('.dt', el);
|
|
209
|
+
cx.setAttribute('x1', x); cx.setAttribute('x2', x); dt.setAttribute('cx', x); dt.setAttribute('cy', y); dt.style.opacity = has ? '' : '0';
|
|
210
|
+
el.classList.toggle('hv', !now); el.dataset.j = j;
|
|
211
|
+
const val = now ? t.value : has ? metFmt(t, v) : 'No runs', cap = now ? t.text : 'Week to ' + t.wk[j] + (t.nt[j] ? ' · ' + t.nt[j] : '');
|
|
212
|
+
$('.mval', el).textContent = val; $('.mwk', el).textContent = cap;
|
|
213
|
+
el.setAttribute('aria-label', t.label + ', ' + (now ? 'now ' : '') + val + ', ' + cap + '. Use the arrow keys to read each week.');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function metDraw() {
|
|
217
|
+
const card = $('#cMet'); if (!MM) { card.hidden = true; return; }
|
|
218
|
+
card.hidden = false;
|
|
219
|
+
$('#metCap').textContent = MM.header;
|
|
220
|
+
$('#metHint').textContent = MM.hint;
|
|
221
|
+
$('#metBody').style.gridTemplateColumns = 'repeat(' + Math.min(4, MM.tiles.length) + ',minmax(0,1fr))';
|
|
222
|
+
$('#metBody').innerHTML = MM.tiles.map(metTileHtml).join('');
|
|
223
|
+
$('#metNote').textContent = MM.note;
|
|
224
|
+
pillOf($('#perTabs'));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function metOpenTile(i) {
|
|
228
|
+
const t = MM.tiles[i], r = MM.rows[i]; if (!t || !r) return;
|
|
229
|
+
const d = r.dialog;
|
|
230
|
+
const rows = t.absent ? [['Now', t.absent], ['Why', t.why], ['Counted from', MM.repo]]
|
|
231
|
+
: [['Now', t.value], ['Counted', t.text], ['Change', t.change ? t.change.txt : ''], ['Better when', t.lowBetter ? 'Lower' : t.lowBetter === false ? 'Higher' : ''], ['Period', t.ser ? 'Week · last 8 weeks' : 'Now'], ['Source', MM.repo]];
|
|
232
|
+
const vals = t.ser ? H('Values') + `<div class="vtb">${t.ser.map((v, j) => `<div><span>${esc(t.wk[j])}</span><b>${v == null ? 'No runs' : esc(metFmt(t, v))}</b></div>`).join('')}</div>` : '';
|
|
233
|
+
const pane = `<article class="paper doc"><p class="lede" style="margin:0 0 10px">${esc(t.def)}</p>${kv(rows)}${vals}<p class="note" style="margin-top:12px">${esc(MM.note)}</p></article>`;
|
|
234
|
+
openDlg({ eye: 'Measure · real data', title: t.label, sub: MM.repo, say: d.say, stream: turnA(logHtml({ logTitle: 'Read from the repository', log: ['Read the repository\'s own records', 'Counted them by week', 'Changed nothing'] })) + turnA(ptx(d.say)), noteKey: r.key, pane, label: 'Measure' });
|
|
235
|
+
loadThread(r.key);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const metIdx = (e) => { const t = e.target.closest('[data-m]'); return t ? t : null; };
|
|
239
|
+
const metBody = () => $('#metBody');
|
|
240
|
+
function metWire() {
|
|
241
|
+
metBody().addEventListener('click', (e) => { const t = metIdx(e); if (t) metOpenTile(+t.dataset.m); });
|
|
242
|
+
metBody().addEventListener('keydown', (e) => {
|
|
243
|
+
const t = metIdx(e); if (!t) return;
|
|
244
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); metOpenTile(+t.dataset.m); return; }
|
|
245
|
+
if (!t.classList.contains('mch') || !/^Arrow(Left|Right)$/.test(e.key)) return;
|
|
246
|
+
e.preventDefault(); const m = MM.tiles[+t.dataset.m], now = t.dataset.j == null ? m.ser.length - 1 : +t.dataset.j; metChartAt(t, now + (e.key === 'ArrowLeft' ? -1 : 1));
|
|
247
|
+
});
|
|
248
|
+
metBody().addEventListener('pointermove', (e) => { const t = metIdx(e); if (!t || !t.classList.contains('mch')) return; const s = $('svg', t).getBoundingClientRect(), m = MM.tiles[+t.dataset.m], g = metGeo(m); metChartAt(t, Math.round(((e.clientX - s.left) / s.width * g.W - g.P) / (g.W - 2 * g.P) * (g.n - 1))); });
|
|
249
|
+
metBody().addEventListener('pointerout', (e) => { const t = metIdx(e); if (t && t.classList.contains('mch') && !t.contains(e.relatedTarget)) metChartAt(t, null); });
|
|
250
|
+
$('#perTabs').addEventListener('click', (e) => { const b = e.target.closest('[aria-disabled="true"]'); if (b) toast('PMOS counts runs and merges by week only.'); });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function startMetrics(ws) {
|
|
254
|
+
clearTimeout(metTimer);
|
|
255
|
+
let r = null;
|
|
256
|
+
try { const res = await fetch('/api/board-metrics-premium?ws=' + encodeURIComponent(ws || ''), { credentials: 'same-origin' }); r = await res.json(); } catch { r = null; }
|
|
257
|
+
if (r && r.ok && r.state === 'none') { MM = null; metDraw(); return; } // the home workspace's board has no card
|
|
258
|
+
if (!r || !r.ok || r.state !== 'ready') {
|
|
259
|
+
MM = null; metDraw(); $('#cMet').hidden = false; $('#metCap').textContent = 'not read'; $('#metHint').textContent = 'The metrics could not be read, so none is shown.' + (r && r.error ? ' The reason: ' + r.error : ''); $('#metBody').innerHTML = ''; $('#metNote').textContent = '';
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
MM = r.model; metDraw();
|
|
263
|
+
if (MM.pending) metTimer = setTimeout(() => startMetrics(ws), 4000); // the workflow engine is still deriving the Blocked and Idle tiles
|
|
264
|
+
}
|
|
265
|
+
metWire();
|
|
@@ -1211,3 +1211,58 @@ button.dsn:hover{box-shadow:inset 0 0 0 1.5px rgb(79,109,245)}
|
|
|
1211
1211
|
.pkempty{padding:10px 14px}
|
|
1212
1212
|
.top{position:relative;z-index:6}
|
|
1213
1213
|
.pkw{z-index:40}
|
|
1214
|
+
.bt2{display:grid;gap:10px;margin:16px 0 6px}
|
|
1215
|
+
.mt1{background:var(--well);border-radius:18px;padding:14px 16px 12px;display:grid;gap:2px;align-content:start;outline:0}
|
|
1216
|
+
.mt1 span{font-size:12.5px;color:var(--ink-3)}
|
|
1217
|
+
.mt1 b{font-size:28px;letter-spacing:-.03em;line-height:1.15}
|
|
1218
|
+
.mt1 em{font-style:normal;font-size:12.5px;color:var(--ink-3)}
|
|
1219
|
+
.mt1.warn em{color:var(--ember-ink)}
|
|
1220
|
+
.mt1.bad em{color:var(--bad)}
|
|
1221
|
+
.mt1.ok em{color:var(--ok)}
|
|
1222
|
+
.mch{cursor:crosshair}
|
|
1223
|
+
.mch:focus-visible{box-shadow:0 0 0 2px var(--b3)}
|
|
1224
|
+
.mch svg{width:100%;height:auto;display:block;margin-top:8px;overflow:visible}
|
|
1225
|
+
.mch .ar{fill:color-mix(in oklab,var(--ink) 8%,transparent);stroke:none}
|
|
1226
|
+
.mch .ln2{fill:none;stroke:var(--ink);stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke}
|
|
1227
|
+
.mch .tg{stroke:var(--ink-3);stroke-dasharray:3 3;stroke-width:1;vector-effect:non-scaling-stroke}
|
|
1228
|
+
.mch .cx{stroke:var(--ink-3);stroke-width:1;opacity:0;vector-effect:non-scaling-stroke;transition:opacity .2s}
|
|
1229
|
+
.mch.hv .cx{opacity:1}
|
|
1230
|
+
.mch .dt{fill:var(--ink);transition:cx .15s,cy .15s}
|
|
1231
|
+
.mch.ok .dt{fill:var(--ok)}
|
|
1232
|
+
.mch.warn .dt{fill:var(--ember)}
|
|
1233
|
+
.mch.bad .dt{fill:var(--bad)}
|
|
1234
|
+
.mch.hv .mval{color:var(--ink)}
|
|
1235
|
+
@media (max-width:700px){.bt2{grid-template-columns:1fr!important}}
|
|
1236
|
+
.mch .gp{stroke:var(--ink-3);opacity:.55;stroke-dasharray:2 4;fill:none;stroke-width:1.4}
|
|
1237
|
+
.ibt .mt1{background:var(--well)}
|
|
1238
|
+
@media (max-width:999px){.ibt{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
|
|
1239
|
+
.ibt .mt1:not(.mch){align-content:center;gap:4px}
|
|
1240
|
+
.ibt .mt1:not(.mch) b{font-size:28px}
|
|
1241
|
+
@media (max-width:700px){.ibt>.mt1:last-child:nth-child(odd){grid-column:1/-1}}
|
|
1242
|
+
.ch .mper button{height:30px;padding:0 14px;font-size:12.5px}
|
|
1243
|
+
.ch .mper button[aria-disabled="true"]{opacity:.4;cursor:not-allowed}
|
|
1244
|
+
#cMet .ch{flex-wrap:wrap;gap:10px}
|
|
1245
|
+
.mhint{margin:2px 0 14px;color:var(--ink-2);font-size:12.5px;line-height:1.55;max-width:78ch}
|
|
1246
|
+
.mhint b{color:var(--ink)}
|
|
1247
|
+
.mhint .mflag{color:var(--ember-ink)}
|
|
1248
|
+
.mlab{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
1249
|
+
.mchip{font-style:normal;font-size:12.5px;font-weight:650;padding:2px 9px;border-radius:999px;background:var(--well);color:var(--ink-2)}
|
|
1250
|
+
.mchip.ok{background:color-mix(in oklab,var(--ok) 14%,transparent);color:var(--ok)}
|
|
1251
|
+
.mchip.warn{background:color-mix(in oklab,var(--ember) 18%,transparent);color:var(--ember-ink)}
|
|
1252
|
+
.mt1.off{box-shadow:inset 0 0 0 1.5px color-mix(in oklab,var(--ember) 55%,transparent)}
|
|
1253
|
+
.mchg{font-size:12.5px;color:var(--ink-3);margin-top:2px}
|
|
1254
|
+
.mchg.good{color:var(--ok)}
|
|
1255
|
+
.mchg.bad{color:var(--ember-ink)}
|
|
1256
|
+
.mfoot{display:flex;justify-content:space-between;gap:8px;font:600 12.5px var(--mono);letter-spacing:.04em;text-transform:uppercase;color:var(--ink-3);margin-top:4px}
|
|
1257
|
+
.mfoot i{font-style:normal}
|
|
1258
|
+
.mfoot .tgl{text-transform:none;font-family:var(--sans);font-weight:550;letter-spacing:0;color:var(--ink-3)}
|
|
1259
|
+
.mt1[data-op]{cursor:pointer;transition:transform .25s,box-shadow .25s}
|
|
1260
|
+
.mt1[data-op]:hover{transform:translateY(-2px)}
|
|
1261
|
+
.mbad{color:var(--ember-ink)!important}
|
|
1262
|
+
.ndi .mchip{justify-self:start;margin-top:4px}
|
|
1263
|
+
.ntw .mt1{margin:0}
|
|
1264
|
+
.nedit .mt1{box-shadow:inset 0 0 0 1.5px var(--hair);border-style:dashed}
|
|
1265
|
+
.bpv .mt1{margin:0}
|
|
1266
|
+
.rsm .mchip{font-size:1.8cqw;margin:1.2cqw 0 1.6cqw;display:inline-block}
|
|
1267
|
+
.nrep .mhint{margin-bottom:10px}
|
|
1268
|
+
.rsg .mt1{margin:0;padding:0;background:none;box-shadow:none}
|
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
<button class="glass round" id="theme" aria-label="Switch light or dark"><svg class="i" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"/></svg></button></div>
|
|
39
39
|
</header>
|
|
40
40
|
<section class="hero hero-slim"><p class="brief rise" style="--d:2" id="brief" aria-live="polite"></p></section>
|
|
41
|
+
<section class="card rise" style="--d:4" id="cMet" aria-labelledby="hMet" hidden>
|
|
42
|
+
<div class="ch"><span class="ic"><svg class="i" viewBox="0 0 24 24"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg></span><h2 id="hMet">Metrics</h2><small id="metCap"></small><div class="seg mper" id="perTabs" role="group" aria-label="Period"><i class="pill"></i><button data-per="day" aria-pressed="false" aria-disabled="true" title="PMOS counts runs and merges by week only">Day</button><button data-per="week" aria-pressed="true">Week</button><button data-per="month" aria-pressed="false" aria-disabled="true" title="PMOS counts runs and merges by week only">Month</button></div></div>
|
|
43
|
+
<p class="mhint" id="metHint"></p>
|
|
44
|
+
<div class="bt2 ibt" id="metBody"></div>
|
|
45
|
+
<p class="note" id="metNote"></p>
|
|
46
|
+
</section>
|
|
41
47
|
<div class="bcols">
|
|
42
48
|
<div>
|
|
43
49
|
<section class="card rise" style="--d:5" id="cIni" aria-labelledby="hIni">
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"briefing-premium-initiative.test.js",
|
|
44
44
|
"briefing-premium-products.test.js",
|
|
45
45
|
"briefing-premium-sources.test.js",
|
|
46
|
+
"briefing-premium-metrics.test.js",
|
|
46
47
|
"calendar-events.test.js",
|
|
47
48
|
"calendar-supersede.test.js",
|
|
48
49
|
"tracker-read.test.js",
|
|
@@ -204,6 +205,7 @@
|
|
|
204
205
|
"briefing-premium-initiative.test.js": 1,
|
|
205
206
|
"briefing-premium-products.test.js": 1,
|
|
206
207
|
"briefing-premium-sources.test.js": 1,
|
|
208
|
+
"briefing-premium-metrics.test.js": 1,
|
|
207
209
|
"calendar-events.test.js": 1,
|
|
208
210
|
"calendar-supersede.test.js": 1,
|
|
209
211
|
"tracker-read.test.js": 1,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"briefing-premium-initiative-test.js",
|
|
8
8
|
"briefing-premium-products-test.js",
|
|
9
9
|
"briefing-premium-sources-test.js",
|
|
10
|
+
"briefing-premium-metrics-test.js",
|
|
10
11
|
"briefing-premium-agent-server.test.js"
|
|
11
12
|
],
|
|
12
13
|
"seconds": {
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"briefing-premium-initiative-test.js": 120,
|
|
17
18
|
"briefing-premium-products-test.js": 90,
|
|
18
19
|
"briefing-premium-sources-test.js": 90,
|
|
20
|
+
"briefing-premium-metrics-test.js": 120,
|
|
19
21
|
"briefing-premium-agent-server.test.js": 40
|
|
20
22
|
}
|
|
21
23
|
}
|