feat(insights): subscribers tab (NPPD-1616)#217
Draft
kmwilkerson wants to merge 56 commits into
Draft
Conversation
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>
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>
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>
9 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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.
What's in this PR
Storage abstraction layer
Storage_Interfacedefining the per-metric query contractHPOS_StorageandLegacy_Storageimplementations dispatching bywoocommerce_custom_orders_table_enabledStorage_Detectorcaching the backend detection result (24h TTL)Donation product classifier
Donation_Product_Classifierwrapping the canonical\Newspack\Donations::is_donation_product()andis_donation_order()methodsNOT INfilters across subscription queriesSubscribers metric and REST endpoint
Subscribers_Metricorchestrator with per-method WP transient caching (30min / 60min TTL for v1; will migrate to NPPD-1605 cache table when that lands)GET /newspack-insights/v1/subscribersreturning 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
SubscribersTabwith four sections: at-a-glance scorecards, time-windowed metrics, subscriber tenure, performance by productComparison mode
lowerIsBettersemantics for churn count and refund rate (drops render green, increases render red)How to test
NEWSPACK_INSIGHTS_ENABLEDconstant inwp-config.phpwp-admin/admin.php?page=newspack-insights