Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
44a6ed4
SCALE-SEAM (95) — element state: two read/write pairs, and why the ma…
claude Sep 4, 2026
121c095
SCALE-SEAM (96) — the as-built question's aggregate reader, and a wit…
claude Sep 4, 2026
2f09a5c
Merge already-squashed (95) branch history
claude Sep 4, 2026
0472f97
SCALE-SEAM (97) — the undo stack, and a destination that looked right…
claude Sep 4, 2026
66ab67a
Merge already-squashed (96) branch history
claude Sep 4, 2026
58525e1
Correct the undo/redo republish docs — all three sites, not just the …
claude Sep 4, 2026
75f8597
SCALE-SEAM (98) — detailing carriers, and a field map total over one …
claude Sep 4, 2026
f2926a5
Merge already-squashed (97) branch history
claude Sep 4, 2026
83e149e
SCALE-SEAM (99) — the content shelf, and a destination header that wa…
claude Sep 4, 2026
17103db
Merge already-squashed (98) branch history
claude Sep 4, 2026
2a8f46f
SCALE-SEAM (100) — the element-connection pair, and a destination nam…
claude Sep 4, 2026
6d68219
Merge already-squashed (99) branch history
claude Sep 4, 2026
370a1f8
Merge already-squashed (95)-(100) branch history
claude Sep 4, 2026
f5d6751
R22-ENTITLEMENT ⑤ — an agency review comment becomes an RFI somebody …
claude Sep 4, 2026
5c79d56
Merge branch history — reconcile after the (95)-(100) squashes
claude Sep 4, 2026
d826115
R22-ENTITLEMENT ⑤ review: promotion claims the comment atomically, an…
claude Sep 4, 2026
9f048eb
Merge branch history after the #434 squash — content already identical
claude Sep 4, 2026
c437a7f
R24-REPORTS-BY-MOMENT — a finished pack can be sent, not only downloaded
claude Sep 4, 2026
87cc0e8
Review round on #435: five findings, all verified real, all fixed
claude Sep 4, 2026
67dfc36
Second review round on #435: the port fix is the root cause my first …
claude Sep 4, 2026
eff9568
Merge after the #435 squash
claude Sep 4, 2026
0fadd1c
Only committed capital owns anything — cap table and waterfall
claude Sep 4, 2026
87f3a59
Merge after the #436 squash
claude Sep 4, 2026
2fa0692
SCALE-SEAM (101) — design-phase predicted performance, client.ts 642 …
claude Sep 4, 2026
276c4e5
Merge after the #437 squash
claude Sep 4, 2026
ea414ab
Portfolio risk heat map — R22-PIPELINE
claude Sep 4, 2026
9ba106e
Review round on #439 — deterministic tie-break, keyboard-operable rows
claude Sep 4, 2026
4ae3e28
Merge remote-tracking branch 'origin/main' into claude/codebase-audit…
claude Sep 4, 2026
27d6f40
Cross-project Gantt — R22-PIPELINE
claude Sep 4, 2026
3f07a03
Review round on #440 — reject normalised dates, require the id separa…
claude Sep 4, 2026
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build
(Windows / macOS / Linux); the updater always serves the latest. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/).

## Unreleased — Cross-project Gantt

The Programme card (`/projects/{pid}/schedule/portfolio`) now draws a bar per project on a shared
span: start, finish, duration, which project drives the programme finish, and which are named by an
external link. `apps/web/src/portal/panels/programmeGantt.ts` holds the geometry as a pure function;
the panel only paints what it returns.

**It needed no new engine, and that is the finding.** The roadmap recorded a cross-project Gantt as
missing because `schedule_viz.py` is per-project. But R46's `schedule_portfolio.py` already computes
`project_starts` and `project_finishes` in its single merged pass, and the route already returned
them — `apps/web/src/api/schedule.ts` **named only three scalars**. `HttpCore.json<T>` returns
`res.json()` under an unchecked cast, so the dates were in the parsed response all along; nothing
declared them, so no call site could reach them and none did. Same class as R37-TESTED-UNWIRED one
layer further out: not a route without a caller, but a payload without a reader.

Bars come from the **merged** pass, never each project's standalone CPM. A project can look
comfortable alone and be critical to the programme; its own run would show the comfortable answer.
`services/api/test_programme_gantt.py` asserts the FS link actually pushes fit-out past enabling's
finish — removing the link fails it with exactly that explanation, so the claim is load-bearing
rather than decorative.

