construction-gantt 0.2.0 → 0.3.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,47 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { schedule } from '../schedule.js';
3
+ import type { Project } from '../types.js';
4
+ import { type ActiveCell, parseFieldValue } from './useEditState.js';
5
+
6
+ export function usePreviewEngine(
7
+ committed: Project,
8
+ activeCell: ActiveCell | null,
9
+ dirtyValue: string,
10
+ debounceMs = 80,
11
+ ): Project | null {
12
+ const [ghostProject, setGhostProject] = useState<Project | null>(null);
13
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14
+
15
+ useEffect(() => {
16
+ const CPM_IRRELEVANT_FIELDS = new Set(['text', 'progress', 'scheduleMode']);
17
+ if (activeCell === null || CPM_IRRELEVANT_FIELDS.has(activeCell.field)) {
18
+ setGhostProject(null);
19
+ return;
20
+ }
21
+
22
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
23
+
24
+ timerRef.current = setTimeout(() => {
25
+ const patch = parseFieldValue(activeCell.field, dirtyValue);
26
+ if (Object.keys(patch).length === 0) {
27
+ setGhostProject(null); // invalid input — suppress ghost
28
+ return;
29
+ }
30
+ const patchedTasks = committed.tasks.map((t) =>
31
+ t.id === activeCell.taskId ? { ...t, ...patch } : t,
32
+ );
33
+ setGhostProject(schedule({ ...committed, tasks: patchedTasks }));
34
+ }, debounceMs);
35
+
36
+ return () => {
37
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
38
+ };
39
+ }, [committed, activeCell, dirtyValue, debounceMs]);
40
+
41
+ // Clear immediately when cell deactivates (don't wait for debounce timeout).
42
+ useEffect(() => {
43
+ if (activeCell === null) setGhostProject(null);
44
+ }, [activeCell]);
45
+
46
+ return ghostProject;
47
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  } from './editing/factories.js';
34
34
  export type { EditableProject } from './editing/use-editable-project.js';
35
35
  export { useEditableProject } from './editing/use-editable-project.js';
