@critical-path/core 0.53.0 → 0.55.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 +24 -0
- package/README.md +4 -3
- package/dist/domain/cpm.d.ts +14 -3
- package/dist/domain/cpm.d.ts.map +1 -1
- package/dist/domain/cpm.js +146 -48
- package/dist/domain/cpm.js.map +1 -1
- package/dist/domain/effort.d.ts +17 -0
- package/dist/domain/effort.d.ts.map +1 -0
- package/dist/domain/effort.js +30 -0
- package/dist/domain/effort.js.map +1 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +4 -6
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +7 -0
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +10 -0
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/ladder.d.ts.map +1 -1
- package/dist/domain/ladder.js +33 -21
- package/dist/domain/ladder.js.map +1 -1
- package/dist/domain/metrics.d.ts.map +1 -1
- package/dist/domain/metrics.js +18 -4
- package/dist/domain/metrics.js.map +1 -1
- package/dist/domain/workload.d.ts.map +1 -1
- package/dist/domain/workload.js +3 -1
- package/dist/domain/workload.js.map +1 -1
- package/dist/engine/index.d.ts +27 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +130 -48
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +8 -60
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +7 -11
- package/dist/schemas/index.js.map +1 -1
- package/dist/types/index.d.ts +15 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +3 -13
- package/dist/utils/status.js.map +1 -1
- package/package.json +1 -1
package/dist/engine/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { DomainEventBus } from '../domain/events.js';
|
|
|
7
7
|
import { validateAttachmentUrl } from '../domain/entities.js';
|
|
8
8
|
import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
|
|
9
9
|
import { detectDependencyCycle, CircularDependencyError, precedenceOf } from '../domain/graph.js';
|
|
10
|
-
import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
|
|
10
|
+
import { ValidationError, NotFoundError, ForbiddenError, ConflictError } from '../domain/errors.js';
|
|
11
11
|
import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
|
|
12
12
|
import { buildStorageKey, normalizeUploadData } from '../storage/file-storage.js';
|
|
13
13
|
import { generateWebhookSecret } from '../webhooks/signature.js';
|
|
@@ -16,6 +16,8 @@ import { CreateTaskSchema } from '../schemas/index.js';
|
|
|
16
16
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
17
17
|
import { calculateTaskMetrics, reconstructTaskProgressHistory } from '../domain/metrics.js';
|
|
18
18
|
import { calculateWorkloadDistribution } from '../domain/workload.js';
|
|
19
|
+
import { getTaskEstimateHours, getSummaryTaskIds } from '../domain/effort.js';
|
|
20
|
+
import { inferTaskProgress } from '../domain/metrics.js';
|
|
19
21
|
/** Every caller-settable task field, derived from the create schema so new fields are never dropped. */
|
|
20
22
|
const CREATE_TASK_FIELDS = Object.keys(CreateTaskSchema.shape);
|
|
21
23
|
export class CriticalPathEngine {
|
|
@@ -361,12 +363,28 @@ export class CriticalPathEngine {
|
|
|
361
363
|
});
|
|
362
364
|
return created;
|
|
363
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Updates a workflow. Removing a status that tasks in a project using this workflow are still in
|
|
368
|
+
* is refused (`ConflictError`): move those tasks first, or switch the project with `statusMapping`.
|
|
369
|
+
*/
|
|
364
370
|
async updateWorkflow(id, updates) {
|
|
365
371
|
const existing = await this.getWorkflow(id);
|
|
366
372
|
if (!existing)
|
|
367
373
|
return null;
|
|
368
374
|
await this.requireWorkspaceAccess('workspace.manage');
|
|
369
375
|
updates = this.withoutTenant(updates);
|
|
376
|
+
if (updates.statuses) {
|
|
377
|
+
const kept = new Set(updates.statuses.map((status) => status.key));
|
|
378
|
+
const removed = existing.statuses.map((status) => status.key).filter((key) => !kept.has(key));
|
|
379
|
+
if (removed.length) {
|
|
380
|
+
for (const project of await this.projectsUsingWorkflow(existing)) {
|
|
381
|
+
const stranded = [...new Set((await this.store.getTasks(project.id)).map((t) => t.status))].filter((key) => removed.includes(key));
|
|
382
|
+
if (stranded.length) {
|
|
383
|
+
throw new ConflictError(`Tasks in project "${project.id}" are still in ${stranded.map((k) => `"${k}"`).join(', ')}, which this update removes. Move them to another status first.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
370
388
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
371
389
|
if (updated) {
|
|
372
390
|
const now = new Date().toISOString();
|
|
@@ -387,11 +405,16 @@ export class CriticalPathEngine {
|
|
|
387
405
|
}
|
|
388
406
|
return updated;
|
|
389
407
|
}
|
|
408
|
+
/** Deletes a workflow. Refused (`ConflictError`) while any project uses it, even as its default. */
|
|
390
409
|
async deleteWorkflow(id) {
|
|
391
410
|
const existing = await this.getWorkflow(id);
|
|
392
411
|
if (!existing)
|
|
393
412
|
return false;
|
|
394
413
|
await this.requireWorkspaceAccess('workspace.manage');
|
|
414
|
+
const users = await this.projectsUsingWorkflow(existing);
|
|
415
|
+
if (users.length) {
|
|
416
|
+
throw new ConflictError(`Workflow "${id}" is used by ${users.length} project(s) (${users.map((p) => p.id).join(', ')}). Switch them to another workflow first.`);
|
|
417
|
+
}
|
|
395
418
|
const deleted = await this.store.deleteWorkflow(id);
|
|
396
419
|
if (deleted) {
|
|
397
420
|
const now = new Date().toISOString();
|
|
@@ -413,7 +436,10 @@ export class CriticalPathEngine {
|
|
|
413
436
|
return deleted;
|
|
414
437
|
}
|
|
415
438
|
async resolveProjectWorkflow(projectId) {
|
|
416
|
-
|
|
439
|
+
return this.workflowFor(await this.store.getProject(projectId));
|
|
440
|
+
}
|
|
441
|
+
/** The workflow a project (or a project as it would be after an update) uses. */
|
|
442
|
+
async workflowFor(project) {
|
|
417
443
|
if (project?.workflow)
|
|
418
444
|
return project.workflow;
|
|
419
445
|
if (project?.workflowId) {
|
|
@@ -421,14 +447,33 @@ export class CriticalPathEngine {
|
|
|
421
447
|
if (wf && (wf.tenantId ?? undefined) === (project.tenantId ?? undefined))
|
|
422
448
|
return wf;
|
|
423
449
|
}
|
|
450
|
+
return (await this.defaultWorkflowFor(project?.tenantId)) ?? DEFAULT_SOFTWARE_WORKFLOW;
|
|
451
|
+
}
|
|
452
|
+
/** The tenant's stored default workflow: the one marked `isDefault`, else the first. */
|
|
453
|
+
async defaultWorkflowFor(tenantId) {
|
|
424
454
|
// Only fall back to workflows from the project's own tenant.
|
|
425
|
-
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId ===
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
455
|
+
const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === tenantId);
|
|
456
|
+
return workflows.find((w) => w.isDefault) ?? workflows[0];
|
|
457
|
+
}
|
|
458
|
+
/** The statuses tasks in a project may have: its own definitions, else its workflow's. */
|
|
459
|
+
async statusDefinitionsFor(project) {
|
|
460
|
+
return project?.statusDefinitions?.length ? project.statusDefinitions : (await this.workflowFor(project)).statuses;
|
|
461
|
+
}
|
|
462
|
+
/** Projects whose tasks follow `workflow`, explicitly or as their tenant's default. */
|
|
463
|
+
async projectsUsingWorkflow(workflow) {
|
|
464
|
+
const projects = await this.store.getProjects(workflow.tenantId ? { tenantId: workflow.tenantId } : undefined);
|
|
465
|
+
const using = [];
|
|
466
|
+
for (const project of projects) {
|
|
467
|
+
if ((project.tenantId ?? undefined) === (workflow.tenantId ?? undefined) && (await this.workflowFor(project)).id === workflow.id)
|
|
468
|
+
using.push(project);
|
|
469
|
+
}
|
|
470
|
+
return using;
|
|
471
|
+
}
|
|
472
|
+
/** Throws unless `status` is one of the project's statuses. */
|
|
473
|
+
assertKnownStatus(status, definitions) {
|
|
474
|
+
if (definitions.length && !definitions.some((d) => d.key === status)) {
|
|
475
|
+
throw new ValidationError(`Status "${status}" is not in this project's workflow (${definitions.map((d) => d.key).join(', ')}).`);
|
|
476
|
+
}
|
|
432
477
|
}
|
|
433
478
|
async getAllowedTaskTransitions(taskId) {
|
|
434
479
|
const task = await this.getTask(taskId);
|
|
@@ -469,6 +514,13 @@ export class CriticalPathEngine {
|
|
|
469
514
|
// Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
|
|
470
515
|
const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
|
|
471
516
|
await this.assertUsableWorkflow(input.workflowId, input.tenantId);
|
|
517
|
+
// Pin the workflow the project starts with, so adding a new default workflow later does not
|
|
518
|
+
// silently change it.
|
|
519
|
+
if (!input.workflowId && !input.workflow) {
|
|
520
|
+
const workflow = await this.defaultWorkflowFor(input.tenantId);
|
|
521
|
+
if (workflow)
|
|
522
|
+
input.workflowId = workflow.id;
|
|
523
|
+
}
|
|
472
524
|
// The creator administers the project they create.
|
|
473
525
|
if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
|
|
474
526
|
input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
|
|
@@ -492,7 +544,16 @@ export class CriticalPathEngine {
|
|
|
492
544
|
});
|
|
493
545
|
return created;
|
|
494
546
|
}
|
|
495
|
-
|
|
547
|
+
/**
|
|
548
|
+
* Updates a project. When the update changes which statuses the project has (`workflowId`,
|
|
549
|
+
* `workflow` or `statusDefinitions`), every task in a status the project no longer has must be
|
|
550
|
+
* moved: pass `statusMapping` (old status key -> new status key). Without a mapping for each such
|
|
551
|
+
* status the update is refused with a `ConflictError`, so no task is left in an unreachable status.
|
|
552
|
+
*/
|
|
553
|
+
async updateProject(id, updates, options = {}) {
|
|
554
|
+
return this.inTransaction((engine) => engine.updateProjectNow(id, updates, options));
|
|
555
|
+
}
|
|
556
|
+
async updateProjectNow(id, updates, options) {
|
|
496
557
|
const existing = await this.getProject(id);
|
|
497
558
|
if (!existing)
|
|
498
559
|
return null;
|
|
@@ -512,7 +573,24 @@ export class CriticalPathEngine {
|
|
|
512
573
|
if (updates.workflowId && updates.workflowId !== existing.workflowId) {
|
|
513
574
|
await this.assertUsableWorkflow(updates.workflowId, existing.tenantId);
|
|
514
575
|
}
|
|
576
|
+
// Tasks whose status the project will no longer have, and where they go
|
|
577
|
+
const moves = [];
|
|
578
|
+
if ('workflowId' in updates || 'workflow' in updates || 'statusDefinitions' in updates) {
|
|
579
|
+
const next = new Set((await this.statusDefinitionsFor({ ...existing, ...updates })).map((d) => d.key));
|
|
580
|
+
const mapping = options.statusMapping ?? {};
|
|
581
|
+
const stranded = (await this.store.getTasks(id)).filter((task) => !next.has(task.status));
|
|
582
|
+
const unmapped = [...new Set(stranded.map((task) => task.status))].filter((key) => !next.has(mapping[key]));
|
|
583
|
+
if (unmapped.length) {
|
|
584
|
+
throw new ConflictError(`Tasks are in ${unmapped.map((k) => `"${k}"`).join(', ')}, which the project will no longer have. ` +
|
|
585
|
+
`Pass statusMapping with a new status for each (available: ${[...next].join(', ')}).`);
|
|
586
|
+
}
|
|
587
|
+
moves.push(...stranded.map((task) => ({ taskId: task.id, status: mapping[task.status] })));
|
|
588
|
+
}
|
|
515
589
|
const updated = await this.store.updateProject(id, updates);
|
|
590
|
+
// The old status is not in the new workflow, so there is no transition to check
|
|
591
|
+
for (const move of moves) {
|
|
592
|
+
await this.elevated().updateTask(move.taskId, { status: move.status }, { ...(this.actor ? { actor: this.actor } : {}), ignoreTransitions: true });
|
|
593
|
+
}
|
|
516
594
|
if (updated) {
|
|
517
595
|
const now = new Date().toISOString();
|
|
518
596
|
const event = {
|
|
@@ -648,9 +726,11 @@ export class CriticalPathEngine {
|
|
|
648
726
|
await this.assertValidParent(input.parentId, projectId);
|
|
649
727
|
const defaultStatus = workflow?.defaultStatusKey || 'todo';
|
|
650
728
|
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
729
|
+
const statusDefinitions = await this.statusDefinitionsFor(project);
|
|
730
|
+
this.assertKnownStatus(initialStatus, statusDefinitions);
|
|
651
731
|
const now = new Date().toISOString();
|
|
652
732
|
// Derive initial lifecycle timestamps and semantic status
|
|
653
|
-
const statusDef = resolveStatusDefinition(initialStatus,
|
|
733
|
+
const statusDef = resolveStatusDefinition(initialStatus, statusDefinitions);
|
|
654
734
|
const inProgress = statusDef.category === 'in_progress';
|
|
655
735
|
const completed = statusDef.category === 'completed';
|
|
656
736
|
const isBlocked = input.isBlocked ?? false;
|
|
@@ -659,15 +739,16 @@ export class CriticalPathEngine {
|
|
|
659
739
|
projectId,
|
|
660
740
|
title: processedInput.title || taskInput.title,
|
|
661
741
|
status: initialStatus,
|
|
662
|
-
|
|
742
|
+
// Always derived from the status, so every module agrees on it
|
|
743
|
+
semanticStatus: statusDef.category,
|
|
663
744
|
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
664
745
|
taskType: processedInput.taskType || taskInput.taskType || 'task',
|
|
665
746
|
actualStartDate: input.actualStartDate ?? (inProgress ? now : undefined),
|
|
666
747
|
actualEndDate: input.actualEndDate ?? (completed || statusDef.category === 'canceled' ? now : undefined),
|
|
667
748
|
completedAt: input.completedAt ?? (completed ? now : undefined),
|
|
668
|
-
loggedHours:
|
|
669
|
-
inProgressSince:
|
|
670
|
-
blockedSince:
|
|
749
|
+
loggedHours: 0,
|
|
750
|
+
inProgressSince: inProgress ? now : undefined,
|
|
751
|
+
blockedSince: isBlocked && inProgress ? now : undefined,
|
|
671
752
|
progress: input.progress ?? (completed ? 100 : 0),
|
|
672
753
|
isBlocked,
|
|
673
754
|
blockedReason: input.blockedReason ?? null,
|
|
@@ -711,8 +792,13 @@ export class CriticalPathEngine {
|
|
|
711
792
|
// Plugins run first; their output is then validated like caller input. Identity and
|
|
712
793
|
// ownership fields are never changed by hooks.
|
|
713
794
|
const { id: _id, projectId: _projectId, createdAt: _createdAt, ...processedUpdates } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
|
|
795
|
+
// Derived from the status below; never taken from callers or hooks
|
|
796
|
+
delete processedUpdates.semanticStatus;
|
|
714
797
|
// Validate workflow transition invariant
|
|
715
798
|
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
799
|
+
this.assertKnownStatus(processedUpdates.status, await this.statusDefinitionsFor(project));
|
|
800
|
+
}
|
|
801
|
+
if (processedUpdates.status && processedUpdates.status !== existing.status && !options?.ignoreTransitions) {
|
|
716
802
|
const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
|
|
717
803
|
if (!isValid) {
|
|
718
804
|
throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
|
|
@@ -733,7 +819,7 @@ export class CriticalPathEngine {
|
|
|
733
819
|
const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
|
|
734
820
|
// Auto-update execution/completion timestamps if status changes
|
|
735
821
|
if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
|
|
736
|
-
processedUpdates.semanticStatus =
|
|
822
|
+
processedUpdates.semanticStatus = newStatusDef.category;
|
|
737
823
|
// 1. Leaving in_progress: finalize active session duration and blocked duration
|
|
738
824
|
if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
|
|
739
825
|
const inProgressSince = existing.inProgressSince;
|
|
@@ -744,9 +830,6 @@ export class CriticalPathEngine {
|
|
|
744
830
|
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
745
831
|
processedUpdates.actualDurationSeconds =
|
|
746
832
|
(processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
|
|
747
|
-
if (processedUpdates.actualHours === undefined) {
|
|
748
|
-
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
749
|
-
}
|
|
750
833
|
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
751
834
|
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
752
835
|
}
|
|
@@ -854,18 +937,10 @@ export class CriticalPathEngine {
|
|
|
854
937
|
else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
|
|
855
938
|
processedUpdates.blockedSince = null;
|
|
856
939
|
}
|
|
857
|
-
//
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
|
|
865
|
-
processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
|
|
866
|
-
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
867
|
-
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
|
|
868
|
-
}
|
|
940
|
+
// Elapsed time (actualDuration*) and effort (actualHours, loggedHours) are separate quantities:
|
|
941
|
+
// keep the elapsed minutes in step with the seconds, and never derive effort from the clock.
|
|
942
|
+
if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualDurationMinutes === undefined) {
|
|
943
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
869
944
|
}
|
|
870
945
|
const updated = await this.store.updateTask(id, processedUpdates);
|
|
871
946
|
if (!updated)
|
|
@@ -1947,6 +2022,10 @@ export class CriticalPathEngine {
|
|
|
1947
2022
|
let estimatedHours = 0;
|
|
1948
2023
|
let loggedHours = 0;
|
|
1949
2024
|
let totalProgress = 0;
|
|
2025
|
+
let progressCount = 0;
|
|
2026
|
+
// Summary tasks are spanned by their subtasks (counted once, through the leaves); canceled
|
|
2027
|
+
// tasks keep their logged hours but leave the estimate and progress.
|
|
2028
|
+
const summaries = getSummaryTaskIds(allTasks);
|
|
1950
2029
|
for (const task of tasks) {
|
|
1951
2030
|
const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
|
|
1952
2031
|
if (statusDef.category === 'completed') {
|
|
@@ -1955,18 +2034,16 @@ export class CriticalPathEngine {
|
|
|
1955
2034
|
else if (statusDef.category === 'in_progress') {
|
|
1956
2035
|
activeTasks++;
|
|
1957
2036
|
}
|
|
1958
|
-
if (task.
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
totalProgress += statusDef.category === 'completed' ? 100 : 0;
|
|
1967
|
-
}
|
|
2037
|
+
if (summaries.has(task.id))
|
|
2038
|
+
continue;
|
|
2039
|
+
loggedHours += task.loggedHours ?? 0;
|
|
2040
|
+
if (statusDef.category === 'canceled')
|
|
2041
|
+
continue;
|
|
2042
|
+
estimatedHours += getTaskEstimateHours(task) ?? 0;
|
|
2043
|
+
totalProgress += inferTaskProgress(task, { customStatusDefinitions: project?.statusDefinitions || workflow?.statuses }).progressPercentage;
|
|
2044
|
+
progressCount++;
|
|
1968
2045
|
}
|
|
1969
|
-
const progressPercentage =
|
|
2046
|
+
const progressPercentage = progressCount > 0 ? Math.round(totalProgress / progressCount) : 0;
|
|
1970
2047
|
return {
|
|
1971
2048
|
deliverable,
|
|
1972
2049
|
totalTasks,
|
|
@@ -2119,9 +2196,9 @@ export class CriticalPathEngine {
|
|
|
2119
2196
|
const dependencies = await this.loadDependencies(tasks);
|
|
2120
2197
|
const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
|
|
2121
2198
|
const projectStartDate = options.projectStartDate || project?.startDate;
|
|
2122
|
-
const { calendars, levelResources, levelingPriority, effort } = this.resolveCpmModes(options);
|
|
2199
|
+
const { calendars, levelResources, levelingPriority, effort, defaultEstimateHours } = this.resolveCpmModes(options);
|
|
2123
2200
|
if (calendars === 'project')
|
|
2124
|
-
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate, effort });
|
|
2201
|
+
return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate, effort, defaultEstimateHours });
|
|
2125
2202
|
const { users, teams } = await this.loadPeople(options, [project?.tenantId]);
|
|
2126
2203
|
return calculateCPM(projectId, tasks, dependencies, {
|
|
2127
2204
|
schedule,
|
|
@@ -2131,7 +2208,8 @@ export class CriticalPathEngine {
|
|
|
2131
2208
|
teams,
|
|
2132
2209
|
levelResources,
|
|
2133
2210
|
levelingPriority,
|
|
2134
|
-
effort
|
|
2211
|
+
effort,
|
|
2212
|
+
defaultEstimateHours
|
|
2135
2213
|
});
|
|
2136
2214
|
}
|
|
2137
2215
|
/**
|
|
@@ -2143,7 +2221,7 @@ export class CriticalPathEngine {
|
|
|
2143
2221
|
* Rejects runs above `portfolioTaskLimit` tasks (default 5000).
|
|
2144
2222
|
*/
|
|
2145
2223
|
async calculatePortfolioCriticalPath(options = {}) {
|
|
2146
|
-
const { calendars, levelResources, levelingPriority, effort } = this.resolveCpmModes(options);
|
|
2224
|
+
const { calendars, levelResources, levelingPriority, effort, defaultEstimateHours } = this.resolveCpmModes(options);
|
|
2147
2225
|
let projects;
|
|
2148
2226
|
if (options.projectIds) {
|
|
2149
2227
|
const ids = [...new Set(options.projectIds)];
|
|
@@ -2189,7 +2267,7 @@ export class CriticalPathEngine {
|
|
|
2189
2267
|
projectStartDate: p.startDate || options.projectStartDate,
|
|
2190
2268
|
schedule: p.schedule || options.schedule || this.config.defaultSchedule,
|
|
2191
2269
|
...(mode ? { background: mode } : {})
|
|
2192
|
-
})), dependencies, { calendars, users, teams, levelResources, levelingPriority, effort, projectOrder: options.projectOrder });
|
|
2270
|
+
})), dependencies, { calendars, users, teams, levelResources, levelingPriority, effort, defaultEstimateHours, projectOrder: options.projectOrder });
|
|
2193
2271
|
}
|
|
2194
2272
|
/** Validated calendar and levelling modes, with engine defaults applied. */
|
|
2195
2273
|
resolveCpmModes(options) {
|
|
@@ -2209,7 +2287,11 @@ export class CriticalPathEngine {
|
|
|
2209
2287
|
if (effort !== 'estimate' && effort !== 'remaining') {
|
|
2210
2288
|
throw new ValidationError(`Unknown effort "${effort}"; use "estimate" or "remaining".`);
|
|
2211
2289
|
}
|
|
2212
|
-
|
|
2290
|
+
const defaultEstimateHours = options.defaultEstimateHours ?? this.config.criticalPathDefaultEstimateHours;
|
|
2291
|
+
if (defaultEstimateHours !== undefined && !(Number.isFinite(defaultEstimateHours) && defaultEstimateHours >= 0)) {
|
|
2292
|
+
throw new ValidationError('defaultEstimateHours must be a number of hours, 0 or more.');
|
|
2293
|
+
}
|
|
2294
|
+
return { calendars, levelResources, levelingPriority, effort, defaultEstimateHours };
|
|
2213
2295
|
}
|
|
2214
2296
|
/** Every dependency touching `tasks`, once each. */
|
|
2215
2297
|
async loadDependencies(tasks) {
|