@profpowell/vb-project-planning 0.2.0 → 0.2.2

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 (73) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +70 -3
  3. package/dist/vb-project-planning.js +3333 -2565
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +224 -200
  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/utils/copy-init.js +56 -17
  15. package/src/web-components/_ux-base.js +409 -24
  16. package/src/web-components/adr-wc/README.md +18 -16
  17. package/src/web-components/adr-wc/api.json +7 -5
  18. package/src/web-components/adr-wc/logic.js +50 -136
  19. package/src/web-components/burndown-chart/api.json +8 -6
  20. package/src/web-components/burndown-chart/logic.js +75 -39
  21. package/src/web-components/empathy-map/api.json +13 -13
  22. package/src/web-components/empathy-map/logic.js +161 -168
  23. package/src/web-components/empathy-map/static.html +3 -3
  24. package/src/web-components/empathy-map/styles.js +1 -0
  25. package/src/web-components/gantt-chart/README.md +12 -6
  26. package/src/web-components/gantt-chart/api.json +23 -7
  27. package/src/web-components/gantt-chart/logic.js +456 -431
  28. package/src/web-components/gantt-chart/styles.css +9 -1
  29. package/src/web-components/impact-effort/api.json +9 -7
  30. package/src/web-components/impact-effort/logic.js +238 -209
  31. package/src/web-components/impact-effort/static.html +12 -4
  32. package/src/web-components/iron-triangle/_capacity.js +1 -19
  33. package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
  34. package/src/web-components/iron-triangle/api.json +2 -2
  35. package/src/web-components/iron-triangle/logic.js +47 -9
  36. package/src/web-components/iron-triangle/styles.css +22 -1
  37. package/src/web-components/kanban-board/api.json +8 -6
  38. package/src/web-components/kanban-board/logic.js +289 -333
  39. package/src/web-components/product-roadmap/api.json +14 -13
  40. package/src/web-components/product-roadmap/logic.js +393 -246
  41. package/src/web-components/product-roadmap/styles.css +40 -0
  42. package/src/web-components/quadrant-grid/api.json +9 -4
  43. package/src/web-components/quadrant-grid/logic.js +80 -12
  44. package/src/web-components/quadrant-grid/styles.css +7 -0
  45. package/src/web-components/quality-target/_quality-utils.js +2 -46
  46. package/src/web-components/quality-target/api.json +2 -3
  47. package/src/web-components/quality-target/logic.js +85 -13
  48. package/src/web-components/requirement-card/api.json +1 -1
  49. package/src/web-components/requirement-card/logic.js +26 -2
  50. package/src/web-components/requirement-card/static.html +1 -1
  51. package/src/web-components/review-surface/README.md +9 -5
  52. package/src/web-components/review-surface/api.json +10 -8
  53. package/src/web-components/review-surface/logic.js +349 -218
  54. package/src/web-components/risk-register/api.json +1 -6
  55. package/src/web-components/risk-register/logic.js +71 -24
  56. package/src/web-components/story-map/api.json +8 -6
  57. package/src/web-components/story-map/logic.js +280 -281
  58. package/src/web-components/story-map/static.html +15 -5
  59. package/src/web-components/traceability-matrix/api.json +10 -3
  60. package/src/web-components/traceability-matrix/logic.js +112 -63
  61. package/src/web-components/traceability-matrix/styles.css +14 -1
  62. package/src/web-components/user-journey/api.json +25 -24
  63. package/src/web-components/user-journey/logic.js +76 -127
  64. package/src/web-components/user-journey/static.html +2 -2
  65. package/src/web-components/user-persona/api.json +15 -14
  66. package/src/web-components/user-persona/logic.js +71 -117
  67. package/src/web-components/user-persona/styles.js +1 -0
  68. package/src/web-components/user-story/api.json +13 -11
  69. package/src/web-components/user-story/logic.js +164 -195
  70. package/src/web-components/user-story/styles.js +28 -1
  71. package/src/web-components/work-item/README.md +3 -3
  72. package/src/web-components/work-item/api.json +13 -11
  73. package/src/web-components/work-item/logic.js +58 -154
@@ -11,10 +11,14 @@
11
11
  * scheduling. Use roadmap for quarterly product roadmaps; gantt for
12
12
  * project plans.
13
13
  *
14
+ * Dates are calendar days in local time (src/lib/dates.js). Every end
15
+ * date — the host's `end` and each initiative's `data-end` — is
16
+ * INCLUSIVE: an initiative with data-start == data-end covers one day.
17
+ *
14
18
  * @attr {string} start - First date in the range (ISO 8601 or "YYYY-Qn"; required)
15
19
  * @attr {string} end - Last date in the range (ISO 8601 or "YYYY-Qn"; required, inclusive)
16
20
  * @attr {string} view - "quarter" (default) or "month" axis granularity
17
- * @attr {boolean} editable - Wires drag-to-reschedule, drag-to-resize, lane drop
21
+ * @attr {boolean} editable - Wires pointer drag-to-reschedule / resize / lane move, and keyboard editing
18
22
  * @attr {boolean} today-marker - Render a vertical line at today's date if it falls in range
19
23
  *
20
24
  * Children:
@@ -25,38 +29,82 @@
25
29
  * </article>
26
30
  * </section>
27
31
  *
