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.
- package/CHANGELOG.md +57 -0
- package/README.md +5 -0
- package/dist/export/index.cjs +1 -0
- package/dist/export/index.d.cts +112 -0
- package/dist/export/index.d.cts.map +1 -0
- package/dist/export/index.d.ts +112 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +1 -0
- package/dist/index.cjs +3492 -0
- package/dist/index.d.cts +628 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +628 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3461 -0
- package/dist/pdf-CAQDrX0w.cjs +120 -0
- package/dist/pdf-CBaoJRTI.js +120 -0
- package/dist/png-C8t74695.cjs +88 -0
- package/dist/png-DKZeKnRh.js +88 -0
- package/dist/xlsx-5FRPFck7.js +89 -0
- package/dist/xlsx-Gh5L_NL3.cjs +111 -0
- package/package.json +86 -0
- package/src/Gantt.css +23 -0
- package/src/Gantt.tsx +636 -0
- package/src/SpikeGantt.tsx +114 -0
- package/src/analysis.ts +83 -0
- package/src/baseline.ts +119 -0
- package/src/calendars/canterbury-table.ts +44 -0
- package/src/calendars/internal/computus.ts +25 -0
- package/src/calendars/internal/date-utils.ts +13 -0
- package/src/calendars/internal/mondayisation.ts +46 -0
- package/src/calendars/internal/month-rules.ts +65 -0
- package/src/calendars/matariki-table.ts +63 -0
- package/src/calendars/nz-holidays.ts +214 -0
- package/src/editing/command-history.ts +78 -0
- package/src/editing/commands.ts +327 -0
- package/src/editing/composite-command.ts +64 -0
- package/src/editing/draft-project.ts +59 -0
- package/src/editing/errors.ts +14 -0
- package/src/editing/factories.ts +92 -0
- package/src/editing/use-editable-project.ts +122 -0
- package/src/export/index.ts +12 -0
- package/src/export/offscreen.tsx +89 -0
- package/src/export/pdf-dimensions.ts +64 -0
- package/src/export/pdf.ts +68 -0
- package/src/export/png.ts +48 -0
- package/src/export/types.ts +42 -0
- package/src/export/xlsx.ts +70 -0
- package/src/index.ts +89 -0
- package/src/mspdi/parse.ts +820 -0
- package/src/mspdi/serialize.ts +352 -0
- package/src/mspdi/types.ts +53 -0
- package/src/schedule.ts +470 -0
- package/src/topological-sort.ts +51 -0
- package/src/types.ts +254 -0
- package/src/visibility.ts +35 -0
- package/src/working-time.ts +235 -0
package/src/schedule.ts
ADDED
|
@@ -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
|
+
}
|