@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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/vb-project-planning.css +1603 -0
  4. package/dist/vb-project-planning.js +11138 -0
  5. package/dist/vb-project-planning.min.css +1 -0
  6. package/dist/vb-project-planning.min.js +3312 -0
  7. package/package.json +80 -0
  8. package/src/index.css +14 -0
  9. package/src/index.js +26 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/diff-by-key.js +86 -0
  12. package/src/lib/portrait-url.js +87 -0
  13. package/src/lib/vb-collection.js +122 -0
  14. package/src/lib/vb-element.js +96 -0
  15. package/src/lib/vb-store.js +200 -0
  16. package/src/lib/vb-view-transition.js +44 -0
  17. package/src/utils/copy-init.js +287 -0
  18. package/src/web-components/_ux-base.js +106 -0
  19. package/src/web-components/adr-wc/README.md +77 -0
  20. package/src/web-components/adr-wc/api.json +26 -0
  21. package/src/web-components/adr-wc/logic.js +376 -0
  22. package/src/web-components/adr-wc/static.html +17 -0
  23. package/src/web-components/adr-wc/styles.js +338 -0
  24. package/src/web-components/burndown-chart/api.json +74 -0
  25. package/src/web-components/burndown-chart/logic.js +198 -0
  26. package/src/web-components/burndown-chart/static.html +31 -0
  27. package/src/web-components/burndown-chart/styles.css +59 -0
  28. package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
  29. package/src/web-components/capacity-plan/api.json +20 -0
  30. package/src/web-components/capacity-plan/logic.js +222 -0
  31. package/src/web-components/capacity-plan/static.html +24 -0
  32. package/src/web-components/capacity-plan/styles.css +84 -0
  33. package/src/web-components/empathy-map/api.json +31 -0
  34. package/src/web-components/empathy-map/logic.js +502 -0
  35. package/src/web-components/empathy-map/static.html +32 -0
  36. package/src/web-components/empathy-map/styles.js +534 -0
  37. package/src/web-components/gantt-chart/README.md +91 -0
  38. package/src/web-components/gantt-chart/api.json +20 -0
  39. package/src/web-components/gantt-chart/logic.js +812 -0
  40. package/src/web-components/gantt-chart/static.html +60 -0
  41. package/src/web-components/gantt-chart/styles.css +391 -0
  42. package/src/web-components/impact-effort/api.json +21 -0
  43. package/src/web-components/impact-effort/logic.js +392 -0
  44. package/src/web-components/impact-effort/static.html +23 -0
  45. package/src/web-components/impact-effort/styles.css +193 -0
  46. package/src/web-components/iron-triangle/_capacity.js +83 -0
  47. package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
  48. package/src/web-components/iron-triangle/api.json +26 -0
  49. package/src/web-components/iron-triangle/logic.js +474 -0
  50. package/src/web-components/iron-triangle/static.html +28 -0
  51. package/src/web-components/iron-triangle/styles.css +158 -0
  52. package/src/web-components/kanban-board/api.json +24 -0
  53. package/src/web-components/kanban-board/logic.js +698 -0
  54. package/src/web-components/kanban-board/static.html +20 -0
  55. package/src/web-components/kanban-board/styles.css +242 -0
  56. package/src/web-components/product-roadmap/api.json +118 -0
  57. package/src/web-components/product-roadmap/logic.js +460 -0
  58. package/src/web-components/product-roadmap/static.html +44 -0
  59. package/src/web-components/product-roadmap/styles.css +158 -0
  60. package/src/web-components/quadrant-grid/api.json +125 -0
  61. package/src/web-components/quadrant-grid/logic.js +397 -0
  62. package/src/web-components/quadrant-grid/static.html +49 -0
  63. package/src/web-components/quadrant-grid/styles.css +184 -0
  64. package/src/web-components/quality-target/_quality-utils.js +460 -0
  65. package/src/web-components/quality-target/api.json +30 -0
  66. package/src/web-components/quality-target/logic.js +516 -0
  67. package/src/web-components/quality-target/static.html +22 -0
  68. package/src/web-components/quality-target/styles.css +203 -0
  69. package/src/web-components/requirement-card/api.json +25 -0
  70. package/src/web-components/requirement-card/logic.js +166 -0
  71. package/src/web-components/requirement-card/static.html +36 -0
  72. package/src/web-components/requirement-card/styles.js +114 -0
  73. package/src/web-components/review-surface/README.md +178 -0
  74. package/src/web-components/review-surface/api.json +30 -0
  75. package/src/web-components/review-surface/logic.js +807 -0
  76. package/src/web-components/review-surface/static.html +33 -0
  77. package/src/web-components/review-surface/styles.js +459 -0
  78. package/src/web-components/risk-register/api.json +47 -0
  79. package/src/web-components/risk-register/logic.js +180 -0
  80. package/src/web-components/risk-register/static.html +46 -0
  81. package/src/web-components/risk-register/styles.css +37 -0
  82. package/src/web-components/story-map/api.json +22 -0
  83. package/src/web-components/story-map/logic.js +445 -0
  84. package/src/web-components/story-map/static.html +35 -0
  85. package/src/web-components/story-map/styles.css +169 -0
  86. package/src/web-components/traceability-matrix/api.json +84 -0
  87. package/src/web-components/traceability-matrix/logic.js +243 -0
  88. package/src/web-components/traceability-matrix/static.html +41 -0
  89. package/src/web-components/traceability-matrix/styles.css +94 -0
  90. package/src/web-components/user-journey/api.json +84 -0
  91. package/src/web-components/user-journey/logic.js +368 -0
  92. package/src/web-components/user-journey/static.html +14 -0
  93. package/src/web-components/user-journey/styles.js +317 -0
  94. package/src/web-components/user-persona/api.json +35 -0
  95. package/src/web-components/user-persona/logic.js +502 -0
  96. package/src/web-components/user-persona/static.html +20 -0
  97. package/src/web-components/user-persona/styles.js +299 -0
  98. package/src/web-components/user-story/api.json +35 -0
  99. package/src/web-components/user-story/logic.js +567 -0
  100. package/src/web-components/user-story/static.html +27 -0
  101. package/src/web-components/user-story/styles.js +332 -0
  102. package/src/web-components/work-item/README.md +85 -0
  103. package/src/web-components/work-item/api.json +30 -0
  104. package/src/web-components/work-item/logic.js +414 -0
  105. package/src/web-components/work-item/static.html +24 -0
  106. package/src/web-components/work-item/styles.js +376 -0