28
- * @fires roadmap:select - { initiative, lane, start, end, status } on click
29
- * @fires roadmap:reschedule - { initiative, lane, start, end } when bar dragged along time
30
- * @fires roadmap:resize - { initiative, lane, start, end } when right edge dragged
31
- * @fires roadmap:move - { initiative, fromLane, toLane, start, end } when bar dropped on another lane
32
+ * Interaction (bars are role="button", focusable):
33
+ * click / Enter / Space → product-roadmap:select
34
+ * editable, drag body → product-roadmap:reschedule (snaps to the nearest period)
35
+ * editable, drag right edge → product-roadmap:resize
36
+ * editable, drag onto other lane → product-roadmap:move
37
+ * editable, ←/→ → move one period (reschedule)
38
+ * editable, Shift+←/→ → end date one period earlier/later (resize)
39
+ * editable, Alt+↑/↓ → move to the previous/next lane (move)
40
+ *
41
+ * @fires product-roadmap:select - { initiative, lane, start, end, status }
42
+ * @fires product-roadmap:reschedule - { initiative, lane, start, end }
43
+ * @fires product-roadmap:resize - { initiative, lane, start, end }
44
+ * @fires product-roadmap:move - { initiative, fromLane, toLane, start, end }
32
45
  */
33
46
 
34
47
  import { registerComponent } from '../../lib/bundle-registry.js';
35
48
  import { VBElement } from '../../lib/vb-element.js';
49
+ import {
50
+ parseISODate, toISODate, addDays, addMonths, daysBetween,
51
+ startOfMonth, startOfQuarter, formatDate,
52
+ } from '../../lib/dates.js';
53
+
54
+ /** Pointer travel (px) before a press becomes a drag. Below it, it's a click. */
55
+ const DRAG_THRESHOLD = 4;
56
+
57
+ const STATUS_LABELS = {
58
+ planned: 'Planned',
59
+ 'in-progress': 'In progress',
60
+ 'at-risk': 'At risk',
61
+ shipped: 'Shipped',
62
+ blocked: 'Blocked',
63
+ };
64
+
65
+ /** @param {string} s */
66
+ function statusLabel(s) {
67
+ return STATUS_LABELS[s] || (s.charAt(0).toUpperCase() + s.slice(1)).replace(/-/g, ' ');
68
+ }
36
69
 
37
- const MS_PER_DAY = 86_400_000;
70
+ let instanceCounter = 0;
38
71
 
