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/Gantt.css
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* Non-working day shading on the time scale.
|
|
2
|
+
* Applied to cells where the calendar has no working intervals for the
|
|
3
|
+
* given date (weekends + holiday exceptions). Construction PMs expect
|
|
4
|
+
* to see at a glance which days the crew is on site. */
|
|
5
|
+
.construction-gantt-non-working {
|
|
6
|
+
background: rgba(241, 245, 249, 0.65);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Today-line marker. Red 2px vertical line; high-priority visual cue for
|
|
10
|
+
* what the date cursor is on. */
|
|
11
|
+
.construction-gantt-marker-today {
|
|
12
|
+
background: #ef4444;
|
|
13
|
+
color: #ffffff;
|
|
14
|
+
font-weight: 600;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Generic milestone marker (council inspections, payment-claim dates,
|
|
18
|
+
* handover gates). Blue, less aggressive than today. */
|
|
19
|
+
.construction-gantt-marker-milestone {
|
|
20
|
+
background: #2563eb;
|
|
21
|
+
color: #ffffff;
|
|
22
|
+
font-weight: 500;
|
|
23
|
+
}
|
package/src/Gantt.tsx
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// The real <Gantt>. Consumes a Project, runs the scheduling engine, converts
|
|
2
|
+
// our SVAR-agnostic data model to SVAR's ITask/ILink, renders through
|
|
3
|
+
// SVAR's free-tier React component per ADR-002 (shape-c slot composition).
|
|
4
|
+
//
|
|
5
|
+
// Public API surface stays SVAR-agnostic: consumers pass a Project; SVAR
|
|
6
|
+
// is a private implementation detail. If we ever swap renderers (per
|
|
7
|
+
// ADR-002's seam), this file is the only consumer-facing change.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type IColumnConfig,
|
|
11
|
+
type ILink,
|
|
12
|
+
type ITask,
|
|
13
|
+
Gantt as SvarGantt,
|
|
14
|
+
} from '@svar-ui/react-gantt';
|
|
15
|
+
import '@svar-ui/react-gantt/style.css';
|
|
16
|
+
import './Gantt.css';
|
|
17
|
+
import { type FC, forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
|
18
|
+
import { getTaskBaselineVariance } from './baseline';
|
|
19
|
+
import type {
|
|
20
|
+
GanttHandle,
|
|
21
|
+
PdfExportOptions,
|
|
22
|
+
PngExportOptions,
|
|
23
|
+
XlsxExportOptions,
|
|
24
|
+
} from './export/types.js';
|
|
25
|
+
import { schedule } from './schedule';
|
|
26
|
+
import type {
|
|
27
|
+
Baseline,
|
|
28
|
+
BaselineIndex,
|
|
29
|
+
Calendar,
|
|
30
|
+
DependencyType,
|
|
31
|
+
Link,
|
|
32
|
+
Project,
|
|
33
|
+
Task,
|
|
34
|
+
TaskId,
|
|
35
|
+
} from './types';
|
|
36
|
+
import { filterTasksByVisibility } from './visibility.js';
|
|
37
|
+
import { isWorkingDay } from './working-time';
|
|
38
|
+
|
|
39
|
+
export interface GanttMarker {
|
|
40
|
+
start: Date;
|
|
41
|
+
text?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Visual style. 'today' renders as a red line; 'milestone' as blue;
|
|
44
|
+
* 'custom' lets you supply a `css` class name yourself.
|
|
45
|
+
*/
|
|
46
|
+
variant?: 'today' | 'milestone' | 'custom';
|
|
47
|
+
/** CSS class name when `variant: 'custom'`. */
|
|
48
|
+
css?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Defines a column in the left-hand grid of the Gantt chart.
|
|
53
|
+
* Construction-PM-facing: these are the columns site PMs expect alongside
|
|
54
|
+
* the task name (WBS code, trade package, assigned subcontractor, etc.).
|
|
55
|
+
*
|
|
56
|
+
* This is a SVAR-agnostic type — internal conversion to SVAR's IColumnConfig
|
|
57
|
+
* happens inside the <Gantt> wrapper.
|
|
58
|
+
*/
|
|
59
|
+
export interface GanttColumn<TTask = Task> {
|
|
60
|
+
/** Identifier; used as SVAR column id. */
|
|
61
|
+
id: string;
|
|
62
|
+
/** Header label rendered in the column header row. */
|
|
63
|
+
header: string;
|
|
64
|
+
/**
|
|
65
|
+
* The field on the task to pluck for the default cell render.
|
|
66
|
+
* Optional — if `render` is provided, this is ignored.
|
|
67
|
+
*/
|
|
68
|
+
field?: keyof TTask;
|
|
69
|
+
/** Column width in pixels. Defaults to SVAR's default if unset. */
|
|
70
|
+
width?: number;
|
|
71
|
+
/** Text alignment for the cell. */
|
|
72
|
+
align?: 'left' | 'center' | 'right';
|
|
73
|
+
/**
|
|
74
|
+
* Custom cell render. Receives the live (scheduled) task. Use for any
|
|
75
|
+
* column more complex than displaying a single field value.
|
|
76
|
+
*/
|
|
77
|
+
render?: FC<{ task: Task }>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GanttProps {
|
|
81
|
+
project: Project;
|
|
82
|
+
/** Container height. Defaults to 500 (px). */
|
|
83
|
+
height?: number | string;
|
|
84
|
+
/** Width of one time-scale cell (per `cellWidth` SVAR prop). Default 48. */
|
|
85
|
+
cellWidth?: number;
|
|
86
|
+
/** Height of one row. */
|
|
87
|
+
cellHeight?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Skip running the scheduling engine. Use when the project's tasks
|
|
90
|
+
* already have `computed` populated by a prior `schedule()` call.
|
|
91
|
+
*/
|
|
92
|
+
preScheduled?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Vertical markers (today line + arbitrary milestones).
|
|
95
|
+
* Default: a today line if the current date falls within the project
|
|
96
|
+
* window. Pass an empty array to suppress, or your own markers list to
|
|
97
|
+
* override.
|
|
98
|
+
*/
|
|
99
|
+
markers?: GanttMarker[];
|
|
100
|
+
/**
|
|
101
|
+
* Show variance against this baseline index. If unset (or no matching
|
|
102
|
+
* baseline exists on `project.baselines`), bars render without variance
|
|
103
|
+
* pills. Construction-vertical use case (ADR-003): comparing the live
|
|
104
|
+
* programme against the original contract programme captured under
|
|
105
|
+
* NZS 3910 / AS 4000.
|
|
106
|
+
*/
|
|
107
|
+
baselineIndex?: BaselineIndex;
|
|
108
|
+
/**
|
|
109
|
+
* Render the baseline as a separate "ghost" bar beneath each live task.
|
|
110
|
+
* Matches the MS Project baseline-view idiom construction PMs expect
|
|
111
|
+
* when reviewing variation claims. Default true when `baselineIndex`
|
|
112
|
+
* is set; pass `false` to keep variance shown only as in-bar pills.
|
|
113
|
+
*/
|
|
114
|
+
showBaselineBars?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Grid columns displayed alongside the Gantt bars.
|
|
117
|
+
*
|
|
118
|
+
* - `undefined` (default): SVAR renders its built-in columns (task name +
|
|
119
|
+
* duration + start + end).
|
|
120
|
+
* - `[]` (empty array): hides the grid entirely (passes `columns={false}`
|
|
121
|
+
* to SVAR).
|
|
122
|
+
* - `GanttColumn[]`: replaces SVAR's default columns with the supplied set.
|
|
123
|
+
*
|
|
124
|
+
* Construction-PM-facing columns live here: WBS code, trade package,
|
|
125
|
+
* assigned subcontractor, and similar project-specific fields.
|
|
126
|
+
*/
|
|
127
|
+
columns?: GanttColumn[];
|
|
128
|
+
/**
|
|
129
|
+
* Render-only visibility filter. When set, only tasks whose `id` is in
|
|
130
|
+
* the set are rendered. **CPM still runs on the full task set** — hidden
|
|
131
|
+
* predecessors continue to drive their visible successors' computed
|
|
132
|
+
* fields. The visibility filter is a render-only concern (ADR-005).
|
|
133
|
+
*
|
|
134
|
+
* - `undefined` (default): no filter; render everything.
|
|
135
|
+
* - empty set: render nothing.
|
|
136
|
+
* - set containing ids not present in `project.tasks`: those ids are
|
|
137
|
+
* ignored; only matching tasks render.
|
|
138
|
+
*
|
|
139
|
+
* Lifts the "filter-while-keeping-CPM-correct" domain rule that
|
|
140
|
+
* consumer apps would otherwise have to write themselves. See
|
|
141
|
+
* `visibility.ts` for the contract test.
|
|
142
|
+
*/
|
|
143
|
+
visibleTaskIds?: ReadonlySet<TaskId>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface SvarTaskWithComputed extends ITask {
|
|
147
|
+
is_critical?: boolean;
|
|
148
|
+
is_late?: boolean;
|
|
149
|
+
total_slack?: number;
|
|
150
|
+
/** Working-minutes by which start has slipped against the baseline. */
|
|
151
|
+
start_variance?: number;
|
|
152
|
+
/** True if startVariance >= 30 working minutes (drifted later than plan). */
|
|
153
|
+
is_slipped?: boolean;
|
|
154
|
+
/** True if startVariance <= -30 working minutes (ahead of plan). */
|
|
155
|
+
is_ahead?: boolean;
|
|
156
|
+
/** True for phantom rows representing a baseline snapshot's position. */
|
|
157
|
+
is_baseline_ghost?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface SvarMarker {
|
|
161
|
+
start: Date;
|
|
162
|
+
text?: string;
|
|
163
|
+
css?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
167
|
+
{
|
|
168
|
+
project,
|
|
169
|
+
height = 500,
|
|
170
|
+
cellWidth = 48,
|
|
171
|
+
cellHeight = 42,
|
|
172
|
+
preScheduled = false,
|
|
173
|
+
markers,
|
|
174
|
+
baselineIndex,
|
|
175
|
+
showBaselineBars,
|
|
176
|
+
columns,
|
|
177
|
+
visibleTaskIds,
|
|
178
|
+
},
|
|
179
|
+
ref,
|
|
180
|
+
) {
|
|
181
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
182
|
+
|
|
183
|
+
const scheduled = useMemo(
|
|
184
|
+
() => (preScheduled ? project : schedule(project)),
|
|
185
|
+
[project, preScheduled],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Visibility filter is render-only — applied AFTER schedule() has run so
|
|
189
|
+
// computed fields on visible tasks reflect the full project. ADR-005.
|
|
190
|
+
const renderableTasks = useMemo(
|
|
191
|
+
() => filterTasksByVisibility(scheduled.tasks, visibleTaskIds),
|
|
192
|
+
[scheduled.tasks, visibleTaskIds],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const calendar = useMemo(
|
|
196
|
+
() => scheduled.calendars.find((c) => c.id === scheduled.defaultCalendarId),
|
|
197
|
+
[scheduled.calendars, scheduled.defaultCalendarId],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const baseline = useMemo<Baseline | undefined>(() => {
|
|
201
|
+
if (baselineIndex === undefined) return undefined;
|
|
202
|
+
return scheduled.baselines.find((b) => b.index === baselineIndex);
|
|
203
|
+
}, [scheduled.baselines, baselineIndex]);
|
|
204
|
+
|
|
205
|
+
const ghostBarsEnabled = baseline !== undefined && (showBaselineBars ?? true);
|
|
206
|
+
|
|
207
|
+
const svarTasks: ITask[] = useMemo(() => {
|
|
208
|
+
if (!ghostBarsEnabled || !baseline) {
|
|
209
|
+
return renderableTasks.map((t) => toSvarTask(t, baseline, calendar));
|
|
210
|
+
}
|
|
211
|
+
// Interleave each real task with its baseline-snapshot ghost row.
|
|
212
|
+
// Phantoms share the real task's `parent` so they stay grouped under
|
|
213
|
+
// the same summary in hierarchy views.
|
|
214
|
+
const out: SvarTaskWithComputed[] = [];
|
|
215
|
+
for (const t of renderableTasks) {
|
|
216
|
+
out.push(toSvarTask(t, baseline, calendar));
|
|
217
|
+
if (t.type === 'summary') continue;
|
|
218
|
+
const phantom = makeBaselinePhantom(t, baseline);
|
|
219
|
+
if (phantom) out.push(phantom);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}, [renderableTasks, baseline, calendar, ghostBarsEnabled]);
|
|
223
|
+
const svarLinks: ILink[] = useMemo(() => scheduled.links.map(toSvarLink), [scheduled.links]);
|
|
224
|
+
|
|
225
|
+
const projectEnd = useMemo(() => getProjectEnd(scheduled), [scheduled]);
|
|
226
|
+
// Mark calendar referenced even when consumed only by useMemo args (TS unused-let guard)
|
|
227
|
+
void calendar;
|
|
228
|
+
|
|
229
|
+
const svarMarkers: SvarMarker[] = useMemo(
|
|
230
|
+
() => resolveMarkers(markers, scheduled.start, projectEnd),
|
|
231
|
+
[markers, scheduled.start, projectEnd],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const highlightTime = useMemo(() => buildHighlightTime(calendar), [calendar]);
|
|
235
|
+
|
|
236
|
+
// Convert our SVAR-agnostic GanttColumn[] to SVAR's IColumnConfig[].
|
|
237
|
+
// undefined → don't pass columns to SVAR (use SVAR defaults).
|
|
238
|
+
// [] → pass false to SVAR (hide grid entirely).
|
|
239
|
+
// [...] → convert each column.
|
|
240
|
+
const svarColumns: IColumnConfig[] | false | undefined = useMemo(() => {
|
|
241
|
+
if (columns === undefined) return undefined;
|
|
242
|
+
if (columns.length === 0) return false;
|
|
243
|
+
return columns.map(toSvarColumn);
|
|
244
|
+
}, [columns]);
|
|
245
|
+
|
|
246
|
+
useImperativeHandle(
|
|
247
|
+
ref,
|
|
248
|
+
() => ({
|
|
249
|
+
async exportPNG(options?: PngExportOptions): Promise<Blob> {
|
|
250
|
+
const { exportPNG } = await import('./export/png.js');
|
|
251
|
+
return exportPNG({
|
|
252
|
+
scheduled,
|
|
253
|
+
ganttProps: {
|
|
254
|
+
cellWidth,
|
|
255
|
+
cellHeight,
|
|
256
|
+
markers,
|
|
257
|
+
baselineIndex,
|
|
258
|
+
showBaselineBars,
|
|
259
|
+
columns,
|
|
260
|
+
height,
|
|
261
|
+
visibleTaskIds,
|
|
262
|
+
},
|
|
263
|
+
options: options ?? {},
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
async exportPDF(options?: PdfExportOptions): Promise<Blob> {
|
|
267
|
+
const { exportPDF } = await import('./export/pdf.js');
|
|
268
|
+
return exportPDF({
|
|
269
|
+
scheduled,
|
|
270
|
+
ganttProps: {
|
|
271
|
+
cellWidth,
|
|
272
|
+
cellHeight,
|
|
273
|
+
markers,
|
|
274
|
+
baselineIndex,
|
|
275
|
+
showBaselineBars,
|
|
276
|
+
columns,
|
|
277
|
+
height,
|
|
278
|
+
visibleTaskIds,
|
|
279
|
+
},
|
|
280
|
+
options: options ?? {},
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
async exportXLSX(options?: XlsxExportOptions): Promise<Blob> {
|
|
284
|
+
const { exportXLSX } = await import('./export/xlsx.js');
|
|
285
|
+
return exportXLSX({ scheduled, options: options ?? {} });
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
[
|
|
289
|
+
scheduled,
|
|
290
|
+
cellWidth,
|
|
291
|
+
cellHeight,
|
|
292
|
+
markers,
|
|
293
|
+
baselineIndex,
|
|
294
|
+
showBaselineBars,
|
|
295
|
+
columns,
|
|
296
|
+
height,
|
|
297
|
+
visibleTaskIds,
|
|
298
|
+
],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div ref={containerRef} style={{ height }}>
|
|
303
|
+
<SvarGantt
|
|
304
|
+
tasks={svarTasks}
|
|
305
|
+
links={svarLinks}
|
|
306
|
+
start={scheduled.start}
|
|
307
|
+
end={projectEnd}
|
|
308
|
+
cellWidth={cellWidth}
|
|
309
|
+
cellHeight={cellHeight}
|
|
310
|
+
markers={svarMarkers}
|
|
311
|
+
highlightTime={highlightTime}
|
|
312
|
+
taskTemplate={ConstructionBar}
|
|
313
|
+
{...(svarColumns !== undefined ? { columns: svarColumns as IColumnConfig[] } : {})}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
|
|
320
|
+
// Phantom baseline row — render a slim outlined ghost bar.
|
|
321
|
+
if (data.is_baseline_ghost) {
|
|
322
|
+
return (
|
|
323
|
+
<div
|
|
324
|
+
style={{
|
|
325
|
+
height: '60%',
|
|
326
|
+
marginTop: '15%',
|
|
327
|
+
border: '1.5px dashed #94a3b8',
|
|
328
|
+
background: 'transparent',
|
|
329
|
+
borderRadius: 3,
|
|
330
|
+
fontSize: 9,
|
|
331
|
+
color: '#64748b',
|
|
332
|
+
display: 'flex',
|
|
333
|
+
alignItems: 'center',
|
|
334
|
+
padding: '0 6px',
|
|
335
|
+
fontStyle: 'italic',
|
|
336
|
+
whiteSpace: 'nowrap',
|
|
337
|
+
}}
|
|
338
|
+
title="Baseline position — where this task was when the baseline was captured"
|
|
339
|
+
>
|
|
340
|
+
baseline
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const isCritical = data.is_critical ?? false;
|
|
346
|
+
const isLate = data.is_late ?? false;
|
|
347
|
+
const isSummary = data.type === 'summary';
|
|
348
|
+
const isMilestone = data.type === 'milestone';
|
|
349
|
+
|
|
350
|
+
if (isMilestone) {
|
|
351
|
+
return (
|
|
352
|
+
<div
|
|
353
|
+
style={{
|
|
354
|
+
width: '100%',
|
|
355
|
+
height: '100%',
|
|
356
|
+
display: 'flex',
|
|
357
|
+
alignItems: 'center',
|
|
358
|
+
justifyContent: 'center',
|
|
359
|
+
color: isCritical ? '#fff' : '#1f2937',
|
|
360
|
+
fontWeight: 600,
|
|
361
|
+
fontSize: 11,
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
◆ {data.text}
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const isSlipped = data.is_slipped ?? false;
|
|
370
|
+
const isAhead = data.is_ahead ?? false;
|
|
371
|
+
// Show slack indicator for non-critical, non-summary, non-milestone tasks
|
|
372
|
+
// with at least half a working day of total float. Skips the noise of "+5m"
|
|
373
|
+
// pills on the visually-critical path tasks.
|
|
374
|
+
const totalSlack = data.total_slack ?? 0;
|
|
375
|
+
const showSlackIndicator = !isSummary && !isCritical && totalSlack >= 270; // >= 30 min more than half a day
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<div
|
|
379
|
+
style={{
|
|
380
|
+
display: 'flex',
|
|
381
|
+
alignItems: 'center',
|
|
382
|
+
gap: 6,
|
|
383
|
+
height: '100%',
|
|
384
|
+
padding: '0 8px',
|
|
385
|
+
fontSize: 12,
|
|
386
|
+
fontWeight: isSummary ? 600 : 500,
|
|
387
|
+
color: isCritical ? '#fff' : '#1f2937',
|
|
388
|
+
background: isSummary ? 'transparent' : isCritical ? '#dc2626' : '#cbd5e1',
|
|
389
|
+
borderRadius: 4,
|
|
390
|
+
border: isSummary ? '2px solid #1e293b' : undefined,
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
394
|
+
{data.text}
|
|
395
|
+
</span>
|
|
396
|
+
{isSlipped && (
|
|
397
|
+
<span
|
|
398
|
+
style={{
|
|
399
|
+
padding: '0 6px',
|
|
400
|
+
background: '#fed7aa',
|
|
401
|
+
color: '#7c2d12',
|
|
402
|
+
borderRadius: 3,
|
|
403
|
+
fontSize: 10,
|
|
404
|
+
fontWeight: 700,
|
|
405
|
+
lineHeight: '16px',
|
|
406
|
+
whiteSpace: 'nowrap',
|
|
407
|
+
}}
|
|
408
|
+
title="Drifted later than the baseline"
|
|
409
|
+
>
|
|
410
|
+
+{workingMinutesToShortLabel(data.start_variance ?? 0)}
|
|
411
|
+
</span>
|
|
412
|
+
)}
|
|
413
|
+
{isAhead && (
|
|
414
|
+
<span
|
|
415
|
+
style={{
|
|
416
|
+
padding: '0 6px',
|
|
417
|
+
background: '#bbf7d0',
|
|
418
|
+
color: '#14532d',
|
|
419
|
+
borderRadius: 3,
|
|
420
|
+
fontSize: 10,
|
|
421
|
+
fontWeight: 700,
|
|
422
|
+
lineHeight: '16px',
|
|
423
|
+
whiteSpace: 'nowrap',
|
|
424
|
+
}}
|
|
425
|
+
title="Ahead of the baseline"
|
|
426
|
+
>
|
|
427
|
+
−{workingMinutesToShortLabel(data.start_variance ?? 0)}
|
|
428
|
+
</span>
|
|
429
|
+
)}
|
|
430
|
+
{showSlackIndicator && (
|
|
431
|
+
<span
|
|
432
|
+
style={{
|
|
433
|
+
padding: '0 6px',
|
|
434
|
+
background: '#dbeafe',
|
|
435
|
+
color: '#1e3a8a',
|
|
436
|
+
borderRadius: 3,
|
|
437
|
+
fontSize: 10,
|
|
438
|
+
fontWeight: 600,
|
|
439
|
+
lineHeight: '16px',
|
|
440
|
+
whiteSpace: 'nowrap',
|
|
441
|
+
}}
|
|
442
|
+
title="Total float — how much this task can slip before becoming critical"
|
|
443
|
+
>
|
|
444
|
+
{workingMinutesToShortLabel(totalSlack)} float
|
|
445
|
+
</span>
|
|
446
|
+
)}
|
|
447
|
+
{isLate && (
|
|
448
|
+
<span
|
|
449
|
+
style={{
|
|
450
|
+
padding: '0 6px',
|
|
451
|
+
background: '#fde68a',
|
|
452
|
+
color: '#78350f',
|
|
453
|
+
borderRadius: 3,
|
|
454
|
+
fontSize: 10,
|
|
455
|
+
fontWeight: 700,
|
|
456
|
+
lineHeight: '16px',
|
|
457
|
+
whiteSpace: 'nowrap',
|
|
458
|
+
}}
|
|
459
|
+
title="Negative slack — contract trouble"
|
|
460
|
+
>
|
|
461
|
+
{workingMinutesToShortLabel(data.total_slack ?? 0)} late
|
|
462
|
+
</span>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
function toSvarTask(
|
|
469
|
+
t: Task,
|
|
470
|
+
baseline: Baseline | undefined,
|
|
471
|
+
calendar: Calendar | undefined,
|
|
472
|
+
): SvarTaskWithComputed {
|
|
473
|
+
const variance =
|
|
474
|
+
baseline && calendar ? getTaskBaselineVariance(t, baseline, calendar) : undefined;
|
|
475
|
+
const startVariance = variance?.startVariance ?? 0;
|
|
476
|
+
|
|
477
|
+
const base: SvarTaskWithComputed = {
|
|
478
|
+
id: t.id,
|
|
479
|
+
text: t.text,
|
|
480
|
+
start: t.start,
|
|
481
|
+
end: t.end,
|
|
482
|
+
duration: t.duration,
|
|
483
|
+
progress: t.progress,
|
|
484
|
+
type: t.type,
|
|
485
|
+
parent: t.parent,
|
|
486
|
+
is_critical: t.computed?.isCritical ?? false,
|
|
487
|
+
is_late: (t.computed?.totalSlack ?? 0) < 0,
|
|
488
|
+
total_slack: t.computed?.totalSlack ?? 0,
|
|
489
|
+
start_variance: startVariance,
|
|
490
|
+
is_slipped: startVariance >= 30,
|
|
491
|
+
is_ahead: startVariance <= -30,
|
|
492
|
+
};
|
|
493
|
+
// `open` only meaningful on summary tasks. Setting it on leaves trips
|
|
494
|
+
// SVAR's child-iteration path (null forEach).
|
|
495
|
+
if (t.type === 'summary') base.open = t.open ?? true;
|
|
496
|
+
return base;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function makeBaselinePhantom(t: Task, baseline: Baseline): SvarTaskWithComputed | null {
|
|
500
|
+
const snap = baseline.tasks.get(t.id);
|
|
501
|
+
if (!snap) return null;
|
|
502
|
+
return {
|
|
503
|
+
id: `${t.id}-baseline-${baseline.index}`,
|
|
504
|
+
text: '(baseline)',
|
|
505
|
+
start: snap.start,
|
|
506
|
+
end: snap.end,
|
|
507
|
+
duration: snap.duration,
|
|
508
|
+
progress: 0,
|
|
509
|
+
type: 'task',
|
|
510
|
+
parent: t.parent,
|
|
511
|
+
is_baseline_ghost: true,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function toSvarLink(l: Link): ILink {
|
|
516
|
+
return {
|
|
517
|
+
id: l.id,
|
|
518
|
+
source: l.source,
|
|
519
|
+
target: l.target,
|
|
520
|
+
type: dependencyTypeToSvar(l.type),
|
|
521
|
+
lag: l.lag,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function dependencyTypeToSvar(t: DependencyType): ILink['type'] {
|
|
526
|
+
switch (t) {
|
|
527
|
+
case 'FS':
|
|
528
|
+
return 'e2s';
|
|
529
|
+
case 'SS':
|
|
530
|
+
return 's2s';
|
|
531
|
+
case 'FF':
|
|
532
|
+
return 'e2e';
|
|
533
|
+
case 'SF':
|
|
534
|
+
return 's2e';
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function getProjectEnd(p: Project): Date {
|
|
539
|
+
if (p.end) return p.end;
|
|
540
|
+
let latestMs = Number.NEGATIVE_INFINITY;
|
|
541
|
+
for (const t of p.tasks) {
|
|
542
|
+
if (t.end.getTime() > latestMs) latestMs = t.end.getTime();
|
|
543
|
+
}
|
|
544
|
+
// Pad by one cell so the last bar isn't clipped to the right edge.
|
|
545
|
+
const cushion = 24 * 60 * 60 * 1000; // 1 day
|
|
546
|
+
return Number.isFinite(latestMs) ? new Date(latestMs + cushion) : new Date(p.start);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function resolveMarkers(
|
|
550
|
+
userMarkers: GanttMarker[] | undefined,
|
|
551
|
+
projectStart: Date,
|
|
552
|
+
projectEnd: Date,
|
|
553
|
+
): SvarMarker[] {
|
|
554
|
+
if (userMarkers) return userMarkers.map(toSvarMarker);
|
|
555
|
+
// Default: today line, only if today falls within the project window.
|
|
556
|
+
const today = new Date();
|
|
557
|
+
if (today >= projectStart && today <= projectEnd) {
|
|
558
|
+
return [{ start: today, text: 'Today', css: 'construction-gantt-marker-today' }];
|
|
559
|
+
}
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function toSvarMarker(m: GanttMarker): SvarMarker {
|
|
564
|
+
const css =
|
|
565
|
+
m.css ??
|
|
566
|
+
(m.variant === 'milestone'
|
|
567
|
+
? 'construction-gantt-marker-milestone'
|
|
568
|
+
: m.variant === 'today'
|
|
569
|
+
? 'construction-gantt-marker-today'
|
|
570
|
+
: undefined);
|
|
571
|
+
return { start: m.start, text: m.text, css };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Convert a public GanttColumn to SVAR's IColumnConfig.
|
|
576
|
+
*
|
|
577
|
+
* render takes priority over field. When only field is set we emit a default
|
|
578
|
+
* cell that formats the value as a string (Date → ISO date, undefined → "").
|
|
579
|
+
* We cast row to our Task type directly — the relevant fields (id, text,
|
|
580
|
+
* start, end, duration, progress, type, parent, computed, constraint)
|
|
581
|
+
* all overlap. SVAR's internal $x/$y/$w computed fields are never passed
|
|
582
|
+
* through to the consumer's render prop.
|
|
583
|
+
*/
|
|
584
|
+
function toSvarColumn(c: GanttColumn): IColumnConfig {
|
|
585
|
+
let cell: IColumnConfig['cell'] | undefined;
|
|
586
|
+
|
|
587
|
+
if (c.render) {
|
|
588
|
+
const Render = c.render;
|
|
589
|
+
cell = (props: { row: unknown }) => <Render task={props.row as Task} />;
|
|
590
|
+
} else if (c.field) {
|
|
591
|
+
const field = c.field;
|
|
592
|
+
cell = (props: { row: unknown }) => {
|
|
593
|
+
const task = props.row as Task;
|
|
594
|
+
const value = task[field as keyof Task];
|
|
595
|
+
if (value === undefined || value === null) return <span />;
|
|
596
|
+
if (value instanceof Date) return <span>{value.toISOString().slice(0, 10)}</span>;
|
|
597
|
+
return <span>{String(value)}</span>;
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const config: IColumnConfig = {
|
|
602
|
+
id: c.id,
|
|
603
|
+
header: c.header,
|
|
604
|
+
...(c.width !== undefined ? { width: c.width } : {}),
|
|
605
|
+
...(c.align !== undefined ? { align: c.align } : {}),
|
|
606
|
+
...(cell !== undefined ? { cell } : {}),
|
|
607
|
+
};
|
|
608
|
+
return config;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function buildHighlightTime(
|
|
612
|
+
calendar: Calendar | undefined,
|
|
613
|
+
): ((date: Date, unit: 'day' | 'hour') => string) | undefined {
|
|
614
|
+
if (!calendar) return undefined;
|
|
615
|
+
return (date, unit) => {
|
|
616
|
+
if (unit !== 'day') return '';
|
|
617
|
+
if (!isWorkingDay(date, calendar)) return 'construction-gantt-non-working';
|
|
618
|
+
return '';
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function workingMinutesToShortLabel(minutes: number): string {
|
|
623
|
+
const abs = Math.abs(minutes);
|
|
624
|
+
if (abs >= 540) {
|
|
625
|
+
// Approximate working-days from 9h-per-day. Display is "best-effort"
|
|
626
|
+
// since real durations depend on each task's calendar — good enough
|
|
627
|
+
// for an in-bar pill.
|
|
628
|
+
const days = Math.round(abs / 540);
|
|
629
|
+
return `${days}d`;
|
|
630
|
+
}
|
|
631
|
+
if (abs >= 60) {
|
|
632
|
+
const hours = Math.round(abs / 60);
|
|
633
|
+
return `${hours}h`;
|
|
634
|
+
}
|
|
635
|
+
return `${Math.round(abs)}m`;
|
|
636
|
+
}
|