@profpowell/vb-project-planning 0.2.1 → 0.2.3
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/README.md +10 -5
- package/dist/vb-project-planning.css +69 -3
- package/dist/vb-project-planning.js +3191 -2544
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +221 -197
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/lib/vb-view-transition.js +7 -1
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +14 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +0 -18
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/risk-register/styles.css +9 -1
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +22 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- package/src/web-components/work-item/logic.js +58 -154
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
"description": "MADR-format Architectural Decision Record card with status, context, decision, consequences, and cross-referencing via supersedes chains",
|
|
6
6
|
"htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
|
|
7
7
|
"attributes": [
|
|
8
|
-
{ "name": "adr-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "ADR identifier (e.g., ADR-001)
|
|
9
|
-
{ "name": "status", "kind": "host-api", "purpose": "config", "type": "
|
|
8
|
+
{ "name": "adr-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "ADR identifier (e.g., ADR-001); sets the HTML id on first connect when none is set" },
|
|
9
|
+
{ "name": "status", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["proposed", "accepted", "deprecated", "superseded"], "default": "proposed", "description": "Decision status" },
|
|
10
10
|
{ "name": "supersedes", "kind": "host-api", "purpose": "config", "type": "string", "description": "Comma-separated ADR IDs this supersedes" },
|
|
11
11
|
{ "name": "superseded-by", "kind": "host-api", "purpose": "config", "type": "string", "description": "Comma-separated ADR IDs that supersede this" },
|
|
12
|
-
{ "name": "detail", "kind": "host-api", "purpose": "config", "type": "
|
|
12
|
+
{ "name": "detail", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["full", "compact", "minimal"], "default": "full", "description": "Detail level. minimal shows id, status and title only." },
|
|
13
13
|
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Alias for detail=compact" },
|
|
14
|
-
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL
|
|
14
|
+
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of a JSON ADR record. Fetched once per value; a newer value aborts the older request. A YYYY-MM-DD date is shown as that calendar day in every time zone." }
|
|
15
15
|
],
|
|
16
16
|
"slots": [
|
|
17
17
|
{ "name": "title", "description": "Decision heading (e.g., <h2>)" },
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
{ "name": "consequences", "description": "Outcomes (positive and negative)" }
|
|
22
22
|
],
|
|
23
23
|
"events": [
|
|
24
|
-
{ "name": "adr-wc:ready", "detail": "{ adrId, title, status }", "description": "
|
|
24
|
+
{ "name": "adr-wc:ready", "detail": "{ adrId, title, status }", "description": "Once per element, after the first render at any detail level. With src, after the fetched data renders (not fired if the fetch fails). Not re-fired by attribute changes or moves." },
|
|
25
|
+
{ "name": "adr-wc:upgraded", "detail": "", "description": "Once, in a microtask after first connect. Wait for this before assigning .data from a framework." },
|
|
26
|
+
{ "name": "adr-wc:data-changed", "detail": "{ data, source: 'property' }", "description": "After .data is assigned" }
|
|
25
27
|
]
|
|
26
28
|
}
|
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
* @attr {string} superseded-by - Comma-separated ADR IDs that supersede this
|
|
20
20
|
* @attr {string} detail - full | compact | minimal
|
|
21
21
|
* @attr {boolean} compact - Alias for detail="compact"
|
|
22
|
-
* @attr {string} src - URL to JSON data
|
|
22
|
+
* @attr {string} src - URL to JSON data (fetched once per value)
|
|
23
23
|
*
|
|
24
|
-
* @fires adr-wc:ready
|
|
24
|
+
* @fires adr-wc:ready - Once, after the first render (after the src data renders when src is set), detail: { adrId, title, status }
|
|
25
|
+
* @fires adr-wc:upgraded - Once, after first connect
|
|
26
|
+
* @fires adr-wc:data-changed - After .data assignment, detail: { data, source: 'property' }
|
|
25
27
|
*
|
|
26
28
|
* @example
|
|
27
29
|
* <adr-wc adr-id="ADR-001" status="accepted">
|
|
@@ -38,7 +40,10 @@
|
|
|
38
40
|
|
|
39
41
|
import { styles } from './styles.js';
|
|
40
42
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
41
|
-
import {
|
|
43
|
+
import { parseISODate, formatDate } from '../../lib/dates.js';
|
|
44
|
+
import {
|
|
45
|
+
esc, lucideSvg, UXShadowElement, lookup, fillSlot, slottedChild, splitList, detailLevel, markEmptySections,
|
|
46
|
+
} from '../_ux-base.js';
|
|
42
47
|
|
|
43
48
|
/* ── Status metadata ──────────────────────────────── */
|
|
44
49
|
|
|
@@ -61,7 +66,9 @@ const ICONS = {
|
|
|
61
66
|
|
|
62
67
|
/* ── Component ─────────────────────────────────────── */
|
|
63
68
|
|
|
64
|
-
class AdrWc extends
|
|
69
|
+
class AdrWc extends UXShadowElement {
|
|
70
|
+
static readyEvent = 'adr-wc:ready';
|
|
71
|
+
|
|
65
72
|
static get observedAttributes() {
|
|
66
73
|
return [
|
|
67
74
|
'adr-id', 'status',
|
|
@@ -70,48 +77,14 @@ class AdrWc extends HTMLElement {
|
|
|
70
77
|
];
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
constructor() {
|
|
76
|
-
super();
|
|
77
|
-
this.attachShadow({ mode: 'open' });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/* ── Slot caching ───────────────────────────────── */
|
|
81
|
-
|
|
82
|
-
#cacheSlotValues() {
|
|
83
|
-
for (const child of [...this.children]) {
|
|
84
|
-
const slotName = child.getAttribute('slot');
|
|
85
|
-
if (slotName) {
|
|
86
|
-
this.#slotCache.set(slotName, child.textContent.trim());
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/* ── Lifecycle ─────────────────────────────────── */
|
|
80
|
+
/* ── Lifecycle (see UXShadowElement) ──────────── */
|
|
92
81
|
|
|
93
|
-
|
|
94
|
-
this.#cacheSlotValues();
|
|
82
|
+
_setup() {
|
|
95
83
|
if (this.adrId && !this.id) this.id = this.adrId;
|
|
96
|
-
if (this.hasAttribute('src')) {
|
|
97
|
-
this._loadSrc(this.getAttribute('src'));
|
|
98
|
-
}
|
|
99
|
-
this.#render();
|
|
100
|
-
this.setAttribute('data-upgraded', '');
|
|
101
84
|
}
|
|
102
85
|
|
|
103
|
-
|
|
104
|
-
this.
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
attributeChangedCallback(name, oldValue, newValue) {
|
|
108
|
-
if (oldValue !== newValue && this.shadowRoot) {
|
|
109
|
-
if (name === 'src' && this.isConnected) {
|
|
110
|
-
this._loadSrc(newValue);
|
|
111
|
-
} else {
|
|
112
|
-
this.#render();
|
|
113
|
-
}
|
|
114
|
-
}
|
|
86
|
+
_readyDetail() {
|
|
87
|
+
return { adrId: this.adrId, title: this.adrTitle, status: this.status };
|
|
115
88
|
}
|
|
116
89
|
|
|
117
90
|
/* ── Getters ───────────────────────────────────── */
|
|
@@ -121,31 +94,20 @@ class AdrWc extends HTMLElement {
|
|
|
121
94
|
|
|
122
95
|
/** Read the title from the slotted heading's text content */
|
|
123
96
|
get adrTitle() {
|
|
124
|
-
|
|
125
|
-
return slotted?.textContent?.trim() || this.#slotCache.get('title') || '';
|
|
97
|
+
return this._slotText('title');
|
|
126
98
|
}
|
|
127
99
|
|
|
128
100
|
/** Read the date from the slotted <time>'s datetime attribute or text */
|
|
129
101
|
get adrDate() {
|
|
130
|
-
const slotted = this
|
|
131
|
-
return slotted?.getAttribute('datetime') ||
|
|
102
|
+
const slotted = slottedChild(this, 'date');
|
|
103
|
+
return slotted?.getAttribute('datetime') || this._slotText('date');
|
|
132
104
|
}
|
|
133
105
|
|
|
134
|
-
get supersedes() {
|
|
135
|
-
const raw = this.getAttribute('supersedes') || '';
|
|
136
|
-
return raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
137
|
-
}
|
|
106
|
+
get supersedes() { return splitList(this.getAttribute('supersedes')); }
|
|
138
107
|
|
|
139
|
-
get supersededBy() {
|
|
140
|
-
const raw = this.getAttribute('superseded-by') || '';
|
|
141
|
-
return raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
142
|
-
}
|
|
108
|
+
get supersededBy() { return splitList(this.getAttribute('superseded-by')); }
|
|
143
109
|
|
|
144
|
-
get _detailLevel() {
|
|
145
|
-
if (this.getAttribute('detail')) return this.getAttribute('detail');
|
|
146
|
-
if (this.hasAttribute('compact')) return 'compact';
|
|
147
|
-
return 'full';
|
|
148
|
-
}
|
|
110
|
+
get _detailLevel() { return detailLevel(this); }
|
|
149
111
|
|
|
150
112
|
get _minimalLabel() {
|
|
151
113
|
return this.adrTitle || this.adrId || 'ADR';
|
|
@@ -174,19 +136,13 @@ class AdrWc extends HTMLElement {
|
|
|
174
136
|
* call. Idempotent for repeat calls.
|
|
175
137
|
*/
|
|
176
138
|
set data(value) {
|
|
177
|
-
|
|
178
|
-
this._applyData(value);
|
|
179
|
-
this.#cacheSlotValues();
|
|
180
|
-
if (this.shadowRoot) this.#render();
|
|
181
|
-
this.dispatchEvent(new CustomEvent('adr-wc:data-changed', {
|
|
182
|
-
detail: { data: this.data, source: 'property' },
|
|
183
|
-
bubbles: true, composed: true,
|
|
184
|
-
}));
|
|
139
|
+
this._setData(value);
|
|
185
140
|
}
|
|
186
141
|
|
|
187
142
|
/**
|
|
188
143
|
* Apply a data record to attributes + slotted children. Used by both
|
|
189
|
-
* the .data setter and
|
|
144
|
+
* the .data setter and the src loader. Slotted children are created only
|
|
145
|
+
* when absent (authored content wins).
|
|
190
146
|
* @param {Record<string, unknown>} data
|
|
191
147
|
*/
|
|
192
148
|
_applyData(data) {
|
|
@@ -201,73 +157,48 @@ class AdrWc extends HTMLElement {
|
|
|
201
157
|
this.setAttribute('superseded-by', Array.isArray(data.supersededBy) ? data.supersededBy.join(',') : String(data.supersededBy));
|
|
202
158
|
}
|
|
203
159
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (data.date && !this.querySelector('[slot="date"]')) {
|
|
160
|
+
fillSlot(this, 'title', data.title, { tag: 'h3' });
|
|
161
|
+
if (data.date && !slottedChild(this, 'date')) {
|
|
162
|
+
// A calendar date: YYYY-MM-DD is local midnight, so April 1 stays
|
|
163
|
+
// April 1 west of UTC (new Date('2026-04-01') would show March 31).
|
|
164
|
+
const raw = String(data.date);
|
|
165
|
+
const day = parseISODate(raw);
|
|
211
166
|
const t = document.createElement('time');
|
|
212
167
|
t.slot = 'date';
|
|
213
|
-
t.setAttribute('datetime',
|
|
214
|
-
t.textContent =
|
|
168
|
+
t.setAttribute('datetime', raw);
|
|
169
|
+
t.textContent = day ? formatDate(day) : raw;
|
|
215
170
|
this.appendChild(t);
|
|
216
171
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
p.slot = key;
|
|
221
|
-
p.textContent = String(data[key]);
|
|
222
|
-
this.appendChild(p);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (data.consequences && !this.querySelector('[slot="consequences"]')) {
|
|
226
|
-
const ul = document.createElement('ul');
|
|
227
|
-
ul.slot = 'consequences';
|
|
228
|
-
const items = Array.isArray(data.consequences) ? data.consequences : [data.consequences];
|
|
229
|
-
for (const item of items) {
|
|
230
|
-
const li = document.createElement('li');
|
|
231
|
-
li.textContent = String(item);
|
|
232
|
-
ul.appendChild(li);
|
|
233
|
-
}
|
|
234
|
-
this.appendChild(ul);
|
|
235
|
-
}
|
|
172
|
+
fillSlot(this, 'context', data.context);
|
|
173
|
+
fillSlot(this, 'decision', data.decision);
|
|
174
|
+
fillSlot(this, 'consequences', data.consequences, { list: true });
|
|
236
175
|
}
|
|
237
176
|
|
|
238
|
-
/* ── JSON loading
|
|
177
|
+
/* ── JSON loading states ───────────────────────── */
|
|
239
178
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this._applyData(data);
|
|
250
|
-
this.#cacheSlotValues();
|
|
251
|
-
this.#render();
|
|
252
|
-
} catch (err) {
|
|
253
|
-
root.innerHTML = `<style>${styles}</style>` +
|
|
254
|
-
`<div class="state-msg state-msg--error">Could not load: ${esc(/** @type {Error} */ (err).message)}</div>`;
|
|
255
|
-
}
|
|
179
|
+
_renderLoading() {
|
|
180
|
+
/** @type {ShadowRoot} */ (this.shadowRoot).innerHTML =
|
|
181
|
+
`<style>${styles}</style><div class="state-msg">Loading\u2026</div>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {Error} err */
|
|
185
|
+
_renderError(err) {
|
|
186
|
+
/** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
|
|
187
|
+
`<div class="state-msg state-msg--error">Could not load: ${esc(err.message)}</div>`;
|
|
256
188
|
}
|
|
257
189
|
|
|
258
190
|
/* ── Render ────────────────────────────────────── */
|
|
259
191
|
|
|
260
|
-
|
|
192
|
+
_render() {
|
|
261
193
|
const root = /** @type {ShadowRoot} */ (this.shadowRoot);
|
|
262
|
-
const statusInfo = STATUSES
|
|
194
|
+
const statusInfo = lookup(STATUSES, this.status, 'proposed');
|
|
263
195
|
const level = this._detailLevel;
|
|
264
196
|
const ariaLabel = this.adrId ? `ADR: ${esc(this.adrId)}` : 'Architectural Decision Record';
|
|
265
|
-
const hasDate = !!this
|
|
197
|
+
const hasDate = !!slottedChild(this, 'date') || this._slotCache.has('date');
|
|
266
198
|
|
|
267
199
|
if (level === 'minimal') {
|
|
268
200
|
root.innerHTML = `<style>${styles}</style>
|
|
269
|
-
<article class="adr-card adr-card--minimal" role="article" aria-label="${ariaLabel}"
|
|
270
|
-
tabindex="0">
|
|
201
|
+
<article class="adr-card adr-card--minimal" role="article" aria-label="${ariaLabel}">
|
|
271
202
|
<div class="adr-body">
|
|
272
203
|
<div class="adr-meta">
|
|
273
204
|
${this.adrId ? `<span class="adr-id">${esc(this.adrId)}</span>` : ''}
|
|
@@ -350,24 +281,7 @@ class AdrWc extends HTMLElement {
|
|
|
350
281
|
|
|
351
282
|
</article>`;
|
|
352
283
|
|
|
353
|
-
|
|
354
|
-
if (level === 'compact') {
|
|
355
|
-
for (const section of root.querySelectorAll('.adr-section')) {
|
|
356
|
-
const slot = section.querySelector('slot');
|
|
357
|
-
if (slot && slot.assignedNodes().length === 0) {
|
|
358
|
-
section.setAttribute('data-empty', '');
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
this.dispatchEvent(new CustomEvent('adr-wc:ready', {
|
|
364
|
-
detail: {
|
|
365
|
-
adrId: this.adrId,
|
|
366
|
-
title: this.adrTitle,
|
|
367
|
-
status: this.status,
|
|
368
|
-
},
|
|
369
|
-
bubbles: true, composed: true,
|
|
370
|
-
}));
|
|
284
|
+
if (level === 'compact') markEmptySections(root, '.adr-section');
|
|
371
285
|
}
|
|
372
286
|
}
|
|
373
287
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "../../../schemas/api.schema.json",
|
|
3
3
|
"element": "burndown-chart",
|
|
4
4
|
"type": "web-component",
|
|
5
|
-
"description": "Sprint burndown plot. Wraps <chart-wc> with auto-computed ideal line and scope-change annotations.",
|
|
5
|
+
"description": "Sprint burndown plot. Wraps <chart-wc> with auto-computed ideal line and scope-change annotations. All attributes are observed: changing one on a live element re-renders.",
|
|
6
6
|
"htmlvalidate": {
|
|
7
7
|
"flow": true,
|
|
8
8
|
"permittedContent": [
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
"kind": "host-api",
|
|
16
16
|
"purpose": "config",
|
|
17
17
|
"type": "string",
|
|
18
|
-
"description": "Sprint start date (
|
|
18
|
+
"description": "Sprint start date (YYYY-MM-DD, local calendar day; required)"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"name": "end",
|
|
22
22
|
"kind": "host-api",
|
|
23
23
|
"purpose": "config",
|
|
24
24
|
"type": "string",
|
|
25
|
-
"description": "Sprint end date (
|
|
25
|
+
"description": "Sprint end date (YYYY-MM-DD, inclusive; required)"
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"name": "total",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"kind": "host-api",
|
|
37
37
|
"purpose": "config",
|
|
38
38
|
"type": "string",
|
|
39
|
-
"description": "Display unit
|
|
39
|
+
"description": "Display unit for the y-axis and scope-change notes (rendered as text)",
|
|
40
|
+
"default": "\"points\""
|
|
40
41
|
},
|
|
41
42
|
{
|
|
42
43
|
"name": "label",
|
|
@@ -54,7 +55,8 @@
|
|
|
54
55
|
"include",
|
|
55
56
|
"exclude"
|
|
56
57
|
],
|
|
57
|
-
"description": "Whether weekends count toward the ideal-line slope"
|
|
58
|
+
"description": "Whether weekends count toward the ideal-line slope",
|
|
59
|
+
"default": "\"include\""
|
|
58
60
|
}
|
|
59
61
|
],
|
|
60
62
|
"structure": [
|
|
@@ -68,7 +70,7 @@
|
|
|
68
70
|
{
|
|
69
71
|
"name": "burndown-chart:ready",
|
|
70
72
|
"detail": "{ dayCount, total, scopeChanges }",
|
|
71
|
-
"description": "Fired after
|
|
73
|
+
"description": "Fired after each upgrade (first connect, reconnect, or a retried upgrade), in a microtask. Not re-fired when a live attribute change re-renders"
|
|
72
74
|
}
|
|
73
75
|
]
|
|
74
76
|
}
|
|
@@ -25,16 +25,68 @@
|
|
|
25
25
|
* @attr {string} label - Optional heading text (e.g., "Sprint 14")
|
|
26
26
|
* @attr {string} weekends - "include" (default) or "exclude" from ideal slope
|
|
27
27
|
*
|
|
28
|
+
* All six attributes are observed: changing any of them on a live element
|
|
29
|
+
* re-renders the chart in place. Dates are calendar days in local time
|
|
30
|
+
* (see src/lib/dates.js), so the day axis is stable across DST changes.
|
|
31
|
+
*
|
|
28
32
|
* @fires burndown-chart:ready - { dayCount, total, scopeChanges }
|
|
29
33
|
*/
|
|
30
34
|
|
|
31
35
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
32
36
|
import { VBElement } from '../../lib/vb-element.js';
|
|
33
|
-
|
|
34
|
-
const MS_PER_DAY = 86_400_000;
|
|
37
|
+
import { parseISODate, toISODate, enumerateDays } from '../../lib/dates.js';
|
|
35
38
|
|
|
36
39
|
class BurndownChart extends VBElement {
|
|
40
|
+
static get observedAttributes() {
|
|
41
|
+
return ['start', 'end', 'total', 'unit', 'label', 'weekends'];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True between connectedCallback and disconnectedCallback. */
|
|
45
|
+
#live = false;
|
|
46
|
+
|
|
47
|
+
connectedCallback() {
|
|
48
|
+
this.#live = true;
|
|
49
|
+
super.connectedCallback();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
disconnectedCallback() {
|
|
53
|
+
this.#live = false;
|
|
54
|
+
super.disconnectedCallback();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
attributeChangedCallback(_name, oldValue, newValue) {
|
|
58
|
+
if (oldValue === newValue || !this.#live) return;
|
|
59
|
+
if (this.hasAttribute('data-upgraded')) {
|
|
60
|
+
/* Live re-render. If the new config is invalid, drop the stale chart
|
|
61
|
+
rather than leave it showing the old range. */
|
|
62
|
+
if (!this.#renderFromAttributes()) this.#clearRendered();
|
|
63
|
+
} else {
|
|
64
|
+
/* An earlier upgrade was refused (e.g. total missing): retry now. */
|
|
65
|
+
super.connectedCallback();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
37
69
|
setup() {
|
|
70
|
+
if (!this.#renderFromAttributes()) return false;
|
|
71
|
+
const { dayCount, total, scopeChanges } = this.#lastRender;
|
|
72
|
+
queueMicrotask(() => {
|
|
73
|
+
this.dispatchEvent(new CustomEvent('burndown-chart:ready', {
|
|
74
|
+
bubbles: true,
|
|
75
|
+
detail: { dayCount, total, scopeChanges },
|
|
76
|
+
}));
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @type {{ dayCount: number, total: number, scopeChanges: number }} */
|
|
82
|
+
#lastRender = { dayCount: 0, total: 0, scopeChanges: 0 };
|
|
83
|
+
|
|
84
|
+
#clearRendered() {
|
|
85
|
+
[...this.children].forEach((c) => { if (c.tagName !== 'TEMPLATE') c.remove(); });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read attributes + template and render. Returns false when the config is invalid. */
|
|
89
|
+
#renderFromAttributes() {
|
|
38
90
|
const start = this.getAttribute('start');
|
|
39
91
|
const end = this.getAttribute('end');
|
|
40
92
|
const total = parseFloat(this.getAttribute('total') || '');
|
|
@@ -44,8 +96,8 @@ class BurndownChart extends VBElement {
|
|
|
44
96
|
return false;
|
|
45
97
|
}
|
|
46
98
|
|
|
47
|
-
const startDate =
|
|
48
|
-
const endDate =
|
|
99
|
+
const startDate = parseISODate(start);
|
|
100
|
+
const endDate = parseISODate(end);
|
|
49
101
|
if (!startDate || !endDate || endDate < startDate) {
|
|
50
102
|
console.warn('burndown-chart: start/end dates invalid', start, end);
|
|
51
103
|
return false;
|
|
@@ -59,23 +111,17 @@ class BurndownChart extends VBElement {
|
|
|
59
111
|
const samples = tmpl ? BurndownChart.#parseTemplate(tmpl) : [];
|
|
60
112
|
const scopeChanges = samples.filter((s) => s.delta !== 0);
|
|
61
113
|
|
|
62
|
-
const dayLabels =
|
|
114
|
+
const dayLabels = enumerateDays(startDate, endDate);
|
|
63
115
|
const ideal = BurndownChart.#idealSeries(dayLabels, total, includeWeekends);
|
|
64
116
|
const actual = BurndownChart.#actualSeries(dayLabels, samples);
|
|
65
117
|
|
|
66
118
|
this.#render({ label, unit, dayLabels, actual, ideal, scopeChanges });
|
|
67
|
-
|
|
68
|
-
queueMicrotask(() => {
|
|
69
|
-
this.dispatchEvent(new CustomEvent('burndown-chart:ready', {
|
|
70
|
-
bubbles: true,
|
|
71
|
-
detail: { dayCount: dayLabels.length, total, scopeChanges: scopeChanges.length },
|
|
72
|
-
}));
|
|
73
|
-
});
|
|
119
|
+
this.#lastRender = { dayCount: dayLabels.length, total, scopeChanges: scopeChanges.length };
|
|
74
120
|
return true;
|
|
75
121
|
}
|
|
76
122
|
|
|
77
123
|
#render({ label, unit, dayLabels, actual, ideal, scopeChanges }) {
|
|
78
|
-
|
|
124
|
+
this.#clearRendered();
|
|
79
125
|
|
|
80
126
|
if (label) {
|
|
81
127
|
const h = document.createElement('header');
|
|
@@ -113,9 +159,16 @@ class BurndownChart extends VBElement {
|
|
|
113
159
|
notes.appendChild(heading);
|
|
114
160
|
const list = document.createElement('ul');
|
|
115
161
|
for (const change of scopeChanges) {
|
|
162
|
+
/* Built with textContent: template cells and the unit attribute are
|
|
163
|
+
author data and must never be parsed as markup. */
|
|
116
164
|
const li = document.createElement('li');
|
|
117
165
|
const sign = change.delta > 0 ? '+' : '';
|
|
118
|
-
|
|
166
|
+
const time = document.createElement('time');
|
|
167
|
+
time.setAttribute('datetime', change.date);
|
|
168
|
+
time.textContent = change.date;
|
|
169
|
+
const strong = document.createElement('strong');
|
|
170
|
+
strong.textContent = `${sign}${change.delta} ${unit}`;
|
|
171
|
+
li.append(time, ': ', strong);
|
|
119
172
|
list.appendChild(li);
|
|
120
173
|
}
|
|
121
174
|
notes.appendChild(list);
|
|
@@ -128,40 +181,23 @@ class BurndownChart extends VBElement {
|
|
|
128
181
|
const trs = tmpl.content.querySelectorAll('tr');
|
|
129
182
|
for (const tr of trs) {
|
|
130
183
|
const cells = [...tr.children];
|
|
131
|
-
const
|
|
184
|
+
const raw = cells[0]?.textContent?.trim();
|
|
132
185
|
const remaining = parseFloat(cells[1]?.textContent ?? '');
|
|
133
186
|
const delta = parseFloat(cells[2]?.textContent ?? '0') || 0;
|
|
134
|
-
if (!
|
|
187
|
+
if (!raw || !Number.isFinite(remaining)) continue;
|
|
188
|
+
/* Normalise YYYY-MM-DD[Thh:mm…] to YYYY-MM-DD so samples key against
|
|
189
|
+
the day axis. Anything else is kept verbatim (shown, never plotted);
|
|
190
|
+
Date's lenient fallback parsing would invent dates from junk. */
|
|
191
|
+
const parsed = /^\d{4}-\d{2}-\d{2}/.test(raw) ? parseISODate(raw) : null;
|
|
192
|
+
const date = parsed ? toISODate(parsed) : raw;
|
|
135
193
|
out.push({ date, remaining, delta });
|
|
136
194
|
}
|
|
137
195
|
return out;
|
|
138
196
|
}
|
|
139
197
|
|
|
140
|
-
static #parseDate(s) {
|
|
141
|
-
/* Treat YYYY-MM-DD as a local-midnight date so weekend math is
|
|
142
|
-
deterministic across viewer time zones. */
|
|
143
|
-
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
144
|
-
if (!m) return null;
|
|
145
|
-
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
static #toISO(d) {
|
|
149
|
-
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
150
|
-
const day = String(d.getDate()).padStart(2, '0');
|
|
151
|
-
return `${d.getFullYear()}-${m}-${day}`;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
static #enumerateDays(start, end) {
|
|
155
|
-
const days = [];
|
|
156
|
-
for (let t = start.getTime(); t <= end.getTime(); t += MS_PER_DAY) {
|
|
157
|
-
days.push(BurndownChart.#toISO(new Date(t)));
|
|
158
|
-
}
|
|
159
|
-
return days;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
198
|
static #idealSeries(dayLabels, total, includeWeekends) {
|
|
163
199
|
const isWeekend = (label) => {
|
|
164
|
-
const d =
|
|
200
|
+
const d = parseISODate(label);
|
|
165
201
|
if (!d) return false;
|
|
166
202
|
const w = d.getDay();
|
|
167
203
|
return w === 0 || w === 6;
|
|
@@ -5,27 +5,27 @@
|
|
|
5
5
|
"description": "Four-quadrant empathy map visualization (Says, Thinks, Does, Feels) with optional flip-to-edit interaction",
|
|
6
6
|
"htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
|
|
7
7
|
"attributes": [
|
|
8
|
-
{ "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Map heading" },
|
|
9
8
|
{ "name": "persona", "kind": "host-api", "purpose": "config", "type": "string", "description": "Persona name displayed in header" },
|
|
10
|
-
{ "name": "persona-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Links to a user-persona element by id" },
|
|
11
|
-
{ "name": "
|
|
12
|
-
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON file containing quadrant data" },
|
|
9
|
+
{ "name": "persona-id", "kind": "host-api", "purpose": "config", "type": "string", "description": "Links the persona name to a user-persona element by id" },
|
|
10
|
+
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of a JSON record (title, persona, personaId, summary, quadrants, goals, painPoints). Fetched once per value; a newer value aborts the older request." },
|
|
13
11
|
{ "name": "editable", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Enables flip-to-edit on quadrants" },
|
|
14
12
|
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing variant" }
|
|
15
13
|
],
|
|
16
14
|
"slots": [
|
|
17
|
-
{ "name": "title", "description": "Map heading (
|
|
18
|
-
{ "name": "
|
|
19
|
-
{ "name": "
|
|
20
|
-
{ "name": "says", "description": "Content for the Says quadrant" },
|
|
21
|
-
{ "name": "thinks", "description": "Content for the Thinks quadrant" },
|
|
22
|
-
{ "name": "does", "description": "Content for the Does quadrant" },
|
|
23
|
-
{ "name": "feels", "description": "Content for the Feels quadrant" },
|
|
15
|
+
{ "name": "title", "description": "Map heading (e.g. <h2>). Defaults to \"Empathy Map\"." },
|
|
16
|
+
{ "name": "summary", "description": "One-line description (e.g. <p>)" },
|
|
17
|
+
{ "name": "persona", "description": "Not projected: the text of a slot=\"persona\" child is read as the persona name when the persona attribute is absent" },
|
|
18
|
+
{ "name": "says", "description": "Content for the Says quadrant (until the quadrant is edited)" },
|
|
19
|
+
{ "name": "thinks", "description": "Content for the Thinks quadrant (until the quadrant is edited)" },
|
|
20
|
+
{ "name": "does", "description": "Content for the Does quadrant (until the quadrant is edited)" },
|
|
21
|
+
{ "name": "feels", "description": "Content for the Feels quadrant (until the quadrant is edited)" },
|
|
24
22
|
{ "name": "goals", "description": "Optional goals summary below the grid" },
|
|
25
23
|
{ "name": "pain-points", "description": "Optional pain points summary below the grid" }
|
|
26
24
|
],
|
|
27
25
|
"events": [
|
|
28
|
-
{ "name": "empathy-map:ready", "detail": "{ title, persona }", "description": "
|
|
29
|
-
{ "name": "empathy-map:update", "detail": "{ quadrant, items }", "description": "
|
|
26
|
+
{ "name": "empathy-map:ready", "detail": "{ title, persona }", "description": "Once per element, after the first render. With src, after the fetched data renders (not fired if the fetch fails). Not re-fired by later renders or moves." },
|
|
27
|
+
{ "name": "empathy-map:update", "detail": "{ quadrant, items }", "description": "When an open quadrant editor is closed (Done, Escape, or closeQuadrant()). Not fired when no editor is open." },
|
|
28
|
+
{ "name": "empathy-map:upgraded", "detail": "", "description": "Once, in a microtask after first connect. Wait for this before assigning .data from a framework." },
|
|
29
|
+
{ "name": "empathy-map:data-changed", "detail": "{ data, source: 'property' }", "description": "After .data is assigned" }
|
|
30
30
|
]
|
|
31
31
|
}
|