@arjunkhera/atlas 0.3.22 → 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.
@@ -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, LIVE_LIFECYCLES } from './verb-fields.mjs';
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: the items that block an item, the items it
105
- // blocks, and one phrase for a row. lookup gives an item record by id, or
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
- const kept = [], omitted = [];
327
- for (const row of open) {
328
- const item = items.get(row.fields.item) ?? null;
329
- const lifecycle = item?.fields?.lifecycle ?? 'unknown';
330
- // Release 0.2.3: a question on a
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
- kept.push({
339
- question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, cost, cost_rank: rank === -1 ? COSTS.length : 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');
@@ -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 (release 0.2.3). A dropped item is
9
- // closed by the owner's word, and no live list names it.
10
- export const LIVE_LIFECYCLES = Object.freeze(['proposed', 'active', 'paused']);
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
+ }