From df7ccc714a90fece4675bed6a07961cd5143da56 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 09:30:51 -0500 Subject: [PATCH 01/37] feat(insights): add Donors_Storage_Interface for Tab 7 (NPPD-1617) Contract for the Tab 7 per-backend data layer, mirroring the Tab 6 {@see Storage_Interface} pattern. Two implementations will follow (HPOS, legacy CPT) and dispatch via the shared Storage_Detector. 11 methods cover the user-spec metric list: current state: get_active_donors (UNION recurring + trailing-365 one-time) get_active_recurring_donors get_donation_mrr window-scoped: get_new_donors_in_window get_lapsed_donors_in_window get_one_time_donation_revenue get_recurring_donation_revenue get_average_donation_gift retention: get_lapsed_donor_recovery_rate get_recurring_donor_retention per-tier: get_donations_by_tier (parent + nested variations) ARR (MRR x 12) and Total revenue (one-time + recurring) are derived in the orchestrator, not stored. Cancellation Reasons / refund rate / cohort retention are deferred per scope. Documents per-query join surface (shop_order-scoped vs shop_subscription-scoped) per the schema doc's verified opl behavior. The Active Donors UNION metric crosses both surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../class-donors-storage-interface.php | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php new file mode 100644 index 000000000..634c390b1 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -0,0 +1,211 @@ + :start). The end check is + * "currently active" (NOW), not "active at :end" — a v1 + * simplification documented inline on the query. + * + * @param DateTimeInterface $start Current window start. + * @param DateTimeInterface $end Current window end (used for + * cache-key disambiguation only). + * @return float + */ + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float; + + /** + * Per-product donor performance breakdown. One entry per parent + * donation product (or standalone product), sorted by active + * recurring donor count descending, top 50. Parent entries carry + * a `variations` array with one entry per variation, sorted by + * active recurring donor count descending. + * + * Columns per entry: + * + * [ + * 'product_id' => int, + * 'name' => string, + * 'is_parent' => bool, + * 'active_recurring_donors' => int, + * 'new_donors_in_window' => int, + * 'one_time_gifts_in_window' => int, + * 'recurring_revenue_in_window' => float, + * 'lifetime_donation_revenue' => float, + * 'variations' => [ + * [ + * 'variation_id' => int, + * 'label' => string, // 'Monthly' / 'Annual' / etc + * 'active_recurring_donors' => int, + * 'new_donors_in_window' => int, + * 'one_time_gifts_in_window' => int, + * 'recurring_revenue_in_window' => float, + * 'lifetime_donation_revenue' => float, + * ], + * ... + * ], + * ] + * + * `*_in_window` columns are window-scoped to `[start, end]`. + * `active_recurring_donors` and `lifetime_donation_revenue` are + * current state / lifetime respectively. Parent aggregates equal + * the SUM of their variations. + * + * @param DateTimeInterface $start Inclusive window start. + * @param DateTimeInterface $end Inclusive window end. + * @return array> + */ + public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterface $end ): array; +} From 54e4225f583e8c4315b2e509f450dc6bb7f7dda8 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 09:35:28 -0500 Subject: [PATCH 02/37] feat(insights): HPOS_Donors_Storage implementation (NPPD-1617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 11 methods from the Tab 7 storage contract, mirroring HPOS_Storage (Tab 6) but with the donation IN filter and the metric set the user prompt enumerated: get_active_donors — UNION of (active recurring) + (trailing-365 one-time), dedup by customer_id get_active_recurring_donors — distinct customers with a wc-active donation subscription get_donation_mrr — product-meta-driven normalized to monthly (per formula doc) with full day/week/month/year × N coverage and the Tab 6 conservative /12 fallback get_new_donors_in_window — first-ever donation in window get_lapsed_donors_in_window — Tab 6 churn pattern scoped to donations (recurring lapsed) get_one_time_donation_revenue } shared private helper splits get_recurring_donation_revenue } by _subscription_period presence get_average_donation_gift — AVG of donation shop_order totals get_lapsed_donor_recovery_rate — computes prior window of equal length, queries that cohort, counts recoveries in current window get_recurring_donor_retention — active at :start (start_meta <=, cancel_meta empty or >) divided across to currently-active. Simplified "still active at end" to NOW. get_donations_by_tier — two-pass query (subscription pass for active recurring donors + shop_order pass for the four window-scoped / lifetime metrics), merged by variation_id, then aggregated to parent + nested variations via the Tab 6 pattern (COALESCE _variation_id over _product_id; per-period labels). SQL safety per existing Tab 6 conventions: interpolated donation IDs pass through intval; $wpdb->prefix is trusted; date params go through $wpdb->prepare with %s. phpcs:disable for the direct DB query sniff group is justified for this analytics layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../storage/class-hpos-donors-storage.php | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php new file mode 100644 index 000000000..b1aa96664 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -0,0 +1,777 @@ +donation_product_ids = array_map( 'intval', $donation_product_ids ); + } + + /** + * SQL-safe `IN (...)` list from integer IDs. Empty -> `0`. + * + * @param int[] $ids Integer IDs. + * @return string Comma-separated integers. + */ + private function id_list( array $ids ): string { + if ( empty( $ids ) ) { + return '0'; + } + return implode( ',', array_map( 'intval', $ids ) ); + } + + /** + * Format a DateTime for SQL. + * + * @param DateTimeInterface $dt DateTime. + * @return string Y-m-d H:i:s. + */ + private function fmt( DateTimeInterface $dt ): string { + return $dt->format( 'Y-m-d H:i:s' ); + } + + /** + * {@inheritDoc} + */ + public function get_active_donors(): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // UNION of two paths, deduped by customer_id. Path (a): any + // customer with a wc-active donation subscription. Path (b): + // any customer with a completed donation shop_order in the + // trailing 365 days. + $sql = "SELECT COUNT(*) FROM ( + SELECT DISTINCT o.customer_id + FROM {$prefix}wc_orders o + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND oim.meta_value IN ($donations) + UNION + SELECT DISTINCT o.customer_id + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = o.id + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt >= DATE_SUB(NOW(), INTERVAL 365 DAY) + AND opl.product_id IN ($donations) + ) AS active_donor_set + WHERE customer_id > 0"; + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + */ + public function get_active_recurring_donors(): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = "SELECT COUNT(DISTINCT o.customer_id) + FROM {$prefix}wc_orders o + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND oim.meta_value IN ($donations)"; + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * Per the formula doc's per-line-item attribution model: reads + * frequency from the product's `_subscription_period` / + * `_subscription_period_interval` rather than the subscription's + * own billing meta. For multi-line-item donation subscriptions + * (rare for canonical Newspack family) each line item contributes + * its own per-line-item MRR. + */ + public function get_donation_mrr(): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = "SELECT SUM( + CASE + WHEN prd.meta_value = 'month' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN o.total_amount / CAST(pri.meta_value AS UNSIGNED) + WHEN prd.meta_value = 'year' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN o.total_amount / (12 * CAST(pri.meta_value AS UNSIGNED)) + WHEN prd.meta_value = 'week' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN o.total_amount * (52/12) / CAST(pri.meta_value AS UNSIGNED) + WHEN prd.meta_value = 'day' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN o.total_amount * 30 / CAST(pri.meta_value AS UNSIGNED) + ELSE o.total_amount / 12 + END + ) + FROM {$prefix}wc_orders o + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + JOIN {$prefix}postmeta prd + ON prd.post_id = CAST(oim.meta_value AS UNSIGNED) AND prd.meta_key = '_subscription_period' + JOIN {$prefix}postmeta pri + ON pri.post_id = CAST(oim.meta_value AS UNSIGNED) AND pri.meta_key = '_subscription_period_interval' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND oim.meta_value IN ($donations)"; + + return (float) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_new_donors_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // First donation MIN per customer; outer count filters to window. + $sql = $wpdb->prepare( + "SELECT COUNT(*) FROM ( + SELECT o.customer_id, MIN(o.date_created_gmt) AS first_donation_date + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = o.id + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed', 'wc-processing') + AND opl.product_id IN ($donations) + GROUP BY o.customer_id + ) AS first_donations + WHERE first_donation_date BETWEEN %s AND %s", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_lapsed_donors_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Tab 6 churn pattern scoped to donation products: customers + // whose donation subscriptions cancelled/expired in window AND + // who currently have no active donation subscription. + $sql = $wpdb->prepare( + "SELECT COUNT(DISTINCT cancellations.customer_id) FROM ( + SELECT o.customer_id + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE o.type = 'shop_subscription' + AND o.status IN ('wc-cancelled', 'wc-expired') + AND oim.meta_value IN ($donations) + AND om.meta_value BETWEEN %s AND %s + AND om.meta_value != '' + ) AS cancellations + WHERE cancellations.customer_id NOT IN ( + SELECT DISTINCT o2.customer_id + FROM {$prefix}wc_orders o2 + JOIN {$prefix}woocommerce_order_items oi2 + ON oi2.order_id = o2.id AND oi2.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim2 + ON oim2.order_item_id = oi2.order_item_id AND oim2.meta_key = '_product_id' + WHERE o2.type = 'shop_subscription' + AND o2.status = 'wc-active' + AND oim2.meta_value IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_one_time_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return $this->get_donation_revenue_filtered( $start, $end, 'one_time' ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_recurring_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return $this->get_donation_revenue_filtered( $start, $end, 'recurring' ); + } + + /** + * Shared body for one-time vs recurring donation revenue. Filters + * by the presence/absence of a `_subscription_period` postmeta on + * the order's donation line-item product. One-time = no period + * meta or meta NOT IN month/year/week/day. Recurring = period meta + * IN month/year/week/day. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @param string $mode 'one_time' | 'recurring'. + * @return float + */ + private function get_donation_revenue_filtered( DateTimeInterface $start, DateTimeInterface $end, string $mode ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $period_predicate = 'recurring' === $mode + ? "EXISTS (SELECT 1 FROM {$prefix}postmeta pm + WHERE pm.post_id = opl.product_id + AND pm.meta_key = '_subscription_period' + AND pm.meta_value IN ('day','week','month','year'))" + : "NOT EXISTS (SELECT 1 FROM {$prefix}postmeta pm + WHERE pm.post_id = opl.product_id + AND pm.meta_key = '_subscription_period' + AND pm.meta_value IN ('day','week','month','year'))"; + + $sql = $wpdb->prepare( + "SELECT SUM(o.total_amount) + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = o.id + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt BETWEEN %s AND %s + AND opl.product_id IN ($donations) + AND $period_predicate", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (float) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_average_donation_gift( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = $wpdb->prepare( + "SELECT AVG(o.total_amount) + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = o.id + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt BETWEEN %s AND %s + AND opl.product_id IN ($donations)", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $avg = $wpdb->get_var( $sql ); + return null === $avg ? 0.0 : (float) $avg; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + // Prior window of equal length immediately preceding current. + $duration = $end->getTimestamp() - $start->getTimestamp(); + $prior_end_ts = $start->getTimestamp() - 1; + $prior_start_ts = $prior_end_ts - $duration; + $prior_start_iso = gmdate( 'Y-m-d H:i:s', $prior_start_ts ); + $prior_end_iso = gmdate( 'Y-m-d H:i:s', $prior_end_ts ); + $current_start_iso = $this->fmt( $start ); + $current_end_iso = $this->fmt( $end ); + + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Lapsed-in-prior cohort (same shape as get_lapsed_donors_in_window + // but with explicit prior window bounds rather than current window). + $lapsed_sql = $wpdb->prepare( + "SELECT DISTINCT cancellations.customer_id + FROM ( + SELECT o.customer_id + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE o.type = 'shop_subscription' + AND o.status IN ('wc-cancelled', 'wc-expired') + AND oim.meta_value IN ($donations) + AND om.meta_value BETWEEN %s AND %s + AND om.meta_value != '' + ) AS cancellations + WHERE cancellations.customer_id NOT IN ( + SELECT DISTINCT o2.customer_id + FROM {$prefix}wc_orders o2 + JOIN {$prefix}woocommerce_order_items oi2 + ON oi2.order_id = o2.id AND oi2.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim2 + ON oim2.order_item_id = oi2.order_item_id AND oim2.meta_key = '_product_id' + WHERE o2.type = 'shop_subscription' + AND o2.status = 'wc-active' + AND oim2.meta_value IN ($donations) + )", + $prior_start_iso, + $prior_end_iso + ); + + $lapsed_customer_ids = $wpdb->get_col( $lapsed_sql ); + if ( empty( $lapsed_customer_ids ) ) { + return 0.0; + } + $lapsed_count = count( $lapsed_customer_ids ); + $lapsed_list = $this->id_list( array_map( 'intval', $lapsed_customer_ids ) ); + + // Of the lapsed cohort, who made a NEW completed donation order + // in the current window. + $recovered_sql = $wpdb->prepare( + "SELECT COUNT(DISTINCT o.customer_id) + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = o.id + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt BETWEEN %s AND %s + AND opl.product_id IN ($donations) + AND o.customer_id IN ($lapsed_list)", + $current_start_iso, + $current_end_iso + ); + $recovered = (int) $wpdb->get_var( $recovered_sql ); + + return $recovered / $lapsed_count; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end (unused — see docblock). + * @return float + */ + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + unset( $end ); // included in cache key by orchestrator; SQL uses NOW for the "still active" check. + + // Subscriptions active at :start (subscription start <= :start + // AND not cancelled before :start). The CTE yields one row per + // (customer, subscription) pair. + $active_at_start_sql = $wpdb->prepare( + "SELECT DISTINCT o.customer_id, o.id AS subscription_id, o.status + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta start_meta + ON start_meta.order_id = o.id AND start_meta.meta_key = '_schedule_start' + LEFT JOIN {$prefix}wc_orders_meta cancel_meta + ON cancel_meta.order_id = o.id AND cancel_meta.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE o.type = 'shop_subscription' + AND oim.meta_value IN ($donations) + AND start_meta.meta_value != '' + AND start_meta.meta_value <= %s + AND ( + cancel_meta.meta_value IS NULL + OR cancel_meta.meta_value = '' + OR cancel_meta.meta_value > %s + )", + $this->fmt( $start ), + $this->fmt( $start ) + ); + $rows = $wpdb->get_results( $active_at_start_sql, ARRAY_A ); + if ( empty( $rows ) ) { + return 0.0; + } + + // Denominator: distinct customers who were active at start. + $customers_active_at_start = array_unique( array_map( 'intval', array_column( $rows, 'customer_id' ) ) ); + $denominator = count( $customers_active_at_start ); + if ( 0 === $denominator ) { + return 0.0; + } + + // Numerator: those customers who still have at least one + // active donation subscription right now. + $customer_list = $this->id_list( $customers_active_at_start ); + $numerator_sql = "SELECT COUNT(DISTINCT o.customer_id) + FROM {$prefix}wc_orders o + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND oim.meta_value IN ($donations) + AND o.customer_id IN ($customer_list)"; + $numerator = (int) $wpdb->get_var( $numerator_sql ); + + return $numerator / $denominator; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + /* + * Returns flat per-variation (or per-simple-product) rows with + * parent info attached. PHP aggregation below rolls + * variations under their parent. The columns produced: + * + * variation_id, variation_name, parent_id, parent_name, + * sub_period (for label generation), active_recurring_donors, + * new_donors_in_window, one_time_gifts_in_window, + * recurring_revenue_in_window, lifetime_donation_revenue + * + * We can't compute all five metrics in one GROUP BY because + * they scope on different order types (shop_subscription for + * active_recurring_donors; shop_order for the rest). Run two + * passes and merge by product_id in PHP. + */ + + // Pass 1: subscription-side — active recurring donors per + // (effective) product. + $subs_sql = "SELECT + pv.ID AS variation_id, + pv.post_title AS variation_name, + pv.post_parent AS parent_id, + COALESCE(pp.post_title, '') AS parent_name, + COALESCE(period_meta.meta_value, '') AS sub_period, + COUNT(DISTINCT o.customer_id) AS active_recurring_donors + FROM {$prefix}wc_orders o + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta pid_meta + ON pid_meta.order_item_id = oi.order_item_id AND pid_meta.meta_key = '_product_id' + LEFT JOIN {$prefix}woocommerce_order_itemmeta vid_meta + ON vid_meta.order_item_id = oi.order_item_id AND vid_meta.meta_key = '_variation_id' + JOIN {$prefix}posts pv + ON pv.ID = COALESCE( NULLIF( CAST(vid_meta.meta_value AS UNSIGNED), 0 ), CAST(pid_meta.meta_value AS UNSIGNED) ) + LEFT JOIN {$prefix}posts pp ON pp.ID = pv.post_parent + LEFT JOIN {$prefix}postmeta period_meta + ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND pid_meta.meta_value IN ($donations) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period"; + + $subs_rows = $wpdb->get_results( $subs_sql, ARRAY_A ); + + // Pass 2: shop_order-side metrics — new donors, one-time gifts, + // recurring revenue, lifetime revenue. Keyed by opl.product_id + // (the actual purchased product; no variation indirection + // because opl stores the line-item product directly for + // shop_orders). + $orders_sql = $wpdb->prepare( + "SELECT + pv.ID AS variation_id, + pv.post_title AS variation_name, + pv.post_parent AS parent_id, + COALESCE(pp.post_title, '') AS parent_name, + COALESCE(period_meta.meta_value, '') AS sub_period, + COUNT(DISTINCT CASE + WHEN o.date_created_gmt BETWEEN %s AND %s + AND nd.first_donation_date BETWEEN %s AND %s + THEN o.customer_id + END) AS new_donors_in_window, + COUNT(DISTINCT CASE + WHEN o.date_created_gmt BETWEEN %s AND %s + AND COALESCE(period_meta.meta_value, '') NOT IN ('day','week','month','year') + THEN o.id + END) AS one_time_gifts_in_window, + COALESCE(SUM(CASE + WHEN o.date_created_gmt BETWEEN %s AND %s + AND period_meta.meta_value IN ('day','week','month','year') + THEN o.total_amount + END), 0) AS recurring_revenue_in_window, + COALESCE(SUM(o.total_amount), 0) AS lifetime_donation_revenue + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = o.id + JOIN {$prefix}posts pv ON pv.ID = opl.product_id + LEFT JOIN {$prefix}posts pp ON pp.ID = pv.post_parent + LEFT JOIN {$prefix}postmeta period_meta + ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' + LEFT JOIN ( + SELECT customer_id, MIN(date_created_gmt) AS first_donation_date + FROM {$prefix}wc_orders o2 + JOIN {$prefix}wc_order_product_lookup opl2 ON opl2.order_id = o2.id + WHERE o2.type = 'shop_order' + AND o2.status IN ('wc-completed','wc-processing') + AND opl2.product_id IN ($donations) + GROUP BY customer_id + ) AS nd ON nd.customer_id = o.customer_id + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed','wc-processing') + AND opl.product_id IN ($donations) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period", + $this->fmt( $start ), + $this->fmt( $end ), + $this->fmt( $start ), + $this->fmt( $end ), + $this->fmt( $start ), + $this->fmt( $end ), + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $orders_rows = $wpdb->get_results( $orders_sql, ARRAY_A ); + + // Merge by variation_id. Both passes return the same per-product + // metadata (variation_id, names, parent, period). Fill in zeros + // for products that appear in only one of the two passes. + $by_id = []; + foreach ( $subs_rows as $row ) { + $by_id[ (int) $row['variation_id'] ] = [ + 'variation_id' => (int) $row['variation_id'], + 'variation_name' => (string) $row['variation_name'], + 'parent_id' => (int) $row['parent_id'], + 'parent_name' => (string) $row['parent_name'], + 'sub_period' => (string) $row['sub_period'], + 'active_recurring_donors' => (int) $row['active_recurring_donors'], + 'new_donors_in_window' => 0, + 'one_time_gifts_in_window' => 0, + 'recurring_revenue_in_window' => 0.0, + 'lifetime_donation_revenue' => 0.0, + ]; + } + foreach ( $orders_rows as $row ) { + $id = (int) $row['variation_id']; + if ( ! isset( $by_id[ $id ] ) ) { + $by_id[ $id ] = [ + 'variation_id' => $id, + 'variation_name' => (string) $row['variation_name'], + 'parent_id' => (int) $row['parent_id'], + 'parent_name' => (string) $row['parent_name'], + 'sub_period' => (string) $row['sub_period'], + 'active_recurring_donors' => 0, + ]; + } + $by_id[ $id ]['new_donors_in_window'] = (int) $row['new_donors_in_window']; + $by_id[ $id ]['one_time_gifts_in_window'] = (int) $row['one_time_gifts_in_window']; + $by_id[ $id ]['recurring_revenue_in_window'] = (float) $row['recurring_revenue_in_window']; + $by_id[ $id ]['lifetime_donation_revenue'] = (float) $row['lifetime_donation_revenue']; + } + + return $this->aggregate_tier_rows( array_values( $by_id ) ); + } + + /** + * Aggregate flat per-variation rows into parent + nested + * variations shape. Mirrors the Tab 6 pattern but with Tab 7's + * five-metric column set. + * + * Each parent's variations are sorted by active_recurring_donors + * DESC. The outer list is sorted by aggregated + * active_recurring_donors DESC and truncated to top 50. + * + * @param array> $rows Merged per-variation rows. + * @return array> + */ + private function aggregate_tier_rows( array $rows ): array { + $parents = []; + + foreach ( $rows as $row ) { + $variation_id = (int) $row['variation_id']; + $variation_name = (string) $row['variation_name']; + $parent_id = (int) $row['parent_id']; + $parent_name = (string) $row['parent_name']; + $period = (string) $row['sub_period']; + $active_recurring_donors = (int) $row['active_recurring_donors']; + $new_donors = (int) $row['new_donors_in_window']; + $one_time_gifts = (int) $row['one_time_gifts_in_window']; + $recurring_revenue = (float) $row['recurring_revenue_in_window']; + $lifetime_donation_revenue = (float) $row['lifetime_donation_revenue']; + + if ( $parent_id > 0 ) { + if ( ! isset( $parents[ $parent_id ] ) ) { + $parents[ $parent_id ] = [ + 'product_id' => $parent_id, + 'name' => '' !== $parent_name ? $parent_name : __( '(unnamed product)', 'newspack-plugin' ), + 'is_parent' => true, + 'active_recurring_donors' => 0, + 'new_donors_in_window' => 0, + 'one_time_gifts_in_window' => 0, + 'recurring_revenue_in_window' => 0.0, + 'lifetime_donation_revenue' => 0.0, + 'variations' => [], + ]; + } + $parents[ $parent_id ]['active_recurring_donors'] += $active_recurring_donors; + $parents[ $parent_id ]['new_donors_in_window'] += $new_donors; + $parents[ $parent_id ]['one_time_gifts_in_window'] += $one_time_gifts; + $parents[ $parent_id ]['recurring_revenue_in_window'] += $recurring_revenue; + $parents[ $parent_id ]['lifetime_donation_revenue'] += $lifetime_donation_revenue; + $parents[ $parent_id ]['variations'][] = [ + 'variation_id' => $variation_id, + 'label' => $this->variation_label( $period, $variation_name, $parent_name ), + 'active_recurring_donors' => $active_recurring_donors, + 'new_donors_in_window' => $new_donors, + 'one_time_gifts_in_window' => $one_time_gifts, + 'recurring_revenue_in_window' => $recurring_revenue, + 'lifetime_donation_revenue' => $lifetime_donation_revenue, + ]; + } else { + $parents[ $variation_id ] = [ + 'product_id' => $variation_id, + 'name' => '' !== $variation_name ? $variation_name : __( '(unnamed product)', 'newspack-plugin' ), + 'is_parent' => false, + 'active_recurring_donors' => $active_recurring_donors, + 'new_donors_in_window' => $new_donors, + 'one_time_gifts_in_window' => $one_time_gifts, + 'recurring_revenue_in_window' => $recurring_revenue, + 'lifetime_donation_revenue' => $lifetime_donation_revenue, + ]; + } + } + + foreach ( $parents as &$entry ) { + if ( isset( $entry['variations'] ) ) { + usort( + $entry['variations'], + static function ( $a, $b ) { + return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + } + ); + } + } + unset( $entry ); + + $out = array_values( $parents ); + usort( + $out, + static function ( $a, $b ) { + return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + } + ); + return array_slice( $out, 0, 50 ); + } + + /** + * Variation label picker. Same conventions as Tab 6. + * + * @param string $period _subscription_period meta value. + * @param string $variation_name Variation post_title. + * @param string $parent_name Parent product post_title. + * @return string + */ + private function variation_label( string $period, string $variation_name, string $parent_name ): string { + switch ( strtolower( $period ) ) { + case 'day': + return __( 'Daily', 'newspack-plugin' ); + case 'week': + return __( 'Weekly', 'newspack-plugin' ); + case 'month': + return __( 'Monthly', 'newspack-plugin' ); + case 'year': + return __( 'Annual', 'newspack-plugin' ); + } + if ( '' !== $variation_name ) { + $prefix = $parent_name . ' - '; + if ( '' !== $parent_name && 0 === strpos( $variation_name, $prefix ) ) { + return substr( $variation_name, strlen( $prefix ) ); + } + return $variation_name; + } + return __( 'Variation', 'newspack-plugin' ); + } +} From c6b47e746cd1f389eb3e98077881bca2cb1f4db9 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 09:37:57 -0500 Subject: [PATCH 03/37] feat(insights): Legacy_Donors_Storage implementation (NPPD-1617) Tab 7 counterpart to Legacy_Storage. Mirrors HPOS_Donors_Storage method-by-method with the per-row source swapped from HPOS tables to legacy CPT: wc_orders -> posts WHERE post_type = 'shop_subscription'/'shop_order' wc_orders.customer_id -> postmeta._customer_user wc_orders.total_amount -> postmeta._order_total (CAST DECIMAL) wc_orders.date_created -> posts.post_date_gmt wc_orders_meta -> postmeta Line-item tables (woocommerce_order_items / itemmeta) and wc_order_product_lookup are cross-backend and queried identically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../storage/class-legacy-donors-storage.php | 753 ++++++++++++++++++ 1 file changed, 753 insertions(+) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php new file mode 100644 index 000000000..91985bdd3 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -0,0 +1,753 @@ +donation_product_ids = array_map( 'intval', $donation_product_ids ); + } + + /** + * SQL-safe `IN (...)` list. Empty -> `0`. + * + * @param int[] $ids Integer IDs. + * @return string + */ + private function id_list( array $ids ): string { + if ( empty( $ids ) ) { + return '0'; + } + return implode( ',', array_map( 'intval', $ids ) ); + } + + /** + * Format a DateTime for SQL. + * + * @param DateTimeInterface $dt DateTime. + * @return string Y-m-d H:i:s. + */ + private function fmt( DateTimeInterface $dt ): string { + return $dt->format( 'Y-m-d H:i:s' ); + } + + /** + * {@inheritDoc} + */ + public function get_active_donors(): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // UNION of two paths, deduped by customer_id. See HPOS variant + // for the rationale. + $sql = "SELECT COUNT(*) FROM ( + SELECT DISTINCT cust.meta_value AS customer_id + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND oim.meta_value IN ($donations) + UNION + SELECT DISTINCT cust.meta_value AS customer_id + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = p.ID + WHERE p.post_type = 'shop_order' + AND p.post_status IN ('wc-completed', 'wc-processing') + AND p.post_date_gmt >= DATE_SUB(NOW(), INTERVAL 365 DAY) + AND opl.product_id IN ($donations) + ) AS active_donor_set + WHERE CAST(customer_id AS UNSIGNED) > 0"; + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + */ + public function get_active_recurring_donors(): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = "SELECT COUNT(DISTINCT cust.meta_value) + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND oim.meta_value IN ($donations)"; + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + */ + public function get_donation_mrr(): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = "SELECT SUM( + CASE + WHEN prd.meta_value = 'month' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) / CAST(pri.meta_value AS UNSIGNED) + WHEN prd.meta_value = 'year' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) / (12 * CAST(pri.meta_value AS UNSIGNED)) + WHEN prd.meta_value = 'week' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) * (52/12) / CAST(pri.meta_value AS UNSIGNED) + WHEN prd.meta_value = 'day' AND CAST(pri.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) * 30 / CAST(pri.meta_value AS UNSIGNED) + ELSE CAST(tot.meta_value AS DECIMAL(15,2)) / 12 + END + ) + FROM {$prefix}posts p + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + JOIN {$prefix}postmeta prd + ON prd.post_id = CAST(oim.meta_value AS UNSIGNED) AND prd.meta_key = '_subscription_period' + JOIN {$prefix}postmeta pri + ON pri.post_id = CAST(oim.meta_value AS UNSIGNED) AND pri.meta_key = '_subscription_period_interval' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND oim.meta_value IN ($donations)"; + + return (float) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_new_donors_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = $wpdb->prepare( + "SELECT COUNT(*) FROM ( + SELECT cust.meta_value AS customer_id, MIN(p.post_date_gmt) AS first_donation_date + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = p.ID + WHERE p.post_type = 'shop_order' + AND p.post_status IN ('wc-completed', 'wc-processing') + AND opl.product_id IN ($donations) + GROUP BY cust.meta_value + ) AS first_donations + WHERE first_donation_date BETWEEN %s AND %s", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_lapsed_donors_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = $wpdb->prepare( + "SELECT COUNT(DISTINCT cancellations.customer_id) FROM ( + SELECT cust.meta_value AS customer_id + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}postmeta cancelled + ON cancelled.post_id = p.ID AND cancelled.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE p.post_type = 'shop_subscription' + AND p.post_status IN ('wc-cancelled', 'wc-expired') + AND oim.meta_value IN ($donations) + AND cancelled.meta_value BETWEEN %s AND %s + AND cancelled.meta_value != '' + ) AS cancellations + WHERE cancellations.customer_id NOT IN ( + SELECT DISTINCT cust2.meta_value + FROM {$prefix}posts p2 + JOIN {$prefix}postmeta cust2 + ON cust2.post_id = p2.ID AND cust2.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi2 + ON oi2.order_id = p2.ID AND oi2.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim2 + ON oim2.order_item_id = oi2.order_item_id AND oim2.meta_key = '_product_id' + WHERE p2.post_type = 'shop_subscription' + AND p2.post_status = 'wc-active' + AND oim2.meta_value IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_one_time_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return $this->get_donation_revenue_filtered( $start, $end, 'one_time' ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_recurring_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return $this->get_donation_revenue_filtered( $start, $end, 'recurring' ); + } + + /** + * Shared body for one-time vs recurring donation revenue. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @param string $mode 'one_time' | 'recurring'. + * @return float + */ + private function get_donation_revenue_filtered( DateTimeInterface $start, DateTimeInterface $end, string $mode ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $period_predicate = 'recurring' === $mode + ? "EXISTS (SELECT 1 FROM {$prefix}postmeta pm + WHERE pm.post_id = opl.product_id + AND pm.meta_key = '_subscription_period' + AND pm.meta_value IN ('day','week','month','year'))" + : "NOT EXISTS (SELECT 1 FROM {$prefix}postmeta pm + WHERE pm.post_id = opl.product_id + AND pm.meta_key = '_subscription_period' + AND pm.meta_value IN ('day','week','month','year'))"; + + $sql = $wpdb->prepare( + "SELECT SUM(CAST(tot.meta_value AS DECIMAL(15,2))) + FROM {$prefix}posts p + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = p.ID + WHERE p.post_type = 'shop_order' + AND p.post_status IN ('wc-completed', 'wc-processing') + AND p.post_date_gmt BETWEEN %s AND %s + AND opl.product_id IN ($donations) + AND $period_predicate", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (float) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_average_donation_gift( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $sql = $wpdb->prepare( + "SELECT AVG(CAST(tot.meta_value AS DECIMAL(15,2))) + FROM {$prefix}posts p + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = p.ID + WHERE p.post_type = 'shop_order' + AND p.post_status IN ('wc-completed', 'wc-processing') + AND p.post_date_gmt BETWEEN %s AND %s + AND opl.product_id IN ($donations)", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $avg = $wpdb->get_var( $sql ); + return null === $avg ? 0.0 : (float) $avg; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + $duration = $end->getTimestamp() - $start->getTimestamp(); + $prior_end_ts = $start->getTimestamp() - 1; + $prior_start_ts = $prior_end_ts - $duration; + $prior_start_iso = gmdate( 'Y-m-d H:i:s', $prior_start_ts ); + $prior_end_iso = gmdate( 'Y-m-d H:i:s', $prior_end_ts ); + $current_start_iso = $this->fmt( $start ); + $current_end_iso = $this->fmt( $end ); + + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + $lapsed_sql = $wpdb->prepare( + "SELECT DISTINCT cancellations.customer_id + FROM ( + SELECT cust.meta_value AS customer_id + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}postmeta cancelled + ON cancelled.post_id = p.ID AND cancelled.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE p.post_type = 'shop_subscription' + AND p.post_status IN ('wc-cancelled', 'wc-expired') + AND oim.meta_value IN ($donations) + AND cancelled.meta_value BETWEEN %s AND %s + AND cancelled.meta_value != '' + ) AS cancellations + WHERE cancellations.customer_id NOT IN ( + SELECT DISTINCT cust2.meta_value + FROM {$prefix}posts p2 + JOIN {$prefix}postmeta cust2 + ON cust2.post_id = p2.ID AND cust2.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi2 + ON oi2.order_id = p2.ID AND oi2.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim2 + ON oim2.order_item_id = oi2.order_item_id AND oim2.meta_key = '_product_id' + WHERE p2.post_type = 'shop_subscription' + AND p2.post_status = 'wc-active' + AND oim2.meta_value IN ($donations) + )", + $prior_start_iso, + $prior_end_iso + ); + + $lapsed_customer_ids = $wpdb->get_col( $lapsed_sql ); + if ( empty( $lapsed_customer_ids ) ) { + return 0.0; + } + $lapsed_count = count( $lapsed_customer_ids ); + $lapsed_list = $this->id_list( array_map( 'intval', $lapsed_customer_ids ) ); + + $recovered_sql = $wpdb->prepare( + "SELECT COUNT(DISTINCT cust.meta_value) + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = p.ID + WHERE p.post_type = 'shop_order' + AND p.post_status IN ('wc-completed', 'wc-processing') + AND p.post_date_gmt BETWEEN %s AND %s + AND opl.product_id IN ($donations) + AND CAST(cust.meta_value AS UNSIGNED) IN ($lapsed_list)", + $current_start_iso, + $current_end_iso + ); + $recovered = (int) $wpdb->get_var( $recovered_sql ); + + return $recovered / $lapsed_count; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end (unused — see docblock). + * @return float + */ + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + unset( $end ); // cache key only; SQL uses NOW for the "still active" check. + + $active_at_start_sql = $wpdb->prepare( + "SELECT DISTINCT cust.meta_value AS customer_id, p.ID AS subscription_id, p.post_status + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}postmeta start_meta + ON start_meta.post_id = p.ID AND start_meta.meta_key = '_schedule_start' + LEFT JOIN {$prefix}postmeta cancel_meta + ON cancel_meta.post_id = p.ID AND cancel_meta.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE p.post_type = 'shop_subscription' + AND oim.meta_value IN ($donations) + AND start_meta.meta_value != '' + AND start_meta.meta_value <= %s + AND ( + cancel_meta.meta_value IS NULL + OR cancel_meta.meta_value = '' + OR cancel_meta.meta_value > %s + )", + $this->fmt( $start ), + $this->fmt( $start ) + ); + $rows = $wpdb->get_results( $active_at_start_sql, ARRAY_A ); + if ( empty( $rows ) ) { + return 0.0; + } + + $customers_active_at_start = array_unique( array_map( 'intval', array_column( $rows, 'customer_id' ) ) ); + $denominator = count( $customers_active_at_start ); + if ( 0 === $denominator ) { + return 0.0; + } + + $customer_list = $this->id_list( $customers_active_at_start ); + $numerator_sql = "SELECT COUNT(DISTINCT cust.meta_value) + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND oim.meta_value IN ($donations) + AND CAST(cust.meta_value AS UNSIGNED) IN ($customer_list)"; + $numerator = (int) $wpdb->get_var( $numerator_sql ); + + return $numerator / $denominator; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Subscription pass: active recurring donors per (effective) product. + $subs_sql = "SELECT + pv.ID AS variation_id, + pv.post_title AS variation_name, + pv.post_parent AS parent_id, + COALESCE(pp.post_title, '') AS parent_name, + COALESCE(period_meta.meta_value, '') AS sub_period, + COUNT(DISTINCT cust.meta_value) AS active_recurring_donors + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta pid_meta + ON pid_meta.order_item_id = oi.order_item_id AND pid_meta.meta_key = '_product_id' + LEFT JOIN {$prefix}woocommerce_order_itemmeta vid_meta + ON vid_meta.order_item_id = oi.order_item_id AND vid_meta.meta_key = '_variation_id' + JOIN {$prefix}posts pv + ON pv.ID = COALESCE( NULLIF( CAST(vid_meta.meta_value AS UNSIGNED), 0 ), CAST(pid_meta.meta_value AS UNSIGNED) ) + LEFT JOIN {$prefix}posts pp ON pp.ID = pv.post_parent + LEFT JOIN {$prefix}postmeta period_meta + ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND pid_meta.meta_value IN ($donations) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period"; + + $subs_rows = $wpdb->get_results( $subs_sql, ARRAY_A ); + + // shop_order pass: four metrics aggregated per opl.product_id. + $orders_sql = $wpdb->prepare( + "SELECT + pv.ID AS variation_id, + pv.post_title AS variation_name, + pv.post_parent AS parent_id, + COALESCE(pp.post_title, '') AS parent_name, + COALESCE(period_meta.meta_value, '') AS sub_period, + COUNT(DISTINCT CASE + WHEN p.post_date_gmt BETWEEN %s AND %s + AND nd.first_donation_date BETWEEN %s AND %s + THEN cust.meta_value + END) AS new_donors_in_window, + COUNT(DISTINCT CASE + WHEN p.post_date_gmt BETWEEN %s AND %s + AND COALESCE(period_meta.meta_value, '') NOT IN ('day','week','month','year') + THEN p.ID + END) AS one_time_gifts_in_window, + COALESCE(SUM(CASE + WHEN p.post_date_gmt BETWEEN %s AND %s + AND period_meta.meta_value IN ('day','week','month','year') + THEN CAST(tot.meta_value AS DECIMAL(15,2)) + END), 0) AS recurring_revenue_in_window, + COALESCE(SUM(CAST(tot.meta_value AS DECIMAL(15,2))), 0) AS lifetime_donation_revenue + FROM {$prefix}posts p + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}wc_order_product_lookup opl ON opl.order_id = p.ID + JOIN {$prefix}posts pv ON pv.ID = opl.product_id + LEFT JOIN {$prefix}posts pp ON pp.ID = pv.post_parent + LEFT JOIN {$prefix}postmeta period_meta + ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' + LEFT JOIN ( + SELECT cust2.meta_value AS customer_id, MIN(p2.post_date_gmt) AS first_donation_date + FROM {$prefix}posts p2 + JOIN {$prefix}postmeta cust2 + ON cust2.post_id = p2.ID AND cust2.meta_key = '_customer_user' + JOIN {$prefix}wc_order_product_lookup opl2 ON opl2.order_id = p2.ID + WHERE p2.post_type = 'shop_order' + AND p2.post_status IN ('wc-completed','wc-processing') + AND opl2.product_id IN ($donations) + GROUP BY cust2.meta_value + ) AS nd ON nd.customer_id = cust.meta_value + WHERE p.post_type = 'shop_order' + AND p.post_status IN ('wc-completed','wc-processing') + AND opl.product_id IN ($donations) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period", + $this->fmt( $start ), + $this->fmt( $end ), + $this->fmt( $start ), + $this->fmt( $end ), + $this->fmt( $start ), + $this->fmt( $end ), + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $orders_rows = $wpdb->get_results( $orders_sql, ARRAY_A ); + + $by_id = []; + foreach ( $subs_rows as $row ) { + $by_id[ (int) $row['variation_id'] ] = [ + 'variation_id' => (int) $row['variation_id'], + 'variation_name' => (string) $row['variation_name'], + 'parent_id' => (int) $row['parent_id'], + 'parent_name' => (string) $row['parent_name'], + 'sub_period' => (string) $row['sub_period'], + 'active_recurring_donors' => (int) $row['active_recurring_donors'], + 'new_donors_in_window' => 0, + 'one_time_gifts_in_window' => 0, + 'recurring_revenue_in_window' => 0.0, + 'lifetime_donation_revenue' => 0.0, + ]; + } + foreach ( $orders_rows as $row ) { + $id = (int) $row['variation_id']; + if ( ! isset( $by_id[ $id ] ) ) { + $by_id[ $id ] = [ + 'variation_id' => $id, + 'variation_name' => (string) $row['variation_name'], + 'parent_id' => (int) $row['parent_id'], + 'parent_name' => (string) $row['parent_name'], + 'sub_period' => (string) $row['sub_period'], + 'active_recurring_donors' => 0, + ]; + } + $by_id[ $id ]['new_donors_in_window'] = (int) $row['new_donors_in_window']; + $by_id[ $id ]['one_time_gifts_in_window'] = (int) $row['one_time_gifts_in_window']; + $by_id[ $id ]['recurring_revenue_in_window'] = (float) $row['recurring_revenue_in_window']; + $by_id[ $id ]['lifetime_donation_revenue'] = (float) $row['lifetime_donation_revenue']; + } + + return $this->aggregate_tier_rows( array_values( $by_id ) ); + } + + /** + * Aggregate flat per-variation rows into parent + nested variations. + * Duplicated from {@see HPOS_Donors_Storage} — pure PHP transform + * keeping each storage class self-contained. + * + * @param array> $rows Merged rows. + * @return array> + */ + private function aggregate_tier_rows( array $rows ): array { + $parents = []; + + foreach ( $rows as $row ) { + $variation_id = (int) $row['variation_id']; + $variation_name = (string) $row['variation_name']; + $parent_id = (int) $row['parent_id']; + $parent_name = (string) $row['parent_name']; + $period = (string) $row['sub_period']; + $active_recurring_donors = (int) $row['active_recurring_donors']; + $new_donors = (int) $row['new_donors_in_window']; + $one_time_gifts = (int) $row['one_time_gifts_in_window']; + $recurring_revenue = (float) $row['recurring_revenue_in_window']; + $lifetime_donation_revenue = (float) $row['lifetime_donation_revenue']; + + if ( $parent_id > 0 ) { + if ( ! isset( $parents[ $parent_id ] ) ) { + $parents[ $parent_id ] = [ + 'product_id' => $parent_id, + 'name' => '' !== $parent_name ? $parent_name : __( '(unnamed product)', 'newspack-plugin' ), + 'is_parent' => true, + 'active_recurring_donors' => 0, + 'new_donors_in_window' => 0, + 'one_time_gifts_in_window' => 0, + 'recurring_revenue_in_window' => 0.0, + 'lifetime_donation_revenue' => 0.0, + 'variations' => [], + ]; + } + $parents[ $parent_id ]['active_recurring_donors'] += $active_recurring_donors; + $parents[ $parent_id ]['new_donors_in_window'] += $new_donors; + $parents[ $parent_id ]['one_time_gifts_in_window'] += $one_time_gifts; + $parents[ $parent_id ]['recurring_revenue_in_window'] += $recurring_revenue; + $parents[ $parent_id ]['lifetime_donation_revenue'] += $lifetime_donation_revenue; + $parents[ $parent_id ]['variations'][] = [ + 'variation_id' => $variation_id, + 'label' => $this->variation_label( $period, $variation_name, $parent_name ), + 'active_recurring_donors' => $active_recurring_donors, + 'new_donors_in_window' => $new_donors, + 'one_time_gifts_in_window' => $one_time_gifts, + 'recurring_revenue_in_window' => $recurring_revenue, + 'lifetime_donation_revenue' => $lifetime_donation_revenue, + ]; + } else { + $parents[ $variation_id ] = [ + 'product_id' => $variation_id, + 'name' => '' !== $variation_name ? $variation_name : __( '(unnamed product)', 'newspack-plugin' ), + 'is_parent' => false, + 'active_recurring_donors' => $active_recurring_donors, + 'new_donors_in_window' => $new_donors, + 'one_time_gifts_in_window' => $one_time_gifts, + 'recurring_revenue_in_window' => $recurring_revenue, + 'lifetime_donation_revenue' => $lifetime_donation_revenue, + ]; + } + } + + foreach ( $parents as &$entry ) { + if ( isset( $entry['variations'] ) ) { + usort( + $entry['variations'], + static function ( $a, $b ) { + return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + } + ); + } + } + unset( $entry ); + + $out = array_values( $parents ); + usort( + $out, + static function ( $a, $b ) { + return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + } + ); + return array_slice( $out, 0, 50 ); + } + + /** + * Variation label picker. + * + * @param string $period _subscription_period meta value. + * @param string $variation_name Variation post_title. + * @param string $parent_name Parent product post_title. + * @return string + */ + private function variation_label( string $period, string $variation_name, string $parent_name ): string { + switch ( strtolower( $period ) ) { + case 'day': + return __( 'Daily', 'newspack-plugin' ); + case 'week': + return __( 'Weekly', 'newspack-plugin' ); + case 'month': + return __( 'Monthly', 'newspack-plugin' ); + case 'year': + return __( 'Annual', 'newspack-plugin' ); + } + if ( '' !== $variation_name ) { + $prefix = $parent_name . ' - '; + if ( '' !== $parent_name && 0 === strpos( $variation_name, $prefix ) ) { + return substr( $variation_name, strlen( $prefix ) ); + } + return $variation_name; + } + return __( 'Variation', 'newspack-plugin' ); + } +} From 3c99eb838dd01ca46fdd21fde9a44641cd246f09 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 09:39:55 -0500 Subject: [PATCH 04/37] feat(insights): Donors_Metric orchestrator for Tab 7 (NPPD-1617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin dispatch + caching layer over the two donor storage backends. Picks HPOS vs Legacy via Storage_Detector, threads donation IDs from the shared Donation_Product_Classifier into storage construction, and wraps each storage call in a transient cache keyed by backend:method:md5(params_json). Tiers mirror Tab 6: TTL_DEFAULT 30 min — snapshot + windowed metrics TTL_HEAVY 60 min — donations_by_tier, recovery rate, retention Derived metrics computed in this layer (not in storage): get_donation_arr = MRR x 12 get_total_donation_revenue = one-time + recurring Cache prefix newspack_insights_tab7_v1:; bump if cached shape changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/metrics/class-donors-metric.php | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php new file mode 100644 index 000000000..a528317e9 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -0,0 +1,369 @@ +backend = Storage_Detector::detect(); + $donation_ids = Donation_Product_Classifier::get_donation_product_ids(); + + $this->storage = Storage_Detector::BACKEND_HPOS === $this->backend + ? new HPOS_Donors_Storage( $donation_ids ) + : new Legacy_Donors_Storage( $donation_ids ); + } + + /** + * Active storage backend identifier. + * + * @return string + */ + public function get_backend(): string { + return $this->backend; + } + + /** + * Classification metadata for the response shape. + * + * @return array{backend: string, donation_product_count: int, has_donation_family: bool} + */ + public function get_classification_metadata(): array { + $donation_ids = Donation_Product_Classifier::get_donation_product_ids(); + return [ + 'backend' => $this->backend, + 'donation_product_count' => count( $donation_ids ), + 'has_donation_family' => ! empty( $donation_ids ), + ]; + } + + /** + * Active donors (UNION of recurring + trailing-365 one-time). + * + * @return int + */ + public function get_active_donors(): int { + return (int) $this->cached( + 'active_donors', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_active_donors(); + } + ); + } + + /** + * Active recurring donors. + * + * @return int + */ + public function get_active_recurring_donors(): int { + return (int) $this->cached( + 'active_recurring_donors', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_active_recurring_donors(); + } + ); + } + + /** + * Donation MRR. + * + * @return float + */ + public function get_donation_mrr(): float { + return (float) $this->cached( + 'donation_mrr', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_donation_mrr(); + } + ); + } + + /** + * Donation ARR (MRR × 12). + * + * @return float + */ + public function get_donation_arr(): float { + return $this->get_donation_mrr() * 12; + } + + /** + * New donors in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_new_donors_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + return (int) $this->cached( + 'new_donors_in_window', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_new_donors_in_window( $start, $end ); + } + ); + } + + /** + * Lapsed donors in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_lapsed_donors_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + return (int) $this->cached( + 'lapsed_donors_in_window', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_lapsed_donors_in_window( $start, $end ); + } + ); + } + + /** + * One-time donation revenue in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_one_time_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'one_time_donation_revenue', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_one_time_donation_revenue( $start, $end ); + } + ); + } + + /** + * Recurring donation revenue in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_recurring_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'recurring_donation_revenue', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_recurring_donation_revenue( $start, $end ); + } + ); + } + + /** + * Total donation revenue in window (one-time + recurring). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_total_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float { + return $this->get_one_time_donation_revenue( $start, $end ) + + $this->get_recurring_donation_revenue( $start, $end ); + } + + /** + * Average donation gift in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_average_donation_gift( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'average_donation_gift', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_average_donation_gift( $start, $end ); + } + ); + } + + /** + * Lapsed donor recovery rate. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'lapsed_donor_recovery_rate', + $this->window_key( $start, $end ), + self::TTL_HEAVY, + function () use ( $start, $end ) { + return $this->storage->get_lapsed_donor_recovery_rate( $start, $end ); + } + ); + } + + /** + * Recurring donor retention. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'recurring_donor_retention', + $this->window_key( $start, $end ), + self::TTL_HEAVY, + function () use ( $start, $end ) { + return $this->storage->get_recurring_donor_retention( $start, $end ); + } + ); + } + + /** + * Donations by tier. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array> + */ + public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( + 'donations_by_tier', + $this->window_key( $start, $end ), + self::TTL_HEAVY, + function () use ( $start, $end ) { + return $this->storage->get_donations_by_tier( $start, $end ); + } + ); + } + + /** + * Flush all Tab 7 metric caches. Hook point for NPPD-1605. + * + * @return void + */ + public static function flush_all(): void { + global $wpdb; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%', + $wpdb->esc_like( '_transient_timeout_' . self::CACHE_PREFIX ) . '%' + ) + ); + // phpcs:enable + } + + /** + * Build a window key for cache disambiguation. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{start: string, end: string} + */ + private function window_key( DateTimeInterface $start, DateTimeInterface $end ): array { + return [ + 'start' => $start->format( 'U' ), + 'end' => $end->format( 'U' ), + ]; + } + + /** + * Cache helper. Lookup transient; on miss, callback, store, return. + * + * @param string $method Storage method name. + * @param array $params Parameters affecting the result. + * @param int $ttl Seconds. + * @param callable $callback Fresh-value provider. + * @return mixed + */ + private function cached( string $method, array $params, int $ttl, callable $callback ) { + $key = self::CACHE_PREFIX . $this->backend . ':' . $method . ':' . md5( (string) wp_json_encode( $params ) ); + $cached = get_transient( $key ); + if ( false !== $cached ) { + return $cached; + } + $result = $callback(); + set_transient( $key, $result, $ttl ); + return $result; + } +} From 8efee3d458da59e7c0e4030366bf17c384c73d4b Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 10:03:40 -0500 Subject: [PATCH 05/37] feat(insights): wire Tab 7 REST controller + section + autoload (NPPD-1617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - Donors_REST_Controller registering GET /newspack-insights/v1/donors (same date validation, permission_check, response shape pattern as Tab 6's Subscribers_REST_Controller) - Insights_Section_Donors expanded from stub: includes the 7 Tab 7 PHP files via load_dependencies() (mirrors Tab 6's section pattern) and registers the REST route on rest_api_init - Composer autoload regenerated Also fixes two PHPCS / SQL bugs caught during smoketest: 1. class-hpos-storage.php (Tab 6, inherited on this branch): missing blank line before a /* */ block comment after the previous // comment block. PHPCS Squiz.Commenting.BlockComment.NoEmptyLineBefore. 2. donations_by_tier returned 0 rows on populated test data because the LEFT JOIN subquery's `customer_id` reference was unqualified. Both wc_orders and wc_order_product_lookup carry a customer_id column, and an unqualified reference silently resolves to the opl side (which is 0 for most analytics rows). GROUP BY then collapsed every donation order into one bucket and the LEFT JOIN matched nothing. Fixed by qualifying as `o2.customer_id` (HPOS) and `cust2.meta_value` (legacy). Verified 3 donations_by_tier rows now return for Donate: One-Time / Monthly / Yearly with sensible per-tier metrics. Smoketest result on local data: backend: hpos active_donors: 21 (no active recurring subs in test data — correct) total_revenue: $1600 (one-time $575 + recurring $1025) donations_by_tier: 3 rows, math reconciles Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/class-donors-rest-controller.php | 291 ++++++++++++++++++ .../class-insights-section-donors.php | 55 +++- .../storage/class-hpos-donors-storage.php | 10 +- .../storage/class-legacy-donors-storage.php | 3 + 4 files changed, 344 insertions(+), 15 deletions(-) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php b/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php new file mode 100644 index 000000000..e803279a0 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php @@ -0,0 +1,291 @@ +namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_donors_data' ], + 'permission_callback' => [ $this, 'permissions_check' ], + 'args' => $this->get_collection_params(), + ], + ] + ); + } + + /** + * Permission check. + * + * @return bool|WP_Error + */ + public function permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'newspack_insights_rest_forbidden', + __( 'You do not have permission to view Insights data.', 'newspack-plugin' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + return true; + } + + /** + * GET handler. + * + * @param WP_REST_Request $request Request. + * @return \WP_REST_Response|WP_Error + */ + public function get_donors_data( WP_REST_Request $request ) { + $tz = $this->site_timezone(); + + try { + $start = $this->parse_date( $request->get_param( 'start' ), $tz, false ); + $end = $this->parse_date( $request->get_param( 'end' ), $tz, true ); + } catch ( Exception $e ) { + return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] ); + } + if ( $start > $end ) { + return new WP_Error( + 'newspack_insights_invalid_window', + __( 'Start date must be on or before end date.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + $compare_start_param = $request->get_param( 'compare_start' ); + $compare_end_param = $request->get_param( 'compare_end' ); + $compare_start = null; + $compare_end = null; + if ( $compare_start_param || $compare_end_param ) { + if ( ! $compare_start_param || ! $compare_end_param ) { + return new WP_Error( + 'newspack_insights_invalid_comparison', + __( 'Both compare_start and compare_end must be provided to enable comparison mode.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + try { + $compare_start = $this->parse_date( $compare_start_param, $tz, false ); + $compare_end = $this->parse_date( $compare_end_param, $tz, true ); + } catch ( Exception $e ) { + return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] ); + } + if ( $compare_start > $compare_end ) { + return new WP_Error( + 'newspack_insights_invalid_comparison_window', + __( 'compare_start must be on or before compare_end.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + } + + $metric = new Donors_Metric(); + return rest_ensure_response( $this->build_response( $metric, $start, $end, $compare_start, $compare_end ) ); + } + + /** + * Assemble response. + * + * @param Donors_Metric $metric Orchestrator. + * @param DateTimeImmutable $start Current window start. + * @param DateTimeImmutable $end Current window end. + * @param DateTimeImmutable|null $compare_start Prior window start. + * @param DateTimeImmutable|null $compare_end Prior window end. + * @return array + */ + private function build_response( + Donors_Metric $metric, + DateTimeImmutable $start, + DateTimeImmutable $end, + ?DateTimeImmutable $compare_start, + ?DateTimeImmutable $compare_end + ): array { + $response = [ + 'classification' => $metric->get_classification_metadata(), + 'snapshot' => [ + 'active_donors' => $metric->get_active_donors(), + 'active_recurring_donors' => $metric->get_active_recurring_donors(), + 'donation_mrr' => $metric->get_donation_mrr(), + 'donation_arr' => $metric->get_donation_arr(), + ], + 'current' => $this->build_window( $metric, $start, $end ), + 'previous' => null, + ]; + if ( $compare_start && $compare_end ) { + $response['previous'] = $this->build_window( $metric, $compare_start, $compare_end ); + } + return $response; + } + + /** + * Window-bound payload. + * + * @param Donors_Metric $metric Orchestrator. + * @param DateTimeImmutable $start Start. + * @param DateTimeImmutable $end End. + * @return array + */ + private function build_window( Donors_Metric $metric, DateTimeImmutable $start, DateTimeImmutable $end ): array { + return [ + 'window' => [ + 'start' => $start->format( 'Y-m-d' ), + 'end' => $end->format( 'Y-m-d' ), + ], + 'new_donors' => $metric->get_new_donors_in_window( $start, $end ), + 'lapsed_donors' => $metric->get_lapsed_donors_in_window( $start, $end ), + 'one_time_revenue' => $metric->get_one_time_donation_revenue( $start, $end ), + 'recurring_revenue' => $metric->get_recurring_donation_revenue( $start, $end ), + 'total_revenue' => $metric->get_total_donation_revenue( $start, $end ), + 'average_gift' => $metric->get_average_donation_gift( $start, $end ), + 'lapsed_donor_recovery_rate' => $metric->get_lapsed_donor_recovery_rate( $start, $end ), + 'recurring_donor_retention' => $metric->get_recurring_donor_retention( $start, $end ), + 'donations_by_tier' => $metric->get_donations_by_tier( $start, $end ), + ]; + } + + /** + * Args spec. + * + * @return array + */ + public function get_collection_params() { + $base = [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ]; + return [ + 'start' => array_merge( + $base, + [ + 'description' => __( 'Inclusive window start date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'required' => true, + ] + ), + 'end' => array_merge( + $base, + [ + 'description' => __( 'Inclusive window end date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'required' => true, + ] + ), + 'compare_start' => array_merge( + $base, + [ + 'description' => __( 'Optional comparison window start. Must pair with compare_end.', 'newspack-plugin' ), + 'required' => false, + ] + ), + 'compare_end' => array_merge( + $base, + [ + 'description' => __( 'Optional comparison window end. Must pair with compare_start.', 'newspack-plugin' ), + 'required' => false, + ] + ), + ]; + } + + /** + * REST validate_callback. + * + * @param mixed $value Value. + * @return bool|WP_Error + */ + public function validate_date_string( $value ) { + if ( ! is_string( $value ) || '' === $value ) { + return new WP_Error( + 'newspack_insights_invalid_date', + __( 'Date must be a non-empty YYYY-MM-DD string.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + $parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $this->site_timezone() ); + if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) { + return new WP_Error( + 'newspack_insights_invalid_date', + /* translators: %s: the invalid date string */ + sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ), + [ 'status' => 400 ] + ); + } + return true; + } + + /** + * Parse a Y-m-d string into a DateTimeImmutable. + * + * @param mixed $value Raw value. + * @param DateTimeZone $tz Timezone. + * @param bool $end_of_day If true, 23:59:59; else 00:00:00. + * @return DateTimeImmutable + * @throws Exception On parse failure. + */ + private function parse_date( $value, DateTimeZone $tz, bool $end_of_day ): DateTimeImmutable { + if ( ! is_string( $value ) || '' === $value ) { + throw new Exception( esc_html__( 'Missing date value.', 'newspack-plugin' ) ); + } + $parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $tz ); + if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) { + /* translators: %s: the invalid date string */ + throw new Exception( esc_html( sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ) ) ); + } + return $end_of_day ? $parsed->setTime( 23, 59, 59 ) : $parsed->setTime( 0, 0, 0 ); + } + + /** + * Site timezone. + * + * @return DateTimeZone + */ + private function site_timezone(): DateTimeZone { + return wp_timezone(); + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php index 746bdda1b..1e59b570b 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-donors.php @@ -1,21 +1,20 @@ register_routes(); + } + ); + } } diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index b1aa96664..d05f2e494 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -592,13 +592,19 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa LEFT JOIN {$prefix}postmeta period_meta ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' LEFT JOIN ( - SELECT customer_id, MIN(date_created_gmt) AS first_donation_date + -- Column prefix on customer_id is required: both + -- wc_orders and wc_order_product_lookup carry that column, + -- and an unqualified reference silently resolves to the + -- opl side (which is 0 for most analytics rows), so + -- GROUP BY collapses every row into one and the JOIN + -- below matches nothing. + SELECT o2.customer_id, MIN(o2.date_created_gmt) AS first_donation_date FROM {$prefix}wc_orders o2 JOIN {$prefix}wc_order_product_lookup opl2 ON opl2.order_id = o2.id WHERE o2.type = 'shop_order' AND o2.status IN ('wc-completed','wc-processing') AND opl2.product_id IN ($donations) - GROUP BY customer_id + GROUP BY o2.customer_id ) AS nd ON nd.customer_id = o.customer_id WHERE o.type = 'shop_order' AND o.status IN ('wc-completed','wc-processing') diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 91985bdd3..1f7eb3b0c 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -573,6 +573,9 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa LEFT JOIN {$prefix}postmeta period_meta ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' LEFT JOIN ( + -- See HPOS variant for why cust2.meta_value must be + -- qualified — opl2 carries customer_id and would + -- shadow an unqualified reference. SELECT cust2.meta_value AS customer_id, MIN(p2.post_date_gmt) AS first_donation_date FROM {$prefix}posts p2 JOIN {$prefix}postmeta cust2 From 5aa7a460ae87375e2d378fe389b7abd7d3ecf7b3 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 10:04:44 -0500 Subject: [PATCH 06/37] feat(insights): dynamic Donors tab visibility (NPPD-1617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab 7 hides when the publisher has no donation products configured. Boot config's 'donors' visibility now reads from Donation_Product_Classifier::get_donation_product_ids() via a private has_donation_products() helper. The classifier ships its own 1h transient cache, so this query amortizes to roughly one DB round trip per hour per page load. Falls back to `true` if the classifier class isn't available (defensive — the donors/subscribers section file may have failed to load in development); preserves visibility so the missing dependency can be diagnosed rather than silently hiding the tab. The other 7 tabs' visibility stays stubbed at `true`. Each one needs its own feature detection (BQ dataset presence for the audience / engagement / conversion / advertising tabs; non-donation subscription presence for subscribers; etc.) and lands in its own follow-up issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/class-insights-wizard.php | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php index 7253c8135..f1dab1eff 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -110,6 +110,33 @@ public function enqueue_scripts_and_styles() { wp_localize_script( 'newspack-wizards', 'newspackInsights', $this->get_boot_config() ); } + /** + * Build the boot config consumed by the React entry. + * + * @return array + */ + /** + * Donors tab visibility. True when the publisher has at least one + * donation product configured (per the union of canonical Newspack + * donation family + manually flagged products). + * + * Wraps the shared {@see \Newspack\Insights\Donation_Product_Classifier}. + * The classifier is loaded by the Donors / Subscribers sections' + * `init()` callbacks, both of which run before this boot config + * is requested by enqueue_scripts_and_styles. Falls back to true + * if the classifier class isn't available (defensive — the section + * file may have failed to load in development) so the tab still + * appears and the missing-dep can be diagnosed. + * + * @return bool + */ + private static function has_donation_products(): bool { + if ( ! class_exists( '\Newspack\Insights\Donation_Product_Classifier' ) ) { + return true; + } + return ! empty( \Newspack\Insights\Donation_Product_Classifier::get_donation_product_ids() ); + } + /** * Build the boot config consumed by the React entry. * @@ -123,11 +150,15 @@ protected function get_boot_config() { $thirty_ago = $today->modify( '-29 days' ); return [ - // Tab visibility. Real computation (feature detection: GAM - // dataset presence, scroll event presence, non-donation - // subscription product count, donation activity count) needs - // the BigQuery wrapper (NPPD-1598) plus Woo queries. Stubbed - // to all-on for now per the prompt's scope note. + // Tab visibility. The audience/engagement/conversion/gates/ + // prompts/advertising tabs are stubbed to true until their + // data layers land (each needs BQ for proper feature + // detection, NPPD-1598). Subscribers stays all-on for now; + // Tab 6 visibility detection (non-donation subscription + // product presence) is a separate follow-up. Donors hides + // when there are no donation products on the publisher, + // using the shared Donation_Product_Classifier (cached 1h) + // as the single source of truth. 'tabs' => [ 'audience' => true, 'engagement' => true, @@ -135,7 +166,7 @@ protected function get_boot_config() { 'gates' => true, 'prompts' => true, 'subscribers' => true, - 'donors' => true, + 'donors' => self::has_donation_products(), 'advertising' => true, ], 'defaultDateRange' => [ From 044c17637a3b9a246c7aa84e5dbe3884cdbfa494 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 10:07:15 -0500 Subject: [PATCH 07/37] =?UTF-8?q?feat(insights):=20Tab=207=20React=20UI=20?= =?UTF-8?q?=E2=80=94=20DonorsTab=20+=204=20sections=20(NPPD-1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React layer for the Donors tab. Mirrors the Tab 6 SubscribersTab pattern: tab orchestrator dispatches via section components, each section composes the shared MetricCard from tabs/components/, table chrome inherits from sections.scss (extraction landed pre-Tab 7). Files: api/donors.ts - typed REST response (DonorsResponse, DonorsSnapshot, DonorsWindow, DonorsTierRow + DonorsTierVariationRow) hooks/useDonorsData.ts - fetch lifecycle with request-id guard tabs/DonorsTab.tsx - orchestrator (loading/error/success) tabs/donors/ ScorecardSection - "Donors at a glance" (4 current-state cards) WindowedSection - dynamic-heading ("In the last 30 days", etc.) with 6 window-scoped cards. Lapsed donors uses lowerIsBetter for the delta tone. RetentionSection - 2 percent cards (recovery rate, recurring donor retention) using the same comparison semantics. PerformanceSection - 6-column tier table with the shared nested variation row pattern. Donate parent + its Monthly/Annual variations under it. donors.scss - Tab 7-specific only (gap, container); everything else (cards, sections, table, variation rows) reused from tabs/components/sections.scss Storage_Detector + Donation_Product_Classifier reused as-is — no duplication on the backend either. Smoketest: REST returns 21 active donors / $1600 total revenue / 3 tier rows on local test data; tabs visibility correctly reflects donation-product presence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/api/donors.ts | 97 +++++++++++++++ .../wizards/insights/hooks/useDonorsData.ts | 76 ++++++++++++ .../tabs/donors/PerformanceSection.tsx | 102 ++++++++++++++++ .../insights/tabs/donors/RetentionSection.tsx | 57 +++++++++ .../insights/tabs/donors/ScorecardSection.tsx | 64 ++++++++++ .../insights/tabs/donors/WindowedSection.tsx | 111 ++++++++++++++++++ .../wizards/insights/tabs/donors/donors.scss | 16 +++ 7 files changed, 523 insertions(+) create mode 100644 plugins/newspack-plugin/src/wizards/insights/api/donors.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/hooks/useDonorsData.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/donors/donors.scss diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts new file mode 100644 index 000000000..8a5e55712 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -0,0 +1,97 @@ +/** + * Donors API client (NPPD-1617). + * + * Thin wrapper around `@wordpress/api-fetch` for the single Tab 7 + * endpoint: `GET /newspack-insights/v1/donors`. Type definitions + * mirror the PHP response shape assembled by + * `Donors_REST_Controller`. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +export type StorageBackend = 'hpos' | 'legacy'; + +export interface DonorsClassification { + backend: StorageBackend; + donation_product_count: number; + has_donation_family: boolean; +} + +export interface DonorsSnapshot { + active_donors: number; + active_recurring_donors: number; + donation_mrr: number; + donation_arr: number; +} + +export interface DonorsTierVariationRow { + variation_id: number; + label: string; + active_recurring_donors: number; + new_donors_in_window: number; + one_time_gifts_in_window: number; + recurring_revenue_in_window: number; + lifetime_donation_revenue: number; +} + +export interface DonorsTierRow { + product_id: number; + name: string; + is_parent: boolean; + active_recurring_donors: number; + new_donors_in_window: number; + one_time_gifts_in_window: number; + recurring_revenue_in_window: number; + lifetime_donation_revenue: number; + /** Present only when `is_parent` is true. Sorted by active_recurring_donors descending. */ + variations?: DonorsTierVariationRow[]; +} + +export interface DonorsWindow { + window: { start: string; end: string }; + new_donors: number; + lapsed_donors: number; + one_time_revenue: number; + recurring_revenue: number; + total_revenue: number; + average_gift: number; + lapsed_donor_recovery_rate: number; + recurring_donor_retention: number; + donations_by_tier: DonorsTierRow[]; +} + +export interface DonorsResponse { + classification: DonorsClassification; + snapshot: DonorsSnapshot; + current: DonorsWindow; + previous: DonorsWindow | null; +} + +export interface DonorsQuery { + start: string; + end: string; + compare_start?: string; + compare_end?: string; +} + +const ENDPOINT = '/newspack-insights/v1/donors'; + +/** + * Fetch Tab 7 data for the given window pair. + */ +export const fetchDonorsData = async ( query: DonorsQuery ): Promise< DonorsResponse > => { + const params = new URLSearchParams(); + params.set( 'start', query.start ); + params.set( 'end', query.end ); + if ( query.compare_start && query.compare_end ) { + params.set( 'compare_start', query.compare_start ); + params.set( 'compare_end', query.compare_end ); + } + return apiFetch< DonorsResponse >( { + path: `${ ENDPOINT }?${ params.toString() }`, + method: 'GET', + } ); +}; diff --git a/plugins/newspack-plugin/src/wizards/insights/hooks/useDonorsData.ts b/plugins/newspack-plugin/src/wizards/insights/hooks/useDonorsData.ts new file mode 100644 index 000000000..519825f3c --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/hooks/useDonorsData.ts @@ -0,0 +1,76 @@ +/** + * useDonorsData (NPPD-1617). + * + * Tab 7's data fetch lifecycle. Mirrors {@see useSubscribersData}: a + * request-id guard serializes overlapping calls so the latest range + * change wins, and idle / loading / success / error state is local to + * the tab so the wizard chrome stays interactive in all states. + */ + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import { fetchDonorsData, type DonorsResponse } from '../api/donors'; + +export type DonorsFetchStatus = 'idle' | 'loading' | 'success' | 'error'; + +export interface UseDonorsDataResult { + status: DonorsFetchStatus; + data: DonorsResponse | null; + error: string | null; + refetch: () => void; +} + +const errorMessage = ( e: unknown ): string => { + if ( e && typeof e === 'object' && 'message' in e && typeof ( e as { message: unknown } ).message === 'string' ) { + return ( e as { message: string } ).message; + } + return String( e ); +}; + +const useDonorsData = ( range: DateRange, previousRange: DateRange | null ): UseDonorsDataResult => { + const [ status, setStatus ] = useState< DonorsFetchStatus >( 'idle' ); + const [ data, setData ] = useState< DonorsResponse | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + + const requestIdRef = useRef( 0 ); + const [ refetchTick, setRefetchTick ] = useState( 0 ); + const refetch = useCallback( () => setRefetchTick( t => t + 1 ), [] ); + + useEffect( () => { + const myId = ++requestIdRef.current; + setStatus( 'loading' ); + setError( null ); + + fetchDonorsData( { + start: range.start, + end: range.end, + compare_start: previousRange?.start, + compare_end: previousRange?.end, + } ) + .then( response => { + if ( requestIdRef.current !== myId ) { + return; + } + setData( response ); + setStatus( 'success' ); + } ) + .catch( e => { + if ( requestIdRef.current !== myId ) { + return; + } + setError( errorMessage( e ) ); + setStatus( 'error' ); + } ); + }, [ range.start, range.end, previousRange?.start, previousRange?.end, refetchTick ] ); + + return { status, data, error, refetch }; +}; + +export default useDonorsData; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx new file mode 100644 index 000000000..69a9806a7 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx @@ -0,0 +1,102 @@ +/** + * PerformanceSection (NPPD-1617). + * + * Donations by tier — table identical in shape to Tab 6's Performance + * by product, with nested variation rows. Parent rows aggregate the + * SUM of their variations; standalone products render as a single + * row. Top 50 parents/standalones server-side. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DonorsTierRow } from '../../api/donors'; +import { formatCurrency, formatNumber } from '../components/format'; + +export interface PerformanceSectionProps { + rows: DonorsTierRow[]; +} + +const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { + if ( rows.length === 0 ) { + return ( +
+

+ { __( 'Donations by tier', 'newspack-plugin' ) } +

+

{ __( 'No donation activity yet.', 'newspack-plugin' ) }

+
+ ); + } + + return ( +
+

+ { __( 'Donations by tier', 'newspack-plugin' ) } +

+
+ + + + + + + + + + + + + { rows.map( row => ( + + + + + + + + + + { row.is_parent && + row.variations?.map( v => ( + + + + + + + + + ) ) } + + ) ) } + +
{ __( 'Product', 'newspack-plugin' ) } + { __( 'Active recurring donors', 'newspack-plugin' ) } + + { __( 'New donors', 'newspack-plugin' ) } + + { __( 'One-time gifts', 'newspack-plugin' ) } + + { __( 'Recurring revenue', 'newspack-plugin' ) } + + { __( 'Lifetime revenue', 'newspack-plugin' ) } +
{ row.name }{ formatNumber( row.active_recurring_donors ) }{ formatNumber( row.new_donors_in_window ) }{ formatNumber( row.one_time_gifts_in_window ) }{ formatCurrency( row.recurring_revenue_in_window ) }{ formatCurrency( row.lifetime_donation_revenue ) }
{ v.label }{ formatNumber( v.active_recurring_donors ) }{ formatNumber( v.new_donors_in_window ) }{ formatNumber( v.one_time_gifts_in_window ) }{ formatCurrency( v.recurring_revenue_in_window ) }{ formatCurrency( v.lifetime_donation_revenue ) }
+
+
+ ); +}; + +export default PerformanceSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx new file mode 100644 index 000000000..6e4148d2b --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx @@ -0,0 +1,57 @@ +/** + * RetentionSection (NPPD-1617). + * + * Donor retention metrics. Both are window-scoped and visualised as + * percentages with a descriptive subtitle. + * + * - Lapsed donor recovery rate: of donors who lapsed in the prior + * window of equal length, the fraction who made a new donation in + * the current window. Higher is better. + * - Recurring donor retention: of recurring donors active at the + * window start, the fraction still active now. Higher is better. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { DonorsWindow } from '../../api/donors'; +import MetricCard from '../components/MetricCard'; + +export interface RetentionSectionProps { + current: DonorsWindow; + previous: DonorsWindow | null; +} + +const RetentionSection = ( { current, previous }: RetentionSectionProps ) => ( +
+

+ { __( 'Retention', 'newspack-plugin' ) } +

+
+ + +
+
+); + +export default RetentionSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx new file mode 100644 index 000000000..929cec9ea --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx @@ -0,0 +1,64 @@ +/** + * ScorecardSection (NPPD-1617). + * + * "Donors at a glance" — current-state metrics that ignore the date + * picker. Active donors (any), Active recurring donors, Donation + * MRR, Donation ARR. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { DonorsSnapshot } from '../../api/donors'; +import MetricCard from '../components/MetricCard'; + +export interface ScorecardSectionProps { + snapshot: DonorsSnapshot; +} + +const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( +
+

+ { __( 'Donors at a glance', 'newspack-plugin' ) } +

+
+ + + + +
+
+); + +export default ScorecardSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx new file mode 100644 index 000000000..bf780ad92 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx @@ -0,0 +1,111 @@ +/** + * WindowedSection (NPPD-1617). + * + * Tab 7 metrics scoped to the date range picker: new/lapsed donor + * counts, total/one-time/recurring revenue, and the average gift. + * Heading is dynamic ("In the last 30 days", "This month", etc.) — + * same pattern as Tab 6's WindowedSection. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { DonorsWindow } from '../../api/donors'; +import type { DateRange } from '../../state/useDateRange'; +import MetricCard from '../components/MetricCard'; + +export interface WindowedSectionProps { + range: DateRange; + current: DonorsWindow; + previous: DonorsWindow | null; +} + +const parseISO = ( s: string ): Date => { + const [ y, m, d ] = s.split( '-' ).map( Number ); + return new Date( y, m - 1, d ); +}; + +const formatShortDate = ( s: string ): string => new Intl.DateTimeFormat( undefined, { month: 'short', day: 'numeric' } ).format( parseISO( s ) ); + +const getHeading = ( range: DateRange ): string => { + switch ( range.preset ) { + case 'last-7': + return __( 'In the last 7 days', 'newspack-plugin' ); + case 'last-30': + return __( 'In the last 30 days', 'newspack-plugin' ); + case 'last-90': + return __( 'In the last 90 days', 'newspack-plugin' ); + case 'this-month': + return __( 'This month', 'newspack-plugin' ); + case 'last-month': + return __( 'Last month', 'newspack-plugin' ); + case 'custom': + default: + return sprintf( + /* translators: 1: start date formatted like "Sep 5", 2: end date formatted like "Oct 5" */ + __( 'From %1$s to %2$s', 'newspack-plugin' ), + formatShortDate( range.start ), + formatShortDate( range.end ) + ); + } +}; + +const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) => ( +
+

+ { getHeading( range ) } +

+
+ + + + + + +
+
+); + +export default WindowedSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/donors.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/donors.scss new file mode 100644 index 000000000..6806b5693 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/donors.scss @@ -0,0 +1,16 @@ +/** + * Newspack Insights — Tab 7 (Donors) styles (NPPD-1617) + * + * Tab 7-specific layout only. The shared Insights chrome (sections, + * metric cards, table with nested variation rows, tab loading/error) + * lives in `tabs/components/sections.scss` and is loaded by the + * wizard's main `style.scss`. + */ + +.newspack-insights { + &__donors-tab { + display: flex; + flex-direction: column; + gap: 32px; + } +} From 00f54266c3154a6fa313a204a577454cc93907b3 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 10:41:24 -0500 Subject: [PATCH 08/37] fix(insights): scope Tab 7 visibility to donation activity, not product existence Real bug: every Newspack publisher receives the canonical donation product family (Once / Monthly / Yearly + grouped parent) on install regardless of whether they ever collect donations. Tab 7 was showing on every site, including the many publishers who have never taken a donation, because Insights_Wizard::has_donation_products() only checked whether the donation product set was non-empty. Renamed to has_donation_activity() and replaced the product-existence check with an EXISTS query against the line-item tables: SELECT EXISTS ( SELECT 1 FROM {prefix}wc_orders o JOIN {prefix}woocommerce_order_items items ON items.order_id = o.id JOIN {prefix}woocommerce_order_itemmeta meta ON meta.order_item_id = items.order_item_id AND meta.meta_key = '_product_id' WHERE o.type IN ('shop_order', 'shop_subscription') AND meta.meta_value IN (:donation_product_ids) LIMIT 1 ); Legacy variant swaps wc_orders for posts and o.type for p.post_type. Backend dispatch via Storage_Detector::detect() so the query targets the authoritative source rather than a potentially stale opposite backend. Cached for 24h via newspack_insights_has_donation_activity transient. State transitions are rare and one-way so aggressive caching is correct. A new public static force_refresh_donation_activity() lets tests and the publisher's-first-donation case bypass and refresh the cache. Fast-paths: - Empty donation product set -> false immediately, no SQL. - Classifier class unavailable -> true (defensive; keeps tab visible so the missing dep can be diagnosed). Verified on local: 34 qualifying activity rows, has_donation_activity returns true. On a synthetic empty-activity environment the EXISTS would return 0 and the tab would hide. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/class-insights-wizard.php | 114 +++++++++++++++--- 1 file changed, 100 insertions(+), 14 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php index f1dab1eff..ceda8cf79 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -111,30 +111,116 @@ public function enqueue_scripts_and_styles() { } /** - * Build the boot config consumed by the React entry. + * Cache key for the donation-activity detection result. * - * @return array + * @var string */ + const DONATION_ACTIVITY_TRANSIENT = 'newspack_insights_has_donation_activity'; + /** * Donors tab visibility. True when the publisher has at least one - * donation product configured (per the union of canonical Newspack - * donation family + manually flagged products). + * donation-related order or subscription in their history. * - * Wraps the shared {@see \Newspack\Insights\Donation_Product_Classifier}. - * The classifier is loaded by the Donors / Subscribers sections' - * `init()` callbacks, both of which run before this boot config - * is requested by enqueue_scripts_and_styles. Falls back to true - * if the classifier class isn't available (defensive — the section - * file may have failed to load in development) so the tab still - * appears and the missing-dep can be diagnosed. + * Product existence is NOT a useful signal: every Newspack publisher + * receives the canonical donation product family on install regardless + * of whether they ever collect donations, so a product-existence + * check showed Tab 7 on every site, including the many publishers + * who have never taken a donation. Activity is the right heuristic — + * a single qualifying order or subscription gates the tab visible. + * + * Result is cached for 24h via {@see self::DONATION_ACTIVITY_TRANSIENT}. + * State transitions ("publisher started taking donations") are rare + * and one-way, so aggressive caching is correct. Tests / manual + * invalidation can call {@see self::force_refresh_donation_activity()}. + * + * Returns false immediately when the donation product ID set is + * empty (nothing the activity query could match) without running + * the EXISTS query. Falls back to true if the classifier class + * isn't loaded (defensive — preserves visibility so the missing + * dependency can be diagnosed rather than silently hiding the tab). * * @return bool */ - private static function has_donation_products(): bool { + private static function has_donation_activity(): bool { + $cached = get_transient( self::DONATION_ACTIVITY_TRANSIENT ); + if ( 'yes' === $cached ) { + return true; + } + if ( 'no' === $cached ) { + return false; + } + + $has_activity = self::compute_donation_activity(); + set_transient( self::DONATION_ACTIVITY_TRANSIENT, $has_activity ? 'yes' : 'no', DAY_IN_SECONDS ); + return $has_activity; + } + + /** + * Force-recompute the donation activity flag, bypassing and + * refreshing the cache. Useful for tests and for the case where a + * publisher just received their first donation. + * + * @return bool The freshly computed activity flag. + */ + public static function force_refresh_donation_activity(): bool { + delete_transient( self::DONATION_ACTIVITY_TRANSIENT ); + $has_activity = self::compute_donation_activity(); + set_transient( self::DONATION_ACTIVITY_TRANSIENT, $has_activity ? 'yes' : 'no', DAY_IN_SECONDS ); + return $has_activity; + } + + /** + * Run the activity query without consulting the cache. + * + * @return bool + */ + private static function compute_donation_activity(): bool { if ( ! class_exists( '\Newspack\Insights\Donation_Product_Classifier' ) ) { + // Defensive: keep tab visible so the missing dep can be diagnosed. return true; } - return ! empty( \Newspack\Insights\Donation_Product_Classifier::get_donation_product_ids() ); + $donation_ids = \Newspack\Insights\Donation_Product_Classifier::get_donation_product_ids(); + if ( empty( $donation_ids ) ) { + return false; + } + + global $wpdb; + $donations_list = implode( ',', array_map( 'intval', $donation_ids ) ); + + // Dispatch by backend so we read from the authoritative orders + // source rather than scanning a potentially stale legacy CPT + // table on HPOS sites (or vice versa). + $backend = class_exists( '\Newspack\Insights\Storage_Detector' ) + ? \Newspack\Insights\Storage_Detector::detect() + : 'legacy'; + + if ( 'hpos' === $backend ) { + $sql = "SELECT EXISTS ( + SELECT 1 FROM {$wpdb->prefix}wc_orders o + JOIN {$wpdb->prefix}woocommerce_order_items items ON items.order_id = o.id + JOIN {$wpdb->prefix}woocommerce_order_itemmeta meta + ON meta.order_item_id = items.order_item_id + AND meta.meta_key = '_product_id' + WHERE o.type IN ('shop_order', 'shop_subscription') + AND meta.meta_value IN ($donations_list) + LIMIT 1 + ) AS has_activity"; + } else { + $sql = "SELECT EXISTS ( + SELECT 1 FROM {$wpdb->prefix}posts p + JOIN {$wpdb->prefix}woocommerce_order_items items ON items.order_id = p.ID + JOIN {$wpdb->prefix}woocommerce_order_itemmeta meta + ON meta.order_item_id = items.order_item_id + AND meta.meta_key = '_product_id' + WHERE p.post_type IN ('shop_order', 'shop_subscription') + AND meta.meta_value IN ($donations_list) + LIMIT 1 + ) AS has_activity"; + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return (bool) (int) $wpdb->get_var( $sql ); + // phpcs:enable } /** @@ -166,7 +252,7 @@ protected function get_boot_config() { 'gates' => true, 'prompts' => true, 'subscribers' => true, - 'donors' => self::has_donation_products(), + 'donors' => self::has_donation_activity(), 'advertising' => true, ], 'defaultDateRange' => [ From ab41efed162148f28c60159bdea7a58c9be2dc97 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 11:26:08 -0500 Subject: [PATCH 09/37] fix(insights): wire Tab 7 DonorsTab orchestrator + refine copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator was never written when the Tab 7 React UI landed — DonorsTab.tsx was left as the original "Coming soon" stub, so even with the sections, hook, and API client present the tab body stayed on the placeholder. This swaps it for the real orchestrator, mirroring SubscribersTab's loading/error/success lifecycle and composing the four donor sections (Scorecard, Windowed, Retention, Performance). Also refines the One-Time Donation Revenue subtitle from "Gifts from non-subscription donation products" to "Gifts from non-recurring donations" — the former leaks an implementation detail (the product classifier's subscription check); the latter speaks to publishers in donor-flow terms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/tabs/DonorsTab.tsx | 65 ++++++++++++++++--- .../insights/tabs/donors/WindowedSection.tsx | 2 +- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx index b77dba80f..f036c6390 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx @@ -1,7 +1,13 @@ /** - * DonorsTab + * DonorsTab (NPPD-1617). * - * Stub. Real content lands in NPPD-1617. + * Orchestrates the Tab 7 view: fetches data for the active range + + * comparison range, then composes the four sections (scorecard, + * windowed, retention, performance). + * + * Loading / error states are local to this tab; the wizard chrome + * (date picker, comparison toggle, tab navigation) stays interactive + * while the tab body is in any state. */ /** @@ -9,11 +15,54 @@ */ import { __ } from '@wordpress/i18n'; -const DonorsTab = () => ( -
-

{ __( 'Donors', 'newspack-plugin' ) }

-

{ __( 'Coming soon', 'newspack-plugin' ) }

-
-); +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import useDonorsData from '../hooks/useDonorsData'; +import ScorecardSection from './donors/ScorecardSection'; +import WindowedSection from './donors/WindowedSection'; +import RetentionSection from './donors/RetentionSection'; +import PerformanceSection from './donors/PerformanceSection'; +import './donors/donors.scss'; + +export interface DonorsTabProps { + range: DateRange; + previousRange: DateRange | null; +} + +const DonorsTab = ( { range, previousRange }: DonorsTabProps ) => { + const { status, data, error } = useDonorsData( range, previousRange ); + + if ( status === 'loading' && ! data ) { + return ( +
+ { __( 'Loading donor data…', 'newspack-plugin' ) } +
+ ); + } + + if ( status === 'error' ) { + return ( +
+

{ __( 'Could not load donor data.', 'newspack-plugin' ) }

+ { error &&

{ error }

} +
+ ); + } + + if ( ! data ) { + return null; + } + + return ( +
+ + + + +
+ ); +}; export default DonorsTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx index bf780ad92..cd9ed124e 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx @@ -88,7 +88,7 @@ const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) = value={ current.one_time_revenue } format="currency" previousValue={ previous?.one_time_revenue } - description={ __( 'Gifts from non-subscription donation products', 'newspack-plugin' ) } + description={ __( 'Gifts from non-recurring donations', 'newspack-plugin' ) } /> Date: Thu, 4 Jun 2026 11:40:19 -0500 Subject: [PATCH 10/37] =?UTF-8?q?feat(insights):=20consolidate=20Tab=207?= =?UTF-8?q?=20scorecards=2013=20=E2=86=92=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pairs of related metrics now travel together on a single card, with the secondary value rendered below the primary as a compact "X annualized" / "N active recurring" / "$X one-time + $Y recurring" snippet. Same information, ~38% less surface area. Donors at a glance (4 → 2): Active Donors ← merges in Active Recurring Donors Donation MRR ← merges in Donation ARR In the last X days (6 → 4): Total Donation Revenue ← merges in One-Time + Recurring revenue (New Donors, Lapsed Donors, Average Gift unchanged) Retention unchanged (2 cards). Also adds the "Donors at a glance" section caption explicitly noting its current-state, timeframe-independent scope — separately requested in the same Tab 7 audit and only touches this file so it folds in here rather than a separate commit. MetricCard grew an optional `secondary?: string` prop and a matching `.newspack-insights__metric-card-secondary` slot in the shared sections SCSS (rendered between value and delta, gray-700, 13px). Investigation note: the browser-test anomalies that surfaced the $1,365 lifetime parity, $0 monthly recurring revenue in the 90d window, and 0% retention all trace to the same root cause — wc_order_product_lookup was under-populated for the test data generator's renewal orders, which are created programmatically with status set in the constructor and therefore don't trigger the WC analytics sync. Metric SQL is structurally correct; verified against the raw items + itemmeta path. Test generator (workspace-only) now mirrors line items into opl directly via REPLACE INTO so the metric queries see the full dataset on re-seed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/tabs/components/MetricCard.tsx | 11 ++++++- .../insights/tabs/components/sections.scss | 9 ++++++ .../insights/tabs/donors/ScorecardSection.tsx | 32 ++++++++++--------- .../insights/tabs/donors/WindowedSection.tsx | 30 +++++++---------- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx index 359a16fa4..2d6146a68 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -30,6 +30,14 @@ export interface MetricCardProps { previousValue?: number; description?: string; lowerIsBetter?: boolean; + /** + * Short secondary snippet rendered below the value, before the + * delta. Used for compressed paired metrics (e.g. "$X annualized" + * under MRR, "$Y one-time + $Z recurring" under Total Revenue, or + * "N active recurring" under Active Donors) so we can ship the + * paired insight without spending a whole card on it. + */ + secondary?: string; } const formatValue = ( v: number, fmt: MetricFormat ): string => { @@ -43,7 +51,7 @@ const formatValue = ( v: number, fmt: MetricFormat ): string => { }; const MetricCard = ( props: MetricCardProps ) => { - const { label, value, format, previousValue, description, lowerIsBetter = false } = props; + const { label, value, format, previousValue, description, lowerIsBetter = false, secondary } = props; const hasComparison = typeof previousValue === 'number'; const delta = hasComparison ? formatDelta( value, previousValue as number ) : null; const tone = hasComparison ? deltaTone( value, previousValue as number, lowerIsBetter ) : 'neutral'; @@ -61,6 +69,7 @@ const MetricCard = ( props: MetricCardProps ) => {
{ label }
{ formatValue( value, format ) }
+ { secondary &&
{ secondary }
} { hasComparison && delta && (
(

{ __( 'Donors at a glance', 'newspack-plugin' ) }

+

+ { __( 'Current state and recurring revenue, independent of selected timeframe.', 'newspack-plugin' ) } +

- -
); diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx index cd9ed124e..666ea3c8c 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx @@ -2,9 +2,10 @@ * WindowedSection (NPPD-1617). * * Tab 7 metrics scoped to the date range picker: new/lapsed donor - * counts, total/one-time/recurring revenue, and the average gift. - * Heading is dynamic ("In the last 30 days", "This month", etc.) — - * same pattern as Tab 6's WindowedSection. + * counts, total donation revenue (with an inline one-time + recurring + * breakdown as a secondary line), and the average gift. Heading is + * dynamic ("In the last 30 days", "This month", etc.) — same pattern + * as Tab 6's WindowedSection. */ /** @@ -18,6 +19,7 @@ import { __, sprintf } from '@wordpress/i18n'; import type { DonorsWindow } from '../../api/donors'; import type { DateRange } from '../../state/useDateRange'; import MetricCard from '../components/MetricCard'; +import { formatCurrency } from '../components/format'; export interface WindowedSectionProps { range: DateRange; @@ -81,21 +83,13 @@ const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) = value={ current.total_revenue } format="currency" previousValue={ previous?.total_revenue } - description={ __( 'One-time gifts + recurring renewals in selected timeframe', 'newspack-plugin' ) } - /> - - Date: Thu, 4 Jun 2026 11:44:01 -0500 Subject: [PATCH 11/37] feat(insights): refine Tab 7 copy + restrict Average Gift to one-time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Average Gift now sums only one-time donation orders (excludes both renewals and subscription initial installments) using the same `NOT EXISTS _subscription_period` predicate that scopes get_one_time_donation_revenue. Including renewals diluted the metric with predictable recurring amounts that say more about retention than donor generosity; including sub initial orders called the first slice of a recurring commitment a "gift", which it isn't in the donor's mental model. Card label renamed to "Average one-time gift" to match. Lapsed Donors description shortened to "Donors who stopped recurring giving in this timeframe" — parallels Tab 6's "Subscribers who churned in this timeframe." CACHE_PREFIX bumped tab7_v1 → tab7_v2 so prior cached Average Gift values don't linger; v1 entries expire on their own TTL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/metrics/class-donors-metric.php | 2 +- .../storage/class-donors-storage-interface.php | 10 ++++++++-- .../storage/class-hpos-donors-storage.php | 16 +++++++++++++++- .../storage/class-legacy-donors-storage.php | 10 +++++++++- .../insights/tabs/donors/WindowedSection.tsx | 6 +++--- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index a528317e9..e41bbdaec 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v1:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v2:'; /** * Cache TTL for windowed and snapshot metrics (30 min). diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index 634c390b1..a1fae93b5 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -126,11 +126,17 @@ public function get_one_time_donation_revenue( DateTimeInterface $start, DateTim public function get_recurring_donation_revenue( DateTimeInterface $start, DateTimeInterface $end ): float; /** - * Mean order total across donation `shop_order` rows in the window. + * Mean order total across one-time donation `shop_order` rows in + * the window. Excludes subscription renewals AND subscription + * initial installments — those distort the metric (predictable + * recurring amounts) and a sub initial order isn't a "gift" in + * the donor's mental model, it's the first slice of a recurring + * commitment. Filter is the same period-meta predicate that + * scopes {@see get_one_time_donation_revenue()}. * * @param DateTimeInterface $start Inclusive window start. * @param DateTimeInterface $end Inclusive window end. - * @return float Zero when there are no donation orders to average. + * @return float Zero when there are no one-time donation orders to average. */ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInterface $end ): float; diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index d05f2e494..fb13881f8 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -335,6 +335,14 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); + // Restrict to one-time gifts only. Including renewal orders + + // subscription initial installments would dilute the metric: + // renewals are predictable amounts that say more about + // retention than donor generosity, and a sub initial order is + // "first slice of a recurring commitment" not a gift in the + // donor's mental model. Use the same period-meta predicate + // that scopes get_one_time_donation_revenue so the two + // metrics agree on what "one-time" means. $sql = $wpdb->prepare( "SELECT AVG(o.total_amount) FROM {$prefix}wc_orders o @@ -342,7 +350,13 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt WHERE o.type = 'shop_order' AND o.status IN ('wc-completed', 'wc-processing') AND o.date_created_gmt BETWEEN %s AND %s - AND opl.product_id IN ($donations)", + AND opl.product_id IN ($donations) + AND NOT EXISTS ( + SELECT 1 FROM {$prefix}postmeta pm + WHERE pm.post_id = opl.product_id + AND pm.meta_key = '_subscription_period' + AND pm.meta_value IN ('day','week','month','year') + )", $this->fmt( $start ), $this->fmt( $end ) ); diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 1f7eb3b0c..7c9f291eb 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -330,6 +330,8 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); + // One-time gifts only. See HPOS implementation for rationale. + // Same period-meta predicate as get_one_time_donation_revenue. $sql = $wpdb->prepare( "SELECT AVG(CAST(tot.meta_value AS DECIMAL(15,2))) FROM {$prefix}posts p @@ -339,7 +341,13 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt WHERE p.post_type = 'shop_order' AND p.post_status IN ('wc-completed', 'wc-processing') AND p.post_date_gmt BETWEEN %s AND %s - AND opl.product_id IN ($donations)", + AND opl.product_id IN ($donations) + AND NOT EXISTS ( + SELECT 1 FROM {$prefix}postmeta pm + WHERE pm.post_id = opl.product_id + AND pm.meta_key = '_subscription_period' + AND pm.meta_value IN ('day','week','month','year') + )", $this->fmt( $start ), $this->fmt( $end ) ); diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx index 666ea3c8c..c41e22f58 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/WindowedSection.tsx @@ -76,7 +76,7 @@ const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) = format="number" previousValue={ previous?.lapsed_donors } lowerIsBetter - description={ __( 'Recurring donors who cancelled in this timeframe and have no active recurring donation', 'newspack-plugin' ) } + description={ __( 'Donors who stopped recurring giving in this timeframe', 'newspack-plugin' ) } />
From da10458b8900c8e07adbbd5d7e3df719497258dc Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 11:48:21 -0500 Subject: [PATCH 12/37] feat(insights): graceful empty state for Tab 7 retention section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage methods for both retention rates now return null (not 0) when their denominator cohort is empty: no donors lapsed in the prior window, or no recurring donors active at the window start. Lets the UI distinguish "no data yet" from a real 0%, which on a fresh or young site is the usual case for retention metrics. Three rendering modes in RetentionSection: both null → single section-wide "metrics will appear once…" card one null → keep the card with data + per-card "no … yet" note on the empty slot (preserves grid alignment) both numbers → render both normally Interface signature now `?float` for both methods; HPOS + legacy mirror the same null path. Orchestrator threads nullable through the cache helper without coercion. MetricCard's previousValue broadened to `number | null` so the comparison delta is silently suppressed when the previous-window rate is also "no data". CACHE_PREFIX bumped tab7_v2 → tab7_v3 so cached 0.0 values from before this change don't render as "0%" when the new path would return null. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/metrics/class-donors-metric.php | 22 ++-- .../class-donors-storage-interface.php | 18 ++- .../storage/class-hpos-donors-storage.php | 10 +- .../storage/class-legacy-donors-storage.php | 10 +- .../src/wizards/insights/api/donors.ts | 6 +- .../insights/tabs/components/MetricCard.tsx | 3 +- .../insights/tabs/components/sections.scss | 15 +++ .../insights/tabs/donors/RetentionSection.tsx | 108 ++++++++++++++---- 8 files changed, 141 insertions(+), 51 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index e41bbdaec..c4e17065b 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v2:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v3:'; /** * Cache TTL for windowed and snapshot metrics (30 min). @@ -264,12 +264,15 @@ function () use ( $start, $end ) { /** * Lapsed donor recovery rate. * + * Null when the prior-window lapsed cohort is empty — UI uses this + * to render a "no data yet" empty state instead of a misleading 0%. + * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return float|null */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float { - return (float) $this->cached( + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float { + $value = $this->cached( 'lapsed_donor_recovery_rate', $this->window_key( $start, $end ), self::TTL_HEAVY, @@ -277,17 +280,21 @@ function () use ( $start, $end ) { return $this->storage->get_lapsed_donor_recovery_rate( $start, $end ); } ); + return null === $value ? null : (float) $value; } /** * Recurring donor retention. * + * Null when no recurring donors were active at the window start — + * UI uses this to render a "no data yet" empty state. + * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return float|null */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float { - return (float) $this->cached( + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float { + $value = $this->cached( 'recurring_donor_retention', $this->window_key( $start, $end ), self::TTL_HEAVY, @@ -295,6 +302,7 @@ function () use ( $start, $end ) { return $this->storage->get_recurring_donor_retention( $start, $end ); } ); + return null === $value ? null : (float) $value; } /** diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index a1fae93b5..c0013241d 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -143,23 +143,29 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt /** * Of donors who lapsed in the prior window of equal length * preceding `[start, end]`, the fraction who made a new completed - * donation order in `[start, end]`. Range `[0, 1]`. Returns 0 when - * no donors lapsed in the prior window. + * donation order in `[start, end]`. Range `[0, 1]`. + * + * Returns `null` (not 0) when no donors lapsed in the prior window, + * so the UI can distinguish "no data yet" from a real 0% rate. * * Prior window = `[start - duration, start - 1 second]` where * `duration = end - start`. * * @param DateTimeInterface $start Current window start. * @param DateTimeInterface $end Current window end. - * @return float + * @return float|null Null when the prior-window lapsed cohort is empty. */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float; + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float; /** * Of recurring donation subscriptions that were active at the * window start, the fraction whose owner customer still has at * least one active recurring donation subscription right now. * + * Returns `null` (not 0) when no recurring donors were active at + * the window start, so the UI can distinguish "no data yet" from + * a real 0% retention. + * * "Active at start" = `_schedule_start <= :start` AND * (`_schedule_cancelled` empty OR > :start). The end check is * "currently active" (NOW), not "active at :end" — a v1 @@ -168,9 +174,9 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi * @param DateTimeInterface $start Current window start. * @param DateTimeInterface $end Current window end (used for * cache-key disambiguation only). - * @return float + * @return float|null Null when no recurring donors were active at start. */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float; + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float; /** * Per-product donor performance breakdown. One entry per parent diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index fb13881f8..0735c1c05 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -372,7 +372,7 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt * @param DateTimeInterface $end Window end. * @return float */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float { // Prior window of equal length immediately preceding current. $duration = $end->getTimestamp() - $start->getTimestamp(); $prior_end_ts = $start->getTimestamp() - 1; @@ -422,7 +422,7 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi $lapsed_customer_ids = $wpdb->get_col( $lapsed_sql ); if ( empty( $lapsed_customer_ids ) ) { - return 0.0; + return null; } $lapsed_count = count( $lapsed_customer_ids ); $lapsed_list = $this->id_list( array_map( 'intval', $lapsed_customer_ids ) ); @@ -453,7 +453,7 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi * @param DateTimeInterface $end Window end (unused — see docblock). * @return float */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -487,14 +487,14 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim ); $rows = $wpdb->get_results( $active_at_start_sql, ARRAY_A ); if ( empty( $rows ) ) { - return 0.0; + return null; } // Denominator: distinct customers who were active at start. $customers_active_at_start = array_unique( array_map( 'intval', array_column( $rows, 'customer_id' ) ) ); $denominator = count( $customers_active_at_start ); if ( 0 === $denominator ) { - return 0.0; + return null; } // Numerator: those customers who still have at least one diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 7c9f291eb..e253b121f 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -363,7 +363,7 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt * @param DateTimeInterface $end Window end. * @return float */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float { $duration = $end->getTimestamp() - $start->getTimestamp(); $prior_end_ts = $start->getTimestamp() - 1; $prior_start_ts = $prior_end_ts - $duration; @@ -414,7 +414,7 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi $lapsed_customer_ids = $wpdb->get_col( $lapsed_sql ); if ( empty( $lapsed_customer_ids ) ) { - return 0.0; + return null; } $lapsed_count = count( $lapsed_customer_ids ); $lapsed_list = $this->id_list( array_map( 'intval', $lapsed_customer_ids ) ); @@ -445,7 +445,7 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi * @param DateTimeInterface $end Window end (unused — see docblock). * @return float */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -478,13 +478,13 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim ); $rows = $wpdb->get_results( $active_at_start_sql, ARRAY_A ); if ( empty( $rows ) ) { - return 0.0; + return null; } $customers_active_at_start = array_unique( array_map( 'intval', array_column( $rows, 'customer_id' ) ) ); $denominator = count( $customers_active_at_start ); if ( 0 === $denominator ) { - return 0.0; + return null; } $customer_list = $this->id_list( $customers_active_at_start ); diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts index 8a5e55712..e89ff8dd4 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -58,8 +58,10 @@ export interface DonorsWindow { recurring_revenue: number; total_revenue: number; average_gift: number; - lapsed_donor_recovery_rate: number; - recurring_donor_retention: number; + /** Null when the prior-window lapsed cohort is empty ("no data yet"). */ + lapsed_donor_recovery_rate: number | null; + /** Null when no recurring donors were active at the window start. */ + recurring_donor_retention: number | null; donations_by_tier: DonorsTierRow[]; } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx index 2d6146a68..53c44499f 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -27,7 +27,8 @@ export interface MetricCardProps { label: string; value: number; format: MetricFormat; - previousValue?: number; + /** Null is treated the same as undefined — no comparison delta is rendered. */ + previousValue?: number | null; description?: string; lowerIsBetter?: boolean; /** diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss index 330a49a60..41b225f75 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss @@ -192,6 +192,21 @@ color: wp-colors.$gray-700; text-align: left; } + + // Empty-state variant: same card chrome as a populated metric + // (preserves grid alignment) but the body slot renders an + // explanatory line instead of a hero value. Used by sections + // where a per-metric "no data yet" message reads cleaner than + // collapsing to a section-wide block. + &--empty { + .newspack-insights__metric-card-empty-note { + margin: 16px 0 0; + font-size: 13px; + font-weight: 400; + line-height: 1.4; + color: wp-colors.$gray-700; + } + } } // Table — card wraps the table, table fills (padding 0 on diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx index 6e4148d2b..58ccb23ba 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx @@ -1,8 +1,15 @@ /** * RetentionSection (NPPD-1617). * - * Donor retention metrics. Both are window-scoped and visualised as - * percentages with a descriptive subtitle. + * Donor retention metrics. Both rates are derived from cohorts that + * may legitimately not exist yet on a fresh or young site (no donors + * lapsed in the prior window; no recurring donors active at the + * window start), so the storage returns `null` for those cases. The + * UI distinguishes "no data yet" (null) from a real 0%: + * + * both null → single section-wide explanatory card + * one null → render the card that has data + a note on the other + * both numbers → render both normally * * - Lapsed donor recovery rate: of donors who lapsed in the prior * window of equal length, the fraction who made a new donation in @@ -27,31 +34,82 @@ export interface RetentionSectionProps { previous: DonorsWindow | null; } -const RetentionSection = ( { current, previous }: RetentionSectionProps ) => ( -
+const RECOVERY_LABEL = () => __( 'Lapsed donor recovery rate', 'newspack-plugin' ); +const RECOVERY_DESCRIPTION = () => __( 'Donors who lapsed in the previous timeframe and returned to donate in this one', 'newspack-plugin' ); + +const RETENTION_LABEL = () => __( 'Recurring donor retention', 'newspack-plugin' ); +const RETENTION_DESCRIPTION = () => __( 'Recurring donors active at the start of this timeframe who are still active now', 'newspack-plugin' ); + +const RetentionSection = ( { current, previous }: RetentionSectionProps ) => { + const recoveryRate = current.lapsed_donor_recovery_rate; + const retentionRate = current.recurring_donor_retention; + const recoveryHasData = typeof recoveryRate === 'number'; + const retentionHasData = typeof retentionRate === 'number'; + + const sectionProps = { + className: 'newspack-insights__section newspack-insights__section--retention', + 'aria-labelledby': 'newspack-insights-donors-retention-heading', + }; + + const heading = (

{ __( 'Retention', 'newspack-plugin' ) }

-
- - -
-
-); + ); + + if ( ! recoveryHasData && ! retentionHasData ) { + return ( +
+ { heading } +

+ { __( + 'Retention metrics will appear once your data shows donors lapsing and returning, or recurring donors aging through the selected timeframe.', + 'newspack-plugin' + ) } +

+
+ ); + } + + return ( +
+ { heading } +
+ { recoveryHasData ? ( + + ) : ( +
+
{ RECOVERY_LABEL() }
+

+ { __( 'No donors lapsed in the previous timeframe yet.', 'newspack-plugin' ) } +

+
+ ) } + { retentionHasData ? ( + + ) : ( +
+
{ RETENTION_LABEL() }
+

+ { __( 'No recurring donors were active at the start of this timeframe.', 'newspack-plugin' ) } +

+
+ ) } +
+
+ ); +}; export default RetentionSection; From 4a075a8631828841dac59580e7ab489d6a0e7ba6 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 11:50:00 -0500 Subject: [PATCH 13/37] feat(insights): Tab 7 tier table caption + sort by lifetime revenue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side sort flips from active_recurring_donors DESC to lifetime_donation_revenue DESC for both the outer product list and each parent's nested variations. Largest products surface first — "largest products first" is the more useful default and matches Tab 6's performance table convention. PerformanceSection now renders a section caption above the table making the mixed temporal scope explicit: most columns are window-scoped to the date picker, but Lifetime Revenue is all-time. Without the caption, publishers tended to read the columns as uniformly scoped. Both HPOS and legacy storage's aggregate_tier_rows() updated in parallel. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/storage/class-hpos-donors-storage.php | 9 +++++---- .../storage/class-legacy-donors-storage.php | 2 +- .../insights/tabs/donors/PerformanceSection.tsx | 13 ++++++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index 0735c1c05..f05337988 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -680,9 +680,10 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa * variations shape. Mirrors the Tab 6 pattern but with Tab 7's * five-metric column set. * - * Each parent's variations are sorted by active_recurring_donors + * Each parent's variations are sorted by lifetime_donation_revenue * DESC. The outer list is sorted by aggregated - * active_recurring_donors DESC and truncated to top 50. + * lifetime_donation_revenue DESC and truncated to top 50 — same + * "largest products first" convention as Tab 6's performance table. * * @param array> $rows Merged per-variation rows. * @return array> @@ -749,7 +750,7 @@ private function aggregate_tier_rows( array $rows ): array { usort( $entry['variations'], static function ( $a, $b ) { - return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + return $b['lifetime_donation_revenue'] <=> $a['lifetime_donation_revenue']; } ); } @@ -760,7 +761,7 @@ static function ( $a, $b ) { usort( $out, static function ( $a, $b ) { - return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + return $b['lifetime_donation_revenue'] <=> $a['lifetime_donation_revenue']; } ); return array_slice( $out, 0, 50 ); diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index e253b121f..455e70604 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -716,7 +716,7 @@ private function aggregate_tier_rows( array $rows ): array { usort( $entry['variations'], static function ( $a, $b ) { - return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + return $b['lifetime_donation_revenue'] <=> $a['lifetime_donation_revenue']; } ); } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx index 69a9806a7..2841f78f3 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx @@ -4,7 +4,12 @@ * Donations by tier — table identical in shape to Tab 6's Performance * by product, with nested variation rows. Parent rows aggregate the * SUM of their variations; standalone products render as a single - * row. Top 50 parents/standalones server-side. + * row. Sorted by lifetime_donation_revenue DESC, top 50 server-side. + * + * Most columns are window-scoped to the date picker, but Lifetime + * Revenue is all-time. A caption above the table makes that mixed + * temporal scope explicit so publishers don't read the columns as + * uniformly scoped. */ /** @@ -46,6 +51,12 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => {

{ __( 'Donations by tier', 'newspack-plugin' ) }

+

+ { __( + 'Active recurring donors, new donors, one-time gifts, and recurring revenue are scoped to the selected timeframe. Lifetime revenue is the all-time total per product.', + 'newspack-plugin' + ) } +

From 3ec5a2b80ff3971aef476e64b19d499e7dcc024a Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 11:58:34 -0500 Subject: [PATCH 14/37] feat(insights): show non-applicable Tab 7 table cells as em-dashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage now emits a `billing_model` field on every tier row and variation, derived from the product's `_subscription_period` meta: `recurring` when the period is in (day, week, month, year), `one_time` otherwise. Parent rows inherit `recurring` if ANY variation is recurring (the canonical variable-subscription donation shape). PerformanceSection switches on billing_model and renders cells that don't apply to the row's billing model as a gray-700 em-dash ("—") instead of "0" or "$0.00": one-time product → "—" for Active recurring donors + Recurring revenue recurring product → "—" for One-time gifts Lifetime revenue and New donors apply to every donation order and still render numerically even when zero. The em-dash carries an "aria-label='Not applicable'" so screen readers don't announce a bare dash. Section caption tightened to the wording from the audit prompt: "Current state plus activity in the selected timeframe. Lifetime revenue is the all-time total per product." — calls out the mixed temporal scope more concisely than the prior phrasing. CACHE_PREFIX bumped tab7_v3 → tab7_v4 so v3-cached tier rows (missing billing_model) don't render every cell as em-dash on clients that just shipped this code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/metrics/class-donors-metric.php | 2 +- .../class-donors-storage-interface.php | 20 ++++++-- .../storage/class-hpos-donors-storage.php | 14 +++++ .../storage/class-legacy-donors-storage.php | 11 ++++ .../src/wizards/insights/api/donors.ts | 16 +++++- .../insights/tabs/components/sections.scss | 9 ++++ .../tabs/donors/PerformanceSection.tsx | 51 +++++++++++++------ 7 files changed, 101 insertions(+), 22 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index c4e17065b..db2fd61d0 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v3:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v4:'; /** * Cache TTL for windowed and snapshot metrics (30 min). diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index c0013241d..6179b0b79 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -180,10 +180,10 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim /** * Per-product donor performance breakdown. One entry per parent - * donation product (or standalone product), sorted by active - * recurring donor count descending, top 50. Parent entries carry - * a `variations` array with one entry per variation, sorted by - * active recurring donor count descending. + * donation product (or standalone product), sorted by lifetime + * revenue descending, top 50. Parent entries carry a `variations` + * array with one entry per variation, sorted by lifetime revenue + * descending. * * Columns per entry: * @@ -191,6 +191,7 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim * 'product_id' => int, * 'name' => string, * 'is_parent' => bool, + * 'billing_model' => 'recurring' | 'one_time', * 'active_recurring_donors' => int, * 'new_donors_in_window' => int, * 'one_time_gifts_in_window' => int, @@ -200,6 +201,7 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim * [ * 'variation_id' => int, * 'label' => string, // 'Monthly' / 'Annual' / etc + * 'billing_model' => 'recurring' | 'one_time', * 'active_recurring_donors' => int, * 'new_donors_in_window' => int, * 'one_time_gifts_in_window' => int, @@ -210,6 +212,16 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim * ], * ] * + * `billing_model` is derived from the product's `_subscription_period` + * meta: `recurring` when the period meta is in (day, week, month, + * year), else `one_time`. Parent rows inherit `recurring` if ANY + * variation is recurring (the canonical Newspack donation shape), + * else `one_time`. The UI uses this to render cells that don't + * apply to the product's billing model as em-dashes ("—") instead + * of misleading zeros: a one-time product can't have recurring + * donors or recurring revenue; a recurring product can't have + * one-time gifts. + * * `*_in_window` columns are window-scoped to `[start, end]`. * `active_recurring_donors` and `lifetime_donation_revenue` are * current state / lifetime respectively. Parent aggregates equal diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index f05337988..9696ce74a 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -703,12 +703,21 @@ private function aggregate_tier_rows( array $rows ): array { $recurring_revenue = (float) $row['recurring_revenue_in_window']; $lifetime_donation_revenue = (float) $row['lifetime_donation_revenue']; + $is_recurring = in_array( $period, [ 'day', 'week', 'month', 'year' ], true ); + $billing_model = $is_recurring ? 'recurring' : 'one_time'; + if ( $parent_id > 0 ) { if ( ! isset( $parents[ $parent_id ] ) ) { $parents[ $parent_id ] = [ 'product_id' => $parent_id, 'name' => '' !== $parent_name ? $parent_name : __( '(unnamed product)', 'newspack-plugin' ), 'is_parent' => true, + // Parent inherits 'recurring' if any variation is + // recurring (the canonical Newspack donation + // shape: a variable subscription with Monthly + + // Yearly variations). Set to 'one_time' here as + // the floor; upgraded below per variation. + 'billing_model' => 'one_time', 'active_recurring_donors' => 0, 'new_donors_in_window' => 0, 'one_time_gifts_in_window' => 0, @@ -717,6 +726,9 @@ private function aggregate_tier_rows( array $rows ): array { 'variations' => [], ]; } + if ( $is_recurring ) { + $parents[ $parent_id ]['billing_model'] = 'recurring'; + } $parents[ $parent_id ]['active_recurring_donors'] += $active_recurring_donors; $parents[ $parent_id ]['new_donors_in_window'] += $new_donors; $parents[ $parent_id ]['one_time_gifts_in_window'] += $one_time_gifts; @@ -725,6 +737,7 @@ private function aggregate_tier_rows( array $rows ): array { $parents[ $parent_id ]['variations'][] = [ 'variation_id' => $variation_id, 'label' => $this->variation_label( $period, $variation_name, $parent_name ), + 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, @@ -736,6 +749,7 @@ private function aggregate_tier_rows( array $rows ): array { 'product_id' => $variation_id, 'name' => '' !== $variation_name ? $variation_name : __( '(unnamed product)', 'newspack-plugin' ), 'is_parent' => false, + 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 455e70604..7e5d23883 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -669,12 +669,18 @@ private function aggregate_tier_rows( array $rows ): array { $recurring_revenue = (float) $row['recurring_revenue_in_window']; $lifetime_donation_revenue = (float) $row['lifetime_donation_revenue']; + $is_recurring = in_array( $period, [ 'day', 'week', 'month', 'year' ], true ); + $billing_model = $is_recurring ? 'recurring' : 'one_time'; + if ( $parent_id > 0 ) { if ( ! isset( $parents[ $parent_id ] ) ) { $parents[ $parent_id ] = [ 'product_id' => $parent_id, 'name' => '' !== $parent_name ? $parent_name : __( '(unnamed product)', 'newspack-plugin' ), 'is_parent' => true, + // See HPOS implementation for the floor + + // upgrade-on-recurring-variation pattern. + 'billing_model' => 'one_time', 'active_recurring_donors' => 0, 'new_donors_in_window' => 0, 'one_time_gifts_in_window' => 0, @@ -683,6 +689,9 @@ private function aggregate_tier_rows( array $rows ): array { 'variations' => [], ]; } + if ( $is_recurring ) { + $parents[ $parent_id ]['billing_model'] = 'recurring'; + } $parents[ $parent_id ]['active_recurring_donors'] += $active_recurring_donors; $parents[ $parent_id ]['new_donors_in_window'] += $new_donors; $parents[ $parent_id ]['one_time_gifts_in_window'] += $one_time_gifts; @@ -691,6 +700,7 @@ private function aggregate_tier_rows( array $rows ): array { $parents[ $parent_id ]['variations'][] = [ 'variation_id' => $variation_id, 'label' => $this->variation_label( $period, $variation_name, $parent_name ), + 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, @@ -702,6 +712,7 @@ private function aggregate_tier_rows( array $rows ): array { 'product_id' => $variation_id, 'name' => '' !== $variation_name ? $variation_name : __( '(unnamed product)', 'newspack-plugin' ), 'is_parent' => false, + 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts index e89ff8dd4..587e3bca2 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -27,9 +27,18 @@ export interface DonorsSnapshot { donation_arr: number; } +/** + * Whether a product is sold as recurring or one-time. Derived + * server-side from the product's `_subscription_period` meta. The + * UI uses this to render cells that don't apply to the product's + * billing model as em-dashes instead of misleading zeros. + */ +export type BillingModel = 'recurring' | 'one_time'; + export interface DonorsTierVariationRow { variation_id: number; label: string; + billing_model: BillingModel; active_recurring_donors: number; new_donors_in_window: number; one_time_gifts_in_window: number; @@ -41,12 +50,17 @@ export interface DonorsTierRow { product_id: number; name: string; is_parent: boolean; + /** + * For variable subscription parents, this is `recurring` if ANY + * variation is recurring (the canonical Newspack donation shape). + */ + billing_model: BillingModel; active_recurring_donors: number; new_donors_in_window: number; one_time_gifts_in_window: number; recurring_revenue_in_window: number; lifetime_donation_revenue: number; - /** Present only when `is_parent` is true. Sorted by active_recurring_donors descending. */ + /** Present only when `is_parent` is true. Sorted by lifetime_donation_revenue descending. */ variations?: DonorsTierVariationRow[]; } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss index 41b225f75..be1ba5f1f 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss @@ -251,6 +251,15 @@ font-variant-numeric: tabular-nums; } + // Em-dash for cells where the column doesn't apply to the row + // (one-time products can't have recurring donors / recurring + // revenue; recurring products can't have one-time gifts). + // Visually subordinate — gray-700, matching the body-text color + // — to read as "not applicable" rather than "data missing." + &-na { + color: wp-colors.$gray-700; + } + // Nested variation/child rows sit directly under their parent. // Same chrome as parent rows, no background change, just a // slightly lighter text color (gray-700) and an indent on the diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx index 2841f78f3..10514281b 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx @@ -6,10 +6,12 @@ * SUM of their variations; standalone products render as a single * row. Sorted by lifetime_donation_revenue DESC, top 50 server-side. * - * Most columns are window-scoped to the date picker, but Lifetime - * Revenue is all-time. A caption above the table makes that mixed - * temporal scope explicit so publishers don't read the columns as - * uniformly scoped. + * Mixed temporal scope (current state + window + lifetime) is called + * out in the section caption. Cells that don't apply to the row's + * billing model — recurring donors / recurring revenue on a one-time + * product, one-time gifts on a recurring product — render as em-dash + * ("—") rather than 0/$0.00, which would read as "could be higher + * but isn't" instead of "doesn't apply." */ /** @@ -21,13 +23,38 @@ import { Fragment } from '@wordpress/element'; /** * Internal dependencies */ -import type { DonorsTierRow } from '../../api/donors'; +import type { BillingModel, DonorsTierRow, DonorsTierVariationRow } from '../../api/donors'; import { formatCurrency, formatNumber } from '../components/format'; export interface PerformanceSectionProps { rows: DonorsTierRow[]; } +const NotApplicable = () => ( + + — + +); + +const renderCount = ( applies: boolean, value: number ) => ( applies ? formatNumber( value ) : ); +const renderCurrency = ( applies: boolean, value: number ) => ( applies ? formatCurrency( value ) : ); + +const appliesActiveRecurring = ( m: BillingModel ) => m === 'recurring'; +const appliesOneTimeGifts = ( m: BillingModel ) => m === 'one_time'; +const appliesRecurringRevenue = ( m: BillingModel ) => m === 'recurring'; + +const renderRowCells = ( row: DonorsTierRow | DonorsTierVariationRow ) => ( + <> + + + + + + +); + const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { if ( rows.length === 0 ) { return ( @@ -53,7 +80,7 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => {

{ __( - 'Active recurring donors, new donors, one-time gifts, and recurring revenue are scoped to the selected timeframe. Lifetime revenue is the all-time total per product.', + 'Current state plus activity in the selected timeframe. Lifetime revenue is the all-time total per product.', 'newspack-plugin' ) }

@@ -84,21 +111,13 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { - - - - - + { renderRowCells( row ) } { row.is_parent && row.variations?.map( v => ( - - - - - + { renderRowCells( v ) } ) ) } From b958076286cb8ad6a0bb3ebb21033b9b4db164fd Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 12:13:00 -0500 Subject: [PATCH 15/37] feat(insights): structured retention rates + small-cohort denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage methods for both retention rates now return an explicit `{value, computable, denominator}` shape instead of nullable float. Functionally equivalent on the empty-cohort case (computable=false plays the same role as the prior null) but exposes the cohort size to the UI so small-cohort 0% reads as "0% of 2 donors" rather than bare "0%" — which a publisher reads as a catastrophe when the math is actually just statistical noise from a thin denominator. Browser-test diagnostic before this change: recurring retention denom = 2 (2 active at start, 0 still active) lapsed recovery denom = 1 (1 lapsed, 0 returned) Both legitimately compute to 0% under the seeded data. Empty-state was correctly NOT triggered (denominators are non-zero), but the bare "0%" was alarming. Surfacing "of 2 donors" / "of 1 donor" inline makes the small sample explicit. UI behavior unchanged at the boundaries: both non-computable still collapses to the section-wide explanatory card, one non-computable still keeps the populated card and renders an empty note on the other slot. The middle case (computable with any denominator) now shows the cohort size. Interface signature `?float` → `array{value, computable, denominator}`. Orchestrator threads the array through with no coercion. MetricCard unchanged — RetentionSection just unwraps the value to pass in. CACHE_PREFIX bumped tab7_v4 → tab7_v5 so cached float values don't crash the new array unwrap on rolling deploys. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/metrics/class-donors-metric.php | 26 ++++---- .../class-donors-storage-interface.php | 39 ++++++++---- .../storage/class-hpos-donors-storage.php | 34 ++++++++--- .../storage/class-legacy-donors-storage.php | 34 ++++++++--- .../src/wizards/insights/api/donors.ts | 30 ++++++++-- .../insights/tabs/donors/RetentionSection.tsx | 59 ++++++++++++------- 6 files changed, 159 insertions(+), 63 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index db2fd61d0..d6a68a640 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v4:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v5:'; /** * Cache TTL for windowed and snapshot metrics (30 min). @@ -264,15 +264,17 @@ function () use ( $start, $end ) { /** * Lapsed donor recovery rate. * - * Null when the prior-window lapsed cohort is empty — UI uses this - * to render a "no data yet" empty state instead of a misleading 0%. + * Returns the explicit `{value, computable, denominator}` shape + * from storage. UI renders an empty state when `computable` is + * false and surfaces `denominator` inline so small-cohort 0% + * reads as "0% (0 of N donors)" rather than bare 0%. * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float|null + * @return array{value: float, computable: bool, denominator: int} */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float { - $value = $this->cached( + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( 'lapsed_donor_recovery_rate', $this->window_key( $start, $end ), self::TTL_HEAVY, @@ -280,21 +282,20 @@ function () use ( $start, $end ) { return $this->storage->get_lapsed_donor_recovery_rate( $start, $end ); } ); - return null === $value ? null : (float) $value; } /** * Recurring donor retention. * - * Null when no recurring donors were active at the window start — - * UI uses this to render a "no data yet" empty state. + * See {@see get_lapsed_donor_recovery_rate()} for the response + * shape and UI contract. * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float|null + * @return array{value: float, computable: bool, denominator: int} */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float { - $value = $this->cached( + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( 'recurring_donor_retention', $this->window_key( $start, $end ), self::TTL_HEAVY, @@ -302,7 +303,6 @@ function () use ( $start, $end ) { return $this->storage->get_recurring_donor_retention( $start, $end ); } ); - return null === $value ? null : (float) $value; } /** diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index 6179b0b79..eebb2013f 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -143,40 +143,55 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt /** * Of donors who lapsed in the prior window of equal length * preceding `[start, end]`, the fraction who made a new completed - * donation order in `[start, end]`. Range `[0, 1]`. - * - * Returns `null` (not 0) when no donors lapsed in the prior window, - * so the UI can distinguish "no data yet" from a real 0% rate. + * donation order in `[start, end]`. * * Prior window = `[start - duration, start - 1 second]` where * `duration = end - start`. * + * Return shape: + * [ + * 'value' => float, // recovered / lapsed, range [0,1], 0 when not computable + * 'computable' => bool, // false when denominator is 0 (no lapsed cohort) + * 'denominator' => int, // size of the prior-window lapsed cohort + * ] + * + * The UI uses `computable` to render a "no data yet" empty state + * instead of a misleading 0%, and surfaces `denominator` inline so + * publishers can read "0% (0 of 3 donors)" rather than bare "0%" + * when the math is real but the cohort is small. + * * @param DateTimeInterface $start Current window start. * @param DateTimeInterface $end Current window end. - * @return float|null Null when the prior-window lapsed cohort is empty. + * @return array{value: float, computable: bool, denominator: int} */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float; + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): array; /** * Of recurring donation subscriptions that were active at the * window start, the fraction whose owner customer still has at * least one active recurring donation subscription right now. * - * Returns `null` (not 0) when no recurring donors were active at - * the window start, so the UI can distinguish "no data yet" from - * a real 0% retention. - * * "Active at start" = `_schedule_start <= :start` AND * (`_schedule_cancelled` empty OR > :start). The end check is * "currently active" (NOW), not "active at :end" — a v1 * simplification documented inline on the query. * + * Return shape: + * [ + * 'value' => float, // still_active / active_at_start, range [0,1], 0 when not computable + * 'computable' => bool, // false when denominator is 0 (no recurring donors at start) + * 'denominator' => int, // distinct customers active at window start + * ] + * + * See {@see get_lapsed_donor_recovery_rate()} for the UI contract + * on `computable` and `denominator`. + * * @param DateTimeInterface $start Current window start. * @param DateTimeInterface $end Current window end (used for * cache-key disambiguation only). - * @return float|null Null when no recurring donors were active at start. + * @return array{value: float, computable: bool, denominator: int} */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float; + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): array; /** * Per-product donor performance breakdown. One entry per parent diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index 9696ce74a..cee67eec4 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -372,7 +372,7 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt * @param DateTimeInterface $end Window end. * @return float */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float { + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): array { // Prior window of equal length immediately preceding current. $duration = $end->getTimestamp() - $start->getTimestamp(); $prior_end_ts = $start->getTimestamp() - 1; @@ -422,7 +422,11 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi $lapsed_customer_ids = $wpdb->get_col( $lapsed_sql ); if ( empty( $lapsed_customer_ids ) ) { - return null; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } $lapsed_count = count( $lapsed_customer_ids ); $lapsed_list = $this->id_list( array_map( 'intval', $lapsed_customer_ids ) ); @@ -443,7 +447,11 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi ); $recovered = (int) $wpdb->get_var( $recovered_sql ); - return $recovered / $lapsed_count; + return [ + 'value' => $recovered / $lapsed_count, + 'computable' => true, + 'denominator' => $lapsed_count, + ]; } /** @@ -453,7 +461,7 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi * @param DateTimeInterface $end Window end (unused — see docblock). * @return float */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float { + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -487,14 +495,22 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim ); $rows = $wpdb->get_results( $active_at_start_sql, ARRAY_A ); if ( empty( $rows ) ) { - return null; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } // Denominator: distinct customers who were active at start. $customers_active_at_start = array_unique( array_map( 'intval', array_column( $rows, 'customer_id' ) ) ); $denominator = count( $customers_active_at_start ); if ( 0 === $denominator ) { - return null; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } // Numerator: those customers who still have at least one @@ -512,7 +528,11 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim AND o.customer_id IN ($customer_list)"; $numerator = (int) $wpdb->get_var( $numerator_sql ); - return $numerator / $denominator; + return [ + 'value' => $numerator / $denominator, + 'computable' => true, + 'denominator' => $denominator, + ]; } /** diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 7e5d23883..af372cab6 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -363,7 +363,7 @@ public function get_average_donation_gift( DateTimeInterface $start, DateTimeInt * @param DateTimeInterface $end Window end. * @return float */ - public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): ?float { + public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTimeInterface $end ): array { $duration = $end->getTimestamp() - $start->getTimestamp(); $prior_end_ts = $start->getTimestamp() - 1; $prior_start_ts = $prior_end_ts - $duration; @@ -414,7 +414,11 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi $lapsed_customer_ids = $wpdb->get_col( $lapsed_sql ); if ( empty( $lapsed_customer_ids ) ) { - return null; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } $lapsed_count = count( $lapsed_customer_ids ); $lapsed_list = $this->id_list( array_map( 'intval', $lapsed_customer_ids ) ); @@ -435,7 +439,11 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi ); $recovered = (int) $wpdb->get_var( $recovered_sql ); - return $recovered / $lapsed_count; + return [ + 'value' => $recovered / $lapsed_count, + 'computable' => true, + 'denominator' => $lapsed_count, + ]; } /** @@ -445,7 +453,7 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi * @param DateTimeInterface $end Window end (unused — see docblock). * @return float */ - public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): ?float { + public function get_recurring_donor_retention( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -478,13 +486,21 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim ); $rows = $wpdb->get_results( $active_at_start_sql, ARRAY_A ); if ( empty( $rows ) ) { - return null; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } $customers_active_at_start = array_unique( array_map( 'intval', array_column( $rows, 'customer_id' ) ) ); $denominator = count( $customers_active_at_start ); if ( 0 === $denominator ) { - return null; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } $customer_list = $this->id_list( $customers_active_at_start ); @@ -502,7 +518,11 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim AND CAST(cust.meta_value AS UNSIGNED) IN ($customer_list)"; $numerator = (int) $wpdb->get_var( $numerator_sql ); - return $numerator / $denominator; + return [ + 'value' => $numerator / $denominator, + 'computable' => true, + 'denominator' => $denominator, + ]; } /** diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts index 587e3bca2..81d2fde34 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -35,6 +35,18 @@ export interface DonorsSnapshot { */ export type BillingModel = 'recurring' | 'one_time'; +/** + * A rate metric whose denominator may legitimately be zero. The UI + * uses `computable` to decide between rendering the value and a + * "no data yet" empty state, and surfaces `denominator` inline as + * context when the value is real but the cohort is small. + */ +export interface DonorsRateValue { + value: number; + computable: boolean; + denominator: number; +} + export interface DonorsTierVariationRow { variation_id: number; label: string; @@ -72,10 +84,20 @@ export interface DonorsWindow { recurring_revenue: number; total_revenue: number; average_gift: number; - /** Null when the prior-window lapsed cohort is empty ("no data yet"). */ - lapsed_donor_recovery_rate: number | null; - /** Null when no recurring donors were active at the window start. */ - recurring_donor_retention: number | null; + /** + * Lapsed-donor recovery rate. + * + * `computable: false` when the prior-window lapsed cohort is + * empty (no donors to recover) — UI renders an empty state. + * `denominator` is surfaced in the subtitle so small-cohort 0% + * reads as "0% (0 of N donors)" rather than bare 0%. + */ + lapsed_donor_recovery_rate: DonorsRateValue; + /** + * Recurring-donor retention. Same shape and UI contract as + * `lapsed_donor_recovery_rate`. + */ + recurring_donor_retention: DonorsRateValue; donations_by_tier: DonorsTierRow[]; } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx index 58ccb23ba..3d5d36aca 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/RetentionSection.tsx @@ -4,12 +4,19 @@ * Donor retention metrics. Both rates are derived from cohorts that * may legitimately not exist yet on a fresh or young site (no donors * lapsed in the prior window; no recurring donors active at the - * window start), so the storage returns `null` for those cases. The - * UI distinguishes "no data yet" (null) from a real 0%: + * window start), and even when they do exist the cohort can be small + * enough that a real 0% reads as catastrophic without context. * - * both null → single section-wide explanatory card - * one null → render the card that has data + a note on the other - * both numbers → render both normally + * Storage returns `{ value, computable, denominator }` for each rate + * so the UI can: + * + * both !computable → single section-wide explanatory card + * one !computable → keep the card with data + per-card empty + * note on the other (preserves grid alignment) + * both computable → render the rate as a card, with the + * denominator surfaced inline ("0% of 2 donors") + * so small-cohort 0% reads as honest math + * rather than a catastrophe. * * - Lapsed donor recovery rate: of donors who lapsed in the prior * window of equal length, the fraction who made a new donation in @@ -21,13 +28,14 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { DonorsWindow } from '../../api/donors'; +import type { DonorsRateValue, DonorsWindow } from '../../api/donors'; import MetricCard from '../components/MetricCard'; +import { formatNumber } from '../components/format'; export interface RetentionSectionProps { current: DonorsWindow; @@ -40,11 +48,20 @@ const RECOVERY_DESCRIPTION = () => __( 'Donors who lapsed in the previous timefr const RETENTION_LABEL = () => __( 'Recurring donor retention', 'newspack-plugin' ); const RETENTION_DESCRIPTION = () => __( 'Recurring donors active at the start of this timeframe who are still active now', 'newspack-plugin' ); +const cohortSubtitle = ( denominator: number ): string => + sprintf( + /* translators: %s: cohort denominator size, e.g. "of 2 donors" */ + __( 'of %s', 'newspack-plugin' ), + sprintf( + /* translators: %s: count of donors in the comparison cohort */ + _n( '%s donor', '%s donors', denominator, 'newspack-plugin' ), + formatNumber( denominator ) + ) + ); + const RetentionSection = ( { current, previous }: RetentionSectionProps ) => { - const recoveryRate = current.lapsed_donor_recovery_rate; - const retentionRate = current.recurring_donor_retention; - const recoveryHasData = typeof recoveryRate === 'number'; - const retentionHasData = typeof retentionRate === 'number'; + const recovery: DonorsRateValue = current.lapsed_donor_recovery_rate; + const retention: DonorsRateValue = current.recurring_donor_retention; const sectionProps = { className: 'newspack-insights__section newspack-insights__section--retention', @@ -57,7 +74,7 @@ const RetentionSection = ( { current, previous }: RetentionSectionProps ) => { ); - if ( ! recoveryHasData && ! retentionHasData ) { + if ( ! recovery.computable && ! retention.computable ) { return (
{ heading } @@ -75,35 +92,37 @@ const RetentionSection = ( { current, previous }: RetentionSectionProps ) => {
{ heading }
- { recoveryHasData ? ( + { recovery.computable ? ( ) : (
{ RECOVERY_LABEL() }

- { __( 'No donors lapsed in the previous timeframe yet.', 'newspack-plugin' ) } + { __( 'No donors lapsed in the prior timeframe yet.', 'newspack-plugin' ) }

) } - { retentionHasData ? ( + { retention.computable ? ( ) : (
{ RETENTION_LABEL() }

- { __( 'No recurring donors were active at the start of this timeframe.', 'newspack-plugin' ) } + { __( 'No recurring donors at the start of this timeframe.', 'newspack-plugin' ) }

) } From c375cd94e2975a796b3b4ce94c4a38b0ecec1838 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 14:06:10 -0500 Subject: [PATCH 16/37] feat(insights): mirror Tab 7 visual patterns onto Tab 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Tab 7 patterns brought over for cross-tab consistency: 1. Section caption under "Subscribers at a glance" using the same wording as Tab 7's "Donors at a glance" — flags the temporal independence of the current-state metrics from the date picker. 2. "Performance by product" caption now also names the lifetime revenue scope, parallel to Tab 7's tier table caption. The "customer with two subscriptions counts in both rows" detail was already implied by the leading "(subscriptions, not unique customers)" parenthetical, so it folds away cleanly. 3. ARR card merged into MRR's secondary line as "$X annualized" — same merge that landed on Tab 7's Donation MRR. "Subscribers at a glance" goes 4 → 3 cards (Active, MRR, Upcoming Renewals); Upcoming Renewals stays as its own card per the audit's "no Tab 7 analog" note. Shared `.newspack-insights__section-caption` SCSS bumped 13px → 14px with line-height 1.5 so the new + existing captions read as considered information rather than fine-print disclaimer. Affects both tabs. Tab 7's WindowedSection getHeading() pattern was already present on Tab 6 from the Tab 6 build — no parity work needed there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/tabs/components/sections.scss | 3 ++- .../tabs/subscribers/PerformanceSection.tsx | 2 +- .../tabs/subscribers/ScorecardSection.tsx | 24 ++++++++++--------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss index be1ba5f1f..7f646778f 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss @@ -56,8 +56,9 @@ &__section-caption { margin: 0; - font-size: 13px; + font-size: 14px; font-weight: 400; + line-height: 1.5; color: wp-colors.$gray-700; } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx index 841ea8245..ebda0d36f 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx @@ -53,7 +53,7 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => {

{ __( - 'Active subscriptions per product (subscriptions, not unique customers). A customer with two active subscriptions counts in both products’ rows.', + 'Active subscriptions per product (subscriptions, not unique customers). Lifetime revenue is the all-time total per product.', 'newspack-plugin' ) }

diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx index d2c174772..ea71a9531 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx @@ -2,10 +2,9 @@ * ScorecardSection (NPPD-1616). * * "Subscribers at a glance" — current-state metrics that do NOT depend - * on the date range picker. Active subscribers and MRR/ARR reflect - * what's true right now; upcoming renewals (30d) is a forward-looking - * snapshot of currently-active subscriptions but is also independent - * of the picker. + * on the date range picker. Active subscribers, MRR (with ARR rolled + * in as a secondary line), and upcoming renewals all reflect what's + * true right now. * * Window-scoped metrics (new/churned, gross/net revenue, refund rate, * retry rate) live in {@see WindowedSection} below this one. @@ -14,13 +13,14 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import type { SubscribersSnapshot } from '../../api/subscribers'; import MetricCard from '../components/MetricCard'; +import { formatCurrency } from '../components/format'; export interface ScorecardSectionProps { snapshot: SubscribersSnapshot; @@ -31,6 +31,9 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => (

{ __( 'Subscribers at a glance', 'newspack-plugin' ) }

+

+ { __( 'Current state and recurring revenue, independent of selected timeframe.', 'newspack-plugin' ) } +

( label={ __( 'Monthly recurring revenue', 'newspack-plugin' ) } value={ snapshot.mrr } format="currency" + secondary={ sprintf( + /* translators: %s: annualized subscription revenue (MRR × 12), formatted as currency */ + __( '%s annualized', 'newspack-plugin' ), + formatCurrency( snapshot.arr ) + ) } description={ __( 'Normalized across billing periods', 'newspack-plugin' ) } /> - Date: Thu, 4 Jun 2026 14:08:57 -0500 Subject: [PATCH 17/37] feat(insights): structured Tab 6 rate metrics + denominator context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refund rate and Failed payment recovery now return the same {value, computable, denominator} shape that Tab 7's retention rates adopted, so the UI can: - render small-cohort 0% as "0% of N orders / N retries" inline, instead of bare "0%" that reads as catastrophic news - swap to a per-card empty state when the denominator is 0: refund_rate → "No subscription orders in this timeframe." retry_rate → "No payment retries in this timeframe." Both cards keep their MetricCard slot in the windowed grid; the empty variant reuses the `--empty` chrome introduced for Tab 7 retention so grid alignment is preserved. Interface signature `float` → `array{value, computable, denominator}`. HPOS + legacy storage methods + Subscribers_Metric orchestrator threaded the array through without coercion. REST controller is a pass-through (no shape change needed). React side now imports a sibling SubscribersRateValue interface paralleling Tab 7's DonorsRateValue; pluralized "of N orders / N retries" via _n() so the singular case ("of 1 order") reads naturally. CACHE_PREFIX bumped tab6_v2 → tab6_v3 so cached float values from v2 don't blow up the array unwrap on rolling deploys. Net Revenue judgment (audit item 4): kept as a separate card. Total/one-time/recurring on Tab 7 is an additive decomposition with no load-bearing slice; Gross/Net on Tab 6 is subtractive and Net is the operationally important number that publishers track. Merging Net into a Gross subtitle would demote the load-bearing metric. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../metrics/class-subscribers-metric.php | 23 ++- .../insights/storage/class-hpos-storage.php | 30 +++- .../insights/storage/class-legacy-storage.php | 30 +++- .../storage/class-storage-interface.php | 36 +++- .../src/wizards/insights/api/subscribers.ts | 25 ++- .../tabs/subscribers/WindowedSection.tsx | 167 ++++++++++++------ 6 files changed, 226 insertions(+), 85 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php index 12f541990..412a29329 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php @@ -42,7 +42,7 @@ class Subscribers_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab6_v2:'; + const CACHE_PREFIX = 'newspack_insights_tab6_v3:'; /** * Cache TTL for windowed and snapshot metrics (30 min). @@ -236,12 +236,18 @@ function () use ( $start, $end ) { /** * Subscription refund rate in window. * + * Returns the explicit `{value, computable, denominator}` shape + * from storage. UI renders a "No subscription orders in this + * timeframe" empty state when `computable` is false and surfaces + * `denominator` inline so small-cohort 0% reads as "0% of N + * orders" rather than bare 0%. + * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return array{value: float, computable: bool, denominator: int} */ - public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float { - return (float) $this->cached( + public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( 'subscription_refund_rate', $this->window_key( $start, $end ), self::TTL_DEFAULT, @@ -296,12 +302,15 @@ function () { /** * Failed payment retry rate (recoveries / attempts) in window. * + * See {@see get_subscription_refund_rate()} for the response shape + * and UI contract. + * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return array{value: float, computable: bool, denominator: int} */ - public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float { - return (float) $this->cached( + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( 'failed_payment_retry_rate', $this->window_key( $start, $end ), self::TTL_DEFAULT, diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php index 9b480214d..566cbbd28 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php @@ -423,7 +423,7 @@ public function get_subscription_revenue_net( DateTimeInterface $start, DateTime * @param DateTimeInterface $end Window end. * @return float */ - public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -448,7 +448,11 @@ public function get_subscription_refund_rate( DateTimeInterface $start, DateTime $orders = (int) $wpdb->get_var( $orders_sql ); if ( 0 === $orders ) { - return 0.0; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } // Count refunds in window whose parent order had a subscription product. @@ -468,7 +472,11 @@ public function get_subscription_refund_rate( DateTimeInterface $start, DateTime ); $refunds = (int) $wpdb->get_var( $refunds_sql ); - return $refunds / $orders; + return [ + 'value' => $refunds / $orders, + 'computable' => true, + 'denominator' => $orders, + ]; } /** @@ -558,7 +566,7 @@ public function get_upcoming_renewals_30d(): array { * @param DateTimeInterface $end Window end. * @return float */ - public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -594,7 +602,19 @@ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTim $attempt = (int) ( $row['retry_attempts'] ?? 0 ); $success = (int) ( $row['recoveries'] ?? 0 ); - return 0 === $attempt ? 0.0 : $success / $attempt; + if ( 0 === $attempt ) { + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; + } + + return [ + 'value' => $success / $attempt, + 'computable' => true, + 'denominator' => $attempt, + ]; } /** diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php index 81782c8a2..7a1bf26d6 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php @@ -403,7 +403,7 @@ public function get_subscription_revenue_net( DateTimeInterface $start, DateTime * @param DateTimeInterface $end Window end. * @return float */ - public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -427,7 +427,11 @@ public function get_subscription_refund_rate( DateTimeInterface $start, DateTime $orders = (int) $wpdb->get_var( $orders_sql ); if ( 0 === $orders ) { - return 0.0; + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; } $refunds_sql = $wpdb->prepare( @@ -446,7 +450,11 @@ public function get_subscription_refund_rate( DateTimeInterface $start, DateTime ); $refunds = (int) $wpdb->get_var( $refunds_sql ); - return $refunds / $orders; + return [ + 'value' => $refunds / $orders, + 'computable' => true, + 'denominator' => $orders, + ]; } /** @@ -537,7 +545,7 @@ public function get_upcoming_renewals_30d(): array { * @param DateTimeInterface $end Window end. * @return float */ - public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); @@ -571,7 +579,19 @@ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTim $attempt = (int) ( $row['retry_attempts'] ?? 0 ); $success = (int) ( $row['recoveries'] ?? 0 ); - return 0 === $attempt ? 0.0 : $success / $attempt; + if ( 0 === $attempt ) { + return [ + 'value' => 0.0, + 'computable' => false, + 'denominator' => 0, + ]; + } + + return [ + 'value' => $success / $attempt, + 'computable' => true, + 'denominator' => $attempt, + ]; } /** diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php index 4ffb805b9..fa06bfc83 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php @@ -104,14 +104,25 @@ public function get_subscription_revenue_gross( DateTimeInterface $start, DateTi public function get_subscription_revenue_net( DateTimeInterface $start, DateTimeInterface $end ): float; /** - * Refund count divided by subscription order count in the window. 0 when - * there are no subscription orders to divide into. + * Refund count divided by subscription order count in the window. + * + * Return shape: + * [ + * 'value' => float, // refunds / orders, range [0,1], 0 when not computable + * 'computable' => bool, // false when there were no subscription orders in window + * 'denominator' => int, // subscription order count in window + * ] + * + * The UI uses `computable` to render a "No subscription orders in + * this timeframe" empty state instead of a misleading 0%, and + * surfaces `denominator` inline as context so small-cohort 0% + * reads as "0% of N orders" rather than bare 0%. * * @param DateTimeInterface $start Inclusive window start. * @param DateTimeInterface $end Inclusive window end. - * @return float Fraction in [0, 1]. + * @return array{value: float, computable: bool, denominator: int} */ - public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float; + public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): array; /** * Per-subscription tenure rows for the active non-donation subscriber @@ -138,14 +149,23 @@ public function get_upcoming_renewals_30d(): array; /** * Fraction of payment retry attempts in the window that resulted in - * a subscription returning to `wc-active`. 0 when there are no retry - * attempts to divide into. + * a subscription returning to `wc-active`. + * + * Return shape: + * [ + * 'value' => float, // recoveries / attempts, range [0,1], 0 when not computable + * 'computable' => bool, // false when no payment retries were scheduled in window + * 'denominator' => int, // retry attempts in window + * ] + * + * See {@see get_subscription_refund_rate()} for the UI contract + * on `computable` and `denominator`. * * @param DateTimeInterface $start Inclusive window start. * @param DateTimeInterface $end Inclusive window end. - * @return float Fraction in [0, 1]. + * @return array{value: float, computable: bool, denominator: int} */ - public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float; + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): array; /** * Per-product performance for non-donation subscription products. diff --git a/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts b/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts index 579f6578f..3fe4606db 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts @@ -14,6 +14,18 @@ import apiFetch from '@wordpress/api-fetch'; export type StorageBackend = 'hpos' | 'legacy'; +/** + * A rate metric whose denominator may legitimately be zero. The UI + * uses `computable` to decide between rendering the value and a + * "no data yet" empty state, and surfaces `denominator` inline as + * context when the value is real but the cohort is small. + */ +export interface SubscribersRateValue { + value: number; + computable: boolean; + denominator: number; +} + export interface SubscribersClassification { backend: StorageBackend; donation_product_count: number; @@ -70,8 +82,17 @@ export interface SubscribersWindow { churned_subscribers: number; revenue_gross: number; revenue_net: number; - refund_rate: number; - failed_payment_retry_rate: number; + /** + * Refunds ÷ subscription orders in the window. `computable: false` + * when there are no subscription orders in the window — UI renders + * a "No subscription orders in this timeframe" empty state. + */ + refund_rate: SubscribersRateValue; + /** + * Recoveries ÷ retry attempts in the window. Same shape and UI + * contract as `refund_rate`. + */ + failed_payment_retry_rate: SubscribersRateValue; performance_by_product: PerformanceRow[]; cancellation_reasons: CancellationReasonRow[]; } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx index bd8c0fe4a..75bc5949d 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx @@ -7,22 +7,26 @@ * with a dynamic section heading that mirrors the active preset * ("In the last 30 days", "This month", "From Sep 5 to Oct 5", etc.). * - * The heading repeats the time scope contextually so the cards - * underneath are unambiguously windowed — even though the wizard - * chrome already shows the picker selection at the top of the page. + * The two rate metrics (refund rate, retry recovery) carry the + * `{value, computable, denominator}` shape so the UI can: + * - render a small-cohort 0% as "0% of N orders" with inline context + * - swap to a per-card empty state when the denominator is 0 + * ("No subscription orders in this timeframe") + * Same pattern as Tab 7's RetentionSection. */ /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { SubscribersWindow } from '../../api/subscribers'; +import type { SubscribersRateValue, SubscribersWindow } from '../../api/subscribers'; import type { DateRange } from '../../state/useDateRange'; import MetricCard from '../components/MetricCard'; +import { formatNumber } from '../components/format'; export interface WindowedSectionProps { range: DateRange; @@ -60,58 +64,105 @@ const getHeading = ( range: DateRange ): string => { } }; -const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) => ( -
-

- { getHeading( range ) } -

-
- - - - - - -
-
-); +const ordersCohortSubtitle = ( denominator: number ): string => + sprintf( + /* translators: %s: count of subscription orders in the comparison cohort */ + __( 'of %s', 'newspack-plugin' ), + sprintf( + /* translators: %s: count of subscription orders */ + _n( '%s order', '%s orders', denominator, 'newspack-plugin' ), + formatNumber( denominator ) + ) + ); + +const retriesCohortSubtitle = ( denominator: number ): string => + sprintf( + /* translators: %s: count of payment retry attempts in the comparison cohort */ + __( 'of %s', 'newspack-plugin' ), + sprintf( + /* translators: %s: count of payment retry attempts */ + _n( '%s retry', '%s retries', denominator, 'newspack-plugin' ), + formatNumber( denominator ) + ) + ); + +const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) => { + const refund: SubscribersRateValue = current.refund_rate; + const retry: SubscribersRateValue = current.failed_payment_retry_rate; + + return ( +
+

+ { getHeading( range ) } +

+
+ + + + + { refund.computable ? ( + + ) : ( +
+
{ __( 'Refund rate', 'newspack-plugin' ) }
+

+ { __( 'No subscription orders in this timeframe.', 'newspack-plugin' ) } +

+
+ ) } + { retry.computable ? ( + + ) : ( +
+
{ __( 'Failed payment recovery', 'newspack-plugin' ) }
+

+ { __( 'No payment retries in this timeframe.', 'newspack-plugin' ) } +

+
+ ) } +
+
+ ); +}; export default WindowedSection; From 012c78dff5e330b4d283b5d4bd407fd5936384af Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 14:37:58 -0500 Subject: [PATCH 18/37] feat(insights): copy refinements on Tab 6 + 7 (readers, refund empty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small clarifications: Tab 6 Active Subscribers — "customers" → "readers". The Newspack product lexicon is reader-first; "customers" is WooCommerce's term and reads as transactional jargon in the publisher's dashboard. Tab 6 Refund Rate empty state — "No subscription orders in this timeframe." → "No refunds in this timeframe." The orders version was technically the metric's denominator, but the publisher- facing truth is what they actually care about. Tab 7 Active Donors — same "customers" → "readers" tweak as Tab 6 for cross-tab consistency. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/tabs/donors/ScorecardSection.tsx | 5 +---- .../wizards/insights/tabs/subscribers/ScorecardSection.tsx | 2 +- .../wizards/insights/tabs/subscribers/WindowedSection.tsx | 6 ++---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx index 461b4622d..1a74ce9b4 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx @@ -43,10 +43,7 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( __( '%s active recurring', 'newspack-plugin' ), formatNumber( snapshot.active_recurring_donors ) ) } - description={ __( - 'Distinct customers with an active recurring donation or a one-time gift in the last 12 months', - 'newspack-plugin' - ) } + description={ __( 'Distinct readers with an active recurring donation or a one-time gift in the last 12 months', 'newspack-plugin' ) } /> ( label={ __( 'Active subscribers', 'newspack-plugin' ) } value={ snapshot.active_subscribers } format="number" - description={ __( 'Distinct customers with at least one active subscription', 'newspack-plugin' ) } + description={ __( 'Distinct readers with at least one active subscription', 'newspack-plugin' ) } />
{ __( 'Refund rate', 'newspack-plugin' ) }
-

- { __( 'No subscription orders in this timeframe.', 'newspack-plugin' ) } -

+

{ __( 'No refunds in this timeframe.', 'newspack-plugin' ) }

) } { retry.computable ? ( From c4c26d1908ff048b4674d260655fd40cbcd4251d Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 14:51:14 -0500 Subject: [PATCH 19/37] fix(insights): handle WCS '0' cancel-meta sentinel in retention denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recurring Donor Retention's "active at window start" subselect checked `cancel_meta IS NULL OR = '' OR > $start`. WCS stores '0' (string zero) as the "not cancelled" sentinel on _schedule_cancelled — distinct from NULL and from ''. With the prior filter, '0' fell through to the third branch where MySQL string-compared `'0' > '2026-03-06 …'` and resolved false, so every currently-active subscription was silently excluded from the denominator. Observable impact before the fix: - denominator = 3 (only customers whose ONLY sub was wc-cancelled in the window — their cancel_meta was a real date, not '0') - numerator = 0 (those 3 are now cancelled, so none in wc-active) - retention = 0% of 3 donors, regardless of how many real retained donors the publisher has. After: - denominator = 8 (3 lapsed + 5 retained, the actual cohort active at window start) - numerator = 5 (the 5 still-active donors) - retention = 62.5% of 8 donors Verified against the local seeded dataset; the seeded fixture explicitly includes a group_retained cohort to drive non-zero retention and confirms the fix surfaces it. Tab 6 storage uses the safer BETWEEN pattern everywhere it touches _schedule_cancelled and isn't affected — only Tab 7's two donor-retention queries needed the fix (HPOS + legacy). CACHE_PREFIX bumped tab7_v5 → tab7_v6 so cached pre-fix denominators don't continue to render "0% of 3 donors" until natural TTL expiry. Interface docblock updated to call out the '0' sentinel so future contributors don't reintroduce the same drop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wizards/insights/metrics/class-donors-metric.php | 2 +- .../storage/class-donors-storage-interface.php | 10 +++++++--- .../insights/storage/class-hpos-donors-storage.php | 1 + .../insights/storage/class-legacy-donors-storage.php | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index d6a68a640..e3f8164e8 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v5:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v6:'; /** * Cache TTL for windowed and snapshot metrics (30 min). diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index eebb2013f..0ff22d676 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -172,9 +172,13 @@ public function get_lapsed_donor_recovery_rate( DateTimeInterface $start, DateTi * least one active recurring donation subscription right now. * * "Active at start" = `_schedule_start <= :start` AND - * (`_schedule_cancelled` empty OR > :start). The end check is - * "currently active" (NOW), not "active at :end" — a v1 - * simplification documented inline on the query. + * (`_schedule_cancelled` empty/null/`'0'` OR > :start). The + * `'0'` sentinel is WCS's "not cancelled" marker — distinct from + * NULL or '' — so it MUST be treated as "not cancelled" in the + * filter or the denominator silently drops every currently-active + * subscription. The end check is "currently active" (NOW), not + * "active at :end" — a v1 simplification documented inline on + * the query. * * Return shape: * [ diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index cee67eec4..cb20668c9 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -488,6 +488,7 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim AND ( cancel_meta.meta_value IS NULL OR cancel_meta.meta_value = '' + OR cancel_meta.meta_value = '0' OR cancel_meta.meta_value > %s )", $this->fmt( $start ), diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index af372cab6..959848d38 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -479,6 +479,7 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim AND ( cancel_meta.meta_value IS NULL OR cancel_meta.meta_value = '' + OR cancel_meta.meta_value = '0' OR cancel_meta.meta_value > %s )", $this->fmt( $start ), From 9286940bc3f130b2ed7fd5927f62c2147f4fd19b Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 15:20:02 -0500 Subject: [PATCH 20/37] feat(insights): per-tier Lapsed Donors column on Tab 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Lapsed Donors as a new column in the Donations by tier table, between Active Recurring Donors and New Donors so the column order reads current state → window-scoped activity → lifetime: Product | Active recurring | Lapsed | New | One-time gifts | Recurring revenue | Lifetime revenue Lets publishers reconcile the Lapsed Donors scorecard with the products their churned donors had subscribed to. Previously the 3 lapsed customers in the seeded dataset were aggregated into the scorecard total but not surfaced anywhere per-product. Semantics match the scorecard's cohort definition (cancelled or expired in window AND customer has no current active donation sub), just bucketed per product. SQL implementation is a third GROUP BY pass alongside the existing subs + orders passes, keyed by the same effective-product-id variation/parent COALESCE pattern. Em-dash convention follows the existing column treatment: one-time products (Donate: One-Time) render — instead of 0 since a one-time product can't have a recurring subscription to lapse from. Recurring products (Donate: Monthly, Donate: Yearly) render the count numerically including zero. Reconciliation verified on the seeded dataset for the 90d window: Donate: Monthly → 1 lapsed Donate: Yearly → 2 lapsed scorecard total → 3 ✓ A customer who lapsed across multiple donation products in the same window would count once per product row, so SUM across rows can exceed the scorecard for that edge case. Newspack's typical donor only has one recurring donation so this reconciles cleanly in practice; documented in both the storage and interface docblocks. Also fixes a pre-existing inconsistency: the legacy storage's aggregate_tier_rows() still sorted the outer list by active_recurring_donors descending; updated to match the HPOS storage's lifetime_donation_revenue DESC convention so the table order is identical regardless of storage backend. CACHE_PREFIX bumped tab7_v6 → tab7_v7 since the response shape gained a field. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/metrics/class-donors-metric.php | 2 +- .../class-donors-storage-interface.php | 16 ++- .../storage/class-hpos-donors-storage.php | 109 ++++++++++++++++-- .../storage/class-legacy-donors-storage.php | 79 ++++++++++++- .../src/wizards/insights/api/donors.ts | 2 + .../tabs/donors/PerformanceSection.tsx | 30 +++-- 6 files changed, 212 insertions(+), 26 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index e3f8164e8..8e906ad7b 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v6:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v7:'; /** * Cache TTL for windowed and snapshot metrics (30 min). diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index 0ff22d676..d34b662bd 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -212,6 +212,7 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim * 'is_parent' => bool, * 'billing_model' => 'recurring' | 'one_time', * 'active_recurring_donors' => int, + * 'lapsed_donors_in_window' => int, * 'new_donors_in_window' => int, * 'one_time_gifts_in_window' => int, * 'recurring_revenue_in_window' => float, @@ -222,6 +223,7 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim * 'label' => string, // 'Monthly' / 'Annual' / etc * 'billing_model' => 'recurring' | 'one_time', * 'active_recurring_donors' => int, + * 'lapsed_donors_in_window' => int, * 'new_donors_in_window' => int, * 'one_time_gifts_in_window' => int, * 'recurring_revenue_in_window' => float, @@ -238,8 +240,18 @@ public function get_recurring_donor_retention( DateTimeInterface $start, DateTim * else `one_time`. The UI uses this to render cells that don't * apply to the product's billing model as em-dashes ("—") instead * of misleading zeros: a one-time product can't have recurring - * donors or recurring revenue; a recurring product can't have - * one-time gifts. + * donors, recurring revenue, or lapsed donors; a recurring + * product can't have one-time gifts. + * + * `lapsed_donors_in_window` is bucketed-per-product using the same + * cohort definition as {@see get_lapsed_donors_in_window()} + * (cancelled/expired in window AND customer has no current active + * donation sub). A customer who lapsed across multiple donation + * products in the same window counts once per product row, so + * SUM(lapsed_donors_in_window) across rows can exceed the + * scorecard's distinct-customer count. In Newspack's typical + * data shape a donor only has one recurring donation so the + * per-tier counts reconcile to the scorecard in practice. * * `*_in_window` columns are window-scoped to `[start, end]`. * `active_recurring_donors` and `lifetime_donation_revenue` are diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index cb20668c9..8be8303a4 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -555,13 +555,18 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa * * variation_id, variation_name, parent_id, parent_name, * sub_period (for label generation), active_recurring_donors, - * new_donors_in_window, one_time_gifts_in_window, - * recurring_revenue_in_window, lifetime_donation_revenue + * lapsed_donors_in_window, new_donors_in_window, + * one_time_gifts_in_window, recurring_revenue_in_window, + * lifetime_donation_revenue * - * We can't compute all five metrics in one GROUP BY because - * they scope on different order types (shop_subscription for - * active_recurring_donors; shop_order for the rest). Run two - * passes and merge by product_id in PHP. + * Three passes — the metrics scope on different order types + * and statuses so a single GROUP BY can't cover them all: + * pass 1: shop_subscription, status = wc-active → active_recurring_donors + * pass 2: shop_subscription, status IN (wc-cancelled, wc-expired) + * cancelled in window AND customer has no current + * active donation sub → lapsed_donors_in_window + * pass 3: shop_order → window/lifetime revenue + gift counts + * Merge by product_id in PHP. */ // Pass 1: subscription-side — active recurring donors per @@ -592,7 +597,62 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa $subs_rows = $wpdb->get_results( $subs_sql, ARRAY_A ); - // Pass 2: shop_order-side metrics — new donors, one-time gifts, + // Pass 2: per-tier lapsed donors. Same churn pattern as the + // {@see get_lapsed_donors_in_window} scorecard (cancelled or + // expired in window AND customer has no current active + // donation sub), but bucketed per (effective) product instead + // of aggregated. A customer who cancelled subs for multiple + // donation products in the same window will count once per + // product row, so SUM(lapsed_donors_in_window) across rows can + // exceed the scorecard. In Newspack's typical data shape a + // donor only has one recurring donation, so this reconciles + // cleanly in practice. + $lapsed_sql = $wpdb->prepare( + "SELECT + pv.ID AS variation_id, + pv.post_title AS variation_name, + pv.post_parent AS parent_id, + COALESCE(pp.post_title, '') AS parent_name, + COALESCE(period_meta.meta_value, '') AS sub_period, + COUNT(DISTINCT o.customer_id) AS lapsed_donors_in_window + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta cm + ON cm.order_id = o.id AND cm.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = o.id AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta pid_meta + ON pid_meta.order_item_id = oi.order_item_id AND pid_meta.meta_key = '_product_id' + LEFT JOIN {$prefix}woocommerce_order_itemmeta vid_meta + ON vid_meta.order_item_id = oi.order_item_id AND vid_meta.meta_key = '_variation_id' + JOIN {$prefix}posts pv + ON pv.ID = COALESCE( NULLIF( CAST(vid_meta.meta_value AS UNSIGNED), 0 ), CAST(pid_meta.meta_value AS UNSIGNED) ) + LEFT JOIN {$prefix}posts pp ON pp.ID = pv.post_parent + LEFT JOIN {$prefix}postmeta period_meta + ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' + WHERE o.type = 'shop_subscription' + AND o.status IN ('wc-cancelled', 'wc-expired') + AND pid_meta.meta_value IN ($donations) + AND cm.meta_value BETWEEN %s AND %s + AND cm.meta_value != '' + AND o.customer_id NOT IN ( + SELECT DISTINCT o2.customer_id + FROM {$prefix}wc_orders o2 + JOIN {$prefix}woocommerce_order_items oi2 + ON oi2.order_id = o2.id AND oi2.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim2 + ON oim2.order_item_id = oi2.order_item_id AND oim2.meta_key = '_product_id' + WHERE o2.type = 'shop_subscription' + AND o2.status = 'wc-active' + AND oim2.meta_value IN ($donations) + ) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $lapsed_rows = $wpdb->get_results( $lapsed_sql, ARRAY_A ); + + // Pass 3: shop_order-side metrics — new donors, one-time gifts, // recurring revenue, lifetime revenue. Keyed by opl.product_id // (the actual purchased product; no variation indirection // because opl stores the line-item product directly for @@ -657,9 +717,12 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa $orders_rows = $wpdb->get_results( $orders_sql, ARRAY_A ); - // Merge by variation_id. Both passes return the same per-product - // metadata (variation_id, names, parent, period). Fill in zeros - // for products that appear in only one of the two passes. + // Merge by variation_id. All three passes return the same + // per-product metadata (variation_id, names, parent, period). + // Fill in zeros for products that appear in only some of the + // passes (e.g. a one-time product never appears in subs or + // lapsed; a recurring product with no orders in the window + // only appears in subs/lapsed). $by_id = []; foreach ( $subs_rows as $row ) { $by_id[ (int) $row['variation_id'] ] = [ @@ -669,12 +732,32 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa 'parent_name' => (string) $row['parent_name'], 'sub_period' => (string) $row['sub_period'], 'active_recurring_donors' => (int) $row['active_recurring_donors'], + 'lapsed_donors_in_window' => 0, 'new_donors_in_window' => 0, 'one_time_gifts_in_window' => 0, 'recurring_revenue_in_window' => 0.0, 'lifetime_donation_revenue' => 0.0, ]; } + foreach ( $lapsed_rows as $row ) { + $id = (int) $row['variation_id']; + if ( ! isset( $by_id[ $id ] ) ) { + $by_id[ $id ] = [ + 'variation_id' => $id, + 'variation_name' => (string) $row['variation_name'], + 'parent_id' => (int) $row['parent_id'], + 'parent_name' => (string) $row['parent_name'], + 'sub_period' => (string) $row['sub_period'], + 'active_recurring_donors' => 0, + 'lapsed_donors_in_window' => 0, + 'new_donors_in_window' => 0, + 'one_time_gifts_in_window' => 0, + 'recurring_revenue_in_window' => 0.0, + 'lifetime_donation_revenue' => 0.0, + ]; + } + $by_id[ $id ]['lapsed_donors_in_window'] = (int) $row['lapsed_donors_in_window']; + } foreach ( $orders_rows as $row ) { $id = (int) $row['variation_id']; if ( ! isset( $by_id[ $id ] ) ) { @@ -685,6 +768,7 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa 'parent_name' => (string) $row['parent_name'], 'sub_period' => (string) $row['sub_period'], 'active_recurring_donors' => 0, + 'lapsed_donors_in_window' => 0, ]; } $by_id[ $id ]['new_donors_in_window'] = (int) $row['new_donors_in_window']; @@ -719,6 +803,7 @@ private function aggregate_tier_rows( array $rows ): array { $parent_name = (string) $row['parent_name']; $period = (string) $row['sub_period']; $active_recurring_donors = (int) $row['active_recurring_donors']; + $lapsed_donors = (int) ( $row['lapsed_donors_in_window'] ?? 0 ); $new_donors = (int) $row['new_donors_in_window']; $one_time_gifts = (int) $row['one_time_gifts_in_window']; $recurring_revenue = (float) $row['recurring_revenue_in_window']; @@ -740,6 +825,7 @@ private function aggregate_tier_rows( array $rows ): array { // the floor; upgraded below per variation. 'billing_model' => 'one_time', 'active_recurring_donors' => 0, + 'lapsed_donors_in_window' => 0, 'new_donors_in_window' => 0, 'one_time_gifts_in_window' => 0, 'recurring_revenue_in_window' => 0.0, @@ -751,6 +837,7 @@ private function aggregate_tier_rows( array $rows ): array { $parents[ $parent_id ]['billing_model'] = 'recurring'; } $parents[ $parent_id ]['active_recurring_donors'] += $active_recurring_donors; + $parents[ $parent_id ]['lapsed_donors_in_window'] += $lapsed_donors; $parents[ $parent_id ]['new_donors_in_window'] += $new_donors; $parents[ $parent_id ]['one_time_gifts_in_window'] += $one_time_gifts; $parents[ $parent_id ]['recurring_revenue_in_window'] += $recurring_revenue; @@ -760,6 +847,7 @@ private function aggregate_tier_rows( array $rows ): array { 'label' => $this->variation_label( $period, $variation_name, $parent_name ), 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, + 'lapsed_donors_in_window' => $lapsed_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, 'recurring_revenue_in_window' => $recurring_revenue, @@ -772,6 +860,7 @@ private function aggregate_tier_rows( array $rows ): array { 'is_parent' => false, 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, + 'lapsed_donors_in_window' => $lapsed_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, 'recurring_revenue_in_window' => $recurring_revenue, diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 959848d38..5ebd75849 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -567,6 +567,57 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa $subs_rows = $wpdb->get_results( $subs_sql, ARRAY_A ); + // Lapsed-donors pass: per-tier bucket of the {@see get_lapsed_donors_in_window} + // scorecard cohort. See HPOS variant for the over-count note. + $lapsed_sql = $wpdb->prepare( + "SELECT + pv.ID AS variation_id, + pv.post_title AS variation_name, + pv.post_parent AS parent_id, + COALESCE(pp.post_title, '') AS parent_name, + COALESCE(period_meta.meta_value, '') AS sub_period, + COUNT(DISTINCT cust.meta_value) AS lapsed_donors_in_window + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}postmeta cancelled + ON cancelled.post_id = p.ID AND cancelled.meta_key = '_schedule_cancelled' + JOIN {$prefix}woocommerce_order_items oi + ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta pid_meta + ON pid_meta.order_item_id = oi.order_item_id AND pid_meta.meta_key = '_product_id' + LEFT JOIN {$prefix}woocommerce_order_itemmeta vid_meta + ON vid_meta.order_item_id = oi.order_item_id AND vid_meta.meta_key = '_variation_id' + JOIN {$prefix}posts pv + ON pv.ID = COALESCE( NULLIF( CAST(vid_meta.meta_value AS UNSIGNED), 0 ), CAST(pid_meta.meta_value AS UNSIGNED) ) + LEFT JOIN {$prefix}posts pp ON pp.ID = pv.post_parent + LEFT JOIN {$prefix}postmeta period_meta + ON period_meta.post_id = pv.ID AND period_meta.meta_key = '_subscription_period' + WHERE p.post_type = 'shop_subscription' + AND p.post_status IN ('wc-cancelled', 'wc-expired') + AND pid_meta.meta_value IN ($donations) + AND cancelled.meta_value BETWEEN %s AND %s + AND cancelled.meta_value != '' + AND cust.meta_value NOT IN ( + SELECT DISTINCT cust2.meta_value + FROM {$prefix}posts p2 + JOIN {$prefix}postmeta cust2 + ON cust2.post_id = p2.ID AND cust2.meta_key = '_customer_user' + JOIN {$prefix}woocommerce_order_items oi2 + ON oi2.order_id = p2.ID AND oi2.order_item_type = 'line_item' + JOIN {$prefix}woocommerce_order_itemmeta oim2 + ON oim2.order_item_id = oi2.order_item_id AND oim2.meta_key = '_product_id' + WHERE p2.post_type = 'shop_subscription' + AND p2.post_status = 'wc-active' + AND oim2.meta_value IN ($donations) + ) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $lapsed_rows = $wpdb->get_results( $lapsed_sql, ARRAY_A ); + // shop_order pass: four metrics aggregated per opl.product_id. $orders_sql = $wpdb->prepare( "SELECT @@ -640,12 +691,32 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa 'parent_name' => (string) $row['parent_name'], 'sub_period' => (string) $row['sub_period'], 'active_recurring_donors' => (int) $row['active_recurring_donors'], + 'lapsed_donors_in_window' => 0, 'new_donors_in_window' => 0, 'one_time_gifts_in_window' => 0, 'recurring_revenue_in_window' => 0.0, 'lifetime_donation_revenue' => 0.0, ]; } + foreach ( $lapsed_rows as $row ) { + $id = (int) $row['variation_id']; + if ( ! isset( $by_id[ $id ] ) ) { + $by_id[ $id ] = [ + 'variation_id' => $id, + 'variation_name' => (string) $row['variation_name'], + 'parent_id' => (int) $row['parent_id'], + 'parent_name' => (string) $row['parent_name'], + 'sub_period' => (string) $row['sub_period'], + 'active_recurring_donors' => 0, + 'lapsed_donors_in_window' => 0, + 'new_donors_in_window' => 0, + 'one_time_gifts_in_window' => 0, + 'recurring_revenue_in_window' => 0.0, + 'lifetime_donation_revenue' => 0.0, + ]; + } + $by_id[ $id ]['lapsed_donors_in_window'] = (int) $row['lapsed_donors_in_window']; + } foreach ( $orders_rows as $row ) { $id = (int) $row['variation_id']; if ( ! isset( $by_id[ $id ] ) ) { @@ -656,6 +727,7 @@ public function get_donations_by_tier( DateTimeInterface $start, DateTimeInterfa 'parent_name' => (string) $row['parent_name'], 'sub_period' => (string) $row['sub_period'], 'active_recurring_donors' => 0, + 'lapsed_donors_in_window' => 0, ]; } $by_id[ $id ]['new_donors_in_window'] = (int) $row['new_donors_in_window']; @@ -685,6 +757,7 @@ private function aggregate_tier_rows( array $rows ): array { $parent_name = (string) $row['parent_name']; $period = (string) $row['sub_period']; $active_recurring_donors = (int) $row['active_recurring_donors']; + $lapsed_donors = (int) ( $row['lapsed_donors_in_window'] ?? 0 ); $new_donors = (int) $row['new_donors_in_window']; $one_time_gifts = (int) $row['one_time_gifts_in_window']; $recurring_revenue = (float) $row['recurring_revenue_in_window']; @@ -703,6 +776,7 @@ private function aggregate_tier_rows( array $rows ): array { // upgrade-on-recurring-variation pattern. 'billing_model' => 'one_time', 'active_recurring_donors' => 0, + 'lapsed_donors_in_window' => 0, 'new_donors_in_window' => 0, 'one_time_gifts_in_window' => 0, 'recurring_revenue_in_window' => 0.0, @@ -714,6 +788,7 @@ private function aggregate_tier_rows( array $rows ): array { $parents[ $parent_id ]['billing_model'] = 'recurring'; } $parents[ $parent_id ]['active_recurring_donors'] += $active_recurring_donors; + $parents[ $parent_id ]['lapsed_donors_in_window'] += $lapsed_donors; $parents[ $parent_id ]['new_donors_in_window'] += $new_donors; $parents[ $parent_id ]['one_time_gifts_in_window'] += $one_time_gifts; $parents[ $parent_id ]['recurring_revenue_in_window'] += $recurring_revenue; @@ -723,6 +798,7 @@ private function aggregate_tier_rows( array $rows ): array { 'label' => $this->variation_label( $period, $variation_name, $parent_name ), 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, + 'lapsed_donors_in_window' => $lapsed_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, 'recurring_revenue_in_window' => $recurring_revenue, @@ -735,6 +811,7 @@ private function aggregate_tier_rows( array $rows ): array { 'is_parent' => false, 'billing_model' => $billing_model, 'active_recurring_donors' => $active_recurring_donors, + 'lapsed_donors_in_window' => $lapsed_donors, 'new_donors_in_window' => $new_donors, 'one_time_gifts_in_window' => $one_time_gifts, 'recurring_revenue_in_window' => $recurring_revenue, @@ -759,7 +836,7 @@ static function ( $a, $b ) { usort( $out, static function ( $a, $b ) { - return $b['active_recurring_donors'] <=> $a['active_recurring_donors']; + return $b['lifetime_donation_revenue'] <=> $a['lifetime_donation_revenue']; } ); return array_slice( $out, 0, 50 ); diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts index 81d2fde34..12b598947 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -52,6 +52,7 @@ export interface DonorsTierVariationRow { label: string; billing_model: BillingModel; active_recurring_donors: number; + lapsed_donors_in_window: number; new_donors_in_window: number; one_time_gifts_in_window: number; recurring_revenue_in_window: number; @@ -68,6 +69,7 @@ export interface DonorsTierRow { */ billing_model: BillingModel; active_recurring_donors: number; + lapsed_donors_in_window: number; new_donors_in_window: number; one_time_gifts_in_window: number; recurring_revenue_in_window: number; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx index 10514281b..1c1a49810 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/PerformanceSection.tsx @@ -6,12 +6,17 @@ * SUM of their variations; standalone products render as a single * row. Sorted by lifetime_donation_revenue DESC, top 50 server-side. * + * Column order — current state → window-scoped activity → lifetime: + * Product | Active recurring | Lapsed | New | One-time gifts | + * Recurring revenue | Lifetime revenue + * * Mixed temporal scope (current state + window + lifetime) is called * out in the section caption. Cells that don't apply to the row's - * billing model — recurring donors / recurring revenue on a one-time - * product, one-time gifts on a recurring product — render as em-dash - * ("—") rather than 0/$0.00, which would read as "could be higher - * but isn't" instead of "doesn't apply." + * billing model — recurring donors / lapsed donors / recurring + * revenue on a one-time product, one-time gifts on a recurring + * product — render as em-dash ("—") rather than 0/$0.00, which + * would read as "could be higher but isn't" instead of "doesn't + * apply." */ /** @@ -39,18 +44,16 @@ const NotApplicable = () => ( const renderCount = ( applies: boolean, value: number ) => ( applies ? formatNumber( value ) : ); const renderCurrency = ( applies: boolean, value: number ) => ( applies ? formatCurrency( value ) : ); -const appliesActiveRecurring = ( m: BillingModel ) => m === 'recurring'; -const appliesOneTimeGifts = ( m: BillingModel ) => m === 'one_time'; -const appliesRecurringRevenue = ( m: BillingModel ) => m === 'recurring'; +const isRecurring = ( m: BillingModel ) => m === 'recurring'; +const isOneTime = ( m: BillingModel ) => m === 'one_time'; const renderRowCells = ( row: DonorsTierRow | DonorsTierVariationRow ) => ( <> -
+ + - - + + ); @@ -92,6 +95,9 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { + From 9127bef64e162664faf79e5a3b7b77a5bda228a1 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 16:15:18 -0500 Subject: [PATCH 21/37] feat(insights): Upcoming renewals card on Tab 7 + Subscriptions MRR rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small parity tweaks across the Insights pair: 1. Tab 7 gains an "Upcoming renewals (30d)" card in "Donors at a glance" — count of active recurring donation subscriptions whose `_schedule_next_payment` falls in the next 30 days. Mirrors Tab 6's upcoming-renewals card; SQL is the same shape with the donation filter flipped from NOT IN to IN. Subtitle: "Active recurring donations due to renew in the next 30 days". Label stays "Upcoming renewals (30d)" verbatim with Tab 6 — "renewals" is the technical term for recurring billing events on either side; the subtitle does the specialization. 2. Tab 6's MRR card renamed from "Monthly recurring revenue" to "Subscriptions MRR" so the label shape matches Tab 7's "Donation MRR". Same metric, same value, just consistent naming across the two tabs. Storage: new `get_upcoming_donation_renewals_30d()` on the donors interface with HPOS + legacy implementations. Orchestrator caches under TTL_DEFAULT. REST controller adds it to the snapshot under `upcoming_donation_renewals_30d`. TypeScript types extended; React ScorecardSection grows from 2 → 3 cards on Tab 7. CACHE_PREFIX bumped tab7_v7 → tab7_v8 since the snapshot response shape changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/class-donors-rest-controller.php | 9 ++-- .../insights/metrics/class-donors-metric.php | 18 +++++++- .../class-donors-storage-interface.php | 12 ++++++ .../storage/class-hpos-donors-storage.php | 41 ++++++++++++++++++ .../storage/class-legacy-donors-storage.php | 42 +++++++++++++++++++ .../src/wizards/insights/api/donors.ts | 6 +++ .../insights/tabs/donors/ScorecardSection.tsx | 12 +++++- .../tabs/subscribers/ScorecardSection.tsx | 2 +- 8 files changed, 134 insertions(+), 8 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php b/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php index e803279a0..f496fea1a 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php @@ -150,10 +150,11 @@ private function build_response( $response = [ 'classification' => $metric->get_classification_metadata(), 'snapshot' => [ - 'active_donors' => $metric->get_active_donors(), - 'active_recurring_donors' => $metric->get_active_recurring_donors(), - 'donation_mrr' => $metric->get_donation_mrr(), - 'donation_arr' => $metric->get_donation_arr(), + 'active_donors' => $metric->get_active_donors(), + 'active_recurring_donors' => $metric->get_active_recurring_donors(), + 'donation_mrr' => $metric->get_donation_mrr(), + 'donation_arr' => $metric->get_donation_arr(), + 'upcoming_donation_renewals_30d' => $metric->get_upcoming_donation_renewals_30d(), ], 'current' => $this->build_window( $metric, $start, $end ), 'previous' => null, diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index 8e906ad7b..2249d04ce 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v7:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v8:'; /** * Cache TTL for windowed and snapshot metrics (30 min). @@ -159,6 +159,22 @@ public function get_donation_arr(): float { return $this->get_donation_mrr() * 12; } + /** + * Upcoming donation renewals in the next 30 days. + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_renewals_30d(): array { + return (array) $this->cached( + 'upcoming_donation_renewals_30d', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_upcoming_donation_renewals_30d(); + } + ); + } + /** * New donors in window. * diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index d34b662bd..48a1ae0bf 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -79,6 +79,18 @@ public function get_active_recurring_donors(): int; */ public function get_donation_mrr(): float; + /** + * Count + total value of active recurring donation subscriptions + * whose `_schedule_next_payment` falls within the next 30 days + * from NOW. Same shape and treatment as Tab 6's upcoming renewals; + * scoped to the donation product set instead of excluding it. + * + * [ 'count' => int, 'total_value' => float ] + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_renewals_30d(): array; + /** * Distinct customers whose FIRST donation order completed within * the window. Excludes returning donors making their second or diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index 8be8303a4..0169fabfc 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -178,6 +178,47 @@ public function get_donation_mrr(): float { return (float) $wpdb->get_var( $sql ); } + /** + * {@inheritDoc} + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_renewals_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // DISTINCT id-subselect for the donation filter so a multi-line-item + // subscription is counted once and its total_amount isn't summed twice. + // Mirrors Tab 6's upcoming-renewals query exactly, with the donation + // filter flipped from NOT IN to IN. + $row = $wpdb->get_row( + "SELECT + COUNT(*) AS upcoming_count, + COALESCE(SUM(o.total_amount), 0) AS upcoming_value + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = '_schedule_next_payment' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND o.id IN ( + SELECT DISTINCT oi.order_id + FROM {$prefix}woocommerce_order_items oi + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE oi.order_item_type = 'line_item' + AND oim.meta_value IN ($donations) + ) + AND om.meta_value BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY)", + ARRAY_A + ); + + return [ + 'count' => (int) ( $row['upcoming_count'] ?? 0 ), + 'total_value' => (float) ( $row['upcoming_value'] ?? 0 ), + ]; + } + /** * {@inheritDoc} * diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 5ebd75849..222b4ea3e 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -173,6 +173,48 @@ public function get_donation_mrr(): float { return (float) $wpdb->get_var( $sql ); } + /** + * {@inheritDoc} + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_renewals_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Mirrors Tab 6's legacy upcoming-renewals query, donation filter + // flipped from NOT IN to IN. See HPOS variant for the DISTINCT + // subselect rationale. + $row = $wpdb->get_row( + "SELECT + COUNT(*) AS upcoming_count, + COALESCE(SUM(CAST(tot.meta_value AS DECIMAL(15,2))), 0) AS upcoming_value + FROM {$prefix}posts p + JOIN {$prefix}postmeta next + ON next.post_id = p.ID AND next.meta_key = '_schedule_next_payment' + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND p.ID IN ( + SELECT DISTINCT oi.order_id + FROM {$prefix}woocommerce_order_items oi + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE oi.order_item_type = 'line_item' + AND oim.meta_value IN ($donations) + ) + AND next.meta_value BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY)", + ARRAY_A + ); + + return [ + 'count' => (int) ( $row['upcoming_count'] ?? 0 ), + 'total_value' => (float) ( $row['upcoming_value'] ?? 0 ), + ]; + } + /** * {@inheritDoc} * diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts index 12b598947..a19a24eff 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -20,11 +20,17 @@ export interface DonorsClassification { has_donation_family: boolean; } +export interface UpcomingDonationRenewals { + count: number; + total_value: number; +} + export interface DonorsSnapshot { active_donors: number; active_recurring_donors: number; donation_mrr: number; donation_arr: number; + upcoming_donation_renewals_30d: UpcomingDonationRenewals; } /** diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx index 1a74ce9b4..158c93d73 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx @@ -2,8 +2,10 @@ * ScorecardSection (NPPD-1617). * * "Donors at a glance" — current-state metrics that ignore the date - * picker. Two cards, each pairing a primary value with a secondary - * snippet (Active Donors → recurring count; Donation MRR → ARR). + * picker. Three cards: Active Donors (with active recurring count as + * secondary), Donation MRR (with annualized as secondary), and + * Upcoming renewals — count of active recurring donation subscriptions + * due to renew in the next 30 days. Same shape as Tab 6's glance. */ /** @@ -56,6 +58,12 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( ) } description={ __( 'Active recurring donations normalized to a monthly rate', 'newspack-plugin' ) } /> + ); diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx index e2e29c2e6..e0c7b7f73 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx @@ -42,7 +42,7 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( description={ __( 'Distinct readers with at least one active subscription', 'newspack-plugin' ) } /> Date: Thu, 4 Jun 2026 18:47:21 -0500 Subject: [PATCH 22/37] feat(insights): Upcoming cancellations card + Subscriptions by product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Upcoming cancellations (30d)" card to both Insights tabs: counts subscriptions known to be ending in the next 30 days. Covers two cohorts that both legitimately signal "ending soon": - wc-active subs with `_schedule_end` in next 30d (fixed-term reaching its scheduled end) - wc-pending-cancel subs with `_schedule_end` in next 30d (customer cancelled mid-cycle with paid period remaining) WCS uses `_schedule_end` as the canonical end marker regardless of which status set it, so both cohorts share the same predicate. The card uses `lowerIsBetter` so a higher count reads correctly as a churn signal. Tab 6 ("Subscribers at a glance") goes 3 → 4 cards: Active Subscribers, Subscriptions MRR, Upcoming renewals, Upcoming cancellations. Tab 7 ("Donors at a glance") goes 3 → 4 cards (same shape, scoped to donation products via IN filter instead of NOT IN). Tab 6's "Performance by product" section header renamed to "Subscriptions by product" — same pattern as Tab 7's "Donations by tier" ([Thing] by [Grouping]). The internal field name `performance_by_product` and PHP method `get_performance_by_product` literally encoded the old phrase, so renamed both to `subscriptions_by_product` / `get_subscriptions_by_product` across storage interface + HPOS + legacy + orchestrator + REST + TS + SubscribersTab consumer. CSS class `--performance` stays generic (Tab 7 reuses it for the donations-by-tier section). CACHE_PREFIX bumped on both tabs (tab6_v3 → tab6_v4, tab7_v8 → tab7_v9) since each snapshot grew a new field. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/class-donors-rest-controller.php | 11 +++-- .../api/class-subscribers-rest-controller.php | 13 +++--- .../insights/metrics/class-donors-metric.php | 18 +++++++- .../metrics/class-subscribers-metric.php | 24 ++++++++-- .../class-donors-storage-interface.php | 13 ++++++ .../storage/class-hpos-donors-storage.php | 39 ++++++++++++++++ .../insights/storage/class-hpos-storage.php | 44 +++++++++++++++++- .../storage/class-legacy-donors-storage.php | 41 +++++++++++++++++ .../insights/storage/class-legacy-storage.php | 46 +++++++++++++++++-- .../storage/class-storage-interface.php | 22 ++++++++- .../src/wizards/insights/api/donors.ts | 6 +++ .../src/wizards/insights/api/subscribers.ts | 8 +++- .../wizards/insights/tabs/SubscribersTab.tsx | 2 +- .../insights/tabs/donors/ScorecardSection.tsx | 16 +++++-- .../tabs/subscribers/PerformanceSection.tsx | 4 +- .../tabs/subscribers/ScorecardSection.tsx | 15 ++++-- 16 files changed, 290 insertions(+), 32 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php b/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php index f496fea1a..362a881d7 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-donors-rest-controller.php @@ -150,11 +150,12 @@ private function build_response( $response = [ 'classification' => $metric->get_classification_metadata(), 'snapshot' => [ - 'active_donors' => $metric->get_active_donors(), - 'active_recurring_donors' => $metric->get_active_recurring_donors(), - 'donation_mrr' => $metric->get_donation_mrr(), - 'donation_arr' => $metric->get_donation_arr(), - 'upcoming_donation_renewals_30d' => $metric->get_upcoming_donation_renewals_30d(), + 'active_donors' => $metric->get_active_donors(), + 'active_recurring_donors' => $metric->get_active_recurring_donors(), + 'donation_mrr' => $metric->get_donation_mrr(), + 'donation_arr' => $metric->get_donation_arr(), + 'upcoming_donation_renewals_30d' => $metric->get_upcoming_donation_renewals_30d(), + 'upcoming_donation_cancellations_30d' => $metric->get_upcoming_donation_cancellations_30d(), ], 'current' => $this->build_window( $metric, $start, $end ), 'previous' => null, diff --git a/plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php b/plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php index 1aac75b5e..f89767567 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php @@ -178,11 +178,12 @@ private function build_response( $response = [ 'classification' => $metric->get_classification_metadata(), 'snapshot' => [ - 'active_subscribers' => $metric->get_active_non_donation_subscribers(), - 'mrr' => $metric->get_mrr(), - 'arr' => $metric->get_arr(), - 'tenure_distribution' => $metric->get_subscription_tenure_distribution(), - 'upcoming_renewals_30d' => $metric->get_upcoming_renewals_30d(), + 'active_subscribers' => $metric->get_active_non_donation_subscribers(), + 'mrr' => $metric->get_mrr(), + 'arr' => $metric->get_arr(), + 'tenure_distribution' => $metric->get_subscription_tenure_distribution(), + 'upcoming_renewals_30d' => $metric->get_upcoming_renewals_30d(), + 'upcoming_cancellations_30d' => $metric->get_upcoming_cancellations_30d(), ], 'current' => $this->build_window( $metric, $start, $end ), 'previous' => null, @@ -215,7 +216,7 @@ private function build_window( Subscribers_Metric $metric, DateTimeImmutable $st 'revenue_net' => $metric->get_subscription_revenue_net( $start, $end ), 'refund_rate' => $metric->get_subscription_refund_rate( $start, $end ), 'failed_payment_retry_rate' => $metric->get_failed_payment_retry_rate( $start, $end ), - 'performance_by_product' => $metric->get_performance_by_product( $start, $end ), + 'subscriptions_by_product' => $metric->get_subscriptions_by_product( $start, $end ), 'cancellation_reasons' => $metric->get_cancellation_reasons( $start, $end ), ]; } diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php index 2249d04ce..ea338ac0a 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-donors-metric.php @@ -37,7 +37,7 @@ class Donors_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab7_v8:'; + const CACHE_PREFIX = 'newspack_insights_tab7_v9:'; /** * Cache TTL for windowed and snapshot metrics (30 min). @@ -175,6 +175,22 @@ function () { ); } + /** + * Upcoming donation cancellations in the next 30 days. + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_cancellations_30d(): array { + return (array) $this->cached( + 'upcoming_donation_cancellations_30d', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_upcoming_donation_cancellations_30d(); + } + ); + } + /** * New donors in window. * diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php index 412a29329..8e30c74da 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php @@ -42,7 +42,7 @@ class Subscribers_Metric { * * @var string */ - const CACHE_PREFIX = 'newspack_insights_tab6_v3:'; + const CACHE_PREFIX = 'newspack_insights_tab6_v4:'; /** * Cache TTL for windowed and snapshot metrics (30 min). @@ -299,6 +299,22 @@ function () { ); } + /** + * Upcoming cancellations (count + total value) in the next 30 days. + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_cancellations_30d(): array { + return (array) $this->cached( + 'upcoming_cancellations_30d', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_upcoming_cancellations_30d(); + } + ); + } + /** * Failed payment retry rate (recoveries / attempts) in window. * @@ -327,13 +343,13 @@ function () use ( $start, $end ) { * @param DateTimeInterface $end Window end. * @return array */ - public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { + public function get_subscriptions_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { return (array) $this->cached( - 'performance_by_product', + 'subscriptions_by_product', $this->window_key( $start, $end ), self::TTL_HEAVY, function () use ( $start, $end ) { - return $this->storage->get_performance_by_product( $start, $end ); + return $this->storage->get_subscriptions_by_product( $start, $end ); } ); } diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index 48a1ae0bf..b615f66fd 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -91,6 +91,19 @@ public function get_donation_mrr(): float; */ public function get_upcoming_donation_renewals_30d(): array; + /** + * Count + total value of donation subscriptions ending in the next + * 30 days. Covers `wc-active` subs with a scheduled fixed-term end + * and `wc-pending-cancel` subs that the donor cancelled mid-cycle + * with paid period remaining. Mirrors Tab 6's upcoming cancellations + * with the donation filter flipped from NOT IN to IN. + * + * [ 'count' => int, 'total_value' => float ] + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_cancellations_30d(): array; + /** * Distinct customers whose FIRST donation order completed within * the window. Excludes returning donors making their second or diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index 0169fabfc..7b2c83178 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -219,6 +219,45 @@ public function get_upcoming_donation_renewals_30d(): array { ]; } + /** + * {@inheritDoc} + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_cancellations_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Mirrors Tab 6's upcoming-cancellations query exactly with + // the donation filter flipped from NOT IN to IN. + $row = $wpdb->get_row( + "SELECT + COUNT(*) AS upcoming_count, + COALESCE(SUM(o.total_amount), 0) AS upcoming_value + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta em + ON em.order_id = o.id AND em.meta_key = '_schedule_end' + WHERE o.type = 'shop_subscription' + AND o.status IN ('wc-active', 'wc-pending-cancel') + AND o.id IN ( + SELECT DISTINCT oi.order_id + FROM {$prefix}woocommerce_order_items oi + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE oi.order_item_type = 'line_item' + AND oim.meta_value IN ($donations) + ) + AND em.meta_value BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY)", + ARRAY_A + ); + + return [ + 'count' => (int) ( $row['upcoming_count'] ?? 0 ), + 'total_value' => (float) ( $row['upcoming_value'] ?? 0 ), + ]; + } + /** * {@inheritDoc} * diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php index 566cbbd28..d3798a319 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php @@ -559,6 +559,48 @@ public function get_upcoming_renewals_30d(): array { ]; } + /** + * {@inheritDoc} + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_cancellations_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Both wc-active (fixed-term ending naturally) and + // wc-pending-cancel (customer cancelled mid-cycle) carry a + // future `_schedule_end` when applicable. DISTINCT id-subselect + // for the non-donation filter so multi-line-item subs aren't + // double-summed. + $row = $wpdb->get_row( + "SELECT + COUNT(*) AS upcoming_count, + COALESCE(SUM(o.total_amount), 0) AS upcoming_value + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta em + ON em.order_id = o.id AND em.meta_key = '_schedule_end' + WHERE o.type = 'shop_subscription' + AND o.status IN ('wc-active', 'wc-pending-cancel') + AND o.id IN ( + SELECT DISTINCT oi.order_id + FROM {$prefix}woocommerce_order_items oi + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE oi.order_item_type = 'line_item' + AND oim.meta_value NOT IN ($donations) + ) + AND em.meta_value BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY)", + ARRAY_A + ); + + return [ + 'count' => (int) ( $row['upcoming_count'] ?? 0 ), + 'total_value' => (float) ( $row['upcoming_value'] ?? 0 ), + ]; + } + /** * {@inheritDoc} * @@ -624,7 +666,7 @@ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTim * @param DateTimeInterface $end Window end. * @return array */ - public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { + public function get_subscriptions_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 222b4ea3e..896ab9296 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -215,6 +215,47 @@ public function get_upcoming_donation_renewals_30d(): array { ]; } + /** + * {@inheritDoc} + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_donation_cancellations_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Mirrors Tab 6's legacy upcoming-cancellations query with the + // donation filter flipped from NOT IN to IN. + $row = $wpdb->get_row( + "SELECT + COUNT(*) AS upcoming_count, + COALESCE(SUM(CAST(tot.meta_value AS DECIMAL(15,2))), 0) AS upcoming_value + FROM {$prefix}posts p + JOIN {$prefix}postmeta end_meta + ON end_meta.post_id = p.ID AND end_meta.meta_key = '_schedule_end' + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + WHERE p.post_type = 'shop_subscription' + AND p.post_status IN ('wc-active', 'wc-pending-cancel') + AND p.ID IN ( + SELECT DISTINCT oi.order_id + FROM {$prefix}woocommerce_order_items oi + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE oi.order_item_type = 'line_item' + AND oim.meta_value IN ($donations) + ) + AND end_meta.meta_value BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY)", + ARRAY_A + ); + + return [ + 'count' => (int) ( $row['upcoming_count'] ?? 0 ), + 'total_value' => (float) ( $row['upcoming_value'] ?? 0 ), + ]; + } + /** * {@inheritDoc} * diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php index 7a1bf26d6..b89fa789a 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php @@ -538,6 +538,46 @@ public function get_upcoming_renewals_30d(): array { ]; } + /** + * {@inheritDoc} + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_cancellations_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Mirrors HPOS implementation; see that variant for rationale. + $row = $wpdb->get_row( + "SELECT + COUNT(*) AS upcoming_count, + COALESCE(SUM(CAST(tot.meta_value AS DECIMAL(15,2))), 0) AS upcoming_value + FROM {$prefix}posts p + JOIN {$prefix}postmeta end_meta + ON end_meta.post_id = p.ID AND end_meta.meta_key = '_schedule_end' + JOIN {$prefix}postmeta tot + ON tot.post_id = p.ID AND tot.meta_key = '_order_total' + WHERE p.post_type = 'shop_subscription' + AND p.post_status IN ('wc-active', 'wc-pending-cancel') + AND p.ID IN ( + SELECT DISTINCT oi.order_id + FROM {$prefix}woocommerce_order_items oi + JOIN {$prefix}woocommerce_order_itemmeta oim + ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + WHERE oi.order_item_type = 'line_item' + AND oim.meta_value NOT IN ($donations) + ) + AND end_meta.meta_value BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 DAY)", + ARRAY_A + ); + + return [ + 'count' => (int) ( $row['upcoming_count'] ?? 0 ), + 'total_value' => (float) ( $row['upcoming_value'] ?? 0 ), + ]; + } + /** * {@inheritDoc} * @@ -601,12 +641,12 @@ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTim * @param DateTimeInterface $end Window end. * @return array */ - public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { + public function get_subscriptions_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); - // Column scope mirrors HPOS_Storage::get_performance_by_product(): + // Column scope mirrors HPOS_Storage::get_subscriptions_by_product(): // active_subs — current state // active_value — current state // lifetime_revenue — lifetime sum (intentionally not windowed) @@ -619,7 +659,7 @@ public function get_performance_by_product( DateTimeInterface $start, DateTimeIn // (a documented v1 simplification). // COALESCE _variation_id over _product_id to resolve to the // actual variation for variable products. See - // HPOS_Storage::get_performance_by_product() for the rationale. + // HPOS_Storage::get_subscriptions_by_product() for the rationale. $sql = $wpdb->prepare( "SELECT pv.ID AS variation_id, diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php index fa06bfc83..00c0dcb52 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php @@ -147,6 +147,26 @@ public function get_subscription_tenure_distribution(): array; */ public function get_upcoming_renewals_30d(): array; + /** + * Count + total value of non-donation subscriptions known to be + * ending in the next 30 days. Covers two cohorts: + * + * - `wc-active` subs with `_schedule_end` in next 30d + * (fixed-term subscription reaching its scheduled end) + * - `wc-pending-cancel` subs with `_schedule_end` in next 30d + * (customer-initiated cancellation, paid period not yet + * exhausted — the sub remains usable until end) + * + * Both legitimately signal "ending soon" to publishers; WCS uses + * `_schedule_end` as the canonical end marker regardless of which + * status set it. + * + * [ 'count' => int, 'total_value' => float ] + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_cancellations_30d(): array; + /** * Fraction of payment retry attempts in the window that resulted in * a subscription returning to `wc-active`. @@ -203,7 +223,7 @@ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTim * @param DateTimeInterface $end Inclusive window end. * @return array> */ - public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array; + public function get_subscriptions_by_product( DateTimeInterface $start, DateTimeInterface $end ): array; /** * Cancellation reason buckets for non-donation subscriptions whose diff --git a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts index a19a24eff..2b07d1917 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/donors.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/donors.ts @@ -25,12 +25,18 @@ export interface UpcomingDonationRenewals { total_value: number; } +export interface UpcomingDonationCancellations { + count: number; + total_value: number; +} + export interface DonorsSnapshot { active_donors: number; active_recurring_donors: number; donation_mrr: number; donation_arr: number; upcoming_donation_renewals_30d: UpcomingDonationRenewals; + upcoming_donation_cancellations_30d: UpcomingDonationCancellations; } /** diff --git a/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts b/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts index 3fe4606db..7320c45b3 100644 --- a/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts +++ b/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts @@ -42,12 +42,18 @@ export interface UpcomingRenewals { total_value: number; } +export interface UpcomingCancellations { + count: number; + total_value: number; +} + export interface SubscribersSnapshot { active_subscribers: number; mrr: number; arr: number; tenure_distribution: TenureDistributionRow[]; upcoming_renewals_30d: UpcomingRenewals; + upcoming_cancellations_30d: UpcomingCancellations; } export interface PerformanceVariationRow { @@ -93,7 +99,7 @@ export interface SubscribersWindow { * contract as `refund_rate`. */ failed_payment_retry_rate: SubscribersRateValue; - performance_by_product: PerformanceRow[]; + subscriptions_by_product: PerformanceRow[]; cancellation_reasons: CancellationReasonRow[]; } diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx index dcbcb14cb..d80f17d29 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx @@ -64,7 +64,7 @@ const SubscribersTab = ( { range, previousRange }: SubscribersTabProps ) => { - + ); }; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx index 158c93d73..e18f67cf0 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx @@ -2,10 +2,11 @@ * ScorecardSection (NPPD-1617). * * "Donors at a glance" — current-state metrics that ignore the date - * picker. Three cards: Active Donors (with active recurring count as - * secondary), Donation MRR (with annualized as secondary), and - * Upcoming renewals — count of active recurring donation subscriptions - * due to renew in the next 30 days. Same shape as Tab 6's glance. + * picker. Four cards mirroring Tab 6's glance: + * - Active Donors (with active recurring count as secondary) + * - Donation MRR (with annualized as secondary) + * - Upcoming renewals (donation subs due to renew in next 30d) + * - Upcoming cancellations (donation subs set to end in next 30d) */ /** @@ -64,6 +65,13 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( format="number" description={ __( 'Active recurring donations due to renew in the next 30 days', 'newspack-plugin' ) } /> + ); diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx index ebda0d36f..ce9f2e6ce 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx @@ -36,7 +36,7 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { aria-labelledby="newspack-insights-performance-heading" >

- { __( 'Performance by product', 'newspack-plugin' ) } + { __( 'Subscriptions by product', 'newspack-plugin' ) }

{ __( 'No subscription products configured yet.', 'newspack-plugin' ) }

@@ -49,7 +49,7 @@ const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { aria-labelledby="newspack-insights-performance-heading" >

- { __( 'Performance by product', 'newspack-plugin' ) } + { __( 'Subscriptions by product', 'newspack-plugin' ) }

{ __( diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx index e0c7b7f73..5e54baea7 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx @@ -2,9 +2,11 @@ * ScorecardSection (NPPD-1616). * * "Subscribers at a glance" — current-state metrics that do NOT depend - * on the date range picker. Active subscribers, MRR (with ARR rolled - * in as a secondary line), and upcoming renewals all reflect what's - * true right now. + * on the date range picker. Four cards: + * - Active Subscribers + * - Subscriptions MRR (with annualized as secondary) + * - Upcoming renewals (active subs due to renew in next 30d) + * - Upcoming cancellations (subs set to end in next 30d) * * Window-scoped metrics (new/churned, gross/net revenue, refund rate, * retry rate) live in {@see WindowedSection} below this one. @@ -58,6 +60,13 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( format="number" description={ __( 'Active subscriptions due to renew in the next 30 days', 'newspack-plugin' ) } /> + ); From aef966250120b7dc108d49fe545c472b829a87d8 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 18:54:11 -0500 Subject: [PATCH 23/37] =?UTF-8?q?feat(insights):=20rename=20"Upcoming=20ca?= =?UTF-8?q?ncellations"=20card=20=E2=86=92=20"Upcoming=20endings"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing label rename on both Tab 6 and Tab 7. "Endings" reads cleaner across the two cohorts the card covers (fixed-term subs naturally reaching their scheduled end + customer-initiated pending-cancel mid-cycle) — "cancellations" implied only the customer-initiated path. Internal naming (PHP method, REST field, TS interface) stays as `upcoming_cancellations_30d` / `upcoming_donation_cancellations_30d` since the SQL semantics are unchanged and the technical name reads fine at the storage layer. Subtitles already used "set to end" so they need no change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/tabs/donors/ScorecardSection.tsx | 4 ++-- .../wizards/insights/tabs/subscribers/ScorecardSection.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx index e18f67cf0..953b962da 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/donors/ScorecardSection.tsx @@ -6,7 +6,7 @@ * - Active Donors (with active recurring count as secondary) * - Donation MRR (with annualized as secondary) * - Upcoming renewals (donation subs due to renew in next 30d) - * - Upcoming cancellations (donation subs set to end in next 30d) + * - Upcoming endings (donation subs set to end in next 30d) */ /** @@ -66,7 +66,7 @@ const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( description={ __( 'Active recurring donations due to renew in the next 30 days', 'newspack-plugin' ) } /> ( description={ __( 'Active subscriptions due to renew in the next 30 days', 'newspack-plugin' ) } /> Date: Thu, 4 Jun 2026 20:32:07 -0500 Subject: [PATCH 24/37] feat(insights): scaffold Gates tab Phase 1 (feature flag + REST stub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPPD-1604 Phase 1 backend scaffold. Each metric in Gates_Metric returns a `{value, computable: false, pending: true, denominator: null, placeholder_type}` payload so the React layer can render the spec's empty-state value ("0" / "0%" / "$0.00" / "0.0") without inferring type. The orchestrator carries no SQL — Phase 2 (NPPD-1630) will swap each method to dispatch a `query_name` against the Newspack Manager BigQuery query proxy; the REST controller and method signatures stay stable across the boundary. REST endpoint at `GET /newspack-insights/v1/gates` mirrors the Tab 6/7 controllers (same date validation, permission check, comparison window handling). Response carries a top-level `tab_pending: true` flag so React knows to render the Phase 1 banner. Visibility is gated by a new constant `NEWSPACK_INSIGHTS_GATES_PREVIEW` — independent of the parent `NEWSPACK_INSIGHTS_ENABLED` flag so the preview can be flipped on in dev/staging/canary separately from broader Insights rollout. When the constant is missing, the boot config marks the tab not visible and the Insights_Section_Gates::init() bails before registering the REST route, so the endpoint isn't exposed either. UI wiring (sections, viz, banner, MetricCard pending state) follows in subsequent commits in this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/class-gates-rest-controller.php | 310 ++++++++++++++++++ .../insights/class-insights-section-gates.php | 53 ++- .../insights/class-insights-wizard.php | 36 +- .../insights/metrics/class-gates-metric.php | 289 ++++++++++++++++ 4 files changed, 673 insertions(+), 15 deletions(-) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php create mode 100644 plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php b/plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php new file mode 100644 index 000000000..ebf14d695 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php @@ -0,0 +1,310 @@ +namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_gates_data' ], + 'permission_callback' => [ $this, 'permissions_check' ], + 'args' => $this->get_collection_params(), + ], + ] + ); + } + + /** + * Permission check. + * + * @return bool|WP_Error + */ + public function permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'newspack_insights_rest_forbidden', + __( 'You do not have permission to view Insights data.', 'newspack-plugin' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + return true; + } + + /** + * GET handler. + * + * @param WP_REST_Request $request Request. + * @return \WP_REST_Response|WP_Error + */ + public function get_gates_data( WP_REST_Request $request ) { + $tz = $this->site_timezone(); + + try { + $start = $this->parse_date( $request->get_param( 'start' ), $tz, false ); + $end = $this->parse_date( $request->get_param( 'end' ), $tz, true ); + } catch ( Exception $e ) { + return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] ); + } + if ( $start > $end ) { + return new WP_Error( + 'newspack_insights_invalid_window', + __( 'Start date must be on or before end date.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + $compare_start_param = $request->get_param( 'compare_start' ); + $compare_end_param = $request->get_param( 'compare_end' ); + $compare_start = null; + $compare_end = null; + if ( $compare_start_param || $compare_end_param ) { + if ( ! $compare_start_param || ! $compare_end_param ) { + return new WP_Error( + 'newspack_insights_invalid_comparison', + __( 'Both compare_start and compare_end must be provided to enable comparison mode.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + try { + $compare_start = $this->parse_date( $compare_start_param, $tz, false ); + $compare_end = $this->parse_date( $compare_end_param, $tz, true ); + } catch ( Exception $e ) { + return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] ); + } + if ( $compare_start > $compare_end ) { + return new WP_Error( + 'newspack_insights_invalid_comparison_window', + __( 'compare_start must be on or before compare_end.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + } + + $metric = new Gates_Metric(); + return rest_ensure_response( $this->build_response( $metric, $start, $end, $compare_start, $compare_end ) ); + } + + /** + * Assemble the top-level response. + * + * `tab_pending` is true in Phase 1 (placeholder phase). React + * uses it to render the top-of-tab banner; remove the flag (or + * have it return false based on real data state) when Phase 2 + * wires up BigQuery. + * + * @param Gates_Metric $metric Orchestrator. + * @param DateTimeImmutable $start Current window start. + * @param DateTimeImmutable $end Current window end. + * @param DateTimeImmutable|null $compare_start Prior window start. + * @param DateTimeImmutable|null $compare_end Prior window end. + * @return array + */ + private function build_response( + Gates_Metric $metric, + DateTimeImmutable $start, + DateTimeImmutable $end, + ?DateTimeImmutable $compare_start, + ?DateTimeImmutable $compare_end + ): array { + $response = [ + 'tab_pending' => true, + 'current' => $this->build_window( $metric, $start, $end ), + 'previous' => null, + ]; + if ( $compare_start && $compare_end ) { + $response['previous'] = $this->build_window( $metric, $compare_start, $compare_end ); + } + return $response; + } + + /** + * Window-bound payload covering all five sections. + * + * @param Gates_Metric $metric Orchestrator. + * @param DateTimeImmutable $start Start. + * @param DateTimeImmutable $end End. + * @return array + */ + private function build_window( Gates_Metric $metric, DateTimeImmutable $start, DateTimeImmutable $end ): array { + return [ + 'window' => [ + 'start' => $start->format( 'Y-m-d' ), + 'end' => $end->format( 'Y-m-d' ), + ], + // Section 1. + 'total_gate_impressions' => $metric->get_total_gate_impressions( $start, $end ), + 'unique_readers_reached' => $metric->get_unique_readers_reached( $start, $end ), + 'avg_exposures_per_reader' => $metric->get_avg_exposures_per_reader( $start, $end ), + 'sessions_with_gate' => $metric->get_sessions_with_gate( $start, $end ), + // Section 2. + 'regwall_conversion_direct' => $metric->get_regwall_conversion_direct( $start, $end ), + 'regwall_conversion_influenced_7d' => $metric->get_regwall_conversion_influenced_7d( $start, $end ), + // Section 3. + 'paywall_conversion_direct' => $metric->get_paywall_conversion_direct( $start, $end ), + 'paywall_conversion_influenced_14d' => $metric->get_paywall_conversion_influenced_14d( $start, $end ), + 'total_paywall_revenue_direct' => $metric->get_total_paywall_revenue_direct( $start, $end ), + 'avg_revenue_per_paywall_conversion' => $metric->get_avg_revenue_per_paywall_conversion( $start, $end ), + // Section 4. + 'conversion_funnel' => $metric->get_conversion_funnel( $start, $end ), + 'exposures_distribution' => $metric->get_exposures_distribution( $start, $end ), + // Section 5. + 'performance_by_gate' => $metric->get_performance_by_gate( $start, $end ), + ]; + } + + /** + * Args spec. + * + * @return array + */ + public function get_collection_params() { + $base = [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ]; + return [ + 'start' => array_merge( + $base, + [ + 'description' => __( 'Inclusive window start date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'required' => true, + ] + ), + 'end' => array_merge( + $base, + [ + 'description' => __( 'Inclusive window end date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'required' => true, + ] + ), + 'compare_start' => array_merge( + $base, + [ + 'description' => __( 'Optional comparison window start. Must pair with compare_end.', 'newspack-plugin' ), + 'required' => false, + ] + ), + 'compare_end' => array_merge( + $base, + [ + 'description' => __( 'Optional comparison window end. Must pair with compare_start.', 'newspack-plugin' ), + 'required' => false, + ] + ), + ]; + } + + /** + * REST validate_callback. + * + * @param mixed $value Value. + * @return bool|WP_Error + */ + public function validate_date_string( $value ) { + if ( ! is_string( $value ) || '' === $value ) { + return new WP_Error( + 'newspack_insights_invalid_date', + __( 'Date must be a non-empty YYYY-MM-DD string.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + $parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $this->site_timezone() ); + if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) { + return new WP_Error( + 'newspack_insights_invalid_date', + /* translators: %s: the invalid date string */ + sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ), + [ 'status' => 400 ] + ); + } + return true; + } + + /** + * Parse a Y-m-d string into a DateTimeImmutable. + * + * @param mixed $value Raw value. + * @param DateTimeZone $tz Timezone. + * @param bool $end_of_day If true, 23:59:59; else 00:00:00. + * @return DateTimeImmutable + * @throws Exception On parse failure. + */ + private function parse_date( $value, DateTimeZone $tz, bool $end_of_day ): DateTimeImmutable { + if ( ! is_string( $value ) || '' === $value ) { + throw new Exception( esc_html__( 'Missing date value.', 'newspack-plugin' ) ); + } + $parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $tz ); + if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) { + /* translators: %s: the invalid date string */ + throw new Exception( esc_html( sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ) ) ); + } + return $end_of_day ? $parsed->setTime( 23, 59, 59 ) : $parsed->setTime( 0, 0, 0 ); + } + + /** + * Site timezone. + * + * @return DateTimeZone + */ + private function site_timezone(): DateTimeZone { + return wp_timezone(); + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php index c6b479d8b..09cbd9e6c 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php @@ -1,21 +1,25 @@ register_routes(); + } + ); + } } diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php index ceda8cf79..34d3cd0cc 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -70,6 +70,34 @@ public static function is_enabled() { return defined( 'NEWSPACK_INSIGHTS_ENABLED' ) && NEWSPACK_INSIGHTS_ENABLED; } + /** + * Whether the Gates preview tab (Tab 4 / NPPD-1604) is enabled + * for this environment. + * + * Independent from {@see self::is_enabled()} so the preview can + * be flipped on only where it's wanted (development, staging, + * canary), separately from the broader Insights wizard rollout. + * Once Phase 2 (NPPD-1630) lands and the tab is no longer a + * placeholder, this gate can be retired in favor of the standard + * Insights flag plus a runtime feature-detection check. + * + * @return bool True when the Gates preview should appear in the + * Insights tab nav and have its REST route active. + */ + public static function is_gates_preview_enabled(): bool { + /** + * Enables the Gates tab preview (Phase 1, placeholder data). + * + * @constant NEWSPACK_INSIGHTS_GATES_PREVIEW + * @type bool + * @default Gates preview tab hidden + * @status draft + * + * @example define( 'NEWSPACK_INSIGHTS_GATES_PREVIEW', true ); + */ + return defined( 'NEWSPACK_INSIGHTS_GATES_PREVIEW' ) && NEWSPACK_INSIGHTS_GATES_PREVIEW; + } + /** * Constructor. * @@ -236,7 +264,7 @@ protected function get_boot_config() { $thirty_ago = $today->modify( '-29 days' ); return [ - // Tab visibility. The audience/engagement/conversion/gates/ + // Tab visibility. The audience/engagement/conversion/ // prompts/advertising tabs are stubbed to true until their // data layers land (each needs BQ for proper feature // detection, NPPD-1598). Subscribers stays all-on for now; @@ -244,12 +272,14 @@ protected function get_boot_config() { // product presence) is a separate follow-up. Donors hides // when there are no donation products on the publisher, // using the shared Donation_Product_Classifier (cached 1h) - // as the single source of truth. + // as the single source of truth. Gates is gated to the + // preview constant NEWSPACK_INSIGHTS_GATES_PREVIEW while + // Phase 1 (placeholder data) is being validated. 'tabs' => [ 'audience' => true, 'engagement' => true, 'conversion' => true, - 'gates' => true, + 'gates' => self::is_gates_preview_enabled(), 'prompts' => true, 'subscribers' => true, 'donors' => self::has_donation_activity(), diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php new file mode 100644 index 000000000..9141124a4 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php @@ -0,0 +1,289 @@ + 'decimal' === $placeholder_type ? 0.0 : 0, + 'computable' => false, + 'pending' => true, + 'denominator' => null, + 'placeholder_type' => $placeholder_type, + ]; + } + + // --- Section 1: Gate exposure --------------------------------------- + + /** + * Total gate impressions in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_total_gate_impressions( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'count' ); + } + + /** + * Unique readers who saw at least one gate. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_unique_readers_reached( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'count' ); + } + + /** + * Average gate exposures per reader. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_avg_exposures_per_reader( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'decimal' ); + } + + /** + * Percentage of sessions that hit at least one gate. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_sessions_with_gate( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + // --- Section 2: Free reader conversion ------------------------------ + + /** + * Regwall conversion rate, direct attribution. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_regwall_conversion_direct( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + /** + * Regwall conversion rate, influenced (7-day lookback). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_regwall_conversion_influenced_7d( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + // --- Section 3: Paid reader conversion ------------------------------ + + /** + * Paywall conversion rate, direct attribution. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_paywall_conversion_direct( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + /** + * Paywall conversion rate, influenced (14-day lookback). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_paywall_conversion_influenced_14d( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + /** + * Total revenue from paywall conversions, direct attribution. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_total_paywall_revenue_direct( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'currency' ); + } + + /** + * Average revenue per paywall conversion. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_avg_revenue_per_paywall_conversion( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'currency' ); + } + + // --- Section 4: How readers convert --------------------------------- + + /** + * Conversion funnel — three stages with zeros and a pending flag. + * Stage shape kept stable so the React Funnel viz can render the + * same chrome regardless of phase. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{ + * pending: bool, + * stages: array + * } + */ + public function get_conversion_funnel( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return [ + 'pending' => true, + 'stages' => [ + [ + 'label' => __( 'Impression', 'newspack-plugin' ), + 'count' => 0, + 'pct_of_top' => 0.0, + ], + [ + 'label' => __( 'Engagement', 'newspack-plugin' ), + 'count' => 0, + 'pct_of_top' => 0.0, + ], + [ + 'label' => __( 'Conversion', 'newspack-plugin' ), + 'count' => 0, + 'pct_of_top' => 0.0, + ], + ], + ]; + } + + /** + * Exposures-before-conversion distribution buckets. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{ + * pending: bool, + * buckets: array + * } + */ + public function get_exposures_distribution( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return [ + 'pending' => true, + 'buckets' => [ + [ + 'label' => __( '1 exposure', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + [ + 'label' => __( '2 exposures', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + [ + 'label' => __( '3–5 exposures', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + [ + 'label' => __( '6+ exposures', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + ], + ]; + } + + // --- Section 5: Performance by gate --------------------------------- + + /** + * Per-gate breakdown. Phase 1 returns an empty `rows` array; the + * React PerformanceByGateSection renders the spec's empty-state + * copy when the array is empty. Phase 2 will populate this with + * real BQ rows enriched server-side from `wp_posts.post_title` + * keyed on `gate_post_id`. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{pending: bool, rows: array} + */ + public function get_performance_by_gate( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return [ + 'pending' => true, + 'rows' => [], + ]; + } +} From 15d6047aff80d580064b3c16270222fc87e1667e Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:33:01 -0500 Subject: [PATCH 25/37] feat(insights): MetricCard pending state for Tab 4 Phase 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive `pending?: boolean` prop on MetricCard. When true, the card renders the formatted value normally but suppresses the comparison delta even if `previousValue` is supplied — placeholder zeros don't have a meaningful delta to show. Tab 6 and Tab 7 never set `pending`, so their rendering is unchanged. Tab 4 sections set it on every card during Phase 1; once Phase 2 (NPPD-1630) lands and real BQ data flows in, the flag flips off per metric and the comparison delta renders normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/tabs/components/MetricCard.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx index 53c44499f..51286a343 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -1,5 +1,5 @@ /** - * MetricCard (NPPD-1616). + * MetricCard (NPPD-1616, extended for NPPD-1604). * * Scorecard atom: label (top) → value + optional delta (vertically * centered hero region) → description (pinned to the bottom). Every @@ -9,6 +9,13 @@ * * `lowerIsBetter` flips the green/red delta tone for metrics where a * decrease is desirable (refund rate, churned subscriber count). + * + * `pending` (NPPD-1604) renders the value normally but suppresses the + * comparison delta even when `previousValue` is supplied. Used by Tab + * 4's Phase 1 placeholder cards: the value is a real "0" / "0%" / + * etc., so it visually matches the surrounding chrome — the + * top-of-tab banner is the only Phase 1 signal. Additive: Tab 6/7 + * never set `pending`, so their rendering is unchanged. */ /** @@ -39,6 +46,13 @@ export interface MetricCardProps { * paired insight without spending a whole card on it. */ secondary?: string; + /** + * Phase 1 placeholder marker (NPPD-1604). When true, the card + * renders the value normally but suppresses the comparison delta + * even if `previousValue` is provided — there's no real delta to + * show while a metric is pending real data. + */ + pending?: boolean; } const formatValue = ( v: number, fmt: MetricFormat ): string => { @@ -52,8 +66,8 @@ const formatValue = ( v: number, fmt: MetricFormat ): string => { }; const MetricCard = ( props: MetricCardProps ) => { - const { label, value, format, previousValue, description, lowerIsBetter = false, secondary } = props; - const hasComparison = typeof previousValue === 'number'; + const { label, value, format, previousValue, description, lowerIsBetter = false, secondary, pending = false } = props; + const hasComparison = ! pending && typeof previousValue === 'number'; const delta = hasComparison ? formatDelta( value, previousValue as number ) : null; const tone = hasComparison ? deltaTone( value, previousValue as number, lowerIsBetter ) : 'neutral'; const deltaA11y = From e5dac035bb7437dda49880b1124a988406de514b Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:40:30 -0500 Subject: [PATCH 26/37] feat(insights): Gates tab UI (sections, viz, banner, explainer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab 4 React layer for NPPD-1604 Phase 1. Mirrors the SubscribersTab / DonorsTab loading-error-success lifecycle and the shared section / metric-card chrome from Tab 6/7. Five sections in the spec order: 1. Gate exposure — 4 scorecards, Direct vs Influenced explainer callout below the caption 2. Free reader conversion — 2 scorecards (Direct + Influenced 7d) 3. Paid reader conversion — 4 scorecards including direct revenue 4. How readers convert — Funnel (left) + Distribution (right) 5. Performance by gate — table with empty-state row from spec Plus the Phase 1 top-of-tab dismissable banner. Banner + callout dismissals are session-only — both reappear on page reload per spec, intentional so the visual cue stays prominent. Viz components are tab-local under tabs/gates/viz/ — minimal Funnel + DistributionTable scoped to Tab 4. When the canonical versions land in packages/components/src/ (likely alongside the broader data-viz library work tracked separately), swap them in and delete the tab-local copies. Scalar metrics share a small `scalarToCard.ts` helper that maps the server's `placeholder_type` → MetricCard `format` and decides whether to surface a comparison `previousValue`. The MetricCard `pending` flag (added in the prior commit) suppresses the comparison delta when the metric is still in the placeholder phase so toggling Compare-to-previous doesn't render a misleading 0% delta. format.ts gained a `decimal` formatter so "Avg exposures per reader" renders as "0.0" per the spec's placeholder table. GatesTab.tsx replaces the prior Coming-Soon stub and is registered in TabContent.tsx (no nav change needed — the tab key was already in TabNavigation; visibility is gated by the boot config's `tabs.gates` flag which reads NEWSPACK_INSIGHTS_GATES_PREVIEW). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/api/gates.ts | 139 +++++++++++ .../wizards/insights/hooks/useGatesData.ts | 76 ++++++ .../src/wizards/insights/tabs/GatesTab.tsx | 71 +++++- .../insights/tabs/components/MetricCard.tsx | 7 +- .../insights/tabs/components/format.ts | 8 + .../tabs/gates/DirectVsInfluencedCallout.tsx | 62 +++++ .../gates/FreeReaderConversionSection.tsx | 60 +++++ .../tabs/gates/GateExposureSection.tsx | 72 ++++++ .../tabs/gates/HowReadersConvertSection.tsx | 51 ++++ .../gates/PaidReaderConversionSection.tsx | 74 ++++++ .../tabs/gates/PerformanceByGateSection.tsx | 106 ++++++++ .../insights/tabs/gates/PreviewBanner.tsx | 47 ++++ .../wizards/insights/tabs/gates/gates.scss | 227 ++++++++++++++++++ .../insights/tabs/gates/scalarToCard.ts | 43 ++++ .../tabs/gates/viz/DistributionTable.tsx | 60 +++++ .../insights/tabs/gates/viz/Funnel.tsx | 83 +++++++ 16 files changed, 1176 insertions(+), 10 deletions(-) create mode 100644 plugins/newspack-plugin/src/wizards/insights/api/gates.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx diff --git a/plugins/newspack-plugin/src/wizards/insights/api/gates.ts b/plugins/newspack-plugin/src/wizards/insights/api/gates.ts new file mode 100644 index 000000000..a80315a2a --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/api/gates.ts @@ -0,0 +1,139 @@ +/** + * Gates API client (NPPD-1604, Phase 1). + * + * Thin wrapper around `@wordpress/api-fetch` for the single Tab 4 + * endpoint: `GET /newspack-insights/v1/gates`. Type definitions + * mirror the PHP response shape assembled by `Gates_REST_Controller`. + * + * Phase 1: every metric carries `pending: true` and a zero value. + * Phase 2 (NPPD-1630) keeps the same shape but flips `pending` to + * false and surfaces real BQ values; the React layer does not need + * to know which phase produced a payload — it reads `pending` and + * the `tab_pending` banner flag. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * The kind of placeholder a metric renders. Encoded server-side so + * the React format layer doesn't have to guess from the field name. + */ +export type GatesPlaceholderType = 'count' | 'rate' | 'currency' | 'decimal'; + +/** + * Standard scorecard metric payload. Carries the value plus the + * `pending` and `placeholder_type` markers the UI needs to render + * the Phase 1 zeros in the correct visual format. + */ +export interface GatesScalarMetric { + value: number; + computable: boolean; + pending: boolean; + denominator: number | null; + placeholder_type: GatesPlaceholderType; +} + +export interface GatesFunnelStage { + label: string; + count: number; + pct_of_top: number; +} + +export interface GatesFunnelData { + pending: boolean; + stages: GatesFunnelStage[]; +} + +export interface GatesDistributionBucket { + label: string; + count: number; + pct: number; +} + +export interface GatesDistributionData { + pending: boolean; + buckets: GatesDistributionBucket[]; +} + +/** + * One row in the Performance by gate table. Phase 1 returns no rows + * (the section renders the spec's empty-state copy). Phase 2 will + * populate this server-side with `wp_posts.post_title` enrichment + * keyed on `gate_post_id`. + */ +export interface GatesPerformanceRow { + gate_post_id: number; + gate_name: string; + impressions: number; + unique_viewers: number; + regwall_conversions: number | null; + regwall_conversion_rate: number | null; + paywall_conversions: number | null; + paywall_conversion_rate: number | null; +} + +export interface GatesPerformanceTable { + pending: boolean; + rows: GatesPerformanceRow[]; +} + +export interface GatesWindow { + window: { start: string; end: string }; + // Section 1 — Gate exposure. + total_gate_impressions: GatesScalarMetric; + unique_readers_reached: GatesScalarMetric; + avg_exposures_per_reader: GatesScalarMetric; + sessions_with_gate: GatesScalarMetric; + // Section 2 — Free reader conversion. + regwall_conversion_direct: GatesScalarMetric; + regwall_conversion_influenced_7d: GatesScalarMetric; + // Section 3 — Paid reader conversion. + paywall_conversion_direct: GatesScalarMetric; + paywall_conversion_influenced_14d: GatesScalarMetric; + total_paywall_revenue_direct: GatesScalarMetric; + avg_revenue_per_paywall_conversion: GatesScalarMetric; + // Section 4 — How readers convert. + conversion_funnel: GatesFunnelData; + exposures_distribution: GatesDistributionData; + // Section 5 — Performance by gate. + performance_by_gate: GatesPerformanceTable; +} + +export interface GatesResponse { + /** + * True while Tab 4 is in the Phase 1 placeholder phase. React + * uses this to render the top-of-tab banner. + */ + tab_pending: boolean; + current: GatesWindow; + previous: GatesWindow | null; +} + +export interface GatesQuery { + start: string; + end: string; + compare_start?: string; + compare_end?: string; +} + +const ENDPOINT = '/newspack-insights/v1/gates'; + +/** + * Fetch Tab 4 data for the given window pair. + */ +export const fetchGatesData = async ( query: GatesQuery ): Promise< GatesResponse > => { + const params = new URLSearchParams(); + params.set( 'start', query.start ); + params.set( 'end', query.end ); + if ( query.compare_start && query.compare_end ) { + params.set( 'compare_start', query.compare_start ); + params.set( 'compare_end', query.compare_end ); + } + return apiFetch< GatesResponse >( { + path: `${ ENDPOINT }?${ params.toString() }`, + method: 'GET', + } ); +}; diff --git a/plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts b/plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts new file mode 100644 index 000000000..6d434fa42 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts @@ -0,0 +1,76 @@ +/** + * useGatesData (NPPD-1604). + * + * Tab 4's data fetch lifecycle. Mirrors {@see useDonorsData} and + * {@see useSubscribersData}: a request-id guard serializes + * overlapping calls so the latest range change wins, and + * idle / loading / success / error state is local to the tab. + */ + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import { fetchGatesData, type GatesResponse } from '../api/gates'; + +export type GatesFetchStatus = 'idle' | 'loading' | 'success' | 'error'; + +export interface UseGatesDataResult { + status: GatesFetchStatus; + data: GatesResponse | null; + error: string | null; + refetch: () => void; +} + +const errorMessage = ( e: unknown ): string => { + if ( e && typeof e === 'object' && 'message' in e && typeof ( e as { message: unknown } ).message === 'string' ) { + return ( e as { message: string } ).message; + } + return String( e ); +}; + +const useGatesData = ( range: DateRange, previousRange: DateRange | null ): UseGatesDataResult => { + const [ status, setStatus ] = useState< GatesFetchStatus >( 'idle' ); + const [ data, setData ] = useState< GatesResponse | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + + const requestIdRef = useRef( 0 ); + const [ refetchTick, setRefetchTick ] = useState( 0 ); + const refetch = useCallback( () => setRefetchTick( t => t + 1 ), [] ); + + useEffect( () => { + const myId = ++requestIdRef.current; + setStatus( 'loading' ); + setError( null ); + + fetchGatesData( { + start: range.start, + end: range.end, + compare_start: previousRange?.start, + compare_end: previousRange?.end, + } ) + .then( response => { + if ( requestIdRef.current !== myId ) { + return; + } + setData( response ); + setStatus( 'success' ); + } ) + .catch( e => { + if ( requestIdRef.current !== myId ) { + return; + } + setError( errorMessage( e ) ); + setStatus( 'error' ); + } ); + }, [ range.start, range.end, previousRange?.start, previousRange?.end, refetchTick ] ); + + return { status, data, error, refetch }; +}; + +export default useGatesData; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx index e5dc54046..b53efe4b7 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx @@ -1,7 +1,15 @@ /** - * GatesTab + * GatesTab (NPPD-1604). * - * Stub. Real content lands in NPPD-1604. + * Tab 4 orchestrator. Mirrors the SubscribersTab / DonorsTab + * loading / error / success lifecycle and composes the five Gates + * sections plus the Phase 1 top-of-tab banner. + * + * Date range picker affects every metric — there are no current-state + * metrics on this tab, only window-scoped ones. Comparison toggle is + * forwarded by the wizard chrome via the standard `previousRange` + * prop; when set, the response carries a `previous` window that the + * sections thread into their per-card MetricCards. */ /** @@ -9,11 +17,58 @@ */ import { __ } from '@wordpress/i18n'; -const GatesTab = () => ( -

-

{ __( 'Gates', 'newspack-plugin' ) }

-

{ __( 'Coming soon', 'newspack-plugin' ) }

-
-); +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import useGatesData from '../hooks/useGatesData'; +import PreviewBanner from './gates/PreviewBanner'; +import GateExposureSection from './gates/GateExposureSection'; +import FreeReaderConversionSection from './gates/FreeReaderConversionSection'; +import PaidReaderConversionSection from './gates/PaidReaderConversionSection'; +import HowReadersConvertSection from './gates/HowReadersConvertSection'; +import PerformanceByGateSection from './gates/PerformanceByGateSection'; +import './gates/gates.scss'; + +export interface GatesTabProps { + range: DateRange; + previousRange: DateRange | null; +} + +const GatesTab = ( { range, previousRange }: GatesTabProps ) => { + const { status, data, error } = useGatesData( range, previousRange ); + + if ( status === 'loading' && ! data ) { + return ( +
+ { __( 'Loading gate data…', 'newspack-plugin' ) } +
+ ); + } + + if ( status === 'error' ) { + return ( +
+

{ __( 'Could not load gate data.', 'newspack-plugin' ) }

+ { error &&

{ error }

} +
+ ); + } + + if ( ! data ) { + return null; + } + + return ( +
+ { data.tab_pending && } + + + + + +
+ ); +}; export default GatesTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx index 51286a343..df7bcb681 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -26,9 +26,9 @@ import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import { formatCurrency, formatNumber, formatPercent, formatDelta, deltaTone } from './format'; +import { formatCurrency, formatDecimal, formatNumber, formatPercent, formatDelta, deltaTone } from './format'; -export type MetricFormat = 'number' | 'currency' | 'percent'; +export type MetricFormat = 'number' | 'currency' | 'percent' | 'decimal'; export interface MetricCardProps { label: string; @@ -62,6 +62,9 @@ const formatValue = ( v: number, fmt: MetricFormat ): string => { if ( fmt === 'percent' ) { return formatPercent( v ); } + if ( fmt === 'decimal' ) { + return formatDecimal( v ); + } return formatNumber( v ); }; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts b/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts index 89b786a5c..a6b677319 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts @@ -16,6 +16,11 @@ const numberFormatter = new Intl.NumberFormat( undefined, { maximumFractionDigits: 0, } ); +const decimalFormatter = new Intl.NumberFormat( undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +} ); + const currencyFormatter = new Intl.NumberFormat( undefined, { style: 'currency', currency: 'USD', @@ -35,6 +40,9 @@ const signedPercentFormatter = new Intl.NumberFormat( undefined, { export const formatNumber = ( n: number ): string => numberFormatter.format( n ); +/** Format a number with exactly one decimal place: 0 -> "0.0", 1.23 -> "1.2". */ +export const formatDecimal = ( n: number ): string => decimalFormatter.format( n ); + export const formatCurrency = ( n: number ): string => currencyFormatter.format( n ); /** Format a fraction in [0, 1] as a percent: 0.123 -> "12.3%". */ diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx new file mode 100644 index 000000000..bfc9a2e52 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx @@ -0,0 +1,62 @@ +/** + * DirectVsInfluencedCallout (NPPD-1604). + * + * Small dismissable info callout immediately below the Section 1 + * caption, explaining the Direct vs Influenced distinction used by + * Sections 2 and 3. Per spec, dismissal is session-only (no persisted + * "don't show again" — the callout reappears on page reload). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { Icon, closeSmall, info } from '@wordpress/icons'; + +const DirectVsInfluencedCallout = () => { + const [ visible, setVisible ] = useState( true ); + if ( ! visible ) { + return null; + } + return ( +
+ +
+

+ { __( 'About Direct vs Influenced conversion', 'newspack-plugin' ) } +

+

+ { __( 'Direct', 'newspack-plugin' ) }{ ' ' } + { __( + 'conversions are tagged to a specific gate at the moment of conversion (a gate_post_id is captured on the registration or checkout event).', + 'newspack-plugin' + ) } +

+

+ { __( 'Influenced', 'newspack-plugin' ) }{ ' ' } + { __( + 'conversions count readers who saw a gate within a lookback window (7 days for free, 14 days for paid) but converted later, possibly elsewhere on the site.', + 'newspack-plugin' + ) } +

+

+ { __( + 'Influenced is broader than Direct. Use Direct for "this specific gate drove this specific conversion" attribution; use Influenced for "gates contributed to this conversion somewhere in the reader’s journey."', + 'newspack-plugin' + ) } +

+
+ +
+ ); +}; + +export default DirectVsInfluencedCallout; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx new file mode 100644 index 000000000..10e9dc930 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx @@ -0,0 +1,60 @@ +/** + * FreeReaderConversionSection (NPPD-1604, Section 2). + * + * Two scorecards side-by-side covering registration-gate conversion + * (Direct attribution and Influenced 7-day lookback). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import MetricCard from '../components/MetricCard'; +import { scalarToMetricCardProps } from './scalarToCard'; + +export interface FreeReaderConversionSectionProps { + current: GatesWindow; + previous: GatesWindow | null; +} + +const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversionSectionProps ) => ( +
+

+ { __( 'Free reader conversion', 'newspack-plugin' ) } +

+

+ { __( + 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate; Influenced counts conversions by readers who saw a gate within the last 7 days.', + 'newspack-plugin' + ) } +

+
+ + +
+
+); + +export default FreeReaderConversionSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx new file mode 100644 index 000000000..4270bb3de --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx @@ -0,0 +1,72 @@ +/** + * GateExposureSection (NPPD-1604, Section 1). + * + * Top-of-funnel exposure scorecards. Four cards in a single row. + * Caption + Direct-vs-Influenced callout below the heading. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import MetricCard from '../components/MetricCard'; +import DirectVsInfluencedCallout from './DirectVsInfluencedCallout'; +import { scalarToMetricCardProps } from './scalarToCard'; + +export interface GateExposureSectionProps { + current: GatesWindow; + previous: GatesWindow | null; +} + +const GateExposureSection = ( { current, previous }: GateExposureSectionProps ) => ( +
+

+ { __( 'Gate exposure', 'newspack-plugin' ) } +

+

+ { __( 'Top of the funnel. How many readers see gates in this timeframe.', 'newspack-plugin' ) } +

+ +
+ + + + +
+
+); + +export default GateExposureSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx new file mode 100644 index 000000000..1dd86dc7f --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx @@ -0,0 +1,51 @@ +/** + * HowReadersConvertSection (NPPD-1604, Section 4). + * + * Funnel (left) + Distribution (right), side-by-side at equal width. + * Both viz components are tab-local for Phase 1 — when canonical + * data-viz components land in `packages/components/src/`, swap them + * in here. + * + * Per spec: comparison overlays on these visualizations are deferred + * to v1.1 — no `previous` consumption here. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import Funnel from './viz/Funnel'; +import DistributionTable from './viz/DistributionTable'; + +export interface HowReadersConvertSectionProps { + current: GatesWindow; +} + +const HowReadersConvertSection = ( { current }: HowReadersConvertSectionProps ) => ( +
+

+ { __( 'How readers convert', 'newspack-plugin' ) } +

+

+ { __( + 'The journey from gate impression to conversion. The funnel shows where readers drop off; the distribution shows how many touches it typically takes before conversion.', + 'newspack-plugin' + ) } +

+
+
+ +
+
+ +
+
+
+); + +export default HowReadersConvertSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx new file mode 100644 index 000000000..fcb3c5e99 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx @@ -0,0 +1,74 @@ +/** + * PaidReaderConversionSection (NPPD-1604, Section 3). + * + * Four scorecards in a single row covering paywall-gate conversion + * (Direct attribution, Influenced 14-day lookback) plus revenue + * captured from gate-tagged conversions. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import MetricCard from '../components/MetricCard'; +import { scalarToMetricCardProps } from './scalarToCard'; + +export interface PaidReaderConversionSectionProps { + current: GatesWindow; + previous: GatesWindow | null; +} + +const PaidReaderConversionSection = ( { current, previous }: PaidReaderConversionSectionProps ) => ( +
+

+ { __( 'Paid reader conversion', 'newspack-plugin' ) } +

+

+ { __( + 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate; Influenced counts subscriptions by readers who saw a paywall in the last 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', + 'newspack-plugin' + ) } +

+
+ + + + +
+
+); + +export default PaidReaderConversionSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx new file mode 100644 index 000000000..4bc4df08b --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx @@ -0,0 +1,106 @@ +/** + * PerformanceByGateSection (NPPD-1604, Section 5). + * + * Full-width per-gate breakdown table. Phase 1 always renders the + * empty-state copy from spec since `rows` is empty. When Phase 2 + * (NPPD-1630) populates `performance_by_gate.rows` from BQ + a + * server-side `wp_posts.post_title` enrichment, the table will + * render rows sorted by impressions DESC with em-dash cells where a + * gate doesn't have the matching block (regwall or paywall). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesPerformanceRow, GatesPerformanceTable } from '../../api/gates'; +import { formatNumber, formatPercent } from '../components/format'; + +export interface PerformanceByGateSectionProps { + data: GatesPerformanceTable; +} + +const NotApplicable = () => ( + + — + +); + +const renderCount = ( v: number | null ) => ( v === null ? : formatNumber( v ) ); +const renderPercent = ( v: number | null ) => ( v === null ? : formatPercent( v ) ); + +const renderRow = ( row: GatesPerformanceRow ) => ( +
+ + + + + + + + +); + +const PerformanceByGateSection = ( { data }: PerformanceByGateSectionProps ) => { + const isEmpty = data.rows.length === 0; + return ( +
+

+ { __( 'Performance by gate', 'newspack-plugin' ) } +

+

+ { __( 'Per-gate breakdown for the selected timeframe. Sorted by impressions, highest first.', 'newspack-plugin' ) } +

+
+
{ renderCount( appliesActiveRecurring( row.billing_model ), row.active_recurring_donors ) }{ formatNumber( row.new_donors_in_window ) }{ renderCount( appliesOneTimeGifts( row.billing_model ), row.one_time_gifts_in_window ) } + { renderCurrency( appliesRecurringRevenue( row.billing_model ), row.recurring_revenue_in_window ) } + { formatCurrency( row.lifetime_donation_revenue ) }
{ row.name }{ formatNumber( row.active_recurring_donors ) }{ formatNumber( row.new_donors_in_window ) }{ formatNumber( row.one_time_gifts_in_window ) }{ formatCurrency( row.recurring_revenue_in_window ) }{ formatCurrency( row.lifetime_donation_revenue ) }
{ v.label }{ formatNumber( v.active_recurring_donors ) }{ formatNumber( v.new_donors_in_window ) }{ formatNumber( v.one_time_gifts_in_window ) }{ formatCurrency( v.recurring_revenue_in_window ) }{ formatCurrency( v.lifetime_donation_revenue ) }
{ renderCount( appliesActiveRecurring( row.billing_model ), row.active_recurring_donors ) }{ renderCount( isRecurring( row.billing_model ), row.active_recurring_donors ) }{ renderCount( isRecurring( row.billing_model ), row.lapsed_donors_in_window ) } { formatNumber( row.new_donors_in_window ) }{ renderCount( appliesOneTimeGifts( row.billing_model ), row.one_time_gifts_in_window ) } - { renderCurrency( appliesRecurringRevenue( row.billing_model ), row.recurring_revenue_in_window ) } - { renderCount( isOneTime( row.billing_model ), row.one_time_gifts_in_window ) }{ renderCurrency( isRecurring( row.billing_model ), row.recurring_revenue_in_window ) } { formatCurrency( row.lifetime_donation_revenue ) } { __( 'Active recurring donors', 'newspack-plugin' ) } + { __( 'Lapsed donors', 'newspack-plugin' ) } + { __( 'New donors', 'newspack-plugin' ) }
{ row.gate_name }{ formatNumber( row.impressions ) }{ formatNumber( row.unique_viewers ) }{ renderCount( row.regwall_conversions ) }{ renderPercent( row.regwall_conversion_rate ) }{ renderCount( row.paywall_conversions ) }{ renderPercent( row.paywall_conversion_rate ) }
+ + + + + + + + + + + + + { isEmpty ? ( + + + + ) : ( + data.rows.map( renderRow ) + ) } + +
{ __( 'Gate name', 'newspack-plugin' ) } + { __( 'Impressions', 'newspack-plugin' ) } + + { __( 'Unique viewers', 'newspack-plugin' ) } + + { __( 'Regwall conversions', 'newspack-plugin' ) } + + { __( 'Regwall conversion rate', 'newspack-plugin' ) } + + { __( 'Paywall conversions', 'newspack-plugin' ) } + + { __( 'Paywall conversion rate', 'newspack-plugin' ) } +
+ { __( + 'No gate data yet. Performance metrics will appear once readers begin interacting with your gates.', + 'newspack-plugin' + ) } +
+
+ + ); +}; + +export default PerformanceByGateSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx new file mode 100644 index 000000000..99cc449fe --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx @@ -0,0 +1,47 @@ +/** + * PreviewBanner (NPPD-1604, Phase 1). + * + * Top-of-tab dismissable banner that calls out the Phase 1 + * placeholder state. Dismissal is session-only (component state) per + * spec — the banner reappears on page reload so the visual cue isn't + * accidentally hidden across sessions. + * + * Remove this component entirely when Phase 2 (NPPD-1630) lands and + * the tab carries real BQ data. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { Icon, closeSmall, info } from '@wordpress/icons'; + +const PreviewBanner = () => { + const [ visible, setVisible ] = useState( true ); + if ( ! visible ) { + return null; + } + return ( +
+ +

+ { __( 'This tab is live in preview mode.', 'newspack-plugin' ) }{ ' ' } + { __( + 'Real-time metrics will populate once BigQuery integration is complete. The structure, sections, and visualizations are final.', + 'newspack-plugin' + ) } +

+ +
+ ); +}; + +export default PreviewBanner; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss new file mode 100644 index 000000000..76f635791 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss @@ -0,0 +1,227 @@ +/** + * Newspack Insights — Tab 4 (Gates) styles (NPPD-1604, Phase 1) + * + * Tab 4-specific layout only. The shared Insights chrome (sections, + * metric cards, table with empty state, tab loading/error) lives in + * `tabs/components/sections.scss` and is loaded by the wizard's main + * `style.scss`. Visualization styles (funnel + distribution) live + * here because both viz components are tab-local for Phase 1. + */ + +@use "~@wordpress/base-styles/colors" as wp-colors; + +.newspack-insights { + &__gates-tab { + display: flex; + flex-direction: column; + gap: 32px; + } + + // Phase 1 preview banner — light blue background, info icon, X to dismiss. + &__gates-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 20px; + background-color: #f0f6fc; + border: 1px solid #c5d9ed; + border-radius: 4px; + + &-icon { + flex: 0 0 24px; + width: 24px; + height: 24px; + color: var(--wp-admin-theme-color); + } + + &-message { + margin: 0; + flex: 1 1 auto; + font-size: 14px; + line-height: 1.5; + color: wp-colors.$gray-900; + } + + &-dismiss { + flex: 0 0 auto; + background: transparent; + border: 0; + cursor: pointer; + padding: 4px; + color: wp-colors.$gray-700; + + &:hover { + color: wp-colors.$gray-900; + } + } + } + + // Direct vs Influenced explainer — same chrome family as the banner + // but a notch lighter and aligned under the Section 1 caption. + &__gates-callout { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 20px; + background-color: wp-colors.$gray-100; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + + &-icon { + flex: 0 0 20px; + width: 20px; + height: 20px; + color: wp-colors.$gray-700; + } + + &-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; + line-height: 1.5; + color: wp-colors.$gray-900; + + p { + margin: 0; + } + } + + &-title { + font-size: 14px; + } + + &-dismiss { + flex: 0 0 auto; + background: transparent; + border: 0; + cursor: pointer; + padding: 4px; + color: wp-colors.$gray-700; + + &:hover { + color: wp-colors.$gray-900; + } + } + } + + // Two-card row (Section 2). The shared metric-grid is auto-fill + // with a 220px minimum; for the dedicated two-card sections we + // cap at two equal columns instead of letting more squeeze in. + &__metric-grid--pair { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + + // Section 4 — funnel + distribution side-by-side at equal width, + // stacking under 720px container width. + &__gates-convert-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 24px; + align-items: start; + } + + &__gates-convert-col { + min-width: 0; // allow the column to shrink within the grid + } + + // Funnel viz — tab-local. Vertical trapezoids approximated as + // centered, width-scaled rounded rectangles. When the canonical + // Funnel component lands, swap in. + &__funnel { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 20px; + background-color: #fff; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + + &-row { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + + &-dropoff { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: wp-colors.$gray-700; + } + + &-stage { + width: 40%; + min-height: 72px; + padding: 12px 16px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background-color: var(--wp-admin-theme-color); + color: #fff; + border-radius: 6px; + + &[data-stage-index="0"] { + opacity: 1; + } + + &[data-stage-index="1"] { + opacity: 0.8; + } + + &[data-stage-index="2"] { + opacity: 0.6; + } + + &-label { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + &-count { + font-size: 24px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } + + &-pct { + font-size: 12px; + font-weight: 400; + opacity: 0.9; + } + } + } + + // Distribution viz — tab-local. Reuses the shared table chrome. + &__distribution { + display: flex; + flex-direction: column; + gap: 8px; + + &-caption { + margin: 0; + font-size: 13px; + font-weight: 400; + line-height: 1.5; + color: wp-colors.$gray-700; + } + } + + // Empty state row inside the Performance by gate table. + &__gates-performance-empty { + padding: 32px 20px; + text-align: center; + font-style: italic; + color: wp-colors.$gray-700; + } +} diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts new file mode 100644 index 000000000..01fb24de8 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts @@ -0,0 +1,43 @@ +/** + * Small helper that maps a `GatesScalarMetric` payload + section + * copy into the MetricCard props used by every Tab 4 scorecard. + * + * Centralises the `placeholder_type` → `MetricFormat` mapping so + * the section components stay declarative. + */ + +import type { GatesScalarMetric } from '../../api/gates'; +import type { MetricFormat } from '../components/MetricCard'; + +const formatFor = ( m: GatesScalarMetric ): MetricFormat => { + switch ( m.placeholder_type ) { + case 'rate': + return 'percent'; + case 'currency': + return 'currency'; + case 'decimal': + return 'decimal'; + case 'count': + default: + return 'number'; + } +}; + +export interface ScalarCardProps { + label: string; + description: string; + current: GatesScalarMetric; + previous?: GatesScalarMetric | null; +} + +export const scalarToMetricCardProps = ( props: ScalarCardProps ) => { + const { label, description, current, previous } = props; + return { + label, + description, + value: current.value, + format: formatFor( current ), + previousValue: previous?.computable ? previous.value : null, + pending: current.pending, + }; +}; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx new file mode 100644 index 000000000..28187ea2f --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx @@ -0,0 +1,60 @@ +/** + * Tab-local Distribution table viz (NPPD-1604, Phase 1). + * + * Bucket distribution table used inside Tab 4 only. Mirrors the + * pattern the canonical Table component will use when it lands in + * `packages/components/src/`, so swap-in later is mechanical. + * + * Phase 1 behavior: every bucket renders 0 / 0% in the standard + * table chrome; the section caption below the table explains the + * cohort definition regardless of phase. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesDistributionData } from '../../../api/gates'; +import { formatNumber, formatPercent } from '../../components/format'; + +export interface DistributionTableProps { + data: GatesDistributionData; +} + +const DistributionTable = ( { data }: DistributionTableProps ) => ( +
+
+ + + + + + + + + + { data.buckets.map( bucket => ( + + + + + + ) ) } + +
{ __( 'Exposures before conversion', 'newspack-plugin' ) } + { __( 'Converters', 'newspack-plugin' ) } + + { __( '% of total', 'newspack-plugin' ) } +
{ bucket.label }{ formatNumber( bucket.count ) }{ formatPercent( bucket.pct ) }
+
+

+ { __( 'Of readers who converted, this is how many gates they saw first.', 'newspack-plugin' ) } +

+
+); + +export default DistributionTable; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx new file mode 100644 index 000000000..4a3567b56 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx @@ -0,0 +1,83 @@ +/** + * Tab-local Funnel viz (NPPD-1604, Phase 1). + * + * Minimal vertical three-stage funnel used inside Tab 4 only. When a + * canonical Funnel component lands in `packages/components/src/` + * (likely alongside the broader data-viz library work), swap this + * usage out and delete this file. Keeping the API surface narrow on + * purpose: a `stages` array, a `pending` flag, and CSS classes the + * shared sections.scss / tab-local gates.scss can style. + * + * Phase 1 behavior: + * - All stages render at zero + * - Drop-off labels are hidden when every stage is 0 (per spec) + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesFunnelData, GatesFunnelStage } from '../../../api/gates'; +import { formatNumber, formatPercent } from '../../components/format'; + +export interface FunnelProps { + data: GatesFunnelData; +} + +const stageWidthPct = ( stage: GatesFunnelStage, topCount: number ): number => { + if ( topCount <= 0 ) { + // Phase 1 / no data: each stage renders at a fixed minimum so + // the funnel chrome stays visible without implying a value. + return 40; + } + const ratio = stage.count / topCount; + return Math.max( 12, Math.round( ratio * 100 ) ); +}; + +const Funnel = ( { data }: FunnelProps ) => { + const { stages } = data; + const topCount = stages.length > 0 ? stages[ 0 ].count : 0; + const allZero = stages.every( s => s.count === 0 ); + + return ( +
+ { stages.map( ( stage, idx ) => { + const prev = idx > 0 ? stages[ idx - 1 ] : null; + const dropOffPct = prev && prev.count > 0 ? 1 - stage.count / prev.count : 0; + const widthPct = stageWidthPct( stage, topCount ); + return ( +
+ { idx > 0 && ! allZero && ( +
+ { sprintf( + /* translators: %s: percentage of readers dropped off between two funnel stages */ + __( '%s drop-off', 'newspack-plugin' ), + formatPercent( dropOffPct ) + ) } +
+ ) } +
+
{ stage.label }
+
{ formatNumber( stage.count ) }
+ { idx > 0 && ! allZero && ( +
+ { sprintf( + /* translators: %s: percentage of stage-1 readers reaching this stage */ + __( '%s of top', 'newspack-plugin' ), + formatPercent( stage.pct_of_top ) + ) } +
+ ) } +
+
+ ); + } ) } +
+ ); +}; + +export default Funnel; From cf9cded819794a749793aedc0b864e3364c14689 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:54:15 -0500 Subject: [PATCH 27/37] feat(insights): clarify Influenced copy + lift explainer to tab top Two copy + placement tweaks to the Gates tab: 1. Reword "Influenced" subtitles and captions. The prior "in the prior X days" / "in the last X days" phrasing read as "the last X days of the selected timeframe" rather than "X days between the gate impression and the conversion event." Anchoring the time gap to the gate exposure (not the picker) eliminates the ambiguity: Card 2.2 subtitle, Card 3.2 subtitle, Section 2 caption, Section 3 caption, and the middle bullet of the Direct vs Influenced explainer all updated to the "within X days of seeing a gate" form. 2. Move the Direct vs Influenced explainer from below Section 1's caption up to the tab top, between the Phase 1 preview banner and Section 1's header. The framing is foundational to Sections 2 and 3, so publishers should encounter it before reading any section that uses the terms. Dismissal remains session-only. The matching spec doc at ~/Sites/insights-docs/specs/gates.md was updated to reflect the same copy + placement decisions (out-of-tree; not part of this PR's diff). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/tabs/GatesTab.tsx | 2 ++ .../tabs/gates/DirectVsInfluencedCallout.tsx | 13 ++++++++----- .../tabs/gates/FreeReaderConversionSection.tsx | 4 ++-- .../insights/tabs/gates/GateExposureSection.tsx | 7 ++++--- .../tabs/gates/PaidReaderConversionSection.tsx | 4 ++-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx index b53efe4b7..0a7760aca 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx @@ -23,6 +23,7 @@ import { __ } from '@wordpress/i18n'; import type { DateRange } from '../state/useDateRange'; import useGatesData from '../hooks/useGatesData'; import PreviewBanner from './gates/PreviewBanner'; +import DirectVsInfluencedCallout from './gates/DirectVsInfluencedCallout'; import GateExposureSection from './gates/GateExposureSection'; import FreeReaderConversionSection from './gates/FreeReaderConversionSection'; import PaidReaderConversionSection from './gates/PaidReaderConversionSection'; @@ -62,6 +63,7 @@ const GatesTab = ( { range, previousRange }: GatesTabProps ) => { return (
{ data.tab_pending && } + diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx index bfc9a2e52..74841bd2b 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx @@ -1,10 +1,13 @@ /** * DirectVsInfluencedCallout (NPPD-1604). * - * Small dismissable info callout immediately below the Section 1 - * caption, explaining the Direct vs Influenced distinction used by - * Sections 2 and 3. Per spec, dismissal is session-only (no persisted - * "don't show again" — the callout reappears on page reload). + * Small dismissable info callout rendered at the top of the Gates + * tab (immediately below the Phase 1 preview banner, above Section + * 1's heading). The Direct vs Influenced framing is foundational to + * Sections 2 and 3, so publishers should see it before reading any + * section that uses the terms. Per spec, dismissal is session-only + * (no persisted "don't show again" — the callout reappears on + * page reload). */ /** @@ -36,7 +39,7 @@ const DirectVsInfluencedCallout = () => {

{ __( 'Influenced', 'newspack-plugin' ) }{ ' ' } { __( - 'conversions count readers who saw a gate within a lookback window (7 days for free, 14 days for paid) but converted later, possibly elsewhere on the site.', + 'conversions count readers who saw a gate and then converted within a lookback window (7 days for free conversions, 14 days for paid). The conversion may happen later, possibly on a different page than the gate.', 'newspack-plugin' ) }

diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx index 10e9dc930..c9668c0ec 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx @@ -29,7 +29,7 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio

{ __( - 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate; Influenced counts conversions by readers who saw a gate within the last 7 days.', + 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate. Influenced counts readers who saw a registration gate and then registered within 7 days.', 'newspack-plugin' ) }

@@ -46,7 +46,7 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio { ...scalarToMetricCardProps( { label: __( 'Regwall Conversion (Influenced, 7d)', 'newspack-plugin' ), description: __( - 'Registered readers who saw a registration gate in the prior 7 days ÷ readers who saw a registration gate', + 'Readers who registered within 7 days of seeing a registration gate ÷ readers who saw a registration gate', 'newspack-plugin' ), current: current.regwall_conversion_influenced_7d, diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx index 4270bb3de..c541a7761 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx @@ -2,7 +2,10 @@ * GateExposureSection (NPPD-1604, Section 1). * * Top-of-funnel exposure scorecards. Four cards in a single row. - * Caption + Direct-vs-Influenced callout below the heading. + * The Direct vs Influenced explainer used to live below this + * section's caption but moved to the tab top (above Section 1) so + * publishers encounter the framing before any section that uses + * the terms — see {@see GatesTab}. */ /** @@ -15,7 +18,6 @@ import { __ } from '@wordpress/i18n'; */ import type { GatesWindow } from '../../api/gates'; import MetricCard from '../components/MetricCard'; -import DirectVsInfluencedCallout from './DirectVsInfluencedCallout'; import { scalarToMetricCardProps } from './scalarToCard'; export interface GateExposureSectionProps { @@ -31,7 +33,6 @@ const GateExposureSection = ( { current, previous }: GateExposureSectionProps )

{ __( 'Top of the funnel. How many readers see gates in this timeframe.', 'newspack-plugin' ) }

-

{ __( - 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate; Influenced counts subscriptions by readers who saw a paywall in the last 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', + 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate. Influenced counts readers who saw a paywall and then subscribed within 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', 'newspack-plugin' ) }

@@ -46,7 +46,7 @@ const PaidReaderConversionSection = ( { current, previous }: PaidReaderConversio Date: Thu, 4 Jun 2026 21:02:19 -0500 Subject: [PATCH 28/37] feat(insights): sortable Performance by gate table on Tab 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click any column header to re-sort. Default sort is Impressions descending per spec. Numeric columns open DESC on first click (biggest first — what publishers usually want); the gate-name column opens ASC (alphabetical). Toggle direction by clicking the same header again. Null cells (em-dash, e.g. Regwall columns on a gate without a registration block) always sort to the bottom regardless of direction, so a "—" never claims the top of an ascending sort. Sortable header buttons fill the full click target with hover + focus-visible affordances per the data-viz component spec — chevron at 0.3 opacity inactive, 0.7 hover, 1.0 active. Aria-sort on every so screen readers announce the current state. Phase 1 still renders the empty-state row since `data.rows` is empty, but the sortable chrome stays visible so Phase 2 swap-in is seamless — when NPPD-1630 wires BigQuery and rows start flowing through, the click-to-sort UI starts shuffling them. Caption tweaked from "Sorted by impressions, highest first." to "Click any column to re-sort." since the sort is interactive now. Sortable styles scoped to Tab 4 (`.newspack-insights__table--sortable`) in gates.scss. When Tabs 6/7 want sortable tables, lift these styles + the SortableHeader component into the shared sections.scss together. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tabs/gates/PerformanceByGateSection.tsx | 166 ++++++++++++++---- .../wizards/insights/tabs/gates/gates.scss | 73 ++++++++ 2 files changed, 209 insertions(+), 30 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx index 4bc4df08b..b42042d9c 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx @@ -1,18 +1,30 @@ /** * PerformanceByGateSection (NPPD-1604, Section 5). * - * Full-width per-gate breakdown table. Phase 1 always renders the - * empty-state copy from spec since `rows` is empty. When Phase 2 - * (NPPD-1630) populates `performance_by_gate.rows` from BQ + a - * server-side `wp_posts.post_title` enrichment, the table will - * render rows sorted by impressions DESC with em-dash cells where a - * gate doesn't have the matching block (regwall or paywall). + * Full-width per-gate breakdown table. Sortable on every column; + * default sort is impressions descending per spec. Click a column + * header to toggle direction (numeric columns flip to DESC on first + * click, ASC on second click; the gate-name column starts ASC). + * + * Null cells (em-dash) always sort to the bottom regardless of + * direction — a gate without a registration block has no Regwall + * conversions to compare, so a "—" should never claim the top of + * an ascending sort. + * + * Phase 1 always renders the empty-state copy from spec since + * `rows` is empty. The sort affordances stay visible so the chrome + * is identical between phases — Phase 2 (NPPD-1630) populates + * `performance_by_gate.rows` from BQ + a server-side + * `wp_posts.post_title` enrichment, at which point the click-to-sort + * UI starts shuffling rows. */ /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { useMemo, useState } from '@wordpress/element'; +import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; /** * Internal dependencies @@ -24,6 +36,27 @@ export interface PerformanceByGateSectionProps { data: GatesPerformanceTable; } +/** + * The columns we expose for sorting. Matches the visible column set + * one-for-one. + */ +type SortKey = + | 'gate_name' + | 'impressions' + | 'unique_viewers' + | 'regwall_conversions' + | 'regwall_conversion_rate' + | 'paywall_conversions' + | 'paywall_conversion_rate'; + +type SortDir = 'asc' | 'desc'; + +interface ColumnDef { + key: SortKey; + label: string; + numeric: boolean; +} + const NotApplicable = () => ( — @@ -45,8 +78,97 @@ const renderRow = ( row: GatesPerformanceRow ) => ( ); +/** + * Compare two rows on a given column. Nulls always sort last + * regardless of direction. String compare for `gate_name` uses the + * browser locale. + */ +const compareRows = ( a: GatesPerformanceRow, b: GatesPerformanceRow, key: SortKey, dir: SortDir ): number => { + const av = a[ key ]; + const bv = b[ key ]; + // Nulls last (regardless of direction). + if ( av === null && bv === null ) { + return 0; + } + if ( av === null ) { + return 1; + } + if ( bv === null ) { + return -1; + } + let cmp: number; + if ( typeof av === 'string' && typeof bv === 'string' ) { + cmp = av.localeCompare( bv ); + } else { + cmp = ( av as number ) - ( bv as number ); + } + return dir === 'asc' ? cmp : -cmp; +}; + +interface SortableHeaderProps { + column: ColumnDef; + activeKey: SortKey; + activeDir: SortDir; + onSort: ( key: SortKey ) => void; +} + +const ariaSortFor = ( isActive: boolean, activeDir: SortDir ): 'ascending' | 'descending' | 'none' => { + if ( ! isActive ) { + return 'none'; + } + return activeDir === 'asc' ? 'ascending' : 'descending'; +}; + +const SortableHeader = ( { column, activeKey, activeDir, onSort }: SortableHeaderProps ) => { + const isActive = column.key === activeKey; + const ariaSort = ariaSortFor( isActive, activeDir ); + const className = column.numeric ? 'newspack-insights__table-num newspack-insights__table-sort-cell' : 'newspack-insights__table-sort-cell'; + return ( + + + + ); +}; + const PerformanceByGateSection = ( { data }: PerformanceByGateSectionProps ) => { - const isEmpty = data.rows.length === 0; + const columns: ColumnDef[] = [ + { key: 'gate_name', label: __( 'Gate name', 'newspack-plugin' ), numeric: false }, + { key: 'impressions', label: __( 'Impressions', 'newspack-plugin' ), numeric: true }, + { key: 'unique_viewers', label: __( 'Unique viewers', 'newspack-plugin' ), numeric: true }, + { key: 'regwall_conversions', label: __( 'Regwall conversions', 'newspack-plugin' ), numeric: true }, + { key: 'regwall_conversion_rate', label: __( 'Regwall conversion rate', 'newspack-plugin' ), numeric: true }, + { key: 'paywall_conversions', label: __( 'Paywall conversions', 'newspack-plugin' ), numeric: true }, + { key: 'paywall_conversion_rate', label: __( 'Paywall conversion rate', 'newspack-plugin' ), numeric: true }, + ]; + + const [ sortKey, setSortKey ] = useState< SortKey >( 'impressions' ); + const [ sortDir, setSortDir ] = useState< SortDir >( 'desc' ); + + const handleSort = ( key: SortKey ) => { + if ( key === sortKey ) { + setSortDir( prev => ( prev === 'asc' ? 'desc' : 'asc' ) ); + return; + } + setSortKey( key ); + // Default direction depends on column type: numeric columns + // open DESC (biggest first), string columns open ASC. + const def = columns.find( c => c.key === key ); + setSortDir( def?.numeric ? 'desc' : 'asc' ); + }; + + const sortedRows = useMemo( () => [ ...data.rows ].sort( ( a, b ) => compareRows( a, b, sortKey, sortDir ) ), [ data.rows, sortKey, sortDir ] ); + + const isEmpty = sortedRows.length === 0; + return (
{ __( 'Performance by gate', 'newspack-plugin' ) }

- { __( 'Per-gate breakdown for the selected timeframe. Sorted by impressions, highest first.', 'newspack-plugin' ) } + { __( 'Per-gate breakdown for the selected timeframe. Click any column to re-sort.', 'newspack-plugin' ) }

- +
- - - - - - - + { columns.map( col => ( + + ) ) } { isEmpty ? ( - ) : ( - data.rows.map( renderRow ) + sortedRows.map( renderRow ) ) }
{ __( 'Gate name', 'newspack-plugin' ) } - { __( 'Impressions', 'newspack-plugin' ) } - - { __( 'Unique viewers', 'newspack-plugin' ) } - - { __( 'Regwall conversions', 'newspack-plugin' ) } - - { __( 'Regwall conversion rate', 'newspack-plugin' ) } - - { __( 'Paywall conversions', 'newspack-plugin' ) } - - { __( 'Paywall conversion rate', 'newspack-plugin' ) } -
+ { __( 'No gate data yet. Performance metrics will appear once readers begin interacting with your gates.', 'newspack-plugin' @@ -94,7 +200,7 @@ const PerformanceByGateSection = ( { data }: PerformanceByGateSectionProps ) =>
diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss index 76f635791..31d2eec71 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss @@ -224,4 +224,77 @@ font-style: italic; color: wp-colors.$gray-700; } + + // Sortable table affordances (Tab 4 Performance by gate). When + // other tables across Tabs 6/7 want sortable headers, lift these + // styles into the shared sections.scss and the SortableHeader + // component along with them. + &__table--sortable { + .newspack-insights__table-sort-cell { + // Eliminate the th's default padding because the button + // inside owns the click target and re-paints the padding + // itself; without this, half the cell isn't clickable. + padding: 0; + } + + .newspack-insights__table-sort { + // Button fills the cell so any click anywhere in the th + // triggers the sort. + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 12px 20px; + background: transparent; + border: 0; + cursor: pointer; + text-align: inherit; + font: inherit; + color: inherit; + letter-spacing: inherit; + text-transform: inherit; + + &:hover { + background-color: wp-colors.$gray-200; + } + + &:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + } + + // Numeric columns are right-aligned, so reverse the button's + // content order: label first → indicator pinned to the right + // edge. Non-numeric columns keep left-to-right (label then + // indicator) but with margin-right: auto on the label. + .newspack-insights__table-num .newspack-insights__table-sort { + justify-content: flex-end; + text-align: right; + } + + .newspack-insights__table-sort-label { + // Push the indicator to whichever side the cell is aligned + // against; this collapses with the parent button's + // justify-content rule above. + flex: 1 1 auto; + } + + .newspack-insights__table-sort-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.3; + color: wp-colors.$gray-700; + } + + .newspack-insights__table-sort:hover .newspack-insights__table-sort-indicator { + opacity: 0.7; + } + + .newspack-insights__table-sort.is-active .newspack-insights__table-sort-indicator { + opacity: 1; + color: wp-colors.$gray-900; + } + } } From a4fe47cc68a7896c0654b5533fd2d9c143c5dc5f Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 22:24:06 -0500 Subject: [PATCH 29/37] feat(insights): align Gates React strings with session-scoped attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the Section 2 / Section 3 captions, Cards 2.1 / 2.2 / 3.1 / 3.2 / 3.3 subtitles, and the Direct vs Influenced callout body verbatim from specs/gates.md to bring the React UI back in sync with the session-scoped attribution model. Direct = same-session: gate impression and conversion share a GA session. Influenced = cross-session within lookback (7d free / 14d paid). Same-session is excluded from Influenced so the two definitions stay mutually exclusive. Also updates the docblock on PaidReaderConversionSection to drop the old "gate-tagged" phrasing for internal consistency. Spec doc cross-reference: ~/Sites/insights-docs/specs/gates.md (separate doc commit, out-of-tree). Phase 2 SQL implementation follows the same model — see formulas/tab-4-gates.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tabs/gates/DirectVsInfluencedCallout.tsx | 6 +++--- .../gates/FreeReaderConversionSection.tsx | 9 ++++++--- .../gates/PaidReaderConversionSection.tsx | 19 ++++++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx index 74841bd2b..b6be3421e 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx @@ -32,20 +32,20 @@ const DirectVsInfluencedCallout = () => {

{ __( 'Direct', 'newspack-plugin' ) }{ ' ' } { __( - 'conversions are tagged to a specific gate at the moment of conversion (a gate_post_id is captured on the registration or checkout event).', + 'conversions happen in the same session as a gate impression. The gate is credited regardless of whether checkout happens on the same page (embedded checkout block) or after clicking through to a subscription page.', 'newspack-plugin' ) }

{ __( 'Influenced', 'newspack-plugin' ) }{ ' ' } { __( - 'conversions count readers who saw a gate and then converted within a lookback window (7 days for free conversions, 14 days for paid). The conversion may happen later, possibly on a different page than the gate.', + 'conversions happen after a gate impression but in a later session, within a lookback window (7 days for free conversions, 14 days for paid).', 'newspack-plugin' ) }

{ __( - 'Influenced is broader than Direct. Use Direct for "this specific gate drove this specific conversion" attribution; use Influenced for "gates contributed to this conversion somewhere in the reader’s journey."', + 'Same-session is Direct. Later-session-within-lookback is Influenced. The two are mutually exclusive and together capture every gate-touched conversion within the lookback period.', 'newspack-plugin' ) }

diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx index c9668c0ec..44f2d806b 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx @@ -29,7 +29,7 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio

{ __( - 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate. Influenced counts readers who saw a registration gate and then registered within 7 days.', + 'How effectively registration gates convert visitors into registered readers. Direct counts registrations that happened in the same session as a registration gate impression. Influenced counts registrations that happened in a later session within 7 days of a registration gate impression.', 'newspack-plugin' ) }

@@ -37,7 +37,10 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio

{ __( - 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate. Influenced counts readers who saw a paywall and then subscribed within 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', + 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions that happened in the same session as a paywall impression. Influenced counts subscriptions that happened in a later session within 14 days of a paywall impression. Revenue is computed from actual Woo orders, not gate-event amounts.', 'newspack-plugin' ) }

@@ -38,7 +38,10 @@ const PaidReaderConversionSection = ( { current, previous }: PaidReaderConversio Date: Mon, 8 Jun 2026 13:15:02 -0500 Subject: [PATCH 30/37] fix(insights): address donors PR review (MRR, tab visibility, copy) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../insights/class-insights-wizard.php | 15 +++++++++++++-- .../class-donors-storage-interface.php | 9 +++++---- .../storage/class-hpos-donors-storage.php | 17 ++++++++++++----- .../storage/class-legacy-donors-storage.php | 19 ++++++++++++------- .../tabs/subscribers/WindowedSection.tsx | 6 ++++-- 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php index ceda8cf79..7a861830a 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -194,6 +194,11 @@ private static function compute_donation_activity(): bool { ? \Newspack\Insights\Storage_Detector::detect() : 'legacy'; + // Constrain to statuses that represent actual donation activity: + // completed/processing/refunded one-time orders, and subscriptions that + // have genuinely existed (active through expired). This keeps failed, + // pending, trash, auto-draft, and checkout-draft objects from surfacing + // the tab on a site that never actually took a donation. if ( 'hpos' === $backend ) { $sql = "SELECT EXISTS ( SELECT 1 FROM {$wpdb->prefix}wc_orders o @@ -201,7 +206,10 @@ private static function compute_donation_activity(): bool { JOIN {$wpdb->prefix}woocommerce_order_itemmeta meta ON meta.order_item_id = items.order_item_id AND meta.meta_key = '_product_id' - WHERE o.type IN ('shop_order', 'shop_subscription') + WHERE ( + ( o.type = 'shop_order' AND o.status IN ('wc-completed', 'wc-processing', 'wc-refunded') ) + OR ( o.type = 'shop_subscription' AND o.status IN ('wc-active', 'wc-on-hold', 'wc-pending-cancel', 'wc-cancelled', 'wc-expired') ) + ) AND meta.meta_value IN ($donations_list) LIMIT 1 ) AS has_activity"; @@ -212,7 +220,10 @@ private static function compute_donation_activity(): bool { JOIN {$wpdb->prefix}woocommerce_order_itemmeta meta ON meta.order_item_id = items.order_item_id AND meta.meta_key = '_product_id' - WHERE p.post_type IN ('shop_order', 'shop_subscription') + WHERE ( + ( p.post_type = 'shop_order' AND p.post_status IN ('wc-completed', 'wc-processing', 'wc-refunded') ) + OR ( p.post_type = 'shop_subscription' AND p.post_status IN ('wc-active', 'wc-on-hold', 'wc-pending-cancel', 'wc-cancelled', 'wc-expired') ) + ) AND meta.meta_value IN ($donations_list) LIMIT 1 ) AS has_activity"; diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php index b615f66fd..49136845e 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-donors-storage-interface.php @@ -129,8 +129,9 @@ public function get_lapsed_donors_in_window( DateTimeInterface $start, DateTimeI /** * Sum of completed donation `shop_order` totals in the window - * filtered to one-time donations only (products with no - * `_subscription_period` meta value of 'month' or 'year'). + * filtered to one-time donations only (products whose + * `_subscription_period` is not one of 'day', 'week', 'month', or + * 'year' — i.e. not recurring at any cadence). * * @param DateTimeInterface $start Inclusive window start. * @param DateTimeInterface $end Inclusive window end. @@ -141,8 +142,8 @@ public function get_one_time_donation_revenue( DateTimeInterface $start, DateTim /** * Sum of completed donation `shop_order` totals in the window * filtered to recurring donations (products with - * `_subscription_period` IN ('month','year')). These rows are - * renewal orders generated by donation subscriptions. + * `_subscription_period` IN ('day','week','month','year')). These + * rows are renewal orders generated by donation subscriptions. * * @param DateTimeInterface $start Inclusive window start. * @param DateTimeInterface $end Inclusive window end. diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php index 7b2c83178..168c46616 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-donors-storage.php @@ -149,17 +149,22 @@ public function get_donation_mrr(): float { $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); + // Per-line-item attribution: normalize each donation line item's own + // recurring total (`_line_total`) to a monthly rate. Using the line + // item total rather than the subscription's `total_amount` keeps a + // multi-line-item donation subscription from summing the full + // subscription total once per line item (which would overstate MRR). $sql = "SELECT SUM( CASE WHEN prd.meta_value = 'month' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN o.total_amount / CAST(pri.meta_value AS UNSIGNED) + THEN CAST(lt.meta_value AS DECIMAL(20,2)) / CAST(pri.meta_value AS UNSIGNED) WHEN prd.meta_value = 'year' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN o.total_amount / (12 * CAST(pri.meta_value AS UNSIGNED)) + THEN CAST(lt.meta_value AS DECIMAL(20,2)) / (12 * CAST(pri.meta_value AS UNSIGNED)) WHEN prd.meta_value = 'week' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN o.total_amount * (52/12) / CAST(pri.meta_value AS UNSIGNED) + THEN CAST(lt.meta_value AS DECIMAL(20,2)) * (52/12) / CAST(pri.meta_value AS UNSIGNED) WHEN prd.meta_value = 'day' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN o.total_amount * 30 / CAST(pri.meta_value AS UNSIGNED) - ELSE o.total_amount / 12 + THEN CAST(lt.meta_value AS DECIMAL(20,2)) * 30 / CAST(pri.meta_value AS UNSIGNED) + ELSE CAST(lt.meta_value AS DECIMAL(20,2)) / 12 END ) FROM {$prefix}wc_orders o @@ -167,6 +172,8 @@ public function get_donation_mrr(): float { ON oi.order_id = o.id AND oi.order_item_type = 'line_item' JOIN {$prefix}woocommerce_order_itemmeta oim ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + JOIN {$prefix}woocommerce_order_itemmeta lt + ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total' JOIN {$prefix}postmeta prd ON prd.post_id = CAST(oim.meta_value AS UNSIGNED) AND prd.meta_key = '_subscription_period' JOIN {$prefix}postmeta pri diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php index 896ab9296..9877c0fff 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-donors-storage.php @@ -142,26 +142,31 @@ public function get_donation_mrr(): float { $prefix = $wpdb->prefix; $donations = $this->id_list( $this->donation_product_ids ); + // Per-line-item attribution: normalize each donation line item's own + // recurring total (`_line_total`) to a monthly rate. Using the line + // item total rather than the subscription's `_order_total` keeps a + // multi-line-item donation subscription from summing the full + // subscription total once per line item (which would overstate MRR). $sql = "SELECT SUM( CASE WHEN prd.meta_value = 'month' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN CAST(tot.meta_value AS DECIMAL(15,2)) / CAST(pri.meta_value AS UNSIGNED) + THEN CAST(lt.meta_value AS DECIMAL(15,2)) / CAST(pri.meta_value AS UNSIGNED) WHEN prd.meta_value = 'year' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN CAST(tot.meta_value AS DECIMAL(15,2)) / (12 * CAST(pri.meta_value AS UNSIGNED)) + THEN CAST(lt.meta_value AS DECIMAL(15,2)) / (12 * CAST(pri.meta_value AS UNSIGNED)) WHEN prd.meta_value = 'week' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN CAST(tot.meta_value AS DECIMAL(15,2)) * (52/12) / CAST(pri.meta_value AS UNSIGNED) + THEN CAST(lt.meta_value AS DECIMAL(15,2)) * (52/12) / CAST(pri.meta_value AS UNSIGNED) WHEN prd.meta_value = 'day' AND CAST(pri.meta_value AS UNSIGNED) > 0 - THEN CAST(tot.meta_value AS DECIMAL(15,2)) * 30 / CAST(pri.meta_value AS UNSIGNED) - ELSE CAST(tot.meta_value AS DECIMAL(15,2)) / 12 + THEN CAST(lt.meta_value AS DECIMAL(15,2)) * 30 / CAST(pri.meta_value AS UNSIGNED) + ELSE CAST(lt.meta_value AS DECIMAL(15,2)) / 12 END ) FROM {$prefix}posts p - JOIN {$prefix}postmeta tot - ON tot.post_id = p.ID AND tot.meta_key = '_order_total' JOIN {$prefix}woocommerce_order_items oi ON oi.order_id = p.ID AND oi.order_item_type = 'line_item' JOIN {$prefix}woocommerce_order_itemmeta oim ON oim.order_item_id = oi.order_item_id AND oim.meta_key = '_product_id' + JOIN {$prefix}woocommerce_order_itemmeta lt + ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total' JOIN {$prefix}postmeta prd ON prd.post_id = CAST(oim.meta_value AS UNSIGNED) AND prd.meta_key = '_subscription_period' JOIN {$prefix}postmeta pri diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx index 1b4b87f8d..3522880ed 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx @@ -11,7 +11,7 @@ * `{value, computable, denominator}` shape so the UI can: * - render a small-cohort 0% as "0% of N orders" with inline context * - swap to a per-card empty state when the denominator is 0 - * ("No refunds in this timeframe.") + * ("No subscription orders in this timeframe." for refund rate) * Same pattern as Tab 7's RetentionSection. */ @@ -138,7 +138,9 @@ const WindowedSection = ( { range, current, previous }: WindowedSectionProps ) = ) : (
{ __( 'Refund rate', 'newspack-plugin' ) }
-

{ __( 'No refunds in this timeframe.', 'newspack-plugin' ) }

+

+ { __( 'No subscription orders in this timeframe.', 'newspack-plugin' ) } +

) } { retry.computable ? ( From 36d15b0547277e365c1b8d9a3512139ac08469eb Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:32:07 -0500 Subject: [PATCH 31/37] feat(insights): scaffold Gates tab Phase 1 (feature flag + REST stub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPPD-1604 Phase 1 backend scaffold. Each metric in Gates_Metric returns a `{value, computable: false, pending: true, denominator: null, placeholder_type}` payload so the React layer can render the spec's empty-state value ("0" / "0%" / "$0.00" / "0.0") without inferring type. The orchestrator carries no SQL — Phase 2 (NPPD-1630) will swap each method to dispatch a `query_name` against the Newspack Manager BigQuery query proxy; the REST controller and method signatures stay stable across the boundary. REST endpoint at `GET /newspack-insights/v1/gates` mirrors the Tab 6/7 controllers (same date validation, permission check, comparison window handling). Response carries a top-level `tab_pending: true` flag so React knows to render the Phase 1 banner. Visibility is gated by a new constant `NEWSPACK_INSIGHTS_GATES_PREVIEW` — independent of the parent `NEWSPACK_INSIGHTS_ENABLED` flag so the preview can be flipped on in dev/staging/canary separately from broader Insights rollout. When the constant is missing, the boot config marks the tab not visible and the Insights_Section_Gates::init() bails before registering the REST route, so the endpoint isn't exposed either. UI wiring (sections, viz, banner, MetricCard pending state) follows in subsequent commits in this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/class-gates-rest-controller.php | 310 ++++++++++++++++++ .../insights/class-insights-section-gates.php | 53 ++- .../insights/class-insights-wizard.php | 36 +- .../insights/metrics/class-gates-metric.php | 289 ++++++++++++++++ 4 files changed, 673 insertions(+), 15 deletions(-) create mode 100644 plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php create mode 100644 plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php diff --git a/plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php b/plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php new file mode 100644 index 000000000..ebf14d695 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-gates-rest-controller.php @@ -0,0 +1,310 @@ +namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_gates_data' ], + 'permission_callback' => [ $this, 'permissions_check' ], + 'args' => $this->get_collection_params(), + ], + ] + ); + } + + /** + * Permission check. + * + * @return bool|WP_Error + */ + public function permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'newspack_insights_rest_forbidden', + __( 'You do not have permission to view Insights data.', 'newspack-plugin' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + return true; + } + + /** + * GET handler. + * + * @param WP_REST_Request $request Request. + * @return \WP_REST_Response|WP_Error + */ + public function get_gates_data( WP_REST_Request $request ) { + $tz = $this->site_timezone(); + + try { + $start = $this->parse_date( $request->get_param( 'start' ), $tz, false ); + $end = $this->parse_date( $request->get_param( 'end' ), $tz, true ); + } catch ( Exception $e ) { + return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] ); + } + if ( $start > $end ) { + return new WP_Error( + 'newspack_insights_invalid_window', + __( 'Start date must be on or before end date.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + $compare_start_param = $request->get_param( 'compare_start' ); + $compare_end_param = $request->get_param( 'compare_end' ); + $compare_start = null; + $compare_end = null; + if ( $compare_start_param || $compare_end_param ) { + if ( ! $compare_start_param || ! $compare_end_param ) { + return new WP_Error( + 'newspack_insights_invalid_comparison', + __( 'Both compare_start and compare_end must be provided to enable comparison mode.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + try { + $compare_start = $this->parse_date( $compare_start_param, $tz, false ); + $compare_end = $this->parse_date( $compare_end_param, $tz, true ); + } catch ( Exception $e ) { + return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] ); + } + if ( $compare_start > $compare_end ) { + return new WP_Error( + 'newspack_insights_invalid_comparison_window', + __( 'compare_start must be on or before compare_end.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + } + + $metric = new Gates_Metric(); + return rest_ensure_response( $this->build_response( $metric, $start, $end, $compare_start, $compare_end ) ); + } + + /** + * Assemble the top-level response. + * + * `tab_pending` is true in Phase 1 (placeholder phase). React + * uses it to render the top-of-tab banner; remove the flag (or + * have it return false based on real data state) when Phase 2 + * wires up BigQuery. + * + * @param Gates_Metric $metric Orchestrator. + * @param DateTimeImmutable $start Current window start. + * @param DateTimeImmutable $end Current window end. + * @param DateTimeImmutable|null $compare_start Prior window start. + * @param DateTimeImmutable|null $compare_end Prior window end. + * @return array + */ + private function build_response( + Gates_Metric $metric, + DateTimeImmutable $start, + DateTimeImmutable $end, + ?DateTimeImmutable $compare_start, + ?DateTimeImmutable $compare_end + ): array { + $response = [ + 'tab_pending' => true, + 'current' => $this->build_window( $metric, $start, $end ), + 'previous' => null, + ]; + if ( $compare_start && $compare_end ) { + $response['previous'] = $this->build_window( $metric, $compare_start, $compare_end ); + } + return $response; + } + + /** + * Window-bound payload covering all five sections. + * + * @param Gates_Metric $metric Orchestrator. + * @param DateTimeImmutable $start Start. + * @param DateTimeImmutable $end End. + * @return array + */ + private function build_window( Gates_Metric $metric, DateTimeImmutable $start, DateTimeImmutable $end ): array { + return [ + 'window' => [ + 'start' => $start->format( 'Y-m-d' ), + 'end' => $end->format( 'Y-m-d' ), + ], + // Section 1. + 'total_gate_impressions' => $metric->get_total_gate_impressions( $start, $end ), + 'unique_readers_reached' => $metric->get_unique_readers_reached( $start, $end ), + 'avg_exposures_per_reader' => $metric->get_avg_exposures_per_reader( $start, $end ), + 'sessions_with_gate' => $metric->get_sessions_with_gate( $start, $end ), + // Section 2. + 'regwall_conversion_direct' => $metric->get_regwall_conversion_direct( $start, $end ), + 'regwall_conversion_influenced_7d' => $metric->get_regwall_conversion_influenced_7d( $start, $end ), + // Section 3. + 'paywall_conversion_direct' => $metric->get_paywall_conversion_direct( $start, $end ), + 'paywall_conversion_influenced_14d' => $metric->get_paywall_conversion_influenced_14d( $start, $end ), + 'total_paywall_revenue_direct' => $metric->get_total_paywall_revenue_direct( $start, $end ), + 'avg_revenue_per_paywall_conversion' => $metric->get_avg_revenue_per_paywall_conversion( $start, $end ), + // Section 4. + 'conversion_funnel' => $metric->get_conversion_funnel( $start, $end ), + 'exposures_distribution' => $metric->get_exposures_distribution( $start, $end ), + // Section 5. + 'performance_by_gate' => $metric->get_performance_by_gate( $start, $end ), + ]; + } + + /** + * Args spec. + * + * @return array + */ + public function get_collection_params() { + $base = [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ]; + return [ + 'start' => array_merge( + $base, + [ + 'description' => __( 'Inclusive window start date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'required' => true, + ] + ), + 'end' => array_merge( + $base, + [ + 'description' => __( 'Inclusive window end date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'required' => true, + ] + ), + 'compare_start' => array_merge( + $base, + [ + 'description' => __( 'Optional comparison window start. Must pair with compare_end.', 'newspack-plugin' ), + 'required' => false, + ] + ), + 'compare_end' => array_merge( + $base, + [ + 'description' => __( 'Optional comparison window end. Must pair with compare_start.', 'newspack-plugin' ), + 'required' => false, + ] + ), + ]; + } + + /** + * REST validate_callback. + * + * @param mixed $value Value. + * @return bool|WP_Error + */ + public function validate_date_string( $value ) { + if ( ! is_string( $value ) || '' === $value ) { + return new WP_Error( + 'newspack_insights_invalid_date', + __( 'Date must be a non-empty YYYY-MM-DD string.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + $parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $this->site_timezone() ); + if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) { + return new WP_Error( + 'newspack_insights_invalid_date', + /* translators: %s: the invalid date string */ + sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ), + [ 'status' => 400 ] + ); + } + return true; + } + + /** + * Parse a Y-m-d string into a DateTimeImmutable. + * + * @param mixed $value Raw value. + * @param DateTimeZone $tz Timezone. + * @param bool $end_of_day If true, 23:59:59; else 00:00:00. + * @return DateTimeImmutable + * @throws Exception On parse failure. + */ + private function parse_date( $value, DateTimeZone $tz, bool $end_of_day ): DateTimeImmutable { + if ( ! is_string( $value ) || '' === $value ) { + throw new Exception( esc_html__( 'Missing date value.', 'newspack-plugin' ) ); + } + $parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $tz ); + if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) { + /* translators: %s: the invalid date string */ + throw new Exception( esc_html( sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ) ) ); + } + return $end_of_day ? $parsed->setTime( 23, 59, 59 ) : $parsed->setTime( 0, 0, 0 ); + } + + /** + * Site timezone. + * + * @return DateTimeZone + */ + private function site_timezone(): DateTimeZone { + return wp_timezone(); + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php index c6b479d8b..09cbd9e6c 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-gates.php @@ -1,21 +1,25 @@ register_routes(); + } + ); + } } diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php index 7a861830a..672a59f21 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -70,6 +70,34 @@ public static function is_enabled() { return defined( 'NEWSPACK_INSIGHTS_ENABLED' ) && NEWSPACK_INSIGHTS_ENABLED; } + /** + * Whether the Gates preview tab (Tab 4 / NPPD-1604) is enabled + * for this environment. + * + * Independent from {@see self::is_enabled()} so the preview can + * be flipped on only where it's wanted (development, staging, + * canary), separately from the broader Insights wizard rollout. + * Once Phase 2 (NPPD-1630) lands and the tab is no longer a + * placeholder, this gate can be retired in favor of the standard + * Insights flag plus a runtime feature-detection check. + * + * @return bool True when the Gates preview should appear in the + * Insights tab nav and have its REST route active. + */ + public static function is_gates_preview_enabled(): bool { + /** + * Enables the Gates tab preview (Phase 1, placeholder data). + * + * @constant NEWSPACK_INSIGHTS_GATES_PREVIEW + * @type bool + * @default Gates preview tab hidden + * @status draft + * + * @example define( 'NEWSPACK_INSIGHTS_GATES_PREVIEW', true ); + */ + return defined( 'NEWSPACK_INSIGHTS_GATES_PREVIEW' ) && NEWSPACK_INSIGHTS_GATES_PREVIEW; + } + /** * Constructor. * @@ -247,7 +275,7 @@ protected function get_boot_config() { $thirty_ago = $today->modify( '-29 days' ); return [ - // Tab visibility. The audience/engagement/conversion/gates/ + // Tab visibility. The audience/engagement/conversion/ // prompts/advertising tabs are stubbed to true until their // data layers land (each needs BQ for proper feature // detection, NPPD-1598). Subscribers stays all-on for now; @@ -255,12 +283,14 @@ protected function get_boot_config() { // product presence) is a separate follow-up. Donors hides // when there are no donation products on the publisher, // using the shared Donation_Product_Classifier (cached 1h) - // as the single source of truth. + // as the single source of truth. Gates is gated to the + // preview constant NEWSPACK_INSIGHTS_GATES_PREVIEW while + // Phase 1 (placeholder data) is being validated. 'tabs' => [ 'audience' => true, 'engagement' => true, 'conversion' => true, - 'gates' => true, + 'gates' => self::is_gates_preview_enabled(), 'prompts' => true, 'subscribers' => true, 'donors' => self::has_donation_activity(), diff --git a/plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php new file mode 100644 index 000000000..9141124a4 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-gates-metric.php @@ -0,0 +1,289 @@ + 'decimal' === $placeholder_type ? 0.0 : 0, + 'computable' => false, + 'pending' => true, + 'denominator' => null, + 'placeholder_type' => $placeholder_type, + ]; + } + + // --- Section 1: Gate exposure --------------------------------------- + + /** + * Total gate impressions in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_total_gate_impressions( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'count' ); + } + + /** + * Unique readers who saw at least one gate. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_unique_readers_reached( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'count' ); + } + + /** + * Average gate exposures per reader. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_avg_exposures_per_reader( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'decimal' ); + } + + /** + * Percentage of sessions that hit at least one gate. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_sessions_with_gate( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + // --- Section 2: Free reader conversion ------------------------------ + + /** + * Regwall conversion rate, direct attribution. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_regwall_conversion_direct( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + /** + * Regwall conversion rate, influenced (7-day lookback). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_regwall_conversion_influenced_7d( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + // --- Section 3: Paid reader conversion ------------------------------ + + /** + * Paywall conversion rate, direct attribution. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_paywall_conversion_direct( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + /** + * Paywall conversion rate, influenced (14-day lookback). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_paywall_conversion_influenced_14d( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'rate' ); + } + + /** + * Total revenue from paywall conversions, direct attribution. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_total_paywall_revenue_direct( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'currency' ); + } + + /** + * Average revenue per paywall conversion. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_avg_revenue_per_paywall_conversion( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return $this->placeholder( 'currency' ); + } + + // --- Section 4: How readers convert --------------------------------- + + /** + * Conversion funnel — three stages with zeros and a pending flag. + * Stage shape kept stable so the React Funnel viz can render the + * same chrome regardless of phase. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{ + * pending: bool, + * stages: array + * } + */ + public function get_conversion_funnel( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return [ + 'pending' => true, + 'stages' => [ + [ + 'label' => __( 'Impression', 'newspack-plugin' ), + 'count' => 0, + 'pct_of_top' => 0.0, + ], + [ + 'label' => __( 'Engagement', 'newspack-plugin' ), + 'count' => 0, + 'pct_of_top' => 0.0, + ], + [ + 'label' => __( 'Conversion', 'newspack-plugin' ), + 'count' => 0, + 'pct_of_top' => 0.0, + ], + ], + ]; + } + + /** + * Exposures-before-conversion distribution buckets. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{ + * pending: bool, + * buckets: array + * } + */ + public function get_exposures_distribution( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return [ + 'pending' => true, + 'buckets' => [ + [ + 'label' => __( '1 exposure', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + [ + 'label' => __( '2 exposures', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + [ + 'label' => __( '3–5 exposures', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + [ + 'label' => __( '6+ exposures', 'newspack-plugin' ), + 'count' => 0, + 'pct' => 0.0, + ], + ], + ]; + } + + // --- Section 5: Performance by gate --------------------------------- + + /** + * Per-gate breakdown. Phase 1 returns an empty `rows` array; the + * React PerformanceByGateSection renders the spec's empty-state + * copy when the array is empty. Phase 2 will populate this with + * real BQ rows enriched server-side from `wp_posts.post_title` + * keyed on `gate_post_id`. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array{pending: bool, rows: array} + */ + public function get_performance_by_gate( DateTimeInterface $start, DateTimeInterface $end ): array { + unset( $start, $end ); + return [ + 'pending' => true, + 'rows' => [], + ]; + } +} From 37ac6463ac02bb8db72367d75ef3340d06c595b6 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:33:01 -0500 Subject: [PATCH 32/37] feat(insights): MetricCard pending state for Tab 4 Phase 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive `pending?: boolean` prop on MetricCard. When true, the card renders the formatted value normally but suppresses the comparison delta even if `previousValue` is supplied — placeholder zeros don't have a meaningful delta to show. Tab 6 and Tab 7 never set `pending`, so their rendering is unchanged. Tab 4 sections set it on every card during Phase 1; once Phase 2 (NPPD-1630) lands and real BQ data flows in, the flag flips off per metric and the comparison delta renders normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/tabs/components/MetricCard.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx index 53c44499f..51286a343 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -1,5 +1,5 @@ /** - * MetricCard (NPPD-1616). + * MetricCard (NPPD-1616, extended for NPPD-1604). * * Scorecard atom: label (top) → value + optional delta (vertically * centered hero region) → description (pinned to the bottom). Every @@ -9,6 +9,13 @@ * * `lowerIsBetter` flips the green/red delta tone for metrics where a * decrease is desirable (refund rate, churned subscriber count). + * + * `pending` (NPPD-1604) renders the value normally but suppresses the + * comparison delta even when `previousValue` is supplied. Used by Tab + * 4's Phase 1 placeholder cards: the value is a real "0" / "0%" / + * etc., so it visually matches the surrounding chrome — the + * top-of-tab banner is the only Phase 1 signal. Additive: Tab 6/7 + * never set `pending`, so their rendering is unchanged. */ /** @@ -39,6 +46,13 @@ export interface MetricCardProps { * paired insight without spending a whole card on it. */ secondary?: string; + /** + * Phase 1 placeholder marker (NPPD-1604). When true, the card + * renders the value normally but suppresses the comparison delta + * even if `previousValue` is provided — there's no real delta to + * show while a metric is pending real data. + */ + pending?: boolean; } const formatValue = ( v: number, fmt: MetricFormat ): string => { @@ -52,8 +66,8 @@ const formatValue = ( v: number, fmt: MetricFormat ): string => { }; const MetricCard = ( props: MetricCardProps ) => { - const { label, value, format, previousValue, description, lowerIsBetter = false, secondary } = props; - const hasComparison = typeof previousValue === 'number'; + const { label, value, format, previousValue, description, lowerIsBetter = false, secondary, pending = false } = props; + const hasComparison = ! pending && typeof previousValue === 'number'; const delta = hasComparison ? formatDelta( value, previousValue as number ) : null; const tone = hasComparison ? deltaTone( value, previousValue as number, lowerIsBetter ) : 'neutral'; const deltaA11y = From 58a94d476b81bd413aa0d8d3723756df243d26a7 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:40:30 -0500 Subject: [PATCH 33/37] feat(insights): Gates tab UI (sections, viz, banner, explainer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab 4 React layer for NPPD-1604 Phase 1. Mirrors the SubscribersTab / DonorsTab loading-error-success lifecycle and the shared section / metric-card chrome from Tab 6/7. Five sections in the spec order: 1. Gate exposure — 4 scorecards, Direct vs Influenced explainer callout below the caption 2. Free reader conversion — 2 scorecards (Direct + Influenced 7d) 3. Paid reader conversion — 4 scorecards including direct revenue 4. How readers convert — Funnel (left) + Distribution (right) 5. Performance by gate — table with empty-state row from spec Plus the Phase 1 top-of-tab dismissable banner. Banner + callout dismissals are session-only — both reappear on page reload per spec, intentional so the visual cue stays prominent. Viz components are tab-local under tabs/gates/viz/ — minimal Funnel + DistributionTable scoped to Tab 4. When the canonical versions land in packages/components/src/ (likely alongside the broader data-viz library work tracked separately), swap them in and delete the tab-local copies. Scalar metrics share a small `scalarToCard.ts` helper that maps the server's `placeholder_type` → MetricCard `format` and decides whether to surface a comparison `previousValue`. The MetricCard `pending` flag (added in the prior commit) suppresses the comparison delta when the metric is still in the placeholder phase so toggling Compare-to-previous doesn't render a misleading 0% delta. format.ts gained a `decimal` formatter so "Avg exposures per reader" renders as "0.0" per the spec's placeholder table. GatesTab.tsx replaces the prior Coming-Soon stub and is registered in TabContent.tsx (no nav change needed — the tab key was already in TabNavigation; visibility is gated by the boot config's `tabs.gates` flag which reads NEWSPACK_INSIGHTS_GATES_PREVIEW). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/api/gates.ts | 139 +++++++++++ .../wizards/insights/hooks/useGatesData.ts | 76 ++++++ .../src/wizards/insights/tabs/GatesTab.tsx | 71 +++++- .../insights/tabs/components/MetricCard.tsx | 7 +- .../insights/tabs/components/format.ts | 8 + .../tabs/gates/DirectVsInfluencedCallout.tsx | 62 +++++ .../gates/FreeReaderConversionSection.tsx | 60 +++++ .../tabs/gates/GateExposureSection.tsx | 72 ++++++ .../tabs/gates/HowReadersConvertSection.tsx | 51 ++++ .../gates/PaidReaderConversionSection.tsx | 74 ++++++ .../tabs/gates/PerformanceByGateSection.tsx | 106 ++++++++ .../insights/tabs/gates/PreviewBanner.tsx | 47 ++++ .../wizards/insights/tabs/gates/gates.scss | 227 ++++++++++++++++++ .../insights/tabs/gates/scalarToCard.ts | 43 ++++ .../tabs/gates/viz/DistributionTable.tsx | 60 +++++ .../insights/tabs/gates/viz/Funnel.tsx | 83 +++++++ 16 files changed, 1176 insertions(+), 10 deletions(-) create mode 100644 plugins/newspack-plugin/src/wizards/insights/api/gates.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx create mode 100644 plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx diff --git a/plugins/newspack-plugin/src/wizards/insights/api/gates.ts b/plugins/newspack-plugin/src/wizards/insights/api/gates.ts new file mode 100644 index 000000000..a80315a2a --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/api/gates.ts @@ -0,0 +1,139 @@ +/** + * Gates API client (NPPD-1604, Phase 1). + * + * Thin wrapper around `@wordpress/api-fetch` for the single Tab 4 + * endpoint: `GET /newspack-insights/v1/gates`. Type definitions + * mirror the PHP response shape assembled by `Gates_REST_Controller`. + * + * Phase 1: every metric carries `pending: true` and a zero value. + * Phase 2 (NPPD-1630) keeps the same shape but flips `pending` to + * false and surfaces real BQ values; the React layer does not need + * to know which phase produced a payload — it reads `pending` and + * the `tab_pending` banner flag. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * The kind of placeholder a metric renders. Encoded server-side so + * the React format layer doesn't have to guess from the field name. + */ +export type GatesPlaceholderType = 'count' | 'rate' | 'currency' | 'decimal'; + +/** + * Standard scorecard metric payload. Carries the value plus the + * `pending` and `placeholder_type` markers the UI needs to render + * the Phase 1 zeros in the correct visual format. + */ +export interface GatesScalarMetric { + value: number; + computable: boolean; + pending: boolean; + denominator: number | null; + placeholder_type: GatesPlaceholderType; +} + +export interface GatesFunnelStage { + label: string; + count: number; + pct_of_top: number; +} + +export interface GatesFunnelData { + pending: boolean; + stages: GatesFunnelStage[]; +} + +export interface GatesDistributionBucket { + label: string; + count: number; + pct: number; +} + +export interface GatesDistributionData { + pending: boolean; + buckets: GatesDistributionBucket[]; +} + +/** + * One row in the Performance by gate table. Phase 1 returns no rows + * (the section renders the spec's empty-state copy). Phase 2 will + * populate this server-side with `wp_posts.post_title` enrichment + * keyed on `gate_post_id`. + */ +export interface GatesPerformanceRow { + gate_post_id: number; + gate_name: string; + impressions: number; + unique_viewers: number; + regwall_conversions: number | null; + regwall_conversion_rate: number | null; + paywall_conversions: number | null; + paywall_conversion_rate: number | null; +} + +export interface GatesPerformanceTable { + pending: boolean; + rows: GatesPerformanceRow[]; +} + +export interface GatesWindow { + window: { start: string; end: string }; + // Section 1 — Gate exposure. + total_gate_impressions: GatesScalarMetric; + unique_readers_reached: GatesScalarMetric; + avg_exposures_per_reader: GatesScalarMetric; + sessions_with_gate: GatesScalarMetric; + // Section 2 — Free reader conversion. + regwall_conversion_direct: GatesScalarMetric; + regwall_conversion_influenced_7d: GatesScalarMetric; + // Section 3 — Paid reader conversion. + paywall_conversion_direct: GatesScalarMetric; + paywall_conversion_influenced_14d: GatesScalarMetric; + total_paywall_revenue_direct: GatesScalarMetric; + avg_revenue_per_paywall_conversion: GatesScalarMetric; + // Section 4 — How readers convert. + conversion_funnel: GatesFunnelData; + exposures_distribution: GatesDistributionData; + // Section 5 — Performance by gate. + performance_by_gate: GatesPerformanceTable; +} + +export interface GatesResponse { + /** + * True while Tab 4 is in the Phase 1 placeholder phase. React + * uses this to render the top-of-tab banner. + */ + tab_pending: boolean; + current: GatesWindow; + previous: GatesWindow | null; +} + +export interface GatesQuery { + start: string; + end: string; + compare_start?: string; + compare_end?: string; +} + +const ENDPOINT = '/newspack-insights/v1/gates'; + +/** + * Fetch Tab 4 data for the given window pair. + */ +export const fetchGatesData = async ( query: GatesQuery ): Promise< GatesResponse > => { + const params = new URLSearchParams(); + params.set( 'start', query.start ); + params.set( 'end', query.end ); + if ( query.compare_start && query.compare_end ) { + params.set( 'compare_start', query.compare_start ); + params.set( 'compare_end', query.compare_end ); + } + return apiFetch< GatesResponse >( { + path: `${ ENDPOINT }?${ params.toString() }`, + method: 'GET', + } ); +}; diff --git a/plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts b/plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts new file mode 100644 index 000000000..6d434fa42 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/hooks/useGatesData.ts @@ -0,0 +1,76 @@ +/** + * useGatesData (NPPD-1604). + * + * Tab 4's data fetch lifecycle. Mirrors {@see useDonorsData} and + * {@see useSubscribersData}: a request-id guard serializes + * overlapping calls so the latest range change wins, and + * idle / loading / success / error state is local to the tab. + */ + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import { fetchGatesData, type GatesResponse } from '../api/gates'; + +export type GatesFetchStatus = 'idle' | 'loading' | 'success' | 'error'; + +export interface UseGatesDataResult { + status: GatesFetchStatus; + data: GatesResponse | null; + error: string | null; + refetch: () => void; +} + +const errorMessage = ( e: unknown ): string => { + if ( e && typeof e === 'object' && 'message' in e && typeof ( e as { message: unknown } ).message === 'string' ) { + return ( e as { message: string } ).message; + } + return String( e ); +}; + +const useGatesData = ( range: DateRange, previousRange: DateRange | null ): UseGatesDataResult => { + const [ status, setStatus ] = useState< GatesFetchStatus >( 'idle' ); + const [ data, setData ] = useState< GatesResponse | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + + const requestIdRef = useRef( 0 ); + const [ refetchTick, setRefetchTick ] = useState( 0 ); + const refetch = useCallback( () => setRefetchTick( t => t + 1 ), [] ); + + useEffect( () => { + const myId = ++requestIdRef.current; + setStatus( 'loading' ); + setError( null ); + + fetchGatesData( { + start: range.start, + end: range.end, + compare_start: previousRange?.start, + compare_end: previousRange?.end, + } ) + .then( response => { + if ( requestIdRef.current !== myId ) { + return; + } + setData( response ); + setStatus( 'success' ); + } ) + .catch( e => { + if ( requestIdRef.current !== myId ) { + return; + } + setError( errorMessage( e ) ); + setStatus( 'error' ); + } ); + }, [ range.start, range.end, previousRange?.start, previousRange?.end, refetchTick ] ); + + return { status, data, error, refetch }; +}; + +export default useGatesData; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx index e5dc54046..b53efe4b7 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx @@ -1,7 +1,15 @@ /** - * GatesTab + * GatesTab (NPPD-1604). * - * Stub. Real content lands in NPPD-1604. + * Tab 4 orchestrator. Mirrors the SubscribersTab / DonorsTab + * loading / error / success lifecycle and composes the five Gates + * sections plus the Phase 1 top-of-tab banner. + * + * Date range picker affects every metric — there are no current-state + * metrics on this tab, only window-scoped ones. Comparison toggle is + * forwarded by the wizard chrome via the standard `previousRange` + * prop; when set, the response carries a `previous` window that the + * sections thread into their per-card MetricCards. */ /** @@ -9,11 +17,58 @@ */ import { __ } from '@wordpress/i18n'; -const GatesTab = () => ( -
-

{ __( 'Gates', 'newspack-plugin' ) }

-

{ __( 'Coming soon', 'newspack-plugin' ) }

-
-); +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import useGatesData from '../hooks/useGatesData'; +import PreviewBanner from './gates/PreviewBanner'; +import GateExposureSection from './gates/GateExposureSection'; +import FreeReaderConversionSection from './gates/FreeReaderConversionSection'; +import PaidReaderConversionSection from './gates/PaidReaderConversionSection'; +import HowReadersConvertSection from './gates/HowReadersConvertSection'; +import PerformanceByGateSection from './gates/PerformanceByGateSection'; +import './gates/gates.scss'; + +export interface GatesTabProps { + range: DateRange; + previousRange: DateRange | null; +} + +const GatesTab = ( { range, previousRange }: GatesTabProps ) => { + const { status, data, error } = useGatesData( range, previousRange ); + + if ( status === 'loading' && ! data ) { + return ( +
+ { __( 'Loading gate data…', 'newspack-plugin' ) } +
+ ); + } + + if ( status === 'error' ) { + return ( +
+

{ __( 'Could not load gate data.', 'newspack-plugin' ) }

+ { error &&

{ error }

} +
+ ); + } + + if ( ! data ) { + return null; + } + + return ( +
+ { data.tab_pending && } + + + + + +
+ ); +}; export default GatesTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx index 51286a343..df7bcb681 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -26,9 +26,9 @@ import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import { formatCurrency, formatNumber, formatPercent, formatDelta, deltaTone } from './format'; +import { formatCurrency, formatDecimal, formatNumber, formatPercent, formatDelta, deltaTone } from './format'; -export type MetricFormat = 'number' | 'currency' | 'percent'; +export type MetricFormat = 'number' | 'currency' | 'percent' | 'decimal'; export interface MetricCardProps { label: string; @@ -62,6 +62,9 @@ const formatValue = ( v: number, fmt: MetricFormat ): string => { if ( fmt === 'percent' ) { return formatPercent( v ); } + if ( fmt === 'decimal' ) { + return formatDecimal( v ); + } return formatNumber( v ); }; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts b/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts index 89b786a5c..a6b677319 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts @@ -16,6 +16,11 @@ const numberFormatter = new Intl.NumberFormat( undefined, { maximumFractionDigits: 0, } ); +const decimalFormatter = new Intl.NumberFormat( undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +} ); + const currencyFormatter = new Intl.NumberFormat( undefined, { style: 'currency', currency: 'USD', @@ -35,6 +40,9 @@ const signedPercentFormatter = new Intl.NumberFormat( undefined, { export const formatNumber = ( n: number ): string => numberFormatter.format( n ); +/** Format a number with exactly one decimal place: 0 -> "0.0", 1.23 -> "1.2". */ +export const formatDecimal = ( n: number ): string => decimalFormatter.format( n ); + export const formatCurrency = ( n: number ): string => currencyFormatter.format( n ); /** Format a fraction in [0, 1] as a percent: 0.123 -> "12.3%". */ diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx new file mode 100644 index 000000000..bfc9a2e52 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx @@ -0,0 +1,62 @@ +/** + * DirectVsInfluencedCallout (NPPD-1604). + * + * Small dismissable info callout immediately below the Section 1 + * caption, explaining the Direct vs Influenced distinction used by + * Sections 2 and 3. Per spec, dismissal is session-only (no persisted + * "don't show again" — the callout reappears on page reload). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { Icon, closeSmall, info } from '@wordpress/icons'; + +const DirectVsInfluencedCallout = () => { + const [ visible, setVisible ] = useState( true ); + if ( ! visible ) { + return null; + } + return ( +
+ +
+

+ { __( 'About Direct vs Influenced conversion', 'newspack-plugin' ) } +

+

+ { __( 'Direct', 'newspack-plugin' ) }{ ' ' } + { __( + 'conversions are tagged to a specific gate at the moment of conversion (a gate_post_id is captured on the registration or checkout event).', + 'newspack-plugin' + ) } +

+

+ { __( 'Influenced', 'newspack-plugin' ) }{ ' ' } + { __( + 'conversions count readers who saw a gate within a lookback window (7 days for free, 14 days for paid) but converted later, possibly elsewhere on the site.', + 'newspack-plugin' + ) } +

+

+ { __( + 'Influenced is broader than Direct. Use Direct for "this specific gate drove this specific conversion" attribution; use Influenced for "gates contributed to this conversion somewhere in the reader’s journey."', + 'newspack-plugin' + ) } +

+
+ +
+ ); +}; + +export default DirectVsInfluencedCallout; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx new file mode 100644 index 000000000..10e9dc930 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx @@ -0,0 +1,60 @@ +/** + * FreeReaderConversionSection (NPPD-1604, Section 2). + * + * Two scorecards side-by-side covering registration-gate conversion + * (Direct attribution and Influenced 7-day lookback). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import MetricCard from '../components/MetricCard'; +import { scalarToMetricCardProps } from './scalarToCard'; + +export interface FreeReaderConversionSectionProps { + current: GatesWindow; + previous: GatesWindow | null; +} + +const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversionSectionProps ) => ( +
+

+ { __( 'Free reader conversion', 'newspack-plugin' ) } +

+

+ { __( + 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate; Influenced counts conversions by readers who saw a gate within the last 7 days.', + 'newspack-plugin' + ) } +

+
+ + +
+
+); + +export default FreeReaderConversionSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx new file mode 100644 index 000000000..4270bb3de --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx @@ -0,0 +1,72 @@ +/** + * GateExposureSection (NPPD-1604, Section 1). + * + * Top-of-funnel exposure scorecards. Four cards in a single row. + * Caption + Direct-vs-Influenced callout below the heading. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import MetricCard from '../components/MetricCard'; +import DirectVsInfluencedCallout from './DirectVsInfluencedCallout'; +import { scalarToMetricCardProps } from './scalarToCard'; + +export interface GateExposureSectionProps { + current: GatesWindow; + previous: GatesWindow | null; +} + +const GateExposureSection = ( { current, previous }: GateExposureSectionProps ) => ( +
+

+ { __( 'Gate exposure', 'newspack-plugin' ) } +

+

+ { __( 'Top of the funnel. How many readers see gates in this timeframe.', 'newspack-plugin' ) } +

+ +
+ + + + +
+
+); + +export default GateExposureSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx new file mode 100644 index 000000000..1dd86dc7f --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/HowReadersConvertSection.tsx @@ -0,0 +1,51 @@ +/** + * HowReadersConvertSection (NPPD-1604, Section 4). + * + * Funnel (left) + Distribution (right), side-by-side at equal width. + * Both viz components are tab-local for Phase 1 — when canonical + * data-viz components land in `packages/components/src/`, swap them + * in here. + * + * Per spec: comparison overlays on these visualizations are deferred + * to v1.1 — no `previous` consumption here. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import Funnel from './viz/Funnel'; +import DistributionTable from './viz/DistributionTable'; + +export interface HowReadersConvertSectionProps { + current: GatesWindow; +} + +const HowReadersConvertSection = ( { current }: HowReadersConvertSectionProps ) => ( +
+

+ { __( 'How readers convert', 'newspack-plugin' ) } +

+

+ { __( + 'The journey from gate impression to conversion. The funnel shows where readers drop off; the distribution shows how many touches it typically takes before conversion.', + 'newspack-plugin' + ) } +

+
+
+ +
+
+ +
+
+
+); + +export default HowReadersConvertSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx new file mode 100644 index 000000000..fcb3c5e99 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PaidReaderConversionSection.tsx @@ -0,0 +1,74 @@ +/** + * PaidReaderConversionSection (NPPD-1604, Section 3). + * + * Four scorecards in a single row covering paywall-gate conversion + * (Direct attribution, Influenced 14-day lookback) plus revenue + * captured from gate-tagged conversions. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesWindow } from '../../api/gates'; +import MetricCard from '../components/MetricCard'; +import { scalarToMetricCardProps } from './scalarToCard'; + +export interface PaidReaderConversionSectionProps { + current: GatesWindow; + previous: GatesWindow | null; +} + +const PaidReaderConversionSection = ( { current, previous }: PaidReaderConversionSectionProps ) => ( +
+

+ { __( 'Paid reader conversion', 'newspack-plugin' ) } +

+

+ { __( + 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate; Influenced counts subscriptions by readers who saw a paywall in the last 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', + 'newspack-plugin' + ) } +

+
+ + + + +
+
+); + +export default PaidReaderConversionSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx new file mode 100644 index 000000000..4bc4df08b --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx @@ -0,0 +1,106 @@ +/** + * PerformanceByGateSection (NPPD-1604, Section 5). + * + * Full-width per-gate breakdown table. Phase 1 always renders the + * empty-state copy from spec since `rows` is empty. When Phase 2 + * (NPPD-1630) populates `performance_by_gate.rows` from BQ + a + * server-side `wp_posts.post_title` enrichment, the table will + * render rows sorted by impressions DESC with em-dash cells where a + * gate doesn't have the matching block (regwall or paywall). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesPerformanceRow, GatesPerformanceTable } from '../../api/gates'; +import { formatNumber, formatPercent } from '../components/format'; + +export interface PerformanceByGateSectionProps { + data: GatesPerformanceTable; +} + +const NotApplicable = () => ( + + — + +); + +const renderCount = ( v: number | null ) => ( v === null ? : formatNumber( v ) ); +const renderPercent = ( v: number | null ) => ( v === null ? : formatPercent( v ) ); + +const renderRow = ( row: GatesPerformanceRow ) => ( + + { row.gate_name } + { formatNumber( row.impressions ) } + { formatNumber( row.unique_viewers ) } + { renderCount( row.regwall_conversions ) } + { renderPercent( row.regwall_conversion_rate ) } + { renderCount( row.paywall_conversions ) } + { renderPercent( row.paywall_conversion_rate ) } + +); + +const PerformanceByGateSection = ( { data }: PerformanceByGateSectionProps ) => { + const isEmpty = data.rows.length === 0; + return ( +
+

+ { __( 'Performance by gate', 'newspack-plugin' ) } +

+

+ { __( 'Per-gate breakdown for the selected timeframe. Sorted by impressions, highest first.', 'newspack-plugin' ) } +

+
+ + + + + + + + + + + + + + { isEmpty ? ( + + + + ) : ( + data.rows.map( renderRow ) + ) } + +
{ __( 'Gate name', 'newspack-plugin' ) } + { __( 'Impressions', 'newspack-plugin' ) } + + { __( 'Unique viewers', 'newspack-plugin' ) } + + { __( 'Regwall conversions', 'newspack-plugin' ) } + + { __( 'Regwall conversion rate', 'newspack-plugin' ) } + + { __( 'Paywall conversions', 'newspack-plugin' ) } + + { __( 'Paywall conversion rate', 'newspack-plugin' ) } +
+ { __( + 'No gate data yet. Performance metrics will appear once readers begin interacting with your gates.', + 'newspack-plugin' + ) } +
+
+
+ ); +}; + +export default PerformanceByGateSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx new file mode 100644 index 000000000..99cc449fe --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PreviewBanner.tsx @@ -0,0 +1,47 @@ +/** + * PreviewBanner (NPPD-1604, Phase 1). + * + * Top-of-tab dismissable banner that calls out the Phase 1 + * placeholder state. Dismissal is session-only (component state) per + * spec — the banner reappears on page reload so the visual cue isn't + * accidentally hidden across sessions. + * + * Remove this component entirely when Phase 2 (NPPD-1630) lands and + * the tab carries real BQ data. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { Icon, closeSmall, info } from '@wordpress/icons'; + +const PreviewBanner = () => { + const [ visible, setVisible ] = useState( true ); + if ( ! visible ) { + return null; + } + return ( +
+ +

+ { __( 'This tab is live in preview mode.', 'newspack-plugin' ) }{ ' ' } + { __( + 'Real-time metrics will populate once BigQuery integration is complete. The structure, sections, and visualizations are final.', + 'newspack-plugin' + ) } +

+ +
+ ); +}; + +export default PreviewBanner; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss new file mode 100644 index 000000000..76f635791 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss @@ -0,0 +1,227 @@ +/** + * Newspack Insights — Tab 4 (Gates) styles (NPPD-1604, Phase 1) + * + * Tab 4-specific layout only. The shared Insights chrome (sections, + * metric cards, table with empty state, tab loading/error) lives in + * `tabs/components/sections.scss` and is loaded by the wizard's main + * `style.scss`. Visualization styles (funnel + distribution) live + * here because both viz components are tab-local for Phase 1. + */ + +@use "~@wordpress/base-styles/colors" as wp-colors; + +.newspack-insights { + &__gates-tab { + display: flex; + flex-direction: column; + gap: 32px; + } + + // Phase 1 preview banner — light blue background, info icon, X to dismiss. + &__gates-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 20px; + background-color: #f0f6fc; + border: 1px solid #c5d9ed; + border-radius: 4px; + + &-icon { + flex: 0 0 24px; + width: 24px; + height: 24px; + color: var(--wp-admin-theme-color); + } + + &-message { + margin: 0; + flex: 1 1 auto; + font-size: 14px; + line-height: 1.5; + color: wp-colors.$gray-900; + } + + &-dismiss { + flex: 0 0 auto; + background: transparent; + border: 0; + cursor: pointer; + padding: 4px; + color: wp-colors.$gray-700; + + &:hover { + color: wp-colors.$gray-900; + } + } + } + + // Direct vs Influenced explainer — same chrome family as the banner + // but a notch lighter and aligned under the Section 1 caption. + &__gates-callout { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 20px; + background-color: wp-colors.$gray-100; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + + &-icon { + flex: 0 0 20px; + width: 20px; + height: 20px; + color: wp-colors.$gray-700; + } + + &-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; + line-height: 1.5; + color: wp-colors.$gray-900; + + p { + margin: 0; + } + } + + &-title { + font-size: 14px; + } + + &-dismiss { + flex: 0 0 auto; + background: transparent; + border: 0; + cursor: pointer; + padding: 4px; + color: wp-colors.$gray-700; + + &:hover { + color: wp-colors.$gray-900; + } + } + } + + // Two-card row (Section 2). The shared metric-grid is auto-fill + // with a 220px minimum; for the dedicated two-card sections we + // cap at two equal columns instead of letting more squeeze in. + &__metric-grid--pair { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + + // Section 4 — funnel + distribution side-by-side at equal width, + // stacking under 720px container width. + &__gates-convert-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 24px; + align-items: start; + } + + &__gates-convert-col { + min-width: 0; // allow the column to shrink within the grid + } + + // Funnel viz — tab-local. Vertical trapezoids approximated as + // centered, width-scaled rounded rectangles. When the canonical + // Funnel component lands, swap in. + &__funnel { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 20px; + background-color: #fff; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + + &-row { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + + &-dropoff { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: wp-colors.$gray-700; + } + + &-stage { + width: 40%; + min-height: 72px; + padding: 12px 16px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + background-color: var(--wp-admin-theme-color); + color: #fff; + border-radius: 6px; + + &[data-stage-index="0"] { + opacity: 1; + } + + &[data-stage-index="1"] { + opacity: 0.8; + } + + &[data-stage-index="2"] { + opacity: 0.6; + } + + &-label { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + &-count { + font-size: 24px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } + + &-pct { + font-size: 12px; + font-weight: 400; + opacity: 0.9; + } + } + } + + // Distribution viz — tab-local. Reuses the shared table chrome. + &__distribution { + display: flex; + flex-direction: column; + gap: 8px; + + &-caption { + margin: 0; + font-size: 13px; + font-weight: 400; + line-height: 1.5; + color: wp-colors.$gray-700; + } + } + + // Empty state row inside the Performance by gate table. + &__gates-performance-empty { + padding: 32px 20px; + text-align: center; + font-style: italic; + color: wp-colors.$gray-700; + } +} diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts new file mode 100644 index 000000000..01fb24de8 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/scalarToCard.ts @@ -0,0 +1,43 @@ +/** + * Small helper that maps a `GatesScalarMetric` payload + section + * copy into the MetricCard props used by every Tab 4 scorecard. + * + * Centralises the `placeholder_type` → `MetricFormat` mapping so + * the section components stay declarative. + */ + +import type { GatesScalarMetric } from '../../api/gates'; +import type { MetricFormat } from '../components/MetricCard'; + +const formatFor = ( m: GatesScalarMetric ): MetricFormat => { + switch ( m.placeholder_type ) { + case 'rate': + return 'percent'; + case 'currency': + return 'currency'; + case 'decimal': + return 'decimal'; + case 'count': + default: + return 'number'; + } +}; + +export interface ScalarCardProps { + label: string; + description: string; + current: GatesScalarMetric; + previous?: GatesScalarMetric | null; +} + +export const scalarToMetricCardProps = ( props: ScalarCardProps ) => { + const { label, description, current, previous } = props; + return { + label, + description, + value: current.value, + format: formatFor( current ), + previousValue: previous?.computable ? previous.value : null, + pending: current.pending, + }; +}; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx new file mode 100644 index 000000000..28187ea2f --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/DistributionTable.tsx @@ -0,0 +1,60 @@ +/** + * Tab-local Distribution table viz (NPPD-1604, Phase 1). + * + * Bucket distribution table used inside Tab 4 only. Mirrors the + * pattern the canonical Table component will use when it lands in + * `packages/components/src/`, so swap-in later is mechanical. + * + * Phase 1 behavior: every bucket renders 0 / 0% in the standard + * table chrome; the section caption below the table explains the + * cohort definition regardless of phase. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesDistributionData } from '../../../api/gates'; +import { formatNumber, formatPercent } from '../../components/format'; + +export interface DistributionTableProps { + data: GatesDistributionData; +} + +const DistributionTable = ( { data }: DistributionTableProps ) => ( +
+
+ + + + + + + + + + { data.buckets.map( bucket => ( + + + + + + ) ) } + +
{ __( 'Exposures before conversion', 'newspack-plugin' ) } + { __( 'Converters', 'newspack-plugin' ) } + + { __( '% of total', 'newspack-plugin' ) } +
{ bucket.label }{ formatNumber( bucket.count ) }{ formatPercent( bucket.pct ) }
+
+

+ { __( 'Of readers who converted, this is how many gates they saw first.', 'newspack-plugin' ) } +

+
+); + +export default DistributionTable; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx new file mode 100644 index 000000000..4a3567b56 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/viz/Funnel.tsx @@ -0,0 +1,83 @@ +/** + * Tab-local Funnel viz (NPPD-1604, Phase 1). + * + * Minimal vertical three-stage funnel used inside Tab 4 only. When a + * canonical Funnel component lands in `packages/components/src/` + * (likely alongside the broader data-viz library work), swap this + * usage out and delete this file. Keeping the API surface narrow on + * purpose: a `stages` array, a `pending` flag, and CSS classes the + * shared sections.scss / tab-local gates.scss can style. + * + * Phase 1 behavior: + * - All stages render at zero + * - Drop-off labels are hidden when every stage is 0 (per spec) + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { GatesFunnelData, GatesFunnelStage } from '../../../api/gates'; +import { formatNumber, formatPercent } from '../../components/format'; + +export interface FunnelProps { + data: GatesFunnelData; +} + +const stageWidthPct = ( stage: GatesFunnelStage, topCount: number ): number => { + if ( topCount <= 0 ) { + // Phase 1 / no data: each stage renders at a fixed minimum so + // the funnel chrome stays visible without implying a value. + return 40; + } + const ratio = stage.count / topCount; + return Math.max( 12, Math.round( ratio * 100 ) ); +}; + +const Funnel = ( { data }: FunnelProps ) => { + const { stages } = data; + const topCount = stages.length > 0 ? stages[ 0 ].count : 0; + const allZero = stages.every( s => s.count === 0 ); + + return ( +
+ { stages.map( ( stage, idx ) => { + const prev = idx > 0 ? stages[ idx - 1 ] : null; + const dropOffPct = prev && prev.count > 0 ? 1 - stage.count / prev.count : 0; + const widthPct = stageWidthPct( stage, topCount ); + return ( +
+ { idx > 0 && ! allZero && ( +
+ { sprintf( + /* translators: %s: percentage of readers dropped off between two funnel stages */ + __( '%s drop-off', 'newspack-plugin' ), + formatPercent( dropOffPct ) + ) } +
+ ) } +
+
{ stage.label }
+
{ formatNumber( stage.count ) }
+ { idx > 0 && ! allZero && ( +
+ { sprintf( + /* translators: %s: percentage of stage-1 readers reaching this stage */ + __( '%s of top', 'newspack-plugin' ), + formatPercent( stage.pct_of_top ) + ) } +
+ ) } +
+
+ ); + } ) } +
+ ); +}; + +export default Funnel; From ac4c585903895a43f77ab1111f364542e4ff9867 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 20:54:15 -0500 Subject: [PATCH 34/37] feat(insights): clarify Influenced copy + lift explainer to tab top Two copy + placement tweaks to the Gates tab: 1. Reword "Influenced" subtitles and captions. The prior "in the prior X days" / "in the last X days" phrasing read as "the last X days of the selected timeframe" rather than "X days between the gate impression and the conversion event." Anchoring the time gap to the gate exposure (not the picker) eliminates the ambiguity: Card 2.2 subtitle, Card 3.2 subtitle, Section 2 caption, Section 3 caption, and the middle bullet of the Direct vs Influenced explainer all updated to the "within X days of seeing a gate" form. 2. Move the Direct vs Influenced explainer from below Section 1's caption up to the tab top, between the Phase 1 preview banner and Section 1's header. The framing is foundational to Sections 2 and 3, so publishers should encounter it before reading any section that uses the terms. Dismissal remains session-only. The matching spec doc at ~/Sites/insights-docs/specs/gates.md was updated to reflect the same copy + placement decisions (out-of-tree; not part of this PR's diff). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wizards/insights/tabs/GatesTab.tsx | 2 ++ .../tabs/gates/DirectVsInfluencedCallout.tsx | 13 ++++++++----- .../tabs/gates/FreeReaderConversionSection.tsx | 4 ++-- .../insights/tabs/gates/GateExposureSection.tsx | 7 ++++--- .../tabs/gates/PaidReaderConversionSection.tsx | 4 ++-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx index b53efe4b7..0a7760aca 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx @@ -23,6 +23,7 @@ import { __ } from '@wordpress/i18n'; import type { DateRange } from '../state/useDateRange'; import useGatesData from '../hooks/useGatesData'; import PreviewBanner from './gates/PreviewBanner'; +import DirectVsInfluencedCallout from './gates/DirectVsInfluencedCallout'; import GateExposureSection from './gates/GateExposureSection'; import FreeReaderConversionSection from './gates/FreeReaderConversionSection'; import PaidReaderConversionSection from './gates/PaidReaderConversionSection'; @@ -62,6 +63,7 @@ const GatesTab = ( { range, previousRange }: GatesTabProps ) => { return (
{ data.tab_pending && } + diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx index bfc9a2e52..74841bd2b 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx @@ -1,10 +1,13 @@ /** * DirectVsInfluencedCallout (NPPD-1604). * - * Small dismissable info callout immediately below the Section 1 - * caption, explaining the Direct vs Influenced distinction used by - * Sections 2 and 3. Per spec, dismissal is session-only (no persisted - * "don't show again" — the callout reappears on page reload). + * Small dismissable info callout rendered at the top of the Gates + * tab (immediately below the Phase 1 preview banner, above Section + * 1's heading). The Direct vs Influenced framing is foundational to + * Sections 2 and 3, so publishers should see it before reading any + * section that uses the terms. Per spec, dismissal is session-only + * (no persisted "don't show again" — the callout reappears on + * page reload). */ /** @@ -36,7 +39,7 @@ const DirectVsInfluencedCallout = () => {

{ __( 'Influenced', 'newspack-plugin' ) }{ ' ' } { __( - 'conversions count readers who saw a gate within a lookback window (7 days for free, 14 days for paid) but converted later, possibly elsewhere on the site.', + 'conversions count readers who saw a gate and then converted within a lookback window (7 days for free conversions, 14 days for paid). The conversion may happen later, possibly on a different page than the gate.', 'newspack-plugin' ) }

diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx index 10e9dc930..c9668c0ec 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx @@ -29,7 +29,7 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio

{ __( - 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate; Influenced counts conversions by readers who saw a gate within the last 7 days.', + 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate. Influenced counts readers who saw a registration gate and then registered within 7 days.', 'newspack-plugin' ) }

@@ -46,7 +46,7 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio { ...scalarToMetricCardProps( { label: __( 'Regwall Conversion (Influenced, 7d)', 'newspack-plugin' ), description: __( - 'Registered readers who saw a registration gate in the prior 7 days ÷ readers who saw a registration gate', + 'Readers who registered within 7 days of seeing a registration gate ÷ readers who saw a registration gate', 'newspack-plugin' ), current: current.regwall_conversion_influenced_7d, diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx index 4270bb3de..c541a7761 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/GateExposureSection.tsx @@ -2,7 +2,10 @@ * GateExposureSection (NPPD-1604, Section 1). * * Top-of-funnel exposure scorecards. Four cards in a single row. - * Caption + Direct-vs-Influenced callout below the heading. + * The Direct vs Influenced explainer used to live below this + * section's caption but moved to the tab top (above Section 1) so + * publishers encounter the framing before any section that uses + * the terms — see {@see GatesTab}. */ /** @@ -15,7 +18,6 @@ import { __ } from '@wordpress/i18n'; */ import type { GatesWindow } from '../../api/gates'; import MetricCard from '../components/MetricCard'; -import DirectVsInfluencedCallout from './DirectVsInfluencedCallout'; import { scalarToMetricCardProps } from './scalarToCard'; export interface GateExposureSectionProps { @@ -31,7 +33,6 @@ const GateExposureSection = ( { current, previous }: GateExposureSectionProps )

{ __( 'Top of the funnel. How many readers see gates in this timeframe.', 'newspack-plugin' ) }

-

{ __( - 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate; Influenced counts subscriptions by readers who saw a paywall in the last 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', + 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate. Influenced counts readers who saw a paywall and then subscribed within 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', 'newspack-plugin' ) }

@@ -46,7 +46,7 @@ const PaidReaderConversionSection = ( { current, previous }: PaidReaderConversio Date: Thu, 4 Jun 2026 21:02:19 -0500 Subject: [PATCH 35/37] feat(insights): sortable Performance by gate table on Tab 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click any column header to re-sort. Default sort is Impressions descending per spec. Numeric columns open DESC on first click (biggest first — what publishers usually want); the gate-name column opens ASC (alphabetical). Toggle direction by clicking the same header again. Null cells (em-dash, e.g. Regwall columns on a gate without a registration block) always sort to the bottom regardless of direction, so a "—" never claims the top of an ascending sort. Sortable header buttons fill the full click target with hover + focus-visible affordances per the data-viz component spec — chevron at 0.3 opacity inactive, 0.7 hover, 1.0 active. Aria-sort on every so screen readers announce the current state. Phase 1 still renders the empty-state row since `data.rows` is empty, but the sortable chrome stays visible so Phase 2 swap-in is seamless — when NPPD-1630 wires BigQuery and rows start flowing through, the click-to-sort UI starts shuffling them. Caption tweaked from "Sorted by impressions, highest first." to "Click any column to re-sort." since the sort is interactive now. Sortable styles scoped to Tab 4 (`.newspack-insights__table--sortable`) in gates.scss. When Tabs 6/7 want sortable tables, lift these styles + the SortableHeader component into the shared sections.scss together. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tabs/gates/PerformanceByGateSection.tsx | 166 ++++++++++++++---- .../wizards/insights/tabs/gates/gates.scss | 73 ++++++++ 2 files changed, 209 insertions(+), 30 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx index 4bc4df08b..b42042d9c 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/PerformanceByGateSection.tsx @@ -1,18 +1,30 @@ /** * PerformanceByGateSection (NPPD-1604, Section 5). * - * Full-width per-gate breakdown table. Phase 1 always renders the - * empty-state copy from spec since `rows` is empty. When Phase 2 - * (NPPD-1630) populates `performance_by_gate.rows` from BQ + a - * server-side `wp_posts.post_title` enrichment, the table will - * render rows sorted by impressions DESC with em-dash cells where a - * gate doesn't have the matching block (regwall or paywall). + * Full-width per-gate breakdown table. Sortable on every column; + * default sort is impressions descending per spec. Click a column + * header to toggle direction (numeric columns flip to DESC on first + * click, ASC on second click; the gate-name column starts ASC). + * + * Null cells (em-dash) always sort to the bottom regardless of + * direction — a gate without a registration block has no Regwall + * conversions to compare, so a "—" should never claim the top of + * an ascending sort. + * + * Phase 1 always renders the empty-state copy from spec since + * `rows` is empty. The sort affordances stay visible so the chrome + * is identical between phases — Phase 2 (NPPD-1630) populates + * `performance_by_gate.rows` from BQ + a server-side + * `wp_posts.post_title` enrichment, at which point the click-to-sort + * UI starts shuffling rows. */ /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { useMemo, useState } from '@wordpress/element'; +import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; /** * Internal dependencies @@ -24,6 +36,27 @@ export interface PerformanceByGateSectionProps { data: GatesPerformanceTable; } +/** + * The columns we expose for sorting. Matches the visible column set + * one-for-one. + */ +type SortKey = + | 'gate_name' + | 'impressions' + | 'unique_viewers' + | 'regwall_conversions' + | 'regwall_conversion_rate' + | 'paywall_conversions' + | 'paywall_conversion_rate'; + +type SortDir = 'asc' | 'desc'; + +interface ColumnDef { + key: SortKey; + label: string; + numeric: boolean; +} + const NotApplicable = () => ( — @@ -45,8 +78,97 @@ const renderRow = ( row: GatesPerformanceRow ) => ( ); +/** + * Compare two rows on a given column. Nulls always sort last + * regardless of direction. String compare for `gate_name` uses the + * browser locale. + */ +const compareRows = ( a: GatesPerformanceRow, b: GatesPerformanceRow, key: SortKey, dir: SortDir ): number => { + const av = a[ key ]; + const bv = b[ key ]; + // Nulls last (regardless of direction). + if ( av === null && bv === null ) { + return 0; + } + if ( av === null ) { + return 1; + } + if ( bv === null ) { + return -1; + } + let cmp: number; + if ( typeof av === 'string' && typeof bv === 'string' ) { + cmp = av.localeCompare( bv ); + } else { + cmp = ( av as number ) - ( bv as number ); + } + return dir === 'asc' ? cmp : -cmp; +}; + +interface SortableHeaderProps { + column: ColumnDef; + activeKey: SortKey; + activeDir: SortDir; + onSort: ( key: SortKey ) => void; +} + +const ariaSortFor = ( isActive: boolean, activeDir: SortDir ): 'ascending' | 'descending' | 'none' => { + if ( ! isActive ) { + return 'none'; + } + return activeDir === 'asc' ? 'ascending' : 'descending'; +}; + +const SortableHeader = ( { column, activeKey, activeDir, onSort }: SortableHeaderProps ) => { + const isActive = column.key === activeKey; + const ariaSort = ariaSortFor( isActive, activeDir ); + const className = column.numeric ? 'newspack-insights__table-num newspack-insights__table-sort-cell' : 'newspack-insights__table-sort-cell'; + return ( + + + + ); +}; + const PerformanceByGateSection = ( { data }: PerformanceByGateSectionProps ) => { - const isEmpty = data.rows.length === 0; + const columns: ColumnDef[] = [ + { key: 'gate_name', label: __( 'Gate name', 'newspack-plugin' ), numeric: false }, + { key: 'impressions', label: __( 'Impressions', 'newspack-plugin' ), numeric: true }, + { key: 'unique_viewers', label: __( 'Unique viewers', 'newspack-plugin' ), numeric: true }, + { key: 'regwall_conversions', label: __( 'Regwall conversions', 'newspack-plugin' ), numeric: true }, + { key: 'regwall_conversion_rate', label: __( 'Regwall conversion rate', 'newspack-plugin' ), numeric: true }, + { key: 'paywall_conversions', label: __( 'Paywall conversions', 'newspack-plugin' ), numeric: true }, + { key: 'paywall_conversion_rate', label: __( 'Paywall conversion rate', 'newspack-plugin' ), numeric: true }, + ]; + + const [ sortKey, setSortKey ] = useState< SortKey >( 'impressions' ); + const [ sortDir, setSortDir ] = useState< SortDir >( 'desc' ); + + const handleSort = ( key: SortKey ) => { + if ( key === sortKey ) { + setSortDir( prev => ( prev === 'asc' ? 'desc' : 'asc' ) ); + return; + } + setSortKey( key ); + // Default direction depends on column type: numeric columns + // open DESC (biggest first), string columns open ASC. + const def = columns.find( c => c.key === key ); + setSortDir( def?.numeric ? 'desc' : 'asc' ); + }; + + const sortedRows = useMemo( () => [ ...data.rows ].sort( ( a, b ) => compareRows( a, b, sortKey, sortDir ) ), [ data.rows, sortKey, sortDir ] ); + + const isEmpty = sortedRows.length === 0; + return (
{ __( 'Performance by gate', 'newspack-plugin' ) }

- { __( 'Per-gate breakdown for the selected timeframe. Sorted by impressions, highest first.', 'newspack-plugin' ) } + { __( 'Per-gate breakdown for the selected timeframe. Click any column to re-sort.', 'newspack-plugin' ) }

- +
- - - - - - - + { columns.map( col => ( + + ) ) } { isEmpty ? ( - ) : ( - data.rows.map( renderRow ) + sortedRows.map( renderRow ) ) }
{ __( 'Gate name', 'newspack-plugin' ) } - { __( 'Impressions', 'newspack-plugin' ) } - - { __( 'Unique viewers', 'newspack-plugin' ) } - - { __( 'Regwall conversions', 'newspack-plugin' ) } - - { __( 'Regwall conversion rate', 'newspack-plugin' ) } - - { __( 'Paywall conversions', 'newspack-plugin' ) } - - { __( 'Paywall conversion rate', 'newspack-plugin' ) } -
+ { __( 'No gate data yet. Performance metrics will appear once readers begin interacting with your gates.', 'newspack-plugin' @@ -94,7 +200,7 @@ const PerformanceByGateSection = ( { data }: PerformanceByGateSectionProps ) =>
diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss index 76f635791..31d2eec71 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/gates.scss @@ -224,4 +224,77 @@ font-style: italic; color: wp-colors.$gray-700; } + + // Sortable table affordances (Tab 4 Performance by gate). When + // other tables across Tabs 6/7 want sortable headers, lift these + // styles into the shared sections.scss and the SortableHeader + // component along with them. + &__table--sortable { + .newspack-insights__table-sort-cell { + // Eliminate the th's default padding because the button + // inside owns the click target and re-paints the padding + // itself; without this, half the cell isn't clickable. + padding: 0; + } + + .newspack-insights__table-sort { + // Button fills the cell so any click anywhere in the th + // triggers the sort. + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 12px 20px; + background: transparent; + border: 0; + cursor: pointer; + text-align: inherit; + font: inherit; + color: inherit; + letter-spacing: inherit; + text-transform: inherit; + + &:hover { + background-color: wp-colors.$gray-200; + } + + &:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + } + + // Numeric columns are right-aligned, so reverse the button's + // content order: label first → indicator pinned to the right + // edge. Non-numeric columns keep left-to-right (label then + // indicator) but with margin-right: auto on the label. + .newspack-insights__table-num .newspack-insights__table-sort { + justify-content: flex-end; + text-align: right; + } + + .newspack-insights__table-sort-label { + // Push the indicator to whichever side the cell is aligned + // against; this collapses with the parent button's + // justify-content rule above. + flex: 1 1 auto; + } + + .newspack-insights__table-sort-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.3; + color: wp-colors.$gray-700; + } + + .newspack-insights__table-sort:hover .newspack-insights__table-sort-indicator { + opacity: 0.7; + } + + .newspack-insights__table-sort.is-active .newspack-insights__table-sort-indicator { + opacity: 1; + color: wp-colors.$gray-900; + } + } } From de01578ee378495cd027f276a18239b79b191475 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 4 Jun 2026 22:24:06 -0500 Subject: [PATCH 36/37] feat(insights): align Gates React strings with session-scoped attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the Section 2 / Section 3 captions, Cards 2.1 / 2.2 / 3.1 / 3.2 / 3.3 subtitles, and the Direct vs Influenced callout body verbatim from specs/gates.md to bring the React UI back in sync with the session-scoped attribution model. Direct = same-session: gate impression and conversion share a GA session. Influenced = cross-session within lookback (7d free / 14d paid). Same-session is excluded from Influenced so the two definitions stay mutually exclusive. Also updates the docblock on PaidReaderConversionSection to drop the old "gate-tagged" phrasing for internal consistency. Spec doc cross-reference: ~/Sites/insights-docs/specs/gates.md (separate doc commit, out-of-tree). Phase 2 SQL implementation follows the same model — see formulas/tab-4-gates.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tabs/gates/DirectVsInfluencedCallout.tsx | 6 +++--- .../gates/FreeReaderConversionSection.tsx | 9 ++++++--- .../gates/PaidReaderConversionSection.tsx | 19 ++++++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx index 74841bd2b..b6be3421e 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/DirectVsInfluencedCallout.tsx @@ -32,20 +32,20 @@ const DirectVsInfluencedCallout = () => {

{ __( 'Direct', 'newspack-plugin' ) }{ ' ' } { __( - 'conversions are tagged to a specific gate at the moment of conversion (a gate_post_id is captured on the registration or checkout event).', + 'conversions happen in the same session as a gate impression. The gate is credited regardless of whether checkout happens on the same page (embedded checkout block) or after clicking through to a subscription page.', 'newspack-plugin' ) }

{ __( 'Influenced', 'newspack-plugin' ) }{ ' ' } { __( - 'conversions count readers who saw a gate and then converted within a lookback window (7 days for free conversions, 14 days for paid). The conversion may happen later, possibly on a different page than the gate.', + 'conversions happen after a gate impression but in a later session, within a lookback window (7 days for free conversions, 14 days for paid).', 'newspack-plugin' ) }

{ __( - 'Influenced is broader than Direct. Use Direct for "this specific gate drove this specific conversion" attribution; use Influenced for "gates contributed to this conversion somewhere in the reader’s journey."', + 'Same-session is Direct. Later-session-within-lookback is Influenced. The two are mutually exclusive and together capture every gate-touched conversion within the lookback period.', 'newspack-plugin' ) }

diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx index c9668c0ec..44f2d806b 100644 --- a/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/gates/FreeReaderConversionSection.tsx @@ -29,7 +29,7 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio

{ __( - 'How effectively registration gates convert visitors into registered readers. Direct counts conversions tagged to a gate. Influenced counts readers who saw a registration gate and then registered within 7 days.', + 'How effectively registration gates convert visitors into registered readers. Direct counts registrations that happened in the same session as a registration gate impression. Influenced counts registrations that happened in a later session within 7 days of a registration gate impression.', 'newspack-plugin' ) }

@@ -37,7 +37,10 @@ const FreeReaderConversionSection = ( { current, previous }: FreeReaderConversio

{ __( - 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions tagged to a gate. Influenced counts readers who saw a paywall and then subscribed within 14 days. Revenue is computed from actual Woo orders, not gate-event amounts.', + 'How effectively paywall gates convert visitors into paying subscribers. Direct counts subscriptions that happened in the same session as a paywall impression. Influenced counts subscriptions that happened in a later session within 14 days of a paywall impression. Revenue is computed from actual Woo orders, not gate-event amounts.', 'newspack-plugin' ) }

@@ -38,7 +38,10 @@ const PaidReaderConversionSection = ( { current, previous }: PaidReaderConversio Date: Mon, 8 Jun 2026 21:11:54 -0500 Subject: [PATCH 37/37] docs(insights): correct rate-method @return shapes + donors-visibility comment Address Copilot on #243: get_subscription_refund_rate / get_failed_payment_retry_rate (HPOS + legacy) return an array{value,computable,denominator}, not float; and the donors tab-visibility comment now matches has_donation_activity() (activity, not mere product presence; cached a day). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wizards/insights/class-insights-wizard.php | 11 ++++++----- .../wizards/insights/storage/class-hpos-storage.php | 4 ++-- .../wizards/insights/storage/class-legacy-storage.php | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php index 672a59f21..41122fc2b 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -281,11 +281,12 @@ protected function get_boot_config() { // detection, NPPD-1598). Subscribers stays all-on for now; // Tab 6 visibility detection (non-donation subscription // product presence) is a separate follow-up. Donors hides - // when there are no donation products on the publisher, - // using the shared Donation_Product_Classifier (cached 1h) - // as the single source of truth. Gates is gated to the - // preview constant NEWSPACK_INSIGHTS_GATES_PREVIEW while - // Phase 1 (placeholder data) is being validated. + // when there's no donation activity — has_donation_activity() + // uses the Donation_Product_Classifier to find donation + // products, then checks for actual orders/subscriptions in + // qualifying statuses (result cached for a day). Gates is + // gated to the preview constant NEWSPACK_INSIGHTS_GATES_PREVIEW + // while Phase 1 (placeholder data) is being validated. 'tabs' => [ 'audience' => true, 'engagement' => true, diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php index 821e19476..3318a4427 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php @@ -429,7 +429,7 @@ public function get_subscription_revenue_net( DateTimeInterface $start, DateTime * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return array{value: float, computable: bool, denominator: int} */ public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; @@ -614,7 +614,7 @@ public function get_upcoming_cancellations_30d(): array { * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return array{value: float, computable: bool, denominator: int} */ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php index 248a9ba31..d511d65c7 100644 --- a/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php @@ -409,7 +409,7 @@ public function get_subscription_revenue_net( DateTimeInterface $start, DateTime * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return array{value: float, computable: bool, denominator: int} */ public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb; @@ -591,7 +591,7 @@ public function get_upcoming_cancellations_30d(): array { * * @param DateTimeInterface $start Window start. * @param DateTimeInterface $end Window end. - * @return float + * @return array{value: float, computable: bool, denominator: int} */ public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): array { global $wpdb;