Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

# Runs the engine test suite on every push and PR, and — only when master is
# green — publishes the static site to GitHub Pages. The deploy job depends on
# the test job, so a red build can no longer reach the live site.
#
# NOTE: this requires the repository's Pages source to be set to "GitHub Actions"
# (Settings → Pages → Build and deployment → Source). While it is still set to
# "Deploy from a branch", this deploy job is inert and the branch deploy keeps
# running unguarded.

on:
push:
branches: [master]
pull_request:

# Least privilege by default; the deploy job widens its own permissions.
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# No dependencies to install — the suite runs on Node's built-in test
# runner, which auto-discovers test/*.test.js.
- run: node --test

deploy:
name: Deploy to Pages
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: .
- id: deployment
uses: actions/deploy-pages@v4
45 changes: 45 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Contributing to CPD

Thanks for looking under the hood. CPD is deliberately dependency-free: static
files, vendored libraries, and Node's built-in test runner. There is nothing to
install and nothing to build.

## Running it

```sh
npm start # serves the directory (npx serve) — or: python -m http.server 8080
npm test # runs the whole suite on Node's test runner
```

The app is built from ES modules, so it must be served over HTTP — opening
`index.html` from the filesystem will not work.

## Layout in one breath

- **Pure engine, no DOM** — `js/cpm.js`, `js/calendar.js`, `js/layout.js`,
`js/resources.js`, `js/quality.js`, `js/evm.js`, `js/critical-chain.js`,
`js/sampling.js`. These take data and return data; they are directly
importable in Node and are where the tests live.
- **State** — `js/state.js` owns the project shape, validation/migration, and
undo/redo.
- **Rendering** — `js/network.js` (canvas), `js/panel.js` (cards, Gantt, the
analysis panels), `js/modals.js` (dialogs), `js/main.js` (boot + wiring).

Keep the pure files pure: if a change to one of them reaches for `document` or a
global, it belongs in a rendering module instead.

## Tests are the contract

- Run `npm test` before opening a PR. CI runs the same command on every push and
PR, and **master will not deploy unless it is green**.
- `test/cpm.test.js` includes a **baseline lock**: the shipped default project
must schedule to exactly the figures it always has. If that test changes, the
engine's behaviour changed — make sure that was intended, and update the
baseline deliberately in the same commit.
- New behaviour in a pure module should come with a test. The runner needs no
setup: `import { test } from 'node:test'` and `import assert from 'node:assert'`.

## Style

Match the surrounding code: small pure functions, comments that explain *why*
rather than *what*, and no new runtime dependencies without discussion.
10 changes: 7 additions & 3 deletions js/cpm.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,12 +484,16 @@ export function computeCPM(nodes, options = {}) {
const metrics = {};

nodes.forEach(n => {
// One resolve per task: durationOf can walk a sub-page roll-up, so calling
// it three times to seed duration/remaining/span was three times the work
// for one answer.
const duration = durationOf(n, options);
metrics[n.id] = {
...n,
duration: durationOf(n, options),
duration,
ES: 0, EF: 0, LS: 0, LF: 0, slack: 0, freeFloat: 0,
remaining: durationOf(n, options),
span: durationOf(n, options),
remaining: duration,
span: duration,
successors: (graph.succs.get(n.id) || []).map(s => s.id)
};
});
Expand Down
11 changes: 9 additions & 2 deletions js/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -412,13 +412,20 @@ export function buildVisData() {
const visNodes = [];
sizeEstimates = new Map();

// Milestone title per task id, built once. Calling findNode() inside the loop
// rescans every milestone for each task, which is O(tasks²) on a wide diagram
// and runs on every canvas rebuild; a single pass up front is O(tasks).
const milestoneTitleById = new Map();
(diagram?.milestones || []).forEach(ms => {
(ms.nodes || []).forEach(n => milestoneTitleById.set(n.id, ms.title));
});

nodes.forEach(node => {
const metric = metrics[node.id];
const label = boxes
? buildBoxLabel(node, metric, calendar)
: buildNodeLabel(node, metric, state, calendar);
const lineCount = label.split('\n').length;
const found = findNode(node.id);
const criticality = getCriticality()?.get(node.id);
const rollup = rollupForNode(node);
sizeEstimates.set(node.id, estimateNodeSize(label, boxes));
Expand All @@ -445,7 +452,7 @@ export function buildVisData() {
? `Cost: ${state.currency || '$'}${Math.round(node.cost).toLocaleString()}` +
(node.actualCost != null ? ` · actual ${state.currency || '$'}${Math.round(node.actualCost).toLocaleString()}` : '')
: '',
`Milestone: ${found?.milestone.title || '—'}`,
`Milestone: ${milestoneTitleById.get(node.id) || '—'}`,
`Duration used: ${fmt(metric.duration)}d`,
node.mustFinishBy != null
? `Must finish by: ${calendar.enabled ? calendar.formatOffset(node.mustFinishBy) : `day ${fmt(node.mustFinishBy)}`}`
Expand Down
8 changes: 4 additions & 4 deletions js/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import { $, escapeHtml, refreshIcons } from './dom.js';
import {
schedule, fmt, fmtDelta, fmtPercent, getCriticality, rollupForNode, isProjectCritical, effectiveStatus
schedule, resourceLoadFor, fmt, fmtDelta, fmtPercent, getCriticality, rollupForNode, isProjectCritical, effectiveStatus
} from './schedule.js';
import { getState, currentDiagram } from './state.js';
import { dependenciesOf } from './cpm.js';
import { linkBadgeHtml } from './links.js';
import { orderedNodes } from './layout.js';
import { resourceLoad, levelResources, UNASSIGNED } from './resources.js';
import { levelResources, UNASSIGNED } from './resources.js';
import { assessSchedule } from './quality.js';
import { projectEVM } from './evm.js';
import { criticalChainReport } from './critical-chain.js';
Expand Down Expand Up @@ -684,7 +684,7 @@ export function renderResources() {
return;
}

const load = resourceLoad(nodes, metrics, { capacity: resourceCapacity });
const load = resourceLoadFor(resourceCapacity);
const anyNamed = load.some(r => r.name !== UNASSIGNED);
if (!anyNamed) {
body.innerHTML = `
Expand Down Expand Up @@ -843,7 +843,7 @@ export function renderQuality() {
return;
}

const overAllocated = resourceLoad(nodes, metrics, { capacity: resourceCapacity })
const overAllocated = resourceLoadFor(resourceCapacity)
.filter(p => p.name !== UNASSIGNED && p.overloadedDays > 0)
.map(p => p.name);

Expand Down
19 changes: 19 additions & 0 deletions js/schedule.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
computeCPM, createRollup, createProgressRollup, compileGraph, nodesOf
} from './cpm.js';
import { createCalendar } from './calendar.js';
import { resourceLoad } from './resources.js';
import { getState, allNodes } from './state.js';

let cached = null;
let cachedMain = null;
let cachedRollups = null;
let cachedChain = null;
let cachedLoad = null;
let criticality = null;

/** Drop the cached schedule. Call after any mutation. */
Expand All @@ -23,6 +25,23 @@ export function invalidateSchedule() {
cachedMain = null;
cachedRollups = null;
cachedChain = null;
cachedLoad = null;
}

/**
* Resource load for the active schedule, computed once per render.
*
* The Resources panel, the Health panel, and the levelling section each need
* it, so the same over-allocation sweep ran up to three times per render off
* the same nodes and metrics. Memoised on the capacity it was asked for, and
* dropped with the rest of the cache on any mutation.
*/
export function resourceLoadFor(capacity) {
if (cachedLoad && cachedLoad.capacity === capacity) return cachedLoad.load;
const s = schedule();
const load = resourceLoad(s.nodes, s.metrics, { capacity });
cachedLoad = { capacity, load };
return load;
}

/**
Expand Down
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "cpd-critical-path-network",
"version": "1.3.0",
"private": true,
"description": "Browser-based Critical Path Method planner with PERT, working-day calendars, a Gantt timeline, and Monte Carlo schedule risk analysis.",
"homepage": "https://ryan-war.github.io/CPD/",
"repository": {
"type": "git",
"url": "https://github.com/ryan-war/CPD.git"
},
"license": "MIT",
"type": "module",
"scripts": {
"test": "node --test",
"start": "npx --yes serve ."
},
"engines": {
"node": ">=18"
}
}
126 changes: 126 additions & 0 deletions test/state.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { normalizeState } from '../js/state.js';
import { SCHEMA_VERSION } from '../js/config.js';

// normalizeState is the repair gate every loaded, shared, or hand-edited project
// passes through. These lock the repairs the README promises: id de-duplication,
// dangling-reference cleanup, and migration of the legacy dependency form. The
// input in each case is a deliberately malformed file — the point is what comes
// out.

function fileWith(nodes, extra = {}) {
return {
diagrams: { main: { milestones: [{ id: 'm1', title: 'Phase', nodes }] } },
...extra
};
}

function nodesOfMain(state) {
return state.diagrams.main.milestones.flatMap(ms => ms.nodes);
}

test('duplicate task ids within a diagram are made unique', () => {
// Lookups return the first match, so a silent duplicate would shadow the other.
const state = normalizeState(fileWith([
{ id: 'A', min: 1, max: 2, dependencies: [] },
{ id: 'A', min: 1, max: 2, dependencies: [] },
{ id: 'A', min: 1, max: 2, dependencies: [] }
]));
const ids = nodesOfMain(state).map(n => n.id);
assert.equal(new Set(ids).size, ids.length, 'every id is unique after repair');
assert.deepEqual(ids, ['A', 'A_1', 'A_1_1']);
});

test('dependencies pointing at tasks that no longer exist are dropped', () => {
const state = normalizeState(fileWith([
{ id: 'A', min: 1, max: 2, dependencies: [] },
{ id: 'B', min: 1, max: 2, dependencies: ['A', 'GHOST'] }
]));
const b = nodesOfMain(state).find(n => n.id === 'B');
assert.deepEqual(b.dependencies.map(d => d.id), ['A'], 'GHOST is gone, A survives');
});

test('a self-dependency is rejected', () => {
const state = normalizeState(fileWith([
{ id: 'A', min: 1, max: 2, dependencies: ['A'] }
]));
assert.deepEqual(nodesOfMain(state)[0].dependencies, []);
});

test('the legacy bare-array dependency form migrates to objects', () => {
// Files written before precedence types existed held dependencies as a plain
// array of predecessor ids.
const state = normalizeState(fileWith([
{ id: 'A', min: 1, max: 2, dependencies: [] },
{ id: 'B', min: 1, max: 2, dependencies: ['A'] }
]));
const b = nodesOfMain(state).find(n => n.id === 'B');
assert.deepEqual(b.dependencies, [{ id: 'A', type: 'FS', lag: 0 }]);
});

test('duplicate predecessors collapse to one', () => {
const state = normalizeState(fileWith([
{ id: 'A', min: 1, max: 2, dependencies: [] },
{ id: 'B', min: 1, max: 2, dependencies: ['A', { id: 'A', type: 'SS', lag: 3 }] }
]));
const b = nodesOfMain(state).find(n => n.id === 'B');
assert.equal(b.dependencies.length, 1, 'A appears once');
});

test('out-of-range estimates are clamped into a sane triangle', () => {
// max below min, and a most-likely outside [min, max], would make the
// triangular sampler return NaN.
const state = normalizeState(fileWith([
{ id: 'A', min: 5, max: 2, likely: 99, dependencies: [] }
]));
const a = nodesOfMain(state)[0];
assert.ok(a.max >= a.min, 'max is lifted to at least min');
assert.ok(a.likely >= a.min && a.likely <= a.max, 'likely sits inside [min, max]');
});

test('links that no longer resolve are cleared', () => {
const state = normalizeState(fileWith([
{ id: 'A', min: 1, max: 2, dependencies: [], linkedSubPage: 'sub_missing' }
]));
assert.equal(nodesOfMain(state)[0].linkedSubPage, null);
});

test('a sub-page linkedMainNode pointing at nothing is cleared', () => {
const state = normalizeState({
diagrams: {
main: { milestones: [{ id: 'm1', nodes: [{ id: 'A', min: 1, max: 2, dependencies: [] }] }] },
sub_1: { milestones: [{ id: 'm2', nodes: [{ id: 'S', min: 1, max: 2, dependencies: [], linkedMainNode: 'GHOST' }] }] }
},
pageOrder: ['main', 'sub_1']
});
const s = state.diagrams.sub_1.milestones[0].nodes[0];
assert.equal(s.linkedMainNode, null);
});

test('the schema version is stamped and export provenance is stripped', () => {
// appVersion/exportedAt belong to the file that was written, not the live
// project — left in, they would autosave and override the next export.
const state = normalizeState(fileWith(
[{ id: 'A', min: 1, max: 2, dependencies: [] }],
{ schemaVersion: 1, appVersion: '0.0.1', exportedAt: '2020-01-01T00:00:00.000Z' }
));
assert.equal(state.schemaVersion, SCHEMA_VERSION);
assert.equal(state.appVersion, undefined);
assert.equal(state.exportedAt, undefined);
});

test('a negative deadline reads as no deadline, not a day-zero due date', () => {
const state = normalizeState(fileWith(
[{ id: 'A', min: 1, max: 2, dependencies: [] }],
{ deadline: -5, dataDate: -1 }
));
assert.equal(state.deadline, null);
assert.equal(state.dataDate, null);
});

test('a missing diagrams.main is rejected outright', () => {
assert.throws(() => normalizeState({ diagrams: {} }), /diagrams\.main/);
assert.throws(() => normalizeState(null), /object/);
});
Loading