**A project with only one dated end gets no bar**, and is listed with the reason. Substituting the
programme's own start or finish for the missing end would draw a bar that looks measured and is not
— the same rule the risk heat map applies to an unmeasured cell.

Writing the test also found that an external link must name activities by **record id**: `wbs` and
`ref` are aliases resolved only for a project's own predecessor tokens, so a link written in WBS
terms is refused as "no such activity". Recorded next to the link that uses it.

## Unreleased — Portfolio risk heat map

`GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/api/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,14 @@ export function withSchedule<TBase extends Ctor<HttpCore>>(Base: TBase) {
rejected_links: string[]; projects_without_activities: string[];
programme_finish: string | null; project_count: number | null;
external_link_count: number | null;
// The merged pass returns per-project dates and the activities that cross a boundary. This
// type declared only the three scalars above until v0.3.1144, so the dates reached the browser
// and were dropped before anything could draw them — which is why the roadmap recorded the
// cross-project Gantt as missing an engine it already had. Keyed by project id.
project_starts?: Record<string, string>;
project_finishes?: Record<string, string>;
crossing_activities?: string[];
issues?: { code?: string; message?: string }[];
}>(`/projects/${pid}/schedule/portfolio`, { method: "POST", body: JSON.stringify(body) });
}

Expand Down
128 changes: 128 additions & 0 deletions apps/web/src/portal/panels/programmeGantt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";

import { programmeBars } from "./programmeGantt";

const P = (id: string, name = id, activities = 3) => ({ id, name, activities });

describe("programmeBars", () => {
it("places each project on a shared span, ordered by start", () => {
const r = programmeBars({
projects: [P("b", "Fit-out"), P("a", "Enabling")],
project_starts: { a: "2026-01-01", b: "2026-02-01" },
project_finishes: { a: "2026-01-31", b: "2026-03-01" },
});
expect(r.bars.map((x) => x.name)).toEqual(["Enabling", "Fit-out"]);
expect(r.span).toEqual({ start: "2026-01-01", finish: "2026-03-01", days: 60 });
expect(r.bars[0]!.left).toBe(0);
// Enabling runs 30 of the 59-day span; Fit-out starts 31 days in.
expect(Math.round(r.bars[0]!.width)).toBe(51);
expect(Math.round(r.bars[1]!.left)).toBe(53);
expect(r.bars[0]!.days).toBe(31);
expect(r.unplotted).toEqual([]);
});

it("marks the bar that finishes on the programme finish as driving", () => {
const r = programmeBars({
projects: [P("a"), P("b")],
project_starts: { a: "2026-01-01", b: "2026-01-01" },
project_finishes: { a: "2026-01-10", b: "2026-02-10" },
});
expect(r.bars.find((x) => x.id === "b")!.driving).toBe(true);
expect(r.bars.find((x) => x.id === "a")!.driving).toBe(false);
});

// A HALF-DATED PROJECT GETS NO BAR. Substituting the programme's own start or finish for the
// missing end would draw a bar that looks measured and is not — the defect this module exists to
// avoid, so it is asserted rather than left to the renderer.
it("refuses a bar when either end is missing, and says which", () => {
const r = programmeBars({
projects: [P("a"), P("b", "No finish"), P("c", "No start"), P("d", "Nothing")],
project_starts: { a: "2026-01-01", b: "2026-01-05", d: undefined as unknown as string },
project_finishes: { a: "2026-01-31", c: "2026-02-01" },
});
expect(r.bars.map((x) => x.id)).toEqual(["a"]);
expect(r.unplotted).toEqual([
{ id: "b", name: "No finish", reason: "no finish date" },
{ id: "c", name: "No start", reason: "no start date" },
{ id: "d", name: "Nothing", reason: "no scheduled dates" },
]);
// and the span is computed from the plotted bar alone, never widened by a half-dated project
expect(r.span).toEqual({ start: "2026-01-01", finish: "2026-01-31", days: 31 });
});

it("refuses a bar whose finish precedes its start", () => {
const r = programmeBars({
projects: [P("a")],
project_starts: { a: "2026-03-01" }, project_finishes: { a: "2026-01-01" },
});
expect(r.bars).toEqual([]);
expect(r.unplotted[0]!.reason).toBe("finish precedes start");
expect(r.span).toBeNull();
});

it("survives a single-day programme without NaN widths", () => {
const r = programmeBars({
projects: [P("a")],
project_starts: { a: "2026-01-01" }, project_finishes: { a: "2026-01-01" },
});
expect(r.bars[0]!.left).toBe(0);
expect(r.bars[0]!.width).toBe(100);
expect(r.bars[0]!.days).toBe(1);
expect(r.span!.days).toBe(1);
});

it("flags the projects an external link names — their dates are a commitment", () => {
const r = programmeBars({
projects: [P("enabling"), P("fitout"), P("infra")],
project_starts: { enabling: "2026-01-01", fitout: "2026-02-01", infra: "2026-01-15" },
project_finishes: { enabling: "2026-01-31", fitout: "2026-03-01", infra: "2026-02-15" },
external_links: [{ predecessor: "enabling::A1", successor: "fitout::B1" }],
});
expect(r.bars.find((x) => x.id === "enabling")!.linked).toBe(true);
expect(r.bars.find((x) => x.id === "fitout")!.linked).toBe(true);
expect(r.bars.find((x) => x.id === "infra")!.linked).toBe(false);
});

// `Date` normalises an out-of-range DAY instead of rejecting it — "2026-02-30" becomes
// 2026-03-02 — while an out-of-range MONTH is NaN. A normalised date is an invented one, so it
// must not reach a bar.
it("rejects a date the Date constructor would silently normalise", () => {
const r = programmeBars({
projects: [{ id: "a", name: "Feb 30", activities: 1 }],
project_starts: { a: "2026-02-30" }, project_finishes: { a: "2026-03-31" },
});
expect(r.bars).toEqual([]);
expect(r.unplotted[0]!.reason).toBe("no start date");
expect(r.span).toBeNull();
});

it("rejects an impossible month outright", () => {
const r = programmeBars({
projects: [{ id: "a", name: "Month 13", activities: 1 }],
project_starts: { a: "2026-01-01" }, project_finishes: { a: "2026-13-01" },
});
expect(r.bars).toEqual([]);
expect(r.unplotted[0]!.reason).toBe("no finish date");
});

// A bare `startsWith` matches "p10::A1" against a project "p1", flagging the wrong bar as linked
// and leaving the right one plain. Ordering matters: `p1` is found first by `find`.
it("does not let one project id prefix-match another", () => {
const r = programmeBars({
projects: [{ id: "p1", name: "One", activities: 1 },
{ id: "p10", name: "Ten", activities: 1 }],
project_starts: { p1: "2026-01-01", p10: "2026-01-05" },
project_finishes: { p1: "2026-01-31", p10: "2026-02-05" },
external_links: [{ predecessor: "p10::A1", successor: "p10::B1" }],
});
expect(r.bars.find((x) => x.id === "p10")!.linked).toBe(true);
expect(r.bars.find((x) => x.id === "p1")!.linked).toBe(false);
});

it("returns an empty, well-formed result when the run carried no dates at all", () => {
const r = programmeBars({ projects: [P("a"), P("b")] });
expect(r.bars).toEqual([]);
expect(r.span).toBeNull();
expect(r.unplotted).toHaveLength(2);
});
});
127 changes: 127 additions & 0 deletions apps/web/src/portal/panels/programmeGantt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* PROGRAMME-GANTT — the cross-project bar chart, R22-PIPELINE's last visualisation item.
*
* ## The roadmap said this needed a new engine. It did not.
*
* That entry lists "a cross-project Gantt (`schedule_viz.py` is per-project)" as genuinely missing.
* Half true: `schedule_viz` *is* per-project, but R46's portfolio scheduler already computes
* `project_starts` and `project_finishes` in its merged pass, and the route already puts them on the
* wire. What was missing is that **the client type named only three scalars** —
* `programme_finish`, `project_count`, `external_link_count`. `HttpCore.json<T>` returns
* `res.json()` under an unchecked cast, so the dates were present in the parsed response the whole
* time; nothing *declared* them, so no call site could reach them and none did. The bars here are
* geometry over data the server was already sending.
*
* That matters for where the dates come from. These are the finishes from the ONE merged pass, not
* each project's standalone schedule: a project that looks comfortable alone can be critical to the
* programme, and a bar drawn from its own CPM run would show the comfortable answer.
*
* ## A bar needs both ends
*
* A project with a start and no finish (or the reverse) gets **no bar at all**, and is returned in
* `unplotted` with the reason. The alternative is to substitute the programme's own start or finish
* for the missing end, which draws a bar that looks measured and is not — the same defect the risk
* heat map refuses when it declines to render an unmeasured cell as a green zero.
*/

const DAY_MS = 86_400_000;
/** Matches `SEPARATOR` in `services/api/src/massingplan/core/portfolio.py`. */
const SEPARATOR = "::";

export type ProgrammeInput = {
projects: { id: string; name: string; activities: number }[];
project_starts?: Record<string, string>;
project_finishes?: Record<string, string>;
crossing_activities?: string[];
external_links?: { predecessor: string; successor: string }[];
};

export type ProgrammeBar = {
id: string; name: string; activities: number;
start: string; finish: string;
/** Left edge and width as percentages of the programme span, ready for a CSS bar. */
left: number; width: number;
days: number;
/** This project is named by at least one external link — its dates are a commitment. */
linked: boolean;
/** Finishes on the programme's own finish date: it is what the whole span waits for. */
driving: boolean;
};

export type ProgrammeBars = {
bars: ProgrammeBar[];
unplotted: { id: string; name: string; reason: string }[];
span: { start: string; finish: string; days: number } | null;
};

function day(s: string | undefined): Date | null {
if (!s) return null;
const ymd = String(s).slice(0, 10);
const d = new Date(`${ymd}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
// `Date` NORMALISES an out-of-range day instead of rejecting it: "2026-02-30" parses happily and
// becomes 2026-03-02. Only the month is range-checked ("2026-13-01" is NaN). Round-tripping is
// what catches the day, and it matters here more than usual — a normalised date is an invented
// one, and this module's whole rule is that it does not draw a bar it cannot measure.
return d.toISOString().slice(0, 10) === ymd ? d : null;
}

