diff --git a/plugins/newspack-plugin/includes/class-newspack.php b/plugins/newspack-plugin/includes/class-newspack.php index 606fcf9762..75b4819fa0 100644 --- a/plugins/newspack-plugin/includes/class-newspack.php +++ b/plugins/newspack-plugin/includes/class-newspack.php @@ -165,6 +165,15 @@ private function includes() { // Newspack Wizards and Sections. include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-newspack-dashboard.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-newspack-settings.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-wizard.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-audience.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-engagement.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-conversion.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-gates.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-prompts.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-subscribers.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-donors.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/insights/class-insights-section-advertising.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-custom-events-section.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-emails-section.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-syndication-section.php'; diff --git a/plugins/newspack-plugin/includes/class-wizards.php b/plugins/newspack-plugin/includes/class-wizards.php index 873b72558f..713f4d3ce3 100644 --- a/plugins/newspack-plugin/includes/class-wizards.php +++ b/plugins/newspack-plugin/includes/class-wizards.php @@ -69,6 +69,7 @@ public static function init_wizards() { 'audience-content-gates' => new Audience_Content_Gates(), 'audience-donations' => new Audience_Donations(), 'audience-integrations' => new Audience_Integrations(), + 'insights' => new Insights_Wizard(), 'listings' => new Listings_Wizard(), 'network' => new Network_Wizard(), 'newsletters' => new Newsletters_Wizard(), @@ -77,6 +78,18 @@ public static function init_wizards() { if ( Memberships::is_active() ) { self::$wizards['audience-subscriptions'] = new Audience_Subscriptions(); } + + // Initialize Insights section classes. These are plain classes (not + // Wizard_Section subclasses) that hold the hook points for future + // per-tab REST endpoint registration as each tab's data layer lands. + Insights_Section_Audience::init(); + Insights_Section_Engagement::init(); + Insights_Section_Conversion::init(); + Insights_Section_Gates::init(); + Insights_Section_Prompts::init(); + Insights_Section_Subscribers::init(); + Insights_Section_Donors::init(); + Insights_Section_Advertising::init(); } /** 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 new file mode 100644 index 0000000000..1aac75b5e4 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/api/class-subscribers-rest-controller.php @@ -0,0 +1,318 @@ +namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_subscribers_data' ], + 'permission_callback' => [ $this, 'permissions_check' ], + 'args' => $this->get_collection_params(), + ], + ] + ); + } + + /** + * Permission check. Mirrors the Insights wizard capability so the + * data layer is only available to users who can view the tab. + * + * @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 /newspack-insights/v1/subscribers handler. + * + * @param WP_REST_Request $request Incoming request. + * @return WP_REST_Response|WP_Error + */ + public function get_subscribers_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 Subscribers_Metric(); + + return rest_ensure_response( $this->build_response( $metric, $start, $end, $compare_start, $compare_end ) ); + } + + /** + * Assemble the response payload. + * + * @param Subscribers_Metric $metric Metric orchestrator. + * @param DateTimeImmutable $start Current window start (00:00:00). + * @param DateTimeImmutable $end Current window end (23:59:59). + * @param DateTimeImmutable|null $compare_start Prior window start (or null). + * @param DateTimeImmutable|null $compare_end Prior window end (or null). + * @return array + */ + private function build_response( + Subscribers_Metric $metric, + DateTimeImmutable $start, + DateTimeImmutable $end, + ?DateTimeImmutable $compare_start, + ?DateTimeImmutable $compare_end + ): array { + $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(), + ], + '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 metric payload. + * + * @param Subscribers_Metric $metric Metric orchestrator. + * @param DateTimeImmutable $start Window start. + * @param DateTimeImmutable $end Window end. + * @return array + */ + private function build_window( Subscribers_Metric $metric, DateTimeImmutable $start, DateTimeImmutable $end ): array { + return [ + 'window' => [ + 'start' => $start->format( 'Y-m-d' ), + 'end' => $end->format( 'Y-m-d' ), + ], + 'new_subscribers' => $metric->get_new_subscribers_in_window( $start, $end ), + 'churned_subscribers' => $metric->get_churned_subscribers_in_window( $start, $end ), + 'revenue_gross' => $metric->get_subscription_revenue_gross( $start, $end ), + '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 ), + 'cancellation_reasons' => $metric->get_cancellation_reasons( $start, $end ), + ]; + } + + /** + * Build the args spec for query parameters. Validation runs before + * the handler so we can reject malformed input early. + * + * @return array + */ + public function get_collection_params() { + return [ + 'start' => [ + 'description' => __( 'Inclusive window start date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ], + 'end' => [ + 'description' => __( 'Inclusive window end date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ], + 'compare_start' => [ + 'description' => __( 'Optional comparison window start (YYYY-MM-DD). Must be paired with compare_end.', 'newspack-plugin' ), + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ], + 'compare_end' => [ + 'description' => __( 'Optional comparison window end (YYYY-MM-DD). Must be paired with compare_start.', 'newspack-plugin' ), + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_date_string' ], + ], + ]; + } + + /** + * REST validate_callback for date params. + * + * @param mixed $value Value provided by the client. + * @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 at the start or end + * of day in the site's timezone. + * + * @param mixed $value The raw value from the request. + * @param DateTimeZone $tz Site timezone. + * @param bool $end_of_day If true, sets time to 23:59:59 (inclusive). + * @return DateTimeImmutable + * @throws Exception If the value cannot be parsed. + */ + 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 resolver. + * + * @return DateTimeZone + */ + private function site_timezone(): DateTimeZone { + return wp_timezone(); + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-advertising.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-advertising.php new file mode 100644 index 0000000000..2d88afea4d --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-section-advertising.php @@ -0,0 +1,48 @@ +register_routes(); + } + ); + Donation_Product_Classifier::register_hooks(); + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php new file mode 100644 index 0000000000..7253c81350 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/class-insights-wizard.php @@ -0,0 +1,151 @@ +slug ) { + return; + } + + wp_enqueue_script( 'newspack-wizards' ); + + wp_localize_script( 'newspack-wizards', 'newspackInsights', $this->get_boot_config() ); + } + + /** + * Build the boot config consumed by the React entry. + * + * @return array + */ + protected function get_boot_config() { + // current_datetime() returns DateTimeImmutable; modify() returns a new + // instance and does not mutate $today. -29 days yields an inclusive + // 30-day window ending today (today + 29 prior days = 30 days). + $today = current_datetime(); + $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. + 'tabs' => [ + 'audience' => true, + 'engagement' => true, + 'conversion' => true, + 'gates' => true, + 'prompts' => true, + 'subscribers' => true, + 'donors' => true, + 'advertising' => true, + ], + 'defaultDateRange' => [ + 'preset' => 'last-30', + 'start' => $thirty_ago->format( 'Y-m-d' ), + 'end' => $today->format( 'Y-m-d' ), + ], + 'defaultComparison' => false, + 'timezone' => wp_timezone_string(), + 'settingsUrl' => admin_url( 'admin.php?page=newspack-settings' ), + ]; + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/classifiers/class-donation-product-classifier.php b/plugins/newspack-plugin/includes/wizards/insights/classifiers/class-donation-product-classifier.php new file mode 100644 index 0000000000..cdf8eed617 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/classifiers/class-donation-product-classifier.php @@ -0,0 +1,231 @@ + 0 ) { + $ids[] = $parent_id; + } + $children = Donations::get_donation_product_child_products_ids(); + foreach ( $children as $child_id ) { + if ( $child_id ) { + $ids[] = (int) $child_id; + } + } + + // Path 1: products manually flagged as donations via + // `_newspack_is_donation` postmeta. + $flagged = Donations::get_flagged_donation_product_ids(); + $ids = array_merge( $ids, array_map( 'intval', (array) $flagged ) ); + } + + // Path 2: variations whose parents are in the union from paths 1 + 3. + // Necessary because the order product lookup table records variation + // product IDs, not parent IDs; a NOT IN filter using only parent IDs + // would leak variation orders through. + $parents = array_values( array_unique( array_filter( $ids ) ) ); + if ( ! empty( $parents ) ) { + global $wpdb; + $parent_list = implode( ',', array_map( 'intval', $parents ) ); + $variations = $wpdb->get_col( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type = 'product_variation' + AND post_parent IN ($parent_list)" + ); + $ids = array_merge( $ids, array_map( 'intval', (array) $variations ) ); + } + + $ids = array_values( array_unique( array_map( 'intval', $ids ) ) ); + sort( $ids ); + return $ids; + } +} 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 new file mode 100644 index 0000000000..12f5419905 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/metrics/class-subscribers-metric.php @@ -0,0 +1,409 @@ +backend = Storage_Detector::detect(); + $donation_ids = Donation_Product_Classifier::get_donation_product_ids(); + + $this->storage = Storage_Detector::BACKEND_HPOS === $this->backend + ? new HPOS_Storage( $donation_ids ) + : new Legacy_Storage( $donation_ids ); + } + + /** + * Active storage backend identifier. + * + * Exposed for the classification banner so the React layer can show + * the publisher which backend is in use. + * + * @return string + */ + public function get_backend(): string { + return $this->backend; + } + + /** + * Classification metadata for the banner. Aggregates the inputs that + * the publisher needs to verify that Insights is reading correctly. + * + * @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 ), + ]; + } + + /** + * Distinct active non-donation subscribers right now. + * + * @return int + */ + public function get_active_non_donation_subscribers(): int { + return (int) $this->cached( + 'active_non_donation_subscribers', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_active_non_donation_subscribers(); + } + ); + } + + /** + * New subscribers in window. See storage contract for semantics. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_new_subscribers_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + return (int) $this->cached( + 'new_subscribers_in_window', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_new_subscribers_in_window( $start, $end ); + } + ); + } + + /** + * Churned subscribers in window. See storage contract for semantics. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_churned_subscribers_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + return (int) $this->cached( + 'churned_subscribers_in_window', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_churned_subscribers_in_window( $start, $end ); + } + ); + } + + /** + * Monthly Recurring Revenue (snapshot). + * + * @return float + */ + public function get_mrr(): float { + return (float) $this->cached( + 'mrr', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_mrr(); + } + ); + } + + /** + * Annual Recurring Revenue (snapshot). + * + * @return float + */ + public function get_arr(): float { + return (float) $this->cached( + 'arr', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_arr(); + } + ); + } + + /** + * Gross subscription revenue in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_subscription_revenue_gross( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'subscription_revenue_gross', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_subscription_revenue_gross( $start, $end ); + } + ); + } + + /** + * Net subscription revenue in window (gross minus refunds processed). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_subscription_revenue_net( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'subscription_revenue_net', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_subscription_revenue_net( $start, $end ); + } + ); + } + + /** + * Subscription refund rate in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'subscription_refund_rate', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_subscription_refund_rate( $start, $end ); + } + ); + } + + /** + * Subscription tenure distribution (one row per active sub). + * + * NOTE: as of v1 the React Subscribers tab no longer renders the + * tenure histogram that previously consumed this data — only the + * median / p25 / p75 callouts are shown, and those are derived from + * the same per-row payload returned here. This method (and the + * corresponding {@see Storage_Interface::get_subscription_tenure_distribution()} + * implementations) is preserved for a potential v1.1 revival of a + * richer tenure visualization. Do not delete as "dead code"; the + * REST endpoint still surfaces this payload and the React layer + * still uses the per-row days array. + * + * @return array + */ + public function get_subscription_tenure_distribution(): array { + return (array) $this->cached( + 'subscription_tenure_distribution', + [], + self::TTL_HEAVY, + function () { + return $this->storage->get_subscription_tenure_distribution(); + } + ); + } + + /** + * Upcoming renewals (count + total value) in the next 30 days. + * + * @return array{count: int, total_value: float} + */ + public function get_upcoming_renewals_30d(): array { + return (array) $this->cached( + 'upcoming_renewals_30d', + [], + self::TTL_DEFAULT, + function () { + return $this->storage->get_upcoming_renewals_30d(); + } + ); + } + + /** + * Failed payment retry rate (recoveries / attempts) in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + return (float) $this->cached( + 'failed_payment_retry_rate', + $this->window_key( $start, $end ), + self::TTL_DEFAULT, + function () use ( $start, $end ) { + return $this->storage->get_failed_payment_retry_rate( $start, $end ); + } + ); + } + + /** + * Per-product performance breakdown (top 50 by active subs). + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( + 'performance_by_product', + $this->window_key( $start, $end ), + self::TTL_HEAVY, + function () use ( $start, $end ) { + return $this->storage->get_performance_by_product( $start, $end ); + } + ); + } + + /** + * Cancellation reason buckets in window. + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_cancellation_reasons( DateTimeInterface $start, DateTimeInterface $end ): array { + return (array) $this->cached( + 'cancellation_reasons', + $this->window_key( $start, $end ), + self::TTL_HEAVY, + function () use ( $start, $end ) { + return $this->storage->get_cancellation_reasons( $start, $end ); + } + ); + } + + /** + * Flush ALL Tab 6 metric caches. Use after a manual data correction + * or from the future NPPD-1605 invalidation system; not wired to any + * automatic trigger today because the WP transient API has no key + * pattern API and individual metrics expire on their own TTL. + * + * @return void + */ + public static function flush_all(): void { + global $wpdb; + $prefix = '_transient_' . self::CACHE_PREFIX; + // 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( $prefix ) . '%', + $wpdb->esc_like( '_transient_timeout_' . self::CACHE_PREFIX ) . '%' + ) + ); + // phpcs:enable + } + + /** + * Common window key builder. Uses UTC epoch seconds so the same window + * across DST transitions hashes consistently. + * + * @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. Looks up a transient; on miss, runs the callback, + * stores, returns. + * + * Key shape: `{prefix}{backend}:{method}:{md5(params_json)}`. + * + * @param string $method Storage method name (no leading `get_`). + * @param array $params Parameters that affect the result. + * @param int $ttl TTL in seconds. + * @param callable $callback Function returning the fresh value. + * @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; + } +} 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 new file mode 100644 index 0000000000..a470378fbc --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-hpos-storage.php @@ -0,0 +1,888 @@ +donation_product_ids = array_map( 'intval', $donation_product_ids ); + } + + /** + * Build a SQL-safe `IN (...)` list from a list of integer product IDs. + * + * Empty input returns `(0)` so the resulting `NOT IN` clause stays + * valid syntactically while never matching a real product. + * + * @param int[] $ids List of integer IDs. + * @return string Comma-separated list of integers (or `0`), unparenthesized. + */ + private function id_list( array $ids ): string { + if ( empty( $ids ) ) { + return '0'; + } + return implode( ',', array_map( 'intval', $ids ) ); + } + + /** + * Format a datetime for SQL comparison, in UTC. + * + * Every column these queries compare against stores UTC: the `*_gmt` + * order/post columns and the WooCommerce Subscriptions `_schedule_*` meta + * (which WCS persists as UTC datetime strings). Window bounds arrive in the + * site timezone (built from `wp_timezone()` in the REST controller), so we + * format the absolute instant in UTC here to keep the window aligned on + * non-UTC sites. Uses `getTimestamp()` so the result is correct regardless + * of the input DateTime's own timezone. + * + * @param DateTimeInterface $dt DateTime to format. + * @return string `Y-m-d H:i:s` UTC-formatted string. + */ + private function fmt( DateTimeInterface $dt ): string { + return gmdate( 'Y-m-d H:i:s', $dt->getTimestamp() ); + } + + /** + * {@inheritDoc} + */ + public function get_active_non_donation_subscribers(): 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 NOT IN ($donations)"; + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_new_subscribers_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Customers whose earliest non-donation subscription's _schedule_start + // falls in the window. Inner aggregate computes first-start per + // customer; outer count filters that to the window. + $sql = $wpdb->prepare( + "SELECT COUNT(*) FROM ( + SELECT o.customer_id, MIN(om.meta_value) AS first_start + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = '_schedule_start' + 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 NOT IN ($donations) + GROUP BY o.customer_id + ) AS first_subs + WHERE first_subs.first_start 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_churned_subscribers_in_window( DateTimeInterface $start, DateTimeInterface $end ): int { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Customers whose non-donation subscriptions cancelled/expired in + // window AND who have no remaining active non-donation subscriptions. + $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 NOT 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 NOT IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + */ + public function get_mrr(): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // phpcs:disable Squiz.PHP.CommentedOutCode.Found -- prose with billing math triggers heuristic + + /* + * Normalize each active subscription's total to a monthly rate. + * The CASE statement covers all documented Woo billing periods at + * any positive integer interval N. Daily subscriptions multiply + * the row total by thirty and divide by N, treating a month as + * thirty days. Weekly subscriptions multiply by fifty-two over + * twelve and divide by N. Monthly subscriptions divide by N. + * Yearly subscriptions divide by twelve times N. + * + * The ELSE branch is truly conservative — it falls through to + * total over twelve, which undercounts MRR for anything except + * yearly. A publisher with weird intervals will see slightly + * lower MRR than reality rather than the previous behavior of + * multiplying everything to look monthly. A separate diagnostic + * query below counts subscriptions hitting this fallback and + * logs a notice via Newspack Logger so the publisher can correct + * the product configuration. + * + * The DISTINCT order-id sub-select dedupes subscriptions that + * have more than one non-donation line item so MRR isn't + * multiplied across line items. + */ + // phpcs:enable Squiz.PHP.CommentedOutCode.Found + $sql = "SELECT SUM( + CASE + WHEN bp.meta_value = 'day' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN o.total_amount * 30 / CAST(bi.meta_value AS UNSIGNED) + WHEN bp.meta_value = 'week' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN o.total_amount * (52/12) / CAST(bi.meta_value AS UNSIGNED) + WHEN bp.meta_value = 'month' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN o.total_amount / CAST(bi.meta_value AS UNSIGNED) + WHEN bp.meta_value = 'year' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN o.total_amount / (12 * CAST(bi.meta_value AS UNSIGNED)) + ELSE o.total_amount / 12 + END + ) + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta bp + ON bp.order_id = o.id AND bp.meta_key = '_billing_period' + JOIN {$prefix}wc_orders_meta bi + ON bi.order_id = o.id AND bi.meta_key = '_billing_interval' + 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 NOT IN ($donations) + )"; + + $mrr = (float) $wpdb->get_var( $sql ); + + // Diagnostic: count active non-donation subscriptions whose + // _billing_period or _billing_interval is unrecognized. If any + // exist, their MRR contribution was the conservative fallback — + // surface so the publisher can fix the product config. + $unrecognized = (int) $wpdb->get_var( + "SELECT COUNT(DISTINCT o.id) + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta bp ON bp.order_id = o.id AND bp.meta_key = '_billing_period' + JOIN {$prefix}wc_orders_meta bi ON bi.order_id = o.id AND bi.meta_key = '_billing_interval' + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND ( + bp.meta_value NOT IN ('day', 'week', 'month', 'year') + OR CAST(bi.meta_value AS UNSIGNED) = 0 + ) + 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) + )" + ); + + if ( $unrecognized > 0 && class_exists( Logger::class ) ) { + Logger::log( + sprintf( + '%d active non-donation subscription(s) have unrecognized _billing_period/_billing_interval combinations. Their MRR contribution fell through to the conservative annual-amortized fallback (total / 12). Review product configuration.', + $unrecognized + ), + 'NEWSPACK-INSIGHTS' + ); + } + + return $mrr; + } + + /** + * {@inheritDoc} + */ + public function get_arr(): float { + return $this->get_mrr() * 12; + } + + /** + * Compute the ID set of subscription-type products (subscription, + * variable-subscription, subscription_variation) for use in the + * subscription-product filter clauses. Live query per call; callers + * should be cached. + * + * @return string Comma-separated list of integer product IDs, or `0` + * when the publisher has none. + */ + private function subscription_product_ids_sql(): string { + global $wpdb; + $prefix = $wpdb->prefix; + + $rows = $wpdb->get_col( + "SELECT p.ID + FROM {$prefix}posts p + JOIN {$prefix}term_relationships tr ON p.ID = tr.object_id + JOIN {$prefix}term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + JOIN {$prefix}terms t ON tt.term_id = t.term_id + WHERE tt.taxonomy = 'product_type' + AND t.slug IN ('subscription', 'variable-subscription', 'subscription_variation')" + ); + + return $this->id_list( array_map( 'intval', (array) $rows ) ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_subscription_revenue_gross( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + $subscription_p = $this->subscription_product_ids_sql(); + + // Sum shop_order totals where order contains a subscription product + // AND no donation product. Two separate filters on the lookup table. + $sql = $wpdb->prepare( + "SELECT SUM(o.total_amount) + FROM {$prefix}wc_orders o + WHERE o.type = 'shop_order' + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt BETWEEN %s AND %s + AND o.id IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + ) + AND o.id NOT IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($donations) + )", + $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_subscription_revenue_net( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + $subscription_p = $this->subscription_product_ids_sql(); + + // Sum across shop_order + shop_order_refund rows. Refunds carry + // negative totals so SUM yields correct net. + $sql = $wpdb->prepare( + "SELECT SUM(o.total_amount) + FROM {$prefix}wc_orders o + WHERE o.type IN ('shop_order', 'shop_order_refund') + AND o.status IN ('wc-completed', 'wc-processing') + AND o.date_created_gmt BETWEEN %s AND %s + AND ( + ( + o.type = 'shop_order' + AND o.id IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + ) + ) + OR ( + o.type = 'shop_order_refund' + AND o.parent_order_id IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + ) + ) + )", + $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_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + $subscription_p = $this->subscription_product_ids_sql(); + + // Count subscription orders in window. + $orders_sql = $wpdb->prepare( + "SELECT COUNT(*) + FROM {$prefix}wc_orders + WHERE type = 'shop_order' + AND status IN ('wc-completed', 'wc-processing') + AND date_created_gmt BETWEEN %s AND %s + AND id IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + $orders = (int) $wpdb->get_var( $orders_sql ); + + if ( 0 === $orders ) { + return 0.0; + } + + // Count refunds in window whose parent order had a subscription product. + $refunds_sql = $wpdb->prepare( + "SELECT COUNT(*) + FROM {$prefix}wc_orders r + WHERE r.type = 'shop_order_refund' + AND r.date_created_gmt BETWEEN %s AND %s + AND r.parent_order_id IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + $refunds = (int) $wpdb->get_var( $refunds_sql ); + + return $refunds / $orders; + } + + /** + * {@inheritDoc} + */ + public function get_subscription_tenure_distribution(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Tenure days since _schedule_start for each active non-donation + // subscription line item, grouped client-side by product_name. + // Excludes empty or future start dates (data-corruption edge case). + $sql = "SELECT + p.post_title AS product_name, + TIMESTAMPDIFF(DAY, om.meta_value, NOW()) AS tenure_days + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = '_schedule_start' + 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}posts p ON p.ID = CAST(oim.meta_value AS UNSIGNED) + WHERE o.type = 'shop_subscription' + AND o.status = 'wc-active' + AND oim.meta_value NOT IN ($donations) + AND om.meta_value != '' + AND om.meta_value < NOW()"; + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + if ( empty( $rows ) ) { + return []; + } + return array_map( + function ( $row ) { + return [ + 'product_name' => (string) $row['product_name'], + 'tenure_days' => (int) $row['tenure_days'], + ]; + }, + $rows + ); + } + + /** + * {@inheritDoc} + */ + public function get_upcoming_renewals_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // DISTINCT id-subselect for the non-donation filter so a multi-line-item + // subscription is counted once and its total_amount isn't summed twice. + $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 NOT 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} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Count retry-scheduled subscriptions in window vs. how many ended + // the window with status wc-active (= successful recovery). + // DISTINCT id-subselect for the non-donation filter so a + // multi-line-item subscription doesn't show up as multiple retries. + $sql = $wpdb->prepare( + "SELECT + COUNT(*) AS retry_attempts, + SUM(CASE WHEN sub.status = 'wc-active' THEN 1 ELSE 0 END) AS recoveries + FROM ( + SELECT DISTINCT o.id AS subscription_id + FROM {$prefix}wc_orders o + JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = '_schedule_payment_retry' + 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 NOT IN ($donations) + AND om.meta_value BETWEEN %s AND %s + AND om.meta_value != '' + ) AS retries + JOIN {$prefix}wc_orders sub ON sub.id = retries.subscription_id", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $row = $wpdb->get_row( $sql, ARRAY_A ); + $attempt = (int) ( $row['retry_attempts'] ?? 0 ); + $success = (int) ( $row['recoveries'] ?? 0 ); + + return 0 === $attempt ? 0.0 : $success / $attempt; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Column scope: + // active_subs — current state (independent of window) + // active_value — current state + // lifetime_revenue — lifetime sum of subscription-record totals + // attributed per product; not windowed by + // design (true LTV waits on the v1.1 BQ wrapper) + // churned_subs — WINDOWED to {start, end} via the + // `_schedule_cancelled` meta join below + // + // Each subscription line item is counted toward the product it + // references. A subscription with two non-donation line items + // contributes to both products' counts and amounts; SUM uses + // `o.total_amount` so a multi-product sub does NOT inflate the + // per-product active_value beyond the subscription's actual total + // (instead it's attributed once per product — a simplification). + // + // The LEFT JOIN to `_schedule_cancelled` is required for window + // scoping. Active subscriptions don't have this meta set, so the + // left-joined row is NULL and the churned CASE naturally rejects + // them. Subscription Woo writes one `_schedule_cancelled` row per + // subscription at most, so no row multiplication. + + /* + * Query at the effective-product level. Woo's convention for + * variable products is to write the PARENT id into the line + * item's `_product_id` meta and the actual variation id into a + * separate `_variation_id` meta. We COALESCE the latter over + * the former so the row resolves to the variation for variable + * products (post_parent > 0) and to the standalone product for + * simple subs (post_parent = 0). + * + * The donation filter stays on `_product_id` because the + * donation set is keyed by the parent in WC's data model. + * Aggregation into parent + nested variations happens in PHP + * below. + */ + $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.status = 'wc-active' THEN o.id END) AS active_subs, + COUNT(DISTINCT CASE + WHEN o.status IN ('wc-cancelled', 'wc-expired') + AND sch.meta_value BETWEEN %s AND %s + THEN o.id + END) AS churned_subs, + COALESCE(SUM(CASE WHEN o.status = 'wc-active' THEN o.total_amount END), 0) AS active_value, + COALESCE(SUM(o.total_amount), 0) AS lifetime_revenue + 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' + LEFT JOIN {$prefix}wc_orders_meta sch + ON sch.order_id = o.id AND sch.meta_key = '_schedule_cancelled' + WHERE o.type = 'shop_subscription' + AND pid_meta.meta_value NOT IN ($donations) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period + ORDER BY active_subs DESC", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + if ( empty( $rows ) ) { + return []; + } + return $this->aggregate_performance_rows( $rows ); + } + + /** + * Aggregate flat per-variation rows from the performance SQL into + * the parent + nested variations shape the React layer expects. + * + * For each row: + * - If parent_id > 0 (variation), attach to its parent's bucket + * and accumulate the parent's aggregates from the variation's + * numbers. + * - If parent_id == 0 (standalone simple/subscription product), + * emit as a single non-parent entry. + * + * Variation labels come from the _subscription_period meta when + * present (month→Monthly, year→Annual, week→Weekly, day→Daily), + * falling back to the variation post_title stripped of the parent + * name prefix, or 'Variation' as last resort. + * + * Each parent's variations array is sorted by active_subs DESC. + * The outer list is truncated to the top 50 parents/standalones by + * active_subs. + * + * @param array> $rows Flat SQL rows. + * @return array> + */ + private function aggregate_performance_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_subs = (int) $row['active_subs']; + $churned_subs = (int) $row['churned_subs']; + $active_value = (float) $row['active_value']; + $lifetime_revenue = (float) $row['lifetime_revenue']; + + if ( $parent_id > 0 ) { + // Variation under a parent product. + 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_subs' => 0, + 'churned_subs' => 0, + 'active_value' => 0.0, + 'lifetime_revenue' => 0.0, + 'variations' => [], + ]; + } + $parents[ $parent_id ]['active_subs'] += $active_subs; + $parents[ $parent_id ]['churned_subs'] += $churned_subs; + $parents[ $parent_id ]['active_value'] += $active_value; + $parents[ $parent_id ]['lifetime_revenue'] += $lifetime_revenue; + $parents[ $parent_id ]['variations'][] = [ + 'variation_id' => $variation_id, + 'label' => $this->variation_label( $period, $variation_name, $parent_name ), + 'active_subs' => $active_subs, + 'churned_subs' => $churned_subs, + 'active_value' => $active_value, + 'lifetime_revenue' => $lifetime_revenue, + ]; + } else { + // Standalone simple/subscription product. + $parents[ $variation_id ] = [ + 'product_id' => $variation_id, + 'name' => '' !== $variation_name ? $variation_name : __( '(unnamed product)', 'newspack-plugin' ), + 'is_parent' => false, + 'active_subs' => $active_subs, + 'churned_subs' => $churned_subs, + 'active_value' => $active_value, + 'lifetime_revenue' => $lifetime_revenue, + ]; + } + } + + // Sort each parent's variations by active_subs DESC. + foreach ( $parents as &$entry ) { + if ( isset( $entry['variations'] ) ) { + usort( + $entry['variations'], + static function ( $a, $b ) { + return $b['active_subs'] <=> $a['active_subs']; + } + ); + } + } + unset( $entry ); + + // Sort outer list by aggregated active_subs DESC, top 50. + $out = array_values( $parents ); + usort( + $out, + static function ( $a, $b ) { + return $b['active_subs'] <=> $a['active_subs']; + } + ); + return array_slice( $out, 0, 50 ); + } + + /** + * Pick a variation label. Prefer the period meta translated to a + * human-friendly cadence; fall back to the variation's own title + * with the parent name + ' - ' prefix stripped; last resort is a + * generic "Variation" string. + * + * @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' ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_cancellation_reasons( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // DISTINCT id-subselect on the non-donation filter so a sub with + // multiple line items doesn't get counted multiple times under the + // same reason. + $sql = $wpdb->prepare( + "SELECT + COALESCE(om.meta_value, 'unknown') AS cancellation_reason, + COUNT(*) AS count + FROM {$prefix}wc_orders o + LEFT JOIN {$prefix}wc_orders_meta om + ON om.order_id = o.id AND om.meta_key = 'newspack_subscriptions_cancellation_reason' + JOIN {$prefix}wc_orders_meta sch + ON sch.order_id = o.id AND sch.meta_key = '_schedule_cancelled' + WHERE o.type = 'shop_subscription' + AND o.status IN ('wc-cancelled', 'wc-expired') + 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 sch.meta_value BETWEEN %s AND %s + GROUP BY cancellation_reason + ORDER BY count DESC", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + if ( empty( $rows ) ) { + return []; + } + return array_map( + function ( $row ) { + return [ + 'cancellation_reason' => (string) $row['cancellation_reason'], + 'count' => (int) $row['count'], + ]; + }, + $rows + ); + } +} 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 new file mode 100644 index 0000000000..d6b2a641fd --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-legacy-storage.php @@ -0,0 +1,826 @@ +donation_product_ids = array_map( 'intval', $donation_product_ids ); + } + + /** + * Build a SQL-safe `IN (...)` list from integer IDs. Empty -> `0`. + * + * @param int[] $ids List of integer IDs. + * @return string Comma-separated integers (or `0`), unparenthesized. + */ + private function id_list( array $ids ): string { + if ( empty( $ids ) ) { + return '0'; + } + return implode( ',', array_map( 'intval', $ids ) ); + } + + /** + * Format a datetime for SQL comparison, in UTC. + * + * Every column these queries compare against stores UTC: the `post_date_gmt` + * column and the WooCommerce Subscriptions `_schedule_*` meta (which WCS + * persists as UTC datetime strings). Window bounds arrive in the site + * timezone (built from `wp_timezone()` in the REST controller), so we format + * the absolute instant in UTC here to keep the window aligned on non-UTC + * sites. Uses `getTimestamp()` so the result is correct regardless of the + * input DateTime's own timezone. + * + * @param DateTimeInterface $dt DateTime to format. + * @return string `Y-m-d H:i:s` UTC-formatted string. + */ + private function fmt( DateTimeInterface $dt ): string { + return gmdate( 'Y-m-d H:i:s', $dt->getTimestamp() ); + } + + /** + * Look up subscription product type IDs (same logic as HPOS — uses the + * shared product_type taxonomy, not order-storage-specific tables). + * + * @return string Comma-separated integer IDs (or `0` if none). + */ + private function subscription_product_ids_sql(): string { + global $wpdb; + $prefix = $wpdb->prefix; + + $rows = $wpdb->get_col( + "SELECT p.ID + FROM {$prefix}posts p + JOIN {$prefix}term_relationships tr ON p.ID = tr.object_id + JOIN {$prefix}term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + JOIN {$prefix}terms t ON tt.term_id = t.term_id + WHERE tt.taxonomy = 'product_type' + AND t.slug IN ('subscription', 'variable-subscription', 'subscription_variation')" + ); + + return $this->id_list( array_map( 'intval', (array) $rows ) ); + } + + /** + * {@inheritDoc} + */ + public function get_active_non_donation_subscribers(): 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 NOT IN ($donations)"; + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return int + */ + public function get_new_subscribers_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(start.meta_value) AS first_start + FROM {$prefix}posts p + JOIN {$prefix}postmeta cust + ON cust.post_id = p.ID AND cust.meta_key = '_customer_user' + JOIN {$prefix}postmeta start + ON start.post_id = p.ID AND start.meta_key = '_schedule_start' + 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 NOT IN ($donations) + GROUP BY cust.meta_value + ) AS first_subs + WHERE first_subs.first_start 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_churned_subscribers_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 NOT 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 NOT IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + return (int) $wpdb->get_var( $sql ); + } + + /** + * {@inheritDoc} + */ + public function get_mrr(): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // Same CASE-on-period logic as HPOS_Storage::get_mrr(), but the + // total amount comes from _order_total postmeta (DECIMAL string). + // See the HPOS implementation for the documented spec — covers + // day/week/month/year at any positive interval; ELSE is truly + // conservative (total / 12) and a diagnostic surfaces any + // fallthroughs via Newspack\Logger. + $sql = "SELECT SUM( + CASE + WHEN bp.meta_value = 'day' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) * 30 / CAST(bi.meta_value AS UNSIGNED) + WHEN bp.meta_value = 'week' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) * (52/12) / CAST(bi.meta_value AS UNSIGNED) + WHEN bp.meta_value = 'month' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) / CAST(bi.meta_value AS UNSIGNED) + WHEN bp.meta_value = 'year' AND CAST(bi.meta_value AS UNSIGNED) > 0 + THEN CAST(tot.meta_value AS DECIMAL(15,2)) / (12 * CAST(bi.meta_value AS UNSIGNED)) + ELSE CAST(tot.meta_value AS DECIMAL(15,2)) / 12 + END + ) + FROM {$prefix}posts p + JOIN {$prefix}postmeta bp + ON bp.post_id = p.ID AND bp.meta_key = '_billing_period' + JOIN {$prefix}postmeta bi + ON bi.post_id = p.ID AND bi.meta_key = '_billing_interval' + 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 NOT IN ($donations) + )"; + + $mrr = (float) $wpdb->get_var( $sql ); + + $unrecognized = (int) $wpdb->get_var( + "SELECT COUNT(DISTINCT p.ID) + FROM {$prefix}posts p + JOIN {$prefix}postmeta bp ON bp.post_id = p.ID AND bp.meta_key = '_billing_period' + JOIN {$prefix}postmeta bi ON bi.post_id = p.ID AND bi.meta_key = '_billing_interval' + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND ( + bp.meta_value NOT IN ('day', 'week', 'month', 'year') + OR CAST(bi.meta_value AS UNSIGNED) = 0 + ) + 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) + )" + ); + + if ( $unrecognized > 0 && class_exists( Logger::class ) ) { + Logger::log( + sprintf( + '%d active non-donation subscription(s) have unrecognized _billing_period/_billing_interval combinations. Their MRR contribution fell through to the conservative annual-amortized fallback (total / 12). Review product configuration.', + $unrecognized + ), + 'NEWSPACK-INSIGHTS' + ); + } + + return $mrr; + } + + /** + * {@inheritDoc} + */ + public function get_arr(): float { + return $this->get_mrr() * 12; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_subscription_revenue_gross( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + $subscription_p = $this->subscription_product_ids_sql(); + + $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' + 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 p.ID IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + ) + AND p.ID NOT IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($donations) + )", + $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_subscription_revenue_net( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + $subscription_p = $this->subscription_product_ids_sql(); + + // Sum across shop_order + shop_order_refund. Refund totals are + // negative so SUM yields the right net. + $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' + WHERE p.post_type IN ('shop_order', 'shop_order_refund') + AND p.post_status IN ('wc-completed', 'wc-processing') + AND p.post_date_gmt BETWEEN %s AND %s + AND ( + ( + p.post_type = 'shop_order' + AND p.ID IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + ) + ) + OR ( + p.post_type = 'shop_order_refund' + AND p.post_parent IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + ) + ) + )", + $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_subscription_refund_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + $subscription_p = $this->subscription_product_ids_sql(); + + $orders_sql = $wpdb->prepare( + "SELECT COUNT(*) + FROM {$prefix}posts p + 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 p.ID IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + $orders = (int) $wpdb->get_var( $orders_sql ); + + if ( 0 === $orders ) { + return 0.0; + } + + $refunds_sql = $wpdb->prepare( + "SELECT COUNT(*) + FROM {$prefix}posts p + WHERE p.post_type = 'shop_order_refund' + AND p.post_date_gmt BETWEEN %s AND %s + AND p.post_parent IN ( + SELECT DISTINCT order_id + FROM {$prefix}wc_order_product_lookup + WHERE product_id IN ($subscription_p) + AND product_id NOT IN ($donations) + )", + $this->fmt( $start ), + $this->fmt( $end ) + ); + $refunds = (int) $wpdb->get_var( $refunds_sql ); + + return $refunds / $orders; + } + + /** + * {@inheritDoc} + */ + public function get_subscription_tenure_distribution(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // One row per active subscription line item; the React layer groups + // client-side and computes box-plot quartiles. + $sql = "SELECT + prod.post_title AS product_name, + TIMESTAMPDIFF(DAY, start.meta_value, NOW()) AS tenure_days + FROM {$prefix}posts p + JOIN {$prefix}postmeta start + ON start.post_id = p.ID AND start.meta_key = '_schedule_start' + 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}posts prod ON prod.ID = CAST(oim.meta_value AS UNSIGNED) + WHERE p.post_type = 'shop_subscription' + AND p.post_status = 'wc-active' + AND oim.meta_value NOT IN ($donations) + AND start.meta_value != '' + AND start.meta_value < NOW()"; + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + if ( empty( $rows ) ) { + return []; + } + return array_map( + function ( $row ) { + return [ + 'product_name' => (string) $row['product_name'], + 'tenure_days' => (int) $row['tenure_days'], + ]; + }, + $rows + ); + } + + /** + * {@inheritDoc} + */ + public function get_upcoming_renewals_30d(): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // DISTINCT id-subselect for the non-donation filter so a multi-line-item + // subscription is counted once and its _order_total isn't summed twice. + $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 NOT 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} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return float + */ + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // DISTINCT id-subselect for the non-donation filter so a + // multi-line-item subscription doesn't show up as multiple retries. + $sql = $wpdb->prepare( + "SELECT + COUNT(*) AS retry_attempts, + SUM(CASE WHEN sub.post_status = 'wc-active' THEN 1 ELSE 0 END) AS recoveries + FROM ( + SELECT DISTINCT p.ID AS subscription_id + FROM {$prefix}posts p + JOIN {$prefix}postmeta retry + ON retry.post_id = p.ID AND retry.meta_key = '_schedule_payment_retry' + 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 NOT IN ($donations) + AND retry.meta_value BETWEEN %s AND %s + AND retry.meta_value != '' + ) AS retries + JOIN {$prefix}posts sub ON sub.ID = retries.subscription_id", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $row = $wpdb->get_row( $sql, ARRAY_A ); + $attempt = (int) ( $row['retry_attempts'] ?? 0 ); + $success = (int) ( $row['recoveries'] ?? 0 ); + + return 0 === $attempt ? 0.0 : $success / $attempt; + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_performance_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(): + // active_subs — current state + // active_value — current state + // lifetime_revenue — lifetime sum (intentionally not windowed) + // churned_subs — WINDOWED via _schedule_cancelled postmeta + // + // Each subscription line item is counted toward the product it + // references. Multi-product subs contribute to each product's + // counts and amounts; SUM uses the subscription's _order_total so + // the per-product active_value is attributed once per product + // (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. + $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_status = 'wc-active' THEN p.ID END) AS active_subs, + COUNT(DISTINCT CASE + WHEN p.post_status IN ('wc-cancelled', 'wc-expired') + AND sch.meta_value BETWEEN %s AND %s + THEN p.ID + END) AS churned_subs, + COALESCE(SUM(CASE WHEN p.post_status = 'wc-active' THEN CAST(tot.meta_value AS DECIMAL(15,2)) END), 0) AS active_value, + COALESCE(SUM(CAST(tot.meta_value AS DECIMAL(15,2))), 0) AS lifetime_revenue + 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 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' + LEFT JOIN {$prefix}postmeta sch + ON sch.post_id = p.ID AND sch.meta_key = '_schedule_cancelled' + WHERE p.post_type = 'shop_subscription' + AND pid_meta.meta_value NOT IN ($donations) + GROUP BY pv.ID, pv.post_title, pv.post_parent, parent_name, sub_period + ORDER BY active_subs DESC", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + if ( empty( $rows ) ) { + return []; + } + return $this->aggregate_performance_rows( $rows ); + } + + /** + * Aggregate flat per-variation rows into parent + nested + * variations shape. Duplicated from {@see HPOS_Storage} — pure PHP + * transformation with no backend-specific logic, so duplication + * keeps each storage class self-contained. + * + * @param array> $rows Flat SQL rows. + * @return array> + */ + private function aggregate_performance_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_subs = (int) $row['active_subs']; + $churned_subs = (int) $row['churned_subs']; + $active_value = (float) $row['active_value']; + $lifetime_revenue = (float) $row['lifetime_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_subs' => 0, + 'churned_subs' => 0, + 'active_value' => 0.0, + 'lifetime_revenue' => 0.0, + 'variations' => [], + ]; + } + $parents[ $parent_id ]['active_subs'] += $active_subs; + $parents[ $parent_id ]['churned_subs'] += $churned_subs; + $parents[ $parent_id ]['active_value'] += $active_value; + $parents[ $parent_id ]['lifetime_revenue'] += $lifetime_revenue; + $parents[ $parent_id ]['variations'][] = [ + 'variation_id' => $variation_id, + 'label' => $this->variation_label( $period, $variation_name, $parent_name ), + 'active_subs' => $active_subs, + 'churned_subs' => $churned_subs, + 'active_value' => $active_value, + 'lifetime_revenue' => $lifetime_revenue, + ]; + } else { + $parents[ $variation_id ] = [ + 'product_id' => $variation_id, + 'name' => '' !== $variation_name ? $variation_name : __( '(unnamed product)', 'newspack-plugin' ), + 'is_parent' => false, + 'active_subs' => $active_subs, + 'churned_subs' => $churned_subs, + 'active_value' => $active_value, + 'lifetime_revenue' => $lifetime_revenue, + ]; + } + } + + foreach ( $parents as &$entry ) { + if ( isset( $entry['variations'] ) ) { + usort( + $entry['variations'], + static function ( $a, $b ) { + return $b['active_subs'] <=> $a['active_subs']; + } + ); + } + } + unset( $entry ); + + $out = array_values( $parents ); + usort( + $out, + static function ( $a, $b ) { + return $b['active_subs'] <=> $a['active_subs']; + } + ); + return array_slice( $out, 0, 50 ); + } + + /** + * Pick a variation label. See HPOS_Storage::variation_label() for + * the full doc; duplicated here so each storage class is + * self-contained. + * + * @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' ); + } + + /** + * {@inheritDoc} + * + * @param DateTimeInterface $start Window start. + * @param DateTimeInterface $end Window end. + * @return array + */ + public function get_cancellation_reasons( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + $prefix = $wpdb->prefix; + $donations = $this->id_list( $this->donation_product_ids ); + + // DISTINCT id-subselect on the non-donation filter so a sub with + // multiple line items doesn't get counted multiple times under the + // same reason. + $sql = $wpdb->prepare( + "SELECT + COALESCE(reason.meta_value, 'unknown') AS cancellation_reason, + COUNT(*) AS count + FROM {$prefix}posts p + LEFT JOIN {$prefix}postmeta reason + ON reason.post_id = p.ID AND reason.meta_key = 'newspack_subscriptions_cancellation_reason' + JOIN {$prefix}postmeta cancelled + ON cancelled.post_id = p.ID AND cancelled.meta_key = '_schedule_cancelled' + WHERE p.post_type = 'shop_subscription' + AND p.post_status IN ('wc-cancelled', 'wc-expired') + 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 cancelled.meta_value BETWEEN %s AND %s + GROUP BY cancellation_reason + ORDER BY count DESC", + $this->fmt( $start ), + $this->fmt( $end ) + ); + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + if ( empty( $rows ) ) { + return []; + } + return array_map( + function ( $row ) { + return [ + 'cancellation_reason' => (string) $row['cancellation_reason'], + 'count' => (int) $row['count'], + ]; + }, + $rows + ); + } +} diff --git a/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-detector.php b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-detector.php new file mode 100644 index 0000000000..258a7605ba --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-detector.php @@ -0,0 +1,106 @@ + HPOS. Anything else (including a missing option on sites + * that have never enabled the feature) -> legacy. + * + * @return string + */ + private static function compute(): string { + return 'yes' === get_option( 'woocommerce_custom_orders_table_enabled' ) + ? self::BACKEND_HPOS + : self::BACKEND_LEGACY; + } +} 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 new file mode 100644 index 0000000000..4ffb805b96 --- /dev/null +++ b/plugins/newspack-plugin/includes/wizards/insights/storage/class-storage-interface.php @@ -0,0 +1,204 @@ + string, 'tenure_days' => int ] + * + * Aggregation into box-plot quartiles happens in the React layer (so the + * raw distribution remains available for future drill-downs). + * + * @return array + */ + public function get_subscription_tenure_distribution(): array; + + /** + * Aggregate count + total value of active non-donation subscriptions + * whose `_schedule_next_payment` falls within the next 30 days. + * + * [ 'count' => int, 'total_value' => float ] + * + * @return array{count: int, total_value: float} + */ + 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. + * + * @param DateTimeInterface $start Inclusive window start. + * @param DateTimeInterface $end Inclusive window end. + * @return float Fraction in [0, 1]. + */ + public function get_failed_payment_retry_rate( DateTimeInterface $start, DateTimeInterface $end ): float; + + /** + * Per-product performance for non-donation subscription products. + * One entry per parent product (or standalone simple/subscription + * product), ordered by aggregated active subscriber count + * descending, top 50. Parent entries carry a `variations` array + * with one entry per variation, sorted by active_subs descending: + * + * [ + * 'product_id' => int, + * 'name' => string, + * 'is_parent' => bool, // true when this entry has variations + * 'active_subs' => int, // parent: SUM of variation active_subs + * 'churned_subs' => int, // parent: SUM (windowed) + * 'active_value' => float, // parent: SUM + * 'lifetime_revenue' => float, // parent: SUM (approximate; see Tab 6 doc) + * 'variations' => [ // parents only; absent for is_parent=false + * [ + * 'variation_id' => int, + * 'label' => string, // 'Monthly' / 'Annual' / etc + * 'active_subs' => int, + * 'churned_subs' => int, // windowed + * 'active_value' => float, + * 'lifetime_revenue' => float, + * ], + * ... + * ], + * ] + * + * `churned_subs` is windowed to `[$start, $end]`. The other three + * aggregates are current-state / lifetime (see HPOS_Storage's + * column-scope comment). + * + * @param DateTimeInterface $start Inclusive window start. + * @param DateTimeInterface $end Inclusive window end. + * @return array> + */ + public function get_performance_by_product( DateTimeInterface $start, DateTimeInterface $end ): array; + + /** + * Cancellation reason buckets for non-donation subscriptions whose + * `_schedule_cancelled` falls in the window. One row per reason, ordered + * by count descending: + * + * [ 'cancellation_reason' => string, 'count' => int ] + * + * Reasons map to `newspack_subscriptions_cancellation_reason` postmeta; + * unset values bucket as `'unknown'` (often substantial for cancellations + * predating the feature). + * + * @param DateTimeInterface $start Inclusive window start. + * @param DateTimeInterface $end Inclusive window end. + * @return array + */ + public function get_cancellation_reasons( DateTimeInterface $start, DateTimeInterface $end ): array; +} diff --git a/plugins/newspack-plugin/src/wizards/index.tsx b/plugins/newspack-plugin/src/wizards/index.tsx index 7e23bf9041..912210363c 100644 --- a/plugins/newspack-plugin/src/wizards/index.tsx +++ b/plugins/newspack-plugin/src/wizards/index.tsx @@ -36,6 +36,10 @@ const components: Record< string, any > = { label: __( 'Settings', 'newspack-plugin' ), component: lazy( () => import( /* webpackChunkName: "newspack-wizards" */ './newspack/views/settings' ) ), }, + 'newspack-insights': { + label: __( 'Insights', 'newspack-plugin' ), + component: lazy( () => import( /* webpackChunkName: "insights-wizard" */ './insights' ) ), + }, 'newspack-audience': { label: __( 'Audience', 'newspack-plugin' ), component: lazy( () => import( /* webpackChunkName: "audience-wizards" */ './audience/views/setup' ) ), diff --git a/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts b/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts new file mode 100644 index 0000000000..579f6578f9 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/api/subscribers.ts @@ -0,0 +1,114 @@ +/** + * Subscribers API client (NPPD-1616). + * + * Thin wrapper around `@wordpress/api-fetch` for the single Tab 6 + * endpoint: `GET /newspack-insights/v1/subscribers`. Type definitions + * here are the source of truth for the React layer and mirror the PHP + * response shape assembled by `Subscribers_REST_Controller`. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +export type StorageBackend = 'hpos' | 'legacy'; + +export interface SubscribersClassification { + backend: StorageBackend; + donation_product_count: number; + has_donation_family: boolean; +} + +export interface TenureDistributionRow { + product_name: string; + tenure_days: number; +} + +export interface UpcomingRenewals { + count: number; + total_value: number; +} + +export interface SubscribersSnapshot { + active_subscribers: number; + mrr: number; + arr: number; + tenure_distribution: TenureDistributionRow[]; + upcoming_renewals_30d: UpcomingRenewals; +} + +export interface PerformanceVariationRow { + variation_id: number; + label: string; + active_subs: number; + churned_subs: number; + active_value: number; + lifetime_revenue: number; +} + +export interface PerformanceRow { + product_id: number; + name: string; + is_parent: boolean; + active_subs: number; + churned_subs: number; + active_value: number; + lifetime_revenue: number; + /** Present only when `is_parent` is true. Sorted by active_subs descending. */ + variations?: PerformanceVariationRow[]; +} + +export interface CancellationReasonRow { + cancellation_reason: string; + count: number; +} + +export interface SubscribersWindow { + window: { start: string; end: string }; + new_subscribers: number; + churned_subscribers: number; + revenue_gross: number; + revenue_net: number; + refund_rate: number; + failed_payment_retry_rate: number; + performance_by_product: PerformanceRow[]; + cancellation_reasons: CancellationReasonRow[]; +} + +export interface SubscribersResponse { + classification: SubscribersClassification; + snapshot: SubscribersSnapshot; + current: SubscribersWindow; + previous: SubscribersWindow | null; +} + +export interface SubscribersQuery { + start: string; + end: string; + compare_start?: string; + compare_end?: string; +} + +const ENDPOINT = '/newspack-insights/v1/subscribers'; + +/** + * Fetch Tab 6 data for the given window pair. Returns the full + * classification + snapshot + current + previous payload. + * + * Throws on REST error (caught by the calling hook into an `error` + * state). + */ +export const fetchSubscribersData = async ( query: SubscribersQuery ): Promise< SubscribersResponse > => { + 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< SubscribersResponse >( { + path: `${ ENDPOINT }?${ params.toString() }`, + method: 'GET', + } ); +}; diff --git a/plugins/newspack-plugin/src/wizards/insights/components/ComparisonToggle.tsx b/plugins/newspack-plugin/src/wizards/insights/components/ComparisonToggle.tsx new file mode 100644 index 0000000000..9cc6036255 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/components/ComparisonToggle.tsx @@ -0,0 +1,29 @@ +/** + * ComparisonToggle + * + * Checkbox to enable "compare to previous period". Component owns no + * state — caller wires it to useComparisonMode. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export interface ComparisonToggleProps { + enabled: boolean; + onChange: ( v: boolean ) => void; + className?: string; +} + +const ComparisonToggle = ( { enabled, onChange, className }: ComparisonToggleProps ) => { + const inputId = 'newspack-insights-comparison-toggle'; + return ( + + ); +}; + +export default ComparisonToggle; diff --git a/plugins/newspack-plugin/src/wizards/insights/components/DateRangePicker.tsx b/plugins/newspack-plugin/src/wizards/insights/components/DateRangePicker.tsx new file mode 100644 index 0000000000..3ba279f9b1 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/components/DateRangePicker.tsx @@ -0,0 +1,69 @@ +/** + * DateRangePicker + * + * Preset-driven date range selector. Six presets (last-7, last-30 default, + * last-90, this-month, last-month, custom). Custom mode reveals two date + * inputs. + * + * Component owns no state — caller wires it to useDateRange. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { DATE_RANGE_PRESETS, type DateRange, type DateRangePreset } from '../state/useDateRange'; + +export interface DateRangePickerProps { + range: DateRange; + onPresetChange: ( preset: DateRangePreset ) => void; + onCustomChange: ( start: string, end: string ) => void; + className?: string; +} + +const DateRangePicker = ( { range, onPresetChange, onCustomChange, className }: DateRangePickerProps ) => { + const presetId = 'newspack-insights-date-range-preset'; + const startId = 'newspack-insights-date-range-start'; + const endId = 'newspack-insights-date-range-end'; + return ( +
+ + + { range.preset === 'custom' && ( +
+ + + +
+ ) } +
+ ); +}; + +export default DateRangePicker; diff --git a/plugins/newspack-plugin/src/wizards/insights/components/InsightsWizard.tsx b/plugins/newspack-plugin/src/wizards/insights/components/InsightsWizard.tsx new file mode 100644 index 0000000000..4e28774990 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/components/InsightsWizard.tsx @@ -0,0 +1,156 @@ +/** + * InsightsWizard + * + * Top-level chrome for the Newspack Insights wizard. Owns active tab, + * date range, and comparison-mode state; renders header (title + + * LastUpdated), date picker, comparison toggle, tab navigation, and + * the lazy-loaded tab content. + * + * Tab routing happens entirely client-side via URL query persistence so + * tabs are linkable and refresh restores state. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ComparisonToggle from './ComparisonToggle'; +import DateRangePicker from './DateRangePicker'; +import LastUpdated from './LastUpdated'; +import TabContent from './TabContent'; +import TabNavigation, { ALL_TABS, type TabKey, type TabVisibility } from './TabNavigation'; +import useComparisonMode from '../state/useComparisonMode'; +import useDateRange, { type DateRange } from '../state/useDateRange'; + +export interface InsightsBootConfig { + tabs: TabVisibility; + defaultDateRange: DateRange; + defaultComparison: boolean; + timezone: string; + settingsUrl: string; + /** + * Optional ISO 8601 timestamp of the most recent cache update for the + * currently-displayed data. Null while no data has loaded. + */ + lastUpdated?: string | null; +} + +export interface InsightsWizardProps { + config: InsightsBootConfig; +} + +const TAB_KEYS = ALL_TABS.map( t => t.key ); + +const isTabKey = ( v: unknown ): v is TabKey => typeof v === 'string' && ( TAB_KEYS as readonly string[] ).includes( v ); + +/** + * The list of visible tabs derived from the boot config visibility map. + */ +const getVisibleTabs = ( visibility: TabVisibility ): TabKey[] => TAB_KEYS.filter( k => visibility[ k as TabKey ] ) as TabKey[]; + +/** + * Read initial active tab from URL ?tab=, falling back to the first + * visible tab. Returns null if no tabs are visible — caller renders an + * empty state in that case rather than forcing an arbitrary tab key. + */ +const readInitialTab = ( visibility: TabVisibility, visibleTabs: TabKey[] ): TabKey | null => { + if ( visibleTabs.length === 0 ) { + return null; + } + if ( typeof window === 'undefined' ) { + return visibleTabs[ 0 ]; + } + const fromUrl = new URLSearchParams( window.location.search ).get( 'tab' ); + if ( isTabKey( fromUrl ) && visibility[ fromUrl ] ) { + return fromUrl; + } + return visibleTabs[ 0 ]; +}; + +const writeTabToUrl = ( tab: TabKey ) => { + if ( typeof window === 'undefined' ) { + return; + } + const params = new URLSearchParams( window.location.search ); + params.set( 'tab', tab ); + const next = `${ window.location.pathname }?${ params.toString() }${ window.location.hash }`; + window.history.replaceState( window.history.state, '', next ); +}; + +const InsightsWizard = ( { config }: InsightsWizardProps ) => { + const visibleTabs = getVisibleTabs( config.tabs ); + const initialTab = readInitialTab( config.tabs, visibleTabs ); + + const [ activeTab, setActiveTabState ] = useState< TabKey | null >( () => initialTab ); + + const setActiveTab = useCallback( ( tab: TabKey ) => { + setActiveTabState( tab ); + }, [] ); + + useEffect( () => { + if ( activeTab ) { + writeTabToUrl( activeTab ); + } + }, [ activeTab ] ); + + const { range, setPreset, setCustom } = useDateRange( { + defaultRange: config.defaultDateRange, + } ); + + const { + enabled: comparisonEnabled, + setEnabled: setComparisonEnabled, + previousRange, + } = useComparisonMode( { + defaultEnabled: config.defaultComparison, + currentRange: range, + } ); + + const hasVisibleTabs = visibleTabs.length > 0; + + return ( +
+
+
+

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

+
+
+ { hasVisibleTabs && ( + <> + + + { /* TODO(NPPD-1605): wire computed_at from the SWR cache layer. */ } + { /* Currently always null because the REST endpoints don't surface */ } + { /* their cache timestamps yet; LastUpdated renders nothing in that case. */ } + + + ) } +
+
+ + { hasVisibleTabs && activeTab ? ( + <> + + + + ) : ( +
+

{ __( 'No insights sections available', 'newspack-plugin' ) }

+

+ { __( + 'Insights sections light up as data sources become available for this site. Check back after you have receivers configured, or visit Settings to configure data sources.', + 'newspack-plugin' + ) } +

+
+ ) } +
+ ); +}; + +export default InsightsWizard; diff --git a/plugins/newspack-plugin/src/wizards/insights/components/LastUpdated.tsx b/plugins/newspack-plugin/src/wizards/insights/components/LastUpdated.tsx new file mode 100644 index 0000000000..08e6171eeb --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/components/LastUpdated.tsx @@ -0,0 +1,68 @@ +/** + * LastUpdated + * + * Header-row timestamp display. Renders "Updated X minutes ago" or + * absolute time depending on staleness. Renders nothing if no timestamp + * is available (e.g., chrome is loaded before first cache hit). + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +export interface LastUpdatedProps { + /** ISO 8601 timestamp of the most recent cache update, or null if unknown. */ + timestamp: string | null; + /** Optional className for wrapper styling overrides. */ + className?: string; +} + +const formatRelative = ( ts: Date, now: Date ): string => { + const diffMs = now.getTime() - ts.getTime(); + const diffMin = Math.round( diffMs / ( 1000 * 60 ) ); + if ( diffMin < 1 ) { + return __( 'Updated just now', 'newspack-plugin' ); + } + if ( diffMin < 60 ) { + return sprintf( + /* translators: %d is number of minutes */ + __( 'Updated %d minutes ago', 'newspack-plugin' ), + diffMin + ); + } + const diffHr = Math.round( diffMin / 60 ); + if ( diffHr < 24 ) { + return sprintf( + /* translators: %d is number of hours */ + __( 'Updated %d hours ago', 'newspack-plugin' ), + diffHr + ); + } + const diffDay = Math.round( diffHr / 24 ); + return sprintf( + /* translators: %d is number of days */ + __( 'Updated %d days ago', 'newspack-plugin' ), + diffDay + ); +}; + +const LastUpdated = ( { timestamp, className }: LastUpdatedProps ) => { + if ( ! timestamp ) { + return null; + } + const ts = new Date( timestamp ); + if ( Number.isNaN( ts.getTime() ) ) { + return null; + } + const now = new Date(); + const label = formatRelative( ts, now ); + const absolute = ts.toLocaleString(); + return ( + + { label } + + ); +}; + +export default LastUpdated; diff --git a/plugins/newspack-plugin/src/wizards/insights/components/TabContent.tsx b/plugins/newspack-plugin/src/wizards/insights/components/TabContent.tsx new file mode 100644 index 0000000000..bc087b9995 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/components/TabContent.tsx @@ -0,0 +1,78 @@ +/** + * TabContent + * + * Lazy-loads the appropriate tab component based on activeTab and renders + * it inside a Suspense boundary with a skeleton fallback. Each tab is + * code-split via React.lazy. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { lazy, Suspense } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabKey } from './TabNavigation'; +import type { DateRange } from '../state/useDateRange'; + +const AudienceTab = lazy( () => import( '../tabs/AudienceTab' ) ); +const EngagementTab = lazy( () => import( '../tabs/EngagementTab' ) ); +const ConversionTab = lazy( () => import( '../tabs/ConversionTab' ) ); +const GatesTab = lazy( () => import( '../tabs/GatesTab' ) ); +const PromptsTab = lazy( () => import( '../tabs/PromptsTab' ) ); +const SubscribersTab = lazy( () => import( '../tabs/SubscribersTab' ) ); +const DonorsTab = lazy( () => import( '../tabs/DonorsTab' ) ); +const AdvertisingTab = lazy( () => import( '../tabs/AdvertisingTab' ) ); + +export interface TabContentProps { + activeTab: TabKey; + range: DateRange; + previousRange: DateRange | null; +} + +const Fallback = () => ( +
+ { __( 'Loading…', 'newspack-plugin' ) } +
+); + +const TabContent = ( props: TabContentProps ) => { + const { activeTab } = props; + const renderTab = () => { + switch ( activeTab ) { + case 'audience': + return ; + case 'engagement': + return ; + case 'conversion': + return ; + case 'gates': + return ; + case 'prompts': + return ; + case 'subscribers': + return ; + case 'donors': + return ; + case 'advertising': + return ; + default: + return null; + } + }; + return ( +
+ }>{ renderTab() } +
+ ); +}; + +export default TabContent; diff --git a/plugins/newspack-plugin/src/wizards/insights/components/TabNavigation.tsx b/plugins/newspack-plugin/src/wizards/insights/components/TabNavigation.tsx new file mode 100644 index 0000000000..8be4ff5cf5 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/components/TabNavigation.tsx @@ -0,0 +1,77 @@ +/** + * TabNavigation + * + * Horizontal tab bar for the Insights wizard. Visibility per tab is + * driven by props (computed at boot from feature detection — stubbed + * all-on for now). Active tab highlighting per design spec. + * + * Component owns no state — caller passes activeTab and onTabChange. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * External dependencies + */ +import classnames from 'classnames'; + +export type TabKey = 'audience' | 'engagement' | 'conversion' | 'gates' | 'prompts' | 'subscribers' | 'donors' | 'advertising'; + +export interface TabDef { + key: TabKey; + label: string; +} + +export const ALL_TABS: TabDef[] = [ + { key: 'audience', label: __( 'Audience', 'newspack-plugin' ) }, + { key: 'engagement', label: __( 'Engagement', 'newspack-plugin' ) }, + { key: 'conversion', label: __( 'Conversion Journey', 'newspack-plugin' ) }, + { key: 'gates', label: __( 'Gates', 'newspack-plugin' ) }, + { key: 'prompts', label: __( 'Prompts', 'newspack-plugin' ) }, + { key: 'subscribers', label: __( 'Subscribers', 'newspack-plugin' ) }, + { key: 'donors', label: __( 'Donors', 'newspack-plugin' ) }, + { key: 'advertising', label: __( 'Advertising', 'newspack-plugin' ) }, +]; + +export type TabVisibility = Record< TabKey, boolean >; + +export interface TabNavigationProps { + activeTab: TabKey; + visibility: TabVisibility; + onTabChange: ( tab: TabKey ) => void; + className?: string; +} + +const TabNavigation = ( { activeTab, visibility, onTabChange, className }: TabNavigationProps ) => { + const visibleTabs = ALL_TABS.filter( t => visibility[ t.key ] ); + return ( +
+ { visibleTabs.map( tab => { + const isActive = tab.key === activeTab; + return ( + + ); + } ) } +
+ ); +}; + +export default TabNavigation; diff --git a/plugins/newspack-plugin/src/wizards/insights/hooks/useSubscribersData.ts b/plugins/newspack-plugin/src/wizards/insights/hooks/useSubscribersData.ts new file mode 100644 index 0000000000..d3f5f23353 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/hooks/useSubscribersData.ts @@ -0,0 +1,91 @@ +/** + * useSubscribersData (NPPD-1616). + * + * React hook owning the Tab 6 data fetch lifecycle. Refetches whenever + * the active range or comparison range changes; serializes overlapping + * requests via a request-id guard so the latest call wins. Exposes + * idle / loading / success / error state plus a manual `refetch()` for + * future force-refresh actions. + */ + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import { fetchSubscribersData, type SubscribersResponse } from '../api/subscribers'; + +export type SubscribersFetchStatus = 'idle' | 'loading' | 'success' | 'error'; + +export interface UseSubscribersDataResult { + status: SubscribersFetchStatus; + data: SubscribersResponse | 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 ); +}; + +/** + * Fetch Tab 6 data for the given current/comparison range pair. + * + * Refetches on: + * - range change (start or end) + * - previousRange change (toggle, or current range moved so the + * comparison window recomputes) + * - explicit refetch() + */ +const useSubscribersData = ( range: DateRange, previousRange: DateRange | null ): UseSubscribersDataResult => { + const [ status, setStatus ] = useState< SubscribersFetchStatus >( 'idle' ); + const [ data, setData ] = useState< SubscribersResponse | null >( null ); + const [ error, setError ] = useState< string | null >( null ); + + // Request-id guard. Each call increments; only the latest id may write + // to state. Prevents older slow calls from overwriting newer ones on + // rapid range switches. + const requestIdRef = useRef( 0 ); + + // Bump on refetch() to retrigger the effect without changing inputs. + const [ refetchTick, setRefetchTick ] = useState( 0 ); + const refetch = useCallback( () => setRefetchTick( t => t + 1 ), [] ); + + useEffect( () => { + const myId = ++requestIdRef.current; + setStatus( 'loading' ); + setError( null ); + + fetchSubscribersData( { + 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 useSubscribersData; diff --git a/plugins/newspack-plugin/src/wizards/insights/index.tsx b/plugins/newspack-plugin/src/wizards/insights/index.tsx new file mode 100644 index 0000000000..fe919f4b8c --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/index.tsx @@ -0,0 +1,56 @@ +/** + * Newspack Insights wizard React entry (NPPD-1602). + * + * Loaded lazily by src/wizards/index.tsx when ?page=newspack-insights. + * Reads boot config from window.newspackInsights (set by the PHP + * wizard via wp_localize_script) and mounts InsightsWizard. + */ + +/** + * Internal dependencies + */ +import InsightsWizard, { type InsightsBootConfig } from './components/InsightsWizard'; +import './style.scss'; + +declare global { + interface Window { + newspackInsights?: InsightsBootConfig; + } +} + +const FALLBACK_CONFIG: InsightsBootConfig = { + tabs: { + audience: true, + engagement: true, + conversion: true, + gates: true, + prompts: true, + subscribers: true, + donors: true, + advertising: true, + }, + defaultDateRange: ( () => { + const pad = ( n: number ) => String( n ).padStart( 2, '0' ); + const toISO = ( d: Date ) => `${ d.getFullYear() }-${ pad( d.getMonth() + 1 ) }-${ pad( d.getDate() ) }`; + const today = new Date(); + const thirtyAgo = new Date( today ); + // Inclusive 30-day window ending today: today + 29 prior days = 30 days. + thirtyAgo.setDate( thirtyAgo.getDate() - 29 ); + return { + preset: 'last-30' as const, + start: toISO( thirtyAgo ), + end: toISO( today ), + }; + } )(), + defaultComparison: false, + timezone: 'UTC', + settingsUrl: '', + lastUpdated: null, +}; + +const Index = () => { + const config = window.newspackInsights ?? FALLBACK_CONFIG; + return ; +}; + +export default Index; diff --git a/plugins/newspack-plugin/src/wizards/insights/state/useComparisonMode.ts b/plugins/newspack-plugin/src/wizards/insights/state/useComparisonMode.ts new file mode 100644 index 0000000000..9548a8b545 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/state/useComparisonMode.ts @@ -0,0 +1,124 @@ +/** + * useComparisonMode + * + * Owns the "compare to previous period" toggle. When enabled, computes + * the previous-period range as the same length immediately preceding the + * current range. Hydrates from URL query (?compare=1) and persists on + * change. + */ + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DateRange } from './useDateRange'; + +const pad2 = ( n: number ) => String( n ).padStart( 2, '0' ); + +const toISO = ( d: Date ): string => `${ d.getFullYear() }-${ pad2( d.getMonth() + 1 ) }-${ pad2( d.getDate() ) }`; + +const fromISO = ( s: string ): Date | null => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec( s ); + if ( ! m ) { + return null; + } + return new Date( Number( m[ 1 ] ), Number( m[ 2 ] ) - 1, Number( m[ 3 ] ) ); +}; + +const daysBetween = ( a: Date, b: Date ): number => { + const ms = b.getTime() - a.getTime(); + return Math.round( ms / ( 1000 * 60 * 60 * 24 ) ); +}; + +/** + * Same-length-back. Previous period is the [start - N days, start - 1 day] + * window where N is the length of the current range. + * + * e.g. current = 2026-05-01 to 2026-05-30 (30 days) + * previous = 2026-04-01 to 2026-04-30 (30 days, ending the day before + * current starts so the two windows don't overlap) + */ +export const computePreviousRange = ( current: DateRange ): DateRange | null => { + const start = fromISO( current.start ); + const end = fromISO( current.end ); + if ( ! start || ! end ) { + return null; + } + const lengthDays = daysBetween( start, end ); + if ( lengthDays < 0 ) { + return null; + } + const prevEnd = new Date( start ); + prevEnd.setDate( prevEnd.getDate() - 1 ); + const prevStart = new Date( prevEnd ); + prevStart.setDate( prevStart.getDate() - lengthDays ); + return { + preset: 'custom', + start: toISO( prevStart ), + end: toISO( prevEnd ), + }; +}; + +const readUrl = (): boolean | undefined => { + if ( typeof window === 'undefined' ) { + return undefined; + } + const v = new URLSearchParams( window.location.search ).get( 'compare' ); + if ( v === '1' ) { + return true; + } + if ( v === '0' ) { + return false; + } + return undefined; +}; + +const writeUrl = ( enabled: boolean ) => { + if ( typeof window === 'undefined' ) { + return; + } + const params = new URLSearchParams( window.location.search ); + if ( enabled ) { + params.set( 'compare', '1' ); + } else { + params.delete( 'compare' ); + } + const next = `${ window.location.pathname }?${ params.toString() }${ window.location.hash }`; + window.history.replaceState( window.history.state, '', next ); +}; + +export interface UseComparisonModeOptions { + defaultEnabled: boolean; + currentRange: DateRange; +} + +export interface UseComparisonModeReturn { + enabled: boolean; + setEnabled: ( v: boolean ) => void; + previousRange: DateRange | null; +} + +const useComparisonMode = ( { defaultEnabled, currentRange }: UseComparisonModeOptions ): UseComparisonModeReturn => { + const [ enabled, setEnabledState ] = useState< boolean >( () => { + const fromUrl = readUrl(); + return fromUrl !== undefined ? fromUrl : defaultEnabled; + } ); + + useEffect( () => { + writeUrl( enabled ); + }, [ enabled ] ); + + const setEnabled = useCallback( ( v: boolean ) => { + setEnabledState( v ); + }, [] ); + + const previousRange = useMemo( () => ( enabled ? computePreviousRange( currentRange ) : null ), [ enabled, currentRange ] ); + + return { enabled, setEnabled, previousRange }; +}; + +export default useComparisonMode; diff --git a/plugins/newspack-plugin/src/wizards/insights/state/useDateRange.ts b/plugins/newspack-plugin/src/wizards/insights/state/useDateRange.ts new file mode 100644 index 0000000000..0de6e8e3aa --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/state/useDateRange.ts @@ -0,0 +1,196 @@ +/** + * useDateRange + * + * Owns the active date range state for the Insights wizard. Hydrates + * initial state from URL query (so refresh / direct links preserve range) + * with fallback to the boot config default; persists changes back to URL + * via history.replaceState (no history pollution). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect, useState } from '@wordpress/element'; + +export type DateRangePreset = 'last-7' | 'last-30' | 'last-90' | 'this-month' | 'last-month' | 'custom'; + +export interface DateRange { + preset: DateRangePreset; + start: string; // YYYY-MM-DD + end: string; // YYYY-MM-DD +} + +export interface DateRangePresetDef { + key: DateRangePreset; + label: string; +} + +export const DATE_RANGE_PRESETS: DateRangePresetDef[] = [ + { key: 'last-7', label: __( 'Last 7 days', 'newspack-plugin' ) }, + { key: 'last-30', label: __( 'Last 30 days', 'newspack-plugin' ) }, + { key: 'last-90', label: __( 'Last 90 days', 'newspack-plugin' ) }, + { key: 'this-month', label: __( 'This month', 'newspack-plugin' ) }, + { key: 'last-month', label: __( 'Last month', 'newspack-plugin' ) }, + { key: 'custom', label: __( 'Custom', 'newspack-plugin' ) }, +]; + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const isValidISODate = ( s: unknown ): s is string => typeof s === 'string' && ISO_DATE_RE.test( s ); + +const isPreset = ( v: unknown ): v is DateRangePreset => typeof v === 'string' && DATE_RANGE_PRESETS.some( p => p.key === v ); + +const pad2 = ( n: number ) => String( n ).padStart( 2, '0' ); + +const toISO = ( d: Date ): string => `${ d.getFullYear() }-${ pad2( d.getMonth() + 1 ) }-${ pad2( d.getDate() ) }`; + +/** + * Compute a range from a preset, anchored to today. + * + * "Last N days" presets produce an inclusive N-day window ending today — + * e.g. "Last 7 days" on Jun 7 = Jun 1 → Jun 7 (7 days total). So we + * subtract (N - 1) days, not N. + * + * Returns null for 'custom' — the caller supplies start/end directly. + */ +export const computeRangeForPreset = ( preset: DateRangePreset, today: Date = new Date() ): { start: string; end: string } | null => { + if ( preset === 'custom' ) { + return null; + } + const end = toISO( today ); + if ( preset === 'last-7' ) { + const s = new Date( today ); + s.setDate( s.getDate() - 6 ); + return { start: toISO( s ), end }; + } + if ( preset === 'last-30' ) { + const s = new Date( today ); + s.setDate( s.getDate() - 29 ); + return { start: toISO( s ), end }; + } + if ( preset === 'last-90' ) { + const s = new Date( today ); + s.setDate( s.getDate() - 89 ); + return { start: toISO( s ), end }; + } + if ( preset === 'this-month' ) { + const s = new Date( today.getFullYear(), today.getMonth(), 1 ); + return { start: toISO( s ), end }; + } + if ( preset === 'last-month' ) { + const s = new Date( today.getFullYear(), today.getMonth() - 1, 1 ); + const e = new Date( today.getFullYear(), today.getMonth(), 0 ); + return { start: toISO( s ), end: toISO( e ) }; + } + return null; +}; + +const readUrl = (): Partial< DateRange > => { + if ( typeof window === 'undefined' ) { + return {}; + } + const params = new URLSearchParams( window.location.search ); + const preset = params.get( 'range' ); + const start = params.get( 'start' ); + const end = params.get( 'end' ); + const out: Partial< DateRange > = {}; + if ( isPreset( preset ) ) { + out.preset = preset; + } + if ( isValidISODate( start ) ) { + out.start = start; + } + if ( isValidISODate( end ) ) { + out.end = end; + } + return out; +}; + +const writeUrl = ( range: DateRange ) => { + if ( typeof window === 'undefined' ) { + return; + } + const params = new URLSearchParams( window.location.search ); + params.set( 'range', range.preset ); + if ( range.preset === 'custom' ) { + params.set( 'start', range.start ); + params.set( 'end', range.end ); + } else { + params.delete( 'start' ); + params.delete( 'end' ); + } + const next = `${ window.location.pathname }?${ params.toString() }${ window.location.hash }`; + window.history.replaceState( window.history.state, '', next ); +}; + +export interface UseDateRangeOptions { + defaultRange: DateRange; +} + +export interface UseDateRangeReturn { + range: DateRange; + setPreset: ( preset: DateRangePreset ) => void; + setCustom: ( start: string, end: string ) => void; +} + +/** + * Hydrate from URL first, fall back to defaultRange. Persist on change. + */ +const useDateRange = ( { defaultRange }: UseDateRangeOptions ): UseDateRangeReturn => { + const [ range, setRange ] = useState< DateRange >( () => { + const fromUrl = readUrl(); + // Custom preset requires both start and end from URL; otherwise fall + // back to default. + if ( fromUrl.preset === 'custom' ) { + if ( fromUrl.start && fromUrl.end ) { + return { + preset: 'custom', + start: fromUrl.start, + end: fromUrl.end, + }; + } + return defaultRange; + } + if ( fromUrl.preset ) { + const computed = computeRangeForPreset( fromUrl.preset ); + if ( computed ) { + return { preset: fromUrl.preset, ...computed }; + } + } + return defaultRange; + } ); + + useEffect( () => { + writeUrl( range ); + }, [ range ] ); + + const setPreset = useCallback( ( preset: DateRangePreset ) => { + if ( preset === 'custom' ) { + // Custom needs explicit start/end; setCustom handles that path. + // Hitting "Custom" without supplying dates keeps the current + // range but flags it as custom so the picker opens. + setRange( prev => ( { + preset: 'custom', + start: prev.start, + end: prev.end, + } ) ); + return; + } + const computed = computeRangeForPreset( preset ); + if ( computed ) { + setRange( { preset, ...computed } ); + } + }, [] ); + + const setCustom = useCallback( ( start: string, end: string ) => { + if ( ! isValidISODate( start ) || ! isValidISODate( end ) ) { + return; + } + setRange( { preset: 'custom', start, end } ); + }, [] ); + + return { range, setPreset, setCustom }; +}; + +export default useDateRange; diff --git a/plugins/newspack-plugin/src/wizards/insights/style.scss b/plugins/newspack-plugin/src/wizards/insights/style.scss new file mode 100644 index 0000000000..1cbafc1296 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/style.scss @@ -0,0 +1,248 @@ +/** + * Newspack Insights wizard chrome (NPPD-1602) + * + * Per spec at ~/Sites/insights-docs/component-design-spec.md. Owns + * page-level layout (header, tab nav, tab content area) plus styles for + * the chrome-only components (DateRangePicker, ComparisonToggle, + * LastUpdated, tab stub). + * + * Also forwards the shared cross-tab chrome — sections, metric cards, + * tables, tab loading/error — from `tabs/components/sections.scss` so + * every tab inherits them without having to import on its own. + * + * Per-component metric viz styles live in + * packages/components/src// and don't belong here. + */ + +@use "~@wordpress/base-styles/colors" as wp-colors; +@use "tabs/components/sections"; + +.newspack-insights { + max-width: 1200px; + margin: 0 auto; + padding: 24px 24px 64px; + color: wp-colors.$gray-900; + + &__header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 24px; + } + + &__header-left { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__header-right { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + } + + &__title { + font-size: 28px; + font-weight: 600; + line-height: 1.2; + margin: 0; + } + + &__last-updated { + font-size: 13px; + font-weight: 400; + line-height: 1.4; + color: wp-colors.$gray-600; + } + + &__settings-link { + font-size: 13px; + font-weight: 500; + color: var(--wp-admin-theme-color); + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: 2px; + } + } + + // Empty state — rendered when no tabs are visible (rare; only when all + // feature-detection checks fail). Header still renders so the Settings + // link is reachable. + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 320px; + padding: 48px 24px; + text-align: center; + gap: 12px; + } + + &__empty-title { + font-size: 22px; + font-weight: 600; + line-height: 1.2; + color: wp-colors.$gray-900; + margin: 0; + } + + &__empty-message { + font-size: 14px; + font-weight: 400; + line-height: 1.4; + color: wp-colors.$gray-700; + max-width: 480px; + margin: 0; + } + + // Date range picker + + &__date-range-picker { + display: flex; + align-items: center; + gap: 8px; + } + + &__date-range-picker-select { + font-size: 13px; + padding: 6px 10px; + border: 1px solid wp-colors.$gray-300; + border-radius: 4px; + background: #fff; + color: wp-colors.$gray-900; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + } + + &__date-range-picker-custom { + display: flex; + align-items: center; + gap: 6px; + + input[type="date"] { + font-size: 13px; + padding: 6px 8px; + border: 1px solid wp-colors.$gray-300; + border-radius: 4px; + background: #fff; + color: wp-colors.$gray-900; + } + } + + &__date-range-picker-sep { + color: wp-colors.$gray-600; + font-size: 13px; + } + + // Comparison toggle + + &__comparison-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 400; + color: wp-colors.$gray-700; + cursor: pointer; + + input[type="checkbox"] { + margin: 0; + } + } + + // Tab navigation + + &__tabs { + display: flex; + flex-wrap: wrap; + gap: 0; + margin-bottom: 24px; + border-bottom: 1px solid wp-colors.$gray-200; + } + + &__tab { + appearance: none; + background: transparent; + border: 0; + padding: 12px 16px; + margin: 0 0 -1px 0; + font-size: 13px; + font-weight: 500; + color: wp-colors.$gray-700; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 120ms ease, border-color 120ms ease; + + &:hover { + color: wp-colors.$gray-900; + } + + &:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + + &.is-active { + color: var(--wp-admin-theme-color); + border-bottom-color: var(--wp-admin-theme-color); + } + } + + // Tab content area + + &__tab-content { + min-height: 320px; + } + + &__tab-fallback { + padding: 64px 24px; + text-align: center; + color: wp-colors.$gray-600; + font-size: 13px; + } + + // Tab stub ("Coming soon") + + &__tab-stub { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 320px; + padding: 48px 24px; + text-align: center; + gap: 8px; + } + + &__tab-stub-title { + font-size: 22px; + font-weight: 600; + line-height: 1.2; + color: wp-colors.$gray-900; + margin: 0; + } + + &__tab-stub-message { + font-size: 14px; + font-weight: 400; + color: wp-colors.$gray-600; + margin: 0; + } +} diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/AdvertisingTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/AdvertisingTab.tsx new file mode 100644 index 0000000000..e2295da734 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/AdvertisingTab.tsx @@ -0,0 +1,19 @@ +/** + * AdvertisingTab + * + * Stub. Real content lands in NPPD-1618. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const AdvertisingTab = () => ( +
+

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

+

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

+
+); + +export default AdvertisingTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/AudienceTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/AudienceTab.tsx new file mode 100644 index 0000000000..97b8cb1378 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/AudienceTab.tsx @@ -0,0 +1,19 @@ +/** + * AudienceTab + * + * Stub. Real content lands in NPPD-1608. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const AudienceTab = () => ( +
+

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

+

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

+
+); + +export default AudienceTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/ConversionTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/ConversionTab.tsx new file mode 100644 index 0000000000..66ab655a21 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/ConversionTab.tsx @@ -0,0 +1,19 @@ +/** + * ConversionTab + * + * Stub. Real content lands in NPPD-1609. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const ConversionTab = () => ( +
+

{ __( 'Conversion Journey', 'newspack-plugin' ) }

+

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

+
+); + +export default ConversionTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx new file mode 100644 index 0000000000..b77dba80f2 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/DonorsTab.tsx @@ -0,0 +1,19 @@ +/** + * DonorsTab + * + * Stub. Real content lands in NPPD-1617. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const DonorsTab = () => ( +
+

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

+

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

+
+); + +export default DonorsTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/EngagementTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/EngagementTab.tsx new file mode 100644 index 0000000000..4718eb3af0 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/EngagementTab.tsx @@ -0,0 +1,19 @@ +/** + * EngagementTab + * + * Stub. Real content lands in NPPD-1624. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const EngagementTab = () => ( +
+

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

+

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

+
+); + +export default EngagementTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx new file mode 100644 index 0000000000..e5dc540468 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/GatesTab.tsx @@ -0,0 +1,19 @@ +/** + * GatesTab + * + * Stub. Real content lands in NPPD-1604. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const GatesTab = () => ( +
+

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

+

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

+
+); + +export default GatesTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/PromptsTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/PromptsTab.tsx new file mode 100644 index 0000000000..18d80d7e50 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/PromptsTab.tsx @@ -0,0 +1,19 @@ +/** + * PromptsTab + * + * Stub. Real content lands in NPPD-1607. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const PromptsTab = () => ( +
+

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

+

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

+
+); + +export default PromptsTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx new file mode 100644 index 0000000000..dcbcb14cb0 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/SubscribersTab.tsx @@ -0,0 +1,72 @@ +/** + * SubscribersTab (NPPD-1616). + * + * Orchestrates the Tab 6 view: fetches data for the active range + + * comparison range, then composes the four sections (scorecard, + * revenue, tenure, 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. + * + * The REST endpoint still returns `cancellation_reasons` in the + * payload but it is no longer rendered — publisher data on this is + * sparse and the section wasn't pulling its weight. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { DateRange } from '../state/useDateRange'; +import useSubscribersData from '../hooks/useSubscribersData'; +import ScorecardSection from './subscribers/ScorecardSection'; +import WindowedSection from './subscribers/WindowedSection'; +import TenureSection from './subscribers/TenureSection'; +import PerformanceSection from './subscribers/PerformanceSection'; +import './subscribers/subscribers.scss'; + +export interface SubscribersTabProps { + range: DateRange; + previousRange: DateRange | null; +} + +const SubscribersTab = ( { range, previousRange }: SubscribersTabProps ) => { + const { status, data, error } = useSubscribersData( range, previousRange ); + + if ( status === 'loading' && ! data ) { + return ( +
+ { __( 'Loading subscriber data…', 'newspack-plugin' ) } +
+ ); + } + + if ( status === 'error' ) { + return ( +
+

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

+ { error &&

{ error }

} +
+ ); + } + + if ( ! data ) { + return null; + } + + return ( +
+ + + + +
+ ); +}; + +export default SubscribersTab; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx new file mode 100644 index 0000000000..359a16fa4f --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/MetricCard.tsx @@ -0,0 +1,78 @@ +/** + * MetricCard (NPPD-1616). + * + * Scorecard atom: label (top) → value + optional delta (vertically + * centered hero region) → description (pinned to the bottom). Every + * card carries the brand-color top accent so all cards in a row read + * as a single coherent unit, and the hero numbers line up at the same + * vertical position regardless of label or description height. + * + * `lowerIsBetter` flips the green/red delta tone for metrics where a + * decrease is desirable (refund rate, churned subscriber count). + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { formatCurrency, formatNumber, formatPercent, formatDelta, deltaTone } from './format'; + +export type MetricFormat = 'number' | 'currency' | 'percent'; + +export interface MetricCardProps { + label: string; + value: number; + format: MetricFormat; + previousValue?: number; + description?: string; + lowerIsBetter?: boolean; +} + +const formatValue = ( v: number, fmt: MetricFormat ): string => { + if ( fmt === 'currency' ) { + return formatCurrency( v ); + } + if ( fmt === 'percent' ) { + return formatPercent( v ); + } + return formatNumber( v ); +}; + +const MetricCard = ( props: MetricCardProps ) => { + const { label, value, format, previousValue, description, lowerIsBetter = false } = 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'; + const deltaA11y = + hasComparison && delta + ? sprintf( + /* translators: %s: signed percent change from previous timeframe */ + __( '%s vs previous timeframe', 'newspack-plugin' ), + delta + ) + : null; + + return ( +
+
{ label }
+
+
{ formatValue( value, format ) }
+ { hasComparison && delta && ( +
+ { delta } +
+ ) } +
+ { description &&
{ description }
} +
+ ); +}; + +export default MetricCard; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts b/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts new file mode 100644 index 0000000000..89b786a5cc --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/format.ts @@ -0,0 +1,68 @@ +/** + * Tab 6 formatting helpers (NPPD-1616). + * + * Lightweight wrappers around Intl.NumberFormat. Locale is taken from + * the browser; currency code is hardcoded to USD for v1 (the publisher + * may have multiple Woo currencies, and v1 sums them naively — a + * multi-currency rollup is v1.1+ and is documented in the formula doc). + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +const numberFormatter = new Intl.NumberFormat( undefined, { + maximumFractionDigits: 0, +} ); + +const currencyFormatter = new Intl.NumberFormat( undefined, { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, +} ); + +const percentFormatter = new Intl.NumberFormat( undefined, { + style: 'percent', + maximumFractionDigits: 1, +} ); + +const signedPercentFormatter = new Intl.NumberFormat( undefined, { + style: 'percent', + signDisplay: 'exceptZero', + maximumFractionDigits: 1, +} ); + +export const formatNumber = ( n: number ): string => numberFormatter.format( n ); + +export const formatCurrency = ( n: number ): string => currencyFormatter.format( n ); + +/** Format a fraction in [0, 1] as a percent: 0.123 -> "12.3%". */ +export const formatPercent = ( fraction: number ): string => percentFormatter.format( fraction ); + +/** + * Percent change between current and previous, formatted with sign. + * Returns null when previous is 0 (no defined ratio). + */ +export const formatDelta = ( current: number, previous: number ): string | null => { + if ( previous === 0 ) { + return null; + } + return signedPercentFormatter.format( ( current - previous ) / previous ); +}; + +/** + * Compute the user-meaningful tone of a delta. "Positive" means the + * change is good news for the publisher; "negative" means bad news. + * lowerIsBetter inverts the mapping for metrics where a decrease is the + * desired direction (refund rate, churn count, etc.). + */ +export const deltaTone = ( current: number, previous: number, lowerIsBetter = false ): 'positive' | 'negative' | 'neutral' => { + if ( current === previous ) { + return 'neutral'; + } + const improved = lowerIsBetter ? current < previous : current > previous; + return improved ? 'positive' : 'negative'; +}; + +export const noDataLabel = (): string => __( 'No data', 'newspack-plugin' ); diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss new file mode 100644 index 0000000000..031c05da31 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/components/sections.scss @@ -0,0 +1,250 @@ +/** + * Newspack Insights — shared tab chrome (NPPD-1602/1616) + * + * Generic visuals that every Insights tab inherits: section wrappers + * and headings, the metric card chrome + responsive grid, the table + * treatment (incl. nested-variation rows), and tab-level loading / + * error blocks. Tab-specific styles (e.g. Tab 6's tenure card, + * Tab 7's donor-cohort layout) live alongside their tab orchestrator. + * + * Loaded by `src/wizards/insights/style.scss` so all tabs pick up + * the chrome without each having to import it. + */ + +@use "~@wordpress/base-styles/colors" as wp-colors; + +.newspack-insights { + + // Tab-level status blocks shown by orchestrators between data + // fetch and first render. + + &__tab-loading, + &__tab-error { + padding: 32px 24px; + text-align: center; + font-size: 14px; + color: wp-colors.$gray-700; + } + + &__tab-error { + color: wp-colors.$alert-red; + + &-detail { + margin-top: 8px; + font-size: 13px; + color: wp-colors.$gray-600; + font-family: monospace; + } + } + + // Sections are pure spacing — no background, no border. The cards + // inside them carry the chrome (per spec). + + &__section { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__section-heading { + font-size: 18px; + font-weight: 600; + line-height: 1.3; + margin: 0; + color: wp-colors.$gray-900; + } + + &__section-caption { + margin: 0; + font-size: 13px; + font-weight: 400; + color: wp-colors.$gray-700; + } + + &__section-empty { + margin: 0; + padding: 20px; + background-color: #fff; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + font-size: 14px; + font-weight: 400; + color: wp-colors.$gray-700; + } + + // Scorecard grid — responsive: auto-fill with a 220px minimum per + // column. Each card stretches to fill its column. + + &__metric-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + } + + // Card chrome per spec: white background, gray-200 border, 4px + // radius. Padding 24px 28px. Min-height 180px so cards line up + // uniformly across the grid AND give the centered hero value room + // to breathe between label and description. + // + // Internal layout (three regions, fixed order): + // label — top, left-aligned (spec's uppercase chip) + // body — flex: 1, hero sits at the top of this region, + // left-aligned. Vertical position consistent across + // cards because all cards share the same label height + // + body padding. + // description — pinned to the bottom edge, left-aligned + // + // Every card carries the brand-color top accent so a row reads as + // a single coherent unit. + + &__metric-card { + display: flex; + flex-direction: column; + padding: 24px 28px; + background-color: #fff; + border: 1px solid wp-colors.$gray-200; + border-top: 3px solid var(--wp-admin-theme-color); + border-radius: 4px; + min-height: 180px; + + &-label { + font-size: 12px; + font-weight: 600; + line-height: 1.4; + letter-spacing: 0.05em; + text-transform: uppercase; + color: wp-colors.$gray-700; + // Reserve two lines of vertical space so a long label that + // wraps ("MONTHLY RECURRING REVENUE") doesn't push its hero + // number down relative to neighboring cards whose labels fit + // on one line. 12px font × 1.4 line-height × 2 lines. + min-height: calc(12px * 1.4 * 2); + } + + &-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: flex-start; // hero sits at the top, just under the label + align-items: flex-start; // left-aligned to match label + gap: 8px; + padding: 16px 0 0; + } + + &-value { + margin: 0; + // Explicit font stack + lockdown to prevent the wp-admin chrome's + // inherited font from flipping between cards (the bug was: digits + // in "35" rendered slim, "$738.33" rendered heavier with a slight + // optical slant — different chars resolving in different + // fallback fonts because no explicit family was declared). The + // stack matches @wordpress/base-styles' admin body font. + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 44px; + font-weight: 500; + font-style: normal; + line-height: 1.05; + letter-spacing: -0.01em; + color: wp-colors.$gray-900; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + font-synthesis: none; + text-align: left; + } + + &-delta { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + + // Spec calls for newspack-colors $success-600 / $error-600. + // Chrome stylesheet only imports @wordpress/base-styles, so + // using its alert tokens here keeps the import surface tight; + // swap to newspack-colors when (if) those tokens get wired in. + &--positive { + color: wp-colors.$alert-green; + } + + &--negative { + color: wp-colors.$alert-red; + } + + &--neutral { + color: wp-colors.$gray-600; + } + } + + &-description { + margin: 16px 0 0; + font-size: 13px; + font-weight: 400; + line-height: 1.4; + color: wp-colors.$gray-700; + text-align: left; + } + } + + // Table — card wraps the table, table fills (padding 0 on + // wrapper). Header bg per spec gray-100. The variation row pattern + // is generic: any nested parent/child data shape can use it. + + &__table-wrap { + overflow-x: auto; + background-color: #fff; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + th, + td { + padding: 12px 20px; + text-align: left; + border-bottom: 1px solid wp-colors.$gray-200; + } + + th { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: wp-colors.$gray-700; + background-color: wp-colors.$gray-100; + border-bottom: 1px solid wp-colors.$gray-300; + } + + tbody tr:last-child td { + border-bottom: 0; + } + + &-num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + // 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 + // first cell so the nesting reads at a glance. + // + // The indent rule targets `td:first-child` rather than a + // per-cell modifier class because the modifier would be + // specificity (0,1,0) and lose to the base `.table td` rule + // (0,2,1). `&-row--variation td:first-child` is (0,2,2) which + // wins the cascade cleanly. + &-row--variation { + td { + color: wp-colors.$gray-700; + } + + td:first-child { + padding-left: 44px; // 20px table padding + 24px indent + } + } + } +} diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx new file mode 100644 index 0000000000..841ea8245f --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/PerformanceSection.tsx @@ -0,0 +1,108 @@ +/** + * PerformanceSection (NPPD-1616). + * + * Per-product breakdown for subscription products. Top 50 parents (or + * standalone simple subs) by active subscriber count (server-limited). + * Variable products render as a parent row with their variations + * indented underneath. The parent row's aggregates equal the SUM of + * its variation rows. + * + * lifetime_revenue is an approximation (sum of renewal-amount rows + * across active + churned subs); a true LTV waits on the BigQuery + * wrapper in v1.1. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { PerformanceRow } from '../../api/subscribers'; +import { formatCurrency, formatNumber } from '../components/format'; + +export interface PerformanceSectionProps { + rows: PerformanceRow[]; +} + +const PerformanceSection = ( { rows }: PerformanceSectionProps ) => { + if ( rows.length === 0 ) { + return ( +
+

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

+

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

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

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

+

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

+
+ + + + + + + + + + + + { rows.map( row => ( + + + + + + + + + { row.is_parent && + row.variations?.map( v => ( + + + + + + + + ) ) } + + ) ) } + +
{ __( 'Product', 'newspack-plugin' ) } + { __( 'Active subs', 'newspack-plugin' ) } + + { __( 'Churned subs', 'newspack-plugin' ) } + + { __( 'Active value', 'newspack-plugin' ) } + + { __( 'Lifetime revenue', 'newspack-plugin' ) } +
{ row.name }{ formatNumber( row.active_subs ) }{ formatNumber( row.churned_subs ) }{ formatCurrency( row.active_value ) }{ formatCurrency( row.lifetime_revenue ) }
{ v.label }{ formatNumber( v.active_subs ) }{ formatNumber( v.churned_subs ) }{ formatCurrency( v.active_value ) }{ formatCurrency( v.lifetime_revenue ) }
+
+
+ ); +}; + +export default PerformanceSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx new file mode 100644 index 0000000000..d2c1747729 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/ScorecardSection.tsx @@ -0,0 +1,63 @@ +/** + * 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. + * + * Window-scoped metrics (new/churned, gross/net revenue, refund rate, + * retry rate) live in {@see WindowedSection} below this one. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { SubscribersSnapshot } from '../../api/subscribers'; +import MetricCard from '../components/MetricCard'; + +export interface ScorecardSectionProps { + snapshot: SubscribersSnapshot; +} + +const ScorecardSection = ( { snapshot }: ScorecardSectionProps ) => ( +
+

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

+
+ + + + +
+
+); + +export default ScorecardSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/TenureSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/TenureSection.tsx new file mode 100644 index 0000000000..4f816df663 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/TenureSection.tsx @@ -0,0 +1,139 @@ +/** + * TenureSection (NPPD-1616). + * + * Subscriber tenure summary — median + 25th / 75th percentiles computed + * client-side from the raw per-active-subscription tenure_days payload + * returned by the REST endpoint. The histogram that previously rendered + * below these callouts was removed (it duplicated the same information + * in chart form without adding insight); the backend method is kept in + * place for potential v1.1 revival of a richer tenure visualization. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf, _n } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TenureDistributionRow } from '../../api/subscribers'; + +export interface TenureSectionProps { + rows: TenureDistributionRow[]; +} + +const percentile = ( sorted: number[], p: number ): number => { + if ( sorted.length === 0 ) { + return 0; + } + if ( sorted.length === 1 ) { + return sorted[ 0 ]; + } + const rank = ( sorted.length - 1 ) * p; + const lower = Math.floor( rank ); + const upper = Math.ceil( rank ); + const weight = rank - lower; + return sorted[ lower ] * ( 1 - weight ) + sorted[ upper ] * weight; +}; + +const TenureSection = ( { rows }: TenureSectionProps ) => { + const stats = useMemo( () => { + if ( rows.length === 0 ) { + return null; + } + const days = rows + .map( r => r.tenure_days ) + .filter( ( d ): d is number => Number.isFinite( d ) ) + .sort( ( a, b ) => a - b ); + return { + p25: Math.round( percentile( days, 0.25 ) ), + median: Math.round( percentile( days, 0.5 ) ), + p75: Math.round( percentile( days, 0.75 ) ), + }; + }, [ rows ] ); + + if ( ! stats ) { + return ( +
+

+ { __( 'Subscriber tenure', 'newspack-plugin' ) } +

+

+ { __( 'No subscribers yet — tenure data will appear once subscriptions exist.', 'newspack-plugin' ) } +

+
+ ); + } + + // Narrative below the callouts. Translates the percentile numbers + // into plain language so the section reads as more than three bare + // numbers. The second sentence is suppressed when the 75th + // percentile collapses to the median (a degenerate case with very + // few subscribers — saying "a quarter have been here longer than X" + // when X equals the median is redundant). + const showSecondSentence = stats.p75 > stats.median; + const medianSentence = sprintf( + /* translators: %d: median tenure in days */ + _n( + 'Half of your subscribers have been here longer than %d day.', + 'Half of your subscribers have been here longer than %d days.', + stats.median, + 'newspack-plugin' + ), + stats.median + ); + const p75Sentence = sprintf( + /* translators: %d: 75th-percentile tenure in days */ + _n( 'A quarter have been here longer than %d day.', 'A quarter have been here longer than %d days.', stats.p75, 'newspack-plugin' ), + stats.p75 + ); + + return ( +
+

+ { __( 'Subscriber tenure', 'newspack-plugin' ) } +

+
+
+
+
{ __( 'Median tenure', 'newspack-plugin' ) }
+
+ { sprintf( + /* translators: %d: number of days */ + _n( '%d day', '%d days', stats.median, 'newspack-plugin' ), + stats.median + ) } +
+
+
+
{ __( '25th percentile', 'newspack-plugin' ) }
+
+ { + /* translators: %d: number of days */ + sprintf( _n( '%d day', '%d days', stats.p25, 'newspack-plugin' ), stats.p25 ) + } +
+
+
+
{ __( '75th percentile', 'newspack-plugin' ) }
+
+ { + /* translators: %d: number of days */ + sprintf( _n( '%d day', '%d days', stats.p75, 'newspack-plugin' ), stats.p75 ) + } +
+
+
+

+ { medianSentence } + { showSecondSentence && ' ' } + { showSecondSentence && p75Sentence } +

+
+
+ ); +}; + +export default TenureSection; diff --git a/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx new file mode 100644 index 0000000000..bd8c0fe4ad --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/WindowedSection.tsx @@ -0,0 +1,117 @@ +/** + * WindowedSection (NPPD-1616). + * + * All Tab 6 metrics that ARE scoped to the date range picker: + * new/churned subscriber counts, gross/net subscription revenue, + * refund rate, and failed payment retry rate. Renders 6 MetricCards + * 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. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { SubscribersWindow } from '../../api/subscribers'; +import type { DateRange } from '../../state/useDateRange'; +import MetricCard from '../components/MetricCard'; + +export interface WindowedSectionProps { + range: DateRange; + current: SubscribersWindow; + previous: SubscribersWindow | 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/subscribers/subscribers.scss b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/subscribers.scss new file mode 100644 index 0000000000..d8fe5bc3a9 --- /dev/null +++ b/plugins/newspack-plugin/src/wizards/insights/tabs/subscribers/subscribers.scss @@ -0,0 +1,75 @@ +/** + * Newspack Insights — Tab 6 (Subscribers) styles (NPPD-1616) + * + * Tab 6-specific layout + the tenure card composition (percentile + * callouts + interpretive narrative inside a single card chrome). + * The shared Insights chrome (sections, metric cards, table, tab + * loading/error) lives in `tabs/components/sections.scss` and is + * loaded by the wizard's main `style.scss`. + */ + +@use "~@wordpress/base-styles/colors" as wp-colors; + +.newspack-insights { + + // Top-level wrapper of the Subscribers tab body. 32px section gap + // between the at-a-glance scorecards, the windowed group, the + // tenure card, and the performance table. + &__subscribers-tab { + display: flex; + flex-direction: column; + gap: 32px; + } + + // Tenure card wraps the percentile callouts + narrative sentence + // so both share a single card chrome. The stats-summary becomes + // layout-only; the card itself carries the bg/border/padding. + + &__tenure-card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px; + background-color: #fff; + border: 1px solid wp-colors.$gray-200; + border-radius: 4px; + } + + &__stats-summary { + display: flex; + flex-wrap: wrap; + gap: 24px; + margin: 0; + + div { + display: flex; + flex-direction: column; + gap: 4px; + } + + dt { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: wp-colors.$gray-700; + margin: 0; + } + + dd { + font-size: 22px; + font-weight: 600; + color: wp-colors.$gray-900; + font-variant-numeric: tabular-nums; + margin: 0; + } + } + + &__tenure-narrative { + margin: 0; + font-size: 14px; + font-weight: 400; + line-height: 1.4; + color: wp-colors.$gray-700; + } +}