@profpowell/vb-project-planning 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +56 -2
  3. package/dist/vb-project-planning.js +3188 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/utils/copy-init.js +56 -17
  15. package/src/web-components/_ux-base.js +409 -24
  16. package/src/web-components/adr-wc/README.md +18 -16
  17. package/src/web-components/adr-wc/api.json +7 -5
  18. package/src/web-components/adr-wc/logic.js +50 -136
  19. package/src/web-components/burndown-chart/api.json +8 -6
  20. package/src/web-components/burndown-chart/logic.js +75 -39
  21. package/src/web-components/empathy-map/api.json +13 -13
  22. package/src/web-components/empathy-map/logic.js +161 -168
  23. package/src/web-components/empathy-map/static.html +3 -3
  24. package/src/web-components/empathy-map/styles.js +1 -0
  25. package/src/web-components/gantt-chart/README.md +12 -6
  26. package/src/web-components/gantt-chart/api.json +23 -7
  27. package/src/web-components/gantt-chart/logic.js +456 -431
  28. package/src/web-components/gantt-chart/styles.css +9 -1
  29. package/src/web-components/impact-effort/api.json +9 -7
  30. package/src/web-components/impact-effort/logic.js +238 -209
  31. package/src/web-components/impact-effort/static.html +12 -4
  32. package/src/web-components/iron-triangle/_capacity.js +0 -18
  33. package/src/web-components/kanban-board/api.json +8 -6
  34. package/src/web-components/kanban-board/logic.js +289 -333
  35. package/src/web-components/product-roadmap/api.json +14 -13
  36. package/src/web-components/product-roadmap/logic.js +393 -246
  37. package/src/web-components/product-roadmap/styles.css +40 -0
  38. package/src/web-components/quadrant-grid/api.json +9 -4
  39. package/src/web-components/quadrant-grid/logic.js +80 -12
  40. package/src/web-components/quadrant-grid/styles.css +7 -0
  41. package/src/web-components/quality-target/_quality-utils.js +2 -46
  42. package/src/web-components/review-surface/README.md +9 -5
  43. package/src/web-components/review-surface/api.json +10 -8
  44. package/src/web-components/review-surface/logic.js +349 -218
  45. package/src/web-components/risk-register/api.json +1 -6
  46. package/src/web-components/risk-register/logic.js +71 -24
  47. package/src/web-components/story-map/api.json +8 -6
  48. package/src/web-components/story-map/logic.js +280 -281
  49. package/src/web-components/story-map/static.html +15 -5
  50. package/src/web-components/traceability-matrix/api.json +10 -3
  51. package/src/web-components/traceability-matrix/logic.js +112 -63
  52. package/src/web-components/traceability-matrix/styles.css +14 -1
  53. package/src/web-components/user-journey/api.json +25 -24
  54. package/src/web-components/user-journey/logic.js +76 -127
  55. package/src/web-components/user-journey/static.html +2 -2
  56. package/src/web-components/user-persona/api.json +15 -14
  57. package/src/web-components/user-persona/logic.js +71 -117
  58. package/src/web-components/user-persona/styles.js +1 -0
  59. package/src/web-components/user-story/api.json +13 -11
  60. package/src/web-components/user-story/logic.js +164 -195
  61. package/src/web-components/user-story/styles.js +28 -1
  62. package/src/web-components/work-item/README.md +3 -3
  63. package/src/web-components/work-item/api.json +13 -11
  64. package/src/web-components/work-item/logic.js +58 -154
@@ -7,6 +7,17 @@
7
7
  * Without JS: renders as a readable data table with progress bars.
8
8
  * With JS: visual timeline with bars, groups, milestones, and today line.
9
9
  *
10
+ * Data sources, in order of precedence: the `.tasks` property, the `src`
11
+ * JSON URL, then the child <table>. `.tasks` assigned before connect means
12
+ * the initial `src` is not fetched; assigning `.tasks` while a src request
13
+ * is in flight aborts it. A later change to `src` loads that URL (the most
14
+ * recent source wins). Dates are calendar days in local time
15
+ * (src/lib/dates.js), so `2026-04-01` shows as Apr 1 in every time zone.
16
+ *
17
+ * Progress (`.tasks` and `src` JSON): a value greater than 1 is a
18
+ * percentage (0–100); a value from 0 to 1 is a fraction. A table's
19
+ * <progress> is read as value / max. `.tasks` reads back as a fraction.
20
+ *
10
21
  * @attr {string} title - Chart heading
11
22
  * @attr {string} src - URL to JSON task data
12
23
  * @attr {string} view - Timeline granularity: auto|day|week|month|quarter
@@ -26,55 +37,110 @@
26
37
  * @attr {string} data-story-ids - Comma-separated linked user-story IDs
27
38
  * @attr {string} data-item-ids - Comma-separated linked work-item IDs
28
39
  *
29
- * @fires gantt-chart:ready - After initial render
30
- * @fires gantt-chart:task-click - Task bar clicked
40
+ * @fires gantt-chart:ready - Once per element: after the first connect render with tasks, or else the first src load
41
+ * @fires gantt-chart:task-click - Task bar or milestone activated (click, Enter, Space)
42
+ * @fires gantt-chart:tasks-changed - After `.tasks` assignment or a src load
31
43
  */
32
44
 
33
45
  import { registerComponent } from '../../lib/bundle-registry.js';
34
46
  import { VBElement } from '../../lib/vb-element.js';
