vendor-retention-cohortsrun.md00_intake/stub.mdThe 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.
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.mdgetRetentionCohorts + 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)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.
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.
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).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.{ 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 < priorround((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.current for the cohort (the "Total revenue" column — current-window service
revenue retained in that cohort).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.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).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.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).< 14 days / 14–21 days / 21–30 days / > 30 days.lead.location.region the funnel's region view uses (multi-region customer →
most-recent lead's region; no region → Unknown), ordered by revenue descending.(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).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.vendor-revenue-foundation
(window) and already present in the view; this feature only supplies the two cohort arrays.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.apps/dashboards — that app stays the dummy source-of-truth (epic-level scope).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.02_build/output/notes.mdpackages/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: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;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).getRetentionCohorts returns { bySpeed, byRegion } with real retention/revenue/churned per
row, derived solely from getRevenueRetention's perCustomerRevenue (no invoice re-aggregation).< 14 days … > 30 days.lead.location.region (multi-region → most-recent lead; none →
Unknown), ordered by revenue desc.retentionBySpeed/retentionByRegion
are engine-driven — no emptyVendorData fallback, no mock left in the section.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 band → region 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.mdYour 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.mdWho 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.mdVendorService method; no new app/route/env/architecture — consistent with the sibling vendor-time-to-value)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 sectionReviewed 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.
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.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.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.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}.bandsByLead verbatim, all four TTV_BAND_ORDER bands emitted in
fixed order, empty bands present as neutral zero rows — verified in bySpeed map.lead.location.region (most-recent windowed lead; none →
Unknown), ordered by revenue desc — verified in regionByCustomer + byRegion.sort.retentionBySpeed/retentionByRegion are
engine-driven, no emptyVendorData fallback rendered, no mock left — verified in page.tsx.tenantId: tenantOid scope + section length guard).