36
+ export type { EditableField, TaskEditPatch } from './editing/useEditState.js';
36
37
  export type {
37
38
  GanttHandle,
38
39
  PdfExportOptions,
@@ -40,6 +40,7 @@ const KNOWN_TASK_FIELDS = new Set([
40
40
  'Finish',
41
41
  'Duration',
42
42
  'ConstraintType',
43
+ 'ConstraintDate',
43
44
  'Milestone',
44
45
  'Summary',
45
46
  'OutlineLevel',
@@ -238,6 +239,7 @@ export function parseMspdi(xml: string): MspdiParseResult {
238
239
 
239
240
  const tasks: Task[] = [];
240
241
  const links: Link[] = [];
242
+ const outlineStack: TaskId[] = [];
241
243
  // Per-task baseline snapshots, keyed by baseline Number (BaselineIndex).
242
244
  // Flattened into project.baselines below.
243
245
  const baselineAccum = new Map<BaselineIndex, Map<TaskId, BaselineTaskSnapshot>>();
@@ -263,8 +265,11 @@ export function parseMspdi(xml: string): MspdiParseResult {
263
265
  const isMilestone = String(raw.Milestone ?? '0') === '1';
264
266
  const isSummary = String(raw.Summary ?? '0') === '1';
265
267
  const taskType: TaskType = isSummary ? 'summary' : isMilestone ? 'milestone' : 'task';
268
+ const outlineLevel = Math.max(1, Number(raw.OutlineLevel ?? '1'));
269
+ const parentId = outlineLevel > 1 ? outlineStack[outlineLevel - 2] : undefined;
270
+ const constraint = parseConstraint(raw);
266
271
 
267
- tasks.push({
272
+ const parsedTask: Task = {
268
273
  id: uid,
269
274
  text: name,
270
275
  type: taskType,
@@ -272,8 +277,14 @@ export function parseMspdi(xml: string): MspdiParseResult {
272
277
  duration,
273
278
  start,
274
279
  end,
275
- progress: 0,
276
- });
280
+ progress: Number(raw.PercentComplete ?? '0'),
281
+ };
282
+ if (parentId !== undefined) parsedTask.parent = parentId;
283
+ if (constraint !== undefined) parsedTask.constraint = constraint;
284
+ tasks.push(parsedTask);
285
+
286
+ outlineStack[outlineLevel - 1] = uid;
287
+ outlineStack.length = outlineLevel;
277
288
 
278
289
  // Walk predecessor links nested inside the task.
279
290
  const preds: unknown[] = Array.isArray(raw.PredecessorLink)
@@ -705,10 +716,33 @@ const MSPDI_TYPE_TO_DEPENDENCY: Record<number, DependencyType> = {
705
716
  3: 'SS',
706
717
  };
707
718
 
719
+ const MSPDI_CONSTRAINT_TO_INTERNAL = {
720
+ 0: 'ASAP',
721
+ 1: 'ALAP',
722
+ 2: 'MSO',
723
+ 3: 'MFO',
724
+ 4: 'SNET',
725
+ 5: 'SNLT',
726
+ 6: 'FNET',
727
+ 7: 'FNLT',
728
+ } as const;
729
+
708
730
  function mspdiTypeToDependencyType(t: number): DependencyType {
709
731
  return MSPDI_TYPE_TO_DEPENDENCY[t] ?? 'FS';
710
732
  }
711
733
 
734
+ function parseConstraint(raw: Record<string, unknown>): Task['constraint'] {
735
+ const value = Number(raw.ConstraintType ?? '0');
736
+ const type = MSPDI_CONSTRAINT_TO_INTERNAL[value as keyof typeof MSPDI_CONSTRAINT_TO_INTERNAL];
737
+ if (!type || type === 'ASAP') return undefined;
738
+
739
+ const constraint: NonNullable<Task['constraint']> = { type };
740
+ if (raw.ConstraintDate !== undefined && raw.ConstraintDate !== '') {
741
+ constraint.date = parseMspdiDate(String(raw.ConstraintDate));
742
+ }
743
+ return constraint;
744
+ }
745
+
712
746
  function parseMspdiDate(s: string): Date {
713
747
  // MSPDI emits ISO 8601 like `2026-01-05T08:00:00` (no timezone in
714
748
  // practice; MS Project writes local time). We treat it as local.
@@ -8,10 +8,12 @@ import type {
8
8
  Baseline,
9
9
  Calendar,
10
10
  CalendarException,
11
+ ConstraintType,
11
12
  DependencyType,
12
13
  Link,
13
14
  Project,
14
15
  Resource,
16
+ Task,
15
17
  WorkInterval,
16
18
  } from '../types.js';
17
19
  import type { MspdiSerializeOptions } from './types.js';
@@ -37,9 +39,11 @@ interface MspdiTaskOut {
37
39
  Finish: string;
38
40
  Duration: string;
39
41
  ConstraintType: number;
42
+ ConstraintDate?: string;
40
43
  Milestone: number;
41
44
  Summary: number;
42
45
  OutlineLevel: number;
46
+ PercentComplete: number;
43
47
  PredecessorLink?: MspdiPredecessorLinkOut[];
44
48
  Baseline?: MspdiTaskBaselineOut[];
45
49
  }
@@ -112,6 +116,7 @@ export function serializeMspdi(project: Project, options: MspdiSerializeOptions
112
116
  // Group baselines by taskId. Each task may have one snapshot per baseline
113
117
  // (0-10), emitted as <Baseline> children nested inside the task.
114
118
  const baselinesByTask = buildBaselinesByTask(project.baselines);
119
+ const outlineLevelByTask = buildOutlineLevels(project.tasks);
115
120
 
116
121
  const tasksOut: MspdiTaskOut[] = project.tasks.map((t, idx) => {
117
122
  const taskOut: MspdiTaskOut = {
@@ -121,11 +126,15 @@ export function serializeMspdi(project: Project, options: MspdiSerializeOptions
121
126
  Start: formatMspdiDate(t.start),
122
127
  Finish: formatMspdiDate(t.end),
123
128
  Duration: formatMspdiDuration(t.duration),
124
- ConstraintType: 0, // ASAP, the MSPDI default
129
+ ConstraintType: constraintTypeToMspdi(t.constraint?.type),
125
130
  Milestone: t.type === 'milestone' ? 1 : 0,
126
131
  Summary: t.type === 'summary' ? 1 : 0,
127
- OutlineLevel: 1,
132
+ OutlineLevel: outlineLevelByTask.get(t.id) ?? 1,
133
+ PercentComplete: t.progress,
128
134
  };
135
+ if (t.constraint?.date) {
136
+ taskOut.ConstraintDate = formatMspdiDate(t.constraint.date);
137
+ }
129
138
 
130
139
  const incoming = linksByTarget.get(String(t.id));
131
140
  if (incoming?.length) {
@@ -199,6 +208,45 @@ function dependencyTypeToMspdi(t: DependencyType): number {
199
208
  }
200
209
  }
201
210
 
211
+ function constraintTypeToMspdi(t: ConstraintType | undefined): number {
212
+ switch (t) {
213
+ case undefined:
214
+ case 'ASAP':
215
+ return 0;
216
+ case 'ALAP':
217
+ return 1;
218
+ case 'MSO':
219
+ return 2;
220
+ case 'MFO':
221
+ return 3;
222
+ case 'SNET':
223
+ return 4;
224
+ case 'SNLT':
225
+ return 5;
226
+ case 'FNET':
227
+ return 6;
228
+ case 'FNLT':
229
+ return 7;
230
+ }
231
+ }
232
+
233
+ function buildOutlineLevels(tasks: Task[]): Map<Task['id'], number> {
234
+ const parentById = new Map(tasks.map((t) => [t.id, t.parent]));
235
+ const cache = new Map<Task['id'], number>();
236
+
237
+ function depth(taskId: Task['id']): number {
238
+ const cached = cache.get(taskId);
239
+ if (cached !== undefined) return cached;
240
+
241
+ const parentId = parentById.get(taskId);
242
+ const value = parentId === undefined ? 1 : depth(parentId) + 1;
243
+ cache.set(taskId, value);
244
+ return value;
245
+ }
246
+
247
+ return new Map(tasks.map((t) => [t.id, depth(t.id)]));
248
+ }
249
+
202
250
  function formatMspdiDate(d: Date): string {
203
251
  // MSPDI emits local time without timezone (e.g. `2026-01-05T08:00:00`).
204
252
  const pad = (n: number): string => String(n).padStart(2, '0');
package/src/visibility.ts CHANGED
@@ -5,11 +5,9 @@
5
5
  // This is the canonical example: the engine always runs on the full task
6
6
  // set; the visibility filter applies only to the rendered output.
7
7
  //
8
- // Direct lift from the CM domain rule (FrappeGanttView.tsx:366-371 in
9
- // Bode-Builds): "Hiding a predecessor would otherwise make a dependent
10
- // task's early-start collapse to 0, which is wrong." We honour the rule
11
- // by structure — the filter sits AFTER schedule() in the pipeline, so
12
- // computed fields on the visible tasks reflect the full schedule.
8
+ // The filter sits AFTER schedule() in the pipeline so computed fields on
9
+ // the visible tasks reflect the full schedule — hiding a predecessor must
10
+ // not cause a dependent task's early-start to collapse to 0.
13
11
 
14
12
  import type { Task, TaskId } from './types.js';
15
13