construction-gantt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +5 -0
  3. package/dist/export/index.cjs +1 -0
  4. package/dist/export/index.d.cts +112 -0
  5. package/dist/export/index.d.cts.map +1 -0
  6. package/dist/export/index.d.ts +112 -0
  7. package/dist/export/index.d.ts.map +1 -0
  8. package/dist/export/index.js +1 -0
  9. package/dist/index.cjs +3492 -0
  10. package/dist/index.d.cts +628 -0
  11. package/dist/index.d.cts.map +1 -0
  12. package/dist/index.d.ts +628 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +3461 -0
  15. package/dist/pdf-CAQDrX0w.cjs +120 -0
  16. package/dist/pdf-CBaoJRTI.js +120 -0
  17. package/dist/png-C8t74695.cjs +88 -0
  18. package/dist/png-DKZeKnRh.js +88 -0
  19. package/dist/xlsx-5FRPFck7.js +89 -0
  20. package/dist/xlsx-Gh5L_NL3.cjs +111 -0
  21. package/package.json +86 -0
  22. package/src/Gantt.css +23 -0
  23. package/src/Gantt.tsx +636 -0
  24. package/src/SpikeGantt.tsx +114 -0
  25. package/src/analysis.ts +83 -0
  26. package/src/baseline.ts +119 -0
  27. package/src/calendars/canterbury-table.ts +44 -0
  28. package/src/calendars/internal/computus.ts +25 -0
  29. package/src/calendars/internal/date-utils.ts +13 -0
  30. package/src/calendars/internal/mondayisation.ts +46 -0
  31. package/src/calendars/internal/month-rules.ts +65 -0
  32. package/src/calendars/matariki-table.ts +63 -0
  33. package/src/calendars/nz-holidays.ts +214 -0
  34. package/src/editing/command-history.ts +78 -0
  35. package/src/editing/commands.ts +327 -0
  36. package/src/editing/composite-command.ts +64 -0
  37. package/src/editing/draft-project.ts +59 -0
  38. package/src/editing/errors.ts +14 -0
  39. package/src/editing/factories.ts +92 -0
  40. package/src/editing/use-editable-project.ts +122 -0
  41. package/src/export/index.ts +12 -0
  42. package/src/export/offscreen.tsx +89 -0
  43. package/src/export/pdf-dimensions.ts +64 -0
  44. package/src/export/pdf.ts +68 -0
  45. package/src/export/png.ts +48 -0
  46. package/src/export/types.ts +42 -0
  47. package/src/export/xlsx.ts +70 -0
  48. package/src/index.ts +89 -0
  49. package/src/mspdi/parse.ts +820 -0
  50. package/src/mspdi/serialize.ts +352 -0
  51. package/src/mspdi/types.ts +53 -0
  52. package/src/schedule.ts +470 -0
  53. package/src/topological-sort.ts +51 -0
  54. package/src/types.ts +254 -0
  55. package/src/visibility.ts +35 -0
  56. package/src/working-time.ts +235 -0
