@profpowell/vb-project-planning 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roadmap: Interactive themes × quarters product roadmap
|
|
3
|
+
*
|
|
4
|
+
* Renders a horizontal time axis (quarters or months) with one
|
|
5
|
+
* swimlane per <section data-lane>. Each <article data-start data-end>
|
|
6
|
+
* inside a lane becomes an absolutely-positioned bar spanning its
|
|
7
|
+
* date range.
|
|
8
|
+
*
|
|
9
|
+
* Distinct from <gantt-chart>: roadmap is theme-centric (initiatives
|
|
10
|
+
* in lanes, status as enum, no dependency arrows). Gantt is task-
|
|
11
|
+
* scheduling. Use roadmap for quarterly product roadmaps; gantt for
|
|
12
|
+
* project plans.
|
|
13
|
+
*
|
|
14
|
+
* @attr {string} start - First date in the range (ISO 8601 or "YYYY-Qn"; required)
|
|
15
|
+
* @attr {string} end - Last date in the range (ISO 8601 or "YYYY-Qn"; required, inclusive)
|
|
16
|
+
* @attr {string} view - "quarter" (default) or "month" axis granularity
|
|
17
|
+
* @attr {boolean} editable - Wires drag-to-reschedule, drag-to-resize, lane drop
|
|
18
|
+
* @attr {boolean} today-marker - Render a vertical line at today's date if it falls in range
|
|
19
|
+
*
|
|
20
|
+
* Children:
|
|
21
|
+
* <section data-lane="..."> — one swimlane row
|
|
22
|
+
* <article data-start="..." data-end="..." data-status="..."> — one initiative bar
|
|
23
|
+
* <h3>Title</h3>
|
|
24
|
+
* <p>Optional description (visible in detail / tooltip)</p>
|
|
25
|
+
* </article>
|
|
26
|
+
* </section>
|
|
27
|
+
*
|
|
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
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
35
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
36
|
+
|
|
37
|
+
const MS_PER_DAY = 86_400_000;
|
|
38
|
+
|
|
39
|
+
class Roadmap extends VBElement {
|
|
40
|
+
setup() {
|
|
41
|
+
const startAttr = this.getAttribute('start');
|
|
42
|
+
const endAttr = this.getAttribute('end');
|
|
43
|
+
if (!startAttr || !endAttr) {
|
|
44
|
+
console.warn('product-roadmap: start and end attributes required');
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const start = Roadmap.#parseDate(startAttr, 'start');
|
|
48
|
+
const end = Roadmap.#parseDate(endAttr, 'end');
|
|
49
|
+
if (!start || !end || end <= start) {
|
|
50
|
+
console.warn('product-roadmap: invalid start/end', startAttr, endAttr);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
this.#start = start;
|
|
54
|
+
this.#end = end;
|
|
55
|
+
this.#totalMs = end.getTime() - start.getTime();
|
|
56
|
+
this.#view = (this.getAttribute('view') === 'month') ? 'month' : 'quarter';
|
|
57
|
+
this.#editable = this.hasAttribute('editable');
|
|
58
|
+
|
|
59
|
+
/* Snapshot original lane sections (auto-discovery) before we wipe
|
|
60
|
+
the host. We do not modify the original elements until/unless
|
|
61
|
+
drag interactions change their data-* attributes. */
|
|
62
|
+
const lanes = [...this.querySelectorAll(':scope > section[data-lane]')];
|
|
63
|
+
if (!lanes.length) {
|
|
64
|
+
console.warn('product-roadmap: no <section data-lane> children found');
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
this.#sourceLanes = lanes;
|
|
68
|
+
this.#render();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
attributeChangedCallback() {
|
|
73
|
+
if (this.isConnected && this.#sourceLanes.length) this.#render();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#render() {
|
|
77
|
+
/* Wipe everything except the source lanes (which we re-read on
|
|
78
|
+
each render so author edits to data-start/data-end are picked up). */
|
|
79
|
+
[...this.children].forEach((c) => {
|
|
80
|
+
if (!this.#sourceLanes.includes(c)) c.remove();
|
|
81
|
+
});
|
|
82
|
+
/* Move source lanes out of the host so they don't display directly. */
|
|
83
|
+
this.#sourceLanes.forEach((lane) => { /** @type {HTMLElement} */ (lane).hidden = true; });
|
|
84
|
+
|
|
85
|
+
const wrapper = document.createElement('div');
|
|
86
|
+
wrapper.className = 'rm-wrapper';
|
|
87
|
+
|
|
88
|
+
/* --- time axis header --- */
|
|
89
|
+
const axis = this.#buildAxis();
|
|
90
|
+
wrapper.appendChild(axis);
|
|
91
|
+
|
|
92
|
+
/* --- lanes --- */
|
|
93
|
+
const lanesEl = document.createElement('div');
|
|
94
|
+
lanesEl.className = 'rm-lanes';
|
|
95
|
+
|
|
96
|
+
for (const sourceLane of this.#sourceLanes) {
|
|
97
|
+
const laneName = sourceLane.getAttribute('data-lane') || '';
|
|
98
|
+
const laneEl = document.createElement('section');
|
|
99
|
+
laneEl.className = 'rm-lane';
|
|
100
|
+
laneEl.dataset.lane = laneName;
|
|
101
|
+
|
|
102
|
+
const laneLabel = document.createElement('header');
|
|
103
|
+
laneLabel.className = 'rm-lane-label';
|
|
104
|
+
laneLabel.textContent = laneName;
|
|
105
|
+
laneEl.appendChild(laneLabel);
|
|
106
|
+
|
|
107
|
+
const track = document.createElement('div');
|
|
108
|
+
track.className = 'rm-track';
|
|
109
|
+
track.dataset.lane = laneName;
|
|
110
|
+
|
|
111
|
+
/* For each <article> in the source lane, build a bar. */
|
|
112
|
+
const initiatives = [...sourceLane.querySelectorAll(':scope > article[data-start][data-end]')];
|
|
113
|
+
for (const init of initiatives) {
|
|
114
|
+
const bar = this.#buildBar(init, laneName);
|
|
115
|
+
if (bar) track.appendChild(bar);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.#editable) {
|
|
119
|
+
track.addEventListener('dragover', this.#onTrackDragOver);
|
|
120
|
+
track.addEventListener('drop', this.#onTrackDrop);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
laneEl.appendChild(track);
|
|
124
|
+
lanesEl.appendChild(laneEl);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
wrapper.appendChild(lanesEl);
|
|
128
|
+
|
|
129
|
+
if (this.hasAttribute('today-marker') && this.#start && this.#end) {
|
|
130
|
+
const today = new Date();
|
|
131
|
+
if (today >= this.#start && today <= this.#end) {
|
|
132
|
+
const marker = document.createElement('div');
|
|
133
|
+
marker.className = 'rm-today';
|
|
134
|
+
marker.style.setProperty('--rm-x', this.#fractionFor(today).toFixed(4));
|
|
135
|
+
marker.title = `Today: ${Roadmap.#toISO(today)}`;
|
|
136
|
+
lanesEl.appendChild(marker);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.appendChild(wrapper);
|
|
141
|
+
this.#wrapper = wrapper;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#buildAxis() {
|
|
145
|
+
const axis = document.createElement('header');
|
|
146
|
+
axis.className = 'rm-axis';
|
|
147
|
+
const ticks = this.#enumerateTicks();
|
|
148
|
+
for (const tick of ticks) {
|
|
149
|
+
const cell = document.createElement('div');
|
|
150
|
+
cell.className = 'rm-tick';
|
|
151
|
+
cell.style.setProperty('--rm-x', tick.startFraction.toFixed(4));
|
|
152
|
+
cell.style.setProperty('--rm-w', (tick.endFraction - tick.startFraction).toFixed(4));
|
|
153
|
+
cell.textContent = tick.label;
|
|
154
|
+
axis.appendChild(cell);
|
|
155
|
+
}
|
|
156
|
+
return axis;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#buildBar(initiative, laneName) {
|
|
160
|
+
const start = Roadmap.#parseDate(initiative.getAttribute('data-start'), 'start');
|
|
161
|
+
const end = Roadmap.#parseDate(initiative.getAttribute('data-end'), 'end');
|
|
162
|
+
if (!start || !end || !this.#start || !this.#end) return null;
|
|
163
|
+
|
|
164
|
+
/* Clip to visible range. */
|
|
165
|
+
const clipStart = start < this.#start ? this.#start : start;
|
|
166
|
+
const clipEnd = end > this.#end ? this.#end : end;
|
|
167
|
+
if (clipStart >= clipEnd) return null;
|
|
168
|
+
|
|
169
|
+
const x = this.#fractionFor(clipStart);
|
|
170
|
+
const w = this.#fractionFor(clipEnd) - x;
|
|
171
|
+
|
|
172
|
+
const bar = document.createElement('article');
|
|
173
|
+
bar.className = 'rm-bar';
|
|
174
|
+
bar.style.setProperty('--rm-x', x.toFixed(4));
|
|
175
|
+
bar.style.setProperty('--rm-w', w.toFixed(4));
|
|
176
|
+
bar.dataset.lane = laneName;
|
|
177
|
+
bar.dataset.start = Roadmap.#toISO(start);
|
|
178
|
+
bar.dataset.end = Roadmap.#toISO(end);
|
|
179
|
+
if (initiative.dataset.status) bar.dataset.status = initiative.dataset.status;
|
|
180
|
+
if (initiative.id) bar.dataset.sourceId = initiative.id;
|
|
181
|
+
if (this.#editable) bar.setAttribute('draggable', 'true');
|
|
182
|
+
|
|
183
|
+
/* Inner content: clone the title and details. */
|
|
184
|
+
const title = initiative.querySelector('h1, h2, h3, h4, h5, h6');
|
|
185
|
+
const heading = document.createElement('span');
|
|
186
|
+
heading.className = 'rm-bar-title';
|
|
187
|
+
heading.textContent = title ? title.textContent.trim() : (initiative.textContent || '').trim().split('\n')[0];
|
|
188
|
+
bar.appendChild(heading);
|
|
189
|
+
|
|
190
|
+
bar.setAttribute('aria-label', `${heading.textContent} — ${bar.dataset.start} to ${bar.dataset.end} in ${laneName}`);
|
|
191
|
+
|
|
192
|
+
/* Click to select. */
|
|
193
|
+
bar.addEventListener('click', (e) => {
|
|
194
|
+
if (this.#dragInfo) return;
|
|
195
|
+
this.dispatchEvent(new CustomEvent('product-roadmap:select', {
|
|
196
|
+
bubbles: true,
|
|
197
|
+
detail: {
|
|
198
|
+
initiative: initiative,
|
|
199
|
+
lane: laneName,
|
|
200
|
+
start: bar.dataset.start,
|
|
201
|
+
end: bar.dataset.end,
|
|
202
|
+
status: bar.dataset.status || null,
|
|
203
|
+
},
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (this.#editable) {
|
|
208
|
+
/* Resize handle. */
|
|
209
|
+
const handle = document.createElement('span');
|
|
210
|
+
handle.className = 'rm-bar-handle';
|
|
211
|
+
handle.setAttribute('aria-label', 'Resize end date');
|
|
212
|
+
bar.appendChild(handle);
|
|
213
|
+
|
|
214
|
+
handle.addEventListener('pointerdown', (e) => this.#startResize(e, bar, initiative));
|
|
215
|
+
bar.addEventListener('pointerdown', (e) => {
|
|
216
|
+
if (e.target === handle) return;
|
|
217
|
+
this.#startMove(e, bar, initiative);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/* HTML5 DnD for cross-lane drops. */
|
|
221
|
+
bar.addEventListener('dragstart', (e) => {
|
|
222
|
+
if (e.dataTransfer) {
|
|
223
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
224
|
+
e.dataTransfer.setData('text/plain', initiative.id || bar.dataset.sourceId || '');
|
|
225
|
+
}
|
|
226
|
+
this.#draggingInitiative = initiative;
|
|
227
|
+
this.#draggingFromLane = laneName;
|
|
228
|
+
});
|
|
229
|
+
bar.addEventListener('dragend', () => {
|
|
230
|
+
this.#draggingInitiative = null;
|
|
231
|
+
this.#draggingFromLane = null;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return bar;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* --- pointer drag (move along time axis) --- */
|
|
239
|
+
#startMove(e, bar, initiative) {
|
|
240
|
+
if (e.button !== 0) return;
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
const trackRect = bar.parentElement.getBoundingClientRect();
|
|
243
|
+
const startX = e.clientX;
|
|
244
|
+
const startFraction = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
|
|
245
|
+
const widthFraction = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
|
|
246
|
+
|
|
247
|
+
this.#dragInfo = { kind: 'move', bar, initiative, trackRect, startX, startFraction, widthFraction };
|
|
248
|
+
bar.setPointerCapture(e.pointerId);
|
|
249
|
+
bar.addEventListener('pointermove', this.#onPointerMove);
|
|
250
|
+
bar.addEventListener('pointerup', this.#onPointerUp);
|
|
251
|
+
bar.addEventListener('pointercancel', this.#onPointerUp);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#startResize(e, bar, initiative) {
|
|
255
|
+
if (e.button !== 0) return;
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
e.stopPropagation();
|
|
258
|
+
const trackRect = bar.parentElement.getBoundingClientRect();
|
|
259
|
+
const startX = e.clientX;
|
|
260
|
+
const startFraction = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
|
|
261
|
+
const widthFraction = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
|
|
262
|
+
|
|
263
|
+
this.#dragInfo = { kind: 'resize', bar, initiative, trackRect, startX, startFraction, widthFraction };
|
|
264
|
+
bar.setPointerCapture(e.pointerId);
|
|
265
|
+
bar.addEventListener('pointermove', this.#onPointerMove);
|
|
266
|
+
bar.addEventListener('pointerup', this.#onPointerUp);
|
|
267
|
+
bar.addEventListener('pointercancel', this.#onPointerUp);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#onPointerMove = (e) => {
|
|
271
|
+
if (!this.#dragInfo) return;
|
|
272
|
+
const { kind, bar, trackRect, startX, startFraction, widthFraction } = this.#dragInfo;
|
|
273
|
+
const dxFraction = (e.clientX - startX) / trackRect.width;
|
|
274
|
+
|
|
275
|
+
if (kind === 'move') {
|
|
276
|
+
const newStart = Math.max(0, Math.min(1 - widthFraction, startFraction + dxFraction));
|
|
277
|
+
bar.style.setProperty('--rm-x', newStart.toFixed(4));
|
|
278
|
+
} else if (kind === 'resize') {
|
|
279
|
+
const newWidth = Math.max(0.02, Math.min(1 - startFraction, widthFraction + dxFraction));
|
|
280
|
+
bar.style.setProperty('--rm-w', newWidth.toFixed(4));
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
#onPointerUp = (e) => {
|
|
285
|
+
if (!this.#dragInfo) return;
|
|
286
|
+
const { kind, bar, initiative } = this.#dragInfo;
|
|
287
|
+
bar.releasePointerCapture(e.pointerId);
|
|
288
|
+
bar.removeEventListener('pointermove', this.#onPointerMove);
|
|
289
|
+
bar.removeEventListener('pointerup', this.#onPointerUp);
|
|
290
|
+
bar.removeEventListener('pointercancel', this.#onPointerUp);
|
|
291
|
+
|
|
292
|
+
/* Snap to grid (quarter or month boundary) and update dates. */
|
|
293
|
+
const xFrac = parseFloat(bar.style.getPropertyValue('--rm-x')) || 0;
|
|
294
|
+
const wFrac = parseFloat(bar.style.getPropertyValue('--rm-w')) || 0;
|
|
295
|
+
const snappedStart = this.#snapDate(this.#dateForFraction(xFrac));
|
|
296
|
+
const snappedEnd = this.#snapDate(this.#dateForFraction(xFrac + wFrac));
|
|
297
|
+
|
|
298
|
+
bar.dataset.start = Roadmap.#toISO(snappedStart);
|
|
299
|
+
bar.dataset.end = Roadmap.#toISO(snappedEnd);
|
|
300
|
+
/* Re-snap visual to grid after drag. */
|
|
301
|
+
const snappedX = this.#fractionFor(snappedStart);
|
|
302
|
+
const snappedW = this.#fractionFor(snappedEnd) - snappedX;
|
|
303
|
+
bar.style.setProperty('--rm-x', snappedX.toFixed(4));
|
|
304
|
+
bar.style.setProperty('--rm-w', snappedW.toFixed(4));
|
|
305
|
+
|
|
306
|
+
/* Mirror back to the source <article> so the author's data is updated. */
|
|
307
|
+
if (initiative) {
|
|
308
|
+
initiative.setAttribute('data-start', bar.dataset.start ?? '');
|
|
309
|
+
initiative.setAttribute('data-end', bar.dataset.end ?? '');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.dispatchEvent(new CustomEvent(kind === 'move' ? 'product-roadmap:reschedule' : 'product-roadmap:resize', {
|
|
313
|
+
bubbles: true,
|
|
314
|
+
detail: {
|
|
315
|
+
initiative,
|
|
316
|
+
lane: bar.dataset.lane,
|
|
317
|
+
start: bar.dataset.start,
|
|
318
|
+
end: bar.dataset.end,
|
|
319
|
+
},
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
/* Clear dragInfo on next tick so any pending click event sees it
|
|
323
|
+
and skips select. */
|
|
324
|
+
setTimeout(() => { this.#dragInfo = null; }, 0);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
#onTrackDragOver = (e) => {
|
|
328
|
+
if (!this.#draggingInitiative) return;
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
e.dataTransfer.dropEffect = 'move';
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
#onTrackDrop = (e) => {
|
|
334
|
+
if (!this.#draggingInitiative) return;
|
|
335
|
+
e.preventDefault();
|
|
336
|
+
const targetLane = e.currentTarget.dataset.lane;
|
|
337
|
+
const initiative = this.#draggingInitiative;
|
|
338
|
+
const fromLane = this.#draggingFromLane;
|
|
339
|
+
if (targetLane === fromLane) return;
|
|
340
|
+
|
|
341
|
+
/* Move the source <article> to the new <section data-lane>. */
|
|
342
|
+
const newSourceLane = this.#sourceLanes.find((l) => l.getAttribute('data-lane') === targetLane);
|
|
343
|
+
if (newSourceLane) newSourceLane.appendChild(initiative);
|
|
344
|
+
|
|
345
|
+
this.dispatchEvent(new CustomEvent('product-roadmap:move', {
|
|
346
|
+
bubbles: true,
|
|
347
|
+
detail: {
|
|
348
|
+
initiative,
|
|
349
|
+
fromLane,
|
|
350
|
+
toLane: targetLane,
|
|
351
|
+
start: initiative.getAttribute('data-start'),
|
|
352
|
+
end: initiative.getAttribute('data-end'),
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
|
|
356
|
+
this.#render();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/* --- date math --- */
|
|
360
|
+
#fractionFor(date) {
|
|
361
|
+
if (!this.#start) return 0;
|
|
362
|
+
const ms = date.getTime() - this.#start.getTime();
|
|
363
|
+
return Math.max(0, Math.min(1, ms / this.#totalMs));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#dateForFraction(frac) {
|
|
367
|
+
const start = this.#start ?? new Date();
|
|
368
|
+
return new Date(start.getTime() + frac * this.#totalMs);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#snapDate(date) {
|
|
372
|
+
if (this.#view === 'month') {
|
|
373
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
374
|
+
}
|
|
375
|
+
/* Quarter snap: round to nearest quarter boundary. */
|
|
376
|
+
const month = date.getMonth();
|
|
377
|
+
const qStartMonth = Math.floor(month / 3) * 3;
|
|
378
|
+
return new Date(date.getFullYear(), qStartMonth, 1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#enumerateTicks() {
|
|
382
|
+
const ticks = [];
|
|
383
|
+
const start = this.#start;
|
|
384
|
+
const end = this.#end;
|
|
385
|
+
if (!start || !end) return ticks;
|
|
386
|
+
if (this.#view === 'quarter') {
|
|
387
|
+
let cursor = this.#snapDate(start);
|
|
388
|
+
while (cursor < end) {
|
|
389
|
+
const next = new Date(cursor.getFullYear(), cursor.getMonth() + 3, 1);
|
|
390
|
+
const startFraction = this.#fractionFor(cursor < start ? start : cursor);
|
|
391
|
+
const endFraction = this.#fractionFor(next > end ? end : next);
|
|
392
|
+
const q = Math.floor(cursor.getMonth() / 3) + 1;
|
|
393
|
+
ticks.push({ startFraction, endFraction, label: `Q${q} ${cursor.getFullYear()}` });
|
|
394
|
+
cursor = next;
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
|
398
|
+
while (cursor < end) {
|
|
399
|
+
const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
|
|
400
|
+
const startFraction = this.#fractionFor(cursor < start ? start : cursor);
|
|
401
|
+
const endFraction = this.#fractionFor(next > end ? end : next);
|
|
402
|
+
const m = cursor.toLocaleString(undefined, { month: 'short' });
|
|
403
|
+
ticks.push({ startFraction, endFraction, label: `${m} ${cursor.getFullYear()}` });
|
|
404
|
+
cursor = next;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return ticks;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Parse "2026-Q3", "2026-Q3-end", or ISO date. `kind` decides how
|
|
411
|
+
"YYYY-Qn" without explicit position resolves: 'start' → first day
|
|
412
|
+
of the quarter; 'end' → last day. */
|
|
413
|
+
static #parseDate(value, kind) {
|
|
414
|
+
if (!value) return null;
|
|
415
|
+
const q = String(value).match(/^(\d{4})-Q([1-4])$/i);
|
|
416
|
+
if (q) {
|
|
417
|
+
const year = Number(q[1]);
|
|
418
|
+
const quarter = Number(q[2]);
|
|
419
|
+
const monthStart = (quarter - 1) * 3;
|
|
420
|
+
if (kind === 'end') {
|
|
421
|
+
/* Last day of last month of the quarter */
|
|
422
|
+
return new Date(year, monthStart + 3, 0);
|
|
423
|
+
}
|
|
424
|
+
return new Date(year, monthStart, 1);
|
|
425
|
+
}
|
|
426
|
+
const iso = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
427
|
+
if (iso) return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
|
|
428
|
+
const d = new Date(value);
|
|
429
|
+
return isNaN(d.getTime()) ? null : d;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
static #toISO(d) {
|
|
433
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
434
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
435
|
+
return `${d.getFullYear()}-${m}-${day}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** @type {Date | null} */
|
|
439
|
+
#start = null;
|
|
440
|
+
/** @type {Date | null} */
|
|
441
|
+
#end = null;
|
|
442
|
+
#totalMs = 0;
|
|
443
|
+
/** @type {"quarter" | "month"} */
|
|
444
|
+
#view = 'quarter';
|
|
445
|
+
#editable = false;
|
|
446
|
+
/** @type {Element[]} */
|
|
447
|
+
#sourceLanes = [];
|
|
448
|
+
/** @type {HTMLElement | null} */
|
|
449
|
+
#wrapper = null;
|
|
450
|
+
/** @type {{ kind: string, bar: HTMLElement, initiative?: Element, trackRect: DOMRect, startX: number, startFraction: number, widthFraction: number } | null} */
|
|
451
|
+
#dragInfo = null;
|
|
452
|
+
/** @type {Element | null} */
|
|
453
|
+
#draggingInitiative = null;
|
|
454
|
+
/** @type {Element | null} */
|
|
455
|
+
#draggingFromLane = null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
registerComponent('product-roadmap', Roadmap);
|
|
459
|
+
|
|
460
|
+
export { Roadmap };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Roadmap — Static Fallback</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; padding: 2rem; }
|
|
9
|
+
section { margin-block: 1rem; }
|
|
10
|
+
section h2 { font-size: 0.9rem; text-transform: uppercase; }
|
|
11
|
+
article { padding: 0.5rem; background: #eee; border-radius: 0.25rem; margin-block: 0.25rem; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<!--
|
|
16
|
+
Static fallback for <roadmap>.
|
|
17
|
+
|
|
18
|
+
Upgrade relationship:
|
|
19
|
+
- Without JS: lanes and initiatives render in source order. Each
|
|
20
|
+
<article data-start data-end> is just a card; the time axis +
|
|
21
|
+
bar positioning is purely additive once the component upgrades.
|
|
22
|
+
- With JS: the component reads start/end attributes, builds a
|
|
23
|
+
horizontal time axis (quarter or month), positions each bar by
|
|
24
|
+
its data-start/data-end fraction, and (with editable) wires
|
|
25
|
+
drag-to-reschedule, drag-to-resize, and drop-on-lane.
|
|
26
|
+
-->
|
|
27
|
+
|
|
28
|
+
<product-roadmap start="2026-Q3" end="2027-Q2">
|
|
29
|
+
<section data-lane="Platform">
|
|
30
|
+
<article data-start="2026-Q3" data-end="2026-Q4" data-status="in-progress">
|
|
31
|
+
<h3>Multi-tenant database</h3>
|
|
32
|
+
</article>
|
|
33
|
+
<article data-start="2027-Q1" data-end="2027-Q2" data-status="planned">
|
|
34
|
+
<h3>Edge cache layer</h3>
|
|
35
|
+
</article>
|
|
36
|
+
</section>
|
|
37
|
+
<section data-lane="Growth">
|
|
38
|
+
<article data-start="2026-Q4" data-end="2027-Q1" data-status="planned">
|
|
39
|
+
<h3>Onboarding rewrite</h3>
|
|
40
|
+
</article>
|
|
41
|
+
</section>
|
|
42
|
+
</product-roadmap>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/* roadmap: Interactive themes × quarters product roadmap */
|
|
2
|
+
|
|
3
|
+
product-roadmap:not(:defined) {
|
|
4
|
+
display: block;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
product-roadmap {
|
|
8
|
+
display: block;
|
|
9
|
+
font-family: var(--font-sans, system-ui);
|
|
10
|
+
color: var(--color-text, #1a1a1a);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Hide author-supplied source lanes — the component reads them at
|
|
14
|
+
upgrade and renders its own visual structure. Without this rule
|
|
15
|
+
the raw <article> headings flash before upgrade. */
|
|
16
|
+
product-roadmap > section[data-lane] {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@scope (product-roadmap) {
|
|
21
|
+
.rm-wrapper {
|
|
22
|
+
display: grid;
|
|
23
|
+
grid-template-columns: minmax(8rem, max-content) 1fr;
|
|
24
|
+
gap: var(--size-2xs, 0.25rem);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.rm-axis {
|
|
28
|
+
grid-column: 2;
|
|
29
|
+
position: relative;
|
|
30
|
+
block-size: var(--size-l, 1.5rem);
|
|
31
|
+
border-block-end: var(--border-width-thin, 1px) solid var(--color-border, oklch(85% 0 0));
|
|
32
|
+
margin-block-end: var(--size-2xs, 0.25rem);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.rm-tick {
|
|
36
|
+
position: absolute;
|
|
37
|
+
inset-block: 0;
|
|
38
|
+
inset-inline-start: calc(var(--rm-x) * 100%);
|
|
39
|
+
inline-size: calc(var(--rm-w) * 100%);
|
|
40
|
+
border-inline-end: var(--border-width-thin, 1px) dashed var(--color-border, oklch(85% 0 0));
|
|
41
|
+
padding-inline: var(--size-2xs, 0.25rem);
|
|
42
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
text-transform: uppercase;
|
|
45
|
+
letter-spacing: 0.06em;
|
|
46
|
+
color: var(--color-text-muted, #666);
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
text-overflow: ellipsis;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.rm-lanes {
|
|
53
|
+
grid-column: 1 / -1;
|
|
54
|
+
display: grid;
|
|
55
|
+
grid-template-columns: subgrid;
|
|
56
|
+
gap: var(--size-2xs, 0.25rem);
|
|
57
|
+
position: relative;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.rm-lane {
|
|
61
|
+
display: contents;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.rm-lane-label {
|
|
65
|
+
grid-column: 1;
|
|
66
|
+
font-size: var(--font-size-sm, 0.875rem);
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
color: var(--color-text, currentColor);
|
|
69
|
+
padding: var(--size-xs, 0.5rem) var(--size-s, 0.75rem);
|
|
70
|
+
background: var(--color-surface-raised, oklch(96% 0 0));
|
|
71
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
72
|
+
align-self: stretch;
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.rm-track {
|
|
78
|
+
grid-column: 2;
|
|
79
|
+
position: relative;
|
|
80
|
+
min-block-size: var(--size-2xl, 3rem);
|
|
81
|
+
background:
|
|
82
|
+
repeating-linear-gradient(
|
|
83
|
+
to right,
|
|
84
|
+
var(--color-surface, transparent),
|
|
85
|
+
var(--color-surface, transparent) calc(var(--rm-tick-w, 25%) - 1px),
|
|
86
|
+
var(--color-border, oklch(85% 0 0 / 0.4)) calc(var(--rm-tick-w, 25%) - 1px),
|
|
87
|
+
var(--color-border, oklch(85% 0 0 / 0.4)) var(--rm-tick-w, 25%)
|
|
88
|
+
);
|
|
89
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.rm-bar {
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset-block: var(--size-2xs, 0.25rem);
|
|
95
|
+
inset-inline-start: calc(var(--rm-x) * 100%);
|
|
96
|
+
inline-size: calc(var(--rm-w) * 100%);
|
|
97
|
+
background: var(--color-accent, oklch(45% 0.18 260));
|
|
98
|
+
color: var(--color-text-on-primary, #fff);
|
|
99
|
+
border-radius: var(--radius-s, 0.25rem);
|
|
100
|
+
padding: var(--size-2xs, 0.25rem) var(--size-s, 0.75rem);
|
|
101
|
+
font-size: var(--font-size-sm, 0.875rem);
|
|
102
|
+
line-height: 1.2;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
|
105
|
+
cursor: grab;
|
|
106
|
+
user-select: none;
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: var(--size-2xs, 0.25rem);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.rm-bar:active { cursor: grabbing; }
|
|
113
|
+
|
|
114
|
+
.rm-bar-title {
|
|
115
|
+
flex: 1;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
white-space: nowrap;
|
|
118
|
+
text-overflow: ellipsis;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.rm-bar-handle {
|
|
122
|
+
position: absolute;
|
|
123
|
+
inset-block: 0;
|
|
124
|
+
inset-inline-end: 0;
|
|
125
|
+
inline-size: var(--size-xs, 0.5rem);
|
|
126
|
+
background: rgba(255, 255, 255, 0.25);
|
|
127
|
+
cursor: ew-resize;
|
|
128
|
+
border-start-end-radius: var(--radius-s, 0.25rem);
|
|
129
|
+
border-end-end-radius: var(--radius-s, 0.25rem);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.rm-bar[data-status="planned"] { background: var(--color-info, oklch(60% 0.12 240)); }
|
|
133
|
+
.rm-bar[data-status="in-progress"] { background: var(--color-accent, oklch(50% 0.16 260)); }
|
|
134
|
+
.rm-bar[data-status="at-risk"] { background: var(--color-warning, oklch(65% 0.16 60)); color: var(--color-text, #1a1a1a); }
|
|
135
|
+
.rm-bar[data-status="shipped"] { background: var(--color-success, oklch(55% 0.14 145)); }
|
|
136
|
+
.rm-bar[data-status="blocked"] { background: var(--color-danger, oklch(55% 0.18 25)); }
|
|
137
|
+
|
|
138
|
+
.rm-today {
|
|
139
|
+
position: absolute;
|
|
140
|
+
inset-block: 0;
|
|
141
|
+
inset-inline-start: calc(var(--rm-x) * 100%);
|
|
142
|
+
inline-size: 0;
|
|
143
|
+
border-inline-start: var(--border-width-medium, 2px) solid var(--color-warning, oklch(60% 0.18 60));
|
|
144
|
+
pointer-events: none;
|
|
145
|
+
z-index: 2;
|
|
146
|
+
/* Position the today line within the track column. The lanes use
|
|
147
|
+
subgrid, so we offset by the label column width. */
|
|
148
|
+
transform: translateX(0);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Read-only roadmap: bars don't show grab cursor or resize handle. */
|
|
153
|
+
product-roadmap:not([editable]) .rm-bar { cursor: default; }
|
|
154
|
+
product-roadmap:not([editable]) .rm-bar-handle { display: none; }
|
|
155
|
+
|
|
156
|
+
@media (prefers-reduced-motion: reduce) {
|
|
157
|
+
product-roadmap .rm-bar { transition: none; }
|
|
158
|
+
}
|