@@ -0,0 +1,812 @@
1
+ /**
2
+ * <gantt-chart> — HTML-first project schedule visualization
3
+ *
4
+ * Progressively enhances a semantic <table> with <time>, <progress>,
5
+ * and <data> elements into an interactive Gantt timeline.
6
+ *
7
+ * Without JS: renders as a readable data table with progress bars.
8
+ * With JS: visual timeline with bars, groups, milestones, and today line.
9
+ *
10
+ * @attr {string} title - Chart heading
11
+ * @attr {string} src - URL to JSON task data
12
+ * @attr {string} view - Timeline granularity: auto|day|week|month|quarter
13
+ * @attr {boolean} show-today - Show vertical today marker
14
+ * @attr {boolean} show-progress - Show progress fill in bars
15
+ * @attr {boolean} show-dependencies - Draw dependency arrows
16
+ * @attr {boolean} compact - Reduced spacing variant
17
+ *
18
+ * Child <tr> attributes:
19
+ * @attr {string} data-task-id - Unique task identifier
20
+ * @attr {string} data-group - Swim lane group
21
+ * @attr {string} data-depends - Comma-separated dependency task IDs
22
+ * @attr {string} data-status - not-started|in-progress|done|blocked
23
+ * @attr {string} data-assignee - Assignee name
24
+ * @attr {boolean} data-milestone - Render as diamond instead of bar
25
+ * @attr {string} data-color - Custom bar color
26
+ * @attr {string} data-story-ids - Comma-separated linked user-story IDs
27
+ * @attr {string} data-item-ids - Comma-separated linked work-item IDs
28
+ *
29
+ * @fires gantt-chart:ready - After initial render
30
+ * @fires gantt-chart:task-click - Task bar clicked
31
+ */
32
+
33
+ import { registerComponent } from '../../lib/bundle-registry.js';
34
+ import { VBElement } from '../../lib/vb-element.js';
35
+
36
+ /* ── Date helpers (adapted from chart-wc TimeScale) ── */
37
+
38
+ const DAY = 86_400_000;
39
+ const WEEK = DAY * 7;
40
+ const MONTH = DAY * 30;
41
+ const QUARTER = DAY * 91;
42
+ const YEAR = DAY * 365;
43
+
44
+ function toTimestamp(value) {
45
+ if (typeof value === 'number') return value;
46
+ const d = new Date(value);
47
+ return d.getTime();
48
+ }
49
+
50
+ function pickFormatter(rangeMs) {
51
+ if (rangeMs <= WEEK * 3) {
52
+ return (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
53
+ }
54
+ if (rangeMs <= MONTH * 3) {
55
+ return (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
56
+ }
57
+ if (rangeMs <= YEAR) {
58
+ return (d) => d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
59
+ }
60
+ return (d) => d.toLocaleDateString(undefined, { year: 'numeric' });
61
+ }
62
+
63
+ function resolveView(rangeMs) {
64
+ if (rangeMs <= WEEK * 3) return 'day';
65
+ if (rangeMs <= MONTH * 3) return 'week';
66
+ if (rangeMs <= YEAR) return 'month';
67
+ return 'quarter';
68
+ }
69
+
70
+ function esc(s) {
71
+ return String(s)
72
+ .replace(/&/g, '&amp;')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;')
75
+ .replace(/"/g, '&quot;');
76
+ }
77
+
78
+ /* ── Component ─────────────────────────────────────── */
79
+
80
+ class GanttChart extends VBElement {
81
+ static get observedAttributes() {
82
+ return ['src', 'title', 'view', 'show-today', 'show-progress', 'show-dependencies', 'compact'];
83
+ }
84
+
85
+ /** @type {HTMLDivElement|null} */
86
+ #container = null;
87
+ /** @type {HTMLDivElement|null} */
88
+ #liveRegion = null;
89
+ /** @type {HTMLElement|null} */
90
+ #titleEl = null;
91
+ /** @type {Array<Object>} */
92
+ #tasks = [];
93
+ /** @type {number} */
94
+ #rangeStart = 0;
95
+ /** @type {number} */
96
+ #rangeEnd = 0;
97
+ /** @type {number} */
98
+ #rangeTotal = 0;
99
+
100
+ setup() {
101
+ const table = this.querySelector(':scope > table');
102
+ if (!table) return false;
103
+
104
+ // 1. Parse tasks from table
105
+ this.#tasks = this.#parseTasks(table);
106
+ if (this.#tasks.length === 0) return false;
107
+
108
+ // 2. Calculate date range
109
+ this.#rangeStart = Math.min(...this.#tasks.map(t => t.start));
110
+ this.#rangeEnd = Math.max(...this.#tasks.map(t => t.end));
111
+ this.#rangeTotal = this.#rangeEnd - this.#rangeStart;
112
+ if (this.#rangeTotal <= 0) this.#rangeTotal = DAY; // minimum 1 day
113
+
114
+ // 3. Build enhanced DOM
115
+ this.#build(table);
116
+
117
+ // 4. Dispatch ready
118
+ this.dispatchEvent(new CustomEvent('gantt-chart:ready', {
119
+ bubbles: true,
120
+ detail: {
121
+ taskCount: this.#tasks.length,
122
+ dateRange: {
123
+ start: new Date(this.#rangeStart).toISOString(),
124
+ end: new Date(this.#rangeEnd).toISOString(),
125
+ },
126
+ },
127
+ }));
128
+ return true;
129
+ }
130
+
131
+ teardown() {
132
+ if (this.#titleEl) { this.#titleEl.remove(); this.#titleEl = null; }
133
+ if (this.#container) { this.#container.remove(); this.#container = null; }
134
+ if (this.#liveRegion) { this.#liveRegion.remove(); this.#liveRegion = null; }
135
+
136
+ // Restore the table visibility
137
+ const table = this.querySelector('table.gc-sr-only');
138
+ if (table) table.classList.remove('gc-sr-only');
139
+
140
+ this.#tasks = [];
141
+ }
142
+
143
+ /** @type {number} */
144
+ static #vtCounter = 0;
145
+
146
+ attributeChangedCallback(name, oldValue, newValue) {
147
+ if (oldValue === newValue || !this.isConnected) return;
148
+ if (name === 'src') {
149
+ this._loadSrc(newValue);
150
+ } else if (this.hasAttribute('data-upgraded')) {
151
+ this.#refresh();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Tear down and re-setup, wrapped in a View Transition so the swap
157
+ * crossfades instead of flashing. Same pattern as diagram-wc and
158
+ * site-map.
159
+ */
160
+ #refresh() {
161
+ const swap = () => {
162
+ this.teardown();
163
+ this.removeAttribute('data-upgraded');
164
+ this.setup();
165
+ };
166
+ if (this.hasAttribute('data-upgraded')
167
+ && 'startViewTransition' in document
168
+ && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
169
+ const name = `gc-vt-${++GanttChart.#vtCounter}`;
170
+ this.style.viewTransitionName = name;
171
+ const tx = document.startViewTransition(swap);
172
+ tx.finished.finally(() => { this.style.viewTransitionName = ''; });
173
+ } else {
174
+ swap();
175
+ }
176
+ }
177
+
178
+ // ── Data API (HTML-first / JS-first dual contract) ──────────────
179
+
180
+ /**
181
+ * The current task list. After upgrade this reflects the parsed
182
+ * <table> rows; after assignment, it reflects whatever was passed in.
183
+ * Each entry: { id, name, start, end, progress?, group?, depends?,
184
+ * status?, assignee?, milestone?, color?, storyIds?, itemIds? }.
185
+ */
186
+ get tasks() { return this.#tasks; }
187
+
188
+ /**
189
+ * Replace the task set and re-render. Accepts plain task objects with
190
+ * start/end as ISO strings or timestamps; both are normalized.
191
+ *
192
+ * Note: v1 is record-shaped (full rebuild on assignment). Per-bar
193
+ * preservation across diffs is on the roadmap for when drag-resize
194
+ * state preservation becomes a felt need.
195
+ */
196
+ set tasks(value) {
197
+ const next = (value || []).map((t, i) => ({
198
+ id: t.id ?? `gc-task-${i}`,
199
+ name: t.name ?? `Task ${i + 1}`,
200
+ start: typeof t.start === 'number' ? t.start : toTimestamp(t.start),
201
+ end: typeof t.end === 'number' ? t.end : (t.end != null ? toTimestamp(t.end) : (typeof t.start === 'number' ? t.start : toTimestamp(t.start))),
202
+ progress: t.progress ?? 0,
203
+ group: t.group ?? null,
204
+ depends: Array.isArray(t.depends) ? t.depends : (t.depends ? String(t.depends).split(',').map(s => s.trim()) : []),
205
+ status: t.status ?? null,
206
+ assignee: t.assignee ?? null,
207
+ milestone: !!t.milestone,
208
+ color: t.color ?? null,
209
+ storyIds: t.storyIds ?? [],
210
+ itemIds: t.itemIds ?? [],
211
+ }));
212
+
213
+ // Tear down existing chrome and re-render from the new task list.
214
+ if (this.#container || this.#titleEl || this.#liveRegion) {
215
+ if (this.#titleEl) { this.#titleEl.remove(); this.#titleEl = null; }
216
+ if (this.#container) { this.#container.remove(); this.#container = null; }
217
+ if (this.#liveRegion) { this.#liveRegion.remove(); this.#liveRegion = null; }
218
+ }
219
+
220
+ this.#tasks = next;
221
+
222
+ if (next.length > 0) {
223
+ this.#rangeStart = Math.min(...next.map(t => t.start));
224
+ this.#rangeEnd = Math.max(...next.map(t => t.end));
225
+ this.#rangeTotal = this.#rangeEnd - this.#rangeStart;
226
+ if (this.#rangeTotal <= 0) this.#rangeTotal = DAY;
227
+
228
+ // Source <table> may not exist (JS-first scaffold). Find or create one
229
+ // for the build path; we hide it via gc-sr-only either way.
230
+ let table = this.querySelector(':scope > table');
231
+ if (!table) {
232
+ table = document.createElement('table');
233
+ table.classList.add('gc-sr-only');
234
+ this.appendChild(table);
235
+ }
236
+ this.#build(table);
237
+ }
238
+
239
+ this.dispatchEvent(new CustomEvent('gantt-chart:tasks-changed', {
240
+ detail: { tasks: next, source: 'property' },
241
+ bubbles: true,
242
+ }));
243
+ }
244
+
245
+ /* ── Table parsing ─────────────────────────────── */
246
+
247
+ #parseTasks(table) {
248
+ const rows = table.querySelectorAll('tbody tr');
249
+ const tasks = [];
250
+
251
+ for (let i = 0; i < rows.length; i++) {
252
+ const row = /** @type {HTMLTableRowElement} */ (rows[i]);
253
+ const times = row.querySelectorAll('time[datetime]');
254
+ if (times.length < 2 && !row.hasAttribute('data-milestone')) continue;
255
+
256
+ const startStr = times[0]?.getAttribute('datetime');
257
+ const endStr = times.length > 1 ? times[1].getAttribute('datetime') : startStr;
258
+ if (!startStr) continue;
259
+
260
+ const progressEl = row.querySelector('progress');
261
+ const progressValue = progressEl ? (progressEl.value / (progressEl.max || 100)) : 0;
262
+
263
+ const name = row.cells[0]?.textContent?.trim() || `Task ${i + 1}`;
264
+
265
+ tasks.push({
266
+ id: row.dataset.taskId || `gc-task-${i}`,
267
+ name,
268
+ start: toTimestamp(startStr),
269
+ end: toTimestamp(endStr),
270
+ progress: progressValue,
271
+ group: row.dataset.group || null,
272
+ depends: row.dataset.depends ? row.dataset.depends.split(',').map(s => s.trim()) : [],
273
+ status: row.dataset.status || null,
274
+ assignee: row.dataset.assignee || null,
275
+ milestone: row.hasAttribute('data-milestone'),
276
+ color: row.dataset.color || null,
277
+ storyIds: row.dataset.storyIds ? row.dataset.storyIds.split(',').map(s => s.trim()) : [],
278
+ itemIds: row.dataset.itemIds ? row.dataset.itemIds.split(',').map(s => s.trim()) : [],
279
+ });
280
+ }
281
+
282
+ return tasks;
283
+ }
284
+
285
+ /* ── Build enhanced DOM ────────────────────────── */
286
+
287
+ #build(table) {
288
+ const title = this.getAttribute('title');
289
+ const showToday = this.hasAttribute('show-today');
290
+ const showProgress = this.hasAttribute('show-progress');
291
+ const showDeps = this.hasAttribute('show-dependencies');
292
+
293
+ // Title
294
+ if (title) {
295
+ this.#titleEl = document.createElement('h2');
296
+ this.#titleEl.className = 'gc-title';
297
+ this.#titleEl.textContent = title;
298
+ this.insertBefore(this.#titleEl, table);
299
+ }
300
+
301
+ // Container
302
+ this.#container = document.createElement('div');
303
+ this.#container.className = 'gc-container';
304
+ this.#container.setAttribute('role', 'region');
305
+ this.#container.setAttribute('aria-label', `Gantt chart${title ? ': ' + title : ''}`);
306
+
307
+ // Timeline header
308
+ const header = this.#buildTimelineHeader();
309
+ this.#container.appendChild(header);
310
+
311
+ // Body: task list + bars
312
+ const body = document.createElement('div');
313
+ body.className = 'gc-body';
314
+
315
+ const taskList = document.createElement('div');
316
+ taskList.className = 'gc-task-list';
317
+
318
+ const barsPanel = document.createElement('div');
319
+ barsPanel.className = 'gc-bars';
320
+
321
+ // Grid lines
322
+ const gridLines = this.#buildGridLines();
323
+ barsPanel.appendChild(gridLines);
324
+
325
+ // Group tasks
326
+ const grouped = this.#groupTasks();
327
+
328
+ for (const [groupName, groupTasks] of grouped) {
329
+ // Group header (if there are multiple groups)
330
+ if (grouped.size > 1 && groupName) {
331
+ const groupHeader = document.createElement('div');
332
+ groupHeader.className = 'gc-group-header';
333
+ const label = document.createElement('span');
334
+ label.className = 'gc-group-label';
335
+ label.textContent = groupName;
336
+ groupHeader.appendChild(label);
337
+ taskList.appendChild(groupHeader);
338
+
339
+ const spacer = document.createElement('div');
340
+ spacer.className = 'gc-group-spacer';
341
+ barsPanel.appendChild(spacer);
342
+ }
343
+
344
+ for (const task of groupTasks) {
345
+ // Task row (left)
346
+ const taskRow = document.createElement('div');
347
+ taskRow.className = 'gc-task-row';
348
+ taskRow.setAttribute('data-task-id', task.id);
349
+
350
+ const taskName = document.createElement('span');
351
+ taskName.className = 'gc-task-name';
352
+ taskName.textContent = task.name;
353
+ taskRow.appendChild(taskName);
354
+
355
+ // SR-only date range
356
+ const taskDates = document.createElement('span');
357
+ taskDates.className = 'gc-task-dates';
358
+ taskDates.textContent = `${new Date(task.start).toLocaleDateString()} to ${new Date(task.end).toLocaleDateString()}`;
359
+ taskRow.appendChild(taskDates);
360
+
361
+ taskList.appendChild(taskRow);
362
+
363
+ // Bar row (right)
364
+ const barRow = document.createElement('div');
365
+ barRow.className = 'gc-bar-row';
366
+
367
+ if (task.milestone) {
368
+ barRow.appendChild(this.#buildMilestone(task));
369
+ } else {
370
+ barRow.appendChild(this.#buildBar(task, showProgress));
371
+ }
372
+
373
+ barsPanel.appendChild(barRow);
374
+ }
375
+ }
376
+
377
+ // Today line
378
+ if (showToday) {
379
+ const todayLine = this.#buildTodayLine();
380
+ if (todayLine) barsPanel.appendChild(todayLine);
381
+ }
382
+
383
+ // Dependency arrows
384
+ if (showDeps) {
385
+ // Defer to after DOM is in place for position calculation
386
+ requestAnimationFrame(() => {
387
+ const svg = this.#buildDependencyArrows(barsPanel);
388
+ if (svg) barsPanel.appendChild(svg);
389
+ });
390
+ }
391
+
392
+ body.appendChild(taskList);
393
+ body.appendChild(barsPanel);
394
+ this.#container.appendChild(body);
395
+
396
+ // Insert container before table
397
+ this.insertBefore(this.#container, table);
398
+
399
+ // Hide original table (keep for screen readers)
400
+ table.classList.add('gc-sr-only');
401
+
402
+ // Live region
403
+ this.#liveRegion = document.createElement('div');
404
+ this.#liveRegion.className = 'gc-sr-only';
405
+ this.#liveRegion.setAttribute('role', 'status');
406
+ this.#liveRegion.setAttribute('aria-live', 'polite');
407
+ this.#liveRegion.setAttribute('aria-atomic', 'true');
408
+ this.appendChild(this.#liveRegion);
409
+
410
+ // Event listeners
411
+ this.listen(this.#container, 'click', (e) => {
412
+ const bar = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.gc-bar, .gc-milestone'));
413
+ if (!bar) return;
414
+ const taskId = bar.dataset.taskId;
415
+ const task = this.#tasks.find(t => t.id === taskId);
416
+ if (!task) return;
417
+
418
+ this.#announce(`Selected: ${task.name}`);
419
+ this.dispatchEvent(new CustomEvent('gantt-chart:task-click', {
420
+ bubbles: true,
421
+ detail: { task },
422
+ }));
423
+ });
424
+
425
+ this.listen(this.#container, 'keydown', (e) => {
426
+ const ke = /** @type {KeyboardEvent} */ (e);
427
+ if (ke.key === 'Enter' || ke.key === ' ') {
428
+ const bar = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (ke.target).closest('.gc-bar, .gc-milestone'));
429
+ if (bar) {
430
+ ke.preventDefault();
431
+ bar.click();
432
+ }
433
+ }
434
+ });
435
+ }
436
+
437
+ /* ── Timeline header ───────────────────────────── */
438
+
439
+ #buildTimelineHeader() {
440
+ const header = document.createElement('div');
441
+ header.className = 'gc-timeline-header';
442
+
443
+ // Spacer matching task list width
444
+ const spacer = document.createElement('div');
445
+ spacer.className = 'gc-timeline-spacer';
446
+ header.appendChild(spacer);
447
+
448
+ // Date labels
449
+ const dates = document.createElement('div');
450
+ dates.className = 'gc-timeline-dates';
451
+
452
+ const labels = this.#generateDateLabels();
453
+ for (const { position, text } of labels) {
454
+ const label = document.createElement('span');
455
+ label.className = 'gc-date-label';
456
+ label.style.left = `${position}%`;
457
+ label.textContent = text;
458
+ dates.appendChild(label);
459
+ }
460
+
461
+ header.appendChild(dates);
462
+ return header;
463
+ }
464
+
465
+ /* ── Date labels ───────────────────────────────── */
466
+
467
+ #generateDateLabels() {
468
+ const viewAttr = this.getAttribute('view') || 'auto';
469
+ const view = viewAttr === 'auto' ? resolveView(this.#rangeTotal) : viewAttr;
470
+ const formatter = pickFormatter(this.#rangeTotal);
471
+ const labels = [];
472
+
473
+ let stepMs;
474
+ switch (view) {
475
+ case 'day': stepMs = DAY; break;
476
+ case 'week': stepMs = WEEK; break;
477
+ case 'month': stepMs = MONTH; break;
478
+ case 'quarter': stepMs = QUARTER; break;
479
+ default: stepMs = MONTH;
480
+ }
481
+
482
+ // Align to calendar boundaries
483
+ let cursor = new Date(this.#rangeStart);
484
+ if (view === 'week') {
485
+ // Align to Monday
486
+ const dayOfWeek = cursor.getDay();
487
+ const daysToMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
488
+ cursor.setDate(cursor.getDate() + daysToMonday);
489
+ } else if (view === 'month') {
490
+ cursor.setDate(1);
491
+ if (cursor.getTime() < this.#rangeStart) {
492
+ cursor.setMonth(cursor.getMonth() + 1);
493
+ }
494
+ } else if (view === 'quarter') {
495
+ const month = cursor.getMonth();
496
+ const nextQuarterMonth = Math.ceil((month + 1) / 3) * 3;
497
+ cursor.setMonth(nextQuarterMonth);
498
+ cursor.setDate(1);
499
+ }
500
+
501
+ const cursorTs = cursor.getTime();
502
+ // Add first label at start if it's before the first aligned cursor
503
+ if (cursorTs > this.#rangeStart + stepMs * 0.5) {
504
+ labels.push({
505
+ position: 0,
506
+ text: formatter(new Date(this.#rangeStart)),
507
+ });
508
+ }
509
+
510
+ let current = cursorTs;
511
+ while (current <= this.#rangeEnd) {
512
+ const pct = ((current - this.#rangeStart) / this.#rangeTotal) * 100;
513
+ if (pct >= 0 && pct <= 100) {
514
+ labels.push({
515
+ position: pct,
516
+ text: formatter(new Date(current)),
517
+ });
518
+ }
519
+
520
+ // Step forward
521
+ if (view === 'month') {
522
+ const d = new Date(current);
523
+ d.setMonth(d.getMonth() + 1);
524
+ current = d.getTime();
525
+ } else if (view === 'quarter') {
526
+ const d = new Date(current);
527
+ d.setMonth(d.getMonth() + 3);
528
+ current = d.getTime();
529
+ } else {
530
+ current += stepMs;
531
+ }
532
+ }
533
+
534
+ return labels;
535
+ }
536
+
537
+ /* ── Grid lines ────────────────────────────────── */
538
+
539
+ #buildGridLines() {
540
+ const container = document.createElement('div');
541
+ container.className = 'gc-grid-lines';
542
+
543
+ const labels = this.#generateDateLabels();
544
+ for (const { position } of labels) {
545
+ if (position <= 0) continue;
546
+ const line = document.createElement('div');
547
+ line.className = 'gc-grid-line';
548
+ line.style.left = `${position}%`;
549
+ container.appendChild(line);
550
+ }
551
+
552
+ return container;
553
+ }
554
+
555
+ /* ── Group tasks ───────────────────────────────── */
556
+
557
+ #groupTasks() {
558
+ /** @type {Map<string|null, Object[]>} */
559
+ const groups = new Map();
560
+
561
+ for (const task of this.#tasks) {
562
+ const key = task.group || '';
563
+ if (!groups.has(key)) groups.set(key, []);
564
+ groups.get(key)?.push(task);
565
+ }
566
+
567
+ return groups;
568
+ }
569
+
570
+ /* ── Build bar ─────────────────────────────────── */
571
+
572
+ #buildBar(task, showProgress) {
573
+ const leftPct = ((task.start - this.#rangeStart) / this.#rangeTotal) * 100;
574
+ const widthPct = ((task.end - task.start) / this.#rangeTotal) * 100;
575
+
576
+ const bar = document.createElement('div');
577
+ bar.className = 'gc-bar';
578
+ bar.setAttribute('data-task-id', task.id);
579
+ bar.setAttribute('role', 'img');
580
+ bar.setAttribute('tabindex', '0');
581
+ bar.setAttribute('aria-label',
582
+ `${esc(task.name)}: ${new Date(task.start).toLocaleDateString()} to ${new Date(task.end).toLocaleDateString()}` +
583
+ (task.progress > 0 ? `, ${Math.round(task.progress * 100)}% complete` : '')
584
+ );
585
+
586
+ bar.style.left = `${leftPct}%`;
587
+ bar.style.width = `${Math.max(widthPct, 0.5)}%`;
588
+
589
+ if (task.status) bar.setAttribute('data-status', task.status);
590
+ if (task.color) bar.style.setProperty('--gc-bar-color', task.color);
591
+
592
+ // Progress fill
593
+ if (showProgress && task.progress > 0) {
594
+ const fill = document.createElement('div');
595
+ fill.className = 'gc-bar-fill';
596
+ fill.style.width = `${Math.round(task.progress * 100)}%`;
597
+ bar.appendChild(fill);
598
+ }
599
+
600
+ // Label (show name if bar is wide enough)
601
+ if (widthPct > 8) {
602
+ const label = document.createElement('span');
603
+ label.className = 'gc-bar-label';
604
+ label.textContent = task.name;
605
+ bar.appendChild(label);
606
+ }
607
+
608
+ return bar;
609
+ }
610
+
611
+ /* ── Build milestone ───────────────────────────── */
612
+
613
+ #buildMilestone(task) {
614
+ const leftPct = ((task.start - this.#rangeStart) / this.#rangeTotal) * 100;
615
+
616
+ const diamond = document.createElement('div');
617
+ diamond.className = 'gc-milestone';
618
+ diamond.setAttribute('data-task-id', task.id);
619
+ diamond.setAttribute('role', 'img');
620
+ diamond.setAttribute('tabindex', '0');
621
+ diamond.setAttribute('aria-label', `Milestone: ${esc(task.name)}, ${new Date(task.start).toLocaleDateString()}`);
622
+ diamond.style.left = `${leftPct}%`;
623
+
624
+ if (task.status) diamond.setAttribute('data-status', task.status);
625
+ if (task.color) diamond.style.setProperty('--gc-bar-color', task.color);
626
+
627
+ return diamond;
628
+ }
629
+
630
+ /* ── Today line ────────────────────────────────── */
631
+
632
+ #buildTodayLine() {
633
+ const now = Date.now();
634
+ if (now < this.#rangeStart || now > this.#rangeEnd) return null;
635
+
636
+ const pct = ((now - this.#rangeStart) / this.#rangeTotal) * 100;
637
+
638
+ const line = document.createElement('div');
639
+ line.className = 'gc-today-line';
640
+ line.style.left = `${pct}%`;
641
+
642
+ const label = document.createElement('span');
643
+ label.className = 'gc-today-label';
644
+ label.textContent = 'Today';
645
+ line.appendChild(label);
646
+
647
+ return line;
648
+ }
649
+
650
+ /* ── Dependency arrows ─────────────────────────── */
651
+
652
+ #buildDependencyArrows(barsPanel) {
653
+ const depsExist = this.#tasks.some(t => t.depends.length > 0);
654
+ if (!depsExist) return null;
655
+
656
+ const ns = 'http://www.w3.org/2000/svg';
657
+ const rowHeight = 36; // default --gc-row-height
658
+ const rowCount = barsPanel.querySelectorAll('.gc-bar-row').length;
659
+ const svg = document.createElementNS(ns, 'svg');
660
+ svg.setAttribute('class', 'gc-deps');
661
+ // x is a percentage (0–100, matching the bars' CSS `left: N%`) and y is in
662
+ // pixels. A non-uniform viewBox maps both, so the path `d` can use plain
663
+ // numbers — SVG path data does not accept `%` units. non-scaling-stroke
664
+ // (below) keeps the line/arrowhead uniform despite the x-axis stretch.
665
+ svg.setAttribute('viewBox', `0 0 100 ${rowCount * rowHeight}`);
666
+ svg.setAttribute('preserveAspectRatio', 'none');
667
+ svg.style.width = '100%';
668
+ svg.style.height = '100%';
669
+
670
+ // Arrowhead marker
671
+ const defs = document.createElementNS(ns, 'defs');
672
+ const marker = document.createElementNS(ns, 'marker');
673
+ marker.setAttribute('id', 'gc-arrowhead');
674
+ marker.setAttribute('markerWidth', '8');
675
+ marker.setAttribute('markerHeight', '6');
676
+ marker.setAttribute('refX', '8');
677
+ marker.setAttribute('refY', '3');
678
+ marker.setAttribute('orient', 'auto');
679
+ const arrow = document.createElementNS(ns, 'polygon');
680
+ arrow.setAttribute('points', '0 0, 8 3, 0 6');
681
+ arrow.setAttribute('fill', 'var(--color-text-muted, #666666)');
682
+ marker.appendChild(arrow);
683
+ defs.appendChild(marker);
684
+ svg.appendChild(defs);
685
+
686
+ for (const task of this.#tasks) {
687
+ for (const depId of task.depends) {
688
+ const source = this.#tasks.find(t => t.id === depId);
689
+ if (!source) continue;
690
+
691
+ const sourceRight = ((source.end - this.#rangeStart) / this.#rangeTotal) * 100;
692
+ const targetLeft = ((task.start - this.#rangeStart) / this.#rangeTotal) * 100;
693
+
694
+ // Find row indices for vertical positioning
695
+ const sourceIdx = this.#getVisualRowIndex(source.id, barsPanel);
696
+ const targetIdx = this.#getVisualRowIndex(task.id, barsPanel);
697
+ if (sourceIdx === -1 || targetIdx === -1) continue;
698
+
699
+ const sourceY = sourceIdx * rowHeight + rowHeight / 2;
700
+ const targetY = targetIdx * rowHeight + rowHeight / 2;
701
+
702
+ const path = document.createElementNS(ns, 'path');
703
+ const midX = sourceRight + (targetLeft - sourceRight) / 2;
704
+ path.setAttribute('d', `M ${sourceRight} ${sourceY} C ${midX} ${sourceY}, ${midX} ${targetY}, ${targetLeft} ${targetY}`);
705
+ path.setAttribute('class', 'gc-dep-line');
706
+ path.setAttribute('vector-effect', 'non-scaling-stroke');
707
+ svg.appendChild(path);
708
+ }
709
+ }
710
+
711
+ return svg;
712
+ }
713
+
714
+ #getVisualRowIndex(taskId, barsPanel) {
715
+ const rows = barsPanel.querySelectorAll('.gc-bar-row');
716
+ for (let i = 0; i < rows.length; i++) {
717
+ const bar = rows[i].querySelector('[data-task-id]');
718
+ if (bar && bar.dataset.taskId === taskId) return i;
719
+ }
720
+ return -1;
721
+ }
722
+
723
+ /* ── JSON loading ──────────────────────────────── */
724
+
725
+ async _loadSrc(url) {
726
+ if (!url) return;
727
+ try {
728
+ const res = await fetch(url);
729
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
730
+ const data = await res.json();
731
+
732
+ // Clear existing children
733
+ while (this.firstChild) this.firstChild.remove();
734
+
735
+ // Build a table from JSON
736
+ const table = document.createElement('table');
737
+ const thead = document.createElement('thead');
738
+ const headerRow = document.createElement('tr');
739
+ for (const label of ['Task', 'Start', 'End', 'Progress']) {
740
+ const th = document.createElement('th');
741
+ th.textContent = label;
742
+ headerRow.appendChild(th);
743
+ }
744
+ thead.appendChild(headerRow);
745
+ table.appendChild(thead);
746
+
747
+ const tbody = document.createElement('tbody');
748
+ for (const task of (data.tasks || [])) {
749
+ const tr = document.createElement('tr');
750
+ if (task.id) tr.dataset.taskId = task.id;
751
+ if (task.group) tr.dataset.group = task.group;
752
+ if (task.depends) tr.dataset.depends = Array.isArray(task.depends) ? task.depends.join(',') : task.depends;
753
+ if (task.status) tr.dataset.status = task.status;
754
+ if (task.assignee) tr.dataset.assignee = task.assignee;
755
+ if (task.milestone) tr.setAttribute('data-milestone', '');
756
+ if (task.color) tr.dataset.color = task.color;
757
+ if (task.storyIds) tr.dataset.storyIds = Array.isArray(task.storyIds) ? task.storyIds.join(',') : task.storyIds;
758
+ if (task.itemIds) tr.dataset.itemIds = Array.isArray(task.itemIds) ? task.itemIds.join(',') : task.itemIds;
759
+
760
+ const tdName = document.createElement('td');
761
+ tdName.textContent = task.name || '';
762
+ tr.appendChild(tdName);
763
+
764
+ const tdStart = document.createElement('td');
765
+ const timeStart = document.createElement('time');
766
+ timeStart.setAttribute('datetime', task.start);
767
+ timeStart.textContent = new Date(task.start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
768
+ tdStart.appendChild(timeStart);
769
+ tr.appendChild(tdStart);
770
+
771
+ const tdEnd = document.createElement('td');
772
+ const timeEnd = document.createElement('time');
773
+ timeEnd.setAttribute('datetime', task.end || task.start);
774
+ timeEnd.textContent = new Date(task.end || task.start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
775
+ tdEnd.appendChild(timeEnd);
776
+ tr.appendChild(tdEnd);
777
+
778
+ const tdProgress = document.createElement('td');
779
+ const progress = document.createElement('progress');
780
+ progress.value = task.progress || 0;
781
+ progress.max = 100;
782
+ progress.textContent = `${task.progress || 0}%`;
783
+ tdProgress.appendChild(progress);
784
+ tr.appendChild(tdProgress);
785
+
786
+ tbody.appendChild(tr);
787
+ }
788
+ table.appendChild(tbody);
789
+ this.appendChild(table);
790
+
791
+ // Apply title from JSON
792
+ if (data.title) this.setAttribute('title', data.title);
793
+
794
+ this.#refresh();
795
+ } catch (err) {
796
+ console.warn(`[gantt-chart] Failed to load src="${url}":`, err);
797
+ }
798
+ }
799
+
800
+ /* ── Live region ───────────────────────────────── */
801
+
802
+ #announce(msg) {
803
+ if (!this.#liveRegion) return;
804
+ this.#liveRegion.textContent = '';
805
+ requestAnimationFrame(() => {
806
+ if (this.#liveRegion) this.#liveRegion.textContent = msg;
807
+ });
808
+ }
809
+ }
810
+
811
+ registerComponent('gantt-chart', GanttChart);
812
+ export { GanttChart };