@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.
- package/README.md +5 -2
- package/dist/vb-project-planning.css +70 -3
- package/dist/vb-project-planning.js +3333 -2565
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +224 -200
- 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/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 +9 -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 +1 -19
- package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
- package/src/web-components/iron-triangle/api.json +2 -2
- package/src/web-components/iron-triangle/logic.js +47 -9
- package/src/web-components/iron-triangle/styles.css +22 -1
- 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/quality-target/api.json +2 -3
- package/src/web-components/quality-target/logic.js +85 -13
- package/src/web-components/requirement-card/api.json +1 -1
- package/src/web-components/requirement-card/logic.js +26 -2
- package/src/web-components/requirement-card/static.html +1 -1
- 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/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 +14 -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
|
@@ -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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
-
/*
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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.#
|
|
76
|
-
this.#
|
|
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
|
|
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
|
-
/*
|
|
90
|
-
|
|
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
|
-
|
|
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)
|
|
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.#
|
|
142
|
-
const today = new Date();
|
|
143
|
-
if (today >= this.#start && today
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
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.#
|
|
240
|
+
if (!start || !end || end < start || !this.#start || !this.#endExcl) return null;
|
|
175
241
|
|
|
176
|
-
/*
|
|
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 =
|
|
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 =
|
|
190
|
-
bar.dataset.end =
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
/* ---
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
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
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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.#
|
|
569
|
+
const end = this.#endExcl;
|
|
397
570
|
if (!start || !end) return ticks;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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"
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
454
|
-
#
|
|
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
|
-
#
|
|
462
|
-
/** @type {{ kind:
|
|
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);
|