wowbagger 0.5.0-beta.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ import { projectReadiness } from './ready.js';
2
+
3
+ // Report-only dependency impact. The core readiness projection stays the
4
+ // authority: ready-if-done is the baseline-to-counterfactual diff of
5
+ // `projectReadiness` on a copied ledger where only the candidate is done.
6
+ // Downstream reach mirrors the recommendation leverage traversal over the
7
+ // retained open graph. Pure: no Node or DOM access, input never mutated.
8
+ export function buildReportImpact(allItems, retainedOpenIds, asOf) {
9
+ const retained = new Set(retainedOpenIds);
10
+ const byId = new Map(allItems.map((item) => [item.data.id, item]));
11
+ const baseline = projectReadiness(allItems, asOf);
12
+
13
+ const dependents = new Map();
14
+ const children = new Map();
15
+ for (const id of retained) {
16
+ dependents.set(id, []);
17
+ children.set(id, []);
18
+ }
19
+ for (const item of allItems) {
20
+ const id = item.data.id;
21
+ if (Array.isArray(item.data.depends_on)) {
22
+ for (const dependencyId of item.data.depends_on) {
23
+ if (dependents.has(dependencyId) && retained.has(id)) {
24
+ dependents.get(dependencyId).push(id);
25
+ }
26
+ }
27
+ }
28
+ const parentId = item.data.parent;
29
+ if (parentId && children.has(parentId) && retained.has(id)) {
30
+ children.get(parentId).push(id);
31
+ }
32
+ }
33
+
34
+ const downstreamCache = new Map();
35
+ function downstream(id, onStack) {
36
+ const cached = downstreamCache.get(id);
37
+ if (cached !== undefined) {
38
+ return cached;
39
+ }
40
+ if (onStack.has(id)) {
41
+ return new Set();
42
+ }
43
+ onStack.add(id);
44
+ const collected = new Set();
45
+ for (const dependentId of dependents.get(id) ?? []) {
46
+ collected.add(dependentId);
47
+ for (const transitiveId of downstream(dependentId, onStack)) {
48
+ collected.add(transitiveId);
49
+ }
50
+ }
51
+ onStack.delete(id);
52
+ downstreamCache.set(id, collected);
53
+ return collected;
54
+ }
55
+
56
+ // Reverse dependency/ancestor reach from a candidate bounds the diff scan.
57
+ function affectedIds(candidateId) {
58
+ const seen = new Set([candidateId]);
59
+ const queue = [candidateId];
60
+ while (queue.length > 0) {
61
+ const current = queue.pop();
62
+ for (const next of dependents.get(current) ?? []) {
63
+ if (!seen.has(next)) {
64
+ seen.add(next);
65
+ queue.push(next);
66
+ }
67
+ }
68
+ for (const next of children.get(current) ?? []) {
69
+ if (!seen.has(next)) {
70
+ seen.add(next);
71
+ queue.push(next);
72
+ }
73
+ }
74
+ }
75
+ seen.delete(candidateId);
76
+ return seen;
77
+ }
78
+
79
+ function readinessIfDone(candidateId) {
80
+ const candidate = byId.get(candidateId);
81
+ if (!candidate) {
82
+ return baseline;
83
+ }
84
+ const copied = allItems.map((item) => (item === candidate
85
+ ? { ...item, data: { ...item.data, status: 'done' } }
86
+ : item));
87
+ return projectReadiness(copied, asOf);
88
+ }
89
+
90
+ const impact = {};
91
+ for (const id of [...retained].sort(compareText)) {
92
+ const downstreamIds = [...downstream(id, new Set())].filter((other) => other !== id).sort(compareText);
93
+ const after = readinessIfDone(id);
94
+ const readyIfDoneIds = [...affectedIds(id)]
95
+ .filter((other) => retained.has(other)
96
+ && baseline.get(other)?.state !== 'ready'
97
+ && after.get(other)?.state === 'ready')
98
+ .sort(compareText);
99
+ impact[id] = { downstreamIds, readyIfDoneIds };
100
+ }
101
+ return impact;
102
+ }
103
+
104
+ function compareText(left, right) {
105
+ return left < right ? -1 : left > right ? 1 : 0;
106
+ }
@@ -0,0 +1,97 @@
1
+ import { reportFieldValues } from './report-view.js';
2
+
3
+ const SEARCH_INDEX = new WeakMap();
4
+ const FACET_INDEX = new WeakMap();
5
+
6
+ function searchableText(item) {
7
+ if (!SEARCH_INDEX.has(item)) {
8
+ SEARCH_INDEX.set(item, [
9
+ item.number, item.id, item.title,
10
+ ...Object.values(item.fields).flatMap(reportFieldValues),
11
+ ].filter((value) => value !== null && value !== undefined).join(' ').toLowerCase());
12
+ }
13
+ return SEARCH_INDEX.get(item);
14
+ }
15
+
16
+ function dimensionValues(item, dimension) {
17
+ if (!FACET_INDEX.has(item)) FACET_INDEX.set(item, new Map());
18
+ const index = FACET_INDEX.get(item);
19
+ if (!index.has(dimension)) {
20
+ const value = dimension === 'readiness' ? item.readiness.state
21
+ : dimension.startsWith('field:') ? item.fields[dimension.slice(6)] : item[dimension];
22
+ index.set(dimension, reportFieldValues(value));
23
+ }
24
+ return index.get(dimension);
25
+ }
26
+
27
+ export function selectReportItems(items, scope) {
28
+ const tokens = scope.search.trim().toLowerCase().split(/\s+/).filter(Boolean);
29
+ const facets = Object.entries(scope.facets);
30
+ return items.filter((item) => tokens.every((token) => searchableText(item).includes(token))
31
+ && facets.every(([dimension, selected]) => {
32
+ const values = dimensionValues(item, dimension);
33
+ return selected.length === 0 || selected.some((entry) => entry.kind === 'missing'
34
+ ? values.length === 0 : values.includes(entry.value));
35
+ }));
36
+ }
37
+
38
+ export function countReportFacets(items, scope, dimensions) {
39
+ return dimensions.map((dimension) => {
40
+ const facets = { ...scope.facets };
41
+ delete facets[dimension];
42
+ const options = new Map();
43
+ for (const item of selectReportItems(items, { search: scope.search, facets })) {
44
+ const values = dimensionValues(item, dimension);
45
+ const selections = values.length === 0 ? [{ kind: 'missing' }]
46
+ : [...new Set(values)].map((value) => ({ kind: 'value', value }));
47
+ for (const selection of selections) {
48
+ const key = JSON.stringify(selection);
49
+ const option = options.get(key);
50
+ if (option) option.count += 1;
51
+ else options.set(key, { selection, count: 1 });
52
+ }
53
+ }
54
+ return { dimension, options: [...options.entries()]
55
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
56
+ .map(([, option]) => option) };
57
+ });
58
+ }
59
+
60
+ const TERMINAL_STATUSES = new Set(['done', 'killed', 'archived', 'deferred']);
61
+
62
+ export function selectListItems(items, state, workNextIds) {
63
+ const scoped = selectReportItems(items, state.scope);
64
+ if (state.drilldown !== null) {
65
+ const ids = new Set(state.drilldown.itemIds);
66
+ return scoped.filter((item) => ids.has(item.id));
67
+ }
68
+ const open = scoped.filter((item) => !TERMINAL_STATUSES.has(item.status));
69
+ let selected;
70
+ if (state.quickView === 'work-next') {
71
+ const byId = new Map(open.map((item) => [item.id, item]));
72
+ selected = workNextIds.filter((id) => byId.has(id)).map((id) => byId.get(id));
73
+ } else {
74
+ selected = open.filter((item) => {
75
+ switch (state.quickView) {
76
+ case 'in-progress': return item.status === 'in-progress';
77
+ case 'blocked': return item.readiness.state === 'blocked';
78
+ case 'triage': return item.status === 'triage';
79
+ case 'all-open': return true;
80
+ default: throw new Error(`Unknown report quick view: ${state.quickView}`);
81
+ }
82
+ });
83
+ }
84
+ return state.showHistory
85
+ ? [...selected, ...scoped.filter((item) => TERMINAL_STATUSES.has(item.status))]
86
+ : selected;
87
+ }
88
+
89
+ export function reportSelectionBrowserSource() {
90
+ return [
91
+ 'const SEARCH_INDEX = new WeakMap();',
92
+ 'const FACET_INDEX = new WeakMap();',
93
+ `const TERMINAL_STATUSES = new Set(${JSON.stringify([...TERMINAL_STATUSES])});`,
94
+ ...[reportFieldValues, searchableText, dimensionValues, selectReportItems,
95
+ countReportFacets, selectListItems].map((fn) => fn.toString().replace(/^export\s+/, '')),
96
+ ].join('\n');
97
+ }
@@ -98,7 +98,7 @@ export function computeEpicEnablement(allItems) {
98
98
  return epicById;
99
99
  }