47
+ import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
48
+ import { SrcLoader } from '../../lib/src-loader.js';
49
+ import {
50
+ parseISODate, toISODate, addDays, addMonths, daysBetween,
51
+ startOfDay, startOfMonth, startOfQuarter, formatDate,
52
+ } from '../../lib/dates.js';
53
+
54
+ /* ── Date helpers (calendar days) ── */
55
+
56
+ /** @param {number} rangeDays */
57
+ function pickFormatter(rangeDays) {
58
+ if (rangeDays <= 90) {
59
+ return (d) => formatDate(d, { month: 'short', day: 'numeric' });
60
+ }
61
+ if (rangeDays <= 365) {
62
+ return (d) => formatDate(d, { month: 'short', year: '2-digit' });
63
+ }
64
+ return (d) => formatDate(d, { year: 'numeric' });
65
+ }
35
66
 
36
- /* ── Date helpers (adapted from chart-wc TimeScale) ── */
67
+ /** @param {number} rangeDays */
68
+ function resolveView(rangeDays) {
69
+ if (rangeDays <= 21) return 'day';
70
+ if (rangeDays <= 90) return 'week';
71
+ if (rangeDays <= 365) return 'month';
72
+ return 'quarter';
73
+ }
37
74
 
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;
75
+ const STEP_DAYS = { day: 1, week: 7, month: 30, quarter: 91 };
43
76
 
