feat(insights): donors tab (NPPD-1617)#225
Draft
kmwilkerson wants to merge 23 commits into
Draft
Conversation
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>
11d65cb to
9286940
Compare
…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>
11 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
What's in this PR
Storage abstraction layer
Donors_Storage_Interfacedefining the per-metric query contract for donor dataHPOS_Donors_StorageandLegacy_Donors_Storageimplementations dispatching via the sameStorage_DetectorTab 6 usesDonation_Product_Classifierfrom Tab 6 for product ID resolutionTab visibility (donation activity heuristic)
Insights_Wizard::has_donation_activity()checks for actual donation orders or subscriptions, not just product existenceforce_refresh_donation_activity()available for testingDonors metric and REST endpoint
Donors_Metricorchestrator 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)GET /newspack-insights/v1/donorsreturning the full payload (classification metadata, snapshot, current window, optional comparison window)'0'on_schedule_cancelledto mean "not cancelled" (distinct from NULL and empty string), so the retention denominator query explicitly includes'0'in the "still active at window start" checkReact UI
DonorsTabwith four sections: at-a-glance scorecards, time-windowed metrics, retention rates, donations by tier{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.Comparison mode
lowerIsBettersemantics for Lapsed Donors (drops render green, increases render red)Backports to Tab 6 for cross-tab consistency
This PR also lifts several patterns into Tab 6 so the two tabs feel like siblings:
{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 statesHow to test
NEWSPACK_INSIGHTS_ENABLEDconstant inwp-config.phpwp-admin/admin.php?page=newspack-insights\Newspack\Insights_Wizard::force_refresh_donation_activity(), and confirm the Donors tab appears.'0'cancel-meta sentinel)