100
100
 
101
- // Transitive unblocking leverage over the depends_on DAG. For each open item,
101
+ // Transitive downstream reach over the depends_on DAG. For each open item,
102
102
  // the set of other open items whose dependency chain passes through it. The
103
103
  // ledger is cycle-checked before a report renders, so the reverse graph is a
104
104
  // DAG; the on-stack guard only keeps a hand-built model from looping.
@@ -170,7 +170,7 @@ export function collectUnknownClasses(openItems) {
170
170
  // The recommended order. Every factor is a separate, visible comparison step,
171
171
  // never a single opaque score: an entry's position is always explained by the
172
172
  // first step on which it beat the entry below it. Dominance runs expedite
173
- // class, fixed-date proximity, unblocking leverage, epic enablement, priority,
173
+ // class, fixed-date proximity, downstream reach, epic enablement, priority,
174
174
  // age, then size, with the immutable ID as the determinism backstop.
175
175
  export function rankWorkNext(readyItems) {
176
176
  return [...readyItems]
@@ -239,7 +239,7 @@ function describeReasons(item) {
239
239
  reasons.push({ code: 'due', label: `due ${sequencing.due.date} (${duePhrase(sequencing.due.daysUntil)})` });
240
240
  }
241
241
  if (sequencing.leverage.count > 0) {
242
- reasons.push({ code: 'leverage', label: `unblocks ${plural(sequencing.leverage.count, 'item')}${namedNumbers(sequencing.leverage.numbers)}` });
242
+ reasons.push({ code: 'leverage', label: `downstream reach: ${plural(sequencing.leverage.count, 'item')}${namedNumbers(sequencing.leverage.numbers)}` });
243
243
  }
244
244
  if (sequencing.epic !== null) {
245
245
  reasons.push({ code: 'epic', label: `advances ${epicPhrase(sequencing.epic)}` });
@@ -264,7 +264,7 @@ function plural(count, noun) {
264
264
  return `${count} ${noun}${count === 1 ? '' : 's'}`;
265
265
  }
266
266
 
267
- // Name at most four unblocked items so the reason stays one readable line.
267
+ // Name at most four downstream items so the reason stays one readable line.
268
268
  function namedNumbers(numbers) {
269
269
  if (numbers.length === 0) {
270
270
  return '';
package/src/report-svg.js CHANGED
@@ -10,7 +10,7 @@
10
10
  const CHART_WIDTH = 720;
11
11
  // The mean the evidence layer computes; named here only so the tooltip can say
12
12
  // how long a window it covers.
13
- const ROLLING_WEEKS = 4;
13
+ const CHART_ROLLING_WEEKS = 4;
14
14
  const PLOT_LEFT = 34;
15
15
  const PLOT_RIGHT = 10;
16
16
 
@@ -64,7 +64,7 @@ export function agingHeatmapChart(matrix) {
64
64
  const top = HEAT_HEADER + rowIndex * HEAT_ROW;
65
65
  const cells = row.counts.map((count, columnIndex) => {
66
66
  const opacity = count === 0 ? 0 : coordinate(HEAT_FLOOR + HEAT_RANGE * (count / peak));
67
- return `<g><title>${escapeText(matrix.statuses[columnIndex])}, ${escapeText(row.label)}: ${plural(count, 'open item')}</title>`
67
+ return `<g data-chart="aging" data-bucket="${escapeText(row.label)}" data-status="${escapeText(matrix.statuses[columnIndex])}"><title>${escapeText(matrix.statuses[columnIndex])}, ${escapeText(row.label)}: ${plural(count, 'open item')}</title>`
68
68
  + `<rect class="heat" x="${HEAT_LABEL_WIDTH + columnIndex * HEAT_CELL_WIDTH + 2}" y="${top + 2}" width="${HEAT_CELL_WIDTH - 4}" height="${HEAT_ROW - 4}" fill-opacity="${opacity}"></rect>`
69
69
  + `<text class="chart-value" x="${columnCentre(columnIndex)}" y="${top + 18}" text-anchor="middle">${count}</text>`
70
70
  + '</g>';
@@ -81,11 +81,12 @@ const THROUGHPUT_HEIGHT = 160;
81
81
  const THROUGHPUT_TOP = 14;
82
82
  const THROUGHPUT_BOTTOM = 34;
83
83
 
84
- // Completions per week with a four-week mean over them. Weekly throughput is
84
+ // Closures per week with a four-week mean over them. Weekly throughput is
85
85
  // noisy enough that the bars alone invite a story about last week; the mean is
86
- // the line that tells whether the ledger is actually speeding up.
86
+ // the line that tells whether the ledger is actually speeding up. The mean is
87
+ // a closure rate: it counts every departure, not delivered work alone.
87
88
  export function throughputChart(weeks) {
88
- const total = weeks.reduce((sum, week) => sum + week.completions, 0);
89
+ const total = weeks.reduce((sum, week) => sum + week.closures, 0);
89
90
  if (weeks.length === 0 || total === 0) {
90
91
  return '';
91
92
  }
@@ -94,7 +95,7 @@ export function throughputChart(weeks) {
94
95
  const plotHeight = THROUGHPUT_HEIGHT - THROUGHPUT_TOP - THROUGHPUT_BOTTOM;
95
96
  const baseline = THROUGHPUT_TOP + plotHeight;
96
97
  // The mean can never exceed the tallest week, so the bars set the scale.
97
- const peak = Math.max(...weeks.map((week) => week.completions));
98
+ const peak = Math.max(...weeks.map((week) => week.closures));
98
99
  const slot = plotWidth / weeks.length;
99
100
  const barWidth = slot * 0.6;
100
101
  const centre = (index) => PLOT_LEFT + slot * (index + 0.5);
@@ -103,12 +104,13 @@ export function throughputChart(weeks) {
103
104
  const bars = weeks.map((week, index) => {
104
105
  const mean = week.rolling === null
105
106
  ? 'no four-week mean yet'
106
- : `${week.rolling} a week over the last ${ROLLING_WEEKS}`;
107
+ : `${week.rolling} a week over the last ${CHART_ROLLING_WEEKS}`;
108
+ const scope = week.partial === true ? ', partial week' : '';
107
109
  const label = (weeks.length - 1 - index) % 2 === 0
108
110
  ? `<text class="chart-tick" x="${coordinate(centre(index))}" y="${baseline + 15}" text-anchor="middle">${escapeText(week.weekStart.slice(5))}</text>`
109
111
  : '';
110
- return `<g><title>Week of ${escapeText(week.weekStart)}: ${plural(week.completions, 'completion')}, ${mean}</title>`
111
- + `<rect class="bar-completion" x="${coordinate(centre(index) - barWidth / 2)}" y="${atCount(week.completions)}" width="${coordinate(barWidth)}" height="${coordinate((week.completions / peak) * plotHeight)}"></rect>`
112
+ return `<g><title>Week of ${escapeText(week.weekStart)}: ${plural(week.closures, 'closure')} (${week.done ?? 0} done)${scope}, ${mean}</title>`
113
+ + `<rect class="bar-closure" x="${coordinate(centre(index) - barWidth / 2)}" y="${atCount(week.closures)}" width="${coordinate(barWidth)}" height="${coordinate((week.closures / peak) * plotHeight)}"></rect>`
112
114
  + `</g>${label}`;
113
115
  }).join('');
114
116
 
@@ -123,7 +125,7 @@ export function throughputChart(weeks) {
123
125
  ? ''
124
126
  : `<polyline class="rolling" points="${meanPoints.join(' ')}"></polyline>`;
125
127
 
126
- return `<svg class="chart" data-testid="chart-throughput" viewBox="0 0 ${CHART_WIDTH} ${THROUGHPUT_HEIGHT}" role="img" aria-label="Completions per week with a four-week mean. ${plural(total, 'completion')} over ${plural(weeks.length, 'week')}.">`
128
+ return `<svg class="chart" data-testid="chart-throughput" viewBox="0 0 ${CHART_WIDTH} ${THROUGHPUT_HEIGHT}" role="img" aria-label="Closures per week with a four-week mean. ${plural(total, 'closure')} over ${plural(weeks.length, 'week')}.">`
127
129
  + `<line class="chart-grid" x1="${PLOT_LEFT}" y1="${THROUGHPUT_TOP}" x2="${CHART_WIDTH - PLOT_RIGHT}" y2="${THROUGHPUT_TOP}"></line>`
128
130
  + `<line class="chart-axis" x1="${PLOT_LEFT}" y1="${baseline}" x2="${CHART_WIDTH - PLOT_RIGHT}" y2="${baseline}"></line>`
129
131
  + `<text class="chart-tick" x="${PLOT_LEFT - 6}" y="${THROUGHPUT_TOP + 4}" text-anchor="end">${peak}</text>`
@@ -186,10 +188,10 @@ export function cumulativeFlowChart(points) {
186
188
  const SCATTER_HEIGHT = 180;
187
189
  const SCATTER_TOP = 12;
188
190
  const SCATTER_BOTTOM = 34;
189
- const MILLISECONDS_PER_DAY = 86400000;
191
+ const CHART_MILLISECONDS_PER_DAY = 86400000;
190
192
 
191
193
  function dayNumber(date) {
192
- return Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_DAY;
194
+ return Date.parse(`${date}T00:00:00Z`) / CHART_MILLISECONDS_PER_DAY;
193
195
  }
194
196
 
195
197
  // Every finished item as one dot: how long it took against when it finished.
@@ -216,7 +218,7 @@ export function cycleTimeChart(cycleTime) {
216
218
 
217
219
  const dots = cycleTime.samples.map((sample, index) => {
218
220
  const handle = sample.number === null ? 'An item' : `#${sample.number}`;
219
- return `<g><title>${escapeText(handle)} completed ${escapeText(sample.completedOn)} after ${plural(sample.days, 'day')}</title>`
221
+ return `<g data-chart="cycle" data-number="${sample.number === null ? '' : sample.number}" data-completed="${escapeText(sample.completedOn)}" data-days="${sample.days}"><title>${escapeText(handle)} completed ${escapeText(sample.completedOn)} after ${plural(sample.days, 'day')}</title>`
220
222
  + `<circle class="sample" cx="${atDay(days[index])}" cy="${atDuration(sample.days)}" r="3.5"></circle></g>`;
221
223
  }).join('');
222
224
 
@@ -300,11 +302,11 @@ const FLOW_HEIGHT = 200;
300
302
  const FLOW_TOP = 14;
301
303
  const FLOW_BOTTOM = 34;
302
304
 
303
- // Arrivals against completions, one grouped pair per week. The gap between the
305
+ // Arrivals against closures, one grouped pair per week. The gap between the
304
306
  // pairs is the backlog growing or shrinking, which is the only thing this
305
307
  // chart exists to make visible.
306
308
  export function weeklyFlowChart(weeks) {
307
- const total = weeks.reduce((sum, week) => sum + week.arrivals + week.completions, 0);
309
+ const total = weeks.reduce((sum, week) => sum + week.arrivals + week.closures, 0);
308
310
  if (weeks.length === 0 || total === 0) {
309
311
  return '';
310
312
  }
@@ -314,29 +316,81 @@ export function weeklyFlowChart(weeks) {
314
316
  const baseline = FLOW_TOP + plotHeight;
315
317
  // A zero total returned above, so some week is positive and the peak can
316
318
  // never be zero. A single week divides just as cleanly as twelve.
317
- const peak = Math.max(...weeks.map((week) => Math.max(week.arrivals, week.completions)));
319
+ const peak = Math.max(...weeks.map((week) => Math.max(week.arrivals, week.closures)));
318
320
  const slot = plotWidth / weeks.length;
319
321
  const barWidth = slot * 0.32;
320
322
 
321
323
  const groups = weeks.map((week, index) => {
322
324
  const centre = PLOT_LEFT + slot * (index + 0.5);
323
325
  const arrivalHeight = (week.arrivals / peak) * plotHeight;
324
- const completionHeight = (week.completions / peak) * plotHeight;
326
+ const closureHeight = (week.closures / peak) * plotHeight;
325
327
  // Tick every other week, counted back from the newest: the most recent
326
328
  // week is the one a reader looks for first, so it always carries a date.
327
329
  const label = (weeks.length - 1 - index) % 2 === 0
328
330
  ? `<text class="chart-tick" x="${coordinate(centre)}" y="${baseline + 15}" text-anchor="middle">${escapeText(week.weekStart.slice(5))}</text>`
329
331
  : '';
330
- return `<g><title>Week of ${escapeText(week.weekStart)}: ${plural(week.arrivals, 'arrival')}, ${plural(week.completions, 'completion')}</title>`
332
+ const scope = week.partial === true ? ', partial week' : '';
333
+ return `<g data-chart="weekly" data-week="${escapeText(week.weekStart)}" data-kind="arrivals"><title>Week of ${escapeText(week.weekStart)}: ${plural(week.arrivals, 'arrival')}, ${plural(week.closures, 'closure')}${scope}</title>`
331
334
  + `<rect class="bar-arrival" x="${coordinate(centre - barWidth - 1)}" y="${coordinate(baseline - arrivalHeight)}" width="${coordinate(barWidth)}" height="${coordinate(arrivalHeight)}"></rect>`
332
- + `<rect class="bar-completion" x="${coordinate(centre + 1)}" y="${coordinate(baseline - completionHeight)}" width="${coordinate(barWidth)}" height="${coordinate(completionHeight)}"></rect>`
335
+ + `</g><g data-chart="weekly" data-week="${escapeText(week.weekStart)}" data-kind="closures"><title>Week of ${escapeText(week.weekStart)}: ${plural(week.arrivals, 'arrival')}, ${plural(week.closures, 'closure')}${scope}</title>`
336
+ + `<rect class="bar-closure" x="${coordinate(centre + 1)}" y="${coordinate(baseline - closureHeight)}" width="${coordinate(barWidth)}" height="${coordinate(closureHeight)}"></rect>`
333
337
  + `</g>${label}`;
334
338
  }).join('');
335
339
 
336
- return `<svg class="chart" data-testid="chart-weekly-flow" viewBox="0 0 ${CHART_WIDTH} ${FLOW_HEIGHT}" role="img" aria-label="Arrivals against completions per week. Peak ${peak} items in a week.">`
340
+ return `<svg class="chart" data-testid="chart-weekly-flow" viewBox="0 0 ${CHART_WIDTH} ${FLOW_HEIGHT}" role="img" aria-label="Arrivals against closures per week. Peak ${peak} items in a week.">`
337
341
  + `<line class="chart-grid" x1="${PLOT_LEFT}" y1="${FLOW_TOP}" x2="${CHART_WIDTH - PLOT_RIGHT}" y2="${FLOW_TOP}"></line>`
338
342
  + `<line class="chart-axis" x1="${PLOT_LEFT}" y1="${baseline}" x2="${CHART_WIDTH - PLOT_RIGHT}" y2="${baseline}"></line>`
339
343
  + `<text class="chart-tick" x="${PLOT_LEFT - 6}" y="${FLOW_TOP + 4}" text-anchor="end">${peak}</text>`
340
344
  + `<text class="chart-tick" x="${PLOT_LEFT - 6}" y="${baseline + 4}" text-anchor="end">0</text>`
341
345
  + `${groups}</svg>`;
342
346
  }
347
+
348
+ // The charts the browser re-renders, serialized from the same functions Node
349
+ // executes above. Tested for runtime parity against the direct functions in a
350
+ // VM: the browser never carries a second formula.
351
+ function browserChartSource(fn) {
352
+ return fn.toString().replace(/^export\s+/, '');
353
+ }
354
+
355
+ export function reportSvgBrowserSource() {
356
+ return [
357
+ `const CHART_WIDTH = ${CHART_WIDTH};`,
358
+ `const CHART_ROLLING_WEEKS = ${CHART_ROLLING_WEEKS};`,
359
+ `const PLOT_LEFT = ${PLOT_LEFT};`,
360
+ `const PLOT_RIGHT = ${PLOT_RIGHT};`,
361
+ `const HEAT_LABEL_WIDTH = ${HEAT_LABEL_WIDTH};`,
362
+ `const HEAT_CELL_WIDTH = ${HEAT_CELL_WIDTH};`,
363
+ `const HEAT_ROW = ${HEAT_ROW};`,
364
+ `const HEAT_HEADER = ${HEAT_HEADER};`,
365
+ `const HEAT_FLOOR = ${HEAT_FLOOR};`,
366
+ `const HEAT_RANGE = ${HEAT_RANGE};`,
367
+ `const THROUGHPUT_HEIGHT = ${THROUGHPUT_HEIGHT};`,
368
+ `const THROUGHPUT_TOP = ${THROUGHPUT_TOP};`,
369
+ `const THROUGHPUT_BOTTOM = ${THROUGHPUT_BOTTOM};`,
370
+ `const FLOW_AREA_HEIGHT = ${FLOW_AREA_HEIGHT};`,
371
+ `const FLOW_AREA_TOP = ${FLOW_AREA_TOP};`,
372
+ `const FLOW_AREA_BOTTOM = ${FLOW_AREA_BOTTOM};`,
373
+ `const FLOW_BANDS = ${JSON.stringify(FLOW_BANDS)};`,
374
+ `const SCATTER_HEIGHT = ${SCATTER_HEIGHT};`,
375
+ `const SCATTER_TOP = ${SCATTER_TOP};`,
376
+ `const SCATTER_BOTTOM = ${SCATTER_BOTTOM};`,
377
+ `const CHART_MILLISECONDS_PER_DAY = ${CHART_MILLISECONDS_PER_DAY};`,
378
+ `const FORECAST_HEIGHT = ${FORECAST_HEIGHT};`,
379
+ `const FORECAST_TOP = ${FORECAST_TOP};`,
380
+ `const FORECAST_BOTTOM = ${FORECAST_BOTTOM};`,
381
+ `const FORECAST_MARKS = ${JSON.stringify(FORECAST_MARKS)};`,
382
+ `const FLOW_HEIGHT = ${FLOW_HEIGHT};`,
383
+ `const FLOW_TOP = ${FLOW_TOP};`,
384
+ `const FLOW_BOTTOM = ${FLOW_BOTTOM};`,
385
+ browserChartSource(escapeText),
386
+ browserChartSource(coordinate),
387
+ browserChartSource(plural),
388
+ browserChartSource(agingHeatmapChart),
389
+ browserChartSource(throughputChart),
390
+ browserChartSource(cumulativeFlowChart),
391
+ browserChartSource(dayNumber),
392
+ browserChartSource(cycleTimeChart),
393
+ browserChartSource(forecastChart),
394
+ browserChartSource(weeklyFlowChart),
395
+ ].join('\n');
396
+ }
@@ -64,6 +64,18 @@ export function normalizeReportViews(value, fieldMappings) {
64
64
  // configured `"1"` are different selections. An item missing the mapped field
65
65
  // matches no selected value for that field. Values inside one group are
66
66
  // alternatives; every present group must match.
67
+
68
+ // One accessor for every consumer of a projected field value: absent values
69
+ // carry no candidates, the tags array carries each member, and a scalar
70
+ // carries itself. Named matching below and later selection code share it so
71
+ // one item with two tags answers either tag filter.
72
+ export function reportFieldValues(value) {
73
+ if (value === undefined || value === null) {
74
+ return [];
75
+ }
76
+ return Array.isArray(value) ? value : [value];
77
+ }
78
+
67
79
  export function matchesReportView(item, filters) {
68
80
  if (filters.readiness !== undefined && !filters.readiness.includes(item.readiness.state)) {
69
81
  return false;
@@ -76,8 +88,9 @@ export function matchesReportView(item, filters) {
76
88
  }
77
89
  if (filters.fields !== undefined) {
78
90
  for (const [name, values] of Object.entries(filters.fields)) {
91
+ const carried = reportFieldValues(item.fields[name]);
79
92
  if (!Object.prototype.hasOwnProperty.call(item.fields, name)
80
- || !values.some((candidate) => isSameScalar(candidate, item.fields[name]))) {
93
+ || !values.some((candidate) => carried.some((entry) => isSameScalar(candidate, entry)))) {
81
94
  return false;
82
95
  }
83
96
  }
package/src/report.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { projectReadiness } from './ready.js';
4
4
  import { buildAttention } from './report-attention.js';
5
5
  import { buildEvidence } from './report-evidence.js';
6
+ import { buildReportImpact } from './report-impact.js';
6
7
  import {
7
8
  classifyItem,
8
9
  collectUnknownClasses,
@@ -38,6 +39,7 @@ const FIELD_KEYS = new Set([
38
39
  'severity',
39
40
  'confidence',
40
41
  'security',
42
+ 'tags',
41
43
  'priority_base',
42
44
  'priority_component',
43
45
  'priority_impact',
@@ -108,11 +110,13 @@ export function resolvePointer(value, pointer) {
108
110
  export function buildReportModel(items, config, asOf) {
109
111
  const view = config.view ?? null;
110
112
  const readinessById = projectReadiness(items, asOf);
111
- const allProjected = items.map((item) => projectItem(item, config.fields, readinessById.get(item.data.id)));
113
+ const projections = items.map((item) => projectItem(item, config.fields, readinessById.get(item.data.id)));
114
+ const allProjected = projections.map((projection) => projection.item);
112
115
  const projected = view === null
113
116
  ? allProjected
114
117
  : allProjected.filter((item) => matchesReportView(item, view.filters));
115
118
  const retainedIds = new Set(projected.map((item) => item.id));
119
+ const fieldCoverage = buildFieldCoverage(projections, config.fields, retainedIds);
116
120
  const reportItems = projected
117
121
  .filter((item) => item.terminalDate === null)
118
122
  .sort(compareProjectedItems);
@@ -145,9 +149,11 @@ export function buildReportModel(items, config, asOf) {
145
149
  items: reportItems,
146
150
  terminalItems,
147
151
  workNext,
152
+ impactById: buildReportImpact(items, new Set(reportItems.map((item) => item.id)), asOf),
148
153
  unknownClasses: collectUnknownClasses(reportItems),
149
154
  evidence,
150
155
  attention: buildAttention(reportItems, terminalItems, evidence.cycleTime, asOf),
156
+ fieldCoverage,
151
157
  stats: {
152
158
  total: projected.length,
153
159
  open: reportItems.length,
@@ -181,33 +187,104 @@ export function buildReportModel(items, config, asOf) {
181
187
  function projectItem(item, fieldMappings, readiness) {
182
188
  const { data } = item;
183
189
  const fields = {};
190
+ const invalidFields = [];
184
191
  for (const [slot, pointer] of Object.entries(fieldMappings)) {
185
- const value = resolvePointer(data, pointer);
186
- if (value !== null && ['string', 'number', 'boolean'].includes(typeof value)) {
187
- fields[slot] = value;
192
+ const outcome = projectFieldValue(slot, resolvePointer(data, pointer));
193
+ if (outcome.state === 'present') {
194
+ fields[slot] = outcome.value;
195
+ } else if (outcome.state === 'invalid') {
196
+ invalidFields.push(slot);
188
197
  }
189
198
  }
190
199
 
191
-
192
200
  return {
193
- id: data.id,
194
- number: data.number ?? null,
195
- title: data.title,
196
- kind: data.kind,
197
- status: data.status,
198
- created: data.created,
199
- updated: data.updated,
200
- terminalDate: terminalDate(data),
201
- priority: data.priority ?? null,
202
- parent: data.parent ?? null,
203
- dependsOn: data.depends_on ?? [],
204
- related: data.related ?? [],
205
- decisions: data.decisions ?? [],
206
- body: item.body,
207
- readiness,
208
- fields,
201
+ item: {
202
+ id: data.id,
203
+ number: data.number ?? null,
204
+ title: data.title,
205
+ kind: data.kind,
206
+ status: data.status,
207
+ created: data.created,
208
+ updated: data.updated,
209
+ terminalDate: terminalDate(data),
210
+ priority: data.priority ?? null,
211
+ parent: data.parent ?? null,
212
+ dependsOn: data.depends_on ?? [],
213
+ related: data.related ?? [],
214
+ decisions: data.decisions ?? [],
215
+ body: item.body,
216
+ readiness,
217
+ fields,
218
+ },
219
+ invalidFields,
209
220
  };
210
221
  }
222
+
223
+ // A resolved mapping is present, missing, or invalid, never guessed: absent
224
+ // pointers and nulls are missing, a rejected value is invalid and stays out
225
+ // of `fields` so it can never become a valid classification.
226
+ function projectFieldValue(slot, value) {
227
+ if (value === undefined || value === null) {
228
+ return { state: 'missing', value: undefined };
229
+ }
230
+ if (slot === 'tags') {
231
+ const normalized = normalizeTags(value);
232
+ if (normalized === null) {
233
+ return { state: 'invalid', value: undefined };
234
+ }
235
+ return normalized.length === 0
236
+ ? { state: 'missing', value: undefined }
237
+ : { state: 'present', value: normalized };
238
+ }
239
+ return ['string', 'number', 'boolean'].includes(typeof value)
240
+ ? { state: 'present', value }
241
+ : { state: 'invalid', value: undefined };
242
+ }
243
+
244
+ // Tags are the one multi-value slot. A scalar source reads as a one-tag set;
245
+ // an array must hold only nonempty strings and is taken whole, never
246
+ // partially. Exact duplicates collapse and values sort deterministically. No
247
+ // comma splitting, lowercasing, or object coercion.
248
+ function normalizeTags(value) {
249
+ const source = typeof value === 'string' ? [value] : value;
250
+ if (!Array.isArray(source)) {
251
+ return null;
252
+ }
253
+ if (source.length === 0) {
254
+ return [];
255
+ }
256
+ if (!source.every((entry) => typeof entry === 'string' && entry.trim().length > 0)) {
257
+ return null;
258
+ }
259
+ return [...new Set(source)].sort();
260
+ }
261
+
262
+ // Coverage counts the retained named-report population, open and terminal, in
263
+ // field-name order. Every configured field appears with `mapped: true`; area
264
+ // and tags appear with `mapped: false` when not configured, with every
265
+ // retained item missing. Counts never print rejected raw values.
266
+ function buildFieldCoverage(projections, fieldMappings, retainedIds) {
267
+ const names = [...new Set([...Object.keys(fieldMappings), 'area', 'tags'])].sort();
268
+ return names.map((name) => {
269
+ const mapped = Object.prototype.hasOwnProperty.call(fieldMappings, name);
270
+ let present = 0;
271
+ let missing = 0;
272
+ let invalid = 0;
273
+ for (const projection of projections) {
274
+ if (!retainedIds.has(projection.item.id)) {
275
+ continue;
276
+ }
277
+ if (Object.prototype.hasOwnProperty.call(projection.item.fields, name)) {
278
+ present += 1;
279
+ } else if (projection.invalidFields.includes(name)) {
280
+ invalid += 1;
281
+ } else {
282
+ missing += 1;
283
+ }
284
+ }
285
+ return { name, mapped, present, missing, invalid };
286
+ });
287
+ }
211
288
  export function buildSwarmBatches(items, eligibleComplexities) {
212
289
  const pool = items.filter((item) => item.readiness.state === 'ready'
213
290
  && isNonEmptyString(item.fields.area)