Skip to content

feat(insights): donors tab (NPPD-1617)#225

Draft
kmwilkerson wants to merge 23 commits into
nppd-1616-insights-tab-6-subscribersfrom
nppd-1617-insights-tab-7-donors
Draft

feat(insights): donors tab (NPPD-1617)#225
kmwilkerson wants to merge 23 commits into
nppd-1616-insights-tab-6-subscribersfrom
nppd-1617-insights-tab-7-donors

Conversation

@kmwilkerson
Copy link
Copy Markdown

@kmwilkerson kmwilkerson commented Jun 4, 2026

Summary

Adds the Donors tab to the Insights wizard. Like Tab 6 (Subscribers), this is sourced entirely from local WooCommerce data with no BigQuery dependency. The tab is gated to publishers with actual donation activity, not just the canonical donation product family that ships seeded on every Newspack install.

Stacks on the Subscribers PR (#217, NPPD-1616), which stacks on the chrome PR (#211, NPPD-1602). All three need to merge for the full Insights experience to land.

image

What's in this PR

Storage abstraction layer

  • Donors_Storage_Interface defining the per-metric query contract for donor data
  • HPOS_Donors_Storage and Legacy_Donors_Storage implementations dispatching via the same Storage_Detector Tab 6 uses
  • Reuses the cached Donation_Product_Classifier from Tab 6 for product ID resolution
  • Both backends verified end-to-end against test data

Tab visibility (donation activity heuristic)

  • Insights_Wizard::has_donation_activity() checks for actual donation orders or subscriptions, not just product existence
  • Necessary because the canonical Newspack donation family (parent plus Once, Monthly, Yearly children) seeds on install for every publisher, so checking product existence would surface Tab 7 on every site regardless of whether donations are collected
  • EXISTS query cached 24 hours; force_refresh_donation_activity() available for testing
  • Defensive fallback to true if the classifier class is missing, so the tab is never wrongly hidden

Donors metric and REST endpoint

  • Donors_Metric orchestrator with per-method WP transient caching (30min for windowed and snapshot metrics, 60min for heavy aggregation queries like tier breakdown; will migrate to NPPD-1605 cache table when that lands)
  • Single REST endpoint GET /newspack-insights/v1/donors returning the full payload (classification metadata, snapshot, current window, optional comparison window)
  • Active Donors uses UNION + dedup across one-time and recurring activity surfaces so a reader with both a recurring donation and a recent one-time gift counts once, not twice
  • Average One-Time Gift filters via product period meta to exclude both subscription renewals and subscription initial installments
  • WooCommerce Subscriptions stores '0' on _schedule_cancelled to mean "not cancelled" (distinct from NULL and empty string), so the retention denominator query explicitly includes '0' in the "still active at window start" check

React UI

  • DonorsTab with four sections: at-a-glance scorecards, time-windowed metrics, retention rates, donations by tier
  • Scorecards regrouped by temporal scope: current-state metrics (Active Donors with active recurring count inline, Donation MRR with annualized value inline) vs window-scoped metrics (New Donors, Lapsed Donors, Total Donation Revenue with one-time plus recurring breakdown inline, Average One-Time Gift)
  • Window-scoped section header is dynamic, reflects the active date range (the same pattern Tab 6 uses)
  • Retention section uses a {value, computable, denominator} shape so the UI distinguishes a real 0% (small-cohort math) from "no data yet" (empty cohort). Small denominators surface inline as "0% of 2 donors" rather than bare 0%. Empty cohorts collapse to per-card or section-wide explanatory empty states.
  • Donations by tier table sorted by lifetime revenue descending. Cells that don't apply to the row's billing model render as em-dashes ("—") rather than misleading zeros: one-time products show em-dashes in recurring-donor and recurring-revenue columns, recurring products show em-dashes in the one-time gifts column. Includes a Lapsed Donors column so publishers can reconcile per-tier churn against the aggregate Lapsed Donors scorecard.

Comparison mode

  • All window-scoped scorecards render deltas when comparison mode is toggled on
  • lowerIsBetter semantics for Lapsed Donors (drops render green, increases render red)
  • Comparison ignored for current-state metrics that don't have meaningful prior values

Backports to Tab 6 for cross-tab consistency

This PR also lifts several patterns into Tab 6 so the two tabs feel like siblings:

  • Section captions explaining temporal scope under "Subscribers at a glance" and "Performance by product"
  • Annual Recurring Revenue consolidated into the MRR card's secondary subtitle (4 cards in glance becomes 3)
  • "In the last X days" dynamic header added to Tab 6's windowed scorecard group
  • Refund Rate and Failed Payment Recovery refactored to the same {value, computable, denominator} shape, with denominator inline ("of N orders" / "of N retries") and "No refunds in this timeframe" / "No payment retries in this timeframe" empty states
  • Subscriber and donor scorecards refined from "customers" to "readers" in the publisher-facing copy

How to test

  1. Set NEWSPACK_INSIGHTS_ENABLED constant in wp-config.php
  2. Navigate to wp-admin/admin.php?page=newspack-insights
  3. On a site with zero donation orders and zero donation subscriptions, confirm the Donors tab is hidden. Create at least one completed donation order, force-refresh the activity cache via \Newspack\Insights_Wizard::force_refresh_donation_activity(), and confirm the Donors tab appears.
  4. Click into the Donors tab
  5. Verify:
    • Donors at a glance: Active Donors with active recurring count inline, Donation MRR with annualized value inline
    • Section headers correctly reflect "Donors at a glance" vs the dynamic windowed group
    • Date range picker changes affect only window-scoped scorecards (not current-state ones)
    • Comparison toggle on: deltas appear with correct colors (lapsed donor drop = green)
    • Retention section: cards show "X% of N donors" with cohort denominator inline. When cohorts are empty, retention collapses to a section-wide explanatory card or per-card empty notes
    • On a site with active recurring donors that started before the window, those donors appear in the Recurring Donor Retention denominator (regression check on the '0' cancel-meta sentinel)
    • Donations by tier table sorted by lifetime revenue, with em-dashes in non-applicable cells (One-Time products in recurring columns, recurring products in one-time gifts column)
    • Lapsed Donors column in the tier table sums to the aggregate Lapsed Donors scorecard for the same window
    • Hard refresh restores state from URL
  6. Verify storage abstraction by testing on both HPOS-enabled and HPOS-disabled environments
  7. Verify Tab 6 backports: section captions added, MRR card has "$X annualized" inline, dynamic "In the last X days" header above windowed scorecards, Refund Rate and Failed Payment Recovery render graceful empty states when their denominators are zero
  8. Verify Tab 6 numbers haven't changed from baseline (only layout details differ)

kmwilkerson and others added 20 commits June 4, 2026 16:04
Contract for the Tab 7 per-backend data layer, mirroring the Tab 6
{@see Storage_Interface} pattern. Two implementations will follow
(HPOS, legacy CPT) and dispatch via the shared Storage_Detector.

11 methods cover the user-spec metric list:

  current state:
    get_active_donors        (UNION recurring + trailing-365 one-time)
    get_active_recurring_donors
    get_donation_mrr

  window-scoped:
    get_new_donors_in_window
    get_lapsed_donors_in_window
    get_one_time_donation_revenue
    get_recurring_donation_revenue
    get_average_donation_gift

  retention:
    get_lapsed_donor_recovery_rate
    get_recurring_donor_retention

  per-tier:
    get_donations_by_tier  (parent + nested variations)

ARR (MRR x 12) and Total revenue (one-time + recurring) are derived
in the orchestrator, not stored. Cancellation Reasons / refund rate /
cohort retention are deferred per scope.

Documents per-query join surface (shop_order-scoped vs
shop_subscription-scoped) per the schema doc's verified opl
behavior. The Active Donors UNION metric crosses both surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 11 methods from the Tab 7 storage contract, mirroring HPOS_Storage
(Tab 6) but with the donation IN filter and the metric set the user
prompt enumerated:

  get_active_donors           — UNION of (active recurring) +
                                (trailing-365 one-time), dedup by
                                customer_id
  get_active_recurring_donors — distinct customers with a wc-active
                                donation subscription
  get_donation_mrr            — product-meta-driven normalized to
                                monthly (per formula doc) with full
                                day/week/month/year × N coverage and
                                the Tab 6 conservative /12 fallback
  get_new_donors_in_window    — first-ever donation in window
  get_lapsed_donors_in_window — Tab 6 churn pattern scoped to
                                donations (recurring lapsed)
  get_one_time_donation_revenue  } shared private helper splits
  get_recurring_donation_revenue } by _subscription_period presence
  get_average_donation_gift   — AVG of donation shop_order totals
  get_lapsed_donor_recovery_rate — computes prior window of equal
                                length, queries that cohort, counts
                                recoveries in current window
  get_recurring_donor_retention  — active at :start (start_meta <=,
                                cancel_meta empty or >) divided
                                across to currently-active. Simplified
                                "still active at end" to NOW.
  get_donations_by_tier       — two-pass query (subscription pass for
                                active recurring donors + shop_order
                                pass for the four window-scoped /
                                lifetime metrics), merged by
                                variation_id, then aggregated to
                                parent + nested variations via the
                                Tab 6 pattern (COALESCE _variation_id
                                over _product_id; per-period labels).

