Skip to content

feat(insights): subscribers tab (NPPD-1616)#217

Draft
kmwilkerson wants to merge 56 commits into
mainfrom
nppd-1616-insights-tab-6-subscribers
Draft

feat(insights): subscribers tab (NPPD-1616)#217
kmwilkerson wants to merge 56 commits into
mainfrom
nppd-1616-insights-tab-6-subscribers

Conversation

@kmwilkerson
Copy link
Copy Markdown

@kmwilkerson kmwilkerson commented Jun 4, 2026

Summary

Builds the fully-functional Subscribers tab inside the Insights wizard. This is the first Insights tab that ships with real publisher data, sourced entirely from local WooCommerce (no BigQuery dependency).

Stacks on the chrome PR (#211, NPPD-1602). Both PRs need to merge for Subscribers to be visible — chrome alone is empty tabs, Subscribers alone won't load without the chrome scaffold.

image

What's in this PR

Storage abstraction layer

  • Storage_Interface defining the per-metric query contract
  • HPOS_Storage and Legacy_Storage implementations dispatching by woocommerce_custom_orders_table_enabled
  • Storage_Detector caching the backend detection result (24h TTL)
  • Both backends verified end-to-end against test data

Donation product classifier

  • Donation_Product_Classifier wrapping the canonical \Newspack\Donations::is_donation_product() and is_donation_order() methods
  • Caches the donation product ID set (1h TTL) for use in NOT IN filters across subscription queries
  • Combines all three canonical detection paths (legacy parent/child, v6.41.0 manual flag, variation inheritance)

Subscribers metric and REST endpoint

  • Subscribers_Metric orchestrator with per-method WP transient caching (30min / 60min TTL for v1; will migrate to NPPD-1605 cache table when that lands)
  • Single REST endpoint GET /newspack-insights/v1/subscribers returning the full payload (classification metadata, snapshot, current window, optional comparison window). Caching is per-metric inside the orchestrator so a comparison-mode request reuses the same per-method cache entries.

React UI

  • SubscribersTab with four sections: at-a-glance scorecards, time-windowed metrics, subscriber tenure, performance by product
  • Scorecards regrouped by temporal scope: current-state metrics (Active, MRR, ARR, Upcoming Renewals) vs window-scoped metrics (New, Churned, Gross, Net, Refund Rate, Failed Payment Recovery)
  • Window-scoped section header is dynamic — reflects the active date range ("In the last 30 days", "This month", "From Sep 5 to Oct 5", etc.)
  • All scorecards share consistent typography (44px / 500 hero, font lockdown to prevent admin-chrome font flips) and a brand-color top accent per design spec
  • Subscriber tenure card shows median + p25 + p75 callouts plus an interpretive sentence ("Half of your subscribers have been here longer than N days. A quarter have been here longer than N days."); the histogram was cut after design review since it duplicated the same information
  • Performance by product table with explanatory caption clarifying "subscriptions, not unique customers"

Comparison mode

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

How to test

  1. Set NEWSPACK_INSIGHTS_ENABLED constant in wp-config.php
  2. Navigate to wp-admin/admin.php?page=newspack-insights
  3. Click into the Subscribers tab
  4. Verify:
    • All 10 scorecards render with real data (4 at-a-glance + 6 windowed)
    • Section headers correctly reflect "Subscribers 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 (churn drop = green, refund rate up = red)
    • Tenure card shows median/p25/p75 + interpretive sentence
    • Performance by product table shows real subscription counts with explanatory caption
    • Hard refresh restores state from URL
  5. Verify storage abstraction by testing on both HPOS-enabled and HPOS-disabled environments

kmwilkerson and others added 30 commits June 3, 2026 16:09
Top-level Insights_Wizard extending Wizard with slug newspack-insights,
parent_menu newspack-dashboard (nests under the top-level Newspack admin
menu — matches Setup wizard precedent), capability manage_options. The
React view is registered separately in src/wizards/index.tsx under the
slug key.

enqueue_scripts_and_styles() localizes a 'newspackInsights' boot config:
tab visibility (stubbed all-on pending NPPD-1598 BQ wrapper + Woo queries
for real feature detection), default date range (last 30 days),
default comparison mode (off), site timezone, settings URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One section class per tab:
- Insights_Section_Audience      (Audience)
- Insights_Section_Engagement    (Engagement)
- Insights_Section_Conversion    (Conversion Journey)
- Insights_Section_Gates         (Gates)
- Insights_Section_Prompts       (Prompts)
- Insights_Section_Subscribers   (Subscribers)
- Insights_Section_Donors        (Donors)
- Insights_Section_Advertising   (Advertising)

Each is a plain class (NOT extending Wizard_Section, NOT registered via
the wizard's sections array) with:
- SECTION_NAME constant matching the React tab label
- static init() that calls self::register_hooks()
- empty register_hooks() — placeholder for future per-tab REST
  endpoint registration as each tab's data layer lands (NPPD-1604,
  1607, 1608, 1609, 1616, 1617, 1618, 1624)
- Doc block describing tab scope and visibility constraints

This is a new convention introduced for Insights: tab routing happens
on the React side via URL query persistence, so PHP doesn't register
8 separate wizards (like Audience does) — these classes exist as the
documented hook point for future REST work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up the 9 new PHP files:

- includes/class-newspack.php: include_once for the Insights_Wizard
  class file plus all 8 section stub files (alphabetical within the
  insights/ subdir grouping)

- includes/class-wizards.php: add 'insights' => new Insights_Wizard()
  to the $wizards array, positioned between audience-integrations and
  listings (matches the visual order in admin)

- includes/class-wizards.php: call ::init() on all 8 section classes
  at the tail of init_wizards() so their (currently empty) register_hooks()
  runs during the 'init' action — placeholder hookpoint for future
  per-tab REST work

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

useDateRange (state/useDateRange.ts):
- DateRangePreset type with 6 presets: last-7, last-30, last-30 (default),
  last-90, this-month, last-month, custom
- Hydrates initial state from URL query params (range, start, end) with
  fallback to boot config default
- Persists changes via history.replaceState (no history pollution)
- Exports computeRangeForPreset() pure helper for testing
- Validates URL inputs against /^\d{4}-\d{2}-\d{2}$/ before trusting

useComparisonMode (state/useComparisonMode.ts):
- Boolean state for "compare to previous period" toggle
- Hydrates from ?compare=1 in URL; default off
- Computes previous-period range as same-length-back (immediately
  preceding current window, no overlap) via computePreviousRange() pure
  helper, memoized against current range
- previousRange is null when comparison disabled or current range
  is malformed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le, LastUpdated (NPPD-1602)

DateRangePicker (components/DateRangePicker.tsx):
- Stateless: caller wires to useDateRange
- Native <select> for preset choice (6 options pulled from
  DATE_RANGE_PRESETS)
- Custom mode reveals two type="date" inputs separated by an arrow

ComparisonToggle (components/ComparisonToggle.tsx):
- Stateless: caller wires to useComparisonMode
- Single checkbox: "Compare to previous period"

LastUpdated (components/LastUpdated.tsx):
- Takes an ISO 8601 timestamp prop (or null if not yet known)
- Renders relative time ("Updated 12 minutes ago", "Updated 3 hours ago",
  "Updated 2 days ago", "Updated just now")
- Title attribute holds the absolute timestamp for tooltip on hover
- Renders nothing if timestamp is null or unparseable — safe for boot
  state before first cache hit

Per spec at ~/Sites/insights-docs/component-design-spec.md. All three
intentionally lean on native HTML inputs (no @wordpress/components
dependencies) so the chrome can render before WP-data hydration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TabNavigation (components/TabNavigation.tsx):
- Exports TabKey type and ALL_TABS list (single source of truth for tab
  identity + display label)
- Stateless: active state via prop, click via callback
- ARIA-correct: role="tablist" on nav, role="tab" + aria-selected +
  aria-controls per button
- Conditional visibility per TabVisibility prop (record per TabKey).
  Hidden tabs are filtered out entirely (not rendered with display:none).

TabContent (components/TabContent.tsx):
- Lazy-loads each of the 8 tab components via React.lazy
- Suspense boundary wraps the render with a simple "Loading…" fallback
- Switch-based dispatch on activeTab (one place to thread the lazy
  imports)
- ARIA-correct: role="tabpanel", id/aria-labelledby paired with the
  TabNavigation button IDs
- Passes activeTab, range, previousRange down to each tab. Tabs receive
  prop-shape they need for future data fetching even though current
  stubs ignore them.

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

Eight files in src/wizards/insights/tabs/:
- AudienceTab.tsx       (real content: NPPD-1604)
- EngagementTab.tsx     (NPPD-1607)
- ConversionTab.tsx     (NPPD-1608)
- GatesTab.tsx          (NPPD-1609)
- PromptsTab.tsx        (NPPD-1616)
- SubscribersTab.tsx    (NPPD-1617)
- DonorsTab.tsx         (NPPD-1618)
- AdvertisingTab.tsx    (NPPD-1624)

Each renders a centered tab name + "Coming soon" using shared
.newspack-insights__tab-stub styles defined alongside the wizard
chrome. Each is its own file so TabContent's React.lazy() can
code-split per tab; this issue's bundle just gets 8 trivial chunks
that future PRs replace with the real per-tab data flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ties the chrome together:

- Owns active tab state with URL persistence (?tab=...). Initial tab
  hydrates from URL with validation against visibility config; if the
  URL-named tab is hidden for this publisher, falls back to the first
  visible tab (handles the edge where someone bookmarks an Advertising
  tab and the publisher hasn't enabled GAM yet).
- Threads useDateRange and useComparisonMode (both URL-persistent).
- Renders the page in three slots: header (title + date picker +
  comparison toggle + last-updated, all in one row), tab navigation,
  and lazy tab content.
- previousRange from useComparisonMode flows through TabContent to
  tabs so future per-tab data fetching can request both windows.

InsightsBootConfig type documents the wp_localize_script payload shape
in one place; the PHP wizard's get_boot_config() and this type should
stay in sync as the real feature-detection logic lands (NPPD-1598+).

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

src/wizards/insights/index.tsx:
- Reads window.newspackInsights (populated by PHP via wp_localize_script)
- Falls back to a hardcoded all-tabs-on / last-30-days config if missing
  (defensive; the PHP path should always populate, but the React module
  can render in isolation for development without a runtime crash)
- Mounts <InsightsWizard config={...} /> as the default export

src/wizards/index.tsx:
- Adds 'newspack-insights' key to the lazy-loaded components map
- Code-split into its own chunk via /* webpackChunkName: "insights-wizard" */
  so the Insights bundle stays out of the shared newspack-wizards chunk
  and only loads when ?page=newspack-insights

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Page-level styles for the Insights wizard chrome only — per-component
data viz styles live in packages/components/src/ and don't belong here.

- Page: 1200px max-width, 24px horizontal padding, gray-900 base
- Header: flex row (title left, picker/toggle/timestamp right), wrap to
  multiple rows on narrow widths
- Title: 28px / 600 (matches the data viz demo gallery convention)
- DateRangePicker: native <select> + date inputs styled to match WP
  admin form controls (gray-300 border, 4px radius, focus-visible
  outline in admin theme color)
- ComparisonToggle: inline checkbox with 13px label
- Tab nav: horizontal bar with bottom border. Active tab gets the
  admin theme color + bottom-border underline. Tabs wrap to multiple
  rows on narrow widths.
- Tab content area: 320px min-height so the page doesn't collapse on
  Suspense fallback
- Tab stub: centered title + "Coming soon" with 320px min-height

Per spec at ~/Sites/insights-docs/component-design-spec.md type scale
and color usage rules. wp-colors for all neutrals.

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

The 8 PHP section stubs and 8 corresponding React tab stubs were
documented with an inverted Linear issue map (original prompt's draft
numbering, not the actual issue assignments). Confirmed correct
mapping is:

  Insights_Section_Audience       -> NPPD-1608
  Insights_Section_Engagement     -> NPPD-1624
  Insights_Section_Conversion     -> NPPD-1609
  Insights_Section_Gates          -> NPPD-1604
  Insights_Section_Prompts        -> NPPD-1607
  Insights_Section_Subscribers    -> NPPD-1616
  Insights_Section_Donors         -> NPPD-1617
  Insights_Section_Advertising    -> NPPD-1618

Doc-block-only change across 16 files (8 PHP + 8 TSX). No behavior
change. The TSX tab stub doc blocks had the same misalignment as the
PHP section docs, so they're updated in the same commit for
consistency — the user's directive scoped to "section stub doc
blocks" but leaving the tab stubs inconsistent would have introduced
a different drift across the same surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Last N days" presets were subtracting N days from today, producing an
(N+1)-day inclusive window — e.g. on Jun 3, last-30 returned May 4 → Jun 3
inclusive, which is 31 days. Subtract (N-1) instead so the window is
exactly N days end-to-end (May 5 → Jun 3 = 30 days inclusive).

Fixes Copilot review on PR #210 in three places:
- includes/wizards/insights/class-insights-wizard.php: PHP boot config
  default range (also added comment confirming current_datetime() returns
  DateTimeImmutable so modify() is non-mutating — Copilot flagged this
  as a mutation risk but WP docs and runtime behavior say otherwise; the
  real bug was the off-by-one, not mutation)
- src/wizards/insights/state/useDateRange.ts: computeRangeForPreset
  for last-7 / last-30 / last-90
- src/wizards/insights/index.tsx: FALLBACK_CONFIG default range

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

Fixes Copilot review on PR #211. Previously the user-facing strings in
TabNavigation (8 tab labels + the nav aria-label) and useDateRange (6
preset labels) were hardcoded English. Wrapped each in __() with the
'newspack-plugin' text domain so wp-scripts string extraction picks
them up for the .pot file and wp-admin renders them in the active
locale.

Module-load-time __() calls are fine — wp-i18n does runtime translation
lookup against bundled translations and locale doesn't change within
a session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes two Copilot review points on PR #211:

1. settingsUrl was in InsightsBootConfig but unused. Now rendered as a
   "Settings" link in the chrome header-right group (always visible when
   the URL is present, even in the empty-state case where the rest of
   the header tools hide — settings should remain reachable for
   configuration).

2. When visibility config had zero true tabs, the chrome rendered a
   blank tab area (TabNavigation empty, TabContent rendering audience
   panel anyway because readInitialTab forced 'audience'). Now:
   - readInitialTab returns null when no tabs are visible
   - InsightsWizard renders a dedicated empty state ("No insights
     sections available") with a brief explanation directing the user
     to Settings
   - DateRangePicker / ComparisonToggle / LastUpdated hide too — they're
     not actionable without any tab to display

Empty-state SCSS follows the spec's empty-state vocabulary (centered,
generous padding, 22px title + 14px gray-700 message, max-width 480px
for readability).

The timezone field stays in InsightsBootConfig for future date-formatting
use (e.g., LastUpdated's absolute tooltip; per-tab data renderers when
real data arrives); not yet consumed, but the PHP side already populates
it so removing now means adding back later.

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

CI on PR #211 surfaced lint failures the local build alone didn't catch.
This commit makes the lint checks pass:

PHP (phpcs / phpcbf):
- Aligned associative array double-arrows in get_boot_config()
- Converted the block-comment annotation above 'tabs' to // comments to
  satisfy WordPress.Files.SpaceBeforeBlockComment (a leading blank line
  inside an array literal reads worse than just using line comments)

JS / TS (prettier + jsx-a11y):
- Prettier-formatted all insights tab stubs and components to satisfy
  the project's prettier config (line-break style on inline JSX)
- Added htmlFor/id pairs to <label> + control associations in
  ComparisonToggle and DateRangePicker for jsx-a11y/label-has-associated-control
- Switched TabNavigation's root from <nav role="tablist"> to
  <div role="tablist"> for jsx-a11y/no-noninteractive-element-to-interactive-role
  (nav is non-interactive; tablist is interactive)

Net behavior unchanged; doc blocks intact; visible markup identical
modulo the nav→div swap which is a11y-correct.

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

Gates Insights wizard registration, asset enqueueing, and section
stub initialization behind NEWSPACK_INSIGHTS_ENABLED. Default: off.

Allows merging subsequent Insights PRs incrementally to main without
exposing the in-progress feature to publishers. The flag is removed
once Insights is ready for general release.

Pattern follows Private_Tags::is_enabled() (includes/tags/class-private-tags.php).

Gating points:

- Insights_Wizard::__construct() bails before parent::__construct() runs,
  so no admin_menu / admin_enqueue_scripts / admin_body_class hooks are
  registered. The object exists in the Wizards $wizards array but is a
  no-op. wp-admin: no menu item, no asset enqueue, the page returns
  WP's "do not have sufficient permissions" since the slug isn't
  registered.

- All 8 section stub init() methods (Audience, Engagement, Conversion,
  Gates, Prompts, Subscribers, Donors, Advertising) bail before calling
  register_hooks(). They're no-ops today; the gate belongs here so when
  per-tab REST registration lands in subsequent PRs it inherits the
  gate for free.

Verified at runtime: with NEWSPACK_INSIGHTS_ENABLED undefined,
is_enabled() returns false and the admin_menu add_page hook is not
attached to the wizard instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 typed methods, one per Tab 6 metric, fixing the PHP boundary that
both backend implementations (HPOS, legacy CPT) will satisfy:

- get_active_non_donation_subscribers
- get_new_subscribers_in_window
- get_churned_subscribers_in_window
- get_mrr / get_arr
- get_subscription_revenue_gross / _net
- get_subscription_refund_rate
- get_subscription_tenure_distribution
- get_upcoming_renewals_30d
- get_failed_payment_retry_rate
- get_performance_by_product
- get_cancellation_reasons

Donation product IDs are injected at construction (not threaded
through each method signature) so the per-method contracts stay
clean — see Donation_Product_Classifier::get_donation_product_ids().

Namespace: \Newspack\Insights\Storage_Interface. Sub-namespace
matches the prompt's notation (Insights\Storage_Interface) and the
prior section stubs in the chrome's \Newspack namespace remain
unaffected.

SQL bodies live in:
- ~/Sites/insights-docs/formulas/tab-6-subscribers.md
- ~/Sites/insights-docs/formulas/subscription-donation-schema.md

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

Reads the woocommerce_custom_orders_table_enabled option, returns
either Storage_Detector::BACKEND_HPOS ('hpos') or
Storage_Detector::BACKEND_LEGACY ('legacy'). Caches the result in a
24h transient since HPOS migration is a one-way event and the option
rarely flips.

Two entry points:
- detect(): cached read, recomputes only on cache miss
- force_refresh(): bypass + refresh cache, returns fresh value

force_refresh() is the hook point for NPPD-1605's eventual cache
invalidation layer and for the HPOS migration window where a single
admin session might toggle the option mid-flight.

The data_sync_enabled flag mentioned in the schema doc isn't relevant
here — that affects which backend's reads are *trustworthy* but not
which is *active*. The active backend is solely determined by the
custom_orders_table_enabled option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Storage_Interface against the HPOS tables ({prefix}wc_orders,
{prefix}wc_orders_meta, {prefix}wc_order_product_lookup). All 13
methods, SQL adapted from ~/Sites/insights-docs/formulas/tab-6-
subscribers.md.

Compat notes:

- WITH ... AS () CTEs in the formula doc are rewritten as inline
  subqueries; MySQL 5.7 (which some Newspack-hosted publishers run)
  does not support CTEs.
- Donation product IDs are injected at construction. Empty input
  coerces to (0) via id_list() so NOT IN clauses stay syntactically
  valid when a publisher has no donation products yet.
- Subscription product type IDs are looked up via term_relationships /
  term_taxonomy at query time (subscription_product_ids_sql helper);
  the metric-layer cache amortizes the lookup across calls.
- Date params bound via $wpdb->prepare with %s; product-ID lists
  interpolated after intval cast to prevent SQL injection.
- Several PHPCS direct-DB-query phpcs:disable comments at the top —
  this is an analytics layer that explicitly wants direct SQL, not
  the WP query API.

Approximations called out:
- performance_by_product.lifetime_revenue sums renewal-amount rows
  (the subscription parent's total_amount), not historical orders.
  True LTV waits on the v1.1 BQ wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Storage_Interface against the pre-HPOS WooCommerce order
storage: {prefix}posts (typed by post_type) and {prefix}postmeta.
Mirrors HPOS_Storage method-by-method.

Per-row translation per the schema doc:

  HPOS                          Legacy
  wc_orders.id                  posts.ID
  wc_orders.type                posts.post_type
  wc_orders.status              posts.post_status
  wc_orders.date_created_gmt    posts.post_date_gmt
  wc_orders.customer_id         postmeta._customer_user
  wc_orders.total_amount        postmeta._order_total (DECIMAL string)
  wc_orders.parent_order_id     posts.post_parent
  wc_orders_meta.*              postmeta.*

The product lookup table {prefix}wc_order_product_lookup is populated
by Woo Analytics regardless of backend, so it joins identically here.

Same compat constraints as HPOS_Storage: no CTEs (rewritten as inline
subqueries), donation IDs injected at construction, empty input coerces
to (0) for valid NOT IN syntax.

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

Wraps the canonical \Newspack\Donations::is_donation_product() with an
aggressive cache so Tab 6 SQL queries can thread a precomputed
:donation_product_ids parameter into NOT IN filters without re-running
the per-product detection logic.

get_donation_product_ids() returns the union of all three detection
paths from the schema doc:

- Path 3 (universal): canonical Newspack donation family — grouped
  parent from the newspack_donation_product_id option plus the three
  children (once/month/year)
- Path 1 (new, v6.41.0): products manually flagged via
  _newspack_is_donation postmeta — Donations::get_flagged_donation_
  product_ids()
- Path 2 (variation expansion): all product_variation post IDs whose
  parent is in the union of Paths 1+3. Necessary because the order
  product lookup table records the variation's product_id, not the
  parent's — a NOT IN filter using only parents would leak variation
  orders through.

is_donation_product( $product_id ) tests against the cached set; safe
for hot loops.

flush_cache() is the hook point for the future NPPD-1605 cache
invalidation layer and for manual recompute after configuration
changes (newspack_donation_product_id option, _newspack_is_donation
flag flips).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin dispatch + caching layer over the per-backend storage classes.
Picks HPOS_Storage or Legacy_Storage via Storage_Detector::detect(),
threads the precomputed donation product ID set from
Donation_Product_Classifier::get_donation_product_ids() into the
storage constructor, and wraps each metric call in a transient cache
keyed by `prefix:backend:method:md5(params_json)`.

Cache tiers:

- 30 min (TTL_DEFAULT): windowed metrics and top-line snapshots —
  revenue gross/net, refund rate, new/churned counts, MRR, ARR,
  active count, upcoming renewals, retry rate
- 60 min (TTL_HEAVY): heavy aggregation queries — tenure
  distribution, performance by product, cancellation reasons

Comparison-mode is not implemented here: the REST layer calls these
methods twice (current + prior window) and the cache makes the second
call free if the prior window has already been requested.

get_classification_metadata() exposes backend + donation_product_count
+ has_donation_family for the React classification banner.

flush_all() is the hook point for NPPD-1605 invalidation and for
manual recompute after corrections; not wired to any automatic trigger
today because metrics expire on their own TTL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Subscribers_REST_Controller registering GET on the dedicated
namespace newspack-insights/v1 (separate from newspack/v1 which is
reserved for wizard infrastructure). Single route, single endpoint:

  GET /newspack-insights/v1/subscribers
    ?start=YYYY-MM-DD
    &end=YYYY-MM-DD
    [&compare_start=YYYY-MM-DD&compare_end=YYYY-MM-DD]

Response shape:
  - classification: { backend, donation_product_count, has_donation_family }
  - snapshot:       active_subscribers, mrr, arr, tenure_distribution,
                    upcoming_renewals_30d (window-independent)
  - current:        window + 7 windowed metrics for the requested range
  - previous:       same shape as current, or null when compare_*
                    params are omitted

Date inputs are Y-m-d in the site timezone; start resolves to 00:00:00
and end to 23:59:59 inclusive. Validation rejects malformed dates,
mismatched comparison-pair, and inverted windows with 400 errors.

The Insights_Section_Subscribers stub is expanded to:
  - load_dependencies(): include_once the 7 Tab 6 PHP files in order
    (interface -> detector -> storage backends -> classifier ->
    orchestrator -> REST controller)
  - register_hooks(): add_action('rest_api_init') to register the
    controller's routes

Permission: manage_options, mirroring the wizard capability so the
data layer is only available to users who can view the tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squiz.Commenting.FunctionComment.MissingParamTag does not follow
{@inheritdoc} for @param resolution, so each windowed storage override
needs explicit @param tags even though the interface already documents
them. Added @param/@return to the 8 windowed methods in each of
HPOS_Storage and Legacy_Storage. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Donation_Product_Classifier::compute_donation_product_ids() was
calling \Newspack\Donations::get_parent_donation_product(), which is
private. Read the option directly via
Donations::DONATION_PRODUCT_ID_OPTION (a public const exposing
'newspack_donation_product_id') instead — same source of truth, no
private-API coupling.

Smoketest confirms both single-window and comparison-mode requests
return the full classification/snapshot/current/previous payload with
donation_product_count = 4 on a configured local site (grouped parent
+ once/month/year children).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api/subscribers.ts:
- Source-of-truth TypeScript types mirroring the PHP response shape:
  SubscribersResponse { classification, snapshot, current, previous },
  with detail types for tenure rows, performance rows, and
  cancellation reason rows.
- fetchSubscribersData(query) builds the URL and dispatches via
  @wordpress/api-fetch. Comparison params are included only when both
  compare_start and compare_end are provided.

hooks/useSubscribersData.ts:
- Owns Tab 6 fetch lifecycle. Refetches whenever range or
  previousRange changes. Request-id guard prevents older slow calls
  from overwriting newer ones on rapid range switches.
- Exposes idle / loading / success / error state plus a manual
  refetch() for future force-refresh UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClassificationBanner: surfaces backend (HPOS vs legacy) +
donation classification (excluded product count, or a muted warning
when no donation family is configured). Renders at the top of the tab
so publishers can verify Insights is reading the right slice.

format.ts: Intl.NumberFormat helpers for number / currency (USD, v1) /
percent / signed-percent delta. formatDelta() returns null when prior
is zero (no defined ratio).

MetricCard: scorecard atom. Label + big value + optional comparison
delta with a11y label and up/down/flat directional class. Composed by
Scorecard and Revenue sections.

ScorecardSection: 6 cards — three snapshots (active subs, MRR, ARR),
two windowed-with-delta (new, churned), one snapshot (upcoming
renewals 30d count).

RevenueSection: 4 cards — gross / net revenue, refund rate, failed
payment retry rate. All windowed with delta vs previous window when
comparison mode is on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TenureSection: computes box-plot stats (p25 / median / p75) and
day-bucket counts client-side from the raw per-active-sub
distribution returned by the server. Renders summary stats + a
horizontal bar list for buckets (0-30, 31-90, 91-180, 181-365, 365+).

PerformanceSection: top-50 products by active subscribers, rendered
as a numeric table (active subs, churned subs, active value, lifetime
revenue). Server applies the limit and the descending sort; no
client-side sorting in v1. Lifetime revenue is the documented v1
approximation (sum of renewal-amount rows).

CancellationReasonsSection: bucketed reasons with horizontal bars.
'unknown' is i18n'd; other slugs are humanized (underscores ->
spaces, title case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the "Coming soon" stub. Calls useSubscribersData on the
active range + comparison range, then composes:
  - ClassificationBanner (top)
  - ScorecardSection (active subs, MRR, ARR, new, churned, upcoming)
  - RevenueSection (gross, net, refund rate, retry rate)
  - TenureSection (box-plot stats + buckets)
  - PerformanceSection (top-50 product table)
  - CancellationReasonsSection (bar list)

Local loading and error states. Wizard chrome (date picker, comparison
toggle, tab nav) stays interactive in all states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-formatted by Prettier (line widths, JSX spacing) and added the
two missing /* translators: */ comments above the p25/p75 sprintf
calls in TenureSection. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kmwilkerson and others added 19 commits June 3, 2026 20:09
Tab-local SCSS imported by SubscribersTab so it code-splits with the
tab bundle. Covers:

- subscribers tab container (vertical stack, 24px gap)
- loading / error states
- classification banner (left-border note, 2 rows)
- generic section container + heading + empty state
- responsive metric grid (auto-fit minmax 200px)
- metric card with delta colors (up: green, down: red, flat: gray)
- tenure stats summary (inline dl)
- bar list (label | bar | value 3-col grid) shared by tenure +
  cancellation reasons
- performance table with right-aligned tabular-nums numeric columns

Uses @wordpress/base-styles colors and var(--wp-admin-theme-color)
per the chrome's conventions.

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

Per user feedback:

- Remove the Settings link from the InsightsWizard header. It was
  wired to admin.php?page=newspack-settings but felt out of place.
  The settingsUrl boot config field stays for now in case it's
  revived; only the rendering is removed.
- Remove the ClassificationBanner from SubscribersTab and delete the
  component + its dead SCSS rules. Backend metadata
  (classification.backend, donation_product_count) still ships in the
  REST payload for future surfacing.
- Replace "in window" / "previous window" with "in selected
  timeframe" / "previous timeframe" across Scorecard, Revenue,
  Cancellation Reasons, and the MetricCard delta aria-label —
  "timeframe" reads more naturally than "window" in user-facing copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab 6 SQL had a real design bug. The 9 subscription-side queries
JOINed `wc_order_product_lookup` to find which products were in each
subscription, but verified against production data (Block Club
Chicago: 39,461 shop_order rows / 0 shop_subscription rows; Richland
Source: 13,279 / 0) the lookup table is shop_order-only by Woo's
design — subscription line items are never indexed there. Every
subscription metric was returning 0 even on populated sites.

Refactored to JOIN through `woocommerce_order_items` +
`woocommerce_order_itemmeta._product_id`, which contain line items
for every order type including shop_subscription.

Affected methods (all now use the new join pattern):
  - get_active_non_donation_subscribers
  - get_new_subscribers_in_window
  - get_churned_subscribers_in_window
  - get_mrr
  - get_subscription_tenure_distribution
  - get_upcoming_renewals_30d
  - get_failed_payment_retry_rate
  - get_performance_by_product
  - get_cancellation_reasons

Unchanged (correctly used opl, which IS populated for shop_orders):
  - get_subscription_revenue_gross
  - get_subscription_revenue_net
  - get_subscription_refund_rate

For aggregate metrics (MRR, upcoming value, retry attempts,
cancellation reason counts) the non-donation filter is wrapped in a
DISTINCT order_id sub-select so subscriptions with multiple
non-donation line items aren't multi-counted into the SUM.

Verified end-to-end on local test data (86 subscriptions, 60
customers, 18-month spread):
  active_subscribers: 35  (was 0)
  mrr: 738.33             (was 0)
  arr: 8860               (was 0)
  new_subscribers: 8      (was 0)
  churned_subscribers: 5  (was 0)
  performance rows: 4     (was 0)
  cancellation reasons: 1 (was 0)
  tenure rows: 51         (was 0)
  upcoming count: 3       (was 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root cause and refactor as the prior HPOS commit. The 9
subscription-side queries previously JOINed
{prefix}wc_order_product_lookup, which production data confirms is
shop_order-only on both backends (Block Club Chicago 39,461 / 0;
Richland Source 13,279 / 0) — opl never holds shop_subscription rows
regardless of HPOS vs legacy.

Refactored to JOIN through {prefix}woocommerce_order_items +
{prefix}woocommerce_order_itemmeta._product_id. These line-item tables
pre-date HPOS and contain line items for every order type on both
backends, so the join pattern is identical to the HPOS implementation
— only the order-row table differs (posts vs wc_orders).

Same DISTINCT id-subselect pattern wraps the non-donation filter on
aggregate metrics (MRR, upcoming value, retry attempts, cancellation
reason counts) so multi-line-item subscriptions don't multi-count.

Revenue methods (gross / net / refund_rate) continue to use opl
because they correctly operate on shop_order line items, which IS
where opl is populated by Woo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser screenshot showed an apparent discrepancy: scorecard reads 35,
Performance table active subs sum to 51. Diagnosed against the local
test dataset:

  active_distinct_customers: 35  (scorecard query)
  active_subscription_rows:  51  (table per-row count)
  customers with >1 active sub: 14  (e.g. customer 33 has 3, customer 8 has 3)

Both queries are correct. The numbers measure different things:

  - Scorecard "Active subscribers" counts distinct customers — one
    person with two active subs counts once.
  - Performance table "Active subs" column counts subscription
    records per product — one customer can appear in multiple rows.

Updated copy to surface the distinction:

  - Scorecard description was "Non-donation, right now". Now:
    "Distinct customers with at least one active non-donation
    subscription".
  - Performance section gains a caption above the table:
    "Active subscriptions per product (subscriptions, not unique
    customers). A customer with two active subscriptions counts in
    both products' rows."

Also tightened two empty-state strings per the spec's tone:
  - Performance: "No subscription products configured yet."
  - Tenure: "No subscribers yet — tenure data will appear once
    subscriptions exist."

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

Prior commit's edit on this block silently failed because Prettier had
reformatted it. Applying the same description swap directly:
"Non-donation, right now" -> "Distinct customers with at least one
active non-donation subscription".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns Tab 6 to ~/Sites/insights-docs/component-design-spec.md and
introduces visual hierarchy across scorecards.

Card chrome (spec section "Card chrome"):
- Cards are now the white surfaces. Previously sections were white
  with gray-filled cards inside — opposite of spec. Sections become
  pure spacing (no background, no border, 32px gap between).
- Cards: bg #fff, border 1px solid wp-colors.$gray-200, radius 4px,
  padding 24px 28px, min-height 160px. Performance table wrap follows
  the same chrome with the table filling (padding 0 on wrapper).

Type scale (spec section "Type scale"):
- Card label: 12px / 600 / uppercase / letter-spacing 0.05em /
  wp-colors.$gray-700.
- Primary scorecard value: 44px / 600 / line-height 1.05 /
  letter-spacing -0.01em (spec value-lg).
- Secondary scorecard value: 32px / 600 (spec value-md).
- Description: 14px / 400 / line-height 1.4 / wp-colors.$gray-700.
- Tabular nums everywhere numeric.
- Section heading bumped 16->18px / 600 (visual separation directive
  was to bump headers OR add divider — picked headers, per spec).

Scorecard hierarchy:
- MetricCard gets a `primary` prop. Primary cards: Active subscribers
  + MRR (scorecard) and Net revenue (revenue). All others stay at the
  smaller secondary 32px size.

Comparison delta tone:
- Renamed deltaDirection -> deltaTone with positive/negative/neutral
  semantics (previously up/down/flat). MetricCard gets a
  `lowerIsBetter` prop that flips the mapping for metrics where a
  decrease is the desired direction. Applied to: Churned subscribers,
  Refund rate. Verified: current=1 / prev=4 churned now renders as
  positive (green), not negative (red).

Empty states applied to Performance + Tenure to match the
CancellationReasons pattern. Empty rendering also gets card chrome so
it doesn't look like a chrome-less mid-page paragraph.

LastUpdated TODO: left in place with NPPD-1605 reference. Currently
config.lastUpdated is always null because REST endpoints don't surface
computed_at; the SWR cache layer in NPPD-1605 will wire it.

Used wp-colors.$alert-green / $alert-red instead of the spec's
newspack-colors.$success-600 / $error-600 to match the chrome's
import surface (which only pulls @wordpress/base-styles). Documented
inline; swap when newspack-colors gets wired into this stylesheet.

Verified:
- PHPCS clean
- ESLint clean (28 remaining warnings all in unrelated newspack/ files)
- Build green
- Endpoint regression: 35 active, $738 MRR — matches pre-polish
- Comparison + lowerIsBetter semantics correct end-to-end

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

Resolves the four issues raised on the polish-pass screenshot:

Typography consistency:
- Add explicit font stack (-apple-system, BlinkMacSystemFont, ...) on
  the metric-card value so the chrome's inherited body font can't
  flip between cards depending on character mix. Also set
  font-style: normal (defensive), font-synthesis: none (block fake
  bold/italic), font-feature-settings: "tnum" 1 (paired with
  tabular-nums for explicit OpenType control). Eliminates the
  reported "slim sans-serif" vs "italic-flavored slant" vs "third
  distinct weight" rendering across cards.

Primary scorecard hierarchy:
- Keep spec's value-lg 44px / value-md 32px size split (per Tab 6
  polish prompt) but add a 3px brand-color border-top to primary
  cards (Active subscribers, MRR, Net revenue) so the hierarchy is
  unambiguous at any zoom level. The size delta alone was visible
  but easy to miss in screenshots.

Cancellation reasons bar denominator:
- Bars now scale relative to the active subscriber count, not the
  max reason count. A single cancellation among 35 active subs now
  renders as ~3% width instead of 100% — communicating "rare"
  instead of "dominant." Falls back to max-reason scaling when
  activeSubscribers is 0 (no active base to compare against).
  Visual width clamped to [0, 100] for the edge case where window
  churn exceeds current active base.

"non-donation" copy removed:
- Two scorecard descriptions and two component docstrings updated.
  The tab scope is already non-donation-only by design, so the
  qualifier was redundant in the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 44px primary / 32px secondary size split made equal values look
unequal: the side-by-side $100 gross vs $100 net rendered as visibly
different magnitudes, defeating the at-a-glance comparison between
cards in the same row.

All scorecard values now render at the spec's value-lg 44px size.
Primary scorecards (Active subscribers, MRR, Net revenue) keep the
3px brand-color top border as the only hierarchy cue — the accent
draws the eye without distorting the visual weight of the value
itself.

This also brings the implementation back in line with the spec, which
defines a single value-lg size for all scorecards rather than separate
primary/secondary sizes.

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

Per design feedback, all six scorecards now share identical chrome
and internal layout:

- Brand-color top accent applied to every card (not just primary)
  so the row reads as one coherent unit.
- Hero number vertically + horizontally centered in the middle of
  the card via a new __metric-card-body flex container with
  justify-content/align-items center.
- Description pinned to the bottom edge of the card so explainer
  text aligns across cards regardless of label or description height.
- Label stays top-anchored (uppercase chip per spec).
- All values render at 44px (spec value-lg). No primary/secondary
  size split — the size disparity defeated at-a-glance comparison
  between equal values like $100 gross vs $100 net.

MetricCard structure adds a `__metric-card-body` wrapper around the
value + delta so they center together. The `primary` prop is removed
since hierarchy is gone; `lowerIsBetter` stays — it controls delta
tone and is independent of the layout change.

Min-height bumped from 160px to 180px to give the centered value
breathing room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit centered the hero vertically AND horizontally inside
the card body. User feedback: keep horizontal centering but anchor
the hero to the TOP of the body region (interpreting "left aligned
vertically" as the vertical analog of left — i.e., top-aligned).

Change: body wrapper switches justify-content from center to
flex-start. The hero now sits just under the label with a 16px
breathing gap, and the description stays pinned to the bottom via
flex: 1 on the body. Cards still line up horizontally because the
label height + body padding are uniform across cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: centering was the opposite of what was wanted.
Everything in the card now reads from the left edge:

- Body wrapper: align-items center -> flex-start
- Value: text-align center -> left
- Description: text-align center -> left

Label was already left-aligned. The accent line, vertical anchoring
(hero at top, description at bottom), and min-height stay as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Variable label wrap was shifting hero numbers out of vertical
alignment across cards. "ACTIVE SUBSCRIBERS" fit on one line, but
"MONTHLY RECURRING REVENUE" wrapped to two, pushing the hero number
~17px lower than its neighbors.

Fix: set explicit line-height (1.4) on the label and reserve a
min-height of 2 × line-height. Single-line labels now occupy the
same vertical space as two-line labels, so hero numbers line up
horizontally across the row regardless of label length.

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

- Hero value font-weight 600 -> 500. The 600 felt too heavy for the
  44px size.
- Description gets a 16px margin-top so it's guaranteed to sit a
  comfortable distance below the hero, even when the body region
  flexes tight.
- Remove the Cancellation Reasons section from the UI. Publisher
  data on cancellation reasons is sparse (most cancellations bucket
  as "unknown"), so the section wasn't pulling its weight. Deleted
  the React component and its render call from SubscribersTab.

The storage layer's get_cancellation_reasons method and the REST
response's `cancellation_reasons` field stay in place — cheap to
keep, surfaces if a future tab wants the same data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The histogram duplicated the information already shown in the
percentile callouts above it. Removing it simplifies the section
and removes the visual competition for attention.

The backend storage method get_subscription_tenure_distribution()
is preserved for potential v1.1 tenure visualization revival.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson changed the title Insights: Tab 6 — Subscribers (NPPD-1616) feat(insights): subscribers tab (NPPD-1616) Jun 4, 2026
kmwilkerson and others added 6 commits June 3, 2026 23:00
get_performance_by_product() in both storage classes accepted
DateTimeInterface $start/$end parameters but the SQL never used them.
The orchestrator's cache key included the window, so every distinct
date range allocated a new transient holding identical data.

Per code review, the four columns have different temporal scopes:

  active_subs       — current state (correctly window-independent)
  active_value      — current state (correctly window-independent)
  lifetime_revenue  — lifetime sum, intentionally not windowed; true
                      LTV waits on the v1.1 BQ wrapper
  churned_subs      — SHOULD be windowed; this commit fixes the bug

Added a LEFT JOIN to the `_schedule_cancelled` meta (wc_orders_meta
on HPOS, postmeta on legacy) and wrapped the churned-count CASE with
a `sch.meta_value BETWEEN %s AND %s` predicate. Active subscriptions
don't have this meta, so the left-joined row is NULL and the CASE
naturally rejects them. Woo writes at most one _schedule_cancelled
row per subscription, so no row multiplication. Window dates pass
through $wpdb->prepare.

Column scope is now documented at the top of each query body.

Verified end-to-end against local test data:
  6-month window:  Captain 7 churned, Boss 4, Ambassador 3
  1-month window:  Captain 1 churned, Boss 0, Ambassador 0
  active_subs and lifetime_revenue identical across both windows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous CASE only covered a small set of explicit period×interval
combos and fell through to `total_amount` for unknown configurations.
That fallthrough was the opposite of conservative — a biennial
subscription (year × 2) was recorded at the full annual amount as if
it were a monthly contribution, inflating MRR by 24x.

New CASE covers all documented Woo billing periods at any positive
integer interval N:

  day   × N -> total *  30      / N    (30-day month)
  week  × N -> total * (52/12)  / N    (4.333 weeks per month)
  month × N -> total            / N
  year  × N -> total / (12 * N)

The ELSE branch is now truly conservative: falls through to
`total / 12`, which undercounts any non-yearly mis-configuration
rather than inflating it.

Added a diagnostic query that counts active non-donation
subscriptions whose `_billing_period` is not in
('day','week','month','year') OR whose `_billing_interval` casts to 0.
If any exist, logs via Newspack\Logger ('NEWSPACK-INSIGHTS' header) so
the publisher can correct product configuration. The diagnostic
benefits from the same orchestrator-level cache as MRR itself, so the
extra query only runs once per cache window.

Applied to both HPOS_Storage and Legacy_Storage. Verified end-to-end:
local test data is all (month × 1) + (year × 1), so MRR remains
$738.33 — same as before but now mathematically defensible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Donation_Product_Classifier had a flush_cache() method but nothing
in the codebase invoked it on relevant Woo configuration changes.
Publishers reconfiguring donation products would see stale Tab 6 data
for up to one hour while waiting for the 1h TTL.

Added Donation_Product_Classifier::register_hooks() wiring:
  - update_option_newspack_donation_product_id -> flush_cache
  - added_post_meta / updated_post_meta / deleted_post_meta on the
    _newspack_is_donation flag -> flush_cache

The post_meta hooks fire site-wide so the callback filters by
meta_key and early-returns on mismatches.

Insights_Section_Subscribers::register_hooks() now calls
Donation_Product_Classifier::register_hooks() during the tab boot.

Verified end-to-end: all three meta hooks fire with correct key
filtering on real product meta changes (added/updated/deleted of
_newspack_is_donation triggers flush; unrelated keys are skipped).
Option-change hook also fires correctly. The local dev env's object
cache backend has a known delete bug (sets and deletes silently
no-op while values persist in memory), but the callbacks themselves
are invoked correctly and the delete_transient() calls will work
normally in production with a healthy memcached / redis backend.

Also rewrote the MRR comment as a /* */ block with a localized
phpcs:disable so the prose describing the billing math doesn't keep
triggering Squiz.PHP.CommentedOutCode.Found heuristics.

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

Variable subscription products (the standard monthly/annual variants of
a single membership tier) previously lost their breakdown. The
Performance by product table grouped at the line-item product level,
which in Woo's data model is the parent ID for variations — so a publisher
with Captain Monthly + Captain Annual saw a single Captain row with
silently summed totals.

Both storage classes now query at the resolved-variation level by
COALESCEing `_variation_id` over `_product_id` in the line-item meta
join. Woo writes the PARENT id into `_product_id` and the actual
variation id into `_variation_id` for variable products; the COALESCE
resolves to the variation when present and the standalone product
otherwise. The donation filter continues to read `_product_id` because
the donation set is keyed by the parent in WC's data model.

PHP aggregation reshapes the flat per-variation rows into a parent +
nested variations structure. Each parent entry carries `variations`
sorted by active_subs DESC; standalone products have no `variations`
key. Math reconciles end-to-end:

  Captain: parent 20 active = Annual 12 + Monthly 8
           parent 7 churned = Annual 5 + Monthly 2
           parent $1296 active value = Annual $1200 + Monthly $96
           parent $2144 lifetime = Annual $2000 + Monthly $144

Variation labels come from `_subscription_period`: month→Monthly,
year→Annual, week→Weekly, day→Daily. Fallbacks: variation post_title
with parent prefix stripped, then a generic "Variation" string.

The aggregation + label helpers are duplicated across HPOS_Storage and
Legacy_Storage rather than extracted to a trait — they're pure
transformation with no backend-specific logic and Newspack convention
favors duplication over premature abstraction.

Storage_Interface docblock + Subscribers_Metric cache prefix bumped to
v2 (cached shape change).

React:
- PerformanceRow type updated; new PerformanceVariationRow.
- PerformanceSection wraps each parent + its variations in a Fragment
  and renders each variation as a `--variation` row with `gray-600`
  text and a `padding-left: 44px` Product cell so the indent reads at
  a glance. Standalone products render as a single row (no extra rows
  after them).

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

Three small fixes to the nested performance table:

1. Variation row indent was not applying. The `&-cell--indented`
   modifier class had specificity (0,1,0), losing to the base
   `.table td` rule (0,2,1) — so `padding-left: 44px` never reached
   the cell. Rewrote the indent as `&-row--variation td:first-child`
   (0,2,2), which wins the cascade cleanly. Dropped the now-dead
   `&-cell--indented` className from PerformanceSection too.

2. Variation text was rendering too faded (wp-colors.$gray-600 felt
   like disabled/placeholder text against the gray-900 parents).
   Bumped to wp-colors.$gray-700 — subordinate to parents but still
   clearly part of the same data table.

3. PHPCS CI failure: Squiz inline-comment sniff flagged the bulleted
   prose in the HPOS performance query comment ("Expected 1 space
   before comment text but found 3"). Converted that block from `//`
   lines with indented bullets to a `/* */` block comment so the
   list structure survives PHPCS.

Churned-subs zeros are NOT a regression: verified the test data has
no `_schedule_cancelled` dates in the trailing 30 days (latest dated
cancellation is May 4, 2026). The windowed churn count is correct.

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

Per the pre-Tab-7 audit. Two extractions, both mechanical, no
behavior change.

1. MetricCard + format helpers moved from tabs/subscribers/ to a
   new tabs/components/ directory. Both are generic — MetricCard's
   props were already free of any subscriber-specific shape and the
   format helpers (currency / number / percent / delta / tone) are
   pure pass-through utilities every future tab will need.

   Updated import paths:
     ScorecardSection  -> '../components/MetricCard'
     WindowedSection   -> '../components/MetricCard'
     PerformanceSection -> '../components/format'
     MetricCard's internal './format' import stays relative (same
       new directory).

2. tabs/subscribers/subscribers.scss was ~80% generic Insights chrome
   despite living in a tab-scoped file. Split into:

     tabs/components/sections.scss
       - __tab-loading, __tab-error, __tab-error-detail
       - __section, __section-heading, __section-caption,
         __section-empty
       - __metric-grid
       - __metric-card and all its sub-rules (-label, -body,
         -value, -delta with tones, -description)
       - __table-wrap and __table (incl. -num, the
         __table-row--variation + td:first-child indent pattern)

     tabs/subscribers/subscribers.scss (now slim, Tab 6 only)
       - __subscribers-tab orchestrator wrapper
       - __tenure-card (the percentile callouts container)
       - __stats-summary (the dl layout inside the tenure card)
       - __tenure-narrative

   style.scss now @use's the shared sections.scss so the chrome
   ships once with the main wizard bundle (insights-wizard.css)
   instead of inside the lazy-loaded subscribers chunk. Verified:
   metric-card / section / table / variation-row rules land in
   insights-wizard.css; tenure-card lands in the subscribers chunk
   (3352.css).

Tab 7 will inherit the chrome without importing it and only ship
its own tab-specific styles in its own lazy chunk.

Verified: build green, lint clean, REST endpoint unchanged
(active=35, mrr=738, performance rows=4 with the same nested
variation structure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squiz.Commenting.BlockComment.NoEmptyLineBefore fires when a `/*`
block comment follows a `//` line-comment block without an empty
line separating them. CI's PHPCS catches it; the local pass missed
it because the same file was downstream of another fixed instance
on the Tab 7 branch and didn't reproduce there.

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