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.
- package/CHANGELOG.md +59 -0
- package/README.md +145 -81
- package/package.json +1 -1
- package/schemas/report-config-v1.json +4 -0
- package/schemas/report-config-v2.json +5 -0
- package/skills/wowbagger/SKILL.md +7 -5
- package/src/report-evidence.js +158 -41
- package/src/report-graph.js +201 -73
- package/src/report-html.js +358 -155
- package/src/report-impact.js +106 -0
- package/src/report-selection.js +97 -0
- package/src/report-sequencing.js +4 -4
- package/src/report-svg.js +74 -20
- package/src/report-view.js +14 -1
- package/src/report.js +98 -21
|
@@ -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
|
+
}
|
package/src/report-sequencing.js
CHANGED
|
@@ -98,7 +98,7 @@ export function computeEpicEnablement(allItems) {
|
|
|
98
98
|
return epicById;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Transitive
|
|
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,
|
|
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: `
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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 ${
|
|
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.
|
|
111
|
-
+ `<rect class="bar-
|
|
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="
|
|
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
|
|
191
|
+
const CHART_MILLISECONDS_PER_DAY = 86400000;
|
|
190
192
|
|
|
191
193
|
function dayNumber(date) {
|
|
192
|
-
return Date.parse(`${date}T00:00:00Z`) /
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
+
|
|
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
|
|
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
|
+
}
|
package/src/report-view.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
186
|
-
if (
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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)
|