SQL safety per existing Tab 6 conventions: interpolated donation IDs
pass through intval; $wpdb->prefix is trusted; date params go through
$wpdb->prepare with %s. phpcs:disable for the direct DB query sniff
group is justified for this analytics layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab 7 counterpart to Legacy_Storage. Mirrors HPOS_Donors_Storage
method-by-method with the per-row source swapped from HPOS tables to
legacy CPT:

  wc_orders               -> posts WHERE post_type = 'shop_subscription'/'shop_order'
  wc_orders.customer_id   -> postmeta._customer_user
  wc_orders.total_amount  -> postmeta._order_total (CAST DECIMAL)
  wc_orders.date_created  -> posts.post_date_gmt
  wc_orders_meta          -> postmeta

Line-item tables (woocommerce_order_items / itemmeta) and
wc_order_product_lookup are cross-backend and queried identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin dispatch + caching layer over the two donor storage backends.
Picks HPOS vs Legacy via Storage_Detector, threads donation IDs from
the shared Donation_Product_Classifier into storage construction,
and wraps each storage call in a transient cache keyed by
backend:method:md5(params_json).

Tiers mirror Tab 6:
  TTL_DEFAULT  30 min — snapshot + windowed metrics
  TTL_HEAVY    60 min — donations_by_tier, recovery rate, retention

