@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,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, '&')
|
|
73
|
+
.replace(/</g, '<')
|
|
74
|
+
.replace(/>/g, '>')
|
|
75
|
+
.replace(/"/g, '"');
|
|
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 };
|