project-health-enginerun.md00_intake/stub.mdThe status-summary boxes (overallStatus: on-track | minor-issues | at-risk), the go-live strip
(status, daysToGoLive, goLiveDate, confidence, delta, atRiskReasons) and the riskStatus
badge are all dummy. There is no health concept in the data model. Per the scope decision these
values are computed by the platform, not entered by a human — so they need a deterministic
heuristic over the now-real milestone, blocker, due-date and invoice data.
overallStatus — on-track / minor-issues / at-riskgoLive — status, goLiveDate (from lead.endDate), daysToGoLive, confidence
(High | Medium | Low), delta (change vs. plan), atRiskReasons[]riskStatus — Low | Medium | HighblocksGoLive + dueDate slippage, open blockers, and
overdue/unpaid invoices.atRiskReasons.daysToGoLive and goLiveDate reflect the real project end date.customer-action-items overdue items should also feed risk (sequenced after this;
can be a follow-up input).touches: packages/services/src/server (health service),
apps/web/app/(app)/customer/dashboard/page.tsx (status boxes),
apps/web/components/dashboard/customer/project-status-strip.tsx,
apps/web/components/dashboard/customer/risk-blockers-feedback.tsx.01_define/output/spec.mdThe customer dashboard's status-summary boxes (overallStatus: on-track | minor-issues | at-risk),
the go-live strip (status, daysToGoLive, goLiveDate, confidence, delta, atRiskReasons) and
the riskStatus badge are still read from apps/web/lib/mock/customer.ts — there is no health
concept anywhere in the data. The dependency features that this one builds on are now real: the
customerProjectService envelope serves the active project, milestones carry status /
blocksGoLive / dueDate, the blocker model exists (its index comment literally notes "the hot
read project-health-engine consumes"), and invoices carry paid / dueDate. What is missing is the
computed layer that turns those facts into the three health badges.
This is the customer's only honest signal of whether their project will land on time, so it must be
computed by the platform (not hand-entered) and derive deterministically from the real milestone,
blocker, due-date and invoice data. It advances Build the Bridge / Q2 2026 Objective 3 — Validate
Technical Infrastructure & Payout Flow by making the delivery step of the service journey
trustworthy end to end, and it is feature 4 of 9 in the customer-dashboard-data epic.
Add a deterministic, pure health-computation function and wire its output into the dashboard.
The heuristic (single severity score, worst-signal-wins). Compute one severity for the active
project — 0 (clear), 1 (minor), 2 (critical) — taking the highest severity any signal
raises:
| Signal (evaluated over the active project) | Raises severity to |
|---|---|
A go-live-blocking milestone is overdue (blocksGoLive && dueDate < today && status ≠ done) |
2 (critical) |
An unpaid invoice is overdue (!paid && dueDate < today) |
2 (critical) |
A non-blocking milestone is overdue (!blocksGoLive && dueDate < today && status ≠ done) |
1 (minor) |
At least one open blocker exists (status = open) |
1 (minor) |
| none of the above | 0 (clear) |
The three badges are then a direct mapping of that one score (so they can never disagree):
| severity | overallStatus | riskStatus | go-live confidence | go-live status |
|---|---|---|---|---|
| 0 | on-track | Low | High | on-track |
| 1 | minor-issues | Medium | Medium | on-track |
| 2 | at-risk | High | Low | at-risk |
Other go-live-strip fields are derived from real project data:
goLiveDate — formatted from lead.endDate (the project's planned end).daysToGoLive — whole days from today to lead.endDate (0 if past / unset).atRiskReasons[] — one human-readable string per contributing signal (e.g. "Milestone 'Core API
build' is overdue and blocks go live", "Open blocker: waiting on API key", "Invoice overdue").
Empty at severity 0.delta — a derived label, not date arithmetic (there is no stored baseline end date to diff
against): "On track, no change" at severity 0, "Minor issues — being monitored" at 1, and at 2
the top atRiskReasons entry.Wiring. Surface a health object on the CustomerProjectEnvelope (the service already fetches
milestones, the quote's invoices and the lead; it additionally reads open blockers for the project
via the existing { tenantId, lead, status } index). The dashboard page then reads
project.health.overallStatus for the status-summary boxes, project.health.goLive for the go-live
strip, and project.health.riskStatus for the risk badge — replacing the corresponding d.* mock
reads. When there is no active project, health follows the existing empty-state path.
Documentation. The thresholds above are committed as a doc-comment on the pure function so the RAG bands are reproducible by inspection.
overallStatus, the full go-live strip, and riskStatus are computed from real milestones +
open blockers + due dates + invoices for the customer's active project — no manual field and no
read from lib/mock/customer.ts for these values.at-risk / High / confidence Low, with a populated atRiskReasons.minor-issues / Medium / confidence Medium, with the cause named in atRiskReasons.on-track
/ Low / confidence High and atRiskReasons is empty.goLiveDate and daysToGoLive reflect the real lead.endDate.project-blockers); this
feature wires only the riskStatus badge there.latestMessage and changeControl sections (owned by project-messaging and
project-change-control); they keep reading mock this round.customer-action-items overdue items into the score — sequenced after this; a possible
follow-up input, not built now.delta against a stored baseline end date — there is no baseline to diff; delta
is a derived label.02_build/output/notes.mdfeat: project-health-engine — computed health engine + dashboard wiringpackages/services/src/db/services/customer-project/health.ts (new): the pure
computeProjectHealth function. Worst-signal-wins severity (0/1/2) over overdue
milestones (blocking → 2, non-blocking → 1), overdue unpaid invoices (→ 2) and open
blockers (→ 1), mapped to overallStatus / riskStatus / go-live confidence + status,
with daysToGoLive / goLiveDate from the lead end date and a populated atRiskReasons.
The RAG thresholds are documented in the function's doc-comment.packages/services/src/db/services/milestone/index.ts: added dueDate to
MilestoneDeliveryRow (selected in findDeliveryByProposal) so the health engine reads
due dates off the milestones the envelope already fetches — no extra query.packages/services/src/db/services/customer-project/index.ts: fetch the project's open
blockers (blockerService.listOpenByLead), compute health in toEnvelope, and expose it
on CustomerProjectEnvelope.packages/services/src/db/services/index.ts: re-export the health types.apps/web/app/(app)/customer/dashboard/page.tsx: wire the status-summary boxes
(health.overallStatus), the go-live strip (health.goLive, ISO date formatted for the
strip) and the risk badge (health.riskStatus) to the computed output, replacing the mock
reads. The blockers list, latest message and change control still read mock (owned by other
features).overallStatus, the full go-live strip, and riskStatus are computed from real
milestones + open blockers + due dates + invoices — the page no longer reads these from
lib/mock/customer.ts.at-risk / High /
confidence Low with a populated atRiskReasons (severity 2).minor-issues / Medium / confidence Medium, cause named in atRiskReasons (severity 1).on-track / Low /
confidence High, empty atRiskReasons (severity 0).goLiveDate and daysToGoLive derive from lead.endDate.computeProjectHealth, now injected) whose
RAG thresholds are documented in a doc-comment.blocksGoLive flag, so (per the Define decision) any open blocker is
a minor (severity 1) signal — only overdue blocking milestones and overdue invoices reach
at-risk. At severity 1 the go-live strip stays "on track" (the summary boxes carry the
minor-issues state); this matches the spec's severity→badge table.delta is a derived label (no stored baseline end date to diff against), per spec.03_release/output/changelog.mdCustomer · 2026-06-23 · PR #506 · live entry: apps/help/app/changelog/2026-06-23-project-health-engine
Your dashboard's status, go-live and risk signals are now worked out automatically from what's really happening on your project — your milestones, blockers, due dates and invoices — instead of placeholder text:
03_release/output/investor-update.mdWho it's for: Customers What shipped: The customer dashboard now computes each project's status, go-live confidence and risk from its real milestones, blockers and invoices. Why it matters: A trustworthy delivery view advances Build the Bridge — Objective 3, Validate Technical Infrastructure & Payout Flow.
Health is derived from live project data by a single reproducible rule — no manual entry, no mock.
Dig deeper: <merged-PR URL> · <changelog entry URL>