Derived metrics computed in this layer (not in storage):
  get_donation_arr            = MRR x 12
  get_total_donation_revenue  = one-time + recurring

Cache prefix newspack_insights_tab7_v1:; bump if cached shape changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-1617)

Adds:
- Donors_REST_Controller registering GET /newspack-insights/v1/donors
  (same date validation, permission_check, response shape pattern as
  Tab 6's Subscribers_REST_Controller)
- Insights_Section_Donors expanded from stub: includes the 7 Tab 7
  PHP files via load_dependencies() (mirrors Tab 6's section pattern)
  and registers the REST route on rest_api_init
- Composer autoload regenerated

Also fixes two PHPCS / SQL bugs caught during smoketest:

1. class-hpos-storage.php (Tab 6, inherited on this branch): missing
   blank line before a /* */ block comment after the previous //
   comment block. PHPCS Squiz.Commenting.BlockComment.NoEmptyLineBefore.

2. donations_by_tier returned 0 rows on populated test data because
   the LEFT JOIN subquery's `customer_id` reference was unqualified.
   Both wc_orders and wc_order_product_lookup carry a customer_id
   column, and an unqualified reference silently resolves to the opl
   side (which is 0 for most analytics rows). GROUP BY then collapsed
   every donation order into one bucket and the LEFT JOIN matched
   nothing. Fixed by qualifying as `o2.customer_id` (HPOS) and
   `cust2.meta_value` (legacy). Verified 3 donations_by_tier rows now
   return for Donate: One-Time / Monthly / Yearly with sensible
   per-tier metrics.

Smoketest result on local data:
  backend: hpos
  active_donors: 21 (no active recurring subs in test data — correct)
  total_revenue: $1600 (one-time $575 + recurring $1025)
  donations_by_tier: 3 rows, math reconciles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab 7 hides when the publisher has no donation products configured.
Boot config's 'donors' visibility now reads from
Donation_Product_Classifier::get_donation_product_ids() via a private
has_donation_products() helper.

The classifier ships its own 1h transient cache, so this query
amortizes to roughly one DB round trip per hour per page load.

Falls back to `true` if the classifier class isn't available
(defensive — the donors/subscribers section file may have failed
to load in development); preserves visibility so the missing
dependency can be diagnosed rather than silently hiding the tab.

The other 7 tabs' visibility stays stubbed at `true`. Each one needs
its own feature detection (BQ dataset presence for the audience /
engagement / conversion / advertising tabs; non-donation subscription
presence for subscribers; etc.) and lands in its own follow-up issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
React layer for the Donors tab. Mirrors the Tab 6 SubscribersTab
pattern: tab orchestrator dispatches via section components, each
section composes the shared MetricCard from tabs/components/, table
chrome inherits from sections.scss (extraction landed pre-Tab 7).

Files:
  api/donors.ts          - typed REST response (DonorsResponse,
                           DonorsSnapshot, DonorsWindow,
                           DonorsTierRow + DonorsTierVariationRow)
  hooks/useDonorsData.ts - fetch lifecycle with request-id guard
  tabs/DonorsTab.tsx     - orchestrator (loading/error/success)
  tabs/donors/
    ScorecardSection     - "Donors at a glance" (4 current-state cards)
    WindowedSection      - dynamic-heading ("In the last 30 days", etc.)
                           with 6 window-scoped cards. Lapsed donors
                           uses lowerIsBetter for the delta tone.
    RetentionSection     - 2 percent cards (recovery rate, recurring
                           donor retention) using the same comparison
                           semantics.
    PerformanceSection   - 6-column tier table with the shared nested
                           variation row pattern. Donate parent + its
                           Monthly/Annual variations under it.
    donors.scss          - Tab 7-specific only (gap, container);
                           everything else (cards, sections, table,
                           variation rows) reused from
                           tabs/components/sections.scss

Storage_Detector + Donation_Product_Classifier reused as-is — no
duplication on the backend either.

Smoketest: REST returns 21 active donors / $1600 total revenue /
3 tier rows on local test data; tabs visibility correctly reflects
donation-product presence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ct existence

Real bug: every Newspack publisher receives the canonical donation
product family (Once / Monthly / Yearly + grouped parent) on install
regardless of whether they ever collect donations. Tab 7 was showing
on every site, including the many publishers who have never taken a
donation, because Insights_Wizard::has_donation_products() only
checked whether the donation product set was non-empty.

Renamed to has_donation_activity() and replaced the product-existence
check with an EXISTS query against the line-item tables:

  SELECT EXISTS (
    SELECT 1 FROM {prefix}wc_orders o
    JOIN {prefix}woocommerce_order_items items ON items.order_id = o.id
    JOIN {prefix}woocommerce_order_itemmeta meta
      ON meta.order_item_id = items.order_item_id
      AND meta.meta_key = '_product_id'
    WHERE o.type IN ('shop_order', 'shop_subscription')
      AND meta.meta_value IN (:donation_product_ids)
    LIMIT 1
  );

Legacy variant swaps wc_orders for posts and o.type for p.post_type.
Backend dispatch via Storage_Detector::detect() so the query targets
the authoritative source rather than a potentially stale opposite
backend.

Cached for 24h via newspack_insights_has_donation_activity transient.
State transitions are rare and one-way so aggressive caching is
correct. A new public static force_refresh_donation_activity() lets
tests and the publisher's-first-donation case bypass and refresh the
cache.

Fast-paths:
  - Empty donation product set -> false immediately, no SQL.
  - Classifier class unavailable -> true (defensive; keeps tab
    visible so the missing dep can be diagnosed).

Verified on local: 34 qualifying activity rows, has_donation_activity
returns true. On a synthetic empty-activity environment the EXISTS
would return 0 and the tab would hide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The orchestrator was never written when the Tab 7 React UI landed —
DonorsTab.tsx was left as the original "Coming soon" stub, so even
with the sections, hook, and API client present the tab body stayed
on the placeholder. This swaps it for the real orchestrator,
mirroring SubscribersTab's loading/error/success lifecycle and
composing the four donor sections (Scorecard, Windowed, Retention,
Performance).

Also refines the One-Time Donation Revenue subtitle from "Gifts from
non-subscription donation products" to "Gifts from non-recurring
donations" — the former leaks an implementation detail (the product
classifier's subscription check); the latter speaks to publishers
in donor-flow terms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pairs of related metrics now travel together on a single card,
with the secondary value rendered below the primary as a compact
"X annualized" / "N active recurring" / "$X one-time + $Y recurring"
snippet. Same information, ~38% less surface area.

Donors at a glance (4 → 2):
  Active Donors      ← merges in Active Recurring Donors
  Donation MRR       ← merges in Donation ARR

In the last X days (6 → 4):
  Total Donation Revenue  ← merges in One-Time + Recurring revenue
  (New Donors, Lapsed Donors, Average Gift unchanged)

Retention unchanged (2 cards).

Also adds the "Donors at a glance" section caption explicitly noting
its current-state, timeframe-independent scope — separately requested
in the same Tab 7 audit and only touches this file so it folds in
here rather than a separate commit.

MetricCard grew an optional `secondary?: string` prop and a matching
`.newspack-insights__metric-card-secondary` slot in the shared
sections SCSS (rendered between value and delta, gray-700, 13px).

Investigation note: the browser-test anomalies that surfaced the
$1,365 lifetime parity, $0 monthly recurring revenue in the 90d
window, and 0% retention all trace to the same root cause —
wc_order_product_lookup was under-populated for the test data
generator's renewal orders, which are created programmatically with
status set in the constructor and therefore don't trigger the WC
analytics sync. Metric SQL is structurally correct; verified against
the raw items + itemmeta path. Test generator (workspace-only) now
mirrors line items into opl directly via REPLACE INTO so the metric
queries see the full dataset on re-seed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Average Gift now sums only one-time donation orders (excludes both
renewals and subscription initial installments) using the same
`NOT EXISTS _subscription_period` predicate that scopes
get_one_time_donation_revenue. Including renewals diluted the metric
with predictable recurring amounts that say more about retention than
donor generosity; including sub initial orders called the first slice
of a recurring commitment a "gift", which it isn't in the donor's
mental model. Card label renamed to "Average one-time gift" to match.

Lapsed Donors description shortened to "Donors who stopped recurring
giving in this timeframe" — parallels Tab 6's "Subscribers who
churned in this timeframe."

CACHE_PREFIX bumped tab7_v1 → tab7_v2 so prior cached Average Gift
values don't linger; v1 entries expire on their own TTL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Storage methods for both retention rates now return null (not 0)
when their denominator cohort is empty: no donors lapsed in the
prior window, or no recurring donors active at the window start.
Lets the UI distinguish "no data yet" from a real 0%, which on a
fresh or young site is the usual case for retention metrics.

Three rendering modes in RetentionSection:
  both null    → single section-wide "metrics will appear once…" card
  one null     → keep the card with data + per-card "no … yet" note
                 on the empty slot (preserves grid alignment)
  both numbers → render both normally

Interface signature now `?float` for both methods; HPOS + legacy
mirror the same null path. Orchestrator threads nullable through
the cache helper without coercion. MetricCard's previousValue
broadened to `number | null` so the comparison delta is silently
suppressed when the previous-window rate is also "no data".

CACHE_PREFIX bumped tab7_v2 → tab7_v3 so cached 0.0 values from
before this change don't render as "0%" when the new path would
return null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side sort flips from active_recurring_donors DESC to
lifetime_donation_revenue DESC for both the outer product list and
each parent's nested variations. Largest products surface first —
"largest products first" is the more useful default and matches
Tab 6's performance table convention.

PerformanceSection now renders a section caption above the table
making the mixed temporal scope explicit: most columns are
window-scoped to the date picker, but Lifetime Revenue is all-time.
Without the caption, publishers tended to read the columns as
uniformly scoped.

Both HPOS and legacy storage's aggregate_tier_rows() updated in
parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Storage now emits a `billing_model` field on every tier row and
variation, derived from the product's `_subscription_period` meta:
`recurring` when the period is in (day, week, month, year),
`one_time` otherwise. Parent rows inherit `recurring` if ANY
variation is recurring (the canonical variable-subscription
donation shape).

PerformanceSection switches on billing_model and renders cells that
don't apply to the row's billing model as a gray-700 em-dash
("—") instead of "0" or "$0.00":

  one-time product → "—" for Active recurring donors + Recurring revenue
  recurring product → "—" for One-time gifts

Lifetime revenue and New donors apply to every donation order and
still render numerically even when zero. The em-dash carries an
"aria-label='Not applicable'" so screen readers don't announce a
bare dash.

Section caption tightened to the wording from the audit prompt:
"Current state plus activity in the selected timeframe. Lifetime
revenue is the all-time total per product." — calls out the mixed
temporal scope more concisely than the prior phrasing.

CACHE_PREFIX bumped tab7_v3 → tab7_v4 so v3-cached tier rows
(missing billing_model) don't render every cell as em-dash on
clients that just shipped this code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Storage methods for both retention rates now return an explicit
`{value, computable, denominator}` shape instead of nullable float.
Functionally equivalent on the empty-cohort case (computable=false
plays the same role as the prior null) but exposes the cohort size
to the UI so small-cohort 0% reads as "0% of 2 donors" rather than
bare "0%" — which a publisher reads as a catastrophe when the math
is actually just statistical noise from a thin denominator.

Browser-test diagnostic before this change:
  recurring retention denom = 2 (2 active at start, 0 still active)
  lapsed recovery denom    = 1 (1 lapsed, 0 returned)

Both legitimately compute to 0% under the seeded data. Empty-state
was correctly NOT triggered (denominators are non-zero), but the
bare "0%" was alarming. Surfacing "of 2 donors" / "of 1 donor"
inline makes the small sample explicit.

UI behavior unchanged at the boundaries: both non-computable still
collapses to the section-wide explanatory card, one non-computable
still keeps the populated card and renders an empty note on the
other slot. The middle case (computable with any denominator) now
shows the cohort size.

Interface signature `?float` → `array{value, computable, denominator}`.
Orchestrator threads the array through with no coercion. MetricCard
unchanged — RetentionSection just unwraps the value to pass in.

CACHE_PREFIX bumped tab7_v4 → tab7_v5 so cached float values don't
crash the new array unwrap on rolling deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Tab 7 patterns brought over for cross-tab consistency:

1. Section caption under "Subscribers at a glance" using the same
   wording as Tab 7's "Donors at a glance" — flags the temporal
   independence of the current-state metrics from the date picker.

2. "Performance by product" caption now also names the lifetime
   revenue scope, parallel to Tab 7's tier table caption. The
   "customer with two subscriptions counts in both rows" detail
   was already implied by the leading "(subscriptions, not unique
   customers)" parenthetical, so it folds away cleanly.

3. ARR card merged into MRR's secondary line as "$X annualized" —
   same merge that landed on Tab 7's Donation MRR. "Subscribers at
   a glance" goes 4 → 3 cards (Active, MRR, Upcoming Renewals);
   Upcoming Renewals stays as its own card per the audit's "no
   Tab 7 analog" note.

Shared `.newspack-insights__section-caption` SCSS bumped 13px → 14px
with line-height 1.5 so the new + existing captions read as
considered information rather than fine-print disclaimer. Affects
both tabs.

Tab 7's WindowedSection getHeading() pattern was already present on
Tab 6 from the Tab 6 build — no parity work needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refund rate and Failed payment recovery now return the same
{value, computable, denominator} shape that Tab 7's retention rates
adopted, so the UI can:

  - render small-cohort 0% as "0% of N orders / N retries" inline,
    instead of bare "0%" that reads as catastrophic news
  - swap to a per-card empty state when the denominator is 0:
      refund_rate     → "No subscription orders in this timeframe."
      retry_rate      → "No payment retries in this timeframe."

Both cards keep their MetricCard slot in the windowed grid; the
empty variant reuses the `--empty` chrome introduced for Tab 7
retention so grid alignment is preserved.

Interface signature `float` → `array{value, computable, denominator}`.
HPOS + legacy storage methods + Subscribers_Metric orchestrator
threaded the array through without coercion. REST controller is a
pass-through (no shape change needed). React side now imports a
sibling SubscribersRateValue interface paralleling Tab 7's
DonorsRateValue; pluralized "of N orders / N retries" via _n() so
the singular case ("of 1 order") reads naturally.

CACHE_PREFIX bumped tab6_v2 → tab6_v3 so cached float values from
v2 don't blow up the array unwrap on rolling deploys.

Net Revenue judgment (audit item 4): kept as a separate card.
Total/one-time/recurring on Tab 7 is an additive decomposition with
no load-bearing slice; Gross/Net on Tab 6 is subtractive and Net is
the operationally important number that publishers track. Merging
Net into a Gross subtitle would demote the load-bearing metric.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small clarifications:

  Tab 6 Active Subscribers — "customers" → "readers". The Newspack
  product lexicon is reader-first; "customers" is WooCommerce's term
  and reads as transactional jargon in the publisher's dashboard.

  Tab 6 Refund Rate empty state — "No subscription orders in this
  timeframe." → "No refunds in this timeframe." The orders version
  was technically the metric's denominator, but the publisher-
  facing truth is what they actually care about.

  Tab 7 Active Donors — same "customers" → "readers" tweak as Tab 6
  for cross-tab consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inator

Recurring Donor Retention's "active at window start" subselect
checked `cancel_meta IS NULL OR = '' OR > $start`. WCS stores '0'
(string zero) as the "not cancelled" sentinel on _schedule_cancelled
— distinct from NULL and from ''. With the prior filter, '0' fell
through to the third branch where MySQL string-compared
`'0' > '2026-03-06 …'` and resolved false, so every currently-active
subscription was silently excluded from the denominator.

Observable impact before the fix:
  - denominator = 3 (only customers whose ONLY sub was wc-cancelled
    in the window — their cancel_meta was a real date, not '0')
  - numerator   = 0 (those 3 are now cancelled, so none in wc-active)
  - retention   = 0% of 3 donors, regardless of how many real
    retained donors the publisher has.

After:
  - denominator = 8 (3 lapsed + 5 retained, the actual cohort
    active at window start)
  - numerator   = 5 (the 5 still-active donors)
  - retention   = 62.5% of 8 donors

Verified against the local seeded dataset; the seeded fixture
explicitly includes a group_retained cohort to drive non-zero
retention and confirms the fix surfaces it. Tab 6 storage uses the
safer BETWEEN pattern everywhere it touches _schedule_cancelled and
isn't affected — only Tab 7's two donor-retention queries needed
the fix (HPOS + legacy).

CACHE_PREFIX bumped tab7_v5 → tab7_v6 so cached pre-fix denominators
don't continue to render "0% of 3 donors" until natural TTL expiry.

Interface docblock updated to call out the '0' sentinel so future
contributors don't reintroduce the same drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Lapsed Donors as a new column in the Donations by tier table,
between Active Recurring Donors and New Donors so the column order
reads current state → window-scoped activity → lifetime:

  Product | Active recurring | Lapsed | New | One-time gifts |
  Recurring revenue | Lifetime revenue

Lets publishers reconcile the Lapsed Donors scorecard with the
products their churned donors had subscribed to. Previously the
3 lapsed customers in the seeded dataset were aggregated into the
scorecard total but not surfaced anywhere per-product.

Semantics match the scorecard's cohort definition (cancelled or
expired in window AND customer has no current active donation
sub), just bucketed per product. SQL implementation is a third
GROUP BY pass alongside the existing subs + orders passes,
keyed by the same effective-product-id variation/parent
COALESCE pattern.