39
72
  class Roadmap extends VBElement {
40
73
  static get observedAttributes() {
41
74
  return ['start', 'end', 'view', 'editable', 'today-marker'];
42
75
  }
43
76
 
77
+ #uid = `rm-${++instanceCounter}`;
78
+
44
79
  setup() {
45
80
  if (!this.#readConfig()) return false;
46
81
 
47
- /* Snapshot original lane sections (auto-discovery) before we wipe
48
- the host. We do not modify the original elements until/unless
49
- drag interactions change their data-* attributes. */
82
+ /* The source lanes stay in the host (hidden) and are re-read on each
83
+ render, so author edits to data-start/data-end are picked up and a
84
+ reconnect re-renders from the same source of truth. */
50
85
  const lanes = [...this.querySelectorAll(':scope > section[data-lane]')];
51
86
  if (!lanes.length) {
52
87
  console.warn('product-roadmap: no <section data-lane> children found');
53
88
  return false;
54
89
  }
55
90
  this.#sourceLanes = lanes;
91
+
92
+ /* Delegated once per connection; listen() removes them on disconnect. */
93
+ this.listen(this, 'click', this.#onClick);
94
+ this.listen(this, 'keydown', this.#onKeyDown);
95
+ this.listen(this, 'pointerdown', this.#onPointerDown);
96
+ this.listen(this, 'pointermove', this.#onPointerMove);
97
+ this.listen(this, 'pointerup', this.#onPointerUp);
98
+ this.listen(this, 'pointercancel', this.#onPointerCancel);
99
+
56
100
  this.#render();
57
101
  return true;
58
102
  }
59
103
 
104
+ teardown() {
105
+ this.#dragInfo = null;
106
+ }
107
+
60
108
  /** Parse start/end/view/editable from attributes. Returns false when invalid. */
61
109
  #readConfig() {
62
110
  const startAttr = this.getAttribute('start');
@@ -67,13 +115,13 @@ class Roadmap extends VBElement {
67
115
  }
68
116
  const start = Roadmap.#parseDate(startAttr, 'start');
69
117
  const end = Roadmap.#parseDate(endAttr, 'end');
70
- if (!start || !end || end <= start) {
118
+ if (!start || !end || end < start) {
71
119
  console.warn('product-roadmap: invalid start/end', startAttr, endAttr);
72
120
  return false;
73
121
  }
74
122
  this.#start = start;
75
- this.#end = end;
76
- this.#totalMs = end.getTime() - start.getTime();
123
+ this.#endExcl = addDays(end, 1);
124
+ this.#totalDays = daysBetween(start, this.#endExcl);
77
125
  this.#view = (this.getAttribute('view') === 'month') ? 'month' : 'quarter';
78
126
  this.#editable = this.hasAttribute('editable');
79
127
  return true;
@@ -81,30 +129,33 @@ class Roadmap extends VBElement {
81
129
 
82
130
  attributeChangedCallback(_name, oldValue, newValue) {
83
131
  if (oldValue === newValue) return;
84
- if (!this.isConnected || !this.#sourceLanes.length) return;
132
+ if (!this.isConnected || !this.hasAttribute('data-upgraded')) return;
85
133
  if (this.#readConfig()) this.#render();
86
134
  }
87
135
 
136
+ get #periodMonths() { return this.#view === 'month' ? 1 : 3; }
137
+
88
138
  #render() {
89
- /* Wipe everything except the source lanes (which we re-read on
90
- each render so author edits to data-start/data-end are picked up). */
139
+ /* Remember which initiative had focus so keyboard edits keep it. */
140
+ const focused = /** @type {HTMLElement | null} */ (this.querySelector('.rm-bar:focus'));
141
+ const refocus = focused ? this.#initiativeFor.get(focused) : null;
142
+
91
143
  [...this.children].forEach((c) => {
92
144
  if (!this.#sourceLanes.includes(c)) c.remove();
93
145
  });
94
- /* Move source lanes out of the host so they don't display directly. */
95
146
  this.#sourceLanes.forEach((lane) => { /** @type {HTMLElement} */ (lane).hidden = true; });
147
+ this.#initiativeFor = new WeakMap();
96
148
 
97
149
  const wrapper = document.createElement('div');
98
150
  wrapper.className = 'rm-wrapper';
99
151
 
100
- /* --- time axis header --- */
101
- const axis = this.#buildAxis();
102
- wrapper.appendChild(axis);
152
+ wrapper.appendChild(this.#buildAxis());
103
153
 
104
- /* --- lanes --- */
105
154
  const lanesEl = document.createElement('div');
106
155
  lanesEl.className = 'rm-lanes';
107
156
 
157
+ /** @type {HTMLElement | null} */
158
+ let refocusBar = null;
108
159
  for (const sourceLane of this.#sourceLanes) {
109
160
  const laneName = sourceLane.getAttribute('data-lane') || '';
110
161
  const laneEl = document.createElement('section');
@@ -120,16 +171,12 @@ class Roadmap extends VBElement {
120
171
  track.className = 'rm-track';
121
172
  track.dataset.lane = laneName;
122
173
 
123
- /* For each <article> in the source lane, build a bar. */
124
174
  const initiatives = [...sourceLane.querySelectorAll(':scope > article[data-start][data-end]')];
125
175
  for (const init of initiatives) {
126
176
  const bar = this.#buildBar(init, laneName);
127
- if (bar) track.appendChild(bar);
128
- }
129
-
130
- if (this.#editable) {
131
- track.addEventListener('dragover', this.#onTrackDragOver);
132
- track.addEventListener('drop', this.#onTrackDrop);
177
+ if (!bar) continue;
178
+ track.appendChild(bar);
179
+ if (init === refocus) refocusBar = bar;
133
180
  }
134
181
 
135
182
  laneEl.appendChild(track);
@@ -138,26 +185,41 @@ class Roadmap extends VBElement {
138
185
 
139
186
  wrapper.appendChild(lanesEl);
140
187
 
141
- if (this.hasAttribute('today-marker') && this.#start && this.#end) {
142
- const today = new Date();
143
- if (today >= this.#start && today <= this.#end) {
188
+ if (this.hasAttribute('today-marker') && this.#start && this.#endExcl) {
189
+ const today = parseISODate(new Date());
190
+ if (today >= this.#start && today < this.#endExcl) {
144
191
  const marker = document.createElement('div');
145
192
  marker.className = 'rm-today';
146
193
  marker.style.setProperty('--rm-x', this.#fractionFor(today).toFixed(4));
147
- marker.title = `Today: ${Roadmap.#toISO(today)}`;
194
+ marker.title = `Today: ${toISODate(today)}`;
148
195
  lanesEl.appendChild(marker);
149
196
  }
150
197
  }
151
198
 
199
+ if (this.#editable) {
200
+ const help = document.createElement('p');
201
+ help.id = `${this.#uid}-help`;
202
+ help.className = 'rm-sr-only';
203
+ const unit = this.#view === 'month' ? 'month' : 'quarter';
204
+ help.textContent = `Arrow keys move by one ${unit}. Shift plus arrow keys change the end date. Alt plus up or down arrow moves to another lane.`;
205
+ wrapper.appendChild(help);
206
+ }
207
+
208
+ const live = document.createElement('div');
209
+ live.className = 'rm-sr-only';
210
+ live.setAttribute('role', 'status');
211
+ live.setAttribute('aria-live', 'polite');
212
+ wrapper.appendChild(live);
213
+ this.#liveRegion = live;
214
+
152
215
  this.appendChild(wrapper);
153
- this.#wrapper = wrapper;
216
+ if (refocusBar) refocusBar.focus();
154
217
  }
155
218
 
156
219
  #buildAxis() {
157
220
  const axis = document.createElement('header');
158
221
  axis.className = 'rm-axis';
159
- const ticks = this.#enumerateTicks();
160
- for (const tick of ticks) {
222
+ for (const tick of this.#enumerateTicks()) {
161
223
  const cell = document.createElement('div');
162
224
  cell.className = 'rm-tick';
163
225
  cell.style.setProperty('--rm-x', tick.startFraction.toFixed(4));
@@ -168,14 +230,20 @@ class Roadmap extends VBElement {
168
230
  return axis;
169
231
  }
170
232
 
233
+ /**
234
+ * @param {Element} initiative
235
+ * @param {string} laneName
236
+ */
171
237
  #buildBar(initiative, laneName) {
172
238
  const start = Roadmap.#parseDate(initiative.getAttribute('data-start'), 'start');
173
239
  const end = Roadmap.#parseDate(initiative.getAttribute('data-end'), 'end');
174
- if (!start || !end || !this.#start || !this.#end) return null;
240
+ if (!start || !end || end < start || !this.#start || !this.#endExcl) return null;
175
241
 
176
- /* Clip to visible range. */
242
+ /* End is inclusive: geometry runs to the start of the following day.
243
+ Clip to the visible range. */
244
+ const endExcl = addDays(end, 1);
177
245
  const clipStart = start < this.#start ? this.#start : start;
178
- const clipEnd = end > this.#end ? this.#end : end;
246
+ const clipEnd = endExcl > this.#endExcl ? this.#endExcl : endExcl;
179
247
  if (clipStart >= clipEnd) return null;
180
248
 
181
249
  const x = this.#fractionFor(clipStart);
@@ -186,285 +254,364 @@ class Roadmap extends VBElement {
186
254
  bar.style.setProperty('--rm-x', x.toFixed(4));
187
255
  bar.style.setProperty('--rm-w', w.toFixed(4));
188
256
  bar.dataset.lane = laneName;
189
- bar.dataset.start = Roadmap.#toISO(start);
190
- bar.dataset.end = Roadmap.#toISO(end);
191
- if (initiative.dataset.status) bar.dataset.status = initiative.dataset.status;
257
+ bar.dataset.start = toISODate(start);
258
+ bar.dataset.end = toISODate(end);
259
+ const status = /** @type {HTMLElement} */ (initiative).dataset.status || '';
260
+ if (status) bar.dataset.status = status;
192
261
  if (initiative.id) bar.dataset.sourceId = initiative.id;
193
- if (this.#editable) bar.setAttribute('draggable', 'true');
262
+ bar.tabIndex = 0;
263
+ bar.setAttribute('role', 'button');
264
+ this.#initiativeFor.set(bar, initiative);
194
265
 
195
- /* Inner content: clone the title and details. */
196
266
  const title = initiative.querySelector('h1, h2, h3, h4, h5, h6');
197
267
  const heading = document.createElement('span');
198
268
  heading.className = 'rm-bar-title';
199
269
  heading.textContent = title ? title.textContent.trim() : (initiative.textContent || '').trim().split('\n')[0];
200
270
  bar.appendChild(heading);
201
271
 
202
- bar.setAttribute('aria-label', `${heading.textContent} — ${bar.dataset.start} to ${bar.dataset.end} in ${laneName}`);
272
+ /* Status is shown as text as well as colour. */
273
+ if (status) {
274
+ const badge = document.createElement('span');
275
+ badge.className = 'rm-bar-status';
276
+ badge.textContent = statusLabel(status);
277
+ bar.appendChild(badge);
278
+ }
203
279
 
204
- /* Click to select. */
205
- bar.addEventListener('click', (e) => {
206
- if (this.#dragInfo) return;
207
- this.dispatchEvent(new CustomEvent('product-roadmap:select', {
208
- bubbles: true,
209
- detail: {
210
- initiative: initiative,
211
- lane: laneName,
212
- start: bar.dataset.start,
213
- end: bar.dataset.end,
214
- status: bar.dataset.status || null,
215
- },
216
- }));
217
- });
280
+ bar.setAttribute('aria-label',
281
+ `${heading.textContent} — ${formatDate(start)} to ${formatDate(end)} in ${laneName}` +
282
+ (status ? `, status: ${statusLabel(status).toLowerCase()}` : ''));
218
283
 
219
284
  if (this.#editable) {
220
- /* Resize handle. */
285
+ bar.setAttribute('aria-describedby', `${this.#uid}-help`);
286
+ /* Pointer-only affordance; the keyboard equivalent is Shift+Arrow
287
+ on the bar itself, so the handle is hidden from assistive tech. */
221
288
  const handle = document.createElement('span');
222
289
  handle.className = 'rm-bar-handle';
223
- handle.setAttribute('aria-label', 'Resize end date');
290
+ handle.setAttribute('aria-hidden', 'true');
291
+ handle.title = 'Drag to change the end date';
224
292
  bar.appendChild(handle);
225
-
226
- handle.addEventListener('pointerdown', (e) => this.#startResize(e, bar, initiative));
227
- bar.addEventListener('pointerdown', (e) => {
228
- if (e.target === handle) return;
229
- this.#startMove(e, bar, initiative);
230
- });
231
-
232
- /* HTML5 DnD for cross-lane drops. */
233
- bar.addEventListener('dragstart', (e) => {
234
- if (e.dataTransfer) {
235
- e.dataTransfer.effectAllowed = 'move';
236
- e.dataTransfer.setData('text/plain', initiative.id || bar.dataset.sourceId || '');
237
- }
238
- this.#draggingInitiative = initiative;
239
- this.#draggingFromLane = laneName;
240
- });
241
- bar.addEventListener('dragend', () => {
242
- this.#draggingInitiative = null;
243
- this.#draggingFromLane = null;
244
- });
245
293
  }
246
294
 
247
295
  return bar;
248
296
  }
249
297
 
250
- /* --- pointer drag (move along time axis) --- */
251
- #startMove(e, bar, initiative) {
252
- if (e.button !== 0) return;
253
- e.preventDefault();
254
- const trackRect = bar.parentElement.getBoundingClientRect();
255
- const startX = e.clientX;
256
- const startFraction = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
257
- const widthFraction = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
258
-
259
- this.#dragInfo = { kind: 'move', bar, initiative, trackRect, startX, startFraction, widthFraction };
260
- bar.setPointerCapture(e.pointerId);
261
- bar.addEventListener('pointermove', this.#onPointerMove);
262
- bar.addEventListener('pointerup', this.#onPointerUp);
263
- bar.addEventListener('pointercancel', this.#onPointerUp);
264
- }
265
-
266
- #startResize(e, bar, initiative) {
267
- if (e.button !== 0) return;
268
- e.preventDefault();
269
- e.stopPropagation();
270
- const trackRect = bar.parentElement.getBoundingClientRect();
271
- const startX = e.clientX;
272
- const startFraction = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
273
- const widthFraction = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
274
-
275
- this.#dragInfo = { kind: 'resize', bar, initiative, trackRect, startX, startFraction, widthFraction };
276
- bar.setPointerCapture(e.pointerId);
277
- bar.addEventListener('pointermove', this.#onPointerMove);
278
- bar.addEventListener('pointerup', this.#onPointerUp);
279
- bar.addEventListener('pointercancel', this.#onPointerUp);
280
- }
281
-
282
- #onPointerMove = (e) => {
283
- if (!this.#dragInfo) return;
284
- const { kind, bar, trackRect, startX, startFraction, widthFraction } = this.#dragInfo;
285
- const dxFraction = (e.clientX - startX) / trackRect.width;
286
-
287
- if (kind === 'move') {
288
- const newStart = Math.max(0, Math.min(1 - widthFraction, startFraction + dxFraction));
289
- bar.style.setProperty('--rm-x', newStart.toFixed(4));
290
- } else if (kind === 'resize') {
291
- const newWidth = Math.max(0.02, Math.min(1 - startFraction, widthFraction + dxFraction));
292
- bar.style.setProperty('--rm-w', newWidth.toFixed(4));
293
- }
294
- };
298
+ /* --- selection --- */
295
299
 
296
- #onPointerUp = (e) => {
297
- if (!this.#dragInfo) return;
298
- const { kind, bar, initiative } = this.#dragInfo;
299
- bar.releasePointerCapture(e.pointerId);
300
- bar.removeEventListener('pointermove', this.#onPointerMove);
301
- bar.removeEventListener('pointerup', this.#onPointerUp);
302
- bar.removeEventListener('pointercancel', this.#onPointerUp);
303
-
304
- /* Snap to grid (quarter or month boundary) and update dates. */
305
- const xFrac = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
306
- const wFrac = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
307
- const snappedStart = this.#snapDate(this.#dateForFraction(xFrac));
308
- const snappedEnd = this.#snapDate(this.#dateForFraction(xFrac + wFrac));
309
-
310
- bar.dataset.start = Roadmap.#toISO(snappedStart);
311
- bar.dataset.end = Roadmap.#toISO(snappedEnd);
312
- /* Re-snap visual to grid after drag. */
313
- const snappedX = this.#fractionFor(snappedStart);
314
- const snappedW = this.#fractionFor(snappedEnd) - snappedX;
315
- bar.style.setProperty('--rm-x', snappedX.toFixed(4));
316
- bar.style.setProperty('--rm-w', snappedW.toFixed(4));
317
-
318
- /* Mirror back to the source <article> so the author's data is updated. */
319
- if (initiative) {
320
- initiative.setAttribute('data-start', bar.dataset.start ?? '');
321
- initiative.setAttribute('data-end', bar.dataset.end ?? '');
322
- }
323
-
324
- this.dispatchEvent(new CustomEvent(kind === 'move' ? 'product-roadmap:reschedule' : 'product-roadmap:resize', {
300
+ /** @param {HTMLElement} bar */
301
+ #select(bar) {
302
+ const initiative = this.#initiativeFor.get(bar);
303
+ if (!initiative) return;
304
+ this.dispatchEvent(new CustomEvent('product-roadmap:select', {
325
305
  bubbles: true,
326
306
  detail: {
327
307
  initiative,
328
308
  lane: bar.dataset.lane,
329
309
  start: bar.dataset.start,
330
310
  end: bar.dataset.end,
311
+ status: bar.dataset.status || null,
331
312
  },
332
313
  }));
314
+ }
315
+
316
+ /** @param {Event} e */
317
+ #barFromEvent(e) {
318
+ const t = /** @type {Element} */ (e.target);
319
+ const bar = /** @type {HTMLElement | null} */ (t?.closest?.('.rm-bar'));
320
+ return bar && this.contains(bar) ? bar : null;
321
+ }
333
322
 
334
- /* Clear dragInfo on next tick so any pending click event sees it
335
- and skips select. */
336
- setTimeout(() => { this.#dragInfo = null; }, 0);
323
+ #onClick = (e) => {
324
+ const bar = this.#barFromEvent(e);
325
+ if (!bar) return;
326
+ if (this.#suppressClick) return;
327
+ this.#select(bar);
337
328
  };
338
329
 
339
- #onTrackDragOver = (e) => {
340
- if (!this.#draggingInitiative) return;
341
- e.preventDefault();
342
- e.dataTransfer.dropEffect = 'move';
330
+ #onKeyDown = (e) => {
331
+ const ke = /** @type {KeyboardEvent} */ (e);
332
+ const bar = this.#barFromEvent(ke);
333
+ if (!bar || ke.target !== bar) return;
334
+
335
+ if (ke.key === 'Enter' || ke.key === ' ') {
336
+ ke.preventDefault();
337
+ this.#select(bar);
338
+ return;
339
+ }
340
+ if (!this.#editable) return;
341
+
342
+ const initiative = this.#initiativeFor.get(bar);
343
+ if (!initiative) return;
344
+ const p = this.#periodMonths;
345
+
346
+ if (ke.altKey && (ke.key === 'ArrowUp' || ke.key === 'ArrowDown')) {
347
+ ke.preventDefault();
348
+ const names = this.#sourceLanes.map((l) => l.getAttribute('data-lane') || '');
349
+ const idx = names.indexOf(bar.dataset.lane || '');
350
+ const next = names[idx + (ke.key === 'ArrowUp' ? -1 : 1)];
351
+ if (idx === -1 || next === undefined) return;
352
+ this.#commit(initiative, bar.dataset.lane || '', next, null, null, 'move');
353
+ return;
354
+ }
355
+
356
+ if (ke.key !== 'ArrowLeft' && ke.key !== 'ArrowRight') return;
357
+ ke.preventDefault();
358
+ const dir = ke.key === 'ArrowLeft' ? -1 : 1;
359
+ const start = parseISODate(bar.dataset.start);
360
+ const endExcl = addDays(parseISODate(bar.dataset.end), 1);
361
+
362
+ if (ke.shiftKey) {
363
+ /* Resize: move the end by one period, keep at least one day. */
364
+ if (dir > 0 && endExcl >= this.#endExcl) return;
365
+ const nextEndExcl = addMonths(endExcl, dir * p);
366
+ if (nextEndExcl <= start) return;
367
+ this.#commit(initiative, bar.dataset.lane || '', null, start, addDays(nextEndExcl, -1), 'resize');
368
+ } else {
369
+ /* Move: shift both ends by one period; stop at the range edges. */
370
+ if (dir < 0 && start <= this.#start) return;
371
+ if (dir > 0 && endExcl >= this.#endExcl) return;
372
+ const nextStart = addMonths(start, dir * p);
373
+ const nextEndExcl = addMonths(endExcl, dir * p);
374
+ this.#commit(initiative, bar.dataset.lane || '', null, nextStart, addDays(nextEndExcl, -1), 'reschedule');
375
+ }
343
376
  };
344
377
 
345
- #onTrackDrop = (e) => {
346
- if (!this.#draggingInitiative) return;
347
- e.preventDefault();
348
- const targetLane = e.currentTarget.dataset.lane;
349
- const initiative = this.#draggingInitiative;
350
- const fromLane = this.#draggingFromLane;
351
- if (targetLane === fromLane) return;
378
+ /* --- pointer drag (time axis + cross-lane) --- */
379
+
380
+ #onPointerDown = (e) => {
381
+ const pe = /** @type {PointerEvent} */ (e);
382
+ if (!this.#editable || pe.button !== 0) return;
383
+ const bar = this.#barFromEvent(pe);
384
+ if (!bar) return;
385
+ const track = bar.parentElement;
386
+ if (!track) return;
387
+ const isHandle = /** @type {Element} */ (pe.target).classList?.contains('rm-bar-handle');
388
+ this.#dragInfo = {
389
+ kind: isHandle ? 'resize' : 'move',
390
+ pointerId: pe.pointerId,
391
+ bar,
392
+ trackRect: track.getBoundingClientRect(),
393
+ startX: pe.clientX,
394
+ startY: pe.clientY,
395
+ startFraction: parseFloat(bar.style.getPropertyValue('--rm-x')) || 0,
396
+ widthFraction: parseFloat(bar.style.getPropertyValue('--rm-w')) || 0,
397
+ moved: false,
398
+ targetLane: bar.dataset.lane || '',
399
+ };
400
+ };
352
401
 
353
- /* Move the source <article> to the new <section data-lane>. */
354
- const newSourceLane = this.#sourceLanes.find((l) => l.getAttribute('data-lane') === targetLane);
355
- if (newSourceLane) newSourceLane.appendChild(initiative);
402
+ #onPointerMove = (e) => {
403
+ const pe = /** @type {PointerEvent} */ (e);
404
+ const info = this.#dragInfo;
405
+ if (!info || pe.pointerId !== info.pointerId) return;
406
+ const dx = pe.clientX - info.startX;
407
+ const dy = pe.clientY - info.startY;
408
+
409
+ if (!info.moved) {
410
+ if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
411
+ info.moved = true;
412
+ info.bar.setAttribute('data-dragging', '');
413
+ try { info.bar.setPointerCapture(pe.pointerId); } catch { /* synthetic pointer */ }
414
+ }
356
415
 
357
- this.dispatchEvent(new CustomEvent('product-roadmap:move', {
358
- bubbles: true,
359
- detail: {
360
- initiative,
361
- fromLane,
362
- toLane: targetLane,
363
- start: initiative.getAttribute('data-start'),
364
- end: initiative.getAttribute('data-end'),
365
- },
366
- }));
416
+ const dxFraction = dx / info.trackRect.width;
417
+ if (info.kind === 'move') {
418
+ const newStart = Math.max(0, Math.min(1 - info.widthFraction, info.startFraction + dxFraction));
419
+ info.bar.style.setProperty('--rm-x', newStart.toFixed(4));
420
+
421
+ /* Lane under the pointer (by vertical position over the tracks). */
422
+ let target = info.bar.dataset.lane || '';
423
+ for (const track of /** @type {NodeListOf<HTMLElement>} */ (this.querySelectorAll('.rm-track'))) {
424
+ const r = track.getBoundingClientRect();
425
+ if (pe.clientY >= r.top && pe.clientY <= r.bottom) { target = track.dataset.lane || ''; }
426
+ track.toggleAttribute('data-drop-target', false);
427
+ }
428
+ info.targetLane = target;
429
+ if (target !== info.bar.dataset.lane) {
430
+ this.querySelector(`.rm-track[data-lane="${CSS.escape(target)}"]`)?.setAttribute('data-drop-target', '');
431
+ }
432
+ } else {
433
+ const newWidth = Math.max(0.01, Math.min(1 - info.startFraction, info.widthFraction + dxFraction));
434
+ info.bar.style.setProperty('--rm-w', newWidth.toFixed(4));
435
+ }
436
+ };
367
437
 
368
- this.#render();
438
+ #onPointerUp = (e) => {
439
+ const pe = /** @type {PointerEvent} */ (e);
440
+ const info = this.#dragInfo;
441
+ if (!info || pe.pointerId !== info.pointerId) return;
442
+ this.#dragInfo = null;
443
+ if (!info.moved) return; // a plain press: let the click select
444
+
445
+ /* The click that follows a drag must not also select. */
446
+ this.#suppressClick = true;
447
+ setTimeout(() => { this.#suppressClick = false; }, 0);
448
+
449
+ const initiative = this.#initiativeFor.get(info.bar);
450
+ if (!initiative) return;
451
+ const xFrac = parseFloat(info.bar.style.getPropertyValue('--rm-x')) || 0;
452
+ const wFrac = parseFloat(info.bar.style.getPropertyValue('--rm-w')) || 0;
453
+
454
+ /* Work from the bar's true dates plus the dragged distance, not its
455
+ on-screen (range-clipped) geometry, so an initiative that runs past
456
+ the visible range keeps its off-screen part. */
457
+ let start = parseISODate(info.bar.dataset.start);
458
+ let endExcl = addDays(parseISODate(info.bar.dataset.end), 1);
459
+ if (info.kind === 'move') {
460
+ const dFrac = xFrac - info.startFraction;
461
+ start = this.#snapToBoundary(this.#dateForFraction(this.#rawFractionFor(start) + dFrac));
462
+ endExcl = this.#snapToBoundary(this.#dateForFraction(this.#rawFractionFor(endExcl) + dFrac));
463
+ } else {
464
+ const dFrac = wFrac - info.widthFraction;
465
+ endExcl = this.#snapToBoundary(this.#dateForFraction(this.#rawFractionFor(endExcl) + dFrac));
466
+ }
467
+ /* Never collapse a bar below one period. */
468
+ if (endExcl <= start) endExcl = addMonths(start, this.#periodMonths);
469
+
470
+ const fromLane = info.bar.dataset.lane || '';
471
+ const toLane = info.kind === 'move' && info.targetLane !== fromLane ? info.targetLane : null;
472
+ this.#commit(initiative, fromLane, toLane, start, addDays(endExcl, -1), info.kind === 'move' ? 'reschedule' : 'resize');
369
473
  };
370
474
 
371
- /* --- date math --- */
475
+ #onPointerCancel = (e) => {
476
+ const pe = /** @type {PointerEvent} */ (e);
477
+ if (!this.#dragInfo || pe.pointerId !== this.#dragInfo.pointerId) return;
478
+ this.#dragInfo = null;
479
+ this.#render(); // discard the uncommitted visual offset
480
+ };
481
+
482
+ /**
483
+ * Apply a date and/or lane change to the source <article>, fire the
484
+ * matching events, and re-render. Unchanged values fire nothing.
485
+ * @param {Element} initiative
486
+ * @param {string} fromLane
487
+ * @param {string | null} toLane - new lane, or null to stay
488
+ * @param {Date | null} start - new start, or null to keep
489
+ * @param {Date | null} end - new inclusive end, or null to keep
490
+ * @param {'reschedule' | 'resize' | 'move'} dateEvent - event to fire if the dates changed
491
+ */
492
+ #commit(initiative, fromLane, toLane, start, end, dateEvent) {
493
+ const oldStart = toISODate(Roadmap.#parseDate(initiative.getAttribute('data-start'), 'start'));
494
+ const oldEnd = toISODate(Roadmap.#parseDate(initiative.getAttribute('data-end'), 'end'));
495
+ const newStart = start ? toISODate(start) : oldStart;
496
+ const newEnd = end ? toISODate(end) : oldEnd;
497
+ const datesChanged = newStart !== oldStart || newEnd !== oldEnd;
498
+ const targetLane = toLane !== null && toLane !== fromLane
499
+ ? this.#sourceLanes.find((l) => l.getAttribute('data-lane') === toLane)
500
+ : null;
501
+ const laneChanged = !!targetLane;
502
+
503
+ if (datesChanged) {
504
+ initiative.setAttribute('data-start', newStart);
505
+ initiative.setAttribute('data-end', newEnd);
506
+ }
507
+ if (targetLane) targetLane.appendChild(initiative);
508
+
509
+ this.#render();
510
+ if (!datesChanged && !laneChanged) return;
511
+
512
+ const lane = laneChanged ? toLane : fromLane;
513
+ const title = initiative.querySelector('h1, h2, h3, h4, h5, h6')?.textContent?.trim() || 'Initiative';
514
+ this.#announce(`${title}: ${formatDate(parseISODate(newStart))} to ${formatDate(parseISODate(newEnd))} in ${lane}`);
515
+
516
+ if (datesChanged && dateEvent !== 'move') {
517
+ this.dispatchEvent(new CustomEvent(`product-roadmap:${dateEvent}`, {
518
+ bubbles: true,
519
+ detail: { initiative, lane, start: newStart, end: newEnd },
520
+ }));
521
+ }
522
+ if (laneChanged) {
523
+ this.dispatchEvent(new CustomEvent('product-roadmap:move', {
524
+ bubbles: true,
525
+ detail: { initiative, fromLane, toLane, start: newStart, end: newEnd },
526
+ }));
527
+ }
528
+ }
529
+
530
+ /** @param {string} msg */
531
+ #announce(msg) {
532
+ if (this.#liveRegion) this.#liveRegion.textContent = msg;
533
+ }
534
+
535
+ /* --- date math (calendar days, DST-safe) --- */
536
+
537
+ /** @param {Date} date */
372
538
  #fractionFor(date) {
373
- if (!this.#start) return 0;
374
- const ms = date.getTime() - this.#start.getTime();
375
- return Math.max(0, Math.min(1, ms / this.#totalMs));
539
+ if (!this.#start || !this.#totalDays) return 0;
540
+ return Math.max(0, Math.min(1, daysBetween(this.#start, date) / this.#totalDays));
541
+ }
542
+
543
+ /** Unclamped position of `date` on the range (< 0 before it, > 1 after). @param {Date} date */
544
+ #rawFractionFor(date) {
545
+ if (!this.#start || !this.#totalDays) return 0;
546
+ return daysBetween(this.#start, date) / this.#totalDays;
376
547
  }
377
548
 
549
+ /** @param {number} frac */
378
550
  #dateForFraction(frac) {
379
- const start = this.#start ?? new Date();
380
- return new Date(start.getTime() + frac * this.#totalMs);
551
+ return addDays(this.#start ?? new Date(), Math.round(frac * this.#totalDays));
381
552
  }
382
553
 
383
- #snapDate(date) {
384
- if (this.#view === 'month') {
385
- return new Date(date.getFullYear(), date.getMonth(), 1);
386
- }
387
- /* Quarter snap: round to nearest quarter boundary. */
388
- const month = date.getMonth();
389
- const qStartMonth = Math.floor(month / 3) * 3;
390
- return new Date(date.getFullYear(), qStartMonth, 1);
554
+ /** @param {Date} date */
555
+ #periodStart(date) {
556
+ return this.#view === 'month' ? startOfMonth(date) : startOfQuarter(date);
557
+ }
558
+
559
+ /** Round to the nearest period boundary (quarter or month start). @param {Date} date */
560
+ #snapToBoundary(date) {
561
+ const a = this.#periodStart(date);
562
+ const b = addMonths(a, this.#periodMonths);
563
+ return daysBetween(a, date) <= daysBetween(date, b) ? a : b;
391
564
  }
392
565
 
393
566
  #enumerateTicks() {
394
567
  const ticks = [];
395
568
  const start = this.#start;
396
- const end = this.#end;
569
+ const end = this.#endExcl;
397
570
  if (!start || !end) return ticks;
398
- if (this.#view === 'quarter') {
399
- let cursor = this.#snapDate(start);
400
- while (cursor < end) {
401
- const next = new Date(cursor.getFullYear(), cursor.getMonth() + 3, 1);
402
- const startFraction = this.#fractionFor(cursor < start ? start : cursor);
403
- const endFraction = this.#fractionFor(next > end ? end : next);
404
- const q = Math.floor(cursor.getMonth() / 3) + 1;
405
- ticks.push({ startFraction, endFraction, label: `Q${q} ${cursor.getFullYear()}` });
406
- cursor = next;
407
- }
408
- } else {
409
- let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
410
- while (cursor < end) {
411
- const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
412
- const startFraction = this.#fractionFor(cursor < start ? start : cursor);
413
- const endFraction = this.#fractionFor(next > end ? end : next);
414
- const m = cursor.toLocaleString(undefined, { month: 'short' });
415
- ticks.push({ startFraction, endFraction, label: `${m} ${cursor.getFullYear()}` });
416
- cursor = next;
417
- }
571
+ const p = this.#periodMonths;
572
+ for (let cursor = this.#periodStart(start); cursor < end; cursor = addMonths(cursor, p)) {
573
+ const next = addMonths(cursor, p);
574
+ const startFraction = this.#fractionFor(cursor < start ? start : cursor);
575
+ const endFraction = this.#fractionFor(next > end ? end : next);
576
+ const label = this.#view === 'quarter'
577
+ ? `Q${Math.floor(cursor.getMonth() / 3) + 1} ${cursor.getFullYear()}`
578
+ : `${cursor.toLocaleString(undefined, { month: 'short' })} ${cursor.getFullYear()}`;
579
+ ticks.push({ startFraction, endFraction, label });
418
580
  }
419
581
  return ticks;
420
582
  }
421
583
 
422
- /** Parse "2026-Q3", "2026-Q3-end", or ISO date. `kind` decides how
423
- "YYYY-Qn" without explicit position resolves: 'start' → first day
424
- of the quarter; 'end' → last day. */
584
+ /** Parse "2026-Q3" or an ISO date. `kind` decides how "YYYY-Qn"
585
+ resolves: 'start' → first day of the quarter; 'end' → last day.
586
+ @param {string | null} value @param {'start' | 'end'} kind */
425
587
  static #parseDate(value, kind) {
426
588
  if (!value) return null;
427
- const q = String(value).match(/^(\d{4})-Q([1-4])$/i);
589
+ const q = String(value).trim().match(/^(\d{4})-Q([1-4])$/i);
428
590
  if (q) {
429
591
  const year = Number(q[1]);
430
- const quarter = Number(q[2]);
431
- const monthStart = (quarter - 1) * 3;
432
- if (kind === 'end') {
433
- /* Last day of last month of the quarter */
434
- return new Date(year, monthStart + 3, 0);
435
- }
436
- return new Date(year, monthStart, 1);
592
+ const monthStart = (Number(q[2]) - 1) * 3;
593
+ return kind === 'end' ? new Date(year, monthStart + 3, 0) : new Date(year, monthStart, 1);
437
594
  }
438
- const iso = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
439
- if (iso) return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
440
- const d = new Date(value);
441
- return isNaN(d.getTime()) ? null : d;
442
- }
443
-
444
- static #toISO(d) {
445
- const m = String(d.getMonth() + 1).padStart(2, '0');
446
- const day = String(d.getDate()).padStart(2, '0');
447
- return `${d.getFullYear()}-${m}-${day}`;
595
+ return parseISODate(value);
448
596
  }
449
597
 
450
598
  /** @type {Date | null} */
451
599
  #start = null;
452
- /** @type {Date | null} */
453
- #end = null;
454
- #totalMs = 0;
600
+ /** @type {Date | null} first day AFTER the visible range */
601
+ #endExcl = null;
602
+ #totalDays = 0;
455
603
  /** @type {"quarter" | "month"} */
456
604
  #view = 'quarter';
457
605
  #editable = false;
606
+ #suppressClick = false;
458
607
  /** @type {Element[]} */
459
608
  #sourceLanes = [];
609
+ /** @type {WeakMap<HTMLElement, Element>} rendered bar → source <article> */
610
+ #initiativeFor = new WeakMap();
460
611
  /** @type {HTMLElement | null} */
461
- #wrapper = null;
462
- /** @type {{ kind: string, bar: HTMLElement, initiative?: Element, trackRect: DOMRect, startX: number, startFraction: number, widthFraction: number } | null} */
612
+ #liveRegion = null;
613
+ /** @type {{ kind: 'move' | 'resize', pointerId: number, bar: HTMLElement, trackRect: DOMRect, startX: number, startY: number, startFraction: number, widthFraction: number, moved: boolean, targetLane: string } | null} */
463
614
  #dragInfo = null;
464
- /** @type {Element | null} */
465
- #draggingInitiative = null;
466
- /** @type {Element | null} */
467
- #draggingFromLane = null;
468
615
  }
469
616
 
470
617
  registerComponent('product-roadmap', Roadmap);