/**
* Bar geometry for one programme run. Pure — no DOM, no fetch — so the rules above ("a bar needs
* both ends", "driving is measured against the programme finish") are unit-testable rather than
* only visible on screen.
*/
export function programmeBars(r: ProgrammeInput): ProgrammeBars {
const starts = r.project_starts ?? {};
const finishes = r.project_finishes ?? {};
const linked = new Set<string>();
for (const ln of r.external_links ?? []) {
// Link endpoints are "<project><sep><activity>"; the project id is the part before the
// separator, and an id containing no separator is already the project.
for (const end of [ln.predecessor, ln.successor]) {
// The separator is REQUIRED, not decoration: a bare `startsWith` makes "p10::A1" match a
// project "p1", flagging the wrong bar as linked and leaving the right one plain. The key
// format is `<project>::<activity>` (SEPARATOR in `massingplan/core/portfolio.py`).
const p = (r.projects ?? []).find(
(x) => end === x.id || String(end).startsWith(`${x.id}${SEPARATOR}`));
if (p) linked.add(p.id);
}
}

const rows: { p: ProgrammeInput["projects"][number]; s: Date; f: Date }[] = [];
const unplotted: ProgrammeBars["unplotted"] = [];
for (const p of r.projects ?? []) {
const s = day(starts[p.id]), f = day(finishes[p.id]);
if (!s && !f) { unplotted.push({ id: p.id, name: p.name, reason: "no scheduled dates" }); continue; }
if (!s || !f) {
// Deliberately NOT clamped to the programme span — see the header.
unplotted.push({ id: p.id, name: p.name, reason: s ? "no finish date" : "no start date" });
continue;
}
if (f < s) { unplotted.push({ id: p.id, name: p.name, reason: "finish precedes start" }); continue; }
rows.push({ p, s, f });
}
if (!rows.length) return { bars: [], unplotted, span: null };

const t0 = Math.min(...rows.map((x) => x.s.getTime()));
const t1 = Math.max(...rows.map((x) => x.f.getTime()));
// A single-day programme has zero span; dividing by it would give NaN widths, so every bar
// occupies the full track instead — which is what a one-day programme actually looks like.
const total = t1 - t0 || 1;
const bars = rows.map(({ p, s, f }) => ({
id: p.id, name: p.name, activities: p.activities,
start: s.toISOString().slice(0, 10), finish: f.toISOString().slice(0, 10),
left: t1 === t0 ? 0 : ((s.getTime() - t0) / total) * 100,
width: t1 === t0 ? 100 : Math.max(((f.getTime() - s.getTime()) / total) * 100, 0.8),
days: Math.round((f.getTime() - s.getTime()) / DAY_MS) + 1,
linked: linked.has(p.id),
driving: f.getTime() === t1,
}));
bars.sort((a, b) => a.left - b.left || b.width - a.width || a.name.localeCompare(b.name));
return {
bars, unplotted,
span: { start: new Date(t0).toISOString().slice(0, 10),
finish: new Date(t1).toISOString().slice(0, 10),
days: Math.round((t1 - t0) / DAY_MS) + 1 },
};
}
53 changes: 53 additions & 0 deletions apps/web/src/portal/panels/scheduleMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { usd } from "../../ui/charts";
import { escapeHtml as esc } from "../../ui/feedback";
import type { PanelContext } from "../panelContext";
import { programmeBars } from "./programmeGantt";