Em-dash convention follows the existing column treatment: one-time
products (Donate: One-Time) render — instead of 0 since a one-time
product can't have a recurring subscription to lapse from.
Recurring products (Donate: Monthly, Donate: Yearly) render the
count numerically including zero.

Reconciliation verified on the seeded dataset for the 90d window:
  Donate: Monthly → 1 lapsed
  Donate: Yearly  → 2 lapsed
  scorecard total → 3 ✓

A customer who lapsed across multiple donation products in the
same window would count once per product row, so SUM across rows
can exceed the scorecard for that edge case. Newspack's typical
donor only has one recurring donation so this reconciles cleanly
in practice; documented in both the storage and interface
docblocks.

Also fixes a pre-existing inconsistency: the legacy storage's
aggregate_tier_rows() still sorted the outer list by
active_recurring_donors descending; updated to match the HPOS
storage's lifetime_donation_revenue DESC convention so the table
order is identical regardless of storage backend.

CACHE_PREFIX bumped tab7_v6 → tab7_v7 since the response shape
gained a field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson force-pushed the nppd-1617-insights-tab-7-donors branch from 11d65cb to 9286940 Compare June 4, 2026 21:04
kmwilkerson and others added 3 commits June 4, 2026 16:15
…ename

Two small parity tweaks across the Insights pair:

1. Tab 7 gains an "Upcoming renewals (30d)" card in "Donors at a
   glance" — count of active recurring donation subscriptions
   whose `_schedule_next_payment` falls in the next 30 days.
   Mirrors Tab 6's upcoming-renewals card; SQL is the same shape
   with the donation filter flipped from NOT IN to IN.

   Subtitle: "Active recurring donations due to renew in the next
   30 days". Label stays "Upcoming renewals (30d)" verbatim with
   Tab 6 — "renewals" is the technical term for recurring billing
   events on either side; the subtitle does the specialization.

2. Tab 6's MRR card renamed from "Monthly recurring revenue" to
   "Subscriptions MRR" so the label shape matches Tab 7's
   "Donation MRR". Same metric, same value, just consistent
   naming across the two tabs.

Storage: new `get_upcoming_donation_renewals_30d()` on the donors
interface with HPOS + legacy implementations. Orchestrator caches
under TTL_DEFAULT. REST controller adds it to the snapshot under
`upcoming_donation_renewals_30d`. TypeScript types extended; React
ScorecardSection grows from 2 → 3 cards on Tab 7.

CACHE_PREFIX bumped tab7_v7 → tab7_v8 since the snapshot response
shape changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Upcoming cancellations (30d)" card to both Insights tabs:
counts subscriptions known to be ending in the next 30 days. Covers
two cohorts that both legitimately signal "ending soon":

  - wc-active subs with `_schedule_end` in next 30d (fixed-term
    reaching its scheduled end)
  - wc-pending-cancel subs with `_schedule_end` in next 30d
    (customer cancelled mid-cycle with paid period remaining)