44
- function toTimestamp(value) {
45
- if (typeof value === 'number') return value;
46
- const d = new Date(value);
47
- return d.getTime();
77
+ /** Long-form date for labels, e.g. "Apr 1, 2026". @param {number} ts */
78
+ function longDate(ts) {
79
+ return formatDate(new Date(ts), { year: 'numeric', month: 'short', day: 'numeric' });
48
80
  }
49
81
 
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' });
82
+ const STATUS_LABELS = {
83
+ 'not-started': 'Not started',
84
+ 'in-progress': 'In progress',
85
+ done: 'Done',
86
+ blocked: 'Blocked',
87
+ };
88
+
89
+ /** @param {string} s */
90
+ function statusLabel(s) {
91
+ return STATUS_LABELS[s] || (s.charAt(0).toUpperCase() + s.slice(1)).replace(/-/g, ' ');
61
92
  }
62
93
 
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';
94
+ /** Split "a, b" or pass arrays through. @param {unknown} v @returns {string[]} */
95
+ function idList(v) {
96
+ if (Array.isArray(v)) return v.map(String);
97
+ if (v == null || v === '') return [];
98
+ return String(v).split(',').map((s) => s.trim()).filter(Boolean);
99
+ }
100
+
101
+ /**
102
+ * One progress convention everywhere: > 1 is a percentage, 0–1 a fraction.
103
+ * Returns a fraction clamped to [0, 1].
104
+ * @param {unknown} v
105
+ */
106
+ function normalizeProgress(v) {
107
+ const n = Number(v);
108
+ if (!Number.isFinite(n) || n <= 0) return 0;
109
+ return Math.min(1, n > 1 ? n / 100 : n);
68
110
  }
69
111
 
70
- function esc(s) {
71
- return String(s)
72
- .replace(/&/g, '&amp;')
73
- .replace(/</g, '&lt;')
74
- .replace(/>/g, '&gt;')
75
- .replace(/"/g, '&quot;');
112
+ /**
113
+ * Normalize author/property/JSON task records. Start/end become
114
+ * local-midnight timestamps; tasks without a valid start are dropped.
115
+ * @param {Array<any>} list
116
+ */
117
+ function normalizeTasks(list) {
118
+ const out = [];
119
+ (list || []).forEach((t, i) => {
120
+ const start = parseISODate(t?.start);
121
+ if (!start) return;
122
+ const end = parseISODate(t.end) ?? start;
123
+ out.push({
124
+ id: t.id != null && t.id !== '' ? String(t.id) : `gc-task-${i}`,
125
+ name: t.name ?? `Task ${i + 1}`,
126
+ start: start.getTime(),
127
+ end: Math.max(end.getTime(), start.getTime()),
128
+ progress: normalizeProgress(t.progress),
129
+ group: t.group || null,
130
+ depends: idList(t.depends),
131
+ status: t.status || null,
132
+ assignee: t.assignee || null,
133
+ milestone: !!t.milestone,
134
+ color: t.color || null,
135
+ storyIds: idList(t.storyIds),
136
+ itemIds: idList(t.itemIds),
137
+ });
138
+ });
139
+ return out;
76
140
  }
77
141
 
142
+ let instanceCounter = 0;
143
+
78
144
  /* ── Component ─────────────────────────────────────── */
79
145
 
80
146
  class GanttChart extends VBElement {
@@ -82,233 +148,212 @@ class GanttChart extends VBElement {
82
148
  return ['src', 'title', 'view', 'show-today', 'show-progress', 'show-dependencies', 'compact'];
83
149
  }
84
150
 
151
+ #uid = `gc-${++instanceCounter}`;
85
152
  /** @type {HTMLDivElement|null} */
86
153
  #container = null;
87
154
  /** @type {HTMLDivElement|null} */
88
155
  #liveRegion = null;
89
156
  /** @type {HTMLElement|null} */
90
157
  #titleEl = null;
91
- /** @type {Array<Object>} */
158
+ /** @type {Array<any>} */
92
159
  #tasks = [];
93
- /** @type {number} */
94
- #rangeStart = 0;
95
- /** @type {number} */
96
- #rangeEnd = 0;
97
- /** @type {number} */
98
- #rangeTotal = 0;
160
+ /**
161
+ * Where #tasks came from. 'table' re-parses the author's <table> on each
162
+ * connect; 'property' and 'src' keep #tasks and generate the SR table.
163
+ * @type {'table' | 'property' | 'src'}
164
+ */
165
+ #source = 'table';
166
+ /** One in-flight src request; `url` guards against refetching on reconnect. */
167
+ #loader = new SrcLoader();
168
+ #readyFired = false;
169
+ #internalAttr = false;
170
+ /** Local-midnight Date of the first day in range. */
171
+ #rangeStart = new Date(0);
172
+ /** Calendar days spanned by the range (≥ 1). */
173
+ #rangeDays = 1;
174
+
175
+ constructor() {
176
+ super();
177
+ /* A `.tasks` set before the element was defined lands as an own
178
+ property and would shadow the accessor: re-route it. */
179
+ if (Object.prototype.hasOwnProperty.call(this, 'tasks')) {
180
+ const value = /** @type {any} */ (this).tasks;
181
+ delete (/** @type {any} */ (this)).tasks;
182
+ this.tasks = value;
183
+ }
184
+ }
99
185
 
100
186
  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);
187
+ this.listen(this, 'click', this.#onClick);
188
+ this.listen(this, 'keydown', this.#onKeyDown);
116
189
 
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
- }));
190
+ if (this.#source === 'table') {
191
+ const table = this.querySelector(':scope > table');
192
+ this.#tasks = table ? this.#parseTasks(table) : [];
193
+ }
194
+ this.#render();
195
+ if (this.#tasks.length) this.#dispatchReady();
196
+
197
+ /* src loads here as well as in attributeChangedCallback, so
198
+ createElement + setAttribute('src') + append fetches (once).
199
+ `.tasks` takes precedence: once assigned, the initial src is skipped. */
200
+ const src = this.getAttribute('src');
201
+ if (src && this.#source !== 'property' && src !== this.#loader.url) this.#loadSrc(src);
128
202
  return true;
129
203
  }
130
204
 
131
205
  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 = [];
206
+ this.#removeChrome();
207
+ const table = this.querySelector(':scope > table.gc-sr-only');
208
+ if (table && !table.hasAttribute('data-gc-generated')) table.classList.remove('gc-sr-only');
141
209
  }
142
210
 
143
- /** @type {number} */
144
- static #vtCounter = 0;
145
-
146
211
  attributeChangedCallback(name, oldValue, newValue) {
147
- if (oldValue === newValue || !this.isConnected) return;
212
+ if (oldValue === newValue || this.#internalAttr) return;
213
+ if (!this.isConnected || !this.hasAttribute('data-upgraded')) return; // setup() reads them
148
214
  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();
215
+ if (newValue) this.#loadSrc(newValue);
216
+ else this.#loader.cancel();
217
+ return;
175
218
  }
219
+ viewTransitionSwap(this, () => this.#render(), 'gc-vt');
176
220
  }
177
221
 
178
222
  // ── Data API (HTML-first / JS-first dual contract) ──────────────
179
223
 
180
224
  /**
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? }.
225
+ * The current task list (normalized). start/end are local-midnight
226
+ * timestamps; progress is a 0–1 fraction.
185
227
  */
186
228
  get tasks() { return this.#tasks; }
187
229
 
188
230
  /**
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.
231
+ * Replace the task set and re-render. start/end accept YYYY-MM-DD
232
+ * strings, Dates or timestamps; progress accepts 0–1 or 0–100 (> 1).
233
+ * The visually hidden table is regenerated from the new tasks.
195
234
  */
196
235
  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
-
236
+ this.#loader.cancel(); // .tasks wins over a pending src response
237
+ this.#tasks = normalizeTasks(value);
238
+ this.#source = 'property';
239
+ if (this.hasAttribute('data-upgraded')) this.#render();
239
240
  this.dispatchEvent(new CustomEvent('gantt-chart:tasks-changed', {
240
- detail: { tasks: next, source: 'property' },
241
+ detail: { tasks: this.#tasks, source: 'property' },
241
242
  bubbles: true,
242
243
  }));
243
244
  }
244
245
 
245
246
  /* ── Table parsing ─────────────────────────────── */
246
247
 
248
+ /** @param {Element} table */
247
249
  #parseTasks(table) {
248
250
  const rows = table.querySelectorAll('tbody tr');
249
- const tasks = [];
250
-
251
+ const raw = [];
251
252
  for (let i = 0; i < rows.length; i++) {
252
253
  const row = /** @type {HTMLTableRowElement} */ (rows[i]);
253
254
  const times = row.querySelectorAll('time[datetime]');
254
255
  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
-
256
+ const start = times[0]?.getAttribute('datetime');
257
+ if (!start) continue;
260
258
  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({
259
+ raw.push({
266
260
  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,
261
+ name: row.cells[0]?.textContent?.trim() || `Task ${i + 1}`,
262
+ start,
263
+ end: times.length > 1 ? times[1].getAttribute('datetime') : start,
264
+ // <progress> is explicit about its scale, so convert to a fraction here.
265
+ progress: progressEl ? progressEl.value / (progressEl.max || 1) : 0,
266
+ group: row.dataset.group,
267
+ depends: row.dataset.depends,
268
+ status: row.dataset.status,
269
+ assignee: row.dataset.assignee,
275
270
  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()) : [],
271
+ color: row.dataset.color,
272
+ storyIds: row.dataset.storyIds,
273
+ itemIds: row.dataset.itemIds,
279
274
  });
280
275
  }
276
+ return normalizeTasks(raw);
277
+ }
278
+
279
+ /* ── Render ────────────────────────────────────── */
280
+
281
+ #removeChrome() {
282
+ this.#titleEl?.remove(); this.#titleEl = null;
283
+ this.#container?.remove(); this.#container = null;
284
+ this.#liveRegion?.remove(); this.#liveRegion = null;
285
+ }
286
+
287
+ /** The SR table: the author's for 'table', otherwise generated from #tasks. */
288
+ #syncTable() {
289
+ if (this.#source === 'table') {
290
+ return /** @type {HTMLTableElement|null} */ (this.querySelector(':scope > table'));
291
+ }
292
+ this.querySelectorAll(':scope > table').forEach((t) => t.remove());
293
+ if (!this.#tasks.length) return null;
294
+ const table = this.#buildTable();
295
+ this.appendChild(table);
296
+ return table;
297
+ }
298
+
299
+ #render() {
300
+ this.#removeChrome();
301
+ const table = this.#syncTable();
302
+ if (!this.#tasks.length) return;
281
303
 
282
- return tasks;
304
+ const starts = this.#tasks.map((t) => t.start);
305
+ const ends = this.#tasks.map((t) => t.end);
306
+ this.#rangeStart = new Date(Math.min(...starts));
307
+ this.#rangeDays = Math.max(1, daysBetween(this.#rangeStart, new Date(Math.max(...ends))));
308
+
309
+ this.#build(table);
283
310
  }
284
311
 
285
- /* ── Build enhanced DOM ────────────────────────── */
312
+ /** @param {number | Date} ts @returns {number} 0–100 */
313
+ #pct(ts) {
314
+ const d = typeof ts === 'number' ? new Date(ts) : ts;
315
+ return (daysBetween(this.#rangeStart, d) / this.#rangeDays) * 100;
316
+ }
286
317
 
318
+ /** `gantt-chart:ready`, at most once per element. */
319
+ #dispatchReady() {
320
+ if (this.#readyFired) return;
321
+ this.#readyFired = true;
322
+ this.dispatchEvent(new CustomEvent('gantt-chart:ready', {
323
+ bubbles: true,
324
+ detail: {
325
+ taskCount: this.#tasks.length,
326
+ dateRange: {
327
+ start: toISODate(this.#rangeStart),
328
+ end: toISODate(addDays(this.#rangeStart, this.#rangeDays)),
329
+ },
330
+ },
331
+ }));
332
+ }
333
+
334
+ /** @param {HTMLTableElement | null} table */
287
335
  #build(table) {
288
336
  const title = this.getAttribute('title');
289
337
  const showToday = this.hasAttribute('show-today');
290
338
  const showProgress = this.hasAttribute('show-progress');
291
339
  const showDeps = this.hasAttribute('show-dependencies');
340
+ const anchor = table && table.parentNode === this ? table : null;
292
341
 
293
- // Title
294
342
  if (title) {
295
343
  this.#titleEl = document.createElement('h2');
296
344
  this.#titleEl.className = 'gc-title';
297
345
  this.#titleEl.textContent = title;
298
- this.insertBefore(this.#titleEl, table);
346
+ this.insertBefore(this.#titleEl, anchor);
299
347
  }
300
348
 
301
- // Container
302
349
  this.#container = document.createElement('div');
303
350
  this.#container.className = 'gc-container';
304
351
  this.#container.setAttribute('role', 'region');
305
352
  this.#container.setAttribute('aria-label', `Gantt chart${title ? ': ' + title : ''}`);
306
353
 
307
- // Timeline header
308
- const header = this.#buildTimelineHeader();
309
- this.#container.appendChild(header);
354
+ const labels = this.#generateDateLabels();
355
+ this.#container.appendChild(this.#buildTimelineHeader(labels));
310
356
 
311
- // Body: task list + bars
312
357
  const body = document.createElement('div');
313
358
  body.className = 'gc-body';
314
359
 
@@ -317,16 +362,10 @@ class GanttChart extends VBElement {
317
362
 
318
363
  const barsPanel = document.createElement('div');
319
364
  barsPanel.className = 'gc-bars';
365
+ barsPanel.appendChild(this.#buildGridLines(labels));
320
366
 
321
- // Grid lines
322
- const gridLines = this.#buildGridLines();
323
- barsPanel.appendChild(gridLines);
324
-
325
- // Group tasks
326
367
  const grouped = this.#groupTasks();
327
-
328
368
  for (const [groupName, groupTasks] of grouped) {
329
- // Group header (if there are multiple groups)
330
369
  if (grouped.size > 1 && groupName) {
331
370
  const groupHeader = document.createElement('div');
332
371
  groupHeader.className = 'gc-group-header';
@@ -342,7 +381,6 @@ class GanttChart extends VBElement {
342
381
  }
343
382
 
344
383
  for (const task of groupTasks) {
345
- // Task row (left)
346
384
  const taskRow = document.createElement('div');
347
385
  taskRow.className = 'gc-task-row';
348
386
  taskRow.setAttribute('data-task-id', task.id);
@@ -352,54 +390,44 @@ class GanttChart extends VBElement {
352
390
  taskName.textContent = task.name;
353
391
  taskRow.appendChild(taskName);
354
392
 
355
- // SR-only date range
393
+ // Status as visible text, so it is not conveyed by bar colour alone.
394
+ if (task.status) {
395
+ const status = document.createElement('span');
396
+ status.className = 'gc-task-status';
397
+ status.setAttribute('data-status', task.status);
398
+ status.textContent = statusLabel(task.status);
399
+ taskRow.appendChild(status);
400
+ }
401
+
356
402
  const taskDates = document.createElement('span');
357
403
  taskDates.className = 'gc-task-dates';
358
- taskDates.textContent = `${new Date(task.start).toLocaleDateString()} to ${new Date(task.end).toLocaleDateString()}`;
404
+ taskDates.textContent = task.milestone
405
+ ? longDate(task.start)
406
+ : `${longDate(task.start)} to ${longDate(task.end)}`;
359
407
  taskRow.appendChild(taskDates);
360
408
 
361
409
  taskList.appendChild(taskRow);
362
410
 
363
- // Bar row (right)
364
411
  const barRow = document.createElement('div');
365
412
  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
-
413
+ barRow.setAttribute('data-task-id', task.id);
414
+ barRow.appendChild(task.milestone ? this.#buildMilestone(task) : this.#buildBar(task, showProgress));
373
415
  barsPanel.appendChild(barRow);
374
416
  }
375
417
  }
376
418
 
377
- // Today line
378
419
  if (showToday) {
379
420
  const todayLine = this.#buildTodayLine();
380
421
  if (todayLine) barsPanel.appendChild(todayLine);
381
422
  }
382
423
 
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
424
  body.appendChild(taskList);
393
425
  body.appendChild(barsPanel);
394
426
  this.#container.appendChild(body);
427
+ this.insertBefore(this.#container, anchor);
395
428
 
396
- // Insert container before table
397
- this.insertBefore(this.#container, table);
429
+ if (table) table.classList.add('gc-sr-only');
398
430
 
399
- // Hide original table (keep for screen readers)
400
- table.classList.add('gc-sr-only');
401
-
402
- // Live region
403
431
  this.#liveRegion = document.createElement('div');
404
432
  this.#liveRegion.className = 'gc-sr-only';
405
433
  this.#liveRegion.setAttribute('role', 'status');
@@ -407,49 +435,81 @@ class GanttChart extends VBElement {
407
435
  this.#liveRegion.setAttribute('aria-atomic', 'true');
408
436
  this.appendChild(this.#liveRegion);
409
437
 
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
- });
438
+ if (showDeps) {
439
+ // Measure after layout: row heights depend on compact/group spacers.
440
+ requestAnimationFrame(() => {
441
+ if (!barsPanel.isConnected) return;
442
+ const svg = this.#buildDependencyArrows(barsPanel);
443
+ if (svg) barsPanel.appendChild(svg);
444
+ });
445
+ }
446
+ }
424
447
 
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
- }
448
+ /* ── Generated SR table (property / src data) ──── */
449
+
450
+ #buildTable() {
451
+ const table = document.createElement('table');
452
+ table.setAttribute('data-gc-generated', '');
453
+ const thead = document.createElement('thead');
454
+ const headerRow = document.createElement('tr');
455
+ for (const label of ['Task', 'Start', 'End', 'Progress', 'Status']) {
456
+ const th = document.createElement('th');
457
+ th.scope = 'col';
458
+ th.textContent = label;
459
+ headerRow.appendChild(th);
460
+ }
461
+ thead.appendChild(headerRow);
462
+ table.appendChild(thead);
463
+
464
+ const tbody = document.createElement('tbody');
465
+ for (const task of this.#tasks) {
466
+ const tr = document.createElement('tr');
467
+ tr.dataset.taskId = task.id;
468
+ const tdName = document.createElement('td');
469
+ tdName.textContent = task.name;
470
+ tr.appendChild(tdName);
471
+
472
+ for (const ts of [task.start, task.end]) {
473
+ const td = document.createElement('td');
474
+ const time = document.createElement('time');
475
+ time.setAttribute('datetime', toISODate(new Date(ts)));
476
+ time.textContent = longDate(ts);
477
+ td.appendChild(time);
478
+ tr.appendChild(td);
433
479
  }
434
- });
480
+
481
+ const tdProgress = document.createElement('td');
482
+ const progress = document.createElement('progress');
483
+ const pct = Math.round(task.progress * 100);
484
+ progress.max = 100;
485
+ progress.value = pct;
486
+ progress.textContent = `${pct}%`;
487
+ tdProgress.appendChild(progress);
488
+ tr.appendChild(tdProgress);
489
+
490
+ const tdStatus = document.createElement('td');
491
+ tdStatus.textContent = task.status ? statusLabel(task.status) : '';
492
+ tr.appendChild(tdStatus);
493
+
494
+ tbody.appendChild(tr);
495
+ }
496
+ table.appendChild(tbody);
497
+ return table;
435
498
  }
436
499
 
437
500
  /* ── Timeline header ───────────────────────────── */
438
501
 
439
- #buildTimelineHeader() {
502
+ /** @param {Array<{position: number, text: string}>} labels */
503
+ #buildTimelineHeader(labels) {
440
504
  const header = document.createElement('div');
441
505
  header.className = 'gc-timeline-header';
442
506
 
443
- // Spacer matching task list width
444
507
  const spacer = document.createElement('div');
445
508
  spacer.className = 'gc-timeline-spacer';
446
509
  header.appendChild(spacer);
447
510
 
448
- // Date labels
449
511
  const dates = document.createElement('div');
450
512
  dates.className = 'gc-timeline-dates';
451
-
452
- const labels = this.#generateDateLabels();
453
513
  for (const { position, text } of labels) {
454
514
  const label = document.createElement('span');
455
515
  label.className = 'gc-date-label';
@@ -466,81 +526,52 @@ class GanttChart extends VBElement {
466
526
 
467
527
  #generateDateLabels() {
468
528
  const viewAttr = this.getAttribute('view') || 'auto';
469
- const view = viewAttr === 'auto' ? resolveView(this.#rangeTotal) : viewAttr;
470
- const formatter = pickFormatter(this.#rangeTotal);
529
+ const view = STEP_DAYS[viewAttr] ? viewAttr : resolveView(this.#rangeDays);
530
+ const formatter = pickFormatter(this.#rangeDays);
531
+ const rangeStart = this.#rangeStart;
532
+ const rangeEnd = addDays(rangeStart, this.#rangeDays);
471
533
  const labels = [];
472
534
 
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);
535
+ /** @type {(d: Date) => Date} */
536
+ let step;
537
+ let cursor;
484
538
  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);
539
+ // First Monday on or after the range start.
540
+ const dow = rangeStart.getDay();
541
+ cursor = addDays(rangeStart, (8 - dow) % 7);
542
+ step = (d) => addDays(d, 7);
489
543
  } else if (view === 'month') {
490
- cursor.setDate(1);
491
- if (cursor.getTime() < this.#rangeStart) {
492
- cursor.setMonth(cursor.getMonth() + 1);
493
- }
544
+ cursor = startOfMonth(rangeStart);
545
+ if (cursor < rangeStart) cursor = addMonths(cursor, 1);
546
+ step = (d) => addMonths(d, 1);
494
547
  } 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);
548
+ // Day 1 is set before the month moves, so a Mar 31 start aligns to Apr 1.
549
+ cursor = startOfQuarter(rangeStart);
550
+ if (cursor < rangeStart) cursor = addMonths(cursor, 3);
551
+ step = (d) => addMonths(d, 3);
552
+ } else {
553
+ cursor = startOfDay(rangeStart);
554
+ step = (d) => addDays(d, 1);
499
555
  }
500
556
 
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
- });
557
+ // Label the range start if the first aligned tick is well past it.
558
+ if (daysBetween(rangeStart, cursor) > STEP_DAYS[view] * 0.5) {
559
+ labels.push({ position: 0, text: formatter(rangeStart) });
508
560
  }
509
561
 
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
- }
562
+ for (let d = cursor; d <= rangeEnd; d = step(d)) {
563
+ const pct = this.#pct(d);
564
+ if (pct >= 0 && pct <= 100) labels.push({ position: pct, text: formatter(d) });
532
565
  }
533
-
534
566
  return labels;
535
567
  }
536
568
 
537
569
  /* ── Grid lines ────────────────────────────────── */
538
570
 
539
- #buildGridLines() {
571
+ /** @param {Array<{position: number}>} labels */
572
+ #buildGridLines(labels) {
540
573
  const container = document.createElement('div');
541
574
  container.className = 'gc-grid-lines';
542
-
543
- const labels = this.#generateDateLabels();
544
575
  for (const { position } of labels) {
545
576
  if (position <= 0) continue;
546
577
  const line = document.createElement('div');
@@ -548,40 +579,48 @@ class GanttChart extends VBElement {
548
579
  line.style.left = `${position}%`;
549
580
  container.appendChild(line);
550
581
  }
551
-
552
582
  return container;
553
583
  }
554
584
 
555
585
  /* ── Group tasks ───────────────────────────────── */
556
586
 
557
587
  #groupTasks() {
558
- /** @type {Map<string|null, Object[]>} */
588
+ /** @type {Map<string, any[]>} */
559
589
  const groups = new Map();
560
-
561
590
  for (const task of this.#tasks) {
562
591
  const key = task.group || '';
563
592
  if (!groups.has(key)) groups.set(key, []);
564
593
  groups.get(key)?.push(task);
565
594
  }
566
-
567
595
  return groups;
568
596
  }
569
597
 
598
+ /* ── Accessible name ───────────────────────────── */
599
+
600
+ /** @param {any} task */
601
+ #taskLabel(task) {
602
+ const parts = [task.milestone
603
+ ? `Milestone: ${task.name}, ${longDate(task.start)}`
604
+ : `${task.name}: ${longDate(task.start)} to ${longDate(task.end)}`];
605
+ if (task.status) parts.push(`status: ${statusLabel(task.status).toLowerCase()}`);
606
+ if (!task.milestone && task.progress > 0) parts.push(`${Math.round(task.progress * 100)}% complete`);
607
+ if (task.assignee) parts.push(`assigned to ${task.assignee}`);
608
+ return parts.join(', ');
609
+ }
610
+
570
611
  /* ── Build bar ─────────────────────────────────── */
571
612
 
613
+ /** @param {any} task @param {boolean} showProgress */
572
614
  #buildBar(task, showProgress) {
573
- const leftPct = ((task.start - this.#rangeStart) / this.#rangeTotal) * 100;
574
- const widthPct = ((task.end - task.start) / this.#rangeTotal) * 100;
615
+ const leftPct = this.#pct(task.start);
616
+ const widthPct = this.#pct(task.end) - leftPct;
575
617
 
576
618
  const bar = document.createElement('div');
577
619
  bar.className = 'gc-bar';
578
620
  bar.setAttribute('data-task-id', task.id);
579
- bar.setAttribute('role', 'img');
621
+ bar.setAttribute('role', 'button');
580
622
  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
- );
623
+ bar.setAttribute('aria-label', this.#taskLabel(task));
585
624
 
586
625
  bar.style.left = `${leftPct}%`;
587
626
  bar.style.width = `${Math.max(widthPct, 0.5)}%`;
@@ -589,7 +628,6 @@ class GanttChart extends VBElement {
589
628
  if (task.status) bar.setAttribute('data-status', task.status);
590
629
  if (task.color) bar.style.setProperty('--gc-bar-color', task.color);
591
630
 
592
- // Progress fill
593
631
  if (showProgress && task.progress > 0) {
594
632
  const fill = document.createElement('div');
595
633
  fill.className = 'gc-bar-fill';
@@ -597,10 +635,10 @@ class GanttChart extends VBElement {
597
635
  bar.appendChild(fill);
598
636
  }
599
637
 
600
- // Label (show name if bar is wide enough)
601
638
  if (widthPct > 8) {
602
639
  const label = document.createElement('span');
603
640
  label.className = 'gc-bar-label';
641
+ label.setAttribute('aria-hidden', 'true');
604
642
  label.textContent = task.name;
605
643
  bar.appendChild(label);
606
644
  }
@@ -610,16 +648,15 @@ class GanttChart extends VBElement {
610
648
 
611
649
  /* ── Build milestone ───────────────────────────── */
612
650
 
651
+ /** @param {any} task */
613
652
  #buildMilestone(task) {
614
- const leftPct = ((task.start - this.#rangeStart) / this.#rangeTotal) * 100;
615
-
616
653
  const diamond = document.createElement('div');
617
654
  diamond.className = 'gc-milestone';
618
655
  diamond.setAttribute('data-task-id', task.id);
619
- diamond.setAttribute('role', 'img');
656
+ diamond.setAttribute('role', 'button');
620
657
  diamond.setAttribute('tabindex', '0');
621
- diamond.setAttribute('aria-label', `Milestone: ${esc(task.name)}, ${new Date(task.start).toLocaleDateString()}`);
622
- diamond.style.left = `${leftPct}%`;
658
+ diamond.setAttribute('aria-label', this.#taskLabel(task));
659
+ diamond.style.left = `${this.#pct(task.start)}%`;
623
660
 
624
661
  if (task.status) diamond.setAttribute('data-status', task.status);
625
662
  if (task.color) diamond.style.setProperty('--gc-bar-color', task.color);
@@ -630,14 +667,14 @@ class GanttChart extends VBElement {
630
667
  /* ── Today line ────────────────────────────────── */
631
668
 
632
669
  #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;
670
+ const now = new Date();
671
+ const dayFraction = (now.getTime() - startOfDay(now).getTime()) / 86_400_000;
672
+ const days = daysBetween(this.#rangeStart, now) + dayFraction;
673
+ if (days < 0 || days > this.#rangeDays) return null;
637
674
 
638
675
  const line = document.createElement('div');
639
676
  line.className = 'gc-today-line';
640
- line.style.left = `${pct}%`;
677
+ line.style.left = `${(days / this.#rangeDays) * 100}%`;
641
678
 
642
679
  const label = document.createElement('span');
643
680
  label.className = 'gc-today-label';
@@ -649,28 +686,38 @@ class GanttChart extends VBElement {
649
686
 
650
687
  /* ── Dependency arrows ─────────────────────────── */
651
688
 
689
+ /** @param {HTMLElement} barsPanel */
652
690
  #buildDependencyArrows(barsPanel) {
653
- const depsExist = this.#tasks.some(t => t.depends.length > 0);
654
- if (!depsExist) return null;
691
+ if (!this.#tasks.some((t) => t.depends.length > 0)) return null;
692
+
693
+ /* Row centres are measured, not computed from a fixed row height, so
694
+ compact mode and group spacers line up. */
695
+ /** @type {Map<string, number>} */
696
+ const centreY = new Map();
697
+ for (const row of /** @type {NodeListOf<HTMLElement>} */ (barsPanel.querySelectorAll(':scope > .gc-bar-row'))) {
698
+ centreY.set(row.dataset.taskId || '', row.offsetTop + row.offsetHeight / 2);
699
+ }
700
+ const height = Math.max(1, Math.round(barsPanel.scrollHeight));
655
701
 
656
702
  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
703
  const svg = document.createElementNS(ns, 'svg');
660
704
  svg.setAttribute('class', 'gc-deps');
705
+ svg.setAttribute('aria-hidden', 'true');
661
706
  // x is a percentage (0–100, matching the bars' CSS `left: N%`) and y is in
662
707
  // pixels. A non-uniform viewBox maps both, so the path `d` can use plain
663
708
  // 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}`);
709
+ // keeps the line/arrowhead uniform despite the x-axis stretch.
710
+ svg.setAttribute('viewBox', `0 0 100 ${height}`);
666
711
  svg.setAttribute('preserveAspectRatio', 'none');
667
712
  svg.style.width = '100%';
668
- svg.style.height = '100%';
713
+ svg.style.height = `${height}px`;
669
714
 
670
- // Arrowhead marker
715
+ // Per-instance marker id: url(#id) resolves document-wide, so a shared
716
+ // id would point every chart at the first chart's marker.
717
+ const markerId = `${this.#uid}-arrowhead`;
671
718
  const defs = document.createElementNS(ns, 'defs');
672
719
  const marker = document.createElementNS(ns, 'marker');
673
- marker.setAttribute('id', 'gc-arrowhead');
720
+ marker.setAttribute('id', markerId);
674
721
  marker.setAttribute('markerWidth', '8');
675
722
  marker.setAttribute('markerHeight', '6');
676
723
  marker.setAttribute('refX', '8');
@@ -685,24 +732,22 @@ class GanttChart extends VBElement {
685
732
 
686
733
  for (const task of this.#tasks) {
687
734
  for (const depId of task.depends) {
688
- const source = this.#tasks.find(t => t.id === depId);
735
+ const source = this.#tasks.find((t) => t.id === depId);
689
736
  if (!source) continue;
737
+ const sourceY = centreY.get(source.id);
738
+ const targetY = centreY.get(task.id);
739
+ if (sourceY === undefined || targetY === undefined) continue;
690
740
 
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;
741
+ const sourceRight = this.#pct(source.end);
742
+ const targetLeft = this.#pct(task.start);
743
+ const midX = sourceRight + (targetLeft - sourceRight) / 2;
701
744
 
702
745
  const path = document.createElementNS(ns, 'path');
703
- const midX = sourceRight + (targetLeft - sourceRight) / 2;
704
746
  path.setAttribute('d', `M ${sourceRight} ${sourceY} C ${midX} ${sourceY}, ${midX} ${targetY}, ${targetLeft} ${targetY}`);
705
747
  path.setAttribute('class', 'gc-dep-line');
748
+ path.setAttribute('data-from', source.id);
749
+ path.setAttribute('data-to', task.id);
750
+ path.setAttribute('marker-end', `url(#${markerId})`);
706
751
  path.setAttribute('vector-effect', 'non-scaling-stroke');
707
752
  svg.appendChild(path);
708
753
  }
@@ -711,94 +756,74 @@ class GanttChart extends VBElement {
711
756
  return svg;
712
757
  }
713
758
 
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;
759
+ /* ── Interaction (delegated once in setup) ─────── */
760
+
761
+ /** @param {Event} e */
762
+ #barFromEvent(e) {
763
+ const t = /** @type {Element} */ (e.target);
764
+ const bar = /** @type {HTMLElement | null} */ (t?.closest?.('.gc-bar, .gc-milestone'));
765
+ return bar && this.#container?.contains(bar) ? bar : null;
721
766
  }
722
767
 
723
- /* ── JSON loading ──────────────────────────────── */
768
+ #onClick = (e) => {
769
+ const bar = this.#barFromEvent(e);
770
+ if (!bar) return;
771
+ const task = this.#tasks.find((t) => t.id === bar.dataset.taskId);
772
+ if (!task) return;
773
+ this.#announce(`Selected: ${task.name}`);
774
+ this.dispatchEvent(new CustomEvent('gantt-chart:task-click', {
775
+ bubbles: true,
776
+ detail: { task },
777
+ }));
778
+ };
724
779
 
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);
780
+ #onKeyDown = (e) => {
781
+ const ke = /** @type {KeyboardEvent} */ (e);
782
+ if (ke.key !== 'Enter' && ke.key !== ' ') return;
783
+ const bar = this.#barFromEvent(ke);
784
+ if (!bar) return;
785
+ ke.preventDefault();
786
+ bar.click();
787
+ };
790
788
 
791
- // Apply title from JSON
792
- if (data.title) this.setAttribute('title', data.title);
789
+ /* ── JSON loading ──────────────────────────────── */
793
790
 
794
- this.#refresh();
791
+ /**
792
+ * Fetch src JSON and replace the tasks. A newer src, a `.tasks`
793
+ * assignment or removing src aborts this request (SrcLoader).
794
+ * @param {string} url
795
+ */
796
+ async #loadSrc(url) {
797
+ let result;
798
+ try {
799
+ result = await this.#loader.fetchJson(url);
795
800
  } catch (err) {
796
801
  console.warn(`[gantt-chart] Failed to load src="${url}":`, err);
802
+ return;
803
+ }
804
+ if (!result) return; // superseded, cancelled or aborted
805
+ const { data } = result;
806
+ this.#tasks = normalizeTasks(data?.tasks || []);
807
+ this.#source = 'src';
808
+ if (data?.title && data.title !== this.getAttribute('title')) {
809
+ this.#internalAttr = true;
810
+ this.setAttribute('title', data.title);
811
+ this.#internalAttr = false;
797
812
  }
813
+ // Rendered synchronously so listeners of the events below see the new DOM.
814
+ if (this.hasAttribute('data-upgraded')) {
815
+ this.#render();
816
+ this.#dispatchReady();
817
+ }
818
+ this.dispatchEvent(new CustomEvent('gantt-chart:tasks-changed', {
819
+ detail: { tasks: this.#tasks, source: 'src' },
820
+ bubbles: true,
821
+ }));
798
822
  }
799
823
 
800
824
  /* ── Live region ───────────────────────────────── */
801
825
 
826
+ /** @param {string} msg */
802
827
  #announce(msg) {
803
828
  if (!this.#liveRegion) return;
804
829
  this.#liveRegion.textContent = '';