Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ 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 — Portfolio risk heat map

`GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the
five risk engines across (Monte-Carlo schedule risk · predictive alerts · EVM · pre-flight gate ·
overdue coordination), intensity `3·high + 2·medium + 1·low`. Rendered on Portfolio beside the
executive roll-up, with a "worst first" line and click-through to the project.

`/portfolio/executive` and `/portfolio/construction` roll up *performance*; neither could say which
risk **engine** is hot on which project. Cells come from `risk_board.board` unchanged — same engines,
same Monte-Carlo seed — so a cell and the project's own risk panel cannot disagree. That costs a full
board per project, so the sweep is bounded by `limit` (default 25, clamped 1–100) and reports
`truncated` rather than quietly scanning a prefix.

**An empty cell is not a safe cell.** A grid of counts renders `0` for two different facts: *this
engine looked and found nothing*, and *this engine could not run*. `board` is fail-open per lane and
already separates them, so every cell carries a `state`; an unmeasured cell carries **no counts at
all** rather than zeros, and the UI draws it as a dash. `coverage` reports the split at portfolio
level. A clear signal nobody has a basis for is worse than no heat map.

**`risk_board.LANES` is new, and gated against a real board run.** `board` reports coverage under
lane keys (`schedule_risk`) while its items carry source strings (`schedule-risk`); nothing connected
the two, and a roll-up must join on both. `test_risk_portfolio.py` asserts every lane key `board`
emits appears in the table and every `source` its items carry is a value — so a lane added to `board`
alone fails rather than rendering as a column that never lights up.

Both claims mutation-checked: emitting zeros for an error cell, and dropping a lane from `LANES`,
each fail with the shape named.

## Unreleased — SCALE-SEAM (101): design-phase predicted performance

Six methods out of `client.ts` (**642 → 603**). Five to a new
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/api/risk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ export function withRisk<TBase extends Ctor<HttpCore>>(Base: TBase) {
riskDigest(pid: string) {
return this.json<RiskDigest>(`/projects/${pid}/risk-digest`);
}
/** The same board gridded across the portfolio — projects × risk engine, severity-weighted.
* `state` on a cell is not decoration: an engine that could not run reads `error`, and the map
* must not render that as a clear cell. `coverage` says how much of the grid is measured. */
portfolioRisk(limit = 25) {
return this.json<{
projects: { id: string; name: string; band: string | null; score: number; count: number;
high: number; medium: number; low: number; measured_sources: number;
cells: Record<string, { state: string; score?: number; count?: number; high?: number;
medium?: number; low?: number }> }[];
sources: { key: string; label: string; score: number; count: number; high: number;
projects_measured: number; projects_error: number }[];
totals: { high: number; medium: number; low: number; count: number; score: number };
band_tally: Record<string, number>;
hotspots: { project_id: string; project: string; source: string; score: number; high: number;
title: string | null; link: string | null }[];
coverage: { cells: number; measured: number; errored: number; unknown: number;
pct: number | null };
project_count: number; projects_available: number; truncated: boolean; limit: number;
note: string;
}>(`/portfolio/risk?limit=${limit}`);
}
/** RISK-BOARD: one ranked register unifying every computed risk signal (deep-linked per item). */
riskBoard(pid: string) {
return this.json<{ items: { source: string; severity: "high" | "medium" | "low"; title: string;
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/portal/panels/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,73 @@ export async function renderPortfolio(ctx: PanelContext) {
ctx.root.appendChild(card);
}).catch(() => { /* returns spread is best-effort; the roll-up above stands on its own */ });

// RISK HEAT MAP — R22-PIPELINE. The table above says how each project is PERFORMING; this says
// which risk ENGINE is hot on which project, which is the question that decides where a
// programme director spends the morning. Cells come from the same `risk_board` each project's
// own risk panel renders, so clicking through can never show a different number.
//
// A cell whose engine could not run is drawn as a dash on the muted ground, never as a green
// zero. That is the whole reason `state` is on the wire: an unmeasured source rendered as clear
// is worse than no heat map, because it is a clear signal nobody has any basis for.
void ctx.host.api.portfolioRisk().then((hm) => {
if (!hm.projects.length) return;
const card = document.createElement("div"); card.className = "dash-card"; card.style.marginTop = "10px";
const cov = hm.coverage;
card.innerHTML = `<b>Risk heat map</b> <span class="meta">${hm.project_count} project(s) × ${hm.sources.length} engines`
+ ` · ${hm.totals.high} high / ${hm.totals.medium} medium / ${hm.totals.low} low`
+ (cov.pct == null ? "" : ` · ${cov.pct}% of cells measured`)
+ (cov.errored || cov.unknown ? ` · ${cov.errored + cov.unknown} unavailable` : "")
+ (hm.truncated ? ` · showing ${hm.project_count} of ${hm.projects_available}` : "")
+ `</span>`;
// Intensity ramp, not a gradient: four steps a reader can name, keyed off the same
// severity-weighted score the API computes so the colour and the number never diverge.
const heat = (score: number) => score >= 9 ? "var(--status-crit)" : score >= 4 ? "var(--status-warn)"
: score > 0 ? "var(--status-good)" : "transparent";
const tbl = document.createElement("table"); tbl.className = "portal-table"; tbl.style.fontSize = "11px";
tbl.innerHTML = `<thead><tr><th scope="col" style="text-align:left">Project</th>`
+ hm.sources.map((s2) => `<th scope="col" style="text-align:center">${esc(s2.label)}</th>`).join("")
+ `<th scope="col" style="text-align:right">Total</th></tr></thead>`;
const tb = document.createElement("tbody");
for (const p of hm.projects) {
const tr = document.createElement("tr"); tr.className = "kpi-click";
if (p.id === here) tr.style.fontWeight = "700";
const cells = hm.sources.map((s2) => {
const c = p.cells[s2.key];
if (!c || c.state !== "ok") {
return `<td style="text-align:center;color:var(--muted)" title="${esc(s2.label)} could not be computed for this project">–</td>`;
}
const col = heat(c.score ?? 0);
const txt = c.count ? String(c.count) : "·";
return `<td style="text-align:center;background:${col === "transparent" ? "transparent" : col + "33"};color:${c.count ? col : "var(--muted)"}"`
+ ` title="${esc(s2.label)}: ${c.high ?? 0} high / ${c.medium ?? 0} medium / ${c.low ?? 0} low">${txt}</td>`;
}).join("");
tr.innerHTML = `<td>${esc(p.name)}${p.id === here ? " ·" : ""}</td>${cells}`
+ `<td style="text-align:right;font-weight:700;color:${heat(p.score)}">${p.count || "—"}</td>`;
// Keyboard-operable, matching the `documents.ts` folder-row idiom. `.kpi-click` already
// styles `:focus-visible` (style.css) — the stylesheet was written expecting these rows to
// be focusable, and a pointer-only handler quietly never delivered it.
const go = () => { if (p.id !== here) window.location.search = `?project=${p.id}`; };
if (p.id !== here) {
tr.setAttribute("role", "button"); tr.tabIndex = 0;
tr.setAttribute("aria-label", `Open ${p.name}, ${p.count} open risk item(s)`);
tr.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } };
}
tr.onclick = go;
tb.appendChild(tr);
}
tbl.appendChild(tb); card.appendChild(tbl);
if (hm.hotspots.length) {
const hl = document.createElement("div"); hl.className = "meta"; hl.style.marginTop = "6px";
hl.innerHTML = "<b>Worst first:</b> " + hm.hotspots.slice(0, 4)
.map((x) => `${esc(x.project)} — ${esc(x.title ?? x.source)}`).join(" · ");
card.appendChild(hl);
}
card.appendChild(Object.assign(document.createElement("div"), { className: "meta",
textContent: "Cell = open risk items from that engine (hover for the severity split); "
+ "a dash means the engine could not run for that project, which is not the same as clear." }));
ctx.root.appendChild(card);
}).catch(() => { /* heat map is best-effort; the roll-up above stands on its own */ });

// Acquisition funnel sits ABOVE the construction book: executive KPIs answer deals we already
// won; this answers what is in the book, how much of it historically closes, and how long it
// takes. Weighted value uses this firm's closed history — a stage without enough samples is
Expand Down
26 changes: 26 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,32 @@ stakes we are missing.
✅ **Funnel viz SHIPPED v0.3.1135.** `GET /pipeline/funnel` had no caller; Portfolio now renders
stage counts, derived win rates, weighted value with coverage, and closed cycle time beside
open age. Cross-project Gantt, risk heat map, and department resourcing remain.

✅ **Risk heat map SHIPPED — `GET /portfolio/risk`, `services/api/src/aec_api/risk_portfolio.py`.**
Projects down, the five `risk_board` engines across, intensity `3·high + 2·medium + 1·low`. Cells
come from `risk_board.board` unchanged — same engines, same Monte-Carlo seed — so a cell and the
project's own risk panel cannot disagree; that costs a full board per project, which is why the
sweep is bounded by `limit` and reports `truncated` rather than silently scanning a prefix.

**The design decision worth recording is that an empty cell is not a safe cell.** A grid of counts
renders `0` for two different facts — *this engine looked and found nothing* and *this engine could
not run*. `board` already separates them (it is fail-open per lane and returns `lanes: {name: ok |
error}` beside its items), so every cell carries a `state` and an unmeasured one carries **no counts
at all** rather than zeros; the UI draws it as a dash, never a green zero. This is the cap-table
lesson in a second place — *do not let an unmeasured value wear the costume of a measured one* —
and it is mutation-checked: making the error branch emit zeros fails
`services/api/test_risk_portfolio.py` on the cell shape.

**A second gate came out of building it.** `board` reports coverage under lane keys
(`schedule_risk`) while its items carry source strings (`schedule-risk`), and nothing connected the
two — a roll-up has to join on both. The pairing is now `risk_board.LANES`, asserted against a
**real board run**: every lane key `board` emits must appear, every `source` its items carry must be
a value. A lane added to `board` and not to `LANES` would otherwise render as a column that
silently never lights up.

**Still open: cross-project Gantt and department resourcing.** The resourcing half is not the small
item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**,
per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own.
## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)*

**A THIRD false blocker, and the biggest one.** **W10-9 dimensional constraints** has sat gated for
Expand Down
Loading