Skip to content

feat(insights): land Donors + Gates tabs on main (NPPD-1617, NPPD-1604)#243

Merged
kmwilkerson merged 43 commits into
mainfrom
nppd-1617-insights-tab-7-donors
Jun 9, 2026
Merged

feat(insights): land Donors + Gates tabs on main (NPPD-1617, NPPD-1604)#243
kmwilkerson merged 43 commits into
mainfrom
nppd-1617-insights-tab-7-donors

Conversation

@kmwilkerson

Copy link
Copy Markdown
Contributor

Summary

Lands the Donors (NPPD-1617, #225) and Gates (NPPD-1604, #229) Insights tab UIs on main.

Both were reviewed and merged down their stack (donors → nppd-1616, gates → nppd-1617) ~40 seconds after PR #217 (nppd-1616main) merged — so main caught Subscribers but missed Donors and Gates. This promotes the rest of the stack up.

Scope

Net diff vs main is the two tab UIs plus the shared-component refactors that the gates/donors work introduced:

  • Donors tab: tabs/donors/* (Scorecard / Performance / Retention / Windowed sections + scss), hooks/useDonorsData, DonorsTab.
  • Gates tab: tabs/gates/* (sections, viz/Funnel, viz/DistributionTable, callouts, preview banner + scss), api/gates, hooks/useGatesData, GatesTab.
  • Shared (additive): MetricCard.tsx, components/sections.scss, components/format.ts, and reuse-oriented tweaks to tabs/subscribers/* and api/subscribers.

Safety

  • No subscriber regression: every subscriber refinement on main (tenure card, scorecard regroup, tenure-histogram removal) and the shared-chrome refactor are already present in this branch — confirmed as ancestors.
  • The only main-only commit is the feat(insights): subscribers tab (NPPD-1616) #217 merge commit (content already here), so this is additive.

Downstream note

After this lands, the in-flight Audience/Engagement stack (#238#239#240) will be re-rebased onto the new main (the shared MetricCard / sections.scss move here, so a small reconciliation is expected — same pattern as the recent stack rebase).

🤖 Generated with Claude Code

kmwilkerson and others added 30 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>
…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>
NPPD-1604 Phase 1 backend scaffold. Each metric in Gates_Metric
returns a `{value, computable: false, pending: true, denominator: null,
placeholder_type}` payload so the React layer can render the spec's
empty-state value ("0" / "0%" / "$0.00" / "0.0") without inferring
type. The orchestrator carries no SQL — Phase 2 (NPPD-1630) will
swap each method to dispatch a `query_name` against the Newspack
Manager BigQuery query proxy; the REST controller and method
signatures stay stable across the boundary.

REST endpoint at `GET /newspack-insights/v1/gates` mirrors the Tab
6/7 controllers (same date validation, permission check, comparison
window handling). Response carries a top-level `tab_pending: true`
flag so React knows to render the Phase 1 banner.

Visibility is gated by a new constant
`NEWSPACK_INSIGHTS_GATES_PREVIEW` — independent of the parent
`NEWSPACK_INSIGHTS_ENABLED` flag so the preview can be flipped on
in dev/staging/canary separately from broader Insights rollout.
When the constant is missing, the boot config marks the tab not
visible and the Insights_Section_Gates::init() bails before
registering the REST route, so the endpoint isn't exposed either.

UI wiring (sections, viz, banner, MetricCard pending state)
follows in subsequent commits in this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive `pending?: boolean` prop on MetricCard. When true, the card
renders the formatted value normally but suppresses the comparison
delta even if `previousValue` is supplied — placeholder zeros don't
have a meaningful delta to show.

Tab 6 and Tab 7 never set `pending`, so their rendering is unchanged.
Tab 4 sections set it on every card during Phase 1; once Phase 2
(NPPD-1630) lands and real BQ data flows in, the flag flips off
per metric and the comparison delta renders normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab 4 React layer for NPPD-1604 Phase 1. Mirrors the
SubscribersTab / DonorsTab loading-error-success lifecycle and the
shared section / metric-card chrome from Tab 6/7. Five sections in
the spec order:

  1. Gate exposure          — 4 scorecards, Direct vs Influenced
                              explainer callout below the caption
  2. Free reader conversion — 2 scorecards (Direct + Influenced 7d)
  3. Paid reader conversion — 4 scorecards including direct revenue
  4. How readers convert    — Funnel (left) + Distribution (right)
  5. Performance by gate    — table with empty-state row from spec

Plus the Phase 1 top-of-tab dismissable banner. Banner + callout
dismissals are session-only — both reappear on page reload per
spec, intentional so the visual cue stays prominent.

Viz components are tab-local under tabs/gates/viz/ — minimal
Funnel + DistributionTable scoped to Tab 4. When the canonical
versions land in packages/components/src/ (likely alongside the
broader data-viz library work tracked separately), swap them in
and delete the tab-local copies.

Scalar metrics share a small `scalarToCard.ts` helper that maps
the server's `placeholder_type` → MetricCard `format` and decides
whether to surface a comparison `previousValue`. The MetricCard
`pending` flag (added in the prior commit) suppresses the
comparison delta when the metric is still in the placeholder phase
so toggling Compare-to-previous doesn't render a misleading 0%
delta. format.ts gained a `decimal` formatter so "Avg exposures
per reader" renders as "0.0" per the spec's placeholder table.

GatesTab.tsx replaces the prior Coming-Soon stub and is registered
in TabContent.tsx (no nav change needed — the tab key was already
in TabNavigation; visibility is gated by the boot config's
`tabs.gates` flag which reads NEWSPACK_INSIGHTS_GATES_PREVIEW).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two copy + placement tweaks to the Gates tab:

1. Reword "Influenced" subtitles and captions. The prior "in the
   prior X days" / "in the last X days" phrasing read as "the last
   X days of the selected timeframe" rather than "X days between
   the gate impression and the conversion event." Anchoring the
   time gap to the gate exposure (not the picker) eliminates the
   ambiguity:

   Card 2.2 subtitle, Card 3.2 subtitle, Section 2 caption,
   Section 3 caption, and the middle bullet of the Direct vs
   Influenced explainer all updated to the "within X days of
   seeing a gate" form.

2. Move the Direct vs Influenced explainer from below Section 1's
   caption up to the tab top, between the Phase 1 preview banner
   and Section 1's header. The framing is foundational to Sections
   2 and 3, so publishers should encounter it before reading any
   section that uses the terms. Dismissal remains session-only.

The matching spec doc at ~/Sites/insights-docs/specs/gates.md was
updated to reflect the same copy + placement decisions
(out-of-tree; not part of this PR's diff).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click any column header to re-sort. Default sort is Impressions
descending per spec. Numeric columns open DESC on first click
(biggest first — what publishers usually want); the gate-name
column opens ASC (alphabetical). Toggle direction by clicking the
same header again.

Null cells (em-dash, e.g. Regwall columns on a gate without a
registration block) always sort to the bottom regardless of
direction, so a "—" never claims the top of an ascending sort.

Sortable header buttons fill the full <th> click target with
hover + focus-visible affordances per the data-viz component
spec — chevron at 0.3 opacity inactive, 0.7 hover, 1.0 active.
Aria-sort on every <th> so screen readers announce the current
state.

Phase 1 still renders the empty-state row since `data.rows` is
empty, but the sortable chrome stays visible so Phase 2 swap-in
is seamless — when NPPD-1630 wires BigQuery and rows start
flowing through, the click-to-sort UI starts shuffling them.
Caption tweaked from "Sorted by impressions, highest first." to
"Click any column to re-sort." since the sort is interactive now.

Sortable styles scoped to Tab 4 (`.newspack-insights__table--sortable`)
in gates.scss. When Tabs 6/7 want sortable tables, lift these
styles + the SortableHeader component into the shared
sections.scss together.

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

Lift the Section 2 / Section 3 captions, Cards 2.1 / 2.2 / 3.1 / 3.2
/ 3.3 subtitles, and the Direct vs Influenced callout body verbatim
from specs/gates.md to bring the React UI back in sync with the
session-scoped attribution model.

Direct = same-session: gate impression and conversion share a GA
session. Influenced = cross-session within lookback (7d free / 14d
paid). Same-session is excluded from Influenced so the two
definitions stay mutually exclusive.

Also updates the docblock on PaidReaderConversionSection to drop the
old "gate-tagged" phrasing for internal consistency.

Spec doc cross-reference: ~/Sites/insights-docs/specs/gates.md
(separate doc commit, out-of-tree). Phase 2 SQL implementation
follows the same model — see formulas/tab-4-gates.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kmwilkerson and others added 12 commits June 8, 2026 13:15
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NPPD-1604 Phase 1 backend scaffold. Each metric in Gates_Metric
returns a `{value, computable: false, pending: true, denominator: null,
placeholder_type}` payload so the React layer can render the spec's
empty-state value ("0" / "0%" / "$0.00" / "0.0") without inferring
type. The orchestrator carries no SQL — Phase 2 (NPPD-1630) will
swap each method to dispatch a `query_name` against the Newspack
Manager BigQuery query proxy; the REST controller and method
signatures stay stable across the boundary.

REST endpoint at `GET /newspack-insights/v1/gates` mirrors the Tab
6/7 controllers (same date validation, permission check, comparison
window handling). Response carries a top-level `tab_pending: true`
flag so React knows to render the Phase 1 banner.

Visibility is gated by a new constant
`NEWSPACK_INSIGHTS_GATES_PREVIEW` — independent of the parent
`NEWSPACK_INSIGHTS_ENABLED` flag so the preview can be flipped on
in dev/staging/canary separately from broader Insights rollout.
When the constant is missing, the boot config marks the tab not
visible and the Insights_Section_Gates::init() bails before
registering the REST route, so the endpoint isn't exposed either.

UI wiring (sections, viz, banner, MetricCard pending state)
follows in subsequent commits in this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive `pending?: boolean` prop on MetricCard. When true, the card
renders the formatted value normally but suppresses the comparison
delta even if `previousValue` is supplied — placeholder zeros don't
have a meaningful delta to show.

Tab 6 and Tab 7 never set `pending`, so their rendering is unchanged.
Tab 4 sections set it on every card during Phase 1; once Phase 2
(NPPD-1630) lands and real BQ data flows in, the flag flips off
per metric and the comparison delta renders normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab 4 React layer for NPPD-1604 Phase 1. Mirrors the
SubscribersTab / DonorsTab loading-error-success lifecycle and the
shared section / metric-card chrome from Tab 6/7. Five sections in
the spec order:

  1. Gate exposure          — 4 scorecards, Direct vs Influenced
                              explainer callout below the caption
  2. Free reader conversion — 2 scorecards (Direct + Influenced 7d)
  3. Paid reader conversion — 4 scorecards including direct revenue
  4. How readers convert    — Funnel (left) + Distribution (right)
  5. Performance by gate    — table with empty-state row from spec

Plus the Phase 1 top-of-tab dismissable banner. Banner + callout
dismissals are session-only — both reappear on page reload per
spec, intentional so the visual cue stays prominent.

Viz components are tab-local under tabs/gates/viz/ — minimal
Funnel + DistributionTable scoped to Tab 4. When the canonical
versions land in packages/components/src/ (likely alongside the
broader data-viz library work tracked separately), swap them in
and delete the tab-local copies.

Scalar metrics share a small `scalarToCard.ts` helper that maps
the server's `placeholder_type` → MetricCard `format` and decides
whether to surface a comparison `previousValue`. The MetricCard
`pending` flag (added in the prior commit) suppresses the
comparison delta when the metric is still in the placeholder phase
so toggling Compare-to-previous doesn't render a misleading 0%
delta. format.ts gained a `decimal` formatter so "Avg exposures
per reader" renders as "0.0" per the spec's placeholder table.

GatesTab.tsx replaces the prior Coming-Soon stub and is registered
in TabContent.tsx (no nav change needed — the tab key was already
in TabNavigation; visibility is gated by the boot config's
`tabs.gates` flag which reads NEWSPACK_INSIGHTS_GATES_PREVIEW).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two copy + placement tweaks to the Gates tab:

1. Reword "Influenced" subtitles and captions. The prior "in the
   prior X days" / "in the last X days" phrasing read as "the last
   X days of the selected timeframe" rather than "X days between
   the gate impression and the conversion event." Anchoring the
   time gap to the gate exposure (not the picker) eliminates the
   ambiguity:

   Card 2.2 subtitle, Card 3.2 subtitle, Section 2 caption,
   Section 3 caption, and the middle bullet of the Direct vs
   Influenced explainer all updated to the "within X days of
   seeing a gate" form.

2. Move the Direct vs Influenced explainer from below Section 1's
   caption up to the tab top, between the Phase 1 preview banner
   and Section 1's header. The framing is foundational to Sections
   2 and 3, so publishers should encounter it before reading any
   section that uses the terms. Dismissal remains session-only.

The matching spec doc at ~/Sites/insights-docs/specs/gates.md was
updated to reflect the same copy + placement decisions
(out-of-tree; not part of this PR's diff).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click any column header to re-sort. Default sort is Impressions
descending per spec. Numeric columns open DESC on first click
(biggest first — what publishers usually want); the gate-name
column opens ASC (alphabetical). Toggle direction by clicking the
same header again.

Null cells (em-dash, e.g. Regwall columns on a gate without a
registration block) always sort to the bottom regardless of
direction, so a "—" never claims the top of an ascending sort.

Sortable header buttons fill the full <th> click target with
hover + focus-visible affordances per the data-viz component
spec — chevron at 0.3 opacity inactive, 0.7 hover, 1.0 active.
Aria-sort on every <th> so screen readers announce the current
state.

Phase 1 still renders the empty-state row since `data.rows` is
empty, but the sortable chrome stays visible so Phase 2 swap-in
is seamless — when NPPD-1630 wires BigQuery and rows start
flowing through, the click-to-sort UI starts shuffling them.
Caption tweaked from "Sorted by impressions, highest first." to
"Click any column to re-sort." since the sort is interactive now.

Sortable styles scoped to Tab 4 (`.newspack-insights__table--sortable`)
in gates.scss. When Tabs 6/7 want sortable tables, lift these
styles + the SortableHeader component into the shared
sections.scss together.

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

Lift the Section 2 / Section 3 captions, Cards 2.1 / 2.2 / 3.1 / 3.2
/ 3.3 subtitles, and the Direct vs Influenced callout body verbatim
from specs/gates.md to bring the React UI back in sync with the
session-scoped attribution model.

Direct = same-session: gate impression and conversion share a GA
session. Influenced = cross-session within lookback (7d free / 14d
paid). Same-session is excluded from Influenced so the two
definitions stay mutually exclusive.

Also updates the docblock on PaidReaderConversionSection to drop the
old "gate-tagged" phrasing for internal consistency.

Spec doc cross-reference: ~/Sites/insights-docs/specs/gates.md
(separate doc commit, out-of-tree). Phase 2 SQL implementation
follows the same model — see formulas/tab-4-gates.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson requested a review from a team as a code owner June 9, 2026 01:58
Copilot AI review requested due to automatic review settings June 9, 2026 01:58
@kmwilkerson kmwilkerson enabled auto-merge June 9, 2026 02:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR promotes the missing portions of the Insights tab stack onto main by landing the Donors (Tab 7) and Gates (Tab 4) UIs (plus their supporting REST/data layers) and aligning shared components/refactors needed by those tabs.

Changes:

  • Adds full Donors tab data-fetch lifecycle + UI sections, backed by a new /newspack-insights/v1/donors REST endpoint and storage abstractions for HPOS vs legacy order storage.
  • Adds Gates tab Phase 1 UI (placeholder metrics) backed by a new /newspack-insights/v1/gates REST endpoint, preview-gated via NEWSPACK_INSIGHTS_GATES_PREVIEW.
  • Refactors/extends shared Insights UI building blocks (MetricCard formatting, shared section/table styles) and updates Subscribers tab data shapes (rate metrics now carry { value, computable, denominator }, adds upcoming cancellations, renames “performance” payload to subscriptions_by_product).

Reviewed changes

Copilot reviewed 45 out of 45 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx Wires renamed subscriptions_by_product payload into the Subscribers performance table.
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx Updates refund/retry rate rendering to support {value, computable, denominator} + empty states.
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx Updates at-a-glance cards (adds caption, annualized secondary, upcoming endings).
plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx Renames section heading/caption to match new “subscriptions by product” framing.
plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx Replaces stub with full tab orchestration, loading/error states, and section composition.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx Adds tab-local funnel visualization for Gates Phase 1.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx Adds tab-local distribution table visualization for Gates Phase 1.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts Adds helper to map Gates placeholder metric payloads to MetricCard props.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx Adds dismissible Phase 1 preview banner.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx Adds sortable “Performance by gate” table (with empty state + em-dash NA cells).
plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx Adds paid conversion scorecard section for Gates.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx Adds funnel + distribution layout section for Gates.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss Adds Gates tab-specific layout and visualization styling.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx Adds exposure scorecard section for Gates.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx Adds free conversion scorecard section for Gates.
plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx Adds dismissible “Direct vs Influenced” explainer callout.
plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx Replaces stub with full tab orchestration, loading/error states, and section composition.
plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx Adds Donors window-scoped scorecards and dynamic date-range heading.
plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx Adds Donors at-a-glance scorecards (MRR + annualized secondary, upcoming renewals/endings).
plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx Adds retention rendering with cohort-aware empty states and denominator subtitles.
plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx Adds “Donations by tier” table with variation rows and NA em-dash handling.
plugins/newspack-plugin/src/wizards/insights/tabs/donors/donors.scss Adds Donors tab-specific layout styling.
plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss Extends shared section/card/table styling (captions, secondary line, empty-card variant, NA cells).
plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx Adds secondary, pending, and decimal format support; suppresses deltas when pending.
plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts Adds formatDecimal.
plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts Adds Gates data hook mirroring existing tab fetch patterns.
plugins/newspack-plugin/src/wizards/insights/hooks/useDonorsData.ts Adds Donors data hook mirroring existing tab fetch patterns.
plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts Updates Subscribers response types (rate metric shape, upcoming cancellations, payload rename).
plugins/newspack-plugin/src/wizards/insights/api/gates.ts Adds Gates REST client + full placeholder response typing.
plugins/newspack-plugin/src/wizards/insights/api/donors.ts Adds Donors REST client + full response typing.
plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php Updates storage contract (rate metric shapes, upcoming cancellations, rename subscriptions-by-product).
plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php Implements updated storage contract (rate metric shapes, upcoming cancellations, renamed method).
plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php Implements updated storage contract (rate metric shapes, upcoming cancellations, renamed method).
plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php Adds Donors storage interface contract.
plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php Adds legacy CPT Donors storage implementation.
plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php Updates metric cache prefix and adds upcoming cancellations + renamed subscriptions-by-product method.
plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php Adds Gates Phase 1 placeholder metric orchestrator.
plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php Adds Donors metric orchestrator with caching and derived metrics.
plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php Adds Gates preview flag + Donors activity-based visibility heuristic.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php Wires Gates section to register REST routes when preview flag is enabled.
plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php Wires Donors section to load dependencies and register REST routes.
plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php Updates Subscribers response payload shape (upcoming cancellations + renamed subscriptions-by-product key).
plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php Adds Gates REST controller for placeholder Phase 1 payloads.
plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php Adds Donors REST controller.

Comment thread plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php Outdated
@kmwilkerson kmwilkerson disabled auto-merge June 9, 2026 02:04
…y comment

Address Copilot on #243: get_subscription_refund_rate / get_failed_payment_retry_rate
(HPOS + legacy) return an array{value,computable,denominator}, not float; and the
donors tab-visibility comment now matches has_donation_activity() (activity, not
mere product presence; cached a day).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson enabled auto-merge June 9, 2026 02:13
@kmwilkerson kmwilkerson disabled auto-merge June 9, 2026 02:15
@kmwilkerson kmwilkerson merged commit 678f984 into main Jun 9, 2026
12 checks passed
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Hey @kmwilkerson, good job getting this PR merged! 🎉

Now, the needs-changelog label has been added to it.

Please check if this PR needs to be included in the "Upcoming Changes" and "Release Notes" doc. If it doesn't, simply remove the label.

If it does, please add an entry to our shared document, with screenshots and testing instructions if applicable, then remove the label.

Thank you! ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants