diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de67ce7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ed48aa5 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/js/cpm.js b/js/cpm.js index c20a70a..2b5d476 100644 --- a/js/cpm.js +++ b/js/cpm.js @@ -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) }; }); diff --git a/js/network.js b/js/network.js index ffe4262..2b2582c 100644 --- a/js/network.js +++ b/js/network.js @@ -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)); @@ -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)}`}` diff --git a/js/panel.js b/js/panel.js index 45f645f..45b9d94 100644 --- a/js/panel.js +++ b/js/panel.js @@ -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'; @@ -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 = ` @@ -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); diff --git a/js/schedule.js b/js/schedule.js index fbd23c2..b4d2d67 100644 --- a/js/schedule.js +++ b/js/schedule.js @@ -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. */ @@ -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; } /** diff --git a/package.json b/package.json new file mode 100644 index 0000000..37122b5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/test/state.test.js b/test/state.test.js new file mode 100644 index 0000000..6ed47ff --- /dev/null +++ b/test/state.test.js @@ -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/); +});