@arjunkhera/atlas 0.3.24 → 0.3.26
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/skills/tests/SKILL.md +27 -2
- package/tests/contract.mjs +124 -13
- package/tests/drivers/agent.mjs +109 -0
- package/tests/drivers/index.mjs +15 -2
- package/tests/evidence.mjs +2 -1
- package/tests/link-check.mjs +5 -3
- package/tests/model.mjs +263 -0
- package/tests/scenario.mjs +15 -0
- package/tests/tests-yaml.mjs +35 -5
- package/tests/verdict.mjs +12 -3
- 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/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
|
+
}
|