@diagrammo/dgmo 0.6.3 → 0.7.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.
@@ -0,0 +1,677 @@
1
+ // ============================================================
2
+ // Gantt Schedule Calculator
3
+ // ============================================================
4
+ //
5
+ // Takes a ParsedGantt and resolves all tasks to concrete start/end dates.
6
+ // Algorithm:
7
+ // 1. Flatten task tree, build dependency graph
8
+ // 2. Topological sort with cycle detection
9
+ // 3. Forward pass: resolve dates using universal max rule
10
+ // 4. (Optional) Critical path: backward pass to find zero-slack chain
11
+
12
+ import { makeDgmoError, formatDgmoError } from '../diagnostics';
13
+ import type { DgmoError } from '../diagnostics';
14
+ import type {
15
+ ParsedGantt,
16
+ GanttNode,
17
+ GanttTask,
18
+ GanttGroup,
19
+ GanttHolidays,
20
+ ResolvedSchedule,
21
+ ResolvedTask,
22
+ ResolvedGroup,
23
+ Offset,
24
+ } from './types';
25
+ import { collectTasks, resolveTaskName, isResolverError } from './resolver';
26
+ import {
27
+ addGanttDuration,
28
+ buildHolidaySet,
29
+ parseGanttDate,
30
+ daysBetween,
31
+ } from '../utils/duration';
32
+
33
+ // ── Internal types ──────────────────────────────────────────
34
+
35
+ interface TaskNode {
36
+ task: GanttTask;
37
+ /** IDs of tasks this task depends on (both implicit and explicit) */
38
+ predecessors: string[];
39
+ /** Resolved start/end dates (filled during forward pass) */
40
+ startDate: Date | null;
41
+ endDate: Date | null;
42
+ }
43
+
44
+ // ── Main calculator ─────────────────────────────────────────
45
+
46
+ export function calculateSchedule(parsed: ParsedGantt): ResolvedSchedule {
47
+ const diagnostics: DgmoError[] = [...parsed.diagnostics];
48
+ const result: ResolvedSchedule = {
49
+ tasks: [],
50
+ groups: [],
51
+ startDate: new Date(),
52
+ endDate: new Date(),
53
+ holidays: parsed.holidays,
54
+ tagGroups: parsed.tagGroups,
55
+ eras: parsed.eras,
56
+ markers: parsed.markers,
57
+ options: parsed.options,
58
+ diagnostics,
59
+ error: parsed.error,
60
+ };
61
+
62
+ if (parsed.error) return result;
63
+
64
+ const warn = (line: number, message: string): void => {
65
+ diagnostics.push(makeDgmoError(line, message, 'warning'));
66
+ };
67
+
68
+ const fail = (line: number, message: string): ResolvedSchedule => {
69
+ const diag = makeDgmoError(line, message);
70
+ diagnostics.push(diag);
71
+ result.error = formatDgmoError(diag);
72
+ return result;
73
+ };
74
+
75
+ // ── Build holiday set ───────────────────────────────────
76
+
77
+ const holidaySet = buildHolidaySet(parsed.holidays);
78
+
79
+ // ── Determine project start ─────────────────────────────
80
+
81
+ let projectStart: Date;
82
+ if (parsed.options.start) {
83
+ projectStart = parseGanttDate(parsed.options.start);
84
+ } else {
85
+ // Relative timeline: use epoch-like reference (Day 1 = Jan 1, 2000)
86
+ projectStart = new Date(2000, 0, 1);
87
+ }
88
+
89
+ // ── Dep offset storage ─────────────────────────────────
90
+
91
+ const depOffsetMap = new Map<string, Offset>();
92
+
93
+ // ── Collect all tasks ───────────────────────────────────
94
+
95
+ const allTasks = collectTasks(parsed.nodes);
96
+ if (allTasks.length === 0) {
97
+ return result; // empty chart is valid
98
+ }
99
+
100
+ // Build task map by ID
101
+ const taskMap = new Map<string, TaskNode>();
102
+ for (const task of allTasks) {
103
+ taskMap.set(task.id, {
104
+ task,
105
+ predecessors: [],
106
+ startDate: null,
107
+ endDate: null,
108
+ });
109
+ }
110
+
111
+ // ── Build implicit sequential dependencies ──────────────
112
+
113
+ buildImplicitDeps(parsed.nodes, taskMap);
114
+
115
+ // ── Resolve explicit -> dependencies ────────────────────
116
+
117
+ for (const task of allTasks) {
118
+ const node = taskMap.get(task.id)!;
119
+ for (const dep of task.dependencies) {
120
+ const resolved = resolveTaskName(dep.targetName, allTasks);
121
+ if (isResolverError(resolved)) {
122
+ if (resolved.kind === 'ambiguous') {
123
+ return fail(dep.lineNumber, `\`-> ${dep.targetName}\` — ${resolved.message}`);
124
+ } else {
125
+ return fail(dep.lineNumber, `\`-> ${dep.targetName}\` — ${resolved.message}`);
126
+ }
127
+ }
128
+
129
+ // The dependency means: target starts after source
130
+ // In our model: target's predecessor is source
131
+ const targetNode = taskMap.get(resolved.task.id);
132
+ if (targetNode) {
133
+ // Check for redundant dependency (already a sequential predecessor)
134
+ if (targetNode.predecessors.includes(task.id)) {
135
+ warn(dep.lineNumber, `Redundant dependency: "${dep.targetName}" already follows "${task.label}" sequentially. Did you mean to wrap groups in \`parallel\`?`);
136
+ } else {
137
+ targetNode.predecessors.push(task.id);
138
+ // Store dep offset info — we need it during scheduling
139
+ if (dep.offset) {
140
+ depOffsetMap.set(`${task.id}->${resolved.task.id}`, dep.offset);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ // ── Topological sort with cycle detection ───────────────
148
+
149
+ const sortedIds = topologicalSort(taskMap);
150
+ if (!sortedIds) {
151
+ // Find cycle for error message
152
+ const cycle = findCycle(taskMap);
153
+ const cycleStr = cycle.map(id => taskMap.get(id)!.task.label).join(' → ');
154
+ return fail(
155
+ taskMap.get(cycle[0])!.task.lineNumber,
156
+ `Circular dependency detected: ${cycleStr}`,
157
+ );
158
+ }
159
+
160
+ // ── Forward pass: resolve dates ─────────────────────────
161
+
162
+ for (const taskId of sortedIds) {
163
+ const node = taskMap.get(taskId)!;
164
+ const task = node.task;
165
+
166
+ // Determine start date: max of all predecessors' end dates (+ dep offset)
167
+ let start: Date;
168
+
169
+ if (task.explicitStart) {
170
+ // Explicit date anchor
171
+ start = parseGanttDate(task.explicitStart);
172
+ } else if (node.predecessors.length === 0) {
173
+ // No predecessors: starts at project start
174
+ start = new Date(projectStart);
175
+ } else {
176
+ // Universal max rule: start = max(all predecessor end dates + dep offset)
177
+ start = new Date(0); // epoch
178
+ for (const predId of node.predecessors) {
179
+ const predNode = taskMap.get(predId)!;
180
+ if (!predNode.endDate) continue; // shouldn't happen after topo sort
181
+
182
+ let predEnd = new Date(predNode.endDate);
183
+
184
+ // Apply dep offset if present
185
+ const depOffset = depOffsetMap.get(`${predId}->${taskId}`);
186
+ if (depOffset) {
187
+ predEnd = addGanttDuration(predEnd, depOffset.duration, parsed.holidays, holidaySet, depOffset.direction);
188
+ }
189
+
190
+ if (predEnd.getTime() > start.getTime()) {
191
+ start = predEnd;
192
+ }
193
+ }
194
+ }
195
+
196
+ // Apply task-level offset (shifts start forward or backward)
197
+ if (task.offset) {
198
+ start = addGanttDuration(start, task.offset.duration, parsed.holidays, holidaySet, task.offset.direction);
199
+ if (start.getTime() < projectStart.getTime()) {
200
+ warn(task.lineNumber, `Negative offset on task '${task.label}' exceeds available range; start clamped to project start.`);
201
+ start = new Date(projectStart);
202
+ }
203
+ } else if (start.getTime() < projectStart.getTime()) {
204
+ warn(task.lineNumber, `Negative offset on dependency exceeds available range; start of '${task.label}' clamped to project start.`);
205
+ start = new Date(projectStart);
206
+ }
207
+
208
+ // If explicit start (+ offset) conflicts with predecessors, warn but honor explicit
209
+ if (task.explicitStart && node.predecessors.length > 0) {
210
+ let maxPredEnd = new Date(0);
211
+ for (const predId of node.predecessors) {
212
+ const predNode = taskMap.get(predId)!;
213
+ if (predNode.endDate && predNode.endDate.getTime() > maxPredEnd.getTime()) {
214
+ maxPredEnd = predNode.endDate;
215
+ }
216
+ }
217
+ if (start.getTime() < maxPredEnd.getTime()) {
218
+ warn(task.lineNumber, `Explicit date ${task.explicitStart}${task.offset ? ' (with offset)' : ''} overlaps with predecessor ending ${formatDate(maxPredEnd)}. Using explicit date.`);
219
+ }
220
+ }
221
+
222
+ // Calculate end date
223
+ let end: Date;
224
+ if (task.duration) {
225
+ if (task.duration.amount === 0) {
226
+ // Milestone: zero duration, end = start
227
+ end = new Date(start);
228
+ } else {
229
+ end = addGanttDuration(start, task.duration, parsed.holidays, holidaySet);
230
+ }
231
+ } else {
232
+ // Explicit date task with no duration = milestone at that date
233
+ end = new Date(start);
234
+ }
235
+
236
+ node.startDate = start;
237
+ node.endDate = end;
238
+ }
239
+
240
+ // ── Build resolved tasks ────────────────────────────────
241
+
242
+ // Critical path calculation (if enabled)
243
+ const criticalSet = parsed.options.criticalPath
244
+ ? computeCriticalPath(sortedIds, taskMap, depOffsetMap, parsed.holidays, holidaySet)
245
+ : new Set<string>();
246
+
247
+ // Cascading uncertainty: uncertain if task itself is uncertain OR any predecessor is
248
+ const uncertainSet = new Set<string>();
249
+ for (const taskId of sortedIds) {
250
+ const node = taskMap.get(taskId)!;
251
+ if (node.task.uncertain) {
252
+ uncertainSet.add(taskId);
253
+ } else {
254
+ for (const predId of node.predecessors) {
255
+ if (uncertainSet.has(predId)) {
256
+ uncertainSet.add(taskId);
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ for (const taskId of sortedIds) {
264
+ const node = taskMap.get(taskId)!;
265
+ result.tasks.push({
266
+ task: node.task,
267
+ startDate: node.startDate!,
268
+ endDate: node.endDate!,
269
+ isCriticalPath: criticalSet.has(taskId),
270
+ isUncertain: uncertainSet.has(taskId),
271
+ isMilestone: (node.task.duration?.amount === 0) || (!node.task.duration && !node.task.explicitStart),
272
+ groupPath: node.task.groupPath,
273
+ effectiveMetadata: node.task.metadata,
274
+ });
275
+ }
276
+
277
+ // ── Build resolved groups ───────────────────────────────
278
+
279
+ buildResolvedGroups(parsed.nodes, taskMap, result.groups, 0);
280
+
281
+ // ── Compute overall date range ──────────────────────────
282
+
283
+ if (result.tasks.length > 0) {
284
+ let minDate = result.tasks[0].startDate;
285
+ let maxDate = result.tasks[0].endDate;
286
+ for (const t of result.tasks) {
287
+ if (t.startDate.getTime() < minDate.getTime()) minDate = t.startDate;
288
+ if (t.endDate.getTime() > maxDate.getTime()) maxDate = t.endDate;
289
+ }
290
+ result.startDate = minDate;
291
+ result.endDate = maxDate;
292
+ }
293
+
294
+ // ── Warnings ────────────────────────────────────────────
295
+
296
+ // Missing parallel warning: 2+ top-level groups without parallel wrapper
297
+ const topLevelGroups = parsed.nodes.filter(n => n.kind === 'group');
298
+ if (topLevelGroups.length >= 2) {
299
+ const names = topLevelGroups.map(g => (g as GanttGroup & { kind: 'group' }).name);
300
+ warn(
301
+ topLevelGroups[0].lineNumber,
302
+ `${names.join(' and ')} are sequential. Wrap in \`parallel\` if they should run concurrently.`,
303
+ );
304
+ }
305
+
306
+ return result;
307
+ }
308
+
309
+ // ── Implicit dependency builder ─────────────────────────────
310
+
311
+ /**
312
+ * Walk the gantt tree and create implicit sequential dependencies.
313
+ * - Siblings are sequential (each task depends on the previous sibling)
314
+ * - Parallel block children all share the parent's predecessor
315
+ * - Groups: children are sequential within the group
316
+ */
317
+ function buildImplicitDeps(
318
+ nodes: GanttNode[],
319
+ taskMap: Map<string, TaskNode>,
320
+ ): void {
321
+ walkChildren(nodes, null);
322
+
323
+ function walkChildren(children: GanttNode[], afterTaskId: string | null) {
324
+ let prevTaskId = afterTaskId;
325
+
326
+ for (const node of children) {
327
+ if (node.kind === 'task') {
328
+ if (prevTaskId) {
329
+ const taskNode = taskMap.get(node.id);
330
+ if (taskNode && !taskNode.predecessors.includes(prevTaskId)) {
331
+ taskNode.predecessors.push(prevTaskId);
332
+ }
333
+ }
334
+ prevTaskId = node.id;
335
+ } else if (node.kind === 'group') {
336
+ // Walk group children sequentially, starting after prevTaskId
337
+ const lastId = walkSequential(node.children, prevTaskId);
338
+ if (lastId) prevTaskId = lastId;
339
+ } else if (node.kind === 'parallel') {
340
+ // All parallel children start after prevTaskId
341
+ // The parallel block "ends" when all children end
342
+ // We need to find the last task in each branch
343
+ const branchLastIds: string[] = [];
344
+ for (const child of node.children) {
345
+ if (child.kind === 'task') {
346
+ if (prevTaskId) {
347
+ const taskNode = taskMap.get(child.id);
348
+ if (taskNode && !taskNode.predecessors.includes(prevTaskId)) {
349
+ taskNode.predecessors.push(prevTaskId);
350
+ }
351
+ }
352
+ branchLastIds.push(child.id);
353
+ } else if (child.kind === 'group') {
354
+ // First task in group depends on prevTaskId
355
+ const firstId = findFirstTask(child.children);
356
+ if (firstId && prevTaskId) {
357
+ const taskNode = taskMap.get(firstId);
358
+ if (taskNode && !taskNode.predecessors.includes(prevTaskId)) {
359
+ taskNode.predecessors.push(prevTaskId);
360
+ }
361
+ }
362
+ // Walk sequentially within the group
363
+ walkSequential(child.children, null); // internal deps only
364
+ const lastId = findLastTask(child.children);
365
+ if (lastId) branchLastIds.push(lastId);
366
+ } else if (child.kind === 'parallel') {
367
+ // Nested parallel — recurse
368
+ walkChildren([child], prevTaskId);
369
+ const lastId = findLastTask(child.children);
370
+ if (lastId) branchLastIds.push(lastId);
371
+ }
372
+ }
373
+ // After parallel, next sibling depends on ALL branch ends
374
+ if (branchLastIds.length > 0) {
375
+ prevTaskId = null;
376
+ const nextIdx = children.indexOf(node) + 1;
377
+ if (nextIdx < children.length) {
378
+ const nextFirstId = findFirstTask([children[nextIdx]]);
379
+ if (nextFirstId) {
380
+ const nextNode = taskMap.get(nextFirstId);
381
+ if (nextNode) {
382
+ for (const branchId of branchLastIds) {
383
+ if (!nextNode.predecessors.includes(branchId)) {
384
+ nextNode.predecessors.push(branchId);
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ function walkSequential(children: GanttNode[], afterTaskId: string | null): string | null {
396
+ let prevTaskId = afterTaskId;
397
+ for (const node of children) {
398
+ if (node.kind === 'task') {
399
+ if (prevTaskId) {
400
+ const taskNode = taskMap.get(node.id);
401
+ if (taskNode && !taskNode.predecessors.includes(prevTaskId)) {
402
+ taskNode.predecessors.push(prevTaskId);
403
+ }
404
+ }
405
+ prevTaskId = node.id;
406
+ } else if (node.kind === 'group') {
407
+ const lastId = walkSequential(node.children, prevTaskId);
408
+ if (lastId) prevTaskId = lastId;
409
+ } else if (node.kind === 'parallel') {
410
+ walkChildren([node], prevTaskId);
411
+ const lastId = findLastTask(node.children);
412
+ if (lastId) prevTaskId = lastId;
413
+ }
414
+ }
415
+ return prevTaskId;
416
+ }
417
+ }
418
+
419
+ /** Find the first task ID in a tree (depth-first). */
420
+ function findFirstTask(nodes: GanttNode[]): string | null {
421
+ for (const node of nodes) {
422
+ if (node.kind === 'task') return node.id;
423
+ if (node.kind === 'group' || node.kind === 'parallel') {
424
+ const found = findFirstTask(node.children);
425
+ if (found) return found;
426
+ }
427
+ }
428
+ return null;
429
+ }
430
+
431
+ /** Find the last task ID in a tree (depth-first, last branch). */
432
+ function findLastTask(nodes: GanttNode[]): string | null {
433
+ for (let i = nodes.length - 1; i >= 0; i--) {
434
+ const node = nodes[i];
435
+ if (node.kind === 'task') return node.id;
436
+ if (node.kind === 'group' || node.kind === 'parallel') {
437
+ const found = findLastTask(node.children);
438
+ if (found) return found;
439
+ }
440
+ }
441
+ return null;
442
+ }
443
+
444
+ // ── Topological sort ────────────────────────────────────────
445
+
446
+ function topologicalSort(taskMap: Map<string, TaskNode>): string[] | null {
447
+ const inDegree = new Map<string, number>();
448
+ const adjacency = new Map<string, string[]>();
449
+
450
+ for (const [id, node] of taskMap) {
451
+ inDegree.set(id, node.predecessors.length);
452
+ for (const pred of node.predecessors) {
453
+ const adj = adjacency.get(pred) ?? [];
454
+ adj.push(id);
455
+ adjacency.set(pred, adj);
456
+ }
457
+ }
458
+
459
+ const queue: string[] = [];
460
+ for (const [id, degree] of inDegree) {
461
+ if (degree === 0) queue.push(id);
462
+ }
463
+
464
+ const sorted: string[] = [];
465
+ while (queue.length > 0) {
466
+ const current = queue.shift()!;
467
+ sorted.push(current);
468
+ for (const succ of adjacency.get(current) ?? []) {
469
+ const newDegree = (inDegree.get(succ) ?? 1) - 1;
470
+ inDegree.set(succ, newDegree);
471
+ if (newDegree === 0) queue.push(succ);
472
+ }
473
+ }
474
+
475
+ return sorted.length === taskMap.size ? sorted : null;
476
+ }
477
+
478
+ function findCycle(taskMap: Map<string, TaskNode>): string[] {
479
+ const visited = new Set<string>();
480
+ const onStack = new Set<string>();
481
+ const parent = new Map<string, string>();
482
+
483
+ for (const id of taskMap.keys()) {
484
+ if (!visited.has(id)) {
485
+ const cycle = dfs(id);
486
+ if (cycle) return cycle;
487
+ }
488
+ }
489
+ return [];
490
+
491
+ function dfs(id: string): string[] | null {
492
+ visited.add(id);
493
+ onStack.add(id);
494
+
495
+ const successors: string[] = [];
496
+ for (const [otherId, otherNode] of taskMap) {
497
+ if (otherNode.predecessors.includes(id)) {
498
+ successors.push(otherId);
499
+ }
500
+ }
501
+
502
+ for (const succ of successors) {
503
+ if (!visited.has(succ)) {
504
+ parent.set(succ, id);
505
+ const cycle = dfs(succ);
506
+ if (cycle) return cycle;
507
+ } else if (onStack.has(succ)) {
508
+ const cycle = [succ];
509
+ let current = id;
510
+ while (current !== succ) {
511
+ cycle.push(current);
512
+ current = parent.get(current)!;
513
+ }
514
+ cycle.push(succ);
515
+ return cycle.reverse();
516
+ }
517
+ }
518
+
519
+ onStack.delete(id);
520
+ return null;
521
+ }
522
+ }
523
+
524
+ // ── Critical path ───────────────────────────────────────────
525
+
526
+ function computeCriticalPath(
527
+ sortedIds: string[],
528
+ taskMap: Map<string, TaskNode>,
529
+ depOffsetMap: Map<string, Offset>,
530
+ holidays: GanttHolidays,
531
+ holidaySet: Set<string>,
532
+ ): Set<string> {
533
+ if (sortedIds.length === 0) return new Set();
534
+
535
+ const latestEnd = new Map<string, number>();
536
+ const latestStart = new Map<string, number>();
537
+
538
+ let projectEnd = 0;
539
+ for (const id of sortedIds) {
540
+ const node = taskMap.get(id)!;
541
+ if (node.endDate && node.endDate.getTime() > projectEnd) {
542
+ projectEnd = node.endDate.getTime();
543
+ }
544
+ }
545
+
546
+ const successors = new Map<string, string[]>();
547
+ for (const [id, node] of taskMap) {
548
+ for (const pred of node.predecessors) {
549
+ const succs = successors.get(pred) ?? [];
550
+ succs.push(id);
551
+ successors.set(pred, succs);
552
+ }
553
+ }
554
+
555
+ // Backward pass (reverse order)
556
+ for (let i = sortedIds.length - 1; i >= 0; i--) {
557
+ const id = sortedIds[i];
558
+ const node = taskMap.get(id)!;
559
+ const succs = successors.get(id) ?? [];
560
+
561
+ if (succs.length === 0) {
562
+ latestEnd.set(id, projectEnd);
563
+ } else {
564
+ let minVal = Infinity;
565
+ for (const succId of succs) {
566
+ let succLS = latestStart.get(succId);
567
+ if (succLS === undefined) continue;
568
+
569
+ // Reverse successor's task-level offset
570
+ const succTask = taskMap.get(succId)!.task;
571
+ if (succTask.offset) {
572
+ const reverseDir = (succTask.offset.direction * -1) as 1 | -1;
573
+ const adjusted = addGanttDuration(new Date(succLS), succTask.offset.duration, holidays, holidaySet, reverseDir);
574
+ succLS = adjusted.getTime();
575
+ }
576
+
577
+ // Reverse dep offset
578
+ const depOffset = depOffsetMap.get(`${id}->${succId}`);
579
+ if (depOffset) {
580
+ const reverseDir = (depOffset.direction * -1) as 1 | -1;
581
+ const adjusted = addGanttDuration(new Date(succLS), depOffset.duration, holidays, holidaySet, reverseDir);
582
+ succLS = adjusted.getTime();
583
+ }
584
+
585
+ if (succLS < minVal) {
586
+ minVal = succLS;
587
+ }
588
+ }
589
+ latestEnd.set(id, minVal);
590
+ }
591
+
592
+ const duration = node.endDate!.getTime() - node.startDate!.getTime();
593
+ latestStart.set(id, latestEnd.get(id)! - duration);
594
+ }
595
+
596
+ const critical = new Set<string>();
597
+ for (const id of sortedIds) {
598
+ const node = taskMap.get(id)!;
599
+ const slack = (latestStart.get(id) ?? 0) - node.startDate!.getTime();
600
+ if (Math.abs(slack) < 86400000) {
601
+ critical.add(id);
602
+ }
603
+ }
604
+
605
+ return critical;
606
+ }
607
+
608
+ // ── Resolved groups builder ─────────────────────────────────
609
+
610
+ function buildResolvedGroups(
611
+ nodes: GanttNode[],
612
+ taskMap: Map<string, TaskNode>,
613
+ groups: ResolvedGroup[],
614
+ depth: number,
615
+ ): void {
616
+ for (const node of nodes) {
617
+ if (node.kind === 'group') {
618
+ const childTasks = collectTasks(node.children);
619
+ if (childTasks.length === 0) {
620
+ groups.push({
621
+ name: node.name,
622
+ color: node.color,
623
+ metadata: node.metadata,
624
+ startDate: new Date(),
625
+ endDate: new Date(),
626
+ progress: null,
627
+ lineNumber: node.lineNumber,
628
+ depth,
629
+ });
630
+ continue;
631
+ }
632
+
633
+ let minStart = Infinity;
634
+ let maxEnd = -Infinity;
635
+ let totalProgress = 0;
636
+ let totalDuration = 0;
637
+ let hasProgress = false;
638
+
639
+ for (const task of childTasks) {
640
+ const resolved = taskMap.get(task.id);
641
+ if (!resolved?.startDate || !resolved?.endDate) continue;
642
+ if (resolved.startDate.getTime() < minStart) minStart = resolved.startDate.getTime();
643
+ if (resolved.endDate.getTime() > maxEnd) maxEnd = resolved.endDate.getTime();
644
+ if (task.progress !== null) {
645
+ const dur = resolved.endDate.getTime() - resolved.startDate.getTime();
646
+ totalProgress += task.progress * dur;
647
+ totalDuration += dur;
648
+ hasProgress = true;
649
+ }
650
+ }
651
+
652
+ groups.push({
653
+ name: node.name,
654
+ color: node.color,
655
+ metadata: node.metadata,
656
+ startDate: new Date(minStart === Infinity ? 0 : minStart),
657
+ endDate: new Date(maxEnd === -Infinity ? 0 : maxEnd),
658
+ progress: hasProgress && totalDuration > 0 ? totalProgress / totalDuration : null,
659
+ lineNumber: node.lineNumber,
660
+ depth,
661
+ });
662
+
663
+ buildResolvedGroups(node.children, taskMap, groups, depth + 1);
664
+ } else if (node.kind === 'parallel') {
665
+ buildResolvedGroups(node.children, taskMap, groups, depth);
666
+ }
667
+ }
668
+ }
669
+
670
+ // ── Utility ─────────────────────────────────────────────────
671
+
672
+ function formatDate(d: Date): string {
673
+ const y = d.getFullYear();
674
+ const m = String(d.getMonth() + 1).padStart(2, '0');
675
+ const day = String(d.getDate()).padStart(2, '0');
676
+ return `${y}-${m}-${day}`;
677
+ }