Skip to Content

← All archived runs

Run: vendor-retention-cohorts

run.md

Run: vendor-retention-cohorts

  • branch: claude/exciting-johnson-bf1iqx
  • pr: #540

00_intake/stub.md

Stub: Vendor dashboard — retention by speed + region

  • feature-slug: vendor-retention-cohorts
  • 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-retention, vendor-time-to-value
  • sequence: 4 of 8

Problem

The retention-by-speed section (retention-by-speed-section.tsx) shows gross revenue retention split two ways — by time-to-value band (<14d / 14–21d / 21–30d / >30d) and by region — each row carrying retention %, revenue and churned revenue, toggled by the header's time vs region view. It is entirely from the mock. The cohorting cannot exist until both the retention math (feature 2) and the per-lead TTV band (feature 3) are real.

Proposed change

  • Bucket the tenant's customers/revenue into time-to-value cohorts (using feature 3's per-lead TTV band) and into region cohorts (customer region), and for each cohort compute retention %, revenue and churned revenue using feature 2's period-over-period retention.
  • Wire the retention-by-speed / retention-by-region section so the header's time view shows the speed cohorts and region view shows the region cohorts over the real set.

Acceptance criteria (rough)

  • Retention by time-to-value band shows real retention %, revenue and churned revenue per band.
  • Retention by region shows the same metrics per region cohort.
  • The header's time/region toggle switches between the two real cohort sets; no mock remains in this section.

Out of scope (this feature)

  • The retention math and TTV banding themselves (features 2 and 3) — this only cohorts their outputs.
  • Drill-through to the accounts inside a cohort — the section is a summary, not a list.

