create-pmos 0.135.0 → 0.137.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/guide/data-freshness.md +10 -2
- package/workspace/lib/records-dir.js +29 -0
- package/workspace/lib/routes-c3-init.js +5 -5
- package/workspace/lib/routes-c3-reports.js +4 -3
- package/workspace/lib/routes-c3-strategy.js +3 -2
- package/workspace/lib/routes-records.js +7 -5
- package/workspace/lib/routes-roadmap-ops.js +12 -10
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-shell.js +1 -1
- package/workspace/public/pm/components.css +2 -1
- package/workspace/public/pm/freshness-line.js +16 -11
- package/workspace/public/pm/freshness-words.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.137.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@6693e56
|
|
2
|
+
assembled: 2026-10-12
|
|
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-12T00:57:28Z"}
|
|
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-12T00:57:28Z"}
|
|
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-12T00:57:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Data freshness: how the workspace keeps up to date, and how old each source is"
|
|
4
|
-
timestamp: 2026-10-
|
|
4
|
+
timestamp: 2026-10-12
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Data freshness
|
|
@@ -37,4 +37,12 @@ The events are kept in your own runtime folder on this machine, never in a repos
|
|
|
37
37
|
|
|
38
38
|
## Where you see it
|
|
39
39
|
|
|
40
|
-
Each page shows a short
|
|
40
|
+
Each page shows a short line under its title that says when each source was last read, for example **Initiatives read 08:02 · Calendar read 07:15 · Jira status read 06:31**. The line never says that the data is up to date, because the agents read these sources on a schedule and not live.
|
|
41
|
+
|
|
42
|
+
- A source older than its limit is amber.
|
|
43
|
+
- A source that was never read says **not read yet**, in amber.
|
|
44
|
+
- A source whose last read failed is red and says when it was last read.
|
|
45
|
+
- A watched repository is not named, because the workspace checks it every few seconds.
|
|
46
|
+
- When a source is old, failed or never read, a **Refresh** button for the first of them follows the line.
|
|
47
|
+
|
|
48
|
+
Settings › Data freshness shows every source in one table: when it was read, its state, its schedule, and **Refresh**.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* records-dir.js — the records folder (planning/records/<kind>.jsonl, scripts/records) of the workspace a request is about.
|
|
3
|
+
*
|
|
4
|
+
* recordsDirFor(ctx, ws) → an absolute path
|
|
5
|
+
*
|
|
6
|
+
* Order: ctx.recordsDir (a test's folder) → the workspace's own folder, from scripts/signals/layout-paths.js (a kit tenant keeps
|
|
7
|
+
* its records in pmos/state/records, the PMOS repo in planning/records) → the PMOS checkout this server runs from, the old
|
|
8
|
+
* default, for a server with no registered workspace. `ws` is the ?ws= id; null is the home workspace (server.js getWs).
|
|
9
|
+
*
|
|
10
|
+
* Before this, every records route read the PMOS checkout's planning/records whatever the workspace, so a tenant's Reports and
|
|
11
|
+
* Signals showed nothing (initiative reports-tenant-records).
|
|
12
|
+
*/
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { repoScript } = require('./repo-script.js');
|
|
15
|
+
|
|
16
|
+
const CHECKOUT_RECORDS = path.join(__dirname, '..', '..', 'planning', 'records');
|
|
17
|
+
|
|
18
|
+
function recordsDirFor(ctx, ws) {
|
|
19
|
+
if (ctx && ctx.recordsDir) return ctx.recordsDir;
|
|
20
|
+
if (ctx && ctx.repoRoot) return path.join(ctx.repoRoot, 'planning', 'records');
|
|
21
|
+
let w = null;
|
|
22
|
+
try { w = ctx && typeof ctx.getWs === 'function' ? ctx.getWs(ws || null) : null; } catch { w = null; }
|
|
23
|
+
if (w && w.root) {
|
|
24
|
+
try { return require(repoScript('signals/layout-paths.js')).pathsFor(w.root).recordsDir; } catch { /* fall through */ }
|
|
25
|
+
}
|
|
26
|
+
return CHECKOUT_RECORDS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { recordsDirFor, CHECKOUT_RECORDS };
|
|
@@ -22,16 +22,16 @@ function readPeople(runtimeDir) {
|
|
|
22
22
|
} catch { return []; }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function roadmapRead(ctx) {
|
|
26
|
-
const root = roadmapOps.rootOf(ctx);
|
|
25
|
+
function roadmapRead(ctx, ws) {
|
|
26
|
+
const root = roadmapOps.rootOf(ctx, ws);
|
|
27
27
|
const bets = records.list(root, 'bets');
|
|
28
28
|
const people = readPeople(ctx.runtimeDir);
|
|
29
29
|
const krs = records.list(root, 'kr-metrics').map((k) => ({ id: k.rid, title: k.metric }));
|
|
30
30
|
return { status: 200, body: { ok: true, bets, capacity: roadmap.capacityLine(bets, people), people, krs } };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function breakdownRead(ctx) {
|
|
34
|
-
const root = roadmapOps.rootOf(ctx);
|
|
33
|
+
function breakdownRead(ctx, ws) {
|
|
34
|
+
const root = roadmapOps.rootOf(ctx, ws);
|
|
35
35
|
const items = breakdown.list(root);
|
|
36
36
|
const people = readPeople(ctx.runtimeDir);
|
|
37
37
|
const events = breakdown.readLines(root).filter((e) => ['add', 'edit', 'delete', 'dependency'].includes(e.kind))
|
|
@@ -46,7 +46,7 @@ module.exports = {
|
|
|
46
46
|
handle: async (req, res, url, ctx) => {
|
|
47
47
|
if (req.method !== 'GET') return false;
|
|
48
48
|
const sub = url.pathname.slice('/api/c3-init'.length);
|
|
49
|
-
const r = sub === '/roadmap' ? roadmapRead(ctx) : sub === '/breakdown' ? breakdownRead(ctx) : null;
|
|
49
|
+
const r = sub === '/roadmap' ? roadmapRead(ctx, url.searchParams.get('ws')) : sub === '/breakdown' ? breakdownRead(ctx, url.searchParams.get('ws')) : null;
|
|
50
50
|
if (!r) return false;
|
|
51
51
|
ctx.json(res, r.body, r.status);
|
|
52
52
|
return true;
|
|
@@ -124,7 +124,7 @@ async function runDue(ctx, now = new Date()) {
|
|
|
124
124
|
if (!w) throw new Error('no workspace is registered');
|
|
125
125
|
const slug = w.id || (w.root ? path.basename(path.resolve(w.root)) : null);
|
|
126
126
|
const facts = await (ctx.readFacts || readFacts)(w);
|
|
127
|
-
const root = rootOf(ctx);
|
|
127
|
+
const root = rootOf(ctx, s.ws || null);
|
|
128
128
|
let hypotheses = [];
|
|
129
129
|
try { hypotheses = require(repoScript('records/index.js')).list(root, 'hypotheses'); } catch { hypotheses = []; }
|
|
130
130
|
out = runOne(s, { facts, slug, hypotheses, runtimeDir: ctx.runtimeDir, today: ctx.today,
|
|
@@ -137,7 +137,8 @@ async function runDue(ctx, now = new Date()) {
|
|
|
137
137
|
return done;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
const
|
|
140
|
+
const { recordsDirFor } = require('./records-dir.js');
|
|
141
|
+
const rootOf = (ctx, ws) => recordsDirFor(ctx, ws); // the workspace's records (lib/records-dir.js)
|
|
141
142
|
|
|
142
143
|
async function readFacts(w) {
|
|
143
144
|
return require('./facts-async.js').readFactsAsync(path.resolve(w.root));
|
|
@@ -166,7 +167,7 @@ module.exports = {
|
|
|
166
167
|
let index = null; // the Initiatives board model; without it (no module, or a read error) boardOf decides
|
|
167
168
|
try { index = require('./routes-boards.js').boardIndex(ctx, w); } catch { index = null; }
|
|
168
169
|
if (p === '/api/report-dashboards') return send(list({ facts, runtimeDir: ctx.runtimeDir, slug, index }));
|
|
169
|
-
const root = rootOf(ctx);
|
|
170
|
+
const root = rootOf(ctx, url.searchParams.get('ws'));
|
|
170
171
|
let hypotheses = [];
|
|
171
172
|
try { hypotheses = require(repoScript('records/index.js')).list(root, 'hypotheses'); } catch { hypotheses = []; }
|
|
172
173
|
return send(data({ scope: url.searchParams.get('scope'), id: url.searchParams.get('id') }, {
|
|
@@ -30,7 +30,8 @@ const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !
|
|
|
30
30
|
const ms = (s) => Date.parse(String(s).slice(0, 10) + 'T00:00:00Z');
|
|
31
31
|
const fin = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
32
32
|
const r2 = (v) => (fin(v) ? Math.round(v * 100) / 100 : null);
|
|
33
|
-
const
|
|
33
|
+
const { recordsDirFor } = require('./records-dir.js');
|
|
34
|
+
const rootOf = (ctx, ws) => recordsDirFor(ctx, ws); // the workspace's records (lib/records-dir.js)
|
|
34
35
|
const monthKey = (t) => { const d = new Date(t); return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1); };
|
|
35
36
|
const monthIdx = (k) => Number(k.slice(0, 4)) * 12 + Number(k.slice(5, 7)) - 1;
|
|
36
37
|
const keyOfIdx = (i) => Math.floor(i / 12) + '-' + pad((i % 12) + 1);
|
|
@@ -162,7 +163,7 @@ module.exports = {
|
|
|
162
163
|
if (url.pathname !== '/api/c3-strategy') return false;
|
|
163
164
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
164
165
|
try {
|
|
165
|
-
const body = build(rootOf(ctx), path.join(ctx.runtimeDir || '.', 'signals', 'signals.jsonl'), todayStr());
|
|
166
|
+
const body = build(rootOf(ctx, url.searchParams.get('ws')), path.join(ctx.runtimeDir || '.', 'signals', 'signals.jsonl'), todayStr());
|
|
166
167
|
ctx.json(res, body, 200);
|
|
167
168
|
} catch (e) { ctx.json(res, { ok: false, error: String(e && e.message || e) }, 500); }
|
|
168
169
|
return true;
|
|
@@ -15,7 +15,9 @@ const { repoScript } = require('./repo-script.js');
|
|
|
15
15
|
const records = require(repoScript('records/index.js'));
|
|
16
16
|
const ledger = require(repoScript('trust/ledger.js'));
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const { recordsDirFor } = require('./records-dir.js');
|
|
19
|
+
const rootOf = (ctx, ws) => recordsDirFor(ctx, ws); // the workspace's records (lib/records-dir.js)
|
|
20
|
+
const writeRootOf = (ctx, body) => recordsDirFor(ctx, body && body.ws); // writes: the same folder the reads use
|
|
19
21
|
const UI = () => ledger.actorFrom({ ui: true });
|
|
20
22
|
const bad = (status, error, reasons) => ({ status, body: { ok: false, error, reasons } });
|
|
21
23
|
|
|
@@ -46,16 +48,16 @@ module.exports = {
|
|
|
46
48
|
const m = url.pathname.match(/^\/api\/records\/([a-z-]+)(?:\/(add|edit|reading))?$/);
|
|
47
49
|
if (!m) return false;
|
|
48
50
|
const [, kind, verb] = m;
|
|
49
|
-
if (req.method === 'GET' && !verb) { const r = listRecords(rootOf(ctx), kind); ctx.json(res, r.body, r.status); return true; }
|
|
51
|
+
if (req.method === 'GET' && !verb) { const r = listRecords(rootOf(ctx, url.searchParams.get('ws')), kind); ctx.json(res, r.body, r.status); return true; }
|
|
50
52
|
if (req.method !== 'POST' || !verb) return false;
|
|
51
53
|
const got = await ctx.readJson(req);
|
|
52
54
|
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
53
55
|
const body = got.body || {};
|
|
54
56
|
const g = guard.requirePagePress(req, body);
|
|
55
57
|
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
56
|
-
const r = verb === 'add' ? addRecord(
|
|
57
|
-
: verb === 'edit' ? editRecord(
|
|
58
|
-
: kind === 'kr-metrics' ? readingRecord(
|
|
58
|
+
const r = verb === 'add' ? addRecord(writeRootOf(ctx, body), kind, body)
|
|
59
|
+
: verb === 'edit' ? editRecord(writeRootOf(ctx, body), kind, body)
|
|
60
|
+
: kind === 'kr-metrics' ? readingRecord(writeRootOf(ctx, body), body) : bad(404, 'readings belong to kr-metrics');
|
|
59
61
|
ctx.json(res, r.body, r.status);
|
|
60
62
|
return true;
|
|
61
63
|
},
|
|
@@ -26,7 +26,9 @@ const roadmap = require(repoScript('roadmap-ops.js'));
|
|
|
26
26
|
const breakdown = require(repoScript('breakdown-ops.js'));
|
|
27
27
|
|
|
28
28
|
const REPO = path.join(__dirname, '..', '..');
|
|
29
|
-
const
|
|
29
|
+
const { recordsDirFor } = require('./records-dir.js');
|
|
30
|
+
const rootOf = (ctx, ws) => recordsDirFor(ctx, ws); // the workspace's records (lib/records-dir.js)
|
|
31
|
+
const writeRootOf = (ctx, body) => recordsDirFor(ctx, body && body.ws); // writes: the same folder the reads use
|
|
30
32
|
const UI = () => ledger.actorFrom({ ui: true });
|
|
31
33
|
const out = (status, body) => ({ status, body });
|
|
32
34
|
const refusal = (r, status = 400) => out(status, { ok: false, error: r.error, ...Object.fromEntries(Object.entries(r).filter(([k]) => !['ok', 'error'].includes(k))) });
|
|
@@ -108,14 +110,14 @@ function setPeople(runtimeDir, body) {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
const POSTS = {
|
|
111
|
-
'/bet/add': (c, b) => betAdd(
|
|
112
|
-
'/bet/move': (c, b) => betMove(
|
|
113
|
-
'/bet/take-off': (c, b) => betTakeOff(
|
|
114
|
-
'/breakdown/add': (c, b) => breakdownAdd(
|
|
115
|
-
'/breakdown/edit': (c, b) => breakdownEdit(
|
|
116
|
-
'/breakdown/delete': (c, b) => breakdownDelete(
|
|
117
|
-
'/breakdown/undo': (c, b) => breakdownUndo(
|
|
118
|
-
'/breakdown/dependency': (c, b) => breakdownDependency(
|
|
113
|
+
'/bet/add': (c, b) => betAdd(writeRootOf(c, b), b, c),
|
|
114
|
+
'/bet/move': (c, b) => betMove(writeRootOf(c, b), b, c),
|
|
115
|
+
'/bet/take-off': (c, b) => betTakeOff(writeRootOf(c, b), b),
|
|
116
|
+
'/breakdown/add': (c, b) => breakdownAdd(writeRootOf(c, b), b),
|
|
117
|
+
'/breakdown/edit': (c, b) => breakdownEdit(writeRootOf(c, b), b),
|
|
118
|
+
'/breakdown/delete': (c, b) => breakdownDelete(writeRootOf(c, b), b),
|
|
119
|
+
'/breakdown/undo': (c, b) => breakdownUndo(writeRootOf(c, b), b),
|
|
120
|
+
'/breakdown/dependency': (c, b) => breakdownDependency(writeRootOf(c, b), b),
|
|
119
121
|
'/people': (c, b) => setPeople(c.runtimeDir, b),
|
|
120
122
|
};
|
|
121
123
|
|
|
@@ -125,7 +127,7 @@ module.exports = {
|
|
|
125
127
|
handle: async (req, res, url, ctx) => {
|
|
126
128
|
const sub = url.pathname.slice('/api/roadmap-ops'.length);
|
|
127
129
|
if (req.method === 'GET' && sub === '/bet/anchor-suggestions') {
|
|
128
|
-
const r = betAnchorSuggestions(rootOf(ctx), { id: url.searchParams.get('id') }, ctx);
|
|
130
|
+
const r = betAnchorSuggestions(rootOf(ctx, url.searchParams.get('ws')), { id: url.searchParams.get('id') }, ctx);
|
|
129
131
|
ctx.json(res, r.body, r.status);
|
|
130
132
|
return true;
|
|
131
133
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.137.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.137.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -117,7 +117,7 @@ function header() {
|
|
|
117
117
|
S.tools.append(S.gearBtn);
|
|
118
118
|
S.moreBtn = ibtn('More', 'more', { 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'data-c3pg': 'more', onclick: toggleMore });
|
|
119
119
|
S.tools.append(el('span', { class: 'c3pg-menuwrap' }, S.moreBtn));
|
|
120
|
-
/* workspace-data-freshness R9:
|
|
120
|
+
/* workspace-data-freshness R9: when each source was last read, never "up to date" (freshness-line.js) */
|
|
121
121
|
S.fresh = S.demo ? null : freshnessLine(S.ws);
|
|
122
122
|
return el('div', { class: 'c3top' }, el('div', null, S.ttl, S.sub, S.fresh), S.tools);
|
|
123
123
|
}
|
|
@@ -3511,9 +3511,10 @@
|
|
|
3511
3511
|
.fr-fresh__state--fresh { color: var(--foreground); }
|
|
3512
3512
|
.fr-fresh__state--stale { color: var(--warning); }
|
|
3513
3513
|
.fr-fresh__state--failed { color: var(--destructive); }
|
|
3514
|
-
.fr-fresh__state--never
|
|
3514
|
+
.fr-fresh__state--never { color: var(--warning); }
|
|
3515
3515
|
.fr-fresh__state--building,
|
|
3516
3516
|
.fr-fresh__state--off { color: var(--muted-foreground); }
|
|
3517
|
+
.fr-fresh__sep { color: var(--muted-foreground); }
|
|
3517
3518
|
.fr-fresh-table__grid {
|
|
3518
3519
|
width: 100%;
|
|
3519
3520
|
border-collapse: collapse;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/* pm/freshness-line.js — how old each source of the workspace is (workspace-data-freshness R9).
|
|
2
2
|
*
|
|
3
|
-
* freshnessLine(ws) → element the console header's short line:
|
|
4
|
-
*
|
|
3
|
+
* freshnessLine(ws) → element the console header's short line: when each source was last read ("Calendar read
|
|
4
|
+
* 07:15 · Jira status read 08:02"), the old, failed and never-read ones flagged, and
|
|
5
|
+
* a Refresh button for the first of those. It never says "up to date": the sources
|
|
6
|
+
* are read on a schedule, not live (pm/freshness-words.js). Updates itself every 15 s.
|
|
5
7
|
* freshnessTable(ws) → element Settings › Data freshness: every source, when it was read, its state, its schedule,
|
|
6
8
|
* Refresh, and the repository's Watch for changes switch.
|
|
7
9
|
*
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
import { el, setChildren } from './dom.js';
|
|
14
16
|
import { pmGet, pmPost } from './http.js';
|
|
15
17
|
import { button, SEL } from './primitives.js';
|
|
18
|
+
import { lineItems, behindOf } from './freshness-words.js';
|
|
16
19
|
|
|
17
20
|
const POLL_MS = 15000;
|
|
18
21
|
const live = new Map(); // kind|ws -> { node, timer, version }
|
|
@@ -26,7 +29,7 @@ function ago(iso) {
|
|
|
26
29
|
if (ms < 24 * 3600000) return 'today ' + hhmm(iso);
|
|
27
30
|
return Math.round(ms / (24 * 3600000)) + ' d ago';
|
|
28
31
|
}
|
|
29
|
-
const STATE_WORD = { fresh: '
|
|
32
|
+
const STATE_WORD = { fresh: 'Recent', stale: 'Old', never: 'Never read', building: 'Reading…', failed: 'Failed', off: 'Off' };
|
|
30
33
|
// the colour of a state: a class in components.css (.fr-fresh__state--<state>), coloured from the tokens
|
|
31
34
|
const tone = (state) => 'fr-fresh__state fr-fresh__state--' + (STATE_WORD[state] ? state : 'never');
|
|
32
35
|
const label = (b, t) => { const l = b.querySelector(SEL.btnLabel); (l || b).textContent = t; };
|
|
@@ -64,20 +67,22 @@ function poll(key, ws, draw) {
|
|
|
64
67
|
return cur.node;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
/** The header line:
|
|
70
|
+
/** The header line: when each source was last read, old, failed and never-read ones flagged. It never claims "up to date". */
|
|
68
71
|
export function freshnessLine(ws) {
|
|
69
72
|
return poll('line|' + ws, ws, (node, d) => {
|
|
70
73
|
const n = node || el('div', { class: 'fr-fresh', 'data-c3pg': 'freshness' });
|
|
71
|
-
if (!d) { if (
|
|
72
|
-
const
|
|
73
|
-
const behind =
|
|
74
|
-
const building = watched.find((s) => s.state === 'building');
|
|
74
|
+
if (!d) { if (node) setChildren(n, [el('span', { class: tone('never') }, 'Could not read when the sources were read')]); else n.textContent = ''; return n; }
|
|
75
|
+
const items = lineItems(d.sources);
|
|
76
|
+
const behind = behindOf(items);
|
|
75
77
|
const kids = [];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
items.forEach((it, i) => {
|
|
79
|
+
if (i) kids.push(el('span', { class: 'fr-fresh__sep', 'aria-hidden': 'true' }, '·'));
|
|
80
|
+
kids.push(el('span', { class: tone(it.state), title: it.title, 'data-fr-line': it.id }, it.text));
|
|
81
|
+
});
|
|
78
82
|
if (behind.length) {
|
|
79
83
|
const first = behind[0];
|
|
80
|
-
const
|
|
84
|
+
const src = d.sources.find((s) => s.id === first.id);
|
|
85
|
+
const btn = button({ label: 'Refresh ' + (src ? src.label : first.id), size: 'sm', attrs: { 'data-c3pg': 'freshness-refresh' },
|
|
81
86
|
onClick: () => refresh(d.ws, first.id, btn) });
|
|
82
87
|
kids.push(btn);
|
|
83
88
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* pm/freshness-words.js — the words of the header's freshness line (pm/freshness-line.js). No DOM, so a suite can import it.
|
|
2
|
+
*
|
|
3
|
+
* lineItems(sources, now?) → [{ id, state, text, title }] one honest phrase per source: when it was last read, or
|
|
4
|
+
* that it was never read, failed or is reading now
|
|
5
|
+
* behindOf(items) → [item] the sources to flag: old, failed or never read
|
|
6
|
+
* whenRead(iso, now?) → '07:15' | 'yesterday 18:02' | '3 d ago'
|
|
7
|
+
*
|
|
8
|
+
* The line never says "up to date". Calendar, Jira, My work and tracker items are read by an agent on a schedule, not
|
|
9
|
+
* live, so the line says when each was read and lets the PM judge. A watched repository is checked every few seconds,
|
|
10
|
+
* so it is named only when it is not watched as it should be.
|
|
11
|
+
*/
|
|
12
|
+
const H = 3600000;
|
|
13
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
14
|
+
const hhmm = (d) => two(d.getHours()) + ':' + two(d.getMinutes());
|
|
15
|
+
const dayStart = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
16
|
+
|
|
17
|
+
export function whenRead(iso, now = Date.now()) {
|
|
18
|
+
const t = Date.parse(iso);
|
|
19
|
+
if (!iso || Number.isNaN(t)) return '';
|
|
20
|
+
const d = new Date(t);
|
|
21
|
+
const today = dayStart(new Date(now));
|
|
22
|
+
if (t >= today) return hhmm(d);
|
|
23
|
+
if (t >= today - 24 * H) return 'yesterday ' + hhmm(d);
|
|
24
|
+
return Math.max(2, Math.round((today - dayStart(d)) / (24 * H))) + ' d ago';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const limitWord = (ms) => (ms >= 48 * H ? Math.round(ms / (24 * H)) + ' days' : Math.round(ms / H) + ' hours');
|
|
28
|
+
|
|
29
|
+
export function lineItems(sources, now = Date.now()) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const s of sources || []) {
|
|
32
|
+
if (!s || s.state === 'off') continue;
|
|
33
|
+
if (s.id === 'repository' && s.state === 'fresh') continue; // watched: checked every few seconds
|
|
34
|
+
const at = whenRead(s.as_of, now);
|
|
35
|
+
let text; let title;
|
|
36
|
+
if (s.state === 'building') { text = s.label + ' reading…'; title = 'Being read now'; }
|
|
37
|
+
else if (s.state === 'never') { text = s.label + ' not read yet'; title = 'Never read on this machine'; }
|
|
38
|
+
else if (s.state === 'failed') { text = s.label + ' failed' + (at ? ', last read ' + at : ''); title = 'The last read failed'; }
|
|
39
|
+
else if (s.state === 'stale') { text = s.label + ' read ' + at; title = s.stale_after_ms ? 'Older than ' + limitWord(s.stale_after_ms) : 'Changed since it was read'; }
|
|
40
|
+
else { text = s.label + ' read ' + at; title = 'Last read ' + (s.as_of ? new Date(Date.parse(s.as_of)).toLocaleString() : ''); }
|
|
41
|
+
out.push({ id: s.id, state: s.state, text, title });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const behindOf = (items) => items.filter((i) => i.state === 'stale' || i.state === 'failed' || i.state === 'never');
|