@profpowell/vb-project-planning 0.1.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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* impact-effort: 2x2 prioritization matrix with drag-and-drop
|
|
3
|
+
*
|
|
4
|
+
* Coordinates four <drag-surface> elements in a Quick Wins / Big Bets /
|
|
5
|
+
* Fill-Ins / Money Pit grid. Items move between quadrants via native
|
|
6
|
+
* drag-and-drop or keyboard reorder (provided by drag-surface).
|
|
7
|
+
*
|
|
8
|
+
* Light DOM component — does NOT import drag-surface; expects it to be
|
|
9
|
+
* registered by the main bundle or pack entry.
|
|
10
|
+
*
|
|
11
|
+
* @attr {string} src - URL to JSON data for items
|
|
12
|
+
* @attr {boolean} compact - Reduced spacing variant
|
|
13
|
+
*
|
|
14
|
+
* Children use:
|
|
15
|
+
* @attr {string} data-quadrant - Target quadrant: quick-wins | big-bets | fill-ins | money-pit
|
|
16
|
+
* @attr {boolean} draggable="true" - Required for drag capability
|
|
17
|
+
* @attr {string} data-id - Stable identifier for the item
|
|
18
|
+
*
|
|
19
|
+
* @fires impact-effort:move - When an item is dragged between quadrants
|
|
20
|
+
* @fires impact-effort:ready - After component initializes with quadrant counts
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
24
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
25
|
+
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
26
|
+
import { diffByKey } from '../../lib/diff-by-key.js';
|
|
27
|
+
|
|
28
|
+
class ImpactEffort extends VBElement {
|
|
29
|
+
static QUADRANTS = ['quick-wins', 'big-bets', 'fill-ins', 'money-pit'];
|
|
30
|
+
|
|
31
|
+
static LABELS = {
|
|
32
|
+
'quick-wins': 'Quick Wins',
|
|
33
|
+
'big-bets': 'Big Bets',
|
|
34
|
+
'fill-ins': 'Fill-Ins',
|
|
35
|
+
'money-pit': 'Money Pit',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
static DESCRIPTIONS = {
|
|
39
|
+
'quick-wins': 'High impact \u00b7 Low effort',
|
|
40
|
+
'big-bets': 'High impact \u00b7 High effort',
|
|
41
|
+
'fill-ins': 'Low impact \u00b7 Low effort',
|
|
42
|
+
'money-pit': 'Low impact \u00b7 High effort',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
static #instanceCount = 0;
|
|
46
|
+
|
|
47
|
+
static get observedAttributes() {
|
|
48
|
+
return ['src', 'compact', 'title'];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @type {HTMLElement | null} */
|
|
52
|
+
#grid = null;
|
|
53
|
+
/** @type {Record<string, HTMLElement>} */
|
|
54
|
+
#surfaces = {};
|
|
55
|
+
/** @type {HTMLElement | null} */
|
|
56
|
+
#liveRegion = null;
|
|
57
|
+
/** @type {string} */
|
|
58
|
+
#groupId = '';
|
|
59
|
+
/** @type {HTMLElement | null} */
|
|
60
|
+
#wrapper = null;
|
|
61
|
+
|
|
62
|
+
setup() {
|
|
63
|
+
this.#groupId = `ie-${++ImpactEffort.#instanceCount}`;
|
|
64
|
+
|
|
65
|
+
// 1. Snapshot children that belong in quadrants
|
|
66
|
+
const children = [...this.querySelectorAll(':scope > [data-quadrant], :scope > [draggable]')];
|
|
67
|
+
|
|
68
|
+
// 2. Build wrapper: .ie-wrapper > .ie-y-label + column-div > .ie-grid + .ie-x-label
|
|
69
|
+
const wrapper = document.createElement('div');
|
|
70
|
+
wrapper.className = 'ie-wrapper';
|
|
71
|
+
|
|
72
|
+
const yLabel = document.createElement('div');
|
|
73
|
+
yLabel.className = 'ie-y-label';
|
|
74
|
+
yLabel.setAttribute('aria-hidden', 'true');
|
|
75
|
+
yLabel.textContent = 'Impact \u2191';
|
|
76
|
+
|
|
77
|
+
const column = document.createElement('div');
|
|
78
|
+
column.style.cssText = 'display:flex;flex-direction:column;flex:1;min-width:0;';
|
|
79
|
+
|
|
80
|
+
const grid = document.createElement('div');
|
|
81
|
+
grid.className = 'ie-grid';
|
|
82
|
+
grid.setAttribute('role', 'region');
|
|
83
|
+
grid.setAttribute('aria-label', 'Impact-Effort prioritization matrix');
|
|
84
|
+
|
|
85
|
+
const xLabel = document.createElement('div');
|
|
86
|
+
xLabel.className = 'ie-x-label';
|
|
87
|
+
xLabel.setAttribute('aria-hidden', 'true');
|
|
88
|
+
xLabel.textContent = 'Effort \u2192';
|
|
89
|
+
|
|
90
|
+
// 3. Build quadrant sections
|
|
91
|
+
for (const q of ImpactEffort.QUADRANTS) {
|
|
92
|
+
const section = document.createElement('section');
|
|
93
|
+
section.className = 'ie-quadrant';
|
|
94
|
+
section.dataset.quadrantZone = q;
|
|
95
|
+
section.setAttribute('aria-label', `${ImpactEffort.LABELS[q]}: ${ImpactEffort.DESCRIPTIONS[q]}`);
|
|
96
|
+
|
|
97
|
+
const header = document.createElement('header');
|
|
98
|
+
header.className = 'ie-quadrant-label';
|
|
99
|
+
header.innerHTML =
|
|
100
|
+
`${ImpactEffort.LABELS[q]}<br><span class="ie-quadrant-desc">${ImpactEffort.DESCRIPTIONS[q]}</span>`;
|
|
101
|
+
|
|
102
|
+
const surface = document.createElement('drag-surface');
|
|
103
|
+
surface.setAttribute('group', this.#groupId);
|
|
104
|
+
surface.setAttribute('aria-label', ImpactEffort.LABELS[q]);
|
|
105
|
+
surface.setAttribute('data-layout', 'stack');
|
|
106
|
+
surface.setAttribute('data-layout-gap', 'xs');
|
|
107
|
+
|
|
108
|
+
section.appendChild(header);
|
|
109
|
+
section.appendChild(surface);
|
|
110
|
+
grid.appendChild(section);
|
|
111
|
+
|
|
112
|
+
this.#surfaces[q] = surface;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 4. Distribute captured children into matching surfaces
|
|
116
|
+
children.forEach((c, i) => {
|
|
117
|
+
const child = /** @type {HTMLElement} */ (c);
|
|
118
|
+
const quadrant = child.getAttribute('data-quadrant') || 'quick-wins';
|
|
119
|
+
const target = this.#surfaces[quadrant] || this.#surfaces['quick-wins'];
|
|
120
|
+
|
|
121
|
+
// Ensure draggable and data-id
|
|
122
|
+
if (!child.hasAttribute('draggable')) {
|
|
123
|
+
child.setAttribute('draggable', 'true');
|
|
124
|
+
}
|
|
125
|
+
if (!child.hasAttribute('data-id')) {
|
|
126
|
+
child.dataset.id = `ie-item-${i}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
target.appendChild(child);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 5. Create live region
|
|
133
|
+
const liveRegion = document.createElement('div');
|
|
134
|
+
liveRegion.className = 'ie-live-region';
|
|
135
|
+
liveRegion.setAttribute('role', 'status');
|
|
136
|
+
liveRegion.setAttribute('aria-live', 'polite');
|
|
137
|
+
liveRegion.setAttribute('aria-atomic', 'true');
|
|
138
|
+
|
|
139
|
+
// 6. Assemble DOM
|
|
140
|
+
const title = this.getAttribute('title');
|
|
141
|
+
if (title) {
|
|
142
|
+
const heading = document.createElement('h3');
|
|
143
|
+
heading.className = 'ie-title';
|
|
144
|
+
heading.textContent = title;
|
|
145
|
+
this.prepend(heading);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
column.appendChild(grid);
|
|
149
|
+
column.appendChild(xLabel);
|
|
150
|
+
wrapper.appendChild(yLabel);
|
|
151
|
+
wrapper.appendChild(column);
|
|
152
|
+
this.appendChild(wrapper);
|
|
153
|
+
this.appendChild(liveRegion);
|
|
154
|
+
|
|
155
|
+
this.#grid = grid;
|
|
156
|
+
this.#liveRegion = liveRegion;
|
|
157
|
+
this.#wrapper = wrapper;
|
|
158
|
+
|
|
159
|
+
// 7. Listen for cross-surface transfers
|
|
160
|
+
this.listen(this, 'drag-surface:transfer', (/** @type {CustomEvent} */ e) => {
|
|
161
|
+
const { item, fromSurface, toSurface } = e.detail;
|
|
162
|
+
const fromQuadrant = this.#findQuadrantForSurface(fromSurface);
|
|
163
|
+
const toQuadrant = this.#findQuadrantForSurface(toSurface);
|
|
164
|
+
|
|
165
|
+
if (!fromQuadrant || !toQuadrant) return;
|
|
166
|
+
|
|
167
|
+
// Update the item's quadrant attribute
|
|
168
|
+
item.setAttribute('data-quadrant', toQuadrant);
|
|
169
|
+
|
|
170
|
+
// Announce the move
|
|
171
|
+
this.#announce(`Moved ${this.#itemLabel(item)} to ${ImpactEffort.LABELS[toQuadrant]}`);
|
|
172
|
+
|
|
173
|
+
// Dispatch component-level event
|
|
174
|
+
this.dispatchEvent(new CustomEvent('impact-effort:move', {
|
|
175
|
+
bubbles: true,
|
|
176
|
+
detail: {
|
|
177
|
+
itemId: item.dataset.id,
|
|
178
|
+
from: fromQuadrant,
|
|
179
|
+
to: toQuadrant,
|
|
180
|
+
item,
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// 8. Handle src attribute for data mode
|
|
186
|
+
const src = this.getAttribute('src');
|
|
187
|
+
if (src) {
|
|
188
|
+
this.#loadFromSrc(src);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 9. Dispatch ready event with counts
|
|
192
|
+
this.#dispatchReady();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
teardown() {
|
|
196
|
+
if (this.#wrapper) {
|
|
197
|
+
this.#wrapper.remove();
|
|
198
|
+
this.#wrapper = null;
|
|
199
|
+
}
|
|
200
|
+
if (this.#liveRegion) {
|
|
201
|
+
this.#liveRegion.remove();
|
|
202
|
+
this.#liveRegion = null;
|
|
203
|
+
}
|
|
204
|
+
this.#grid = null;
|
|
205
|
+
this.#surfaces = {};
|
|
206
|
+
this.#nodes.clear();
|
|
207
|
+
this.#items = [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
211
|
+
|
|
212
|
+
/** @type {any[]} */
|
|
213
|
+
#items = [];
|
|
214
|
+
/** @type {Map<unknown, Element>} */
|
|
215
|
+
#nodes = new Map();
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the matrix items as a plain array. Each entry:
|
|
219
|
+
* `{ id, quadrant: 'quick-wins'|'big-bets'|'fill-ins'|'money-pit', text? }`.
|
|
220
|
+
* After upgrade reflects the parsed children; after assignment reflects
|
|
221
|
+
* what was passed in.
|
|
222
|
+
*/
|
|
223
|
+
get items() {
|
|
224
|
+
if (this.#items.length) return this.#items;
|
|
225
|
+
const result = [];
|
|
226
|
+
for (const [q, surface] of Object.entries(this.#surfaces)) {
|
|
227
|
+
for (const el of surface.querySelectorAll(':scope > [draggable="true"]')) {
|
|
228
|
+
result.push({
|
|
229
|
+
id: el.getAttribute('data-id') || undefined,
|
|
230
|
+
quadrant: q,
|
|
231
|
+
text: el.textContent?.trim() || undefined,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Replace the matrix items and route each into its quadrant surface.
|
|
240
|
+
* Runs a keyed diff so existing nodes whose id persists are preserved
|
|
241
|
+
* across re-assignment (in-flight drag state, focus, animations survive).
|
|
242
|
+
* New ids render as <article class="ie-card"> by default; override via
|
|
243
|
+
* `.renderItem`.
|
|
244
|
+
*
|
|
245
|
+
* Emits impact-effort:items-changed { items, source: 'property' }.
|
|
246
|
+
*/
|
|
247
|
+
set items(value) {
|
|
248
|
+
if (!this.#grid) return;
|
|
249
|
+
const next = Array.isArray(value) ? value : [];
|
|
250
|
+
diffByKey({
|
|
251
|
+
newItems: next,
|
|
252
|
+
nodes: this.#nodes,
|
|
253
|
+
keyOf: (it) => it.id ?? `${it.quadrant}:${it.text}`,
|
|
254
|
+
renderItem: (it) => {
|
|
255
|
+
const self = /** @type {any} */ (this);
|
|
256
|
+
if (typeof self.renderItem === 'function') {
|
|
257
|
+
const out = self.renderItem(it);
|
|
258
|
+
if (out instanceof Element) {
|
|
259
|
+
if (!out.hasAttribute('draggable')) out.setAttribute('draggable', 'true');
|
|
260
|
+
if (!out.hasAttribute('data-id')) out.setAttribute('data-id', String(it.id ?? ''));
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const article = document.createElement('article');
|
|
265
|
+
article.className = 'ie-card';
|
|
266
|
+
article.setAttribute('draggable', 'true');
|
|
267
|
+
if (it.id) article.setAttribute('data-id', String(it.id));
|
|
268
|
+
article.textContent = it.text || it.id || '';
|
|
269
|
+
return article;
|
|
270
|
+
},
|
|
271
|
+
containerFor: (it) => this.#surfaces[it.quadrant] || this.#surfaces['quick-wins'],
|
|
272
|
+
});
|
|
273
|
+
this.#items = next;
|
|
274
|
+
this.dispatchEvent(new CustomEvent('impact-effort:items-changed', {
|
|
275
|
+
detail: { items: next, source: 'property' },
|
|
276
|
+
bubbles: true,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
281
|
+
if (!this.hasAttribute('data-upgraded')) return;
|
|
282
|
+
|
|
283
|
+
if (name === 'src' && newVal && newVal !== oldVal) {
|
|
284
|
+
this.#loadFromSrc(newVal);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Data loading ──────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
async #loadFromSrc(url) {
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetch(url);
|
|
293
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
294
|
+
const data = await res.json();
|
|
295
|
+
|
|
296
|
+
if (!Array.isArray(data)) return;
|
|
297
|
+
|
|
298
|
+
const swap = () => {
|
|
299
|
+
// Clear existing items from all surfaces
|
|
300
|
+
for (const q of ImpactEffort.QUADRANTS) {
|
|
301
|
+
const surface = this.#surfaces[q];
|
|
302
|
+
if (!surface) continue;
|
|
303
|
+
for (const child of [...surface.querySelectorAll('[draggable]')]) {
|
|
304
|
+
child.remove();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Create and distribute items — use <user-story> when story data present
|
|
309
|
+
data.forEach((entry, i) => {
|
|
310
|
+
const quadrant = entry.quadrant || 'quick-wins';
|
|
311
|
+
const id = entry.id || `ie-item-${i}`;
|
|
312
|
+
let el;
|
|
313
|
+
|
|
314
|
+
if (entry.persona || entry.action || entry.storyId) {
|
|
315
|
+
el = document.createElement('user-story');
|
|
316
|
+
el.setAttribute('detail', 'minimal');
|
|
317
|
+
if (entry.storyId) el.setAttribute('story-id', entry.storyId);
|
|
318
|
+
if (entry.persona) el.setAttribute('persona', entry.persona);
|
|
319
|
+
if (entry.action) el.setAttribute('action', entry.action);
|
|
320
|
+
if (entry.benefit) el.setAttribute('benefit', entry.benefit);
|
|
321
|
+
if (entry.priority) el.setAttribute('priority', entry.priority);
|
|
322
|
+
if (entry.status) el.setAttribute('status', entry.status);
|
|
323
|
+
if (entry.points) el.setAttribute('points', String(entry.points));
|
|
324
|
+
} else {
|
|
325
|
+
el = document.createElement('article');
|
|
326
|
+
el.textContent = entry.label || entry.text || '';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
el.setAttribute('draggable', 'true');
|
|
330
|
+
el.dataset.id = id;
|
|
331
|
+
el.dataset.quadrant = quadrant;
|
|
332
|
+
|
|
333
|
+
const target = this.#surfaces[quadrant] || this.#surfaces['quick-wins'];
|
|
334
|
+
target.appendChild(el);
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
if (this.hasAttribute('data-upgraded') && this.#grid) {
|
|
339
|
+
viewTransitionSwap(this, swap, 'ie-vt');
|
|
340
|
+
} else {
|
|
341
|
+
swap();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
this.#dispatchReady();
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.warn(`[impact-effort] Failed to load src="${url}":`, err);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** Reverse lookup: find which quadrant name owns a given drag-surface element */
|
|
353
|
+
#findQuadrantForSurface(surface) {
|
|
354
|
+
for (const [q, s] of Object.entries(this.#surfaces)) {
|
|
355
|
+
if (s === surface) return q;
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Set live region text content (with clear-and-set for re-announcement) */
|
|
361
|
+
#announce(msg) {
|
|
362
|
+
const region = this.#liveRegion;
|
|
363
|
+
if (!region) return;
|
|
364
|
+
region.textContent = '';
|
|
365
|
+
requestAnimationFrame(() => {
|
|
366
|
+
region.textContent = msg;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Derive a label for an item from data-id or textContent */
|
|
371
|
+
#itemLabel(item) {
|
|
372
|
+
return item.dataset.id || item.textContent.trim().slice(0, 40);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Dispatch the ready event with counts per quadrant */
|
|
376
|
+
#dispatchReady() {
|
|
377
|
+
const quadrantCounts = {};
|
|
378
|
+
for (const q of ImpactEffort.QUADRANTS) {
|
|
379
|
+
const surface = this.#surfaces[q];
|
|
380
|
+
quadrantCounts[q] = surface
|
|
381
|
+
? surface.querySelectorAll('[draggable="true"]').length
|
|
382
|
+
: 0;
|
|
383
|
+
}
|
|
384
|
+
this.dispatchEvent(new CustomEvent('impact-effort:ready', {
|
|
385
|
+
bubbles: true,
|
|
386
|
+
detail: { quadrantCounts },
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
registerComponent('impact-effort', ImpactEffort);
|
|
392
|
+
export { ImpactEffort };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
impact-effort — static (zero-JS) form
|
|
3
|
+
User-story children compose naturally. Before JS upgrades,
|
|
4
|
+
they display as a flat list; after, they're distributed into the 2x2 grid.
|
|
5
|
+
-->
|
|
6
|
+
<impact-effort>
|
|
7
|
+
<user-story data-quadrant="quick-wins" draggable="true" data-id="PROJ-201"
|
|
8
|
+
story-id="PROJ-201" persona="PM" action="add CSV export to reports"
|
|
9
|
+
priority="medium" status="to-do" points="2" detail="minimal">
|
|
10
|
+
</user-story>
|
|
11
|
+
<user-story data-quadrant="big-bets" draggable="true" data-id="PROJ-301"
|
|
12
|
+
story-id="PROJ-301" persona="Developer" action="migrate to new API gateway"
|
|
13
|
+
priority="high" status="in-progress" points="13" detail="minimal">
|
|
14
|
+
</user-story>
|
|
15
|
+
<user-story data-quadrant="fill-ins" draggable="true" data-id="PROJ-401"
|
|
16
|
+
story-id="PROJ-401" persona="Developer" action="update README with new setup steps"
|
|
17
|
+
priority="low" status="backlog" points="1" detail="minimal">
|
|
18
|
+
</user-story>
|
|
19
|
+
<user-story data-quadrant="money-pit" draggable="true" data-id="PROJ-501"
|
|
20
|
+
story-id="PROJ-501" persona="Admin" action="rewrite legacy auth module"
|
|
21
|
+
priority="medium" status="backlog" points="21" detail="minimal">
|
|
22
|
+
</user-story>
|
|
23
|
+
</impact-effort>
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* ── impact-effort ─────────────────────────────────────────────────────────── */
|
|
2
|
+
/* Light DOM styles — element selector pattern */
|
|
3
|
+
|
|
4
|
+
impact-effort {
|
|
5
|
+
display: block;
|
|
6
|
+
container-type: inline-size;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
impact-effort:not(:defined) {
|
|
10
|
+
display: block;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ── Wrapper (y-label + column) ──────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
.ie-wrapper {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: stretch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ie-y-label {
|
|
21
|
+
writing-mode: vertical-rl;
|
|
22
|
+
transform: rotate(180deg);
|
|
23
|
+
font-size: 0.7rem;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
color: var(--color-text-muted, #666);
|
|
26
|
+
text-align: center;
|
|
27
|
+
padding: 0 var(--size-xs, 8px);
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
user-select: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.ie-x-label {
|
|
35
|
+
font-size: 0.7rem;
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
color: var(--color-text-muted, #666);
|
|
38
|
+
text-align: center;
|
|
39
|
+
padding: var(--size-xs, 8px) 0;
|
|
40
|
+
user-select: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── 2x2 Grid ────────────────────────────────────────────────────────────── */
|
|
44
|
+
|
|
45
|
+
.ie-grid {
|
|
46
|
+
grid-template-columns: 1fr 1fr;
|
|
47
|
+
grid-template-rows: 1fr 1fr;
|
|
48
|
+
gap: 1px;
|
|
49
|
+
background: var(--color-border, #e0e0e0);
|
|
50
|
+
border: 1px solid var(--color-border, #e0e0e0);
|
|
51
|
+
border-radius: var(--radius-l, 0.75rem);
|
|
52
|
+
overflow: hidden;
|
|
53
|
+
min-height: 24rem;
|
|
54
|
+
display: grid;
|
|
55
|
+
flex: 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── Quadrant sections ───────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
.ie-quadrant {
|
|
61
|
+
background: var(--color-surface, #fff);
|
|
62
|
+
padding: var(--size-m, 16px);
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: var(--size-xs, 8px);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ie-quadrant-label {
|
|
69
|
+
font-size: 0.75rem;
|
|
70
|
+
font-weight: 700;
|
|
71
|
+
text-transform: uppercase;
|
|
72
|
+
letter-spacing: 0.05em;
|
|
73
|
+
color: var(--color-text-muted, #666);
|
|
74
|
+
margin-bottom: var(--size-xs, 8px);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ie-quadrant-desc {
|
|
78
|
+
font-size: 0.65rem;
|
|
79
|
+
font-weight: 400;
|
|
80
|
+
text-transform: none;
|
|
81
|
+
letter-spacing: normal;
|
|
82
|
+
color: var(--color-text-muted, #999);
|
|
83
|
+
display: block;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── Quadrant zone tints ─────────────────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
[data-quadrant-zone="quick-wins"] {
|
|
89
|
+
background: color-mix(in oklch, var(--color-success, #22c55e) 6%, var(--color-surface, #fff));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
[data-quadrant-zone="big-bets"] {
|
|
93
|
+
background: color-mix(in oklch, var(--color-warning, #f59e0b) 6%, var(--color-surface, #fff));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[data-quadrant-zone="fill-ins"] {
|
|
97
|
+
background: color-mix(in oklch, var(--color-info, #3b82f6) 6%, var(--color-surface, #fff));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
[data-quadrant-zone="money-pit"] {
|
|
101
|
+
background: color-mix(in oklch, var(--color-error, #ef4444) 6%, var(--color-surface, #fff));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── Inner drag-surfaces ─────────────────────────────────────────────────── */
|
|
105
|
+
|
|
106
|
+
impact-effort drag-surface {
|
|
107
|
+
min-block-size: 6rem;
|
|
108
|
+
flex: 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── Live region (visually hidden) ───────────────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
.ie-live-region {
|
|
114
|
+
position: absolute;
|
|
115
|
+
width: 1px;
|
|
116
|
+
height: 1px;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
clip: rect(0, 0, 0, 0);
|
|
119
|
+
white-space: nowrap;
|
|
120
|
+
border: 0;
|
|
121
|
+
padding: 0;
|
|
122
|
+
margin: -1px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── Compact variant ─────────────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
impact-effort[compact] .ie-grid {
|
|
128
|
+
min-height: 16rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
impact-effort[compact] .ie-quadrant {
|
|
132
|
+
padding: var(--size-xs, 8px);
|
|
133
|
+
gap: var(--size-2xs, 4px);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
impact-effort[compact] .ie-quadrant-label {
|
|
137
|
+
font-size: 0.65rem;
|
|
138
|
+
margin-bottom: var(--size-2xs, 4px);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
impact-effort[compact] .ie-quadrant-desc {
|
|
142
|
+
font-size: 0.6rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
impact-effort[compact] drag-surface {
|
|
146
|
+
min-block-size: 4rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
impact-effort[compact] .ie-y-label,
|
|
150
|
+
impact-effort[compact] .ie-x-label {
|
|
151
|
+
font-size: 0.6rem;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ── Responsive: single column ───────────────────────────────────────────── */
|
|
155
|
+
|
|
156
|
+
@container (max-width: 30rem) {
|
|
157
|
+
.ie-grid {
|
|
158
|
+
grid-template-columns: 1fr;
|
|
159
|
+
grid-template-rows: auto;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ── Reduced motion ──────────────────────────────────────────────────────── */
|
|
164
|
+
|
|
165
|
+
@media (prefers-reduced-motion: reduce) {
|
|
166
|
+
impact-effort,
|
|
167
|
+
impact-effort * {
|
|
168
|
+
transition: none;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ── Print ───────────────────────────────────────────────────────────────── */
|
|
173
|
+
|
|
174
|
+
@media print {
|
|
175
|
+
.ie-grid {
|
|
176
|
+
border: 1px solid #ccc;
|
|
177
|
+
gap: 0;
|
|
178
|
+
min-height: auto;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.ie-quadrant {
|
|
182
|
+
border: 0.5pt solid #ccc;
|
|
183
|
+
break-inside: avoid;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
impact-effort drag-surface {
|
|
187
|
+
min-block-size: auto;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
impact-effort [draggable] {
|
|
191
|
+
cursor: default;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure capacity-calculation helpers for <iron-triangle>.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the component so the component stays slim and so
|
|
5
|
+
* the math is unit-testable without DOM stubs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default capacity formula: sprintWeeks × teamFTE × focusFactor, ceilinged.
|
|
10
|
+
* Returns 0 when inputs are missing or non-positive.
|
|
11
|
+
*/
|
|
12
|
+
export function defaultFormula(time, cost, focusFactor) {
|
|
13
|
+
const weeks = num(time?.sprintCount) > 0
|
|
14
|
+
? num(time.sprintCount) * (num(time.sprintWeeks) || 1)
|
|
15
|
+
: num(time?.sprintWeeks);
|
|
16
|
+
const fte = num(cost?.teamFTE);
|
|
17
|
+
const f = clamp(num(focusFactor), 0, 1);
|
|
18
|
+
if (!(weeks > 0) || !(fte > 0) || !(f > 0)) return 0;
|
|
19
|
+
return Math.ceil(weeks * fte * f);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format the formula string used as the human-readable explanation
|
|
24
|
+
* shown beneath the readout. Matches the formula above; if either
|
|
25
|
+
* factor is missing the string degrades gracefully.
|
|
26
|
+
*/
|
|
27
|
+
export function defaultFormulaText(time, cost, focusFactor) {
|
|
28
|
+
const sprintCount = num(time?.sprintCount);
|
|
29
|
+
const sprintWeeks = num(time?.sprintWeeks) || 1;
|
|
30
|
+
const totalWeeks = sprintCount > 0 ? sprintCount * sprintWeeks : sprintWeeks;
|
|
31
|
+
const fte = num(cost?.teamFTE);
|
|
32
|
+
const f = clamp(num(focusFactor), 0, 1);
|
|
33
|
+
const points = defaultFormula(time, cost, focusFactor);
|
|
34
|
+
if (!points) return 'Set sprint length, team size, and focus factor to compute capacity.';
|
|
35
|
+
const weeksLabel = sprintCount > 0
|
|
36
|
+
? `${sprintCount} × ${sprintWeeks}wk = ${totalWeeks}wk`
|
|
37
|
+
: `${sprintWeeks}wk`;
|
|
38
|
+
return `${weeksLabel} × ${fte} FTE × ${f} = ${(totalWeeks * fte * f).toFixed(1)} → ${points} points`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* FNV-1a (32-bit) hash over a stable JSON serialization of the triangle.
|
|
43
|
+
* Used for drift detection by downstream consumers (notably <nfr-compass>'s
|
|
44
|
+
* ironTriangleHash field).
|
|
45
|
+
*
|
|
46
|
+
* Matches the fnv1a in src/web-components/color-picker/_color-utils.js so
|
|
47
|
+
* the two surfaces share one hash function.
|
|
48
|
+
*/
|
|
49
|
+
export function fnv1a(str) {
|
|
50
|
+
let hash = 0x811c9dc5;
|
|
51
|
+
for (let i = 0; i < str.length; i++) {
|
|
52
|
+
hash ^= str.charCodeAt(i);
|
|
53
|
+
hash = Math.imul(hash, 0x01000193);
|
|
54
|
+
}
|
|
55
|
+
return (hash >>> 0).toString(36);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stable JSON serialization (sorted keys) so the hash is deterministic
|
|
60
|
+
* across browsers and key-insertion order.
|
|
61
|
+
*/
|
|
62
|
+
export function stableStringify(value) {
|
|
63
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
64
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
65
|
+
const keys = Object.keys(value).sort();
|
|
66
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function triangleHash({ time, cost, scope }) {
|
|
70
|
+
return fnv1a(stableStringify({ time: time || {}, cost: cost || {}, scope: scope || {} }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function num(value) {
|
|
76
|
+
if (value === '' || value == null) return 0;
|
|
77
|
+
const n = Number(value);
|
|
78
|
+
return Number.isFinite(n) ? n : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function clamp(n, min, max) {
|
|
82
|
+
return Math.max(min, Math.min(max, n));
|
|
83
|
+
}
|