sdm-demand-aggregationrun.md00_intake/stub.mdThe go-live demand table (goLiveDemand in apps/web/lib/mock/sdm.ts,
rendered by demand-table.tsx) shows
demand the SDM must cover, one row per (region, country, language, platform, module) with a demand
count, quotesWaiting, a bidReadyExperts list, a timeBand (Immediate / Next / Upcoming) and
daysToStart. None of this is aggregated today — it is a static array. The real lead pipeline has the
raw signal (active leads in BRD/quote/bid with a product, location, language and start date) but nothing
rolls it into per-cell demand counts or buckets it by time-to-start.
(platform, module, region, language) using sdm-supply-taxonomy, producing the
demand count and quotesWaiting per cell.daysToStart.bidReadyExperts per cell from the existing bid-pool / lead-expert-match data (the experts
already matched/bid-ready for those leads).status, readiness,
coverage, coverageGap) render in a pending/neutral state until sdm-supply-coverage lands, then
they fill in — this feature owns the demand half of the row.demand count and quotesWaiting; bidReadyExperts come from the bid pool.daysToStart is the cell's
soonest start.lib/mock/sdm.status/readiness/coverage columns (feature 4) — those
are computed against supply and only enriched here.lead-expert-match rather than re-deriving who is bid-ready —
see expert-dashboard-data/bid-pool-workbench.touches: packages/services/src/server (demand aggregation read-service over leads/quotes),
apps/web/components/dashboard/sdm/demand-table.tsx (consume the real shape),
apps/web/app/(app)/sdm/dashboard/page.tsx.01_define/output/spec.mdThe SDM "Go live readiness" dashboard's go-live demand table (goLiveDemand in
apps/web/lib/mock/sdm.ts, rendered by apps/web/components/dashboard/sdm/demand-table.tsx) shows
the demand an SDM must cover — one row per supply/location cell with a demand count,
quotesWaiting, a bidReadyExperts list, a timeBand (Immediate / Next / Upcoming) and
daysToStart. None of it is real: it is a static array. sdm-area-foundation (sequence 2) already
resolves the signed-in SDM's area and returns a typed envelope with the in-scope leads[] (the
demand side) and experts[] (the supply side), and sdm-supply-taxonomy (sequence 1) now provides
the platform→module key; but nothing rolls those leads into per-cell demand counts or buckets them
by time-to-start, so the table the SDM lands on still can't be trusted. This is sequence 3 of 8 in
the sdm-dashboard-data epic. It advances Build the Bridge / Q2 2026 Objective 3 — Validate
Technical Infrastructure & Payout Flow by proving the dashboard can turn the real, area-scoped lead
pipeline into the demand picture an SDM plans coverage against.
Aggregate the SDM's area-scoped, active leads into demand cells, and wire the demand table to that
aggregation. This feature owns the demand half of each row; the supply/coverage half stays
neutral until sdm-supply-coverage (sequence 4) lands.
Supply key — platform → module (from sdm-supply-taxonomy). A lead's product is a module
that references its platform (vendor) parent. Cells key on (platform, module, region, language): platform fills the demand table's Vendor column and module (the product name)
fills the Product column. Area scoping reuses the rule sdm-area-foundation defines — area
holds platforms (platform ids), resolved to their module (product) ids so a lead is in-area
when its product sits under one of the SDM's owned platforms.
Demand window — which leads count. A lead counts as demand when it is an active, pre-delivery
lead in the BRD → quote → bid window: its workflow status is one of pending / backlog /
quotation_process / awaiting_confirmation (everything before work_in_progress, excluding
qualified_out), it is not a delivery project (isActiveProject: false), and it is not deleted.
Tenant- and area-scoped, driven by the live mine/tenant scope.
Cell key + measures. Group the in-scope leads into cells keyed by (platform, module, region, language):
platform = the lead's product's platform parent name; module = the lead's product name;
region = lead.location.region; language = the lead's primary language (languages[0]),
so each lead lands in exactly one cell (mirroring the one-language-per-row mock). A missing
platform, module, region or language is grouped under an explicit "unspecified" bucket rather than
silently dropped.demand = count of in-scope leads in the cell.quotesWaiting = the subset of the cell's leads in the quote stage (quotation_process /
awaiting_confirmation).bidReadyExperts = distinct experts who already have a non-excluded lead_expert_match against
the cell's leads — reused from the existing bid-pool / lead-expert-match data (as
bid-pool-workbench does), not re-derived by a fresh matching pass.Time-band + days-to-start. daysToStart for a cell is the soonest days-to-start across its
leads, computed from lead.startDate (the requested start — the only start signal on the lead;
there is no separate go-live date field). The cell's timeBand is bucketed from that soonest
value: Immediate = starts in under 7 days, Next = 7–14 days, Upcoming = more than 14
days. Leads with no startDate fall into Upcoming with a "—" days-to-start. The thresholds
live as named constants so they can be tuned without a schema change.
Aggregation read-service. Add a lean sdmDemandService (new
packages/services/src/db/services/sdm-demand/, following the established index.ts class +
instance.ts singleton pattern, exported through db/services/index.ts, mirroring sdm-area/).
It resolves the SDM's area dimensions (reusing the area-membership rule, not re-implementing the
dashboard's scope resolution), queries the in-scope active leads with the fields demand needs
(resolving product → platform for the cell key), reads the bid-ready matches, and returns the
typed demand-cell array the table renders.
Wire the table + retire the mock. apps/web/app/(app)/sdm/dashboard/page.tsx fetches the
aggregated cells alongside the area envelope (same tenant + SDM + scope) and passes them to
DemandTable, which renders from them — no longer from lib/mock/sdm. The Vendor and
Product columns now show the real platform + module; the coverage columns (status /
coverage-status badge, coverageGap badge) render in a neutral "pending" state for every row
until sdm-supply-coverage lands. Remove goLiveDemand from apps/web/lib/mock/sdm.ts. The table
keeps its Immediate / Next / Upcoming tab filter and its per-tab empty state.
pending / backlog / quotation_process /
awaiting_confirmation, excluding qualified-out and delivery-stage leads) roll into demand
cells keyed by platform + module + region + language, scoped to the SDM's area and tenant via the
area-membership rule.demand count and quotesWaiting (the quote-stage subset);
bidReadyExperts are the distinct non-excluded matches reused from the bid-pool /
lead-expert-match data, not a fresh matching pass.lead.startDate
(Immediate < 7 days, Next 7–14, Upcoming > 14); daysToStart is the cell's soonest start, and
leads with no start date fall into Upcoming.sdmDemandService — no longer from
lib/mock/sdm; the Vendor + Product columns show the real platform + module, the mine/tenant
scope toggle changes the aggregated cells, and goLiveDemand is removed from
apps/web/lib/mock/sdm.ts.status, coverageGap) render in a neutral pending state for every row
this run; they are not computed here.status, readiness, coverage, and
coverageGap values are computed against supply by sdm-supply-coverage (sequence 4); here those
columns are neutral placeholders only.sdm-supply-taxonomy (sequence 1,
now merged); this feature consumes the resolved key, it does not build it.goLiveRisks / actionStrip, sequence 5) — untouched.mine/tenant toggle — owned by sdm-area-foundation
(sequence 2); this feature reuses its area-membership rule.lead_expert_match rows are read as-is for bid-ready
experts.apps/dashboards SDM page — it stays mock by convention (the dashboards app is
self-contained).02_build/output/notes.mdpackages/services/src/db/services/sdm-demand/ (new): sdmDemandService.getDemandForSdm(tenantId, sdmUserId, scope) aggregates the SDM's area-scoped, active leads into demand cells keyed by (platform, module, region, language). It reuses the same area-membership rule as sdmAreaService — area.platforms (platform ids) resolved to their module (product) ids — filters to the BRD→quote→bid demand window (pending / backlog / quotation_process / awaiting_confirmation, isActiveProject: false), resolves product → platform for the cell key, and returns per-cell demand, quotesWaiting, bidReadyExperts, timeBand and daysToStart.…/services/index.ts: export sdmDemandService + the SdmDemandService / SdmDemandCell / DemandTimeBand types.apps/web/app/(app)/sdm/dashboard/page.tsx: resolveArea → resolveDashboard, which fetches the area envelope and the demand aggregation in parallel (same tenant + SDM + scope) and passes the cells to DemandTable.apps/web/components/dashboard/sdm/demand-table.tsx: consumes the aggregated cells; the Vendor column shows the real platform, Product shows the real module, and only the coverage columns render a neutral "Pending" badge until sdm-supply-coverage.apps/web/lib/mock/sdm.ts: removed goLiveDemand.sdm-supply-taxonomy (sequence 1). That feature was unbuilt when this run started, so the first build keyed on product alone as an interim (the convention sdm-area-foundation had set) with the vendor column pending. Sequence 1 then merged to main (PR #524: platform model, product.platform ref, sdm.area.platforms re-pointed to platform). This branch merged main and was reworked onto the real platform→module key — the Vendor column is now real; only the coverage columns stay pending (their feature, sequence 4, is still unbuilt).sdmDemandService lead filter + product→platform resolution + cell aggregation.demand + quotesWaiting (quote-stage subset); bidReadyExperts are the distinct non-excluded lead_expert_match rows, not a fresh matching pass.lead.startDate (Immediate < 7, Next 7–14, Upcoming > 14); daysToStart is the cell's soonest; no-start leads fall into Upcoming.sdmDemandService with real Vendor + Product, the mine/tenant toggle changes the cells, and goLiveDemand is removed from the mock.area directly and rebuilds the same scoped lead filter as sdmAreaService (platforms → product ids), because the envelope's projected leads[] don't carry startDate / languages / the product+platform needed to aggregate. The area-membership rule is intentionally kept identical to the area service — worth a glance to confirm they stay in step.bidReadyExperts and daysToStart are produced but not yet rendered as table columns (the table didn't surface them before either); they're in the cell shape for the coverage/risk features downstream.status is matched against lowercase workflow names, consistent with leadService.03_release/output/changelog.mdLive at apps/help/app/changelog/2026-06-23-sdm-demand-aggregation/page.mdx.
title: "See your real go-live demand on the SDM dashboard" personas: [sdm]
Your go live readiness dashboard's demand table now reflects the real work heading your way:
03_release/output/investor-update.mdWho it's for: Service delivery managers (SDM) What shipped: The go live readiness dashboard's demand table now builds from each SDM's real, area-scoped lead pipeline — demand counts, quotes waiting and bid-ready experts per platform and module, bucketed by time to start. Why it matters: SDMs can plan coverage against real demand — Build the Bridge, Q2 2026 Objective 3 (validate technical infrastructure & payout flow).
Dig deeper: https://github.com/sustentus/sustentus/pull/521 · https://help.sustentus.com/changelog/2026-06-23-sdm-demand-aggregation
03_release/output/release.mdmain (Ready to merge ticked, CI green) — finalised in this session's release summarysdmDemandService is an internal read-service not described at service granularity in apps/docs/app/technical/**; no new app, route, env var, or architecture change (mirrors the sdm-area-foundation sibling).feature-role-matrix/service-journey; this wires real, area-scoped data behind the existing demand table without changing documented persona capabilities.apps/help/app/changelog/2026-06-23-sdm-demand-aggregation/) + investor draft in this PR.resolveDashboard ran the area and demand resolves in one Promise.all inside a single try; a getDemandForSdm failure rejected the lot → the page fell back to SdmDashboardEmpty, hiding the header, area panel and every other section even when the area resolved. Demand now degrades independently (.catch → empty cells → empty demand table); area stays the gate.(platform, module, region, language) key was space-joined, so e.g. "A B"/"C" and "A"/"B C" could collapse into one cell. Switched to a NUL (\u0000) join — a separator that can't appear in a name.fullName helper (matches the per-service pattern across sdm-area/csm-portfolio); the 5-resolve/Promise.all that registers the product + platform models for the nested populate(); bidReadyExperts/daysToStart produced but not yet rendered as columns (carried for the coverage/risk features downstream, as the mock did).DEMAND_STATUSES, isActiveProject: false, region + resolved-product scoping) + cell aggregation.demand + quotesWaiting (quote-stage subset); bidReadyExperts are the distinct non-excluded lead_expert_match rows, not a fresh matching pass.lead.startDate (Immediate < 7, Next 7–14, Upcoming > 14); daysToStart is the cell's soonest; no-start leads fall into Upcoming.sdmDemandService with real Vendor + Product, the mine/tenant toggle changes the cells, and goLiveDemand is removed from apps/web/lib/mock/sdm.ts.