create-pmos 0.254.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 +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/briefing-premium-metrics-test.js +186 -0
- package/workspace/briefing-premium-sources-test.js +153 -0
- package/workspace/guide/briefing-premium.md +16 -2
- package/workspace/lib/board-metrics-premium.js +129 -0
- package/workspace/lib/briefing-premium-agent.js +1 -1
- package/workspace/lib/briefing-premium-products.js +2 -2
- package/workspace/lib/briefing-premium-sources.js +140 -0
- package/workspace/lib/briefing-premium.js +13 -3
- package/workspace/lib/routes-board-premium.js +25 -1
- package/workspace/lib/routes-briefing-premium.js +4 -2
- 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 +56 -0
- package/workspace/public/pm/premium.js +4 -2
- package/workspace/public/premium-board.html +6 -0
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/briefing-premium.json +4 -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
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@f4eae68
|
|
2
|
+
assembled: 2026-10-18
|
|
3
3
|
mode: vendor
|
|
@@ -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
|
|
package/payload/state/log.md
CHANGED
|
@@ -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); });
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* briefing-premium-sources-test.js — mail, Jira and Confluence on the premium Briefing (initiative briefing-premium-sources; planning/prd/briefing-premium-sources-prd.md S1 to S11).
|
|
4
|
+
* The real server with one product repository and a made-up morning brief written to <state>/briefs/<today>.json. Nothing here touches the PM's real briefs.
|
|
5
|
+
* S1/S8 no brief for today: the header says so S2/S5 the rows, each tagged Across products (the brief names no included initiative)
|
|
6
|
+
* S3 the dialog opens an https link and records nothing S7 the digests in Today S9 the writes refuse a source key; the agent answers
|
|
7
|
+
* S11 the source row and the digest look like the mockup's own row and Today item at 1280 and 390 px, light and dark; no sideways scroll
|
|
8
|
+
* Every wait polls the page; no fixed sleep stands in for a state. */
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { spawn, spawnSync } = require('child_process');
|
|
13
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
14
|
+
const T = require('./test-run').configure({ name: 'briefing-premium-sources' });
|
|
15
|
+
|
|
16
|
+
const BASE = T.BASE;
|
|
17
|
+
const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
|
|
18
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
const until = async (fn, ms = 15000, step = 150) => { const t0 = Date.now(); for (;;) { let v = null; try { v = await fn(); } catch { v = null; } if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
|
|
20
|
+
const results = [];
|
|
21
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 300) : ''}`); };
|
|
22
|
+
let srv = null; let browser = null;
|
|
23
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-bps-'));
|
|
24
|
+
const finish = async (code) => {
|
|
25
|
+
if (browser) await browser.close().catch(() => {});
|
|
26
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
27
|
+
T.killTmux();
|
|
28
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* left for the OS */ }
|
|
29
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
30
|
+
console.log(`\n${results.length - bad}/${results.length} briefing-premium-sources assertions passed`);
|
|
31
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
32
|
+
};
|
|
33
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
34
|
+
|
|
35
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
36
|
+
const TODAY = (() => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; })();
|
|
37
|
+
const item = (id, o = {}) => ({ id, title: 'Item ' + id, why_now: 'Why ' + id, evidence: '', source: 'mail', initiative_id: null, owner: 'pm', effort: 'minutes', confidence: 0.9, worth_asking: false, kind: 'you', ...o });
|
|
38
|
+
const BRIEF = {
|
|
39
|
+
date: TODAY, generated_at: new Date(Date.now() - 60000).toISOString(), sources: { mail: 'read', jira: 'read', confluence: 'read', slack: 'read' }, calendar: [],
|
|
40
|
+
priorities: [
|
|
41
|
+
item('aaaaaaaaaaa1', { title: 'Confirm the Thursday review', who: 'Anna Lee', verb: 'reply', todo: 'Confirm Thursday', link: 'https://mail.example/t/1', ask: 'Can you confirm?' }),
|
|
42
|
+
item('aaaaaaaaaaa2', { title: 'Sign off the vendor terms', source: 'confluence', who: 'Budi Santoso', verb: 'sign-off', link: 'https://wiki.example/p/2' }),
|
|
43
|
+
item('aaaaaaaaaaa3', { title: 'Look at the failing ticket', source: 'jira', who: 'Chen', verb: 'review', todo: 'Check the repro', link: 'https://jira.example/browse/AB-3' }),
|
|
44
|
+
],
|
|
45
|
+
worth_asking: [item('bbbbbbbbbbb1', { title: 'A thread that may be for you', who: 'Dewi', confidence: 0.3, worth_asking: true })],
|
|
46
|
+
digests: [{ source: 'slack', name: '#launch', link: 'https://slack.example/c/1', decisions: ['Ship on Friday'], open: ['Who owns QA'], goals: [] }],
|
|
47
|
+
places: [], resolved: [],
|
|
48
|
+
};
|
|
49
|
+
const sh = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
50
|
+
|
|
51
|
+
(async () => {
|
|
52
|
+
const exe = findBrowser();
|
|
53
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the briefing-premium-sources suite cannot run'); return finish(1); }
|
|
54
|
+
let chromium;
|
|
55
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the briefing-premium-sources suite cannot run'); return finish(1); }
|
|
56
|
+
const fakeBin = path.join(tmp, 'bin'); fs.mkdirSync(fakeBin);
|
|
57
|
+
fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/bin/sh\nsleep 30\n', { mode: 0o755 });
|
|
58
|
+
const root = path.join(tmp, 'idpf-repo');
|
|
59
|
+
fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'AGENTS.md'), '# a\n');
|
|
60
|
+
sh(root, ['init', '-q', '-b', 'main']); sh(root, ['config', 'user.email', 't@t']); sh(root, ['config', 'user.name', 't']); sh(root, ['add', '-A']); sh(root, ['commit', '-q', '-m', 'initial']);
|
|
61
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT), PATH: fakeBin + path.delimiter + process.env.PATH }) });
|
|
62
|
+
await T.waitForServer(BASE);
|
|
63
|
+
const json = async (m, p, b, h) => { const res = await fetch(BASE + p, { method: m, headers: { 'content-type': 'application/json', ...(h || {}) }, body: b ? JSON.stringify(b) : undefined }); let j = null; try { j = await res.json(); } catch { j = null; } return { status: res.status, body: j }; };
|
|
64
|
+
const added = await json('POST', '/api/workspaces', { root, name: 'IDPF', focus: false });
|
|
65
|
+
ok('S0 the product is registered', !!(added.body && added.body.workspace));
|
|
66
|
+
const model = async () => { const r = await json('GET', '/api/briefing-premium'); return r.status === 200 && r.body.model && !r.body.model.pending ? r.body : null; };
|
|
67
|
+
const m0 = await until(model, 90000);
|
|
68
|
+
ok('S1 with no brief for today there are no source rows and the header says so', m0 && !m0.model.list.rows.some((r) => r.kind === 'source') && /no morning brief for today/.test(m0.model.header.text), m0 && m0.model.header.text);
|
|
69
|
+
|
|
70
|
+
// the brief is written where the workspace reads it: <state>/briefs/<today>.json
|
|
71
|
+
fs.mkdirSync(path.join(T.STATE_DIR, 'briefs'), { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(T.STATE_DIR, 'briefs', TODAY + '.json'), JSON.stringify(BRIEF));
|
|
73
|
+
const m1 = (await json('GET', '/api/briefing-premium')).body.model;
|
|
74
|
+
const srcRows = m1.list.rows.filter((r) => r.kind === 'source');
|
|
75
|
+
ok('S2 four rows come from the brief: three priorities and one worth asking, in the brief\'s order', srcRows.length === 4 && srcRows[0].title === 'Confirm the Thursday review' && srcRows[3].title === 'A thread that may be for you', JSON.stringify(srcRows.map((r) => r.title)));
|
|
76
|
+
ok('S5 every row reads Across products, because the brief names no initiative of an included product', srcRows.every((r) => r.product === 'Across products'));
|
|
77
|
+
ok('S8 the header no longer says there is no brief', !/no morning brief/.test(m1.header.text), m1.header.text);
|
|
78
|
+
|
|
79
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
80
|
+
const open = async ({ w = 1280, h = 900, theme = 'light' } = {}) => {
|
|
81
|
+
const ctx = await browser.newContext({ viewport: { width: w, height: h } });
|
|
82
|
+
const page = await ctx.newPage(); const errors = [];
|
|
83
|
+
page.on('pageerror', (e) => errors.push(String(e.message || e)));
|
|
84
|
+
await page.goto(BASE + '/premium.html?theme=' + theme, { waitUntil: 'domcontentloaded' });
|
|
85
|
+
await page.waitForSelector('#rank .rr', { timeout: 60000 });
|
|
86
|
+
return { ctx, page, errors };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
const { ctx, page, errors } = await open();
|
|
91
|
+
const rows = await page.$$eval('#rank .rr', (els) => els.map((e) => ({ key: e.dataset.key, title: e.querySelector('.rt').textContent.replace('Do this first', ''), subject: e.querySelector('.rs').textContent, sug: e.querySelector('.sug').textContent, skip: e.querySelector('.skip').textContent })));
|
|
92
|
+
ok('S2 the first row shows the source and sender, the verb and the prepared line, and why now', rows[0] && rows[0].subject === 'Across productsMail · Anna Lee' && rows[0].sug === 'Reply: Confirm Thursday' && rows[0].skip === 'Why now: Why aaaaaaaaaaa1', JSON.stringify(rows[0]));
|
|
93
|
+
ok('S2 a row with a verb and no prepared line shows the verb alone', rows[1] && rows[1].sug === 'Sign off' && /Confluence · Budi Santoso/.test(rows[1].subject), JSON.stringify(rows[1]));
|
|
94
|
+
ok('S6 the dock counts the rows', (await page.textContent('#dN')) === '4', await page.textContent('#dN'));
|
|
95
|
+
await page.click('#rank .rr:first-child .rhd');
|
|
96
|
+
await page.waitForSelector('#srcLink');
|
|
97
|
+
const link = await page.$eval('#srcLink', (a) => ({ href: a.href, target: a.target, rel: a.rel, h: Math.round(a.getBoundingClientRect().height) }));
|
|
98
|
+
ok('S3 the dialog opens the item\'s https link in a new tab', link.href === 'https://mail.example/t/1' && link.target === '_blank' && /noopener/.test(link.rel), JSON.stringify(link));
|
|
99
|
+
ok('S3 the dialog offers nothing that records or sends', (await page.$('#recBtn')) === null && (await page.$('[data-until]')) === null && (await page.$('#dBody [data-op]')) === null);
|
|
100
|
+
ok('S11 the link is at least 44 px high', link.h >= 44, String(link.h));
|
|
101
|
+
await page.keyboard.press('Escape');
|
|
102
|
+
const chip = await page.$eval('#srcChips', (e) => e.textContent);
|
|
103
|
+
ok('S7 Today lists Digests with their count', /Digests1/.test(chip), chip);
|
|
104
|
+
await page.click('#tl .ev.src-digest');
|
|
105
|
+
await page.waitForSelector('#dBody');
|
|
106
|
+
const dtxt = await page.textContent('#dlg');
|
|
107
|
+
ok('S7 the digest dialog holds its lines and its link', /Decision: Ship on Friday/.test(dtxt) && /Open: Who owns QA/.test(dtxt) && (await page.getAttribute('#srcLink', 'href')) === 'https://slack.example/c/1', dtxt.slice(0, 200));
|
|
108
|
+
ok('no page error', errors.length === 0, errors.join(' | '));
|
|
109
|
+
await ctx.close();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
const html = await (await fetch(BASE + '/')).text();
|
|
114
|
+
const nonce = (/<meta name="pmos-nonce" content="([^"]*)"/.exec(html) || [])[1] || '';
|
|
115
|
+
const press = { Origin: BASE, ...(nonce ? { 'x-pmos-nonce': nonce } : {}) };
|
|
116
|
+
const r1 = await json('POST', '/api/briefing-premium/put-off', { key: 'src:aaaaaaaaaaa1', until: 'later' }, press);
|
|
117
|
+
ok('S9 a source row cannot be put off', r1.status === 400, JSON.stringify(r1));
|
|
118
|
+
const r2 = await json('POST', '/api/briefing-premium/decide', { key: 'src:aaaaaaaaaaa1', choice: 'extend', why: 'No.' }, press);
|
|
119
|
+
ok('S9 a source row cannot be decided', r2.status >= 400, JSON.stringify(r2));
|
|
120
|
+
const r3 = await json('POST', '/api/briefing-premium/ask', { key: 'src:aaaaaaaaaaa1', text: 'Why is this on my list?' }, press);
|
|
121
|
+
ok('S9 the read-only agent can be asked about a source row', r3.status === 200 && r3.body.ok && r3.body.state === 'working', JSON.stringify(r3));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* S11 fidelity: the source row against the mockup's second row, the digest against the mockup's Today item */
|
|
125
|
+
if (!fs.existsSync(MOCKUP)) ok('S11 the mockup is in the repository', false, MOCKUP);
|
|
126
|
+
else {
|
|
127
|
+
const PAIRS = [['row', '#rank .rr:nth-child(2)'], ['number', '#rank .rr:nth-child(2) .rn'], ['title', '#rank .rr:nth-child(2) .rt'], ['subject', '#rank .rr:nth-child(2) .rs'], ['prepared', '#rank .rr:nth-child(2) .sug'], ['skip', '#rank .rr:nth-child(2) .skip'], ['event', '#tl .ev:not(:first-of-type)'], ['eventTime', '#tl .ev:not(:first-of-type) time'], ['eventTitle', '#tl .ev:not(:first-of-type) b']];
|
|
128
|
+
const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'borderTopColor'];
|
|
129
|
+
const grab = (pg, sel) => pg.evaluate(([pairs, props]) => { const o = {}; for (const [n, s] of pairs) { const e = /^#tl/.test(s) && window.__ev ? window.__ev(s) : document.querySelector(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]);
|
|
130
|
+
for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
|
|
131
|
+
const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
|
|
132
|
+
await m.goto('file://' + MOCKUP);
|
|
133
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 1 && document.querySelectorAll('#tl .ev').length > 0));
|
|
134
|
+
await m.evaluate((t) => { document.documentElement.dataset.theme = t; }, th);
|
|
135
|
+
const R = await open({ w, h, theme: th });
|
|
136
|
+
await R.page.evaluate(() => { window.__ev = (s) => { const d = document.querySelector('#tl .ev.src-digest'); return !d ? null : / time$/.test(s) ? d.querySelector('time') : / b$/.test(s) ? d.querySelector('b') : d; }; });
|
|
137
|
+
await sleep(500);
|
|
138
|
+
const a = await grab(m, PAIRS), c = await grab(R.page, PAIRS), diffs = [];
|
|
139
|
+
for (const [n] of PAIRS) {
|
|
140
|
+
if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
|
|
141
|
+
for (const k of Object.keys(a[n])) {
|
|
142
|
+
if (k === 'left' || k === 'width') { if (['row', 'number', 'title', 'subject', 'prepared', 'skip', 'event', 'eventTime', 'eventTitle'].includes(n) && (k === 'left' || n !== 'row' && n !== 'event' && n !== 'eventTime')) continue; }
|
|
143
|
+
if ((k === 'left' || k === 'width') ? 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]}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
ok(`S11 the source row and the digest look like the mockup's at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
|
|
147
|
+
const ov = await R.page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
|
|
148
|
+
ok(`S11 no sideways scroll at ${w} px, ${th}`, ov <= 0, String(ov));
|
|
149
|
+
await m.context().close(); await R.ctx.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return finish();
|
|
153
|
+
})().catch((e) => { console.error(e); finish(1); });
|
|
@@ -23,7 +23,20 @@ You work across products, and a conversation in email, the calendar or Confluenc
|
|
|
23
23
|
- **The order** is the same across products: blocked work first, then decisions only you can make, then what has waited longest. A failed-review row is made for each product, so a count is never added across products.
|
|
24
24
|
- **The header** names what is read and what is not, for example "IDPF and microsites · pmos not included". A product that is still being read or could not be read is named there too, and the others still show.
|
|
25
25
|
- **`/premium.html?ws=<id>`** shows that one workspace as the Briefing did before, whether or not it is ticked, with a button back to the ticked products.
|
|
26
|
-
- **
|
|
26
|
+
- **Mail, Jira and Confluence** come from today's morning brief, which a scheduled skill in an agent session writes to the state folder (the server reaches none of those services). See "Mail, Jira and Confluence" below.
|
|
27
|
+
|
|
28
|
+
## Mail, Jira and Confluence
|
|
29
|
+
|
|
30
|
+
The page reads today's morning brief (`briefs/<date>.json` in the state folder) and the Jira tickets assigned to you. Nothing is sent, posted or recorded from these rows.
|
|
31
|
+
|
|
32
|
+
- **Rows.** The brief's mail, Jira and Confluence items become rows in "What you need to do" when you own them, when they mention you, or when they are worth asking. Slack, GitHub, Drive, Figma and website items are left out, and so are agent-owned items that do not mention you. A row shows its source and sender, then the verb and the prepared line, then why it is on the list. Only fields the item holds are shown.
|
|
33
|
+
- **Tickets.** An assigned Jira ticket becomes a row when it is overdue, due today, blocked or a decision, and your Work list has not marked it done or put it aside.
|
|
34
|
+
- **The tag.** A row names a product only when the item names an initiative that belongs to exactly one product you included. Every other row reads "Across products". The page never guesses a product.
|
|
35
|
+
- **The order.** These rows follow the repository rows: tickets first, then the brief's items in the brief's own order. The cap of seven rows is shared, and the rest are counted as more.
|
|
36
|
+
- **The dialog** opens the item's link, when it is an https address, in a new tab. It records nothing. The read-only agent can be asked about the row.
|
|
37
|
+
- **Today** lists one item per digest (a watched Slack channel, Jira project or Confluence space) with the decisions, open lines and goals the brief holds, under a Digests filter that appears when the brief has digests.
|
|
38
|
+
- **The header** says "no morning brief for today" when there is none, or that the brief could not be read, or that it did not reach a source (mail, Jira, Confluence or Slack).
|
|
39
|
+
- **`?ws=<id>`** does not show these rows.
|
|
27
40
|
|
|
28
41
|
## How long it takes to load
|
|
29
42
|
|
|
@@ -129,11 +142,12 @@ Board is the third place on the rail, at `/premium-board.html`. It reads what th
|
|
|
129
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.
|
|
130
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.
|
|
131
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.
|
|
132
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.
|
|
133
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.
|
|
134
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.
|
|
135
149
|
|
|
136
|
-
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.
|
|
137
151
|
|
|
138
152
|
|
|
139
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 };
|
|
@@ -16,7 +16,7 @@ const crypto = require('crypto');
|
|
|
16
16
|
const headless = require('./headless.js');
|
|
17
17
|
|
|
18
18
|
const THREADS = 'briefing-premium-threads.jsonl';
|
|
19
|
-
const KEY_RE = /^(decision|blocked|waited|ev|work|board|init|doc):[A-Za-z0-9][A-Za-z0-9._:-]{0,100}$/;
|
|
19
|
+
const KEY_RE = /^(decision|blocked|waited|src|ev|work|board|init|doc):[A-Za-z0-9][A-Za-z0-9._:-]{0,100}$/;
|
|
20
20
|
const MAX_Q = 2000, MAX_A = 6000, MAX_QUESTIONS = 40, PROMPT_TURNS = 8;
|
|
21
21
|
const fail = (status, error) => ({ status, body: { ok: false, error } });
|
|
22
22
|
const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
|
|
@@ -71,7 +71,7 @@ function start(p, engine, runtimeDir) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/** The merged answer for the workspaces that are on. `available` is choose()'s list (all of them, with `on`). */
|
|
74
|
-
async function modelOfMany({ available, engine, runtimeDir }) {
|
|
74
|
+
async function modelOfMany({ available, engine, runtimeDir, sources }) {
|
|
75
75
|
const now = Date.now();
|
|
76
76
|
const on = (available || []).filter((a) => a.on);
|
|
77
77
|
const parts = [];
|
|
@@ -98,7 +98,7 @@ async function modelOfMany({ available, engine, runtimeDir }) {
|
|
|
98
98
|
const settled = parts.filter((p) => p.state === 'ready').length;
|
|
99
99
|
if (on.length && !settled && parts.every((p) => p.state === 'reading')) return { state: 'reading', elapsed_ms: elapsed };
|
|
100
100
|
if (on.length && !settled && parts.every((p) => p.state === 'error')) return { state: 'error', error: parts.map((p) => p.product.name + ': ' + p.error).join('; ').slice(0, 300) };
|
|
101
|
-
const model = BP.buildMany({ now, asOf: asOf || now, meetings: BP.readMeetings(runtimeDir, now), defers: Object.fromEntries(W.readDefers(runtimeDir, now)), available: (available || []).map((a) => ({ ws: a.ws, name: a.name, home: a.home, on: a.on })), parts });
|
|
101
|
+
const model = BP.buildMany({ now, asOf: asOf || now, meetings: BP.readMeetings(runtimeDir, now), defers: Object.fromEntries(W.readDefers(runtimeDir, now)), available: (available || []).map((a) => ({ ws: a.ws, name: a.name, home: a.home, on: a.on })), parts, sources });
|
|
102
102
|
last = model;
|
|
103
103
|
return { state: 'ready', model, as_of: asOf || now, refreshing, pending: model.pending, ...(error ? { error } : {}) };
|
|
104
104
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/briefing-premium-sources.js — mail, Jira and Confluence on the premium Briefing (initiative briefing-premium-sources, S1 to S9).
|
|
3
|
+
*
|
|
4
|
+
* build({ brief, myWork, itemLines, today, products }) → { rows, digests, state, notRead } PURE.
|
|
5
|
+
* readSources(ctx, now) → { brief, myWork, itemLines, today } what the route reads.
|
|
6
|
+
*
|
|
7
|
+
* brief = { state: 'ok' | 'absent' | 'stale' | 'bad', brief? }: today's morning brief (lib/brief.js, <state>/briefs/<date>.json).
|
|
8
|
+
* products = (initiative id) → { ws, name } | null: the included products' initiatives. A row names a product only through it.
|
|
9
|
+
*
|
|
10
|
+
* THE RULE: a sentence, chip, name, count or link is made only from a field the brief or the ticket holds. A field it does not hold is left out.
|
|
11
|
+
* A row has no write: nothing here sends, replies, posts or records. PURE except readSources. */
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const Real = require('./briefing-real.js');
|
|
15
|
+
const { localDate } = require('./brief.js');
|
|
16
|
+
|
|
17
|
+
const SOURCE_WORD = { mail: 'Mail', jira: 'Jira', confluence: 'Confluence', slack: 'Slack' };
|
|
18
|
+
const ROW_SOURCES = ['mail', 'jira', 'confluence'];
|
|
19
|
+
const VERB_WORD = { reply: 'Reply', review: 'Review', decide: 'Decide', 'sign-off': 'Sign off', read: 'Read', do: 'Do' };
|
|
20
|
+
const EFFORT_WORD = { minutes: 'minutes', 'an-hour': 'an hour', 'half-day': 'half a day', day: 'a day' };
|
|
21
|
+
const ACROSS = 'Across products';
|
|
22
|
+
const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
|
|
23
|
+
const text = (v, n) => (typeof v === 'string' && v.trim() ? clip(v, n) : '');
|
|
24
|
+
const https = (v) => { const s = String(v || ''); try { return s.length <= 500 && new URL(s).protocol === 'https:' ? s : null; } catch { return null; } };
|
|
25
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
26
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
27
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
28
|
+
const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
|
|
29
|
+
|
|
30
|
+
/** The items that become rows: a mail, Jira or Confluence item the PM owns, or that mentions the PM, or that is worth asking. */
|
|
31
|
+
function rowItems(brief) {
|
|
32
|
+
const keep = (x) => x && typeof x === 'object' && x.id && text(x.title, 140) && ROW_SOURCES.includes(x.source);
|
|
33
|
+
const asks = list(brief.worth_asking).filter((x) => keep(x));
|
|
34
|
+
const mine = list(brief.priorities).filter((x) => keep(x) && (x.owner === 'pm' || x.mention === true));
|
|
35
|
+
return [...mine.map((x) => ({ x, ask: false })), ...asks.map((x) => ({ x, ask: true }))];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function itemRow(x, ask, brief, products) {
|
|
39
|
+
const src = SOURCE_WORD[x.source];
|
|
40
|
+
const verb = VERB_WORD[x.verb] || '';
|
|
41
|
+
const todo = text(x.todo, 200);
|
|
42
|
+
const hit = x.initiative_id && products ? products(x.initiative_id) : null;
|
|
43
|
+
const url = https(x.link);
|
|
44
|
+
const facts = [['Source', src], ['From', text(x.who, 120)], ['Where', text(x.where, 120)], ['About', text(x.about, 480)], ['Asked', text(x.ask, 240)], ['Quote', text(x.quote, 280)], ['Evidence', text(x.evidence, 240)],
|
|
45
|
+
['Confidence', typeof x.confidence === 'number' ? Math.round(x.confidence * 100) + '%' + (ask ? ', below the bar: ask before acting' : '') : ''], ['Effort', EFFORT_WORD[x.effort] || ''],
|
|
46
|
+
['Reply needed', typeof x.reply_needed === 'boolean' ? (x.reply_needed ? 'Yes' : 'No') : ''], ['Initiative', hit ? hit.name + ' · ' + x.initiative_id : ''], ['Due', text(x.due, 40)]].filter((f) => f[1]);
|
|
47
|
+
const why = text(x.why_now, 240);
|
|
48
|
+
return {
|
|
49
|
+
kind: 'source', key: 'src:' + x.id, title: text(x.title, 140),
|
|
50
|
+
subject: [src, text(x.who, 120), x.mention ? 'mentions you' : '', ask ? 'worth asking' : ''].filter(Boolean).join(' · '),
|
|
51
|
+
prepared: verb && todo ? verb + ': ' + todo : todo || verb || 'No prepared line is on record.',
|
|
52
|
+
skip: why ? 'Why now: ' + why : 'No reason is on record.',
|
|
53
|
+
product: hit ? hit.name : ACROSS, from: ['brief:' + x.id], detail: { source: x.source },
|
|
54
|
+
dialog: {
|
|
55
|
+
eye: src, logTitle: 'Read from the morning brief', title: text(x.title, 140), sub: [src, text(x.who, 120)].filter(Boolean).join(' · '),
|
|
56
|
+
log: ['Read the morning brief of ' + brief.date, 'Found this ' + src + ' item', hit ? 'It names the initiative ' + x.initiative_id + ' of ' + hit.name : 'It names no initiative of an included product, so it is not tied to a product'],
|
|
57
|
+
say: text(x.about, 480) || why || 'The brief holds no more on this item.',
|
|
58
|
+
head: 'The item', facts, opts: [], sel: null, nodef: '', link: url, linkLabel: 'Open in ' + src,
|
|
59
|
+
pane: { label: src, file: 'briefs/' + brief.date + '.json', h: text(x.title, 140), intro: why, rows: facts, paras: [], parasTitle: '' },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ticketRow(n, today) {
|
|
65
|
+
const url = https(n.url);
|
|
66
|
+
const label = { clock: 'Due', alert: 'Blocked', reply: 'Asked', inbox: 'Status' };
|
|
67
|
+
const fact = (f) => { const t = String(f.text || ''); return [label[f.icon] || '', f.icon === 'clock' ? t.replace(/^Due /, '') : f.icon === 'inbox' ? t.split(' · ').slice(1).join(' · ') : t]; };
|
|
68
|
+
const facts = [['Source', 'Jira'], ['Ticket', n.src || ''], ...list(n.facts).map(fact)].filter((f) => f[0] && f[1]);
|
|
69
|
+
return {
|
|
70
|
+
kind: 'source', key: 'src:' + n.itemId, title: text(n.headline, 140),
|
|
71
|
+
subject: ['Jira', n.src, 'assigned to you'].filter(Boolean).join(' · '),
|
|
72
|
+
prepared: text(n.why, 200) || 'No prepared line is on record.',
|
|
73
|
+
skip: n.dueText ? cap(n.dueText) + '.' : 'No due date is on record.',
|
|
74
|
+
product: ACROSS, from: ['jira:' + n.itemId], detail: { source: 'jira' },
|
|
75
|
+
dialog: {
|
|
76
|
+
eye: 'Jira', logTitle: 'Read from your Jira tickets', title: text(n.headline, 140), sub: 'Jira · ' + (n.src || ''),
|
|
77
|
+
log: ['Read the tickets assigned to you', 'Checked your Work list for a done or put-off copy', 'Found none, so it stays'],
|
|
78
|
+
say: text(n.why, 200), head: 'The ticket', facts, opts: [], sel: null, nodef: '', link: url, linkLabel: 'Open ' + (n.src || 'the ticket') + ' in Jira',
|
|
79
|
+
pane: { label: 'Jira', file: 'Jira · assigned to you', h: text(n.headline, 140), intro: text(n.why, 200), rows: facts, paras: [], parasTitle: '' },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function digestItem(d, brief) {
|
|
85
|
+
const at = Date.parse(brief.generated_at);
|
|
86
|
+
if (!Number.isFinite(at) || !SOURCE_WORD[d.source] || !text(d.name, 120)) return null;
|
|
87
|
+
const dec = list(d.decisions).filter((s) => text(s, 140)), open = list(d.open).filter((s) => text(s, 140)), goals = list(d.goals).filter((s) => text(s, 140));
|
|
88
|
+
const src = SOURCE_WORD[d.source];
|
|
89
|
+
const sub = [dec.length ? dec.length + (dec.length === 1 ? ' decision' : ' decisions') : '', open.length ? open.length + ' open' : '', goals.length ? goals.length + (goals.length === 1 ? ' goal' : ' goals') : ''].filter(Boolean).join(' · ') || 'No lines on record';
|
|
90
|
+
const facts = [['Source', src], ['Name', text(d.name, 120)], ['Decisions', String(dec.length)], ['Open', String(open.length)], ['Goals', goals.length ? String(goals.length) : '']].filter((f) => f[1]);
|
|
91
|
+
return {
|
|
92
|
+
at, t: hhmm(at), kind: 'digest', title: src + ' · ' + text(d.name, 120), sub, src: 'Digests',
|
|
93
|
+
dialog: {
|
|
94
|
+
eye: 'Digest', logTitle: 'Read from the morning brief', title: src + ' · ' + text(d.name, 120), sub: sub,
|
|
95
|
+
log: ['Read the morning brief of ' + brief.date, 'Found the ' + src + ' digest of ' + text(d.name, 120)],
|
|
96
|
+
say: 'This is what the brief holds for ' + text(d.name, 120) + '. It is a summary, not a copy of the source.',
|
|
97
|
+
head: 'The digest', facts, opts: [], sel: null, nodef: '', link: https(d.link), linkLabel: 'Open in ' + src,
|
|
98
|
+
pane: { label: 'Digest', file: 'briefs/' + brief.date + '.json', h: src + ' · ' + text(d.name, 120), intro: '', rows: facts, parasTitle: 'What the digest holds',
|
|
99
|
+
paras: [...dec.map((s) => 'Decision: ' + text(s, 140)), ...open.map((s) => 'Open: ' + text(s, 140)), ...goals.map((s) => 'Goal: ' + text(s, 140))] },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The sources the brief says it did not read, named for the header: only the four that rows and digests come from. */
|
|
105
|
+
function notReadOf(brief) {
|
|
106
|
+
const st = brief.sources && typeof brief.sources === 'object' ? brief.sources : {};
|
|
107
|
+
return ['mail', 'jira', 'confluence', 'slack'].filter((k) => st[k] === 'not-reached' || st[k] === 'off')
|
|
108
|
+
.map((k) => SOURCE_WORD[k].toLowerCase() + (st[k] === 'off' ? ' (switched off)' : ' (the brief says it was not reached)'));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function build({ brief, myWork, itemLines, today, products } = {}) {
|
|
112
|
+
const out = { rows: [], digests: [], state: 'absent', notRead: [], from: [] };
|
|
113
|
+
const b = brief && brief.state === 'ok' && brief.brief && brief.brief.date === today ? brief.brief : null;
|
|
114
|
+
out.state = b ? 'ok' : brief && brief.state === 'ok' ? 'stale' : (brief && brief.state) || 'absent';
|
|
115
|
+
const tickets = (Real.build({ brief: null, myWork, itemLines, today }).needs || []).filter((n) => n.origin === 'jira' && n.itemId)
|
|
116
|
+
.sort((a, c) => a._rank - c._rank || (c._late || 0) - (a._late || 0));
|
|
117
|
+
out.rows.push(...tickets.map((n) => ticketRow(n, today)));
|
|
118
|
+
if (b) {
|
|
119
|
+
out.rows.push(...rowItems(b).map(({ x, ask }) => itemRow(x, ask, b, products)));
|
|
120
|
+
out.digests = list(b.digests).map((d) => digestItem(d, b)).filter(Boolean);
|
|
121
|
+
out.notRead = notReadOf(b);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the route reads each request. A source that fails gives nothing. */
|
|
127
|
+
async function readSources(ctx, now) {
|
|
128
|
+
const today = localDate(now);
|
|
129
|
+
const dir = ctx && ctx.runtimeDir;
|
|
130
|
+
const out = { brief: { state: 'absent' }, myWork: [], itemLines: [], today };
|
|
131
|
+
if (!dir) return out;
|
|
132
|
+
let raw = null;
|
|
133
|
+
try { raw = fs.readFileSync(path.join(dir, 'briefs', today + '.json'), 'utf8'); } catch { /* no brief for today */ }
|
|
134
|
+
if (raw !== null) { try { const j = JSON.parse(raw); out.brief = j && typeof j === 'object' && !Array.isArray(j) ? { state: 'ok', brief: j } : { state: 'bad' }; } catch { out.brief = { state: 'bad' }; } }
|
|
135
|
+
const real = await Real.readSources(ctx, new Date(now), today).catch(() => null);
|
|
136
|
+
if (real) { out.myWork = real.myWork; out.itemLines = real.itemLines; }
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { build, readSources, ACROSS, SOURCE_WORD };
|
|
@@ -21,6 +21,7 @@ const fs = require('fs');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const { execFile } = require('child_process');
|
|
23
23
|
const W = require('./briefing-premium-writes.js');
|
|
24
|
+
const Src = require('./briefing-premium-sources.js');
|
|
24
25
|
|
|
25
26
|
const DAY = 86400000;
|
|
26
27
|
const MAX_ROWS = 7;
|
|
@@ -330,9 +331,10 @@ function todayOf(f) {
|
|
|
330
331
|
const runs = (f.runs || []).filter((r) => { const t = Date.parse(r.created_at); return t >= midnight && t <= f.now; });
|
|
331
332
|
const byId = new Map(f.initiatives.map((i) => [idk(i, i.id), i]));
|
|
332
333
|
for (const r of runs) { const t = Date.parse(r.created_at); items.push({ at: t, t: hhmm(t), kind: 'run', ...(r.product ? { product: r.product } : {}), title: clip((byId.get(idk(r, r.initiative_id)) || {}).title || r.initiative_id, 110), sub: 'Agent run' + (Number.isFinite(Number(r.human_corrections)) ? ' · ' + r.human_corrections + ' ' + plural(Number(r.human_corrections), 'correction', 'corrections') : ''), src: 'Agent runs', dialog: tryD(() => runDialog(r, t, byId)) }); }
|
|
334
|
+
for (const d of f.digests || []) items.push(d);
|
|
333
335
|
const ahead = items.filter((x) => x.at > f.asOf).sort((a, b) => a.at - b.at), earlier = items.filter((x) => x.at <= f.asOf).sort((a, b) => b.at - a.at);
|
|
334
336
|
const count = (k) => items.filter((x) => x.kind === k).length;
|
|
335
|
-
return { ahead, earlier, now: { t: hhmm(f.asOf), label: 'snapshot' }, counts: { meeting: count('meeting'), merge: count('merge'), run: count('run') }, note: ahead.length + earlier.length ? null : 'Nothing is recorded for today yet.' };
|
|
337
|
+
return { ahead, earlier, now: { t: hhmm(f.asOf), label: 'snapshot' }, counts: { meeting: count('meeting'), merge: count('merge'), run: count('run'), digest: count('digest') }, note: ahead.length + earlier.length ? null : 'Nothing is recorded for today yet.' };
|
|
336
338
|
}
|
|
337
339
|
|
|
338
340
|
/* ── the model ────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -378,7 +380,11 @@ function buildMany(g) {
|
|
|
378
380
|
const every = [], putOff = [];
|
|
379
381
|
for (const p of read) { const a = allRowsOf(p.f); every.push(...a.all); putOff.push(...a.putOff); }
|
|
380
382
|
every.sort((a, b) => (KIND_RANK[a.kind] - KIND_RANK[b.kind]) || (a.kind === 'blocked' ? (a.order[0] - b.order[0]) || (a.order[1] < b.order[1] ? -1 : a.order[1] > b.order[1] ? 1 : 0) : a.kind === 'waited' ? longest(b) - longest(a) : 0));
|
|
381
|
-
|
|
383
|
+
// mail, Jira and Confluence (initiative briefing-premium-sources): their rows follow the repositories' rows, and a row names a product only when exactly one included product holds its initiative
|
|
384
|
+
const owner = new Map();
|
|
385
|
+
for (const p of read) for (const i of p.f.initiatives) owner.set(i.id, owner.has(i.id) ? null : { ws: p.product.ws, name: p.product.name });
|
|
386
|
+
const sx = g.sources ? Src.build(Object.assign({}, g.sources, { products: (id) => owner.get(id) || null })) : null;
|
|
387
|
+
const { rows, more } = cut(every.concat(sx ? sx.rows : []), putOff);
|
|
382
388
|
const tag = (list, p) => list.map((x) => Object.assign({}, x, { ws: p.product.ws, product: p.product.name }));
|
|
383
389
|
const cf = {
|
|
384
390
|
now, asOf, meetings, defers: g.defers || {},
|
|
@@ -386,6 +392,7 @@ function buildMany(g) {
|
|
|
386
392
|
runs: [].concat(...read.map((p) => tag(p.f.runs, p))),
|
|
387
393
|
merges: [].concat(...read.map((p) => tag(p.f.merges, p))),
|
|
388
394
|
evals: [].concat(...read.map((p) => tag(p.f.evals, p))),
|
|
395
|
+
digests: sx ? sx.digests : [],
|
|
389
396
|
};
|
|
390
397
|
const available = g.available || [], inc = available.filter((a) => a.on), out = available.filter((a) => !a.on);
|
|
391
398
|
const waiting = parts.filter((p) => p.state === 'reading' || !p.f).filter((p) => p.state !== 'error'), failed = parts.filter((p) => p.state === 'error');
|
|
@@ -398,10 +405,13 @@ function buildMany(g) {
|
|
|
398
405
|
const none = !inc.length;
|
|
399
406
|
const hero = none ? [{ parts: [{ t: available.length ? 'No product is included, so there is nothing to read. Pick one under Products.' : 'No product workspace is registered, so there is nothing to read.', s: false }], from: [] }]
|
|
400
407
|
: heroOf(cf, rows);
|
|
408
|
+
const srcRows = rows.filter((r) => r.kind === 'source'), srcSeen = [...new Set(srcRows.map((r) => r.detail.source))];
|
|
409
|
+
if (srcRows.length) hero.push({ parts: [{ t: num(srcRows.length) + ' ' + plural(srcRows.length, 'item', 'items') + ' from ' + joinNames(srcSeen.map((k) => Src.SOURCE_WORD[k])), s: true }, { t: ' ' + plural(srcRows.length, 'needs', 'need') + ' you.', s: false }], from: srcRows.map((r) => r.from[0]) });
|
|
410
|
+
const sxNote = !sx ? '' : sx.state === 'ok' ? (sx.notRead.length ? ' · not read by the morning brief: ' + sx.notRead.join(', ') : '') : sx.state === 'bad' ? ' · the morning brief could not be read' : ' · no morning brief for today';
|
|
401
411
|
return {
|
|
402
412
|
date: dateLine(now),
|
|
403
413
|
pending,
|
|
404
|
-
header: { text: 'Real data · as of ' + hhmm(asOf) + ' · ' + (none ? 'no product included' : joinNames(inc.map((a) => a.name))) + (out.length ? ' · ' + joinNames(out.map((a) => a.name)) + ' not included' : '') + (missText.length ? ' · not read: ' + missText.join(', ') : '') + (waiting.length ? ' · reading ' + joinNames(waiting.map((p) => p.product.name)) : '') + (skipped.n ? ' · ' + skipped.n + (skipped.n === 1 ? ' record' : ' records') + ' could not be read and ' + (skipped.n === 1 ? 'was' : 'were') + ' left out' : ''), missing },
|
|
414
|
+
header: { text: 'Real data · as of ' + hhmm(asOf) + ' · ' + (none ? 'no product included' : joinNames(inc.map((a) => a.name))) + (out.length ? ' · ' + joinNames(out.map((a) => a.name)) + ' not included' : '') + (missText.length ? ' · not read: ' + missText.join(', ') : '') + sxNote + (waiting.length ? ' · reading ' + joinNames(waiting.map((p) => p.product.name)) : '') + (skipped.n ? ' · ' + skipped.n + (skipped.n === 1 ? ' record' : ' records') + ' could not be read and ' + (skipped.n === 1 ? 'was' : 'were') + ' left out' : ''), missing },
|
|
405
415
|
hero: hero.length ? hero : [{ parts: [{ t: pending ? 'Reading the initiative state. What needs you is not known yet.' : 'Nothing needs you and nothing moved today that the records show.', s: false }], from: [] }],
|
|
406
416
|
list: { title: 'What you need to do', rows, more, count: rows.length + more, pendingNote: pending ? 'Blocked work, prototypes waiting for you and failed reviews appear when the initiative state has been read.' : null, putOff: putOff.map((x) => ({ key: x.key, title: x.title, ...(x.product ? { product: x.product } : {}), until: x.until, text: (x.product ? x.product + ': ' : '') + x.title + ' · back ' + backAt(x.until, now) })), order: 'In this order: blocked work first, then decisions only you can make, then what has waited longest.' },
|
|
407
417
|
today: todayOf(cf),
|
|
@@ -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; };
|
|
@@ -21,6 +21,7 @@ const { correctionEvent } = require(repoScript('card-state.js'));
|
|
|
21
21
|
const { appendOutbox } = require(repoScript('outbox.js'));
|
|
22
22
|
|
|
23
23
|
const Prod = require('./briefing-premium-products.js');
|
|
24
|
+
const Src = require('./briefing-premium-sources.js');
|
|
24
25
|
const settings = require('./settings-store.js');
|
|
25
26
|
|
|
26
27
|
/** The workspaces the Briefing may read right now (initiative briefing-premium-products): choose()'s list, from the server's workspace list and the PM's setting. */
|
|
@@ -65,7 +66,7 @@ async function post(req, res, url, ctx) {
|
|
|
65
66
|
}
|
|
66
67
|
if (act === 'ask') {
|
|
67
68
|
let wsId; try { const w = merged && sk ? { id: sk.ws } : ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); wsId = w && w.id; } catch { wsId = undefined; }
|
|
68
|
-
const a = Ag.ask({ runtimeDir, key: b.key, text: b.text, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
|
|
69
|
+
const a = Ag.ask({ runtimeDir, key: b.key, text: b.text, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk || /^src:/.test(String(b.key || '')) ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
|
|
69
70
|
ctx.json(res, a.body, a.status);
|
|
70
71
|
return true;
|
|
71
72
|
}
|
|
@@ -100,7 +101,8 @@ module.exports = {
|
|
|
100
101
|
// No ws: the Briefing across the products the PM picked. A ws: that one workspace, read as before.
|
|
101
102
|
if (!url.searchParams.get('ws')) {
|
|
102
103
|
const sel = products(ctx);
|
|
103
|
-
const
|
|
104
|
+
const sources = await Src.readSources(ctx, Date.now()).catch(() => null);
|
|
105
|
+
const r = await Prod.modelOfMany({ available: sel.available, engine: enginePath(), runtimeDir: ctx.runtimeDir, sources });
|
|
104
106
|
ctx.json(res, r.state === 'error' ? { ok: false, ...r } : { ok: true, ...r }, r.state === 'ready' ? 200 : r.state === 'reading' ? 202 : 500);
|
|
105
107
|
return true;
|
|
106
108
|
}
|
|
@@ -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();
|
|
@@ -151,6 +151,7 @@ input[type=range]:active::-webkit-slider-thumb{transform:scale(1.15)}
|
|
|
151
151
|
.dbody .kv{display:grid;grid-template-columns:110px minmax(0,1fr);gap:6px 14px;font-size:15px}
|
|
152
152
|
.dbody .kv span:nth-child(odd){color:var(--ink-3)}
|
|
153
153
|
.cta{height:48px;padding:0 24px;border-radius:var(--r-chip);background:var(--solid);color:var(--on-solid);font-weight:650;display:inline-flex;align-items:center;gap:8px;transition:transform .35s var(--spring)}
|
|
154
|
+
a.cta{text-decoration:none}
|
|
154
155
|
.cta:hover{transform:translateY(-2px)}
|
|
155
156
|
.cta:active{transform:scale(.97)}
|
|
156
157
|
.cta svg{width:18px;height:18px}
|
|
@@ -1210,3 +1211,58 @@ button.dsn:hover{box-shadow:inset 0 0 0 1.5px rgb(79,109,245)}
|
|
|
1210
1211
|
.pkempty{padding:10px 14px}
|
|
1211
1212
|
.top{position:relative;z-index:6}
|
|
1212
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}
|
|
@@ -8,8 +8,9 @@ const ICON = {
|
|
|
8
8
|
meeting: '<rect x="4" y="5" width="16" height="15" rx="3"/><path d="M4 10h16M9 3v4M15 3v4"/>',
|
|
9
9
|
merge: '<circle cx="6" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="9" r="2"/><path d="M6 8v8M18 11c0 4-6 3-12 5"/>',
|
|
10
10
|
run: '<path d="M13 3 5 14h6l-1 7 8-11h-6z"/>',
|
|
11
|
+
digest: '<path d="M5 7h14M5 12h14M5 17h9"/>',
|
|
11
12
|
};
|
|
12
|
-
const SRC = { meeting: 'Calendar', merge: 'Merges', run: 'Agent runs' };
|
|
13
|
+
const SRC = { meeting: 'Calendar', merge: 'Merges', run: 'Agent runs', digest: 'Digests' };
|
|
13
14
|
let M = null; // the model
|
|
14
15
|
const on = new Set(Object.keys(SRC));
|
|
15
16
|
const pinned = new Set();
|
|
@@ -70,7 +71,7 @@ function evHtml(e, i) {
|
|
|
70
71
|
}
|
|
71
72
|
const ALL = () => M.today.ahead.concat(M.today.earlier);
|
|
72
73
|
function drawChips() {
|
|
73
|
-
$('#srcChips').innerHTML = Object.entries(SRC).map(([k, n]) => `<button class="fchip" data-k="${k}" aria-pressed="${on.has(k)}">${n}<em>${M.today.counts[k] || 0}</em></button>`).join('');
|
|
74
|
+
$('#srcChips').innerHTML = Object.entries(SRC).filter(([k]) => k !== 'digest' || M.today.counts.digest).map(([k, n]) => `<button class="fchip" data-k="${k}" aria-pressed="${on.has(k)}">${n}<em>${M.today.counts[k] || 0}</em></button>`).join('');
|
|
74
75
|
}
|
|
75
76
|
function drawTl() {
|
|
76
77
|
const T = M.today, all = ALL(), ahead = T.ahead.filter((e) => on.has(e.kind)), early = T.earlier.filter((e) => on.has(e.kind));
|
|
@@ -126,6 +127,7 @@ function cardHtml(r) {
|
|
|
126
127
|
return `${head}<p class="ct">Return this run: why</p><div class="seg2" id="retWhy">${RET_REASONS.map((x) => `<button data-ret="${esc(x)}" aria-pressed="${RET[r.key] === x}">${esc(x)}</button>`).join('')}</div>${err}<div class="cact"><button class="cta" id="recBtn" data-rec="return" disabled>${CHECK}<span>${esc(d.action)}</span></button></div></div>`;
|
|
127
128
|
}
|
|
128
129
|
if (d.opts.length) return `${head}${opts}${d.nodef ? `<p class="nodef">${esc(d.nodef)}</p>` : ''}<div class="cact" id="cardAct"><button class="cta" disabled title="${esc(d.hold || 'Nothing here can be recorded yet.')}">${o ? esc(d.prim[o.k]) : 'Your call: choose one'}</button></div>${d.hold ? holdNote(d.hold) : ''}</div>`;
|
|
130
|
+
if (d.link) return `${head}<div class="cact"><a class="cta" id="srcLink" href="${esc(d.link)}" target="_blank" rel="noopener noreferrer">${esc(d.linkLabel || 'Open the source')}</a></div></div>`;
|
|
129
131
|
return `${head}${d.nodef ? `<p class="nodef">${esc(d.nodef)}</p>` : ''}${d.action ? `<div class="cact"><button class="cta" disabled title="${esc(d.hold || 'Nothing here can be recorded.')}">${CHECK}${esc(d.action)}</button></div>${d.hold ? holdNote(d.hold) : ''}` : ''}</div>`;
|
|
130
132
|
}
|
|
131
133
|
function paneHtml(r) {
|
|
@@ -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">
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"briefing-premium-board.test.js",
|
|
43
43
|
"briefing-premium-initiative.test.js",
|
|
44
44
|
"briefing-premium-products.test.js",
|
|
45
|
+
"briefing-premium-sources.test.js",
|
|
46
|
+
"briefing-premium-metrics.test.js",
|
|
45
47
|
"calendar-events.test.js",
|
|
46
48
|
"calendar-supersede.test.js",
|
|
47
49
|
"tracker-read.test.js",
|
|
@@ -202,6 +204,8 @@
|
|
|
202
204
|
"briefing-premium-board.test.js": 1,
|
|
203
205
|
"briefing-premium-initiative.test.js": 1,
|
|
204
206
|
"briefing-premium-products.test.js": 1,
|
|
207
|
+
"briefing-premium-sources.test.js": 1,
|
|
208
|
+
"briefing-premium-metrics.test.js": 1,
|
|
205
209
|
"calendar-events.test.js": 1,
|
|
206
210
|
"calendar-supersede.test.js": 1,
|
|
207
211
|
"tracker-read.test.js": 1,
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
"briefing-premium-board-test.js",
|
|
7
7
|
"briefing-premium-initiative-test.js",
|
|
8
8
|
"briefing-premium-products-test.js",
|
|
9
|
+
"briefing-premium-sources-test.js",
|
|
10
|
+
"briefing-premium-metrics-test.js",
|
|
9
11
|
"briefing-premium-agent-server.test.js"
|
|
10
12
|
],
|
|
11
13
|
"seconds": {
|
|
@@ -14,6 +16,8 @@
|
|
|
14
16
|
"briefing-premium-board-test.js": 120,
|
|
15
17
|
"briefing-premium-initiative-test.js": 120,
|
|
16
18
|
"briefing-premium-products-test.js": 90,
|
|
19
|
+
"briefing-premium-sources-test.js": 90,
|
|
20
|
+
"briefing-premium-metrics-test.js": 120,
|
|
17
21
|
"briefing-premium-agent-server.test.js": 40
|
|
18
22
|
}
|
|
19
23
|
}
|