@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.
Files changed (66) hide show
  1. package/README.md +10 -5
  2. package/dist/vb-project-planning.css +69 -3
  3. package/dist/vb-project-planning.js +3191 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/lib/vb-view-transition.js +7 -1
  15. package/src/utils/copy-init.js +56 -17
  16. package/src/web-components/_ux-base.js +409 -24
  17. package/src/web-components/adr-wc/README.md +18 -16
  18. package/src/web-components/adr-wc/api.json +7 -5
  19. package/src/web-components/adr-wc/logic.js +50 -136
  20. package/src/web-components/burndown-chart/api.json +8 -6
  21. package/src/web-components/burndown-chart/logic.js +75 -39
  22. package/src/web-components/empathy-map/api.json +13 -13
  23. package/src/web-components/empathy-map/logic.js +161 -168
  24. package/src/web-components/empathy-map/static.html +3 -3
  25. package/src/web-components/empathy-map/styles.js +1 -0
  26. package/src/web-components/gantt-chart/README.md +12 -6
  27. package/src/web-components/gantt-chart/api.json +23 -7
  28. package/src/web-components/gantt-chart/logic.js +456 -431
  29. package/src/web-components/gantt-chart/styles.css +14 -1
  30. package/src/web-components/impact-effort/api.json +9 -7
  31. package/src/web-components/impact-effort/logic.js +238 -209
  32. package/src/web-components/impact-effort/static.html +12 -4
  33. package/src/web-components/iron-triangle/_capacity.js +0 -18
  34. package/src/web-components/kanban-board/api.json +8 -6
  35. package/src/web-components/kanban-board/logic.js +289 -333
  36. package/src/web-components/product-roadmap/api.json +14 -13
  37. package/src/web-components/product-roadmap/logic.js +393 -246
  38. package/src/web-components/product-roadmap/styles.css +40 -0
  39. package/src/web-components/quadrant-grid/api.json +9 -4
  40. package/src/web-components/quadrant-grid/logic.js +80 -12
  41. package/src/web-components/quadrant-grid/styles.css +7 -0
  42. package/src/web-components/quality-target/_quality-utils.js +2 -46
  43. package/src/web-components/review-surface/README.md +9 -5
  44. package/src/web-components/review-surface/api.json +10 -8
  45. package/src/web-components/review-surface/logic.js +349 -218
  46. package/src/web-components/risk-register/api.json +1 -6
  47. package/src/web-components/risk-register/logic.js +71 -24
  48. package/src/web-components/risk-register/styles.css +9 -1
  49. package/src/web-components/story-map/api.json +8 -6
  50. package/src/web-components/story-map/logic.js +280 -281
  51. package/src/web-components/story-map/static.html +15 -5
  52. package/src/web-components/traceability-matrix/api.json +10 -3
  53. package/src/web-components/traceability-matrix/logic.js +112 -63
  54. package/src/web-components/traceability-matrix/styles.css +22 -1
  55. package/src/web-components/user-journey/api.json +25 -24
  56. package/src/web-components/user-journey/logic.js +76 -127
  57. package/src/web-components/user-journey/static.html +2 -2
  58. package/src/web-components/user-persona/api.json +15 -14
  59. package/src/web-components/user-persona/logic.js +71 -117
  60. package/src/web-components/user-persona/styles.js +1 -0
  61. package/src/web-components/user-story/api.json +13 -11
  62. package/src/web-components/user-story/logic.js +164 -195
  63. package/src/web-components/user-story/styles.js +28 -1
  64. package/src/web-components/work-item/README.md +3 -3
  65. package/src/web-components/work-item/api.json +13 -11
  66. 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), auto-sets HTML id" },
9
- { "name": "status", "kind": "host-api", "purpose": "config", "type": "string", "description": "Status: proposed | accepted | deprecated | superseded" },
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": "string", "description": "Detail level: full | compact | minimal" },
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 to JSON data" }
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": "Fired after render" }
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 - After render with { adrId, status }
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 { esc, lucideSvg } from '../_ux-base.js';
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 HTMLElement {
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
- #slotCache = new Map();
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
- connectedCallback() {
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
- disconnectedCallback() {
104
- this.removeAttribute('data-upgraded');
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
- const slotted = this.querySelector('[slot="title"]');
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.querySelector('[slot="date"]');
131
- return slotted?.getAttribute('datetime') || slotted?.textContent?.trim() || this.#slotCache.get('date') || '';
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
- if (!value || typeof value !== 'object') return;
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 async _loadSrc.
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
- if (data.title && !this.querySelector('[slot="title"]')) {
205
- const h = document.createElement('h3');
206
- h.slot = 'title';
207
- h.textContent = String(data.title);
208
- this.appendChild(h);
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', String(data.date));
214
- t.textContent = new Date(String(data.date)).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
168
+ t.setAttribute('datetime', raw);
169
+ t.textContent = day ? formatDate(day) : raw;
215
170
  this.appendChild(t);
216
171
  }
217
- for (const key of ['context', 'decision']) {
218
- if (data[key] && !this.querySelector(`[slot="${key}"]`)) {
219
- const p = document.createElement('p');
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
- async _loadSrc(url) {
241
- if (!url) return;
242
- const root = /** @type {ShadowRoot} */ (this.shadowRoot);
243
- root.innerHTML = `<style>${styles}</style><div class="state-msg">Loading\u2026</div>`;
244
- try {
245
- const res = await fetch(url);
246
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
247
- const data = await res.json();
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
- #render() {
192
+ _render() {
261
193
  const root = /** @type {ShadowRoot} */ (this.shadowRoot);
262
- const statusInfo = STATUSES[this.status] || STATUSES.proposed;
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.querySelector('[slot="date"]') || this.#slotCache.has('date');
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
- // Compact: mark empty slot sections
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 (ISO 8601, required)"
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 (ISO 8601, inclusive, required)"
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, default \"points\""
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 the chart renders"
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 = BurndownChart.#parseDate(start);
48
- const endDate = BurndownChart.#parseDate(end);
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 = BurndownChart.#enumerateDays(startDate, endDate);
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
- [...this.children].forEach((c) => { if (c.tagName !== 'TEMPLATE') c.remove(); });
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
- li.innerHTML = `<time datetime="${change.date}">${change.date}</time>: <strong>${sign}${change.delta} ${unit}</strong>`;
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 date = cells[0]?.textContent?.trim();
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 (!date || !Number.isFinite(remaining)) continue;
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 = BurndownChart.#parseDate(label);
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": "summary", "kind": "host-api", "purpose": "config", "type": "string", "description": "One-line description" },
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 (slot fallback for attribute)" },
18
- { "name": "persona", "description": "Persona name (slot fallback for attribute)" },
19
- { "name": "summary", "description": "One-line description (slot fallback for attribute)" },
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": "Fired after initial render" },
29
- { "name": "empathy-map:update", "detail": "{ quadrant, items }", "description": "Fired when quadrant content changes via edit" }
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
  }