Skip to Content

← All archived runs

Run: vendor-customer-health

run.md

Run: vendor-customer-health

  • branch: claude/dreamy-maxwell-khuvky
  • pr: #547

00_intake/stub.md

Stub: Vendor dashboard — customer health + churn prediction

  • feature-slug: vendor-customer-health
  • epic: vendor-dashboard-data
  • personas: Vendor
  • initiative: Build the Bridge / objective: Q2 2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: vendor-revenue-risk, vendor-revenue-retention
  • sequence: 7 of 8

Problem

The customer-health section (customer-health-section.tsx) shows CSAT by revenue (avg CSAT, revenue-weighted CSAT, revenue below threshold, revenue awaiting CSAT), a service-outcome health rate, the shared GRR, and a forward-looking churn prediction (revenue at risk next period, accounts at risk, confidence) — all from the mock. There is no engine that weights CSAT by revenue or projects next-period churn. It needs the revenue map (feature 2) and the risk signals (feature 6), plus the csat model.

Proposed change

  • CSAT by revenue: from the csat model over the window — avg CSAT, revenue-weighted CSAT (weighted by feature 2's per-customer revenue), revenue below a CSAT threshold, and revenue awaiting CSAT (delivered, no CSAT yet); plus change vs prior.
  • Service-outcome health rate: the share of delivered revenue/engagements meeting the health bar (CSAT ok + within SLA + not at risk), with change vs prior.
  • Heuristic churn prediction: project next-period revenue at risk and accounts at risk from feature 6's current risk signals (SLA breach / low CSAT / ageing), with a simple confidence band (High/Medium/Low) from signal strength/coverage — no trained model.
  • Wire the customer-health section (reusing the shared GRR from feature 2) over the real set.

Acceptance criteria (rough)

  • CSAT-by-revenue metrics (avg, revenue-weighted, below-threshold, awaiting) are computed from real CSAT + revenue over the window.
  • Service-outcome health rate is real, with change vs prior.
  • Churn prediction (next-period revenue at risk, accounts at risk, confidence band) is derived heuristically from the feature-6 risk signals; the section renders entirely from real data.

Out of scope (this feature)

  • A trained / ML churn model — the prediction is heuristic this epic (epic decision).
  • Re-deriving the risk signals (feature 6) or the retention math (feature 2) — both are consumed.

Notes for Define

  • Reuse feature 6's thresholds (low-CSAT cutoff, ageing) so "health" and "at risk" stay consistent across the two sections.
  • Define the confidence band rule explicitly (e.g. by how many independent risk signals an account trips, and revenue coverage) so it is reproducible, not arbitrary.
  • touches: packages/services/src/server (health + heuristic-prediction rollup on the envelope), reads packages/services/src/db/models/csat.ts + the feature-6 risk output, apps/web/components/dashboard/vendor/customer-health-section.tsx.

01_define/output/spec.md

Spec: Vendor dashboard — customer health + heuristic churn prediction

  • slug: vendor-customer-health
  • personas: Vendor
  • touches: packages/services/src/db/services/vendor/index.ts (fold feature-6's loadSlaBreachCustomers into getCustomerHealth's at-risk signal set + the service-health "within SLA" criterion), apps/web/app/(app)/vendor/csat/page.tsx + apps/web/app/(app)/vendor/health/page.tsx (new detail routes) + supporting presentational components under apps/web/components/dashboard/vendor/, reads apps/web/app/(app)/vendor/dashboard/page.tsx (the existing window resolution it reuses)
  • complexity: standard

Problem

The vendor dashboard's customer-health section (customer-health-section.tsx) renders CSAT by revenue, a service-outcome health rate, the shared GRR, and a forward-looking heuristic churn prediction. The original run (PR #531) shipped this section wired to real data — but it landed before its two upstream siblings, so it carried two documented interim reads. Both are now resolvable:

  • Feature 2 (vendor-revenue-retention) has landed and is already consumed — the dashboard page computes getRevenueRetention(...) and passes the real grr into the section, and per-customer revenue already flows through the shared loadCustomerWindow predicate feature 2 defines. Nothing remains to wire here.
  • Feature 6 (vendor-revenue-risk) has now landed too. It exposes a loadSlaBreachCustomers helper whose own docstring states feature 7 "consumes the same helper once it folds slaBreach into its signal set." Until now that signal was stubbed, so the churn at-risk set was only {lowCSAT, ageing>30d} (a fully-tripped account maxed at 2/3) and the service-health bar's stated "within SLA" criterion was not actually enforced.

Separately, the section's two cards link to /vendor/csat and /vendor/health ("View CSAT detail" / "View breakdown"), but those routes don't exist — clicking them 404s.

This revision closes both gaps. It still advances Build the Bridge / Q2 2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: completing the risk model and giving the section real drill-down destinations makes the vendor cockpit a trustworthy retention tool rather than one with a stubbed signal and dead links.

Proposed change

Revision (2026-06-25) — what this run changes on top of PR #531

  1. Fold feature-6's slaBreach into getCustomerHealth (consume, do not fork). Call the existing private loadSlaBreachCustomers helper (computing the summed pre-delivery SLA target the same way getTimeToValue does, via slaConfigService.getSlaDefinitions) and add it as the third risk signal, so:
    • the churn at-risk set becomes the full {lowCSAT, ageing>30d, slaBreach} model and the /3 signal-weighting reaches the intended 3/3 for a fully-tripped account;
    • the service-outcome health rate enforces all three stated criteria — delivered revenue meeting CSAT ≥ 4.0 and within SLA and not ageing/at-risk;
    • the "slaBreach pending feature 6" caveats in the code comments are removed.
  2. Add the two aggregate-breakdown detail pages the cards link to, so the links resolve instead of 404ing:
    • /vendor/csat — expands the CSAT-by-revenue rollup: avg CSAT (+ change vs prior window), revenue-weighted CSAT, revenue below the 4.0/5 cutoff, revenue awaiting CSAT — shown larger, each with a short definition/methodology line.
    • /vendor/health — expands the service-health rollup: the health rate (+ change), the three service-outcome criteria explained, and the churn-prediction figures (accounts at risk, revenue at risk next period, confidence band with the documented rule).
    • Both pages read getCustomerHealth(tenantId, window) over the same VendorPeriodWindow the dashboard resolves from its date-range selector, stay tenant-scoped, and degrade gracefully on an empty/sparse window (no NaN, no crash). This is an aggregate breakdown only — no per-customer tables (see Out of scope).

Unchanged from PR #531 (already shipped, in scope as the feature's standing behaviour)

  • CSAT-by-revenue (avg + change, revenue-weighted, revenue below 4.0/5, revenue awaiting CSAT) computed from the real csat model and per-customer revenue over the selected window.
  • The heuristic (no-ML) churn prediction shape: accountsAtRisk, revenueAtRiskNextPeriod (signal-weighted /3 sum), confidence (High/Medium/Low per the coverage rule).
  • The section rendering entirely from the rollup with the shared GRR wired through; no mock fallback.

Thresholds (reused, not owned)

  • Low-CSAT cutoff = CSAT < 4.0 on the /5 scale — feature 6's constant, read here.
  • Ageing = active project > 30 days; SLA breach = feature 6's loadSlaBreachCustomers over the summed pre-delivery sla-stage-targets budget — both consumed, not re-derived.

Acceptance criteria

  • The churn at-risk signal set folds in feature-6's slaBreach (via loadSlaBreachCustomers), giving the full {lowCSAT, ageing>30d, slaBreach} model; the /3 signal weighting reaches 3/3 for an account tripping all three, and the "slaBreach pending" code caveats are gone.
  • The service-outcome health rate enforces all three criteria — delivered revenue meeting CSAT ≥ 4.0 and within SLA and not ageing/at-risk — and still shows change vs the prior window.
  • /vendor/csat renders an aggregate CSAT breakdown (avg CSAT + change, revenue-weighted CSAT, revenue below 4.0/5, revenue awaiting CSAT) with a definition line per metric; the card's "View CSAT detail" link resolves (no 404).
  • /vendor/health renders an aggregate service-health breakdown (health rate + change, the three criteria explained, and the churn-prediction figures with the confidence rule); the card's "View breakdown" link resolves (no 404).
  • Both detail pages are tenant-scoped, respect the dashboard's date-range / view selection (the same VendorPeriodWindow), and degrade gracefully on an empty/sparse window (no NaN, no crash).

Out of scope

  • Per-customer / per-account tables on the detail pages — this revision is an aggregate breakdown only (chosen scope). Per-account drill-down (and any new per-account service method) is a later run.
  • A trained / ML churn model — the prediction stays heuristic (epic decision).
  • Re-deriving feature-6 risk signals or feature-2 retention/revenue math — slaBreach is consumed via feature 6's helper and GRR/per-customer revenue via feature 2; neither model is forked.
  • Owning the low-CSAT cutoff, ageing or SLA thresholds — they belong to feature 6 / sla-stage-targets; this feature reads them.
  • Any other vendor-dashboard section, and any change to apps/dashboards (the dummy source-of-truth).

Open questions

  • none. Both upstream siblings (features 2 and 6) are now merged to main, so the interim reads this run removes are fully resolvable; nothing here is left for Build to decide.

02_build/output/notes.md

Build notes: vendor-customer-health (revision)

  • commits: feat: vendor-customer-health — fold feature-6 slaBreach into churn + health, add /vendor/csat & /vendor/health detail pages

What changed

Service layer (consume feature 6's slaBreach, do not fork)

  • packages/services/src/db/services/vendor/index.tsgetCustomerHealth:
    • Now fetches slaConfigService.getSlaDefinitions(tenantId) alongside the existing reads and derives slaTargetDays (summed PRE_DELIVERY_SLA_STAGES targets) the same way getTimeToValue / getRevenueRisk do.
    • Calls the existing private loadSlaBreachCustomers helper for both the current and prior windows (prior window ends at currentStart - 1ms so a boundary delivery isn't double-counted).
    • Churn at-risk set is now the full {lowCSAT, ageing>30d, slaBreach} model: slaBreach is the third signal and the customer universe is current ∪ ageing ∪ slaBreach, so an account flagged only by an SLA-breaching delivery (no in-window revenue) still counts toward accountsAtRisk (contributing 0 revenue). The /3 weighting now reaches 3/3 for a fully-tripped account.
    • Service-outcome health rate now enforces all three criteria — CSAT ≥ 4.0 and within SLA and not ageing — for both the current and prior windows (so the change figure is like-for-like).
    • Removed the "slaBreach pending feature 6" caveats from the docstring and inline comments.

Frontend (the two detail routes the cards link to)

  • apps/web/components/dashboard/vendor/customer-health-section.tsx — added an optional range prop and append ?range=<key> to the two card links (/vendor/csat, /vendor/health) so the detail pages open scoped to the dashboard's selected window.
  • apps/web/app/(app)/vendor/dashboard/vendor-dashboard-view.tsx — pass range={range} into the section.
  • apps/web/app/(app)/vendor/csat/page.tsx (new) — aggregate CSAT breakdown: average CSAT (+ change vs prior), revenue-weighted CSAT, revenue below the 4.0/5 cutoff, revenue awaiting CSAT — each with a one-line definition. Reads getCustomerHealth(tenantId, window) over the resolved VendorPeriodWindow.
  • apps/web/app/(app)/vendor/health/page.tsx (new) — aggregate service-health breakdown: the health rate (+ change), the three service-outcome criteria explained, and the churn-prediction figures (accounts at risk, revenue at risk next period, confidence band with the documented rule).

Design decisions

  • Detail pages are aggregate-only (per spec / chosen scope) — no per-customer tables, no new per-account service method. Both call the existing getCustomerHealth rollup, stay tenant-scoped, and resolve the window from ?range= via resolveVendorDateRange exactly as the dashboard page does.
  • Graceful empty state — each page wraps the read in .catch returning a zero-state VendorCustomerHealth, and getCustomerHealth already guards every divisor (percent / weighted means), so an empty or sparse window renders zeros, never NaN or a crash.
  • Prior-window slaBreach is computed so the health-rate change vs prior enforces the same three criteria on both windows (apples-to-apples).

Acceptance criteria status

  • Churn at-risk set folds in feature-6 slaBreach via loadSlaBreachCustomers; full {lowCSAT, ageing>30d, slaBreach} model, /3 weighting reaches 3/3, "pending" caveats removed.
  • Service-outcome health rate enforces all three criteria (CSAT ≥ 4.0 and within SLA and not ageing/at-risk) and still shows change vs the prior window.
  • /vendor/csat renders the aggregate CSAT breakdown with a definition line per metric; the card's "View CSAT detail" link resolves (no 404).
  • /vendor/health renders the aggregate service-health breakdown (health rate + change, the three criteria, churn figures with the confidence rule); the card's "View breakdown" link resolves.
  • Both detail pages are tenant-scoped, respect the dashboard's date-range selection (same VendorPeriodWindow, forwarded via ?range=), and degrade gracefully on an empty/sparse window.

Verify result

  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is known to fail: the new pages reuse existing exports (vendorService, resolveVendorDateRange, VendorCustomerHealth, fmt, Trend, getTenantOrNull) and the service change reuses the existing loadSlaBreachCustomers / slaConfigService / PRE_DELIVERY_SLA_STAGES already used by sibling methods.

Notes for review

  • The slaBreach signal is consumed via feature 6's helper, never re-derived — the SLA-target derivation is copied verbatim from getTimeToValue / getRevenueRisk (three call sites now share the pattern; a future extract could DRY it, out of scope here).
  • The two detail pages each carry a local EMPTY_CUSTOMER_HEALTH zero-state, matching the dashboard page's existing local pattern.

03_release/output/changelog.md

Changelog: vendor-customer-health (revision)

Live entry: apps/help/app/changelog/2026-06-25-vendor-customer-health/page.mdx


Drill into customer health, and see SLA breaches in your churn risk

The customer health cards on your vendor dashboard now go deeper, and your churn risk is more complete:

  • You can now open a full CSAT-by-revenue breakdown and a service-quality breakdown straight from the cards — the "view CSAT detail" and "view breakdown" links now lead to real pages instead of going nowhere.
  • Your churn risk now also counts deliveries that missed their SLA, alongside low satisfaction and ageing projects, so the accounts and revenue flagged as at risk give you the fuller picture.
  • Both breakdowns follow the date range you pick on the dashboard.

03_release/output/investor-update.md

Vendors can now drill into customer health, with SLA breaches folded into churn risk

Who it's for: Vendor partners What shipped: Two new detail pages behind the dashboard's customer-health cards, plus churn risk that now counts missed SLAs alongside low CSAT and stalled projects. Why it matters: A more trustworthy retention cockpit with no dead links — advancing Build the Bridge, Q2 2026 Objective 1: establish product-market fit with vendor partners.

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: vendor-customer-health (revision)

  • pr: #547 · merged: <pending — on green CI + ticked box>
  • CI: <pending>
  • technical docs: no technical docs impact (vendor routes are not enumerated in apps/docs; the getCustomerHealth signature is unchanged)
  • business docs: apps/docs/app/business/feature-role-matrix/vendors/page.mdx — added a row for the new vendor customer-health detail views (CSAT & service-quality breakdown)
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-25-vendor-customer-health/)
    • investor draft in this PR
  • deploy: <pending>
  • sent: <pending>

Review summary

/code-review medium (complexity: standard) — 8 finder angles → verify.

  • No correctness findings. Both correctness and cross-file finders returned empty and verified clean: the SLA prior-window boundary (currentStart - 1ms) correctly avoids double-counting vs. the inclusive loadDeliveredSpans range; the {lowCSAT, ageing, slaBreach} signal set and per-window health-bar exclusion are correct; all new-page imports resolve; getCustomerHealth signature unchanged and all three callers compile.
  • Cleanup (fixed on branch): the EMPTY_CUSTOMER_HEALTH zero-state was triplicated across the two new detail pages and the dashboard page. Resolved by exporting a single emptyVendorCustomerHealth from @sustentus/services (next to the VendorCustomerHealth type) and importing it in all three pages.

Acceptance check (vs spec)

  • Churn at-risk set folds in feature-6 slaBreach via loadSlaBreachCustomers; full {lowCSAT, ageing>30d, slaBreach} model, /3 weighting reaches 3/3, caveats removed — verified by reading the rewritten getCustomerHealth against the sibling SLA-target derivation.
  • Service-outcome health rate enforces all three criteria on both current and prior windows.
  • /vendor/csat renders the aggregate CSAT breakdown with a definition line per metric; link resolves.
  • /vendor/health renders the aggregate service-health breakdown + churn figures with the confidence rule; link resolves.
  • Both pages tenant-scoped, respect the date range (?range= forwarded), and degrade gracefully on an empty window (zero-state + guarded divisors → no NaN).