type Row = { label: string; value: string; hint?: string };

Expand Down Expand Up @@ -344,6 +345,58 @@ export function renderScheduleMethods(ctx: PanelContext): HTMLElement {
t.textContent = r.projects.map((p) => `${p.name} (${p.activities})`).join(" \u00b7 ")
+ (r.rejected_links.length ? ` \u2014 ignored: ${r.rejected_links.join("; ")}` : "");
pfOut.appendChild(t);

// CROSS-PROJECT GANTT — bars from the MERGED pass, not each project's standalone CPM. A project
// can look comfortable alone and be critical to the programme; a bar drawn from its own run
// would show the comfortable answer. `programmeBars` holds the geometry and the rule that a
// half-dated project gets no bar; this only paints what it returns.
const g = programmeBars(r);
if (g.span) {
const gw = document.createElement("div");
gw.style.cssText = "margin-top:8px";
const head = document.createElement("div");
head.className = "meta";
head.textContent = `Programme ${g.span.start} \u2192 ${g.span.finish} \u00b7 ${g.span.days} days`;
gw.appendChild(head);
for (const b of g.bars) {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:8px;margin:3px 0";
const label = document.createElement("div");
label.style.cssText = "flex:0 0 150px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
label.textContent = b.name;
label.title = `${b.name} \u00b7 ${b.start} \u2192 ${b.finish} \u00b7 ${b.days} days`
+ `${b.linked ? " \u00b7 named by an external link" : ""}`;
const track = document.createElement("div");
track.style.cssText = "flex:1;position:relative;height:16px;background:var(--panel2);border-radius:3px";
const bar = document.createElement("div");
// Driving = finishes on the programme finish, so the whole span waits on it. Linked = named
// by a cross-project commitment. Both are facts from the run, not a status guess.
const col = b.driving ? "var(--status-crit)" : b.linked ? "var(--accent)" : "var(--status-good)";
bar.style.cssText = `position:absolute;left:${b.left}%;width:${b.width}%;top:2px;bottom:2px;`
+ `background:${col};border-radius:2px`;
track.appendChild(bar);
const days = document.createElement("div");
days.className = "meta";
days.style.cssText = "flex:0 0 62px;text-align:right;font-variant-numeric:tabular-nums";
days.textContent = `${b.days}d${b.driving ? " \u25c0" : ""}`;
row.append(label, track, days);
gw.appendChild(row);
}
const key = document.createElement("div");
key.className = "meta"; key.style.marginTop = "4px";
key.textContent = "\u25c0 drives the programme finish \u00b7 blue = named by an external link "
+ "(a commitment between parties) \u00b7 bars come from the merged pass, not each project's "
+ "own schedule.";
gw.appendChild(key);
pfOut.appendChild(gw);
}
if (g.unplotted.length) {
// Named, never silently dropped and never drawn with an invented end date.
const u = document.createElement("div");
u.className = "meta"; u.style.cssText = "margin-top:4px;color:var(--status-warn)";
u.textContent = "Not plotted \u2014 " + g.unplotted.map((x) => `${x.name} (${x.reason})`).join("; ");
pfOut.appendChild(u);
}
}));
pfc.appendChild(pfRow); pfc.appendChild(pfOut); wrap.appendChild(pfc);

Expand Down
Loading