Notes for Define

  • Confirm the region source on the customer (the same region used by the funnel's region view) so speed-view and region-view cohorts partition the same revenue consistently.
  • Reuse the band definitions from feature 3 verbatim — don't redefine the TTV bands here.
  • touches: packages/services/src/server (cohort rollup over the retention + TTV outputs), apps/web/components/dashboard/vendor/retention-by-speed-section.tsx.

01_define/output/spec.md

Spec: Vendor dashboard — retention cohorts (by speed + region)

  • slug: vendor-retention-cohorts
  • personas: Vendor
  • touches: packages/services/src/db/services/vendor/index.ts (add getRetentionCohorts + a VendorRetentionCohorts type on VendorService, reusing getRevenueRetention's perCustomerRevenue, the envelope's per-lead region, and feature 3's per-customer time-to-value band); apps/web/app/(app)/vendor/dashboard/page.tsx (call it, map onto data.churnRetention.retentionBySpeed / retentionByRegion); reads apps/web/components/dashboard/vendor/retention-by-speed-section.tsx (already renders from the RetentionRow contract — no component change expected)
  • complexity: standard

Problem

The vendor dashboard's retention-by-speed / retention-by-region section (retention-by-speed-section.tsx) splits gross revenue retention two ways — by time-to-value band (< 14 days / 14–21 days / 21–30 days / > 30 days) and by region — each row carrying a retention %, total revenue and churned revenue, toggled by the header's time-view vs region-view. Since vendor-revenue-foundation (feature 1) removed the mock, both data.churnRetention.retentionBySpeed and retentionByRegion fall back to emptyVendorData (empty) — the only section of the dashboard still showing nothing real.

This is feature 4 of the vendor-dashboard-data epic, advancing the Build the Bridge initiative / Q2 2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: the vendor dashboard is the paying subscriber's revenue-retention cockpit, and the speed/region cut is the section that proves the epic's core thesis — that faster delivery and certain regions retain more service revenue. It does not invent any new metric; it re-slices the per-customer revenue feature 2 already computes along the TTV band feature 3 produces and the region the funnel already resolves.

Proposed change

Add a retention-cohort rollup to VendorService and wire the retention-by-speed/region section to it. The rollup consumes outputs that already exist (or land upstream) rather than recomputing them.

  • Add getRetentionCohorts(tenantId, window) to VendorService, returning { bySpeed: RetentionCohortRow[]; byRegion: RetentionCohortRow[] } where RetentionCohortRow = { band: string; retention: number; revenue: number; churned: number } — the exact shape the section's RetentionRow consumes (band is the first-column label for both cuts).
  • One per-customer revenue source. Reuse getRevenueRetention(tenantId, window)'s perCustomerRevenue map (customerId → { current, prior }) as the single revenue/retention base — the cohort rollup must not re-aggregate paid invoices. This guarantees the cohorts partition the same revenue the headline GRR is computed from.
  • Per-cohort retention reuses feature 2's gross definition. For a cohort = a set of customers, over their { current, prior } entries:
    • starting = Σ prior (the retention base for the cohort)
    • churned = Σ prior for customers with current == 0 (the "Churned revenue" column)
    • contraction = Σ (prior − current) for customers with 0 < current < prior
    • retention % = round((starting − churned − contraction) / starting × 100) — the same gross revenue retention (GRR) feature 2 reports for the headline, applied per cohort (contraction- inclusive, capped so it cannot exceed 100%). When starting == 0 the cohort retention falls back to a neutral 100% (nothing to retain or lose), matching feature 2's zero-base behaviour.
    • revenue = Σ current for the cohort (the "Total revenue" column — current-window service revenue retained in that cohort).
  • Speed cohorts (time view). Assign each customer to a time-to-value band using the per-customer TTV band exposed by feature 3 (vendor-time-to-value) — reused verbatim, not redefined here. Emit the four bands in fixed order (< 14 days, 14–21 days, 21–30 days, > 30 days); a band with no customers still appears with zeroed revenue/churn and neutral retention so the table is stable. Customers with no TTV band (not yet delivered — no first-value date) are excluded from the speed cohorts (there is no delivery speed to attribute them to); this is noted, not silent.
  • Region cohorts (region view). Assign each customer to a region using the same lead.location.region the period envelope already exposes (VendorEnvelopeLead.region) and the funnel's region view partitions on — so speed-view and region-view slice the same revenue consistently. A customer with leads in multiple regions is attributed to their most-recent lead's region; a customer with no resolvable region is bucketed under Unknown. Emit one row per region present, ordered by revenue descending (the section's footer derives best/worst region from these rows).
  • Wire the section. page.tsx calls getRetentionCohorts alongside the existing envelope / funnel / customer-health / retention fetches, and maps the result onto data.churnRetention.retentionBySpeed (= bySpeed) and data.churnRetention.retentionByRegion (= byRegion) instead of the emptyVendorData fallback. vendor-dashboard-view.tsx already passes both arrays and viewMode to RetentionBySpeedSection, which already renders the RetentionRow contract — no view/component change is expected.

Acceptance criteria

  • VendorService.getRetentionCohorts(tenantId, window) returns { bySpeed, byRegion } where each row carries a real retention %, revenue and churned, derived solely from getRevenueRetention's perCustomerRevenue map (no re-aggregation of invoices).
  • Speed cohorts: retention by time-to-value band shows real retention %, total revenue and churned revenue per band, using feature 3's per-customer TTV band reused verbatim, emitted in the fixed order < 14 days / 14–21 days / 21–30 days / > 30 days.
  • Region cohorts: retention by region shows the same three metrics per region cohort, region sourced from the same lead.location.region the funnel's region view uses (multi-region customer → most-recent lead's region; no region → Unknown), ordered by revenue descending.
  • Per-cohort retention % equals feature 2's gross retention (GRR) definition applied over the cohort's customers ((starting − churned − contraction) / starting), neutral 100% on a zero starting base; cohort revenue totals reconcile to the tenant's current-window revenue (modulo customers excluded from speed cohorts for having no TTV band).
  • The header's time/region toggle switches between the two real cohort sets; both data.churnRetention.retentionBySpeed and retentionByRegion are driven by the engine — the emptyVendorData fallback no longer renders for this section, and no mock remains in it.
  • The page stays tenant-scoped to the signed-in vendor and renders cleanly with no customers / a zero starting base (neutral retention, empty-but-present band rows, no crash).

Out of scope

  • The retention math and TTV banding themselves (features 2 and 3) — this feature only re-slices their outputs; it must not redefine GRR or the TTV band thresholds.
  • Drill-through to the accounts inside a cohort — the section is a summary table, not an account list.
  • The date-range selector and the time/region toggle wiring — owned by vendor-revenue-foundation (window) and already present in the view; this feature only supplies the two cohort arrays.
  • The time-to-value section's own read of retentionBySpeed (its banner reads the < 14 days / > 30 days retention values) — that section is feature 3's to wire; feature 4 makes the underlying retentionBySpeed real, but owns only the retention-by-speed/region section.
  • Any change to apps/dashboards — that app stays the dummy source-of-truth (epic-level scope).

Open questions

  • Sequencing prerequisite — not a content gap. Feature 4 consumes the per-customer TTV band from feature 3 (vendor-time-to-value), which has not been built yet (still an active intake stub — feature 4 is being defined ahead of feature 3 in the batch order). The spec is fully settled; Build must not start until feature 3 has landed and exposed a per-customer TTV band reader on the vendor service. If the team prefers, define/build vendor-time-to-value first. This is a scheduling fact for the Build gate, not an undecided requirement — it does not block any acceptance criterion above.
  • Customers with no TTV band are excluded from the speed cohorts (decided above); flagged so the spec-approval reviewer can object if such revenue should instead surface in a catch-all band. Either way the behaviour is settled for Build.

02_build/output/notes.md

Build notes: vendor-retention-cohorts

  • commits: feat: vendor-retention-cohorts — retention cohort rollup + section wiring

What changed

  • packages/services/src/db/services/vendor/index.ts: added getRetentionCohorts(tenantId, window) plus the VendorRetentionSpeedRow / VendorRetentionRegionRow / VendorRetentionCohorts types. It re-slices getRevenueRetention's perCustomerRevenue (the shared revenue/GRR base, feature 2) into:
    • speed cohorts — using feature 3's per-lead bandsByLead (getTimeToValue), resolved to a per-customer band via a tenant-scoped Lead lookup (most-recent delivered lead wins); the four TTV_BAND_ORDER bands always emitted, customers with no delivered lead excluded;
    • region cohorts — using the same lead.location.region the funnel's region view uses (most- recent windowed lead; no region → Unknown), over every customer with windowed revenue so the rows reconcile to tenant current-window revenue. Per-cohort retention reuses feature 2's gross formula (starting − churned − contraction)/starting, neutral 100% on a zero base. No invoice re-aggregation.
  • apps/web/app/(app)/vendor/dashboard/page.tsx: added the getRetentionCohorts fetch to the parallel envelope/funnel/health/retention/ttv reads and mapped its result onto data.churnRetention.retentionBySpeed / retentionByRegion (was the empty emptyVendorData fallback). grr stays from the base.
  • apps/web/components/dashboard/vendor/types.ts: retentionByRegion rows now carry region (matching what the section's footer reads) instead of band.
  • apps/web/components/dashboard/vendor/retention-by-speed-section.tsx: guarded the best/worst-region reduce against an empty array so the region view renders cleanly with zero customers (AC6).

Acceptance criteria status

  • getRetentionCohorts returns { bySpeed, byRegion } with real retention/revenue/churned per row, derived solely from getRevenueRetention's perCustomerRevenue (no invoice re-aggregation).
  • Speed cohorts use feature 3's TTV band verbatim, fixed order < 14 days> 30 days.
  • Region cohorts use the funnel's lead.location.region (multi-region → most-recent lead; none → Unknown), ordered by revenue desc.
  • Per-cohort retention = feature 2's GRR definition; region cohorts reconcile to tenant current- window revenue (speed modulo customers with no TTV band, who are excluded by design).
  • The time/region toggle switches the two real cohort sets; retentionBySpeed/retentionByRegion are engine-driven — no emptyVendorData fallback, no mock left in the section.
  • Page stays tenant-scoped and renders cleanly with no customers / zero base (neutral 100%, empty-but-present speed bands, guarded region reduce — no crash).

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here.

Notes for review

  • The spec said "per-customer TTV band"; feature 3 actually exposes per-lead bandsByLead, with a comment intending feature 4 to do the lead→customer join — which is what getRetentionCohorts does. Tiebreak for a customer with several delivered leads: most-recent delivered lead's band (mirrors the region rule). Worth a sanity check that this is the intended attribution.
  • types.ts retentionByRegion field renamed bandregion so the section footer ("X leads at Y%") renders the region name; only retentionBySpeed.band is read elsewhere, so the rename is safe.

03_release/output/changelog.md

Changelog: vendor-retention-cohorts

  • entry: apps/help/app/changelog/2026-06-24-vendor-retention-cohorts/page.mdx
  • personas: vendor

See which delivery speeds and regions retain your revenue

Your vendor dashboard's retention section now runs on real data for the period you select, not placeholder numbers. Switch between two views: retention by speed to value — your revenue retention split across delivery-speed bands, so you can see whether delivering faster keeps more revenue — and retention by region, the same split across your customers' regions, ranked by revenue. Each row shows its retention rate, total revenue, and churned revenue.

03_release/output/investor-update.md

Vendors can now see whether faster delivery and which regions retain more revenue

Who it's for: Vendor partners What shipped: The vendor dashboard's retention section now splits real revenue retention two ways — by speed-to-value band and by region — each with retention rate, revenue, and churned revenue. Why it matters: It proves the thesis that faster delivery and certain regions retain more revenue — advancing product-market fit with vendor partners.

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

03_release/output/release.md

Release: vendor-retention-cohorts

  • pr: #540 · merged: no — pending CI green on the docs commit + squash-merge (Ready to merge already ticked)
  • CI: green on the code (Quality Project + web/marketing deploys passed on 46a36d4c); docs commit 83663f50 re-running — merge waits on green
  • technical docs: no technical docs impact (adds a VendorService method; no new app/route/env/architecture — consistent with the sibling vendor-time-to-value)
  • business docs: updated apps/docs/app/business/feature-role-matrix/vendors — added a "View retention by speed and region (vendor dashboard)" row (Vendor), mirroring the vendor-customer-health precedent of one row per named dashboard section
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-24-vendor-retention-cohorts) + investor draft in this PR
  • deploy: pending (poll after merge)
  • sent: pending green deploy

Review summary

Reviewed the diff directly at medium effort (standard complexity) — a tight ~180-line, self-contained change (one VendorService method + page wiring + a type rename + a render guard); mechanical checks (typecheck, web/marketing deploys) already green. No open review threads.

  • Per-cohort GRR is provably bounded to [0, 100] — churned and contraction each sum subsets of starting (per-customer prior), so starting − churned − contraction ∈ [0, starting]; zero base falls back to neutral 100%. No explicit cap needed. Verified — correct.
  • retentionByRegion field rename band → region (types.ts) is consumer-safe: the section reads region for the footer and only retentionBySpeed.band elsewhere; the web typecheck passing proves no stale .band access on region rows. Verified — correct.
  • Page wiring: cohorts added last in both the Promise.all array and its destructure (tuple aligned), .catch(() => null) with cohorts?.bySpeed ?? empty fallback — degrades cleanly. Verified — correct.
  • Efficiency — getRetentionCohorts independently re-reads getPeriodEnvelope, getRevenueRetention and getTimeToValue, which page.tsx also fetches directly in the same Promise.all (three heavy aggregations recomputed per load). Accepted: the spec defines getRetentionCohorts(tenantId, window) as a self-contained, independently-callable service method, matching the sibling vendor methods' composition pattern; threading shared reads in would diverge from that contract. Noted for a future cross-method read-sharing pass, not blocking.

Acceptance check (vs spec)

  • getRetentionCohorts(tenantId, window) returns { bySpeed, byRegion } with real retention/revenue/churned per row, derived solely from getRevenueRetention's perCustomerRevenue (no invoice re-aggregation) — verified: cohort() reads only perCustomer[id].{current,prior}.
  • Speed cohorts use feature 3's bandsByLead verbatim, all four TTV_BAND_ORDER bands emitted in fixed order, empty bands present as neutral zero rows — verified in bySpeed map.
  • Region cohorts use the funnel's lead.location.region (most-recent windowed lead; none → Unknown), ordered by revenue desc — verified in regionByCustomer + byRegion.sort.
  • Per-cohort retention = feature 2's GRR; region cohorts span every customer with windowed revenue so they reconcile to tenant current-window revenue (speed modulo no-band customers, by design) — verified.
  • Time/region toggle switches the two real cohort sets; retentionBySpeed/retentionByRegion are engine-driven, no emptyVendorData fallback rendered, no mock left — verified in page.tsx.
  • Tenant-scoped; renders cleanly with no customers / zero base (neutral 100%, empty-but-present bands, guarded region reduce) — verified (tenantId: tenantOid scope + section length guard).