@arjunkhera/atlas 0.3.24 → 0.3.25
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/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +17 -0
- package/door/lib/view.mjs +46 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +1 -0
- package/view/page.html +563 -0
- package/work/lib/questions.mjs +17 -56
- package/work/lib/verb-fields.mjs +3 -3
- package/work/lib/view-rules.mjs +279 -0
package/door/cli.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
|
|
|
25
25
|
import { checkPaths, LIMITS } from './lib/ste.mjs';
|
|
26
26
|
import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
|
|
27
27
|
import { writeDesignPage, readTracker } from './lib/design-build.mjs';
|
|
28
|
+
import { writeViewPage, viewCapabilities } from './lib/view.mjs';
|
|
28
29
|
import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
29
30
|
import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
|
|
30
31
|
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
@@ -59,6 +60,10 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
59
60
|
--out <file> the page; default docs/artifacts/<folder name>.html
|
|
60
61
|
--draft build even when the check has findings, and mark the page a draft
|
|
61
62
|
|
|
63
|
+
atlas view build --product <id> build the project view page of one product: the four views, read live from the tracker
|
|
64
|
+
--name <name> the product's name on the page; default the id with a capital
|
|
65
|
+
--out <file> the page; default docs/artifacts/<id>-view.html
|
|
66
|
+
|
|
62
67
|
atlas tests check --root <area> run the link check on the tests of an area
|
|
63
68
|
--halves <list> static, lint, run; default all three
|
|
64
69
|
--evidence <dir> where the evidence files are; default <area>/test/evidence
|
|
@@ -120,6 +125,7 @@ export const FLAGS = Object.freeze({
|
|
|
120
125
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
|
|
121
126
|
ste: { share: 'switch', terms: 'value' },
|
|
122
127
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
128
|
+
view: { product: 'value', name: 'value', out: 'value' },
|
|
123
129
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch' },
|
|
124
130
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
125
131
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -296,6 +302,16 @@ function designCommand(chosen) {
|
|
|
296
302
|
return count ? 1 : 0;
|
|
297
303
|
}
|
|
298
304
|
|
|
305
|
+
function viewCommand(chosen) {
|
|
306
|
+
if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
|
|
307
|
+
const product = chosen.product;
|
|
308
|
+
const out = resolve(chosen.out ?? join('docs', 'artifacts', `${product}-view.html`));
|
|
309
|
+
const page = writeViewPage(out, { packageRoot: PACKAGE_ROOT, product, name: chosen.name, version: ATLAS_VERSION });
|
|
310
|
+
line(`Wrote ${out}: the project view of ${page.name} (product ${page.product}). It holds no records; it reads them at open.`);
|
|
311
|
+
line(`Publish it with this capability, and no other: ${JSON.stringify(viewCapabilities())}`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
299
315
|
async function testsCommand(chosen) {
|
|
300
316
|
const root = rootOf(chosen);
|
|
301
317
|
const what = chosen._[1];
|
|
@@ -343,6 +359,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
343
359
|
case 'check': return checkCommand(chosen);
|
|
344
360
|
case 'ste': return steCommand(chosen);
|
|
345
361
|
case 'design': return designCommand(chosen);
|
|
362
|
+
case 'view': return viewCommand(chosen);
|
|
346
363
|
case 'tests': return testsCommand(chosen);
|
|
347
364
|
default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
|
|
348
365
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The project view page builder: `atlas view build`. It puts the view rules
|
|
2
|
+
// (work/lib/view-rules.mjs) and one product id into the page template
|
|
3
|
+
// (view/page.html), and writes one page. The page holds no record data: it
|
|
4
|
+
// reads the tracker at open, through the viewer's Engram connector.
|
|
5
|
+
// Design: docs/design-docs/project-view (tracker item 6dd1761a).
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
// The one connector grant the page needs, for the Artifact publish. Both
|
|
10
|
+
// tools only read.
|
|
11
|
+
export const VIEW_CONNECTOR = Object.freeze({ server: 'Engram', tools: Object.freeze(['entities_list', 'get']) });
|
|
12
|
+
export const viewCapabilities = () => ({ mcp: { servers: [{ server: VIEW_CONNECTOR.server, tools: [...VIEW_CONNECTOR.tools] }] } });
|
|
13
|
+
|
|
14
|
+
const PRODUCT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
15
|
+
const quoted = (value) => JSON.stringify(String(value)).slice(1, -1).replace(/</g, '\\u003c');
|
|
16
|
+
const plain = (value) => String(value).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]);
|
|
17
|
+
|
|
18
|
+
export function buildViewPage({ packageRoot, product, name, version }) {
|
|
19
|
+
if (!PRODUCT_ID.test(String(product ?? ''))) throw new Error('give a product id: atlas view build --product <id>, such as atlas');
|
|
20
|
+
const label = String(name ?? '').trim() || product.charAt(0).toUpperCase() + product.slice(1);
|
|
21
|
+
if (label.length > 80) throw new Error('--name is at most 80 characters');
|
|
22
|
+
const template = readFileSync(join(packageRoot, 'view', 'page.html'), 'utf8');
|
|
23
|
+
const rules = readFileSync(join(packageRoot, 'work', 'lib', 'view-rules.mjs'), 'utf8');
|
|
24
|
+
if (/<\/script/i.test(rules)) throw new Error('view-rules.mjs holds "</script", so it cannot go into a page');
|
|
25
|
+
const swaps = {
|
|
26
|
+
'/*@@VIEW_RULES@@*/': rules,
|
|
27
|
+
'@@PRODUCT@@': quoted(product),
|
|
28
|
+
'@@PRODUCT_NAME@@': quoted(label),
|
|
29
|
+
'@@ATLAS_VERSION@@': quoted(version ?? ''),
|
|
30
|
+
'@@TITLE@@': plain(`${label} project view`),
|
|
31
|
+
};
|
|
32
|
+
let html = template;
|
|
33
|
+
for (const [mark, value] of Object.entries(swaps)) {
|
|
34
|
+
if (!html.includes(mark)) throw new Error(`the page template has no ${mark}`);
|
|
35
|
+
html = html.split(mark).join(value);
|
|
36
|
+
}
|
|
37
|
+
if (/@@[A-Z_]+@@/.test(html)) throw new Error('the page template has a mark the builder does not fill');
|
|
38
|
+
return { html, product, name: label };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeViewPage(out, options) {
|
|
42
|
+
const page = buildViewPage(options);
|
|
43
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
44
|
+
writeFileSync(out, page.html);
|
|
45
|
+
return page;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arjunkhera/atlas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.25",
|
|
4
4
|
"description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"work/",
|
|
22
22
|
"shape/",
|
|
23
23
|
"tests/",
|
|
24
|
+
"view/",
|
|
24
25
|
"skills/",
|
|
25
26
|
"agents/",
|
|
26
27
|
".claude-plugin/",
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -102,6 +102,7 @@ set. A test keeps the two lists equal.
|
|
|
102
102
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
103
103
|
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
|
|
104
104
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
105
|
+
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
105
106
|
|
|
106
107
|
A person merges every file that `atlas tooling` writes.
|
|
107
108
|
A person merges every file that `atlas tests write` writes.
|
package/view/page.html
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>@@TITLE@@</title>
|
|
7
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:opsz,wght@8..60,600&display=swap">
|
|
8
|
+
<style>
|
|
9
|
+
/* The project view: the four views locked on the canvas "Project view"
|
|
10
|
+
(9 to 14 October 2026). Every colour is a token; the dark set mirrors the
|
|
11
|
+
light set. Built by `atlas view build`; do not edit a built copy. */
|
|
12
|
+
:root{color-scheme:light;
|
|
13
|
+
--paper:#fbfbf9;--card:#ffffff;--ink:#1b1f1d;--body:#3d4743;--muted:#5b6661;--quiet:#6b7570;
|
|
14
|
+
--line:#e3e5e1;--line-soft:#f0f1ee;--line-strong:#c9cec9;--rail:#f0f2ef;--hover:#f6f8fc;
|
|
15
|
+
--link-ink:#1b3566;--link-bg:#f4f6fa;--link-line:#c6d4ec;--sel-bg:#eef3fb;--sel-bar:#2a4d8f;
|
|
16
|
+
--go:#2a4d8f;--go-bg:#dbe5f5;--done:#2f6b57;--done-bg:#cfe3d8;--done-ink:#1f5a46;--ready-bg:#e7f1ec;
|
|
17
|
+
--you-ink:#7a4300;--you-bg:#fdf3e3;--you-bar:#e9a64a;--you-mark:#b8650f;--before:#b8650f;--after:#7d9bd0;
|
|
18
|
+
--grey-bg:#f0f2ef;--est-a:#eceeeb;--est-b:#ffffff;--est-line:#b8bfbb;--danger:#8f2a24;--danger-bg:#fbeceb;
|
|
19
|
+
--shadow:0 1px 2px rgba(27,31,29,.12);--menu-shadow:0 8px 24px rgba(27,31,29,.16)}
|
|
20
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;
|
|
21
|
+
--paper:#151816;--card:#1d211f;--ink:#e8ebe8;--body:#c9cfcb;--muted:#a3ada8;--quiet:#8f9a95;
|
|
22
|
+
--line:#2f3532;--line-soft:#262b28;--line-strong:#3d4440;--rail:#232825;--hover:#232a33;
|
|
23
|
+
--link-ink:#b9cdf0;--link-bg:#1f2735;--link-line:#3a4b68;--sel-bg:#1f2a3b;--sel-bar:#7fa2e0;
|
|
24
|
+
--go:#7fa2e0;--go-bg:#24324a;--done:#7cc3a6;--done-bg:#1f3a30;--done-ink:#a6dcc4;--ready-bg:#1f3329;
|
|
25
|
+
--you-ink:#f0c58a;--you-bg:#3a2c18;--you-bar:#c98a35;--you-mark:#e0a050;--before:#e0a050;--after:#7f9fd6;
|
|
26
|
+
--grey-bg:#262b28;--est-a:#2a302d;--est-b:#1d211f;--est-line:#4a524e;--danger:#f0a39d;--danger-bg:#3a2220;
|
|
27
|
+
--shadow:0 1px 2px rgba(0,0,0,.5);--menu-shadow:0 8px 24px rgba(0,0,0,.5)}}
|
|
28
|
+
:root[data-theme="dark"]{color-scheme:dark;
|
|
29
|
+
--paper:#151816;--card:#1d211f;--ink:#e8ebe8;--body:#c9cfcb;--muted:#a3ada8;--quiet:#8f9a95;
|
|
30
|
+
--line:#2f3532;--line-soft:#262b28;--line-strong:#3d4440;--rail:#232825;--hover:#232a33;
|
|
31
|
+
--link-ink:#b9cdf0;--link-bg:#1f2735;--link-line:#3a4b68;--sel-bg:#1f2a3b;--sel-bar:#7fa2e0;
|
|
32
|
+
--go:#7fa2e0;--go-bg:#24324a;--done:#7cc3a6;--done-bg:#1f3a30;--done-ink:#a6dcc4;--ready-bg:#1f3329;
|
|
33
|
+
--you-ink:#f0c58a;--you-bg:#3a2c18;--you-bar:#c98a35;--you-mark:#e0a050;--before:#e0a050;--after:#7f9fd6;
|
|
34
|
+
--grey-bg:#262b28;--est-a:#2a302d;--est-b:#1d211f;--est-line:#4a524e;--danger:#f0a39d;--danger-bg:#3a2220;
|
|
35
|
+
--shadow:0 1px 2px rgba(0,0,0,.5);--menu-shadow:0 8px 24px rgba(0,0,0,.5)}
|
|
36
|
+
*{box-sizing:border-box}
|
|
37
|
+
body{margin:0;background:var(--paper);color:var(--ink);font-family:"IBM Plex Sans",system-ui,sans-serif;font-size:14px;line-height:1.45}
|
|
38
|
+
button{font-family:inherit;color:inherit;font-size:inherit}
|
|
39
|
+
.wrap{max-width:1440px;margin:0 auto;padding:16px 16px 48px;display:flex;flex-direction:column;gap:12px}
|
|
40
|
+
.bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;padding:8px 12px;background:var(--card);border:1px solid var(--line);border-radius:10px}
|
|
41
|
+
.brand{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
|
|
42
|
+
.brand b{font-family:"Source Serif 4",Georgia,serif;font-size:17px;font-weight:600}
|
|
43
|
+
.sep{color:var(--quiet)}
|
|
44
|
+
.needs{border:0;font-size:13px;padding:6px 12px;min-height:32px;border-radius:99px;background:var(--you-bg);color:var(--you-ink);cursor:pointer}
|
|
45
|
+
.needs.zero{background:var(--grey-bg);color:var(--muted)}
|
|
46
|
+
.tools{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px}
|
|
47
|
+
.switch{display:flex;flex-wrap:wrap;gap:4px;background:var(--rail);border-radius:8px;padding:3px}
|
|
48
|
+
.switch button{border:0;padding:6px 12px;min-height:34px;border-radius:6px;background:transparent;color:var(--body);cursor:pointer}
|
|
49
|
+
.switch button[aria-pressed="true"]{background:var(--card);box-shadow:var(--shadow);color:var(--ink);font-weight:600}
|
|
50
|
+
.meta{font-size:12px;color:var(--quiet);display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
51
|
+
.ghost{border:1px solid var(--line-strong);background:var(--card);border-radius:6px;padding:4px 10px;min-height:30px;cursor:pointer;font-size:12px}
|
|
52
|
+
.card{background:var(--card);border:1px solid var(--line-strong);border-radius:10px}
|
|
53
|
+
.banner{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;padding:10px 14px;background:var(--link-bg);border:1px solid var(--link-line);border-radius:10px;font-size:13px}
|
|
54
|
+
.banner b{color:var(--link-ink)}
|
|
55
|
+
.k-before{color:var(--before);font-weight:600}.k-after{color:var(--link-ink);font-weight:600}
|
|
56
|
+
.notice{padding:16px;border-radius:10px;border:1px solid var(--line-strong);background:var(--card);display:flex;flex-direction:column;gap:6px}
|
|
57
|
+
.notice.bad{border-color:var(--danger);background:var(--danger-bg)}
|
|
58
|
+
.group{display:flex;justify-content:space-between;align-items:center;width:100%;border:0;font-size:12px;font-weight:600;letter-spacing:.06em;padding:8px 16px;background:var(--grey-bg);color:var(--body);text-align:left;cursor:pointer;min-height:34px}
|
|
59
|
+
.group.you{background:var(--you-bg);color:var(--you-ink)}
|
|
60
|
+
.row{display:flex;align-items:center;gap:8px;padding:4px 10px 4px 6px;min-height:42px;border-bottom:1px solid var(--line-soft);background:var(--card);box-shadow:inset 3px 0 0 transparent;transition:opacity .15s}
|
|
61
|
+
.row:hover{background:var(--hover)}
|
|
62
|
+
.row.sel{background:var(--sel-bg);box-shadow:inset 3px 0 0 var(--sel-bar)}
|
|
63
|
+
.row.before{box-shadow:inset 3px 0 0 var(--before)}.row.after{box-shadow:inset 3px 0 0 var(--after)}
|
|
64
|
+
.row.dim{opacity:.4}
|
|
65
|
+
.indent{flex:none;align-self:stretch}
|
|
66
|
+
.caret{width:28px;height:28px;flex:none;border:0;background:none;cursor:pointer;color:var(--muted);padding:0;font-size:12px}
|
|
67
|
+
.caret.none{visibility:hidden}
|
|
68
|
+
.dot{width:14px;height:14px;flex:none;border-radius:99px;border:1.5px solid var(--quiet)}
|
|
69
|
+
.dot.proposed{border-style:dashed}
|
|
70
|
+
.dot.active{border-color:var(--go);background:conic-gradient(var(--go) 0 40%,transparent 0)}
|
|
71
|
+
.dot.done{border-color:var(--done);background:var(--done)}
|
|
72
|
+
.dot.dropped{opacity:.5}
|
|
73
|
+
.dot.blocked{background:linear-gradient(135deg,transparent 44%,var(--quiet) 44% 56%,transparent 56%)}
|
|
74
|
+
.dot.paused{background:linear-gradient(90deg,transparent 30%,var(--quiet) 30% 42%,transparent 42% 58%,var(--quiet) 58% 70%,transparent 70%)}
|
|
75
|
+
.body{display:flex;flex-wrap:wrap;align-items:center;gap:6px;flex:1 1 auto;min-width:0}
|
|
76
|
+
.title{border:0;background:none;padding:4px 0;cursor:pointer;text-align:left;min-width:0;max-width:100%;color:var(--ink)}
|
|
77
|
+
.title.l0{font-weight:600}.title.l1{font-weight:500}
|
|
78
|
+
.title.grey{color:var(--muted)}
|
|
79
|
+
.tag{font-size:12px;padding:1px 8px;border-radius:99px;white-space:nowrap;background:var(--grey-bg);color:var(--body)}
|
|
80
|
+
.tag.ready{background:var(--ready-bg);color:var(--done-ink)}
|
|
81
|
+
.tag.you{background:var(--you-bg);color:var(--you-ink)}
|
|
82
|
+
.tag.go{background:var(--go-bg);color:var(--link-ink)}
|
|
83
|
+
.tag.with{background:transparent;color:var(--quiet);border:1px solid var(--line)}
|
|
84
|
+
.tag.done{background:var(--done-bg);color:var(--done-ink)}
|
|
85
|
+
.chip{border:1px solid var(--link-line);background:var(--link-bg);color:var(--link-ink);font-size:12px;padding:2px 8px;min-height:26px;border-radius:99px;cursor:pointer;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}
|
|
86
|
+
.chip:hover{filter:brightness(.96)}
|
|
87
|
+
.count{font-size:12px;color:var(--quiet);flex:none}
|
|
88
|
+
.dots{flex:none;width:32px;height:32px;border:1px solid transparent;border-radius:6px;background:none;cursor:pointer;color:var(--muted);font-size:18px;line-height:1;position:relative}
|
|
89
|
+
.dots:hover,.dots[aria-expanded="true"]{border-color:var(--line-strong);background:var(--card)}
|
|
90
|
+
.menu{position:absolute;z-index:20;min-width:210px;background:var(--card);border:1px solid var(--line-strong);border-radius:8px;box-shadow:var(--menu-shadow);padding:6px;display:flex;flex-direction:column}
|
|
91
|
+
.menu .h{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--quiet);padding:6px 8px 2px}
|
|
92
|
+
.menu button{border:0;background:none;text-align:left;padding:7px 8px;min-height:34px;border-radius:5px;cursor:pointer;display:flex;justify-content:space-between;gap:12px}
|
|
93
|
+
.menu button:hover{background:var(--hover)}
|
|
94
|
+
.menu .k{color:var(--quiet);font-family:"IBM Plex Mono",monospace;font-size:11px}
|
|
95
|
+
.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
|
|
96
|
+
.split{display:grid;grid-template-columns:minmax(300px,5fr) minmax(0,7fr);gap:12px;align-items:start}
|
|
97
|
+
@media (max-width:900px){.split{grid-template-columns:1fr}}
|
|
98
|
+
.stack{display:flex;flex-direction:column;gap:10px}
|
|
99
|
+
.panel{background:var(--card);border:1px solid var(--line-strong);border-radius:10px}
|
|
100
|
+
.phead{display:flex;justify-content:space-between;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line)}
|
|
101
|
+
.pl{display:flex;gap:10px;min-width:0}
|
|
102
|
+
.badge{flex:none;width:24px;height:24px;border-radius:6px;background:var(--ink);color:var(--card);font-family:"IBM Plex Mono",monospace;font-size:12px;display:flex;align-items:center;justify-content:center}
|
|
103
|
+
.path{font-size:12px;color:var(--quiet)}
|
|
104
|
+
.phead h2{margin:0;font-family:"Source Serif 4",Georgia,serif;font-size:19px;font-weight:600}
|
|
105
|
+
.pr{display:flex;align-items:flex-start;gap:4px;position:relative;flex:none}
|
|
106
|
+
.facts{display:grid;grid-template-columns:120px minmax(0,1fr);gap:6px 12px;padding:12px 14px;font-size:13px}
|
|
107
|
+
.facts>span:nth-child(odd){color:var(--quiet)}
|
|
108
|
+
.tabs{display:flex;flex-wrap:wrap;gap:4px;padding:0 14px 8px;border-bottom:1px solid var(--line)}
|
|
109
|
+
.tabs button{border:0;background:none;padding:6px 10px;min-height:32px;border-radius:6px;cursor:pointer;color:var(--body)}
|
|
110
|
+
.tabs button[aria-pressed="true"]{background:var(--rail);color:var(--ink);font-weight:600}
|
|
111
|
+
.entries{display:flex;flex-direction:column}
|
|
112
|
+
.entry{padding:10px 14px;border-bottom:1px solid var(--line-soft);display:flex;flex-direction:column;gap:4px;font-size:13px}
|
|
113
|
+
.entry .eh{display:flex;justify-content:space-between;gap:8px;color:var(--quiet);font-size:12px}
|
|
114
|
+
.entry .ev{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
115
|
+
.opts{display:flex;flex-wrap:wrap;gap:6px}
|
|
116
|
+
.fold1{padding:10px 14px;font-size:13px;color:var(--body)}
|
|
117
|
+
.empty{padding:14px;color:var(--quiet);font-size:13px}
|
|
118
|
+
.cols{display:flex;overflow-x:auto;align-items:stretch}
|
|
119
|
+
.col{flex:0 0 280px;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:300px}
|
|
120
|
+
.colh{display:flex;justify-content:space-between;padding:10px 12px;font-size:12px;font-weight:600;letter-spacing:.06em;color:var(--quiet);border-bottom:1px solid var(--line)}
|
|
121
|
+
.citem{border-bottom:1px solid var(--line-soft);padding:6px 10px;display:flex;flex-direction:column;gap:4px}
|
|
122
|
+
.citem.sel{background:var(--sel-bg);box-shadow:inset 3px 0 0 var(--sel-bar)}
|
|
123
|
+
.citem.dim{opacity:.4}
|
|
124
|
+
.citem>button{border:0;background:none;display:flex;justify-content:space-between;gap:8px;text-align:left;cursor:pointer;padding:2px 0;min-height:30px;align-items:center}
|
|
125
|
+
.citem .sub{display:block;font-size:12px;color:var(--quiet)}
|
|
126
|
+
.selpane{flex:1 0 340px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;min-width:300px}
|
|
127
|
+
.watch{display:flex;flex-direction:column;gap:8px}
|
|
128
|
+
.watch .h{font-size:12px;font-weight:600;letter-spacing:.06em;color:var(--quiet)}
|
|
129
|
+
.pins{display:flex;flex-wrap:wrap;gap:8px}
|
|
130
|
+
.pin{display:flex;gap:8px;align-items:flex-start;padding:10px 12px;border:1px solid var(--line-strong);background:var(--card);border-radius:8px;min-width:240px;max-width:360px}
|
|
131
|
+
.pin .t{display:flex;flex-direction:column;gap:2px;font-size:13px;min-width:0}
|
|
132
|
+
.tl{overflow-x:auto}
|
|
133
|
+
.tlin{min-width:760px}
|
|
134
|
+
.tlh,.tlr{display:flex}
|
|
135
|
+
.tll{flex:0 0 300px;display:flex;align-items:center;gap:4px;padding:0 8px 0 4px;border-right:1px solid var(--line);min-width:0}
|
|
136
|
+
@media (max-width:700px){.tll{flex-basis:200px}}
|
|
137
|
+
.tlg{flex:1 1 auto;position:relative;min-height:40px}
|
|
138
|
+
.tlh{border-bottom:1px solid var(--line);background:var(--rail)}
|
|
139
|
+
.tlh .tll{font-size:12px;font-weight:600;letter-spacing:.06em;color:var(--quiet);padding:10px}
|
|
140
|
+
.tick{position:absolute;top:0;height:40px;display:flex;align-items:center;padding-left:4px;font-size:11px;color:var(--muted);border-left:1px solid var(--line)}
|
|
141
|
+
.tlr{border-bottom:1px solid var(--line-soft);min-height:40px}
|
|
142
|
+
.tlr.sel{background:var(--sel-bg)}.tlr.dim{opacity:.42}
|
|
143
|
+
.tlr .title{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto}
|
|
144
|
+
.b{position:absolute;top:9px;height:22px;border-radius:4px;font-size:11px;line-height:20px;padding:0 6px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin-left:1px}
|
|
145
|
+
.b.parent{top:13px;height:14px;line-height:12px}
|
|
146
|
+
.b.done{background:var(--done-bg);border:1px solid var(--done);color:var(--done-ink)}
|
|
147
|
+
.b.going{background:var(--go-bg);border:1px solid var(--go);color:var(--link-ink)}
|
|
148
|
+
.b.planned{background:var(--card);border:1.5px solid var(--go);color:var(--link-ink)}
|
|
149
|
+
.b.estimate{background:repeating-linear-gradient(135deg,var(--est-a) 0 4px,var(--est-b) 4px 8px);border:1px solid var(--est-line);color:var(--body)}
|
|
150
|
+
.m-ask{position:absolute;top:14px;width:11px;height:11px;transform:rotate(45deg);background:var(--you-mark);margin-left:-5px}
|
|
151
|
+
.m-wait{position:absolute;top:32px;height:4px;border-radius:2px;background:var(--you-bar)}
|
|
152
|
+
.m-dead{position:absolute;top:4px;bottom:4px;width:2px;background:var(--danger)}
|
|
153
|
+
.today{position:absolute;top:0;bottom:0;width:2px;background:var(--you-mark);pointer-events:none}
|
|
154
|
+
.legend{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--body);align-items:center}
|
|
155
|
+
.legend span{display:flex;align-items:center;gap:6px}
|
|
156
|
+
.sw{width:26px;height:12px;border-radius:3px;display:inline-block}
|
|
157
|
+
.toast{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:50;background:var(--ink);color:var(--card);padding:10px 14px;border-radius:8px;box-shadow:var(--menu-shadow);max-width:min(92vw,560px);display:flex;flex-direction:column;gap:6px;font-size:13px}
|
|
158
|
+
.toast textarea{width:100%;min-height:64px;font:12px "IBM Plex Mono",monospace;color:var(--ink);background:var(--card);border-radius:6px;border:0;padding:6px}
|
|
159
|
+
.foot{font-size:12px;color:var(--quiet)}
|
|
160
|
+
.needlist .entry{cursor:pointer}
|
|
161
|
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
|
|
162
|
+
</style>
|
|
163
|
+
</head>
|
|
164
|
+
<body>
|
|
165
|
+
<div class="wrap" id="app"><div class="notice">Loading the tracker…</div></div>
|
|
166
|
+
<script type="module">
|
|
167
|
+
// ---- The view rules, from packages/atlas/work/lib/view-rules.mjs ----
|
|
168
|
+
/*@@VIEW_RULES@@*/
|
|
169
|
+
// ---- The page ----
|
|
170
|
+
const PRODUCT = "@@PRODUCT@@";
|
|
171
|
+
const PRODUCT_NAME = "@@PRODUCT_NAME@@";
|
|
172
|
+
const ATLAS_VERSION = "@@ATLAS_VERSION@@";
|
|
173
|
+
const SERVER = 'Engram';
|
|
174
|
+
const PAGE = 200, MAX_RECORDS = 2000;
|
|
175
|
+
// DAY comes from the view rules above.
|
|
176
|
+
const VIEWS = [['outline', 'E1 · Outline'], ['detail', 'E3 · Outline and detail'], ['columns', 'E2 · Columns'], ['timeline', 'Timeline']];
|
|
177
|
+
|
|
178
|
+
const store = { get(key) { try { return localStorage.getItem(`atlas-view:${PRODUCT}:${key}`); } catch { return null; } }, set(key, value) { try { localStorage.setItem(`atlas-view:${PRODUCT}:${key}`, value); } catch {} } };
|
|
179
|
+
const S = { view: VIEWS.some(([key]) => key === store.get('view')) ? store.get('view') : 'outline', sel: null, closed: {}, opened: {}, showClosed: false, stack: [], folded: {}, tabs: {}, path: [], pins: [], menu: null, needs: false, model: null, error: null, readAt: null, loading: true, cut: false };
|
|
180
|
+
|
|
181
|
+
// ---- Small DOM helpers. Every text from the tracker goes in as text. ----
|
|
182
|
+
function h(tag, attrs, ...kids) {
|
|
183
|
+
const el = document.createElement(tag);
|
|
184
|
+
for (const [key, value] of Object.entries(attrs ?? {})) {
|
|
185
|
+
if (value === undefined || value === null || value === false) continue;
|
|
186
|
+
if (key === 'class') el.className = value;
|
|
187
|
+
else if (key === 'style') el.setAttribute('style', value);
|
|
188
|
+
else if (key.startsWith('on')) el.addEventListener(key.slice(2), value);
|
|
189
|
+
else el.setAttribute(key, value === true ? '' : String(value));
|
|
190
|
+
}
|
|
191
|
+
for (const kid of kids.flat(Infinity)) if (kid !== null && kid !== undefined && kid !== false) el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
|
|
192
|
+
return el;
|
|
193
|
+
}
|
|
194
|
+
const set = (patch) => { Object.assign(S, patch); render(); };
|
|
195
|
+
const M = () => S.model;
|
|
196
|
+
const titleOf = (id) => M().items.get(id)?.title ?? id;
|
|
197
|
+
const short = (text, max = 42) => oneLine(text, max);
|
|
198
|
+
const dateOf = (value) => (value ? String(value).slice(0, 10) : '');
|
|
199
|
+
|
|
200
|
+
// ---- Reading the tracker through the Engram connector ----
|
|
201
|
+
function payloadOf(result) {
|
|
202
|
+
let value = result?.payload;
|
|
203
|
+
if (typeof value === 'string') {
|
|
204
|
+
// Engram adds a line after its JSON that marks the data as untrusted.
|
|
205
|
+
const cut = value.indexOf('<<<');
|
|
206
|
+
try { value = JSON.parse(cut >= 0 ? value.slice(0, cut) : value); } catch { value = null; }
|
|
207
|
+
}
|
|
208
|
+
if (value && Array.isArray(value.entities)) return value;
|
|
209
|
+
for (const block of result?.content ?? []) {
|
|
210
|
+
if (block?.type !== 'text') continue;
|
|
211
|
+
const text = String(block.text ?? ''); const cut = text.indexOf('<<<');
|
|
212
|
+
try { const parsed = JSON.parse(cut >= 0 ? text.slice(0, cut) : text); if (parsed && Array.isArray(parsed.entities)) return parsed; } catch {}
|
|
213
|
+
}
|
|
214
|
+
throw Object.assign(new Error('The connector answered in a shape this page does not read.'), { code: 'shape' });
|
|
215
|
+
}
|
|
216
|
+
async function load() {
|
|
217
|
+
set({ loading: true, error: null });
|
|
218
|
+
let mcp = null;
|
|
219
|
+
try { mcp = await window.claude?.use?.('mcp'); } catch { mcp = null; }
|
|
220
|
+
if (!mcp) { set({ loading: false, error: { code: 'no_mcp' } }); return; }
|
|
221
|
+
const records = []; let offset = 0, total = Infinity;
|
|
222
|
+
try {
|
|
223
|
+
while (offset < total && offset < MAX_RECORDS) {
|
|
224
|
+
const result = await mcp.callTool(SERVER, 'entities_list', { tag: `product:${PRODUCT}`, state: 'active', limit: PAGE, offset, order: 'created_at_asc' });
|
|
225
|
+
const page = payloadOf(result);
|
|
226
|
+
total = Number(page.total ?? page.entities.length);
|
|
227
|
+
records.push(...page.entities);
|
|
228
|
+
if (!page.entities.length) break;
|
|
229
|
+
offset += page.entities.length;
|
|
230
|
+
}
|
|
231
|
+
} catch (error) { set({ loading: false, error: { code: error?.code ?? 'upstream_error', message: error?.message ?? String(error) } }); return; }
|
|
232
|
+
const readAt = new Date().toISOString();
|
|
233
|
+
const model = buildModel(records, { product: PRODUCT, now: readAt });
|
|
234
|
+
const opened = {};
|
|
235
|
+
for (const id of model.groups.find((g) => g.key === 'active')?.ids ?? []) opened[id] = true;
|
|
236
|
+
set({ loading: false, model, readAt, cut: total > MAX_RECORDS, opened: Object.keys(S.opened).length ? S.opened : opened, stack: S.stack.filter((id) => model.items.has(id)) });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- Copy a line for a thread. The page never writes to the tracker. ----
|
|
240
|
+
let toastTimer = null;
|
|
241
|
+
function toast(text, line) {
|
|
242
|
+
document.querySelector('.toast')?.remove();
|
|
243
|
+
const box = h('div', { class: 'toast', role: 'status' }, h('span', {}, text), line ? h('textarea', { readonly: true, 'aria-label': 'The line to paste in a thread' }, line) : null);
|
|
244
|
+
document.body.append(box);
|
|
245
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(() => box.remove(), line ? 9000 : 3000);
|
|
246
|
+
}
|
|
247
|
+
async function copyLine(line) {
|
|
248
|
+
S.menu = null; render();
|
|
249
|
+
try { await navigator.clipboard.writeText(line); toast('Copied. Paste it in a thread, and the lead does it.'); }
|
|
250
|
+
catch { toast('Copy it from here, then paste it in a thread:', line); }
|
|
251
|
+
}
|
|
252
|
+
const ask = {
|
|
253
|
+
question: (id) => `${PRODUCT_NAME}: ask me a question on item ${id} (${titleOf(id)}): `,
|
|
254
|
+
link: (id) => `${PRODUCT_NAME}: add a link to item ${id} (${titleOf(id)}): `,
|
|
255
|
+
split: (id) => `${PRODUCT_NAME}: split item ${id} (${titleOf(id)}) into parts: `,
|
|
256
|
+
park: (id) => `${PRODUCT_NAME}: park item ${id} (${titleOf(id)}), because `,
|
|
257
|
+
id: (id) => `${titleOf(id)} · item ${id}`,
|
|
258
|
+
answer: (question, item, option) => `${PRODUCT_NAME}: answer question ${question} on item ${item} (${titleOf(item)}): ${option}`,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ---- Shared pieces: the state of a row, its chips, the chain ----
|
|
262
|
+
function lightOf(id) {
|
|
263
|
+
if (!S.sel || !M().items.has(S.sel)) return '';
|
|
264
|
+
if (id === S.sel) return 'sel';
|
|
265
|
+
const chain = M().chainOf(S.sel);
|
|
266
|
+
if (chain.before.includes(id)) return 'before';
|
|
267
|
+
if (chain.after.includes(id)) return 'after';
|
|
268
|
+
return 'dim';
|
|
269
|
+
}
|
|
270
|
+
function focus(id) {
|
|
271
|
+
const patch = { sel: id, menu: null };
|
|
272
|
+
const opened = { ...S.opened };
|
|
273
|
+
for (const parent of M().ancestorsOf(id)) opened[parent] = true;
|
|
274
|
+
patch.opened = opened;
|
|
275
|
+
const group = M().groups.find((g) => g.ids.includes(M().ancestorsOf(id).at(-1) ?? id));
|
|
276
|
+
if (group?.folded) patch.showClosed = true;
|
|
277
|
+
if (S.view === 'detail' && !S.stack.includes(id)) patch.stack = [id, ...S.stack].slice(0, 3);
|
|
278
|
+
if (S.view === 'columns') patch.path = [...M().ancestorsOf(id).reverse(), id];
|
|
279
|
+
set(patch);
|
|
280
|
+
requestAnimationFrame(() => document.querySelector(`[data-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: 'nearest' }));
|
|
281
|
+
}
|
|
282
|
+
function statusBits(id, { compact = false } = {}) {
|
|
283
|
+
const st = M().stateOf(id);
|
|
284
|
+
const out = [];
|
|
285
|
+
if (st.kind === 'ready') out.push(h('span', { class: 'tag ready' }, 'Ready to start'));
|
|
286
|
+
if (st.kind === 'doing') out.push(h('span', { class: 'tag go' }, `Going on${st.stage ? ` · ${st.stage}` : ''}`));
|
|
287
|
+
if (st.questions.length) out.push(h('span', { class: 'tag you' }, `Waits on you${compact ? '' : `: ${short(st.questions[0].fields?.text ?? st.questions[0].title, 48)}`}`));
|
|
288
|
+
for (const row of st.blockers) out.push(h('button', { class: 'chip', onclick: (event) => { event.stopPropagation(); focus(row.item); }, title: `Blocked by ${row.title ?? row.item}. Click to light its chain.` }, `Blocked by ${short(row.title ?? row.item, compact ? 22 : 40)}`));
|
|
289
|
+
if (st.waitsWith) out.push(h('span', { class: 'tag with' }, `waits with ${short(titleOf(st.waitsWith), 32)}`));
|
|
290
|
+
if (st.kind === 'paused') out.push(h('span', { class: 'tag' }, 'Paused'));
|
|
291
|
+
if (st.kind === 'done') out.push(h('span', { class: 'tag done' }, 'Done'));
|
|
292
|
+
if (st.kind === 'dropped') out.push(h('span', { class: 'tag' }, 'Dropped'));
|
|
293
|
+
if (!compact && st.blocks.length && !['done', 'dropped'].includes(st.kind)) out.push(h('span', { class: 'tag' }, `Blocks ${st.blocks.length === 1 ? short(st.blocks[0].title, 30) : `${st.blocks.length} items`}`));
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
const dotOf = (id) => { const st = M().stateOf(id); return h('span', { class: `dot ${st.lifecycle} ${st.kind === 'blocked' || st.kind === 'waits-with' ? 'blocked' : ''}`, 'aria-hidden': 'true' }); };
|
|
297
|
+
const indentOf = (depth) => h('span', { class: 'indent', style: `width:${depth * 20}px;background:repeating-linear-gradient(90deg,transparent 0 13px,var(--line) 13px 14px,transparent 14px 20px)` });
|
|
298
|
+
|
|
299
|
+
function menuFor(id, where, extra = []) {
|
|
300
|
+
const key = `${where}:${id}`;
|
|
301
|
+
const open = S.menu === key;
|
|
302
|
+
const button = h('button', { class: 'dots', 'aria-label': `Actions for ${titleOf(id)}`, 'aria-haspopup': 'menu', 'aria-expanded': open ? 'true' : 'false', title: 'Actions', onclick: (event) => { event.stopPropagation(); set({ menu: open ? null : key }); } }, h('span', { 'aria-hidden': 'true' }, '⋯'));
|
|
303
|
+
if (!open) return button;
|
|
304
|
+
const item = (label, fn, hint) => h('button', { role: 'menuitem', onclick: (event) => { event.stopPropagation(); fn(); } }, h('span', {}, label), hint ? h('span', { class: 'k' }, hint) : null);
|
|
305
|
+
const viewItems = extra.length ? extra : [item('Light its chain', () => focus(id)), item('Open in E3', () => { set({ view: 'detail' }); focus(id); })];
|
|
306
|
+
const menu = h('div', { class: 'menu', role: 'menu', style: 'right:0;top:36px', onclick: (event) => event.stopPropagation() },
|
|
307
|
+
h('span', { class: 'h' }, 'View'), viewItems, h('hr'),
|
|
308
|
+
h('span', { class: 'h' }, 'Add · copies a line'), item('Ask a question', () => copyLine(ask.question(id))), item('Add a link', () => copyLine(ask.link(id))), item('Split into parts', () => copyLine(ask.split(id))), h('hr'),
|
|
309
|
+
h('span', { class: 'h' }, 'Item · copies a line'), item('Park', () => copyLine(ask.park(id))), item('Copy item id', () => copyLine(ask.id(id))));
|
|
310
|
+
return h('span', { class: 'pr' }, button, menu);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- E1 and the outline half of E3: one tree ----
|
|
314
|
+
function visibleIds() {
|
|
315
|
+
const out = [];
|
|
316
|
+
const walk = (id, depth) => { out.push([id, depth]); if (S.opened[id]) for (const kid of M().childrenOf(id)) walk(kid, depth + 1); };
|
|
317
|
+
for (const group of M().groups) if (!group.folded || S.showClosed) for (const id of group.ids) walk(id, 0);
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
function outline({ withAdd = false } = {}) {
|
|
321
|
+
const box = h('div', { class: 'card', style: 'overflow:hidden' });
|
|
322
|
+
for (const group of M().groups) {
|
|
323
|
+
if (!group.ids.length) continue;
|
|
324
|
+
const shut = group.folded && !S.showClosed;
|
|
325
|
+
box.append(h('button', { class: 'group', 'aria-expanded': shut ? 'false' : 'true', onclick: () => group.folded && set({ showClosed: !S.showClosed }) }, h('span', {}, `${group.folded ? (shut ? '▸ ' : '▾ ') : ''}${group.name.toUpperCase()}`), h('span', {}, String(group.ids.length))));
|
|
326
|
+
if (shut) continue;
|
|
327
|
+
const walk = (id, depth) => {
|
|
328
|
+
const kids = M().childrenOf(id);
|
|
329
|
+
const light = lightOf(id);
|
|
330
|
+
box.append(h('div', { class: `row ${light}`, 'data-id': id },
|
|
331
|
+
indentOf(depth),
|
|
332
|
+
h('button', { class: `caret ${kids.length ? '' : 'none'}`, 'aria-label': S.opened[id] ? 'Close the parts' : 'Open the parts', 'aria-expanded': kids.length ? String(!!S.opened[id]) : null, onclick: () => set({ opened: { ...S.opened, [id]: !S.opened[id] } }) }, S.opened[id] ? '▾' : '▸'),
|
|
333
|
+
dotOf(id),
|
|
334
|
+
h('span', { class: 'body' }, h('button', { class: `title l${Math.min(depth, 2)} ${['blocked', 'waits-with'].includes(M().stateOf(id).kind) ? 'grey' : ''}`, onclick: () => (S.view === 'detail' ? set({ sel: id, stack: [id, ...S.stack.filter((x) => x !== id)].slice(0, 3) }) : set({ sel: S.sel === id ? null : id })) }, M().items.get(id).title), statusBits(id)),
|
|
335
|
+
kids.length ? h('span', { class: 'count' }, `${kids.length} part${kids.length === 1 ? '' : 's'}`) : null,
|
|
336
|
+
withAdd ? h('button', { class: 'dots', 'aria-label': 'Open this item as well', title: 'Open as well', onclick: () => set({ stack: [...S.stack.filter((x) => x !== id), id].slice(-3) }) }, '+') : menuFor(id, 'row')));
|
|
337
|
+
if (S.opened[id]) for (const kid of kids) walk(kid, depth + 1);
|
|
338
|
+
};
|
|
339
|
+
for (const id of group.ids) walk(id, 0);
|
|
340
|
+
}
|
|
341
|
+
return box;
|
|
342
|
+
}
|
|
343
|
+
function chainBanner() {
|
|
344
|
+
if (!S.sel || !M().items.has(S.sel)) return null;
|
|
345
|
+
const chain = M().chainOf(S.sel);
|
|
346
|
+
const names = (ids) => (ids.length ? ids.map((id) => short(titleOf(id), 36)).join(', ') : 'Nothing');
|
|
347
|
+
return h('div', { class: 'banner' }, h('b', {}, `Chain of ${short(titleOf(S.sel), 60)}`), h('span', {}, h('span', { class: 'k-before' }, 'Before it: '), names(chain.before)), h('span', {}, h('span', { class: 'k-after' }, 'Waits on it: '), names(chain.after)), h('button', { class: 'ghost', onclick: () => set({ sel: null }) }, 'Clear'));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- E3: the detail stack ----
|
|
351
|
+
const TABS = [['questions', 'Questions'], ['decisions', 'Decisions'], ['parts', 'Parts'], ['links', 'Links'], ['delivery', 'Delivery'], ['history', 'History']];
|
|
352
|
+
function facts(d) {
|
|
353
|
+
const st = M().stateOf(d.id);
|
|
354
|
+
const rows = [['Lifecycle', d.lifecycle], ['Step', d.stage || '—'], ['Size', d.tier || '—'], ['Driver', d.driver || '—'], ['Next step', d.next_step || '—']];
|
|
355
|
+
if (d.waits_on && d.waits_on.on && d.waits_on.on !== 'nothing') rows.push(['Waits on', `${d.waits_on.on}${d.waits_on.note ? `: ${d.waits_on.note}` : ''}`]);
|
|
356
|
+
rows.push(['Starts', st.blockers.length ? statusBits(d.id).filter((el) => el.classList?.contains('chip')) : (st.waitsWith ? `with ${titleOf(st.waitsWith)}` : (st.kind === 'ready' ? 'Ready to start' : '—'))]);
|
|
357
|
+
if (d.plan && (d.plan.start || d.plan.end || d.plan.deadline)) rows.push(['Planned', [d.plan.start && `start ${d.plan.start}`, d.plan.end && `end ${d.plan.end}`, d.plan.deadline && `deadline ${d.plan.deadline}`].filter(Boolean).join(' · ')]);
|
|
358
|
+
if (d.origin?.text) rows.push(['Your words', `"${short(d.origin.text, 240)}" (${d.origin.date ?? ''})`]);
|
|
359
|
+
rows.push(['Made', dateOf(d.created_at)]);
|
|
360
|
+
if (d.locked_at) rows.push(['Locked', dateOf(d.locked_at)]);
|
|
361
|
+
if (d.delivered_at) rows.push(['Delivered', dateOf(d.delivered_at)]);
|
|
362
|
+
rows.push(['Item id', d.id]);
|
|
363
|
+
return h('div', { class: 'facts' }, rows.map(([k, v]) => [h('span', {}, k), h('span', { style: 'overflow-wrap:anywhere' }, v)]));
|
|
364
|
+
}
|
|
365
|
+
function entries(d, tab) {
|
|
366
|
+
const list = h('div', { class: 'entries' });
|
|
367
|
+
const entry = (k, meta, v, more) => list.append(h('div', { class: 'entry' }, h('div', { class: 'eh' }, h('b', { style: 'color:var(--ink);font-weight:500' }, k), h('span', {}, meta)), v ? h('div', { class: 'ev' }, v) : null, more));
|
|
368
|
+
if (tab === 'questions') for (const q of d.questions) {
|
|
369
|
+
const opts = Array.isArray(q.fields?.context?.options) ? q.fields.context.options : [];
|
|
370
|
+
entry(q.fields?.status === 'open' ? 'Open question' : 'Answered', `${q.fields?.cost ?? ''} · asked ${dateOf(q.fields?.asked_at)}`, q.fields?.text ?? q.title,
|
|
371
|
+
q.fields?.status === 'open' && opts.length ? h('div', { class: 'opts' }, opts.map((opt) => h('button', { class: 'chip', onclick: () => copyLine(ask.answer(q.id, d.id, opt)), title: 'Copies your answer for a thread' }, short(opt, 60)))) : null);
|
|
372
|
+
}
|
|
373
|
+
if (tab === 'decisions') for (const row of d.decisions) entry(row.fields?.kind ?? 'decision', row.fields?.date ?? '', row.fields?.text ?? row.title, row.fields?.source ? h('div', { class: 'path' }, row.fields.source) : null);
|
|
374
|
+
if (tab === 'parts') for (const id of d.parts) list.append(h('div', { class: 'entry', style: 'flex-direction:row;align-items:center;gap:8px' }, dotOf(id), h('button', { class: 'title', onclick: () => focus(id) }, titleOf(id)), statusBits(id, { compact: true })));
|
|
375
|
+
if (tab === 'links') for (const link of d.links) entry(`${link.kind ?? 'link'}${link.role ? ` · ${link.role}` : ''}`, dateOf(link.added_at), link.note || '', h('a', { href: link.url, target: '_blank', rel: 'noopener noreferrer', style: 'overflow-wrap:anywhere' }, link.url));
|
|
376
|
+
if (tab === 'delivery') for (const mark of d.marks) entry(mark.fields?.verdict ?? mark.fields?.kind ?? 'mark', dateOf(mark.fields?.marked_at), (mark.fields?.prs ?? []).map((pr) => pr.url).join('\n') || mark.fields?.reason || '');
|
|
377
|
+
if (tab === 'history') for (const edit of d.edits) entry(edit.by ?? 'edit', dateOf(edit.at), edit.reason ?? '');
|
|
378
|
+
if (!list.children.length) list.append(h('div', { class: 'empty' }, 'Nothing here yet.'));
|
|
379
|
+
return list;
|
|
380
|
+
}
|
|
381
|
+
function panel(id, index) {
|
|
382
|
+
const d = M().detailOf(id);
|
|
383
|
+
const folded = !!S.folded[id];
|
|
384
|
+
const counts = { questions: d.questions.length, decisions: d.decisions.length, parts: d.parts.length, links: d.links.length, delivery: d.marks.length, history: d.edits.length };
|
|
385
|
+
const tab = S.tabs[id] ?? (counts.questions ? 'questions' : 'parts');
|
|
386
|
+
const close = () => set({ stack: S.stack.filter((x) => x !== id), menu: null });
|
|
387
|
+
const parent = M().parentOf(id);
|
|
388
|
+
const extra = [
|
|
389
|
+
h('button', { role: 'menuitem', onclick: () => set({ stack: [id], menu: null }) }, h('span', {}, 'Open alone'), h('span', { class: 'k' }, 'O')),
|
|
390
|
+
parent ? h('button', { role: 'menuitem', onclick: () => set({ stack: [...S.stack.filter((x) => x !== parent), parent].slice(-3), menu: null }) }, h('span', {}, 'Open the parent too'), h('span', { class: 'k' }, 'P')) : null,
|
|
391
|
+
h('button', { role: 'menuitem', onclick: () => { focus(id); } }, h('span', {}, 'Light its chain')),
|
|
392
|
+
h('button', { role: 'menuitem', onclick: close }, h('span', {}, 'Close'), h('span', { class: 'k' }, 'Esc')),
|
|
393
|
+
].filter(Boolean);
|
|
394
|
+
return h('section', { class: 'panel', 'aria-label': d.title },
|
|
395
|
+
h('div', { class: 'phead' },
|
|
396
|
+
h('div', { class: 'pl' }, h('span', { class: 'badge' }, 'ABC'[index]), h('div', {}, d.path.length ? h('div', { class: 'path' }, d.path.join(' / ')) : null, h('h2', {}, d.title), h('div', { class: 'body', style: 'margin-top:4px' }, statusBits(id)))),
|
|
397
|
+
h('div', { class: 'pr' }, h('button', { class: 'dots', 'aria-label': folded ? 'Unfold this panel' : 'Fold this panel', title: 'Fold', onclick: () => set({ folded: { ...S.folded, [id]: !folded } }) }, folded ? '▸' : '▾'), menuFor(id, 'panel', extra))),
|
|
398
|
+
folded ? h('div', { class: 'fold1' }, d.next_step ? `Next: ${short(d.next_step, 140)}` : `${d.lifecycle}${d.stage ? ` · ${d.stage}` : ''}`) : [
|
|
399
|
+
facts(d),
|
|
400
|
+
h('div', { class: 'tabs', role: 'group', 'aria-label': 'Records of this item' }, TABS.map(([key, label]) => h('button', { 'aria-pressed': String(tab === key), onclick: () => set({ tabs: { ...S.tabs, [id]: key } }) }, `${label} ${counts[key]}`))),
|
|
401
|
+
entries(d, tab),
|
|
402
|
+
]);
|
|
403
|
+
}
|
|
404
|
+
function detailView() {
|
|
405
|
+
const stack = S.stack.filter((id) => M().items.has(id));
|
|
406
|
+
return h('div', { class: 'split' }, outline({ withAdd: true }),
|
|
407
|
+
h('div', { class: 'stack' }, stack.length ? stack.map(panel) : h('div', { class: 'notice' }, h('b', {}, 'Click a row to read it.'), h('span', {}, 'Press + on a row to open it as well, up to three. Fold a panel to one line to keep several in view.'))));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- E2: columns ----
|
|
411
|
+
function columnsView() {
|
|
412
|
+
const path = S.path.filter((id) => M().items.has(id));
|
|
413
|
+
const cols = [];
|
|
414
|
+
const live = M().groups.filter((g) => !g.folded).flatMap((g) => g.ids);
|
|
415
|
+
const closed = M().groups.filter((g) => g.folded).flatMap((g) => g.ids);
|
|
416
|
+
cols.push({ title: PRODUCT_NAME.toUpperCase(), ids: S.showClosed ? [...live, ...closed] : live, foot: closed.length });
|
|
417
|
+
path.forEach((id) => { const kids = M().childrenOf(id); if (kids.length) cols.push({ title: short(titleOf(id), 30).toUpperCase(), ids: kids }); });
|
|
418
|
+
const sel = path.at(-1) ?? null;
|
|
419
|
+
const wrap = h('div', { class: 'card cols' });
|
|
420
|
+
cols.forEach((col, level) => {
|
|
421
|
+
const box = h('div', { class: 'col' }, h('div', { class: 'colh' }, h('span', {}, col.title), h('span', {}, String(col.ids.length))));
|
|
422
|
+
for (const id of col.ids) {
|
|
423
|
+
const kids = M().childrenOf(id).length;
|
|
424
|
+
const onPath = path[level] === id;
|
|
425
|
+
const light = lightOf(id);
|
|
426
|
+
box.append(h('div', { class: `citem ${onPath ? 'sel' : ''} ${light === 'dim' ? 'dim' : ''}`, 'data-id': id },
|
|
427
|
+
h('button', { onclick: () => set({ path: [...path.slice(0, level), id], sel: id }) }, h('span', {}, h('span', {}, M().items.get(id).title), h('span', { class: 'sub' }, `${M().stateOf(id).lifecycle}${kids ? ` · ${kids} part${kids === 1 ? '' : 's'}` : ''}`)), h('span', { 'aria-hidden': 'true' }, kids ? '›' : '')),
|
|
428
|
+
h('span', { class: 'body' }, statusBits(id, { compact: true }))));
|
|
429
|
+
}
|
|
430
|
+
if (col.foot && level === 0) box.append(h('button', { class: 'group', onclick: () => set({ showClosed: !S.showClosed }) }, h('span', {}, S.showClosed ? 'Hide done and dropped' : `Show done and dropped (${col.foot})`)));
|
|
431
|
+
wrap.append(box);
|
|
432
|
+
});
|
|
433
|
+
if (sel) {
|
|
434
|
+
const d = M().detailOf(sel);
|
|
435
|
+
const pinned = S.pins.includes(sel);
|
|
436
|
+
const extra = [
|
|
437
|
+
h('button', { role: 'menuitem', onclick: () => set({ pins: pinned ? S.pins.filter((x) => x !== sel) : [...S.pins, sel], menu: null }) }, h('span', {}, pinned ? 'Stop watching' : 'Watch below')),
|
|
438
|
+
h('button', { role: 'menuitem', onclick: () => { set({ view: 'detail', stack: [sel, ...S.stack.filter((x) => x !== sel)].slice(0, 3), menu: null }); } }, h('span', {}, 'Open in E3')),
|
|
439
|
+
h('button', { role: 'menuitem', onclick: () => focus(sel) }, h('span', {}, 'Light its chain')),
|
|
440
|
+
];
|
|
441
|
+
wrap.append(h('div', { class: 'selpane' },
|
|
442
|
+
h('div', { style: 'display:flex;justify-content:space-between;gap:8px' }, h('div', {}, h('div', { class: 'path' }, 'Selected'), h('h2', { style: 'margin:0;font-family:"Source Serif 4",Georgia,serif;font-size:19px' }, d.title)), menuFor(sel, 'pane', extra)),
|
|
443
|
+
h('div', { class: 'body' }, statusBits(sel)), facts(d),
|
|
444
|
+
d.questions.filter((q) => q.fields?.status === 'open').length ? h('div', {}, h('div', { class: 'path' }, 'OPEN QUESTIONS'), d.questions.filter((q) => q.fields?.status === 'open').map((q) => h('div', { class: 'entry' }, q.fields?.text ?? q.title))) : null));
|
|
445
|
+
} else wrap.append(h('div', { class: 'selpane' }, h('div', { class: 'empty' }, 'Pick an item. Each level opens a new column to the right.')));
|
|
446
|
+
const out = [wrap];
|
|
447
|
+
if (S.pins.length) out.push(h('div', { class: 'watch' }, h('span', { class: 'h' }, 'WATCHING · SEVERAL ITEMS AT ONCE'), h('div', { class: 'pins' }, S.pins.filter((id) => M().items.has(id)).map((id) => {
|
|
448
|
+
const d = M().detailOf(id); const q = d.questions.find((row) => row.fields?.status === 'open');
|
|
449
|
+
return h('div', { class: 'pin' }, h('span', { class: 't' }, h('b', {}, d.title), h('span', { class: 'path' }, `${d.lifecycle}${d.stage ? ` · ${d.stage}` : ''}`), h('span', {}, `Next: ${short(d.next_step || '—', 120)}`), q ? h('span', { class: 'tag you' }, `? ${short(q.fields?.text, 80)}`) : null), h('button', { class: 'dots', 'aria-label': 'Stop watching', onclick: () => set({ pins: S.pins.filter((x) => x !== id) }) }, '×'));
|
|
450
|
+
}))));
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---- Timeline ----
|
|
455
|
+
function timelineView() {
|
|
456
|
+
const rows = visibleIds().filter(([id]) => M().spanOf(id).bars.length || M().childrenOf(id).length || M().stateOf(id).lifecycle === 'paused');
|
|
457
|
+
const now = Date.parse(M().now);
|
|
458
|
+
const spans = rows.map(([id]) => M().spanOf(id));
|
|
459
|
+
let from = Math.min(now - 7 * DAY, ...spans.map((s) => s.from));
|
|
460
|
+
from = Math.max(from, now - 30 * DAY);
|
|
461
|
+
let to = Math.max(now + 7 * DAY, ...spans.map((s) => s.to)) + DAY;
|
|
462
|
+
to = Math.min(to, now + 120 * DAY);
|
|
463
|
+
from = Math.floor(from / DAY) * DAY; to = Math.ceil(to / DAY) * DAY;
|
|
464
|
+
const pct = (at) => (Math.min(Math.max(at, from), to) - from) / (to - from) * 100;
|
|
465
|
+
const days = (to - from) / DAY;
|
|
466
|
+
const step = days <= 21 ? 1 : days <= 45 ? 3 : days <= 90 ? 7 : 14;
|
|
467
|
+
const ticks = [];
|
|
468
|
+
for (let at = from; at < to; at += step * DAY) { if (Math.abs(pct(at) - pct(now)) < 3) continue; const d = new Date(at); ticks.push(h('span', { class: 'tick', style: `left:${pct(at)}%` }, d.getUTCDate() === 1 || at === from || step >= 7 ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : String(d.getUTCDate()))); }
|
|
469
|
+
const label = (bar) => ({ done: 'Done', going: 'going on', planned: 'planned', estimate: 'estimate' })[bar.kind];
|
|
470
|
+
const body = h('div', { class: 'tlin' }, h('div', { class: 'tlh' }, h('div', { class: 'tll' }, 'ITEMS AND PARTS'), h('div', { class: 'tlg', style: 'height:40px' }, ticks)));
|
|
471
|
+
for (const [id, depth] of rows) {
|
|
472
|
+
const span = M().spanOf(id);
|
|
473
|
+
const kids = M().childrenOf(id);
|
|
474
|
+
const light = lightOf(id);
|
|
475
|
+
const bars = span.bars.map((bar) => { const width = Math.max(pct(bar.to) - pct(bar.from), 0.8); return h('span', { class: `b ${bar.kind}`, style: `left:${pct(bar.from)}%;width:calc(${width}% - 2px)`, title: `${label(bar)}: ${dateOf(new Date(bar.from).toISOString())} to ${dateOf(new Date(bar.to).toISOString())}` }, width >= 6 ? label(bar) : ''); });
|
|
476
|
+
if (!span.bars.length && kids.length && span.to > span.from) bars.push(h('span', { class: 'b parent estimate', style: `left:${pct(span.from)}%;width:calc(${Math.max(pct(span.to) - pct(span.from), 0.8)}% - 2px)`, title: 'Its parts' }));
|
|
477
|
+
if (!span.bars.length && M().stateOf(id).lifecycle === 'paused') bars.push(h('span', { class: 'tag', style: `position:absolute;top:10px;left:${pct(now)}%;margin-left:6px` }, 'Paused, no date'));
|
|
478
|
+
for (const mark of span.marks) {
|
|
479
|
+
if (mark.kind === 'asked') { bars.push(h('span', { class: 'm-ask', style: `left:${pct(mark.at)}%`, title: `Question asked ${dateOf(new Date(mark.at).toISOString())}` })); bars.push(h('span', { class: 'm-wait', style: `left:${pct(mark.at)}%;width:${pct(mark.until) - pct(mark.at)}%`, title: 'Waits on you' })); }
|
|
480
|
+
if (mark.kind === 'deadline') bars.push(h('span', { class: 'm-dead', style: `left:${pct(mark.at + DAY)}%`, title: `Deadline ${dateOf(new Date(mark.at).toISOString())}` }));
|
|
481
|
+
}
|
|
482
|
+
const st = M().stateOf(id);
|
|
483
|
+
body.append(h('div', { class: `tlr ${light}`, 'data-id': id },
|
|
484
|
+
h('div', { class: 'tll' }, indentOf(depth),
|
|
485
|
+
h('button', { class: `caret ${kids.length ? '' : 'none'}`, 'aria-label': S.opened[id] ? 'Close the parts' : 'Open the parts', onclick: () => set({ opened: { ...S.opened, [id]: !S.opened[id] } }) }, S.opened[id] ? '▾' : '▸'),
|
|
486
|
+
h('button', { class: `title l${Math.min(depth, 2)} ${st.blockers.length ? 'grey' : ''}`, onclick: () => set({ sel: S.sel === id ? null : id }), title: M().items.get(id).title }, M().items.get(id).title),
|
|
487
|
+
st.blockers.slice(0, 1).map((row) => h('button', { class: 'chip', style: 'font-size:11px;min-height:22px', onclick: () => focus(row.item), title: `Blocked by ${row.title}` }, `after ${short(row.title ?? row.item, 14)}`))),
|
|
488
|
+
h('div', { class: 'tlg' }, bars)));
|
|
489
|
+
}
|
|
490
|
+
const grid = h('div', { class: 'card tl' }, h('div', { style: 'position:relative' }, body));
|
|
491
|
+
const todayWrap = body.querySelector('.tlh');
|
|
492
|
+
body.style.position = 'relative';
|
|
493
|
+
const line = h('span', { class: 'today', style: `left:calc(300px + (100% - 300px) * ${pct(now) / 100})` });
|
|
494
|
+
body.append(line);
|
|
495
|
+
if (todayWrap) todayWrap.querySelector('.tlg').append(h('span', { class: 'tick', style: `left:${pct(now)}%;color:var(--you-mark);font-weight:600;border-left:2px solid var(--you-mark)` }, 'Today'));
|
|
496
|
+
const legend = h('div', { class: 'legend' },
|
|
497
|
+
h('span', {}, h('i', { class: 'sw', style: 'background:var(--done-bg);border:1px solid var(--done)' }), 'Done'),
|
|
498
|
+
h('span', {}, h('i', { class: 'sw', style: 'background:var(--go-bg);border:1px solid var(--go)' }), 'Going on'),
|
|
499
|
+
h('span', {}, h('i', { class: 'sw', style: 'background:var(--card);border:1.5px solid var(--go)' }), 'Your planned dates'),
|
|
500
|
+
h('span', {}, h('i', { class: 'sw', style: 'background:repeating-linear-gradient(135deg,var(--est-a) 0 4px,var(--est-b) 4px 8px);border:1px solid var(--est-line)' }), `Estimate from the size: hotfix ${ESTIMATE_DAYS.hotfix} day, standard ${ESTIMATE_DAYS.standard}, initiative ${ESTIMATE_DAYS.initiative}`),
|
|
501
|
+
h('span', {}, h('i', { class: 'sw', style: 'height:4px;background:var(--you-bar)' }), 'Waits on you'),
|
|
502
|
+
h('span', {}, h('i', { style: 'width:10px;height:10px;transform:rotate(45deg);background:var(--you-mark);display:inline-block' }), 'Question asked'));
|
|
503
|
+
return [grid, legend];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---- Needs you ----
|
|
507
|
+
function needsPanel() {
|
|
508
|
+
const rows = M().needsYou;
|
|
509
|
+
return h('div', { class: 'card needlist' }, h('div', { class: 'colh' }, h('span', {}, `NEEDS YOU · ${rows.length}`), h('button', { class: 'ghost', onclick: () => set({ needs: false }) }, 'Close')),
|
|
510
|
+
rows.length ? rows.map((row, index) => h('div', { class: 'entry', role: 'button', tabindex: '0', onclick: () => { set({ needs: false, view: 'detail', stack: [row.item.id, ...S.stack.filter((x) => x !== row.item.id)].slice(0, 3), tabs: { ...S.tabs, [row.item.id]: 'questions' } }); focus(row.item.id); }, onkeydown: (event) => { if (event.key === 'Enter') event.currentTarget.click(); } },
|
|
511
|
+
h('div', { class: 'ev' }, h('b', {}, `${index + 1}. `), short(row.question.fields?.text ?? row.question.title, 200)),
|
|
512
|
+
h('div', { class: 'eh' }, h('span', {}, `${short(row.item.title, 80)} · ${row.lifecycle}`), h('span', {}, `${row.cost}: ${COST_MEANING[row.cost] ?? ''} · asked ${dateOf(row.asked_at)}`)))) : h('div', { class: 'empty' }, 'Nothing needs you: no open question on a proposed, active or paused item.'));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---- Errors: say which, and how to fix it ----
|
|
516
|
+
function errorView(error) {
|
|
517
|
+
const fix = {
|
|
518
|
+
no_mcp: ['This page cannot reach your connectors here.', 'Open it on claude.ai, as a page of your own. A saved copy or a preview cannot read the tracker.'],
|
|
519
|
+
server_not_connected: ['The Engram connector is not connected.', 'Add Engram in claude.ai, Settings, Connectors. Then reload this page.'],
|
|
520
|
+
needs_reauth: ['Your Engram sign-in has lapsed.', 'Reconnect Engram in claude.ai, Settings, Connectors. Then reload this page.'],
|
|
521
|
+
not_in_manifest: ['This page is not allowed to read Engram.', 'Allow Engram for this page when claude.ai asks, then reload. The page asks only for two read tools.'],
|
|
522
|
+
consent_required: ['This page is not allowed to read Engram yet.', 'Reload, and allow Engram when claude.ai asks.'],
|
|
523
|
+
not_granted: ['This page is not allowed to read Engram.', 'Reload, and allow Engram when claude.ai asks.'],
|
|
524
|
+
selection_required: ['You have more than one Engram connector.', 'Pick one when claude.ai asks, then reload.'],
|
|
525
|
+
blocked_by_policy: ['A policy blocks this page from Engram.', 'Ask the person who runs your claude.ai settings.'],
|
|
526
|
+
server_unavailable: ['Engram did not answer.', 'Wait a moment and reload. Engram may be restarting.'],
|
|
527
|
+
shape: ['Engram answered in a shape this page does not read.', 'Rebuild the page with the newest Atlas: atlas view build.'],
|
|
528
|
+
}[error.code] ?? ['The tracker could not be read.', error.message ?? 'Reload the page to try again.'];
|
|
529
|
+
return h('div', { class: 'notice bad', role: 'alert' }, h('b', {}, fix[0]), h('span', {}, fix[1]), h('span', { class: 'foot' }, `Code: ${error.code}. The page shows nothing rather than an empty view that looks true.`), h('div', {}, h('button', { class: 'ghost', onclick: load }, 'Reload')));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function render() {
|
|
533
|
+
const app = document.getElementById('app');
|
|
534
|
+
const scroll = window.scrollY;
|
|
535
|
+
const head = h('header', { class: 'bar' },
|
|
536
|
+
h('span', { class: 'brand' }, h('b', {}, 'Atlas'), h('span', { class: 'sep' }, '/'), h('span', {}, `Product: ${PRODUCT_NAME}`)),
|
|
537
|
+
S.model ? h('button', { class: `needs ${S.model.needsYou.length ? '' : 'zero'}`, 'aria-expanded': String(S.needs), onclick: () => set({ needs: !S.needs }) }, `Needs you · ${S.model.needsYou.length}`) : null);
|
|
538
|
+
const kids = [head];
|
|
539
|
+
if (S.error) kids.push(errorView(S.error));
|
|
540
|
+
else if (S.loading && !S.model) kids.push(h('div', { class: 'notice' }, 'Reading the tracker…'));
|
|
541
|
+
if (S.model) {
|
|
542
|
+
kids.push(h('div', { class: 'tools' },
|
|
543
|
+
h('nav', { class: 'switch', 'aria-label': 'View' }, VIEWS.map(([key, label]) => h('button', { 'aria-pressed': String(S.view === key), onclick: () => { store.set('view', key); set({ view: key, menu: null }); } }, label))),
|
|
544
|
+
h('span', { class: 'meta' }, S.loading ? 'Reading…' : `Read ${new Date(S.readAt).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}`, h('button', { class: 'ghost', onclick: load }, 'Refresh'))));
|
|
545
|
+
if (S.cut) kids.push(h('div', { class: 'notice' }, `This product has more than ${MAX_RECORDS} records. The page read the first ${MAX_RECORDS}.`));
|
|
546
|
+
if (S.needs) kids.push(needsPanel());
|
|
547
|
+
kids.push(chainBanner());
|
|
548
|
+
if (S.view === 'outline') kids.push(outline());
|
|
549
|
+
if (S.view === 'detail') kids.push(detailView());
|
|
550
|
+
if (S.view === 'columns') kids.push(columnsView());
|
|
551
|
+
if (S.view === 'timeline') kids.push(timelineView());
|
|
552
|
+
kids.push(h('div', { class: 'foot' }, `Read only. The ⋯ menu copies a line for a thread, and the lead writes. Built by Atlas ${ATLAS_VERSION}. ${S.model.all.length} items.`));
|
|
553
|
+
}
|
|
554
|
+
app.replaceChildren(...kids.flat().filter(Boolean));
|
|
555
|
+
window.scrollTo(0, scroll);
|
|
556
|
+
}
|
|
557
|
+
document.addEventListener('click', () => { if (S.menu) set({ menu: null }); });
|
|
558
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && (S.menu || S.sel)) set(S.menu ? { menu: null } : { sel: null }); });
|
|
559
|
+
render();
|
|
560
|
+
load();
|
|
561
|
+
</script>
|
|
562
|
+
</body>
|
|
563
|
+
</html>
|
package/work/lib/questions.mjs
CHANGED
|
@@ -5,16 +5,13 @@
|
|
|
5
5
|
// The verbs run behind the same door, the same journal and the same registry
|
|
6
6
|
// checks as slice L1. Every record carries request_id and writer.
|
|
7
7
|
import protectedPaths from '../protected-paths.json' with { type: 'json' };
|
|
8
|
-
import { refuse, text, oneOf, isoDate, url, stringList
|
|
8
|
+
import { refuse, text, oneOf, isoDate, url, stringList } from './verb-fields.mjs';
|
|
9
|
+
import { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, needsYou } from './view-rules.mjs';
|
|
10
|
+
|
|
11
|
+
// R5 (the costs), R8 (the row width), the blocker rules and the needs-you
|
|
12
|
+
// order live in view-rules.mjs, which the project view page also runs.
|
|
13
|
+
export { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, CLEARED_LIFECYCLES, blockersOf, blocksOf } from './view-rules.mjs';
|
|
9
14
|
|
|
10
|
-
// R5: the cost of waiting is a closed vocabulary, ranked. Index 0 costs most.
|
|
11
|
-
export const COSTS = Object.freeze(['blocked-merge', 'paused-item', 'spend', 'date']);
|
|
12
|
-
export const COST_MEANING = Object.freeze({
|
|
13
|
-
'blocked-merge': 'a merge waits on the answer',
|
|
14
|
-
'paused-item': 'the item is stopped until the answer comes',
|
|
15
|
-
spend: 'money runs while the answer waits',
|
|
16
|
-
date: 'a date is at risk',
|
|
17
|
-
});
|
|
18
15
|
// 'sweep' is the kind the slice L5 sweep record carries. It is a decision like
|
|
19
16
|
// any other: the owner's words, dated, listing the items (design finding R7).
|
|
20
17
|
export const DECISION_KINDS = Object.freeze(['decision', 'priority', 'answer', 'sweep']);
|
|
@@ -24,18 +21,9 @@ export const QUESTION_READ_VERBS = Object.freeze(['needs_me']);
|
|
|
24
21
|
|
|
25
22
|
// R8: the row budget. Three visible lines; everything else folds under it.
|
|
26
23
|
export const ROW_LINES = 3;
|
|
27
|
-
export const ROW_LINE_MAX = 160;
|
|
28
24
|
const MAX_OPEN_QUESTIONS = 500;
|
|
29
25
|
const MAX_RENDERED = 20_000;
|
|
30
26
|
|
|
31
|
-
export const oneLine = (value, max = ROW_LINE_MAX) => {
|
|
32
|
-
const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
33
|
-
// Cut by code point, so a cut never leaves half an emoji: Postgres refuses
|
|
34
|
-
// a lone surrogate, and the whole write with it.
|
|
35
|
-
const chars = Array.from(flat);
|
|
36
|
-
return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : flat;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
27
|
// R12: a scrub by pattern in receipt keep. The patterns are the ones the
|
|
40
28
|
// sub-agents deny, in work/protected-paths.json. A (?i) prefix is Python and
|
|
41
29
|
// Go spelling; JavaScript takes it as a flag.
|
|
@@ -101,28 +89,8 @@ export function waitsOnLine(waits) {
|
|
|
101
89
|
return `waits on ${what}${waits.item ? ` ${waits.item}` : ''}${waits.note ? `: ${waits.note}` : ''}`;
|
|
102
90
|
}
|
|
103
91
|
|
|
104
|
-
// Work item additions, slice 2:
|
|
105
|
-
//
|
|
106
|
-
// undefined when the view did not load it. A blocker that is delivered or
|
|
107
|
-
// dropped no longer blocks; the row shows it as cleared.
|
|
108
|
-
export const CLEARED_LIFECYCLES = Object.freeze(['done', 'dropped']);
|
|
109
|
-
export function blockersOf(item, lookup) {
|
|
110
|
-
const list = Array.isArray(item?.fields?.blocked_by) ? item.fields.blocked_by : [];
|
|
111
|
-
return list.filter((entry) => entry && typeof entry.item === 'string').map((entry) => {
|
|
112
|
-
const other = lookup(entry.item);
|
|
113
|
-
// A blocker the view cannot find is archived or gone, so it blocks no more.
|
|
114
|
-
if (!other) return { item: entry.item, title: null, state: 'gone', cleared: true, ...(entry.note ? { note: entry.note } : {}) };
|
|
115
|
-
const lifecycle = other.fields?.lifecycle ?? 'unknown';
|
|
116
|
-
const cleared = CLEARED_LIFECYCLES.includes(lifecycle);
|
|
117
|
-
return { item: entry.item, title: oneLine(other.title, 60), state: cleared ? 'cleared' : (lifecycle === 'active' ? (other.fields?.stage ?? 'active') : lifecycle), cleared, ...(entry.note ? { note: entry.note } : {}) };
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
// Only a live item is still held up, so a delivered or dropped one leaves the
|
|
121
|
-
// "blocks" list.
|
|
122
|
-
export function blocksOf(item, all) {
|
|
123
|
-
return all.filter((other) => other.id !== item.id && LIVE_LIFECYCLES.includes(other.fields?.lifecycle) && Array.isArray(other.fields?.blocked_by) && other.fields.blocked_by.some((entry) => entry?.item === item.id))
|
|
124
|
-
.map((other) => ({ item: other.id, title: oneLine(other.title, 60) }));
|
|
125
|
-
}
|
|
92
|
+
// Work item additions, slice 2: one phrase for a row, from blockersOf and
|
|
93
|
+
// blocksOf in view-rules.mjs.
|
|
126
94
|
export function blockedPhrase(blockers, blocks) {
|
|
127
95
|
const parts = [];
|
|
128
96
|
if (blockers.length) parts.push(`blocked by: ${blockers.map((row) => `${row.title ?? row.item} (${row.state})`).join(', ')}`);
|
|
@@ -323,20 +291,14 @@ export function createQuestionVerbs(core) {
|
|
|
323
291
|
catch (error) { items.set(id, null); unreadable.set(id, error?.status === 404 ? 'the item is not in the graph' : `the graph did not answer: ${error?.message ?? String(error)}`); }
|
|
324
292
|
}
|
|
325
293
|
const readAt = now();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// proposed or paused item still needs him, so it is a row. D3 rule 1
|
|
332
|
-
// kept active items only, and it hid real questions. A question on a
|
|
333
|
-
// done or dropped item is named, not dropped in silence.
|
|
334
|
-
if (!LIVE_LIFECYCLES.includes(lifecycle)) { omitted.push({ question_id: row.id, item: row.fields.item, item_lifecycle: lifecycle, reason: item ? `the item is ${lifecycle}; only a proposed, active or paused item can still need you` : (unreadable.get(row.fields.item) ?? 'the item cannot be read') }); continue; }
|
|
335
|
-
const cost = row.fields.cost;
|
|
336
|
-
const rank = COSTS.indexOf(cost);
|
|
294
|
+
// The order and the filter are the view rules, so the project view page
|
|
295
|
+
// gives the same rows from the same records.
|
|
296
|
+
const ordered = needsYou(open, (id) => items.get(id));
|
|
297
|
+
const omitted = ordered.omitted.map(({ question, item, lifecycle }) => ({ question_id: question.id, item: question.fields.item, item_lifecycle: lifecycle, reason: item ? `the item is ${lifecycle}; only a proposed, active or paused item can still need you` : (unreadable.get(question.fields.item) ?? 'the item cannot be read') }));
|
|
298
|
+
const kept = ordered.kept.map(({ question: row, item, lifecycle, cost, cost_rank: costRank }) => {
|
|
337
299
|
const asked = row.fields.asked_by ?? {};
|
|
338
|
-
|
|
339
|
-
question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, cost, cost_rank:
|
|
300
|
+
return {
|
|
301
|
+
question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, cost, cost_rank: costRank,
|
|
340
302
|
asked_at: row.fields.asked_at ?? row.created_at ?? '', asked_by: asked.agent ?? 'unknown',
|
|
341
303
|
lines: [
|
|
342
304
|
oneLine(row.fields.text),
|
|
@@ -345,9 +307,8 @@ export function createQuestionVerbs(core) {
|
|
|
345
307
|
],
|
|
346
308
|
folded: { question: row.fields.text, context: row.fields.context ?? {}, asked_by: asked, item_title: item.title, item_stage: item.fields.stage ?? '', item_next_step: item.fields.next_step ?? '', item_waits_on: item.fields.waits_on ?? null },
|
|
347
309
|
citation: { question: row.id, item: item.id, read_at: readAt },
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
kept.sort((a, b) => a.cost_rank - b.cost_rank || String(a.asked_at).localeCompare(String(b.asked_at)) || a.question_id.localeCompare(b.question_id));
|
|
310
|
+
};
|
|
311
|
+
});
|
|
351
312
|
const products = [...new Set(kept.map((row) => row.product))].sort();
|
|
352
313
|
const byProduct = products.map((product) => ({ product, rows: kept.filter((row) => row.product === product) }));
|
|
353
314
|
const render = byProduct.map((group) => [`${group.product} — needs you (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n')).join('\n\n');
|
package/work/lib/verb-fields.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
export const MAX_TEXT = 20_000;
|
|
8
|
-
// The lifecycles a view counts as live work
|
|
9
|
-
//
|
|
10
|
-
export
|
|
8
|
+
// The lifecycles a view counts as live work live in view-rules.mjs, which the
|
|
9
|
+
// project view page also runs.
|
|
10
|
+
export { LIVE_LIFECYCLES } from './view-rules.mjs';
|
|
11
11
|
|
|
12
12
|
export class VerbError extends Error {
|
|
13
13
|
constructor(message, code = 'refused') { super(message); this.name = 'VerbError'; this.code = code; }
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The view rules: how tracker records become rows. One module, read by two
|
|
2
|
+
// readers. The Atlas tools import it in a session (needs_me, in_flight, the
|
|
3
|
+
// roundup). The project view page holds a copy of this file, put in by
|
|
4
|
+
// `atlas view build`, and runs it in the viewer's browser. Design:
|
|
5
|
+
// docs/design-docs/project-view (tracker item 6dd1761a).
|
|
6
|
+
//
|
|
7
|
+
// So this file imports nothing and touches no Node API, no DOM and no
|
|
8
|
+
// network. Every function takes plain records, as Engram returns them, and
|
|
9
|
+
// returns plain data.
|
|
10
|
+
|
|
11
|
+
// The lifecycles a view counts as live work (release 0.2.3). A dropped item
|
|
12
|
+
// is closed by the owner's word, and no live list names it.
|
|
13
|
+
export const LIVE_LIFECYCLES = Object.freeze(['proposed', 'active', 'paused']);
|
|
14
|
+
// A blocker that is delivered or dropped no longer blocks.
|
|
15
|
+
export const CLEARED_LIFECYCLES = Object.freeze(['done', 'dropped']);
|
|
16
|
+
// R5: the cost of waiting is a closed vocabulary, ranked. Index 0 costs most.
|
|
17
|
+
export const COSTS = Object.freeze(['blocked-merge', 'paused-item', 'spend', 'date']);
|
|
18
|
+
export const COST_MEANING = Object.freeze({
|
|
19
|
+
'blocked-merge': 'a merge waits on the answer',
|
|
20
|
+
'paused-item': 'the item is stopped until the answer comes',
|
|
21
|
+
spend: 'money runs while the answer waits',
|
|
22
|
+
date: 'a date is at risk',
|
|
23
|
+
});
|
|
24
|
+
// R8: one row line holds at most this many characters.
|
|
25
|
+
export const ROW_LINE_MAX = 160;
|
|
26
|
+
// The striped estimate of the Timeline, in days, from the size of an item.
|
|
27
|
+
// The canvas board set hotfix 1 and standard 2; initiative is the design's.
|
|
28
|
+
export const ESTIMATE_DAYS = Object.freeze({ hotfix: 1, standard: 2, initiative: 5 });
|
|
29
|
+
// The order of the top level: live work first, closed work folded at the end.
|
|
30
|
+
export const GROUPS = Object.freeze([
|
|
31
|
+
{ key: 'active', name: 'In flight', lifecycles: ['active'] },
|
|
32
|
+
{ key: 'proposed', name: 'Proposed', lifecycles: ['proposed'] },
|
|
33
|
+
{ key: 'paused', name: 'Paused', lifecycles: ['paused'] },
|
|
34
|
+
{ key: 'closed', name: 'Done and dropped', lifecycles: ['done', 'dropped'], folded: true },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const DAY = 86_400_000;
|
|
38
|
+
|
|
39
|
+
export const oneLine = (value, max = ROW_LINE_MAX) => {
|
|
40
|
+
const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
// Cut by code point, so a cut never leaves half an emoji: Postgres refuses
|
|
42
|
+
// a lone surrogate, and the whole write with it.
|
|
43
|
+
const chars = Array.from(flat);
|
|
44
|
+
return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : flat;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Work item additions, slice 2: the items that block an item, the items it
|
|
48
|
+
// blocks. lookup gives an item record by id, or undefined when the view did
|
|
49
|
+
// not load it.
|
|
50
|
+
export function blockersOf(item, lookup) {
|
|
51
|
+
const list = Array.isArray(item?.fields?.blocked_by) ? item.fields.blocked_by : [];
|
|
52
|
+
return list.filter((entry) => entry && typeof entry.item === 'string').map((entry) => {
|
|
53
|
+
const other = lookup(entry.item);
|
|
54
|
+
// A blocker the view cannot find is archived or gone, so it blocks no more.
|
|
55
|
+
if (!other) return { item: entry.item, title: null, state: 'gone', cleared: true, ...(entry.note ? { note: entry.note } : {}) };
|
|
56
|
+
const lifecycle = other.fields?.lifecycle ?? 'unknown';
|
|
57
|
+
const cleared = CLEARED_LIFECYCLES.includes(lifecycle);
|
|
58
|
+
return { item: entry.item, title: oneLine(other.title, 60), state: cleared ? 'cleared' : (lifecycle === 'active' ? (other.fields?.stage ?? 'active') : lifecycle), cleared, ...(entry.note ? { note: entry.note } : {}) };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Only a live item is still held up, so a delivered or dropped one leaves the
|
|
62
|
+
// "blocks" list.
|
|
63
|
+
export function blocksOf(item, all) {
|
|
64
|
+
return all.filter((other) => other.id !== item.id && LIVE_LIFECYCLES.includes(other.fields?.lifecycle) && Array.isArray(other.fields?.blocked_by) && other.fields.blocked_by.some((entry) => entry?.item === item.id))
|
|
65
|
+
.map((other) => ({ item: other.id, title: oneLine(other.title, 60) }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "Needs you": every open question on a live item, ordered by what waiting
|
|
69
|
+
// costs, then by age, then by id. needs_me and the page both call this, so
|
|
70
|
+
// the two lists are the same list. itemOf gives the item record, null when it
|
|
71
|
+
// cannot be read, or undefined when the reader did not load it.
|
|
72
|
+
export function needsYou(questions, itemOf) {
|
|
73
|
+
const kept = [], omitted = [];
|
|
74
|
+
for (const question of questions) {
|
|
75
|
+
if (question?.fields?.status !== 'open') continue;
|
|
76
|
+
const item = itemOf(question.fields.item) ?? null;
|
|
77
|
+
const lifecycle = item?.fields?.lifecycle ?? 'unknown';
|
|
78
|
+
// Release 0.2.3: a question on a proposed or paused item still needs the
|
|
79
|
+
// owner. A question on a done or dropped item is named, not dropped in
|
|
80
|
+
// silence.
|
|
81
|
+
if (!LIVE_LIFECYCLES.includes(lifecycle)) { omitted.push({ question, item, lifecycle }); continue; }
|
|
82
|
+
const rank = COSTS.indexOf(question.fields.cost);
|
|
83
|
+
kept.push({ question, item, lifecycle, cost: question.fields.cost, cost_rank: rank === -1 ? COSTS.length : rank, asked_at: question.fields.asked_at ?? question.created_at ?? '' });
|
|
84
|
+
}
|
|
85
|
+
kept.sort((a, b) => a.cost_rank - b.cost_rank || String(a.asked_at).localeCompare(String(b.asked_at)) || a.question.id.localeCompare(b.question.id));
|
|
86
|
+
return { kept, omitted };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sort one product's records by kind. The tags say the kind; an item is a
|
|
90
|
+
// record with a lifecycle and none of the other tags.
|
|
91
|
+
const KIND_TAGS = Object.freeze({ 'atlas-question': 'questions', 'atlas-decision': 'decisions', 'atlas-mark': 'marks', 'atlas-receipt': 'receipts', 'atlas-registry': 'registry' });
|
|
92
|
+
export function classify(records) {
|
|
93
|
+
const out = { items: [], questions: [], decisions: [], marks: [], receipts: [], registry: [], other: [] };
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
if (!record || typeof record.id !== 'string') continue;
|
|
96
|
+
if (record.state && record.state !== 'active') continue;
|
|
97
|
+
const tags = Array.isArray(record.tags) ? record.tags : [];
|
|
98
|
+
const kind = tags.map((tag) => KIND_TAGS[tag]).find(Boolean);
|
|
99
|
+
if (kind) out[kind].push(record);
|
|
100
|
+
else if (typeof record.fields?.lifecycle === 'string') out.items.push(record);
|
|
101
|
+
else out.other.push(record);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const time = (value) => { const at = Date.parse(value ?? ''); return Number.isNaN(at) ? null : at; };
|
|
107
|
+
const day = (value) => (/^\d{4}-\d{2}-\d{2}$/.test(String(value ?? '')) ? time(`${value}T00:00:00Z`) : null);
|
|
108
|
+
|
|
109
|
+
// The whole model of one product: the tree, the state of each row, the
|
|
110
|
+
// chain, needs you, the detail of an item, and the Timeline bars.
|
|
111
|
+
export function buildModel(records, { product = null, now = new Date().toISOString() } = {}) {
|
|
112
|
+
const sorted = classify(records);
|
|
113
|
+
const items = new Map(sorted.items.filter((item) => !product || !item.fields?.product || item.fields.product === product).map((item) => [item.id, item]));
|
|
114
|
+
const all = [...items.values()];
|
|
115
|
+
const lookup = (id) => items.get(id);
|
|
116
|
+
const lifecycleOf = (id) => items.get(id)?.fields?.lifecycle ?? 'unknown';
|
|
117
|
+
|
|
118
|
+
// Parts: split_from names the parent. A parent the view did not load makes
|
|
119
|
+
// the part a top-level row, so nothing hides.
|
|
120
|
+
const parentOf = (id) => { const parent = items.get(id)?.fields?.split_from; return typeof parent === 'string' && items.has(parent) && parent !== id ? parent : null; };
|
|
121
|
+
const kids = new Map();
|
|
122
|
+
for (const item of all) {
|
|
123
|
+
const parent = parentOf(item.id);
|
|
124
|
+
if (!parent) continue;
|
|
125
|
+
if (!kids.has(parent)) kids.set(parent, []);
|
|
126
|
+
kids.get(parent).push(item);
|
|
127
|
+
}
|
|
128
|
+
for (const list of kids.values()) list.sort((a, b) => (a.fields?.split_index ?? 0) - (b.fields?.split_index ?? 0) || a.title.localeCompare(b.title));
|
|
129
|
+
const childrenOf = (id) => (kids.get(id) ?? []).map((item) => item.id);
|
|
130
|
+
const ancestorsOf = (id) => { const out = []; const seen = new Set([id]); let at = parentOf(id); while (at && !seen.has(at)) { out.push(at); seen.add(at); at = parentOf(at); } return out; };
|
|
131
|
+
const depthOf = (id) => ancestorsOf(id).length;
|
|
132
|
+
|
|
133
|
+
const modified = (item) => String(item.updated_at ?? item.modified_at ?? item.created_at ?? '');
|
|
134
|
+
const roots = all.filter((item) => !parentOf(item.id)).sort((a, b) => modified(b).localeCompare(modified(a)) || a.title.localeCompare(b.title));
|
|
135
|
+
const groups = GROUPS.map((group) => ({ key: group.key, name: group.name, folded: !!group.folded, ids: roots.filter((item) => group.lifecycles.includes(item.fields?.lifecycle)).map((item) => item.id) }));
|
|
136
|
+
const known = new Set(GROUPS.flatMap((group) => group.lifecycles));
|
|
137
|
+
const strays = roots.filter((item) => !known.has(item.fields?.lifecycle)).map((item) => item.id);
|
|
138
|
+
if (strays.length) groups[groups.length - 1].ids.push(...strays);
|
|
139
|
+
|
|
140
|
+
const need = needsYou(sorted.questions, (id) => (items.has(id) ? items.get(id) : null));
|
|
141
|
+
const openQuestionsOf = (id) => need.kept.filter((row) => row.item?.id === id).map((row) => row.question);
|
|
142
|
+
|
|
143
|
+
const openBlockers = (id) => blockersOf(items.get(id), lookup).filter((row) => !row.cleared);
|
|
144
|
+
const blockedAncestor = (id) => ancestorsOf(id).find((parent) => openBlockers(parent).length > 0) ?? null;
|
|
145
|
+
|
|
146
|
+
// One state per row, the words of the locked "A with D" board.
|
|
147
|
+
function stateOf(id) {
|
|
148
|
+
const item = items.get(id);
|
|
149
|
+
const lifecycle = lifecycleOf(id);
|
|
150
|
+
const blockers = openBlockers(id);
|
|
151
|
+
const waitsWith = blockers.length ? null : blockedAncestor(id);
|
|
152
|
+
const questions = openQuestionsOf(id);
|
|
153
|
+
const blocks = blocksOf(item, all);
|
|
154
|
+
let kind;
|
|
155
|
+
if (lifecycle === 'done' || lifecycle === 'dropped') kind = lifecycle;
|
|
156
|
+
else if (blockers.length) kind = 'blocked';
|
|
157
|
+
else if (waitsWith) kind = 'waits-with';
|
|
158
|
+
else if (questions.length) kind = 'you';
|
|
159
|
+
else if (lifecycle === 'paused') kind = 'paused';
|
|
160
|
+
else if (lifecycle === 'active') kind = 'doing';
|
|
161
|
+
else kind = 'ready';
|
|
162
|
+
return { id, kind, lifecycle, stage: item.fields?.stage ?? '', tier: item.fields?.tier ?? 'standard', blockers, waitsWith, questions, blocks, ready: kind === 'ready' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The chain of D: what must finish before an item (its blockers and its
|
|
166
|
+
// parents' blockers, all the way back), and what waits on it (the items
|
|
167
|
+
// it blocks, all the way on, with their parts).
|
|
168
|
+
const dependentsOf = (id) => all.filter((other) => Array.isArray(other.fields?.blocked_by) && other.fields.blocked_by.some((entry) => entry?.item === id)).map((other) => other.id);
|
|
169
|
+
function chainOf(id) {
|
|
170
|
+
const before = [], after = [];
|
|
171
|
+
const up = (at) => {
|
|
172
|
+
for (const holder of [at, ...ancestorsOf(at)]) {
|
|
173
|
+
for (const row of blockersOf(items.get(holder), lookup)) {
|
|
174
|
+
if (!items.has(row.item) || before.includes(row.item) || row.item === id) continue;
|
|
175
|
+
before.push(row.item); up(row.item);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const down = (at) => {
|
|
180
|
+
for (const next of dependentsOf(at)) {
|
|
181
|
+
if (after.includes(next) || next === id) continue;
|
|
182
|
+
after.push(next);
|
|
183
|
+
const parts = (from) => { for (const kid of childrenOf(from)) { if (!after.includes(kid)) { after.push(kid); parts(kid); } } };
|
|
184
|
+
parts(next);
|
|
185
|
+
down(next);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
up(id); down(id);
|
|
189
|
+
return { before, after };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const decisionsOf = (id) => sorted.decisions.filter((row) => (Array.isArray(row.fields?.items) && row.fields.items.includes(id))).sort((a, b) => String(b.fields?.date ?? '').localeCompare(String(a.fields?.date ?? '')));
|
|
193
|
+
const marksOf = (id) => sorted.marks.filter((row) => row.fields?.item === id).sort((a, b) => String(a.fields?.marked_at ?? '').localeCompare(String(b.fields?.marked_at ?? '')));
|
|
194
|
+
const questionsOf = (id) => sorted.questions.filter((row) => row.fields?.item === id).sort((a, b) => String(b.fields?.asked_at ?? '').localeCompare(String(a.fields?.asked_at ?? '')));
|
|
195
|
+
|
|
196
|
+
function detailOf(id) {
|
|
197
|
+
const item = items.get(id);
|
|
198
|
+
if (!item) return null;
|
|
199
|
+
const fields = item.fields ?? {};
|
|
200
|
+
return {
|
|
201
|
+
id, title: item.title, path: ancestorsOf(id).reverse().map((parent) => items.get(parent).title),
|
|
202
|
+
lifecycle: fields.lifecycle ?? 'unknown', stage: fields.stage ?? '', tier: fields.tier ?? '', driver: fields.driver ?? '',
|
|
203
|
+
next_step: fields.next_step ?? '', summary: fields.summary ?? '', scope: fields.scope?.text ?? '',
|
|
204
|
+
origin: fields.origin ?? null, waits_on: fields.waits_on ?? null, plan: fields.plan ?? null,
|
|
205
|
+
links: Array.isArray(fields.links) ? fields.links : [], tags: Array.isArray(item.tags) ? item.tags : [],
|
|
206
|
+
edits: Array.isArray(fields.edits) ? [...fields.edits].reverse() : [],
|
|
207
|
+
parts: childrenOf(id), questions: questionsOf(id), decisions: decisionsOf(id), marks: marksOf(id),
|
|
208
|
+
created_at: item.created_at ?? '', locked_at: fields.locked_at ?? '', delivered_at: fields.delivered_at ?? '',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The Timeline of the locked board: real bars for work that happened, a
|
|
213
|
+
// striped estimate for the rest, from the size field. A planned date the
|
|
214
|
+
// owner set replaces the estimate. All times are epoch milliseconds.
|
|
215
|
+
const nowAt = time(now) ?? 0;
|
|
216
|
+
const startOf = (item) => day(item.fields?.plan?.start) ?? time(item.fields?.locked_at) ?? time(item.created_at) ?? nowAt;
|
|
217
|
+
const memo = new Map();
|
|
218
|
+
function spanOf(id, guard = new Set()) {
|
|
219
|
+
if (memo.has(id)) return memo.get(id);
|
|
220
|
+
if (guard.has(id)) return { from: nowAt, to: nowAt, kind: 'none', bars: [] };
|
|
221
|
+
guard.add(id);
|
|
222
|
+
const item = items.get(id);
|
|
223
|
+
const fields = item.fields ?? {};
|
|
224
|
+
const lifecycle = fields.lifecycle;
|
|
225
|
+
const plan = fields.plan ?? {};
|
|
226
|
+
const planStart = day(plan.start), planEnd = day(plan.end);
|
|
227
|
+
const estimate = (ESTIMATE_DAYS[fields.tier] ?? ESTIMATE_DAYS.standard) * DAY;
|
|
228
|
+
const bars = [];
|
|
229
|
+
if (lifecycle === 'done') {
|
|
230
|
+
const to = time(fields.delivered_at) ?? time(item.updated_at ?? item.modified_at) ?? nowAt;
|
|
231
|
+
bars.push({ kind: 'done', from: Math.min(startOf(item), to), to });
|
|
232
|
+
} else if (lifecycle === 'dropped') {
|
|
233
|
+
// A dropped item draws no bar.
|
|
234
|
+
} else if (lifecycle === 'active') {
|
|
235
|
+
const from = Math.min(startOf(item), nowAt);
|
|
236
|
+
bars.push({ kind: 'going', from, to: nowAt });
|
|
237
|
+
if (planEnd !== null && planEnd > nowAt) bars.push({ kind: 'planned', from: nowAt, to: planEnd + DAY });
|
|
238
|
+
else if (planEnd === null) bars.push({ kind: 'estimate', from: nowAt, to: nowAt + estimate });
|
|
239
|
+
} else if (lifecycle === 'proposed' || (lifecycle === 'paused' && planStart !== null)) {
|
|
240
|
+
if (planStart !== null || planEnd !== null) {
|
|
241
|
+
const from = planStart ?? Math.max(nowAt, (planEnd ?? nowAt) - estimate + DAY);
|
|
242
|
+
bars.push({ kind: 'planned', from, to: planEnd !== null ? planEnd + DAY : from + estimate });
|
|
243
|
+
} else {
|
|
244
|
+
// The estimate starts after every open blocker ends, and not before
|
|
245
|
+
// its parent can start.
|
|
246
|
+
let from = nowAt;
|
|
247
|
+
for (const holder of [id, ...ancestorsOf(id)]) {
|
|
248
|
+
for (const row of blockersOf(items.get(holder), lookup)) {
|
|
249
|
+
if (row.cleared || !items.has(row.item)) continue;
|
|
250
|
+
from = Math.max(from, spanOf(row.item, guard).to);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
bars.push({ kind: 'estimate', from, to: from + estimate });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// A parent with parts spans its parts.
|
|
257
|
+
const parts = childrenOf(id).map((kid) => spanOf(kid, guard)).filter((span) => span.bars.length);
|
|
258
|
+
let from = bars.length ? Math.min(...bars.map((bar) => bar.from)) : null;
|
|
259
|
+
let to = bars.length ? Math.max(...bars.map((bar) => bar.to)) : null;
|
|
260
|
+
if (parts.length) {
|
|
261
|
+
from = Math.min(from ?? Infinity, ...parts.map((span) => span.from));
|
|
262
|
+
to = Math.max(to ?? -Infinity, ...parts.map((span) => span.to));
|
|
263
|
+
}
|
|
264
|
+
const marks = [];
|
|
265
|
+
for (const question of openQuestionsOf(id)) { const at = time(question.fields?.asked_at ?? question.created_at); if (at !== null) marks.push({ kind: 'asked', at, until: nowAt, question: question.id }); }
|
|
266
|
+
const deadline = day(plan.deadline);
|
|
267
|
+
if (deadline !== null) marks.push({ kind: 'deadline', at: deadline });
|
|
268
|
+
const span = { from: from ?? nowAt, to: to ?? nowAt, bars, marks, kind: lifecycle, hasParts: parts.length > 0 };
|
|
269
|
+
memo.set(id, span);
|
|
270
|
+
return span;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
product, now, items, all, groups, roots: roots.map((item) => item.id),
|
|
275
|
+
lookup, parentOf, childrenOf, ancestorsOf, depthOf, stateOf, chainOf, detailOf, spanOf,
|
|
276
|
+
needsYou: need.kept, needsYouOmitted: need.omitted,
|
|
277
|
+
questions: sorted.questions, decisions: sorted.decisions, marks: sorted.marks,
|
|
278
|
+
};
|
|
279
|
+
}
|