WCS uses `_schedule_end` as the canonical end marker regardless of
which status set it, so both cohorts share the same predicate.
The card uses `lowerIsBetter` so a higher count reads correctly as
a churn signal.

Tab 6 ("Subscribers at a glance") goes 3 → 4 cards:
  Active Subscribers, Subscriptions MRR, Upcoming renewals,
  Upcoming cancellations.
Tab 7 ("Donors at a glance") goes 3 → 4 cards (same shape, scoped
to donation products via IN filter instead of NOT IN).

Tab 6's "Performance by product" section header renamed to
"Subscriptions by product" — same pattern as Tab 7's "Donations
by tier" ([Thing] by [Grouping]). The internal field name
`performance_by_product` and PHP method `get_performance_by_product`
literally encoded the old phrase, so renamed both to
`subscriptions_by_product` / `get_subscriptions_by_product` across
storage interface + HPOS + legacy + orchestrator + REST + TS +
SubscribersTab consumer. CSS class `--performance` stays generic
(Tab 7 reuses it for the donations-by-tier section).

CACHE_PREFIX bumped on both tabs (tab6_v3 → tab6_v4, tab7_v8 →
tab7_v9) since each snapshot grew a new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngs"

User-facing label rename on both Tab 6 and Tab 7. "Endings" reads
cleaner across the two cohorts the card covers (fixed-term subs
naturally reaching their scheduled end + customer-initiated
pending-cancel mid-cycle) — "cancellations" implied only the
customer-initiated path.

Internal naming (PHP method, REST field, TS interface) stays as
`upcoming_cancellations_30d` / `upcoming_donation_cancellations_30d`
since the SQL semantics are unchanged and the technical name reads
fine at the storage layer.

Subtitles already used "set to end" so they need no change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant