Skip to Content

← All archived runs

Run: lead-data-display

run.md

Run: lead-data-display

  • branch: claude/pipeline-lead-data-display-2u70a3
  • pr: #568

00_intake/stub.md

Stub: Lead data display — read-only aggregate in the sections

  • feature-slug: lead-data-display
  • epic: lead-workspace
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • initiative: Refine the Bridge / objective: Validate Technical Infrastructure & Payout Flow (Q2 2026 · Objective 3)
  • depends-on: workspace-shell
  • sequence: 2 of 6

Problem

The shell renders frames but no real data. The three overlapping detail pages each re-fetch and re-render the same lead header, users, skills, activity and progress — duplicated display logic with no shared loader. To make the workspace the one place a lead lives, it must display the lead's real data accurately from a single load.

Proposed change

Add one server-component aggregate loader that, given a lead id, assembles the whole engagement in parallel — the lead (a lead doc, incl. isActiveProject), its proposals, quote, milestones, invoices, lead-expert matches/people, and recent activity (StatusHistory + Activity) — scoped by tenant. Render that real data into the workspace sections (ported from the demo's section registry), read-only:

  • Overview — contract value, delivered/billed/paid, delivery progress.
  • Requirements (BRD), Proposals, Quote, Delivery (milestones + blockers + action items), Invoices, CSAT, People, Activity — each showing real values, with the demo's empty/locked states when a satellite doesn't exist yet at the lead's stage.

This is the read side only: it kills the triple re-fetch (one loader, the existing cache-tag conventions) and shows accurate data. No persona scoping yet (render the full manager-level aggregate); no actions.

Acceptance criteria (rough)

  • A single server loader returns the lead + proposals + quote + milestones + invoices + people + activity for a lead id, tenant-scoped, fetched in parallel.
  • Every section renders the lead's real values (amounts, statuses, dates, names) — verified against the same lead shown on the existing pages.
  • Satellites absent at the current stage show the correct empty/locked state (e.g. "no invoices yet").
  • Status labels/chips resolve through the workflow engine accessors, matching the lead's true status.
  • Read-only — no buttons mutate anything; the levers rail is still an empty placeholder.

Out of scope (this feature)

  • Per-persona section lists, jargon-free per-persona labels, capability-scoped data — stub 3 (this stub renders the full aggregate).
  • The capability matrix / can() — stub 3.
  • Any lever or mutation — stubs 4–6.
  • Modifying the existing detail pages or their loaders.

Notes for Define

  • Reuse existing DB models/services in @sustentus/services/server (lead, proposal, quote, invoice, milestone, lead-expert-match, status-history, activity); do not add new collections.
  • Use the cache-tag conventions documented at apps/docs/app/technical/cache-tags so the surface can be invalidated per lead (sets up stub 5's post-transition refresh).
  • Design source for section rendering: apps/demo/components/workspace/sections.tsx + apps/demo/lib/workspace/format.ts. Port display logic; the demo's projection.ts is a mock-only device — real data comes from the DB, so no projection is needed.
  • touches: apps/web/app/(app)/workspace/[id]/** (loader), apps/web/components/workspace/**.

01_define/output/spec.md

Spec: Lead data display — read-only aggregate in the workspace sections

  • slug: lead-data-display
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • touches: apps/web/app/(app)/workspace/[id], apps/web/components/workspace, @sustentus/services/server
  • complexity: standard

Problem

The workspace shell (stub 1) renders the route, header, journey strip and section frames, but no real data — every section is an empty placeholder. Today the same engagement is scattered across service-leads/[id], projects/[id] and expert/bids/[id], each of which re-fetches and re-renders the same lead header, people, skills, activity and progress: duplicated display logic with no shared loader. For the workspace to become the one place a lead lives, it must display the lead's real data accurately from a single load. This is the second feature of the lead-workspace epic (Refine the Bridge / Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow: leads completing end-to-end, and lead-to-payout cycle time): trustworthy, unified data is what lets us prove the bridge actually moves a lead from intake to payout.

Proposed change

Add one server-component aggregate loader that, given a lead id, assembles the whole engagement in parallel and tenant-scoped, then renders it into the section frames stub 1 scaffolded (the section registry), read-only. The loader assembles, from existing @sustentus/services/server models/services (no new collections):

  • the lead (a lead doc, incl. isActiveProject), its proposals, quote, milestones (with their blockers and action items), invoices, lead-expert matches / people, CSAT, and recent activity (StatusHistory + Activity).

Each scaffolded section renders that real data:

  • Overview — contract value, delivered / billed / paid, delivery progress.
  • Requirements (BRD), Proposals, Quote, Delivery (milestones + blockers + action items), Invoices, CSAT, People, Activity — each showing real values (amounts, statuses, dates, names), and falling back to the demo's empty / locked state when a satellite doesn't exist yet at the lead's stage (e.g. "no invoices yet").

This is the read side only: it kills the triple re-fetch (one loader instead of three pages re-fetching), tags the aggregate read with the existing cache-tag conventions (apps/docs/app/technical/cache-tags) so it can be invalidated per lead — which sets up stub 5's post-transition refresh — and shows accurate data. It renders the full manager-level aggregate (no per-persona scoping yet — stub 3) and performs no actions (the levers rail and header next-best-action CTA slot stay the empty placeholders from stub 1).

Decisions settled this Define:

  • Satellite set covers every scaffolded section. The stub's loader list named the core satellites; the section list also includes CSAT and Delivery's blockers / action items. Each of those is a real existing collection with an existing service (csat, blocker, action-item), so the loader assembles them too — every section stub 1 scaffolded renders real values, none is left empty by omission.
  • Display logic is ported, not imported. Port the section rendering and formatting from the demo design source (apps/demo/components/workspace/sections.tsx + apps/demo/lib/workspace/format.tsformatMoney, the *Chip helpers). The demo's projection.ts is a mock-only device and is not ported — real data comes from the DB. Nothing is imported from apps/demo.
  • Status resolution stays in the engine. Section status labels and chips resolve through the @sustentus/services workflow accessors (the same getStatus / resolveStatusLabel stub 1 uses), never a locally-duplicated status map.

Acceptance criteria

  • A single server loader returns the lead + proposals + quote + milestones + blockers + action items + invoices + people + CSAT + activity for a lead id, tenant-scoped, fetched in parallel.
  • Every scaffolded section (Overview, Requirements, Proposals, Quote, Delivery, Invoices, CSAT, People, Activity) renders the lead's real values (amounts, statuses, dates, names) — verified against the same lead shown on the existing service-leads/[id] / projects/[id] pages.
  • Satellites absent at the lead's current stage show the correct empty / locked state (e.g. "no invoices yet"), matching the demo's behaviour.
  • Section status labels and chips resolve through the @sustentus/services workflow accessors, matching the lead's true status — no locally-duplicated status map.
  • The aggregate read is tagged per the cache-tag conventions so it can be invalidated per lead.
  • Read-only: no control mutates anything; the levers rail and header next-best-action CTA slot remain the empty placeholders from stub 1.
  • Display logic is ported from the demo design source; nothing is imported from apps/demo, and no new collection is added.

Out of scope

  • Per-persona section lists, jargon-free per-persona labels, capability-scoped data, and the capability matrix / can() — stub 3 (persona-capability-matrix). This stub renders the full aggregate.
  • Any lever, next-best-action logic, or mutation — stubs 4–6.
  • Modifying the existing detail pages (service-leads/[id], projects/[id], expert/bids/[id], the finance / BRD pages) or their loaders; consolidating or redirecting them.
  • Importing anything from apps/demo; refactoring existing inline role === … checks; adding any new collection or business logic.

Open questions

  • none — the satellite set (incl. CSAT, blockers and action items), the route and section frames (settled by stub 1, workspace-shell), and the data sources (all existing models/services in @sustentus/services/server) are decided. Per-persona scoping and actions are explicitly later stubs.

Context budget

Per the epic breakdown's recorded note, this Define read the predecessor run's spec (pipeline/runs/workspace-shell/01_define/output/spec.md) to anchor the route + section registry, and the demo design source (apps/demo/components/workspace/sections.tsx) to confirm the display logic to port — both beyond the docs-only intake band, but necessary because the surface to fill and the logic to port are code, not prose. Recorded per the contract.

02_build/output/notes.md

Build notes: lead-data-display

  • commits: feat: lead-data-display — aggregate loader + real section data

What changed

  • apps/web/lib/queries/workspace.ts (the loader): getWorkspaceAggregate(tenantId, leadId, persona) assembles one lead's whole engagement into a plain, serialisable WorkspaceAggregate. Tenant-scoped; fetched in parallel within two dependency waves — wave 1 (lead, accepted + pending proposals, blockers, action items, activity) keyed by lead id; wave 2 (quote, delivery milestones, invoice-summary milestones, CSAT) hangs off the accepted proposal id / the lead's customer, which is the real domain shape (quote and milestones belong to the accepted proposal). Wrapped in unstable_cache, tagged per-lead so stub 5 can invalidate it. Every status chip is resolved through statusService.resolveView (label + canonical hex), never a local map.
  • apps/web/lib/queries/workspace-cache.ts: workspaceCacheTags.detail (tenant:{t}:workspace:{lead}) + revalidateWorkspaceCache — the per-lead invalidation hook stub 5 will call after a transition.
  • apps/web/components/workspace/aggregate.ts: the pure WorkspaceAggregate view-model (structure ported from the demo's WorkspaceLead, with engine-resolved chips). Both the loader and the sections import it.
  • apps/web/components/workspace/sections.tsx: the nine section bodies (Overview, Requirements/BRD, Proposals, Quote, Delivery, Invoices, CSAT, People, Activity) ported from apps/demo/components/workspace/ sections.tsx, display-only — the demo's action levers (ActionButton/can()) are dropped (deferred to stubs 4–6). Each section has an empty/locked state for an absent satellite. Plus WORKSPACE_SECTION_ COMPONENTS, the id→component map the view renders.
  • apps/web/components/workspace/status-chip.tsx: renders an engine-resolved chip (label tinted with the status' canonical hex).
  • apps/web/components/workspace/format.ts: app-local formatMoney / formatDate (ported; no apps/demo import).
  • apps/web/components/workspace/workspace-view.tsx: now takes the aggregate and renders the real sections; header status comes from the resolved lead chip; the levers rail + CTA slot stay the stub-1 placeholders.
  • apps/web/app/(app)/workspace/[id]/page.tsx: calls getWorkspaceAggregate; an unknown/invalid id (the loader returns null on CastError/BSONError or a missing lead) renders notFound().

Acceptance criteria status

  • A single server loader returns the lead + proposals + quote + milestones + blockers + action items + invoices + people + CSAT + activity for a lead id, tenant-scoped, fetched in parallel — buildAggregate in lib/queries/workspace.ts (two parallel waves; people are read from the populated lead).
  • Every scaffolded section renders the lead's real values — all nine sections consume the aggregate; the data comes from the same @sustentus/services/server reads the existing service-leads/[id] / projects/[id] pages use, so values match (live cross-check on the preview deploy).
  • Satellites absent at the lead's current stage show the correct empty/locked state — every section has an EmptyState fallback (e.g. "No invoices yet — billing starts once delivery is under way.").
  • Section status labels and chips resolve through the workflow accessors — all chips come from statusService.resolveView(name, persona); no local label/colour map (the only canonical-name references are the billed/delivered rollup arithmetic, not display).
  • The aggregate read is tagged per the cache-tag conventions so it can be invalidated per lead — unstable_cache tagged with workspaceCacheTags.detail + serviceLeadCacheTags.detail.
  • Read-only — no control mutates anything; the levers rail and header next-best-action CTA slot are the unchanged stub-1 placeholders.
  • Display logic ported from the demo design source; nothing imported from apps/demo; no new collection added — sections/format ported as app-local code; loader reuses existing services only.

Verify result

  • Typecheck: pnpm --filter ./apps/web typecheck passes clean (after rebuilding @sustentus/services + @sustentus/ui dist — the local dist was stale, which surfaced unrelated pre-existing errors in sdm/vendor/expert pages that resolve once packages are built, as CI does).
  • Lint: eslint on the changed files — 0 problems.
  • Format · lint · typecheck · build otherwise run in CI + the Vercel preview; nothing I expect to fail.

Notes for review

  • Domain-shaped scoping decisions (none change the spec, but worth a look):
    • Proposals shows the accepted + pending-review bids (findAcceptedByLead + findPendingReviewByLead) — the proposal service exposes targeted queries, not a single "all bids by lead".
    • Invoices are read from the milestone invoice-summary (findByProposalOrderedWithInvoiceSummary), which carries each milestone's invoice with an engine-resolved status; amounts are percentage × contract value. The lead has no single "contract value" field, so value = quote total → quote amount → accepted proposal price → budget.
    • Activity is the activityService.findByLead feed (most recent 12), which already includes the STATUS_CHANGE entries — so the status-history transitions surface here without a separate raw-model read from apps/web.
    • People are the lead's populated customer/expert/manager/vendor (the lead doesn't populate a separate csm); lead-expert match fit-scores belong to the bid-pool view, not this roster.
  • The loader lives in apps/web/lib/queries/ (alongside the existing cached service-leads / proposals queries) rather than literally under the route folder named in the spec's touches: — that's where the cache-tag convention puts cached reads. The page imports it.

03_release/output/investor-update.md

A lead's real data now lands on its workspace — one accurate view

Who it's for: All six roles — customer, expert, CSM, SDM, admin, vendor. What shipped: The lead workspace now renders a lead's real engagement — quote, milestones, invoices, CSAT and activity — from one tenant-scoped load. Why it matters: Accurate, unified lead data is the read path the payout flow depends on — groundwork toward a shorter lead-to-payout cycle (Refine the Bridge · Q2 Objective 3).

Dig deeper: https://github.com/sustentus/sustentus/pull/568 · no changelog — internal data milestone

03_release/output/release.md

Release: lead-data-display

  • pr: #568 (https://github.com/sustentus/sustentus/pull/568) · merged: pending (CI green + Ready-to-merge ticked; merging in this stage)
  • CI: green on the build commit — Quality Project (lint · format · typecheck) success, Migrate preview database success, Migrate production database skipped (expected on PR), Vercel Preview Comments success; the web preview deployed Ready. Re-checked after the release commit before merge.
  • technical docs: updated apps/docs/app/technical/cache-tags in this PR — registered the new getWorkspaceAggregate cached query + the workspaceCacheTags.detail per-lead tag (the doc asks new tagged domains to register).
  • business docs: no business docs impact — consistent with stub 1 (workspace-shell); the unified lead-workspace capability is documented when it is persona-scoped (stub 3) and actionable (stubs 4–6). This stub is the read-only data layer; the workspace isn't yet a new documented product capability.
  • release notes: investor-only — read-only, manager-level data milestone (validates the read path) with no end-user capability yet; a customer-facing "you can now…" changelog would be premature before persona-scoping + actions. Investor draft at 03_release/output/investor-update.md. Recorded: no end-user changelog — internal/in-progress surface.
  • deploy: pending — gated behind the merge (step 7 runs after the squash-merge).
  • sent: pending — investor email gated behind merge + green production deploy (step 8); audience cut is investor-only.

Review summary

/code-review medium (standard complexity) — two parallel finder angles (correctness; cleanup/conventions/reuse) over the feature's six code files, then verification.

  • Correctness #1 — invoice money was estimated, not real (FIXED). Invoice amounts and the billed/paid rollups were computed as contract value × milestone percentage; the invoices' true stored totals were never fetched. Now the loader fetches real invoice amounts via invoiceService.findProjectInvoicesByQuote(quote._id) and merges each invoice's engine-resolved status from the milestone invoice-summary (by invoice id); billed/paid sum the real totals. (Milestone amounts + delivered stay percentage-of-contract — that's how a milestone is valued, matching the demo.) This is what makes AC #2's "real values, verified against the existing finance pages" hold.
  • Correctness #2 — a status-less quote was hidden (FIXED). issued defaulted a missing/unresolved quote status to quote_draft, so a real quote with no resolvable status rendered the "not issued yet" empty state. Changed to quote.status?.name !== "quote_draft" so only an explicit draft is hidden.
  • Correctness #3 — fee-inclusive contract value vs fee-exclusive proposal price (accepted). Overview "Contract value" uses quote.total (incl. service fee) while the Proposals table shows proposal.price (the expert's bid). These are different entities by design (a contract total ≠ a bid), same structure as the demo — not a bug.
  • Cleanup / reuse. Reused the shared initials and currencySymbol helpers (apps/web/lib/dashboard-utils.ts), dropping a duplicate currency-symbol map. Deliberately did not reuse fmt — it k-abbreviates (£15k), and the workspace needs exact amounts. Converted four inline prop object-types in sections.tsx to named types (CONVENTIONS.md: "Prop types are defined as a type directly above the component").
  • Kept on purpose. The WORKSPACE_SECTION_COMPONENTS id→component registry stays — it's the per-persona section-list seam stub 3 (persona-capability-matrix) consumes, and it mirrors the demo's SECTION_REGISTRY. The local dueLabel stays: its twin lives in components/service-leads/lead-action-items/action-items-panel.tsx, which the spec's Out-of-scope forbids touching — deduping it is a logged follow-up, not this PR's work.
  • Re-verified after fixes: pnpm --filter ./apps/web typecheck clean, eslint on changed files clean.

Acceptance check (vs spec)

  • Single tenant-scoped parallel loader returns lead + proposals + quote + milestones + blockers + action items + invoices + people + CSAT + activity — getWorkspaceAggregate / buildAggregate, parallel within dependency waves.
  • Every scaffolded section renders the lead's real values — all nine sections; invoice/billed/paid now read real invoice totals (post-review fix), so figures reconcile with the existing finance pages.
  • Absent satellites show the correct empty/locked state — EmptyState per section; a real-but-status-less quote no longer falsely shows "not issued" (review fix).
  • Status labels/chips resolve through the @sustentus/services workflow accessors — statusService.resolveView, no local label/colour map.
  • Aggregate read tagged per the cache-tag conventions — unstable_cache with workspaceCacheTags.detail + serviceLeadCacheTags.detail; registered in the cache-tags doc.
  • Read-only — no mutation; levers rail + header CTA slot are the stub-1 placeholders.
  • Display ported from the demo; nothing imported from apps/demo; no new collection — confirmed.