@@ -0,0 +1,470 @@
1
+ // Forward + backward pass + critical path + constraint resolution.
2
+ //
3
+ // Algorithm:
4
+ // 1. Kahn-ordered topological sort over the link graph.
5
+ // 2. Forward pass: compute earlyStart/earlyFinish per task. After
6
+ // predecessor-based candidates, apply forward-direction constraints
7
+ // (ASAP/MSO/MFO/SNET/FNET).
8
+ // 3. Backward pass: compute lateStart/lateFinish per task working right to
9
+ // left. After successor-based candidates, apply backward-direction
10
+ // constraints (MSO/MFO/SNLT/FNLT). MSO/MFO are hard pins — they lock
11
+ // both early and late dates to the constraint, so predecessors that
12
+ // can't deliver in time get negative slack on themselves.
13
+ // 4. Slack: totalSlack = workingMinutesBetween(earlyStart, lateStart) in
14
+ // working time. freeSlack = min working gap to earliest successor's
15
+ // required start. isCritical = totalSlack <= 0.
16
+ //
17
+ // Per ADR-003, negative slack is preserved, not clipped to zero. A task
18
+ // with negative slack is already late against a downstream constraint;
19
+ // surfacing that is the differentiator vs every existing alternative.
20
+ //
21
+ // ALAP semantics (consume slack to push the task to its latest position)
22
+ // are deferred — full ALAP requires a second forward pass after the
23
+ // backward pass to re-flow downstream dates. For now ALAP is parsed but
24
+ // behaves like ASAP.
25
+
26
+ import { topologicalSort } from './topological-sort';
27
+ import type { Calendar, Link, Project, Task, TaskComputed, TaskId } from './types';
28
+ import {
29
+ addWorkingMinutes,
30
+ snapToNextWorkingMoment,
31
+ snapToPreviousWorkingMoment,
32
+ subtractWorkingMinutes,
33
+ workingMinutesBetween,
34
+ } from './working-time';
35
+
36
+ interface ForwardDates {
37
+ earlyStart: Date;
38
+ earlyFinish: Date;
39
+ }
40
+
41
+ interface BackwardDates {
42
+ lateStart: Date;
43
+ lateFinish: Date;
44
+ }
45
+
46
+ export function schedule(project: Project): Project {
47
+ const calendar = getDefaultCalendar(project);
48
+ const sorted = topologicalSort(project.tasks, project.links);
49
+ const taskById = new Map<TaskId, Task>(sorted.map((t) => [t.id, t]));
50
+ const childrenByParent = groupChildrenByParent(project.tasks);
51
+ const summariesByDepthDesc = summariesDeepestFirst(project.tasks);
52
+
53
+ // Forward pass — leaves first, summaries bottom-up after.
54
+ const forwardById = new Map<TaskId, ForwardDates>();
55
+ const projectFloor = snapToNextWorkingMoment(project.start, calendar);
56
+
57
+ for (const task of sorted) {
58
+ if (task.type === 'summary') continue; // aggregated below
59
+
60
+ if (task.scheduleMode === 'manual') {
61
+ // Manual: user-set dates are authoritative. Skip predecessor logic and
62
+ // constraint application — MS Project semantics. We still populate
63
+ // forwardById so the backward pass can compute slack against the
64
+ // network the user has drawn.
65
+ forwardById.set(task.id, {
66
+ earlyStart: new Date(task.start),
67
+ earlyFinish: new Date(task.end),
68
+ });
69
+ continue;
70
+ }
71
+
72
+ let earliest = projectFloor;
73
+ for (const link of incomingLinks(task.id, project.links)) {
74
+ const source = taskById.get(link.source);
75
+ const sourceFwd = forwardById.get(link.source);
76
+ if (!source || !sourceFwd) continue;
77
+ const fromLink = earliestStartFromLink(link, source, task, sourceFwd, calendar);
78
+ if (fromLink > earliest) earliest = fromLink;
79
+ }
80
+ forwardById.set(task.id, applyForwardConstraint(task, earliest, calendar));
81
+ }
82
+
83
+ // Summary forward aggregation: min(child earlyStart), max(child earlyFinish).
84
+ // Deepest-first so a summary that contains other summaries sees its
85
+ // descendants already aggregated.
86
+ for (const summary of summariesByDepthDesc) {
87
+ const aggregated = aggregateFromChildren(summary, childrenByParent, forwardById);
88
+ if (aggregated) forwardById.set(summary.id, aggregated);
89
+ }
90
+
91
+ // Backward pass — leaves first (reverse-sorted), summaries bottom-up after.
92
+ const projectCeiling = projectFinishAnchor(project, forwardById, calendar);
93
+ const backwardById = new Map<TaskId, BackwardDates>();
94
+
95
+ for (const task of [...sorted].reverse()) {
96
+ if (task.type === 'summary') continue;
97
+
98
+ let latest = projectCeiling;
99
+ for (const link of outgoingLinks(task.id, project.links)) {
100
+ const target = taskById.get(link.target);
101
+ const targetBwd = backwardById.get(link.target);
102
+ if (!target || !targetBwd) continue;
103
+ const fromLink = latestFinishFromLink(link, task, target, targetBwd, calendar);
104
+ if (fromLink < latest) latest = fromLink;
105
+ }
106
+ const fwd = forwardById.get(task.id);
107
+ if (!fwd) continue;
108
+ backwardById.set(task.id, applyBackwardConstraint(task, latest, fwd, calendar));
109
+ }
110
+
111
+ // Summary backward aggregation: min(child lateStart), max(child lateFinish).
112
+ for (const summary of summariesByDepthDesc) {
113
+ const aggregated = aggregateBackwardFromChildren(summary, childrenByParent, backwardById);
114
+ if (aggregated) backwardById.set(summary.id, aggregated);
115
+ }
116
+
117
+ // Assemble final tasks with computed slack + critical.
118
+ // For auto-mode tasks, write the scheduled dates back to task.start/end
119
+ // (MS Project default behavior). Manual-mode tasks keep their user-set
120
+ // dates regardless.
121
+ const newTasks = project.tasks.map((t): Task => {
122
+ const f = forwardById.get(t.id);
123
+ const b = backwardById.get(t.id);
124
+ if (!f || !b) return t;
125
+ const totalSlack = workingMinutesBetween(f.earlyStart, b.lateStart, calendar);
126
+ const freeSlack = computeFreeSlack(t, f, forwardById, project.links, calendar);
127
+ const computed: TaskComputed = {
128
+ earlyStart: f.earlyStart,
129
+ earlyFinish: f.earlyFinish,
130
+ lateStart: b.lateStart,
131
+ lateFinish: b.lateFinish,
132
+ totalSlack,
133
+ freeSlack,
134
+ isCritical: totalSlack <= 0,
135
+ };
136
+ if (t.type === 'summary') {
137
+ // Summary: dates + duration derived from child span; always overwritten.
138
+ return {
139
+ ...t,
140
+ start: new Date(f.earlyStart),
141
+ end: new Date(f.earlyFinish),
142
+ duration: workingMinutesBetween(f.earlyStart, f.earlyFinish, calendar),
143
+ computed,
144
+ };
145
+ }
146
+ if (t.scheduleMode === 'auto') {
147
+ return { ...t, start: new Date(f.earlyStart), end: new Date(f.earlyFinish), computed };
148
+ }
149
+ return { ...t, computed };
150
+ });
151
+
152
+ return { ...project, tasks: newTasks };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Constraint application
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function applyForwardConstraint(
160
+ task: Task,
161
+ predecessorEarliestStart: Date,
162
+ calendar: Calendar,
163
+ ): ForwardDates {
164
+ const baseStart = snapToNextWorkingMoment(predecessorEarliestStart, calendar);
165
+ const baseFinish = addWorkingMinutes(baseStart, task.duration, calendar);
166
+ const base: ForwardDates = { earlyStart: baseStart, earlyFinish: baseFinish };
167
+
168
+ const c = task.constraint;
169
+ if (!c) return base;
170
+
171
+ switch (c.type) {
172
+ case 'ASAP':
173
+ case 'ALAP': // ALAP applied (or rather, not applied) at this layer
174
+ return base;
175
+
176
+ // For constraint dates we trust the user-supplied moment as-is. Snapping
177
+ // gets fiddly for finish-end-of-interval boundaries (5pm is a valid
178
+ // finish but not a valid start). Document: constraint dates should be
179
+ // working-time moments; weird inputs produce weird outputs.
180
+
181
+ case 'MSO': {
182
+ if (!c.date) return base;
183
+ const earlyStart = new Date(c.date);
184
+ const earlyFinish = addWorkingMinutes(earlyStart, task.duration, calendar);
185
+ return { earlyStart, earlyFinish };
186
+ }
187
+
188
+ case 'MFO': {
189
+ if (!c.date) return base;
190
+ const earlyFinish = new Date(c.date);
191
+ const earlyStart = subtractWorkingMinutes(earlyFinish, task.duration, calendar);
192
+ return { earlyStart, earlyFinish };
193
+ }
194
+
195
+ case 'SNET': {
196
+ if (!c.date) return base;
197
+ const earlyStart = c.date > baseStart ? new Date(c.date) : baseStart;
198
+ const earlyFinish = addWorkingMinutes(earlyStart, task.duration, calendar);
199
+ return { earlyStart, earlyFinish };
200
+ }
201
+
202
+ case 'FNET': {
203
+ if (!c.date) return base;
204
+ if (c.date <= baseFinish) return base;
205
+ const earlyFinish = new Date(c.date);
206
+ const earlyStart = subtractWorkingMinutes(earlyFinish, task.duration, calendar);
207
+ return { earlyStart, earlyFinish };
208
+ }
209
+
210
+ case 'SNLT':
211
+ case 'FNLT':
212
+ // Backward-direction constraints; no forward-pass effect.
213
+ return base;
214
+ }
215
+ }
216
+
217
+ function applyBackwardConstraint(
218
+ task: Task,
219
+ successorLatestFinish: Date,
220
+ forward: ForwardDates,
221
+ calendar: Calendar,
222
+ ): BackwardDates {
223
+ const baseLateFinish = snapToPreviousWorkingMoment(successorLatestFinish, calendar);
224
+ const baseLateStart = subtractWorkingMinutes(baseLateFinish, task.duration, calendar);
225
+ const base: BackwardDates = { lateStart: baseLateStart, lateFinish: baseLateFinish };
226
+
227
+ const c = task.constraint;
228
+ if (!c) return base;
229
+
230
+ switch (c.type) {
231
+ case 'ASAP':
232
+ case 'ALAP':
233
+ case 'SNET':
234
+ case 'FNET':
235
+ return base;
236
+
237
+ case 'MSO':
238
+ case 'MFO':
239
+ // Hard pin: task is locked at the forward-pass date. Slack on the
240
+ // task itself is zero; impossibility propagates back to predecessors.
241
+ return { lateStart: forward.earlyStart, lateFinish: forward.earlyFinish };
242
+
243
+ case 'SNLT': {
244
+ if (!c.date) return base;
245
+ const lateStart = c.date < baseLateStart ? new Date(c.date) : baseLateStart;
246
+ const lateFinish = addWorkingMinutes(lateStart, task.duration, calendar);
247
+ return { lateStart, lateFinish };
248
+ }
249
+
250
+ case 'FNLT': {
251
+ if (!c.date) return base;
252
+ const lateFinish = c.date < baseLateFinish ? new Date(c.date) : baseLateFinish;
253
+ const lateStart = subtractWorkingMinutes(lateFinish, task.duration, calendar);
254
+ return { lateStart, lateFinish };
255
+ }
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Link semantics
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function earliestStartFromLink(
264
+ link: Link,
265
+ _source: Task,
266
+ target: Task,
267
+ sourceFwd: ForwardDates,
268
+ calendar: Calendar,
269
+ ): Date {
270
+ switch (link.type) {
271
+ case 'FS':
272
+ return addWorkingTime(sourceFwd.earlyFinish, link.lag, calendar);
273
+ case 'SS':
274
+ return addWorkingTime(sourceFwd.earlyStart, link.lag, calendar);
275
+ case 'FF': {
276
+ const finishConstraint = addWorkingTime(sourceFwd.earlyFinish, link.lag, calendar);
277
+ return subtractWorkingMinutes(finishConstraint, target.duration, calendar);
278
+ }
279
+ case 'SF': {
280
+ const finishConstraint = addWorkingTime(sourceFwd.earlyStart, link.lag, calendar);
281
+ return subtractWorkingMinutes(finishConstraint, target.duration, calendar);
282
+ }
283
+ }
284
+ }
285
+
286
+ function latestFinishFromLink(
287
+ link: Link,
288
+ source: Task,
289
+ _target: Task,
290
+ targetBwd: BackwardDates,
291
+ calendar: Calendar,
292
+ ): Date {
293
+ switch (link.type) {
294
+ case 'FS':
295
+ return subtractWorkingMinutes(targetBwd.lateStart, link.lag, calendar);
296
+ case 'SS': {
297
+ const sourceLateStart = subtractWorkingMinutes(targetBwd.lateStart, link.lag, calendar);
298
+ return addWorkingMinutes(sourceLateStart, source.duration, calendar);
299
+ }
300
+ case 'FF':
301
+ return subtractWorkingMinutes(targetBwd.lateFinish, link.lag, calendar);
302
+ case 'SF': {
303
+ const sourceLateStart = subtractWorkingMinutes(targetBwd.lateFinish, link.lag, calendar);
304
+ return addWorkingMinutes(sourceLateStart, source.duration, calendar);
305
+ }
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Helpers
311
+ // ---------------------------------------------------------------------------
312
+
313
+ function getDefaultCalendar(project: Project): Calendar {
314
+ const calendar = project.calendars.find((c) => c.id === project.defaultCalendarId);
315
+ if (!calendar) {
316
+ throw new Error(`Project default calendar "${project.defaultCalendarId}" not found`);
317
+ }
318
+ return calendar;
319
+ }
320
+
321
+ function incomingLinks(taskId: TaskId, links: Link[]): Link[] {
322
+ return links.filter((l) => l.target === taskId);
323
+ }
324
+
325
+ function outgoingLinks(taskId: TaskId, links: Link[]): Link[] {
326
+ return links.filter((l) => l.source === taskId);
327
+ }
328
+
329
+ function projectFinishAnchor(
330
+ project: Project,
331
+ forwardById: Map<TaskId, ForwardDates>,
332
+ calendar: Calendar,
333
+ ): Date {
334
+ if (project.end) return snapToPreviousWorkingMoment(project.end, calendar);
335
+ let latest: Date | undefined;
336
+ for (const f of forwardById.values()) {
337
+ if (!latest || f.earlyFinish > latest) latest = f.earlyFinish;
338
+ }
339
+ return latest ?? snapToNextWorkingMoment(project.start, calendar);
340
+ }
341
+
342
+ function addWorkingTime(date: Date, minutes: number, calendar: Calendar): Date {
343
+ if (minutes > 0) return addWorkingMinutes(date, minutes, calendar);
344
+ if (minutes < 0) return subtractWorkingMinutes(date, -minutes, calendar);
345
+ return new Date(date);
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Summary task hierarchy
350
+ // ---------------------------------------------------------------------------
351
+
352
+ function groupChildrenByParent(tasks: Task[]): Map<TaskId, Task[]> {
353
+ const map = new Map<TaskId, Task[]>();
354
+ for (const t of tasks) {
355
+ if (t.parent === undefined) continue;
356
+ const list = map.get(t.parent) ?? [];
357
+ list.push(t);
358
+ map.set(t.parent, list);
359
+ }
360
+ return map;
361
+ }
362
+
363
+ function summariesDeepestFirst(tasks: Task[]): Task[] {
364
+ const parentById = new Map<TaskId, TaskId | undefined>();
365
+ for (const t of tasks) parentById.set(t.id, t.parent);
366
+
367
+ const depthCache = new Map<TaskId, number>();
368
+ function depthOf(id: TaskId): number {
369
+ const cached = depthCache.get(id);
370
+ if (cached !== undefined) return cached;
371
+ const parent = parentById.get(id);
372
+ const d = parent === undefined ? 0 : depthOf(parent) + 1;
373
+ depthCache.set(id, d);
374
+ return d;
375
+ }
376
+
377
+ return tasks
378
+ .filter((t) => t.type === 'summary')
379
+ .map((t) => ({ task: t, depth: depthOf(t.id) }))
380
+ .sort((a, b) => b.depth - a.depth)
381
+ .map((x) => x.task);
382
+ }
383
+
384
+ function aggregateFromChildren(
385
+ summary: Task,
386
+ childrenByParent: Map<TaskId, Task[]>,
387
+ forwardById: Map<TaskId, ForwardDates>,
388
+ ): ForwardDates | undefined {
389
+ const children = childrenByParent.get(summary.id) ?? [];
390
+ const dates = children
391
+ .map((c) => forwardById.get(c.id))
392
+ .filter((d): d is ForwardDates => d !== undefined);
393
+ if (dates.length === 0) return undefined;
394
+ let earlyStartMs = Number.POSITIVE_INFINITY;
395
+ let earlyFinishMs = Number.NEGATIVE_INFINITY;
396
+ for (const d of dates) {
397
+ if (d.earlyStart.getTime() < earlyStartMs) earlyStartMs = d.earlyStart.getTime();
398
+ if (d.earlyFinish.getTime() > earlyFinishMs) earlyFinishMs = d.earlyFinish.getTime();
399
+ }
400
+ return {
401
+ earlyStart: new Date(earlyStartMs),
402
+ earlyFinish: new Date(earlyFinishMs),
403
+ };
404
+ }
405
+
406
+ function aggregateBackwardFromChildren(
407
+ summary: Task,
408
+ childrenByParent: Map<TaskId, Task[]>,
409
+ backwardById: Map<TaskId, BackwardDates>,
410
+ ): BackwardDates | undefined {
411
+ const children = childrenByParent.get(summary.id) ?? [];
412
+ const dates = children
413
+ .map((c) => backwardById.get(c.id))
414
+ .filter((d): d is BackwardDates => d !== undefined);
415
+ if (dates.length === 0) return undefined;
416
+ let lateStartMs = Number.POSITIVE_INFINITY;
417
+ let lateFinishMs = Number.NEGATIVE_INFINITY;
418
+ for (const d of dates) {
419
+ if (d.lateStart.getTime() < lateStartMs) lateStartMs = d.lateStart.getTime();
420
+ if (d.lateFinish.getTime() > lateFinishMs) lateFinishMs = d.lateFinish.getTime();
421
+ }
422
+ return {
423
+ lateStart: new Date(lateStartMs),
424
+ lateFinish: new Date(lateFinishMs),
425
+ };
426
+ }
427
+
428
+ function computeFreeSlack(
429
+ task: Task,
430
+ taskFwd: ForwardDates,
431
+ forwardById: Map<TaskId, ForwardDates>,
432
+ links: Link[],
433
+ calendar: Calendar,
434
+ ): number {
435
+ const outgoing = links.filter((l) => l.source === task.id);
436
+ if (outgoing.length === 0) return 0;
437
+
438
+ let minGap = Number.POSITIVE_INFINITY;
439
+ for (const link of outgoing) {
440
+ const targetFwd = forwardById.get(link.target);
441
+ if (!targetFwd) continue;
442
+
443
+ let requiredEnd: Date;
444
+ switch (link.type) {
445
+ case 'FS':
446
+ requiredEnd = subtractWorkingMinutes(targetFwd.earlyStart, link.lag, calendar);
447
+ break;
448
+ case 'SS':
449
+ requiredEnd = addWorkingMinutes(
450
+ subtractWorkingMinutes(targetFwd.earlyStart, link.lag, calendar),
451
+ task.duration,
452
+ calendar,
453
+ );
454
+ break;
455
+ case 'FF':
456
+ requiredEnd = subtractWorkingMinutes(targetFwd.earlyFinish, link.lag, calendar);
457
+ break;
458
+ case 'SF':
459
+ requiredEnd = addWorkingMinutes(
460
+ subtractWorkingMinutes(targetFwd.earlyFinish, link.lag, calendar),
461
+ task.duration,
462
+ calendar,
463
+ );
464
+ break;
465
+ }
466
+ const gap = workingMinutesBetween(taskFwd.earlyFinish, requiredEnd, calendar);
467
+ if (gap < minGap) minGap = gap;
468
+ }
469
+ return Number.isFinite(minGap) ? minGap : 0;
470
+ }
@@ -0,0 +1,51 @@
1
+ import type { Link, Task, TaskId } from './types';
2
+
3
+ /**
4
+ * Order tasks so that every predecessor appears before its successors.
5
+ *
6
+ * Operates on link-based ordering only (all dependency types FS/SS/FF/SF
7
+ * establish "source must be visited before target" for scheduling-pass
8
+ * purposes). Hierarchy (parent/summary) is not considered here — summary
9
+ * task dates are derived from children after the forward/backward pass.
10
+ *
11
+ * Throws if the link graph contains a cycle.
12
+ */
13
+ export function topologicalSort(tasks: Task[], links: Link[]): Task[] {
14
+ const taskById = new Map<TaskId, Task>(tasks.map((t) => [t.id, t]));
15
+ const successors = new Map<TaskId, TaskId[]>();
16
+ const inDegree = new Map<TaskId, number>();
17
+
18
+ for (const t of tasks) {
19
+ successors.set(t.id, []);
20
+ inDegree.set(t.id, 0);
21
+ }
22
+
23
+ for (const link of links) {
24
+ if (!taskById.has(link.source) || !taskById.has(link.target)) continue;
25
+ successors.get(link.source)?.push(link.target);
26
+ inDegree.set(link.target, (inDegree.get(link.target) ?? 0) + 1);
27
+ }
28
+
29
+ const queue: TaskId[] = [];
30
+ for (const t of tasks) {
31
+ if ((inDegree.get(t.id) ?? 0) === 0) queue.push(t.id);
32
+ }
33
+
34
+ const ordered: Task[] = [];
35
+ while (queue.length > 0) {
36
+ const id = queue.shift() as TaskId;
37
+ const t = taskById.get(id);
38
+ if (t) ordered.push(t);
39
+ for (const succId of successors.get(id) ?? []) {
40
+ const newDegree = (inDegree.get(succId) ?? 0) - 1;
41
+ inDegree.set(succId, newDegree);
42
+ if (newDegree === 0) queue.push(succId);
43
+ }
44
+ }
45
+
46
+ if (ordered.length !== tasks.length) {
47
+ throw new Error('Link graph contains a cycle; topological sort is not possible');
48
+ }
49
+
50
+ return ordered;
51
+ }