Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
2fe0f7a
feat(insights): add Insights wizard PHP class (NPPD-1602)
kmwilkerson Jun 3, 2026
2da973d
feat(insights): add 8 Insights section stub classes (NPPD-1602)
kmwilkerson Jun 3, 2026
1906696
feat(insights): register Insights wizard and section stubs (NPPD-1602)
kmwilkerson Jun 3, 2026
bf70e31
feat(insights): add useDateRange and useComparisonMode state hooks (N…
kmwilkerson Jun 3, 2026
1a55324
feat(insights): add header components DateRangePicker, ComparisonTogg…
kmwilkerson Jun 3, 2026
cc74139
feat(insights): add TabNavigation and TabContent components (NPPD-1602)
kmwilkerson Jun 3, 2026
a5b0d9a
feat(insights): add 8 tab stub components with "Coming soon" placehol…
kmwilkerson Jun 3, 2026
ffbf179
feat(insights): add InsightsWizard top-level component (NPPD-1602)
kmwilkerson Jun 3, 2026
fc53c45
feat(insights): register Insights React entry in wizards lazy map (NP…
kmwilkerson Jun 3, 2026
0222a5c
style(insights): add wizard-level styles for chrome (NPPD-1602)
kmwilkerson Jun 3, 2026
a4b8001
fix(insights): correct Linear issue references in section stub doc bl…
kmwilkerson Jun 3, 2026
9a3da53
fix(insights): correct inclusive-window math in date range presets
kmwilkerson Jun 3, 2026
ebd84c7
i18n(insights): wrap tab labels, date preset labels, and aria-label i…
kmwilkerson Jun 3, 2026
b0cf9ad
feat(insights): wire settingsUrl into header and add no-tabs empty state
kmwilkerson Jun 3, 2026
cd03384
fix(insights): satisfy CI lint checks (prettier, phpcbf, a11y, block-…
kmwilkerson Jun 3, 2026
428a351
feat(insights): feature-flag wizard behind NEWSPACK_INSIGHTS_ENABLED …
kmwilkerson Jun 3, 2026
95eb02e
feat(insights): add Tab 6 Storage_Interface contract (NPPD-1616)
kmwilkerson Jun 3, 2026
d2d2ccd
feat(insights): add Storage_Detector for HPOS vs legacy CPT dispatch …
kmwilkerson Jun 3, 2026
f9c3102
feat(insights): add HPOS_Storage implementation for Tab 6 (NPPD-1616)
kmwilkerson Jun 3, 2026
ea65ab5
feat(insights): add Legacy_Storage implementation for Tab 6 (NPPD-1616)
kmwilkerson Jun 3, 2026
ec5231f
feat(insights): add Donation_Product_Classifier with 1h cache (NPPD-1…
kmwilkerson Jun 3, 2026
eaadcac
feat(insights): add Subscribers_Metric orchestrator (NPPD-1616)
kmwilkerson Jun 3, 2026
310bec6
feat(insights): wire Tab 6 REST endpoint at /subscribers (NPPD-1616)
kmwilkerson Jun 4, 2026
66e6304
style(insights): satisfy PHPCS @param docs on Tab 6 storage overrides
kmwilkerson Jun 4, 2026
2bd50ec
fix(insights): use public DONATION_PRODUCT_ID_OPTION for parent lookup
kmwilkerson Jun 4, 2026
178ffae
feat(insights): add Subscribers API client + useSubscribersData hook
kmwilkerson Jun 4, 2026
890cc70
feat(insights): add Tab 6 banner + Scorecard + Revenue sections
kmwilkerson Jun 4, 2026
ac2d6c7
feat(insights): add Tenure, Performance, CancellationReasons sections
kmwilkerson Jun 4, 2026
d2f9cba
feat(insights): wire SubscribersTab orchestrator (NPPD-1616)
kmwilkerson Jun 4, 2026
4a210e0
style(insights): satisfy prettier + add translator comments for Tab 6
kmwilkerson Jun 4, 2026
fb58b34
style(insights): add Tab 6 component styles (NPPD-1616)
kmwilkerson Jun 4, 2026
9fd7351
refactor(insights): drop settings link, classification banner, tighte…
kmwilkerson Jun 4, 2026
2e1f717
fix(insights): use order_items table for subscription queries (HPOS)
kmwilkerson Jun 4, 2026
d180ab6
fix(insights): use order_items table for subscription queries (legacy)
kmwilkerson Jun 4, 2026
a91c7a9
fix(insights): clarify Active subscribers vs Performance table semantics
kmwilkerson Jun 4, 2026
6598b36
fix(insights): apply Active subscribers description fix (missed by pr…
kmwilkerson Jun 4, 2026
175f6e4
refactor(insights): apply Tab 6 polish pass per component-design-spec
kmwilkerson Jun 4, 2026
d3ede09
fix(insights): typography lockdown, primary accent, bar denominator, …
kmwilkerson Jun 4, 2026
2b922a8
fix(insights): unify scorecard value size, keep top-border accent
kmwilkerson Jun 4, 2026
eab88d2
refactor(insights): unify scorecard layout — accent on all, hero cent…
kmwilkerson Jun 4, 2026
5ebf42c
fix(insights): hero number top-aligned in card body (per request)
kmwilkerson Jun 4, 2026
b5b4e86
fix(insights): left-align all scorecard content (per request)
kmwilkerson Jun 4, 2026
4339532
fix(insights): reserve 2-line min-height on scorecard label
kmwilkerson Jun 4, 2026
d3634c5
refactor(insights): lighter hero, breathing room, drop Cancellation R…
kmwilkerson Jun 4, 2026
479af7c
feat(insights): add tenure histogram tooltips and count axis
kmwilkerson Jun 4, 2026
f6a0a1f
feat(insights): clarify churned subscribers subtitle copy
kmwilkerson Jun 4, 2026
1d95e5a
feat(insights): regroup subscriber scorecards by temporal scope
kmwilkerson Jun 4, 2026
7c22059
feat(insights): remove tenure histogram, keep percentile callouts only
kmwilkerson Jun 4, 2026
3c778ac
feat(insights): add interpretive sentence to subscriber tenure card
kmwilkerson Jun 4, 2026
8629719
fix(insights): window-scope churned_subs in performance breakdown
kmwilkerson Jun 4, 2026
66d42b8
fix(insights): MRR covers all Woo billing periods, conservative ELSE
kmwilkerson Jun 4, 2026
31a24dc
fix(insights): wire classifier cache invalidation hooks (NPPD-1616)
kmwilkerson Jun 4, 2026
9e9c9e9
feat(insights): nest variation rows under parent products in performa…
kmwilkerson Jun 4, 2026
256a0ab
fix(insights): performance table — indent + color on variation rows, …
kmwilkerson Jun 4, 2026
57f667c
refactor(insights): extract shared chrome components and SCSS for cro…
kmwilkerson Jun 4, 2026
64dad4a
style(insights): PHPCS empty line before block comment
kmwilkerson Jun 4, 2026
b7391fe
fix(insights): format subscriber query windows in UTC (NPPD-1616)
kmwilkerson Jun 8, 2026
b43d765
Merge branch 'main' into nppd-1616-insights-tab-6-subscribers
dkoo Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions plugins/newspack-plugin/includes/class-newspack.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions plugins/newspack-plugin/includes/class-wizards.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
<?php
/**
* Newspack Insights — Tab 6 Subscribers REST controller (NPPD-1616).
*
* Exposes the single endpoint that powers the Subscribers tab.
* Namespace: `newspack-insights/v1`. Route: `/subscribers`.
*
* Response shape — see {@see self::build_response()}. Split into:
*
* - `classification` — banner metadata (backend, donation product count).
* - `snapshot` — "right now" metrics that do not depend on the
* date window (active subs, MRR, ARR, tenure
* distribution, upcoming renewals).
* - `current` — windowed metrics for the requested window.
* - `previous` — windowed metrics for the optional comparison
* window (`null` if compare params omitted).
*
* Date inputs are `Y-m-d` strings in the site's timezone. Start dates
* resolve to 00:00:00; end dates resolve to 23:59:59 inclusive. The
* controller delegates caching to {@see Subscribers_Metric}, so the
* comparison-mode second call is free on cache hit.
*
* @package Newspack
*/

namespace Newspack\Insights;

defined( 'ABSPATH' ) || exit;

use DateTimeImmutable;
use DateTimeZone;
use Exception;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
* Subscribers REST controller.
*/
class Subscribers_REST_Controller extends WP_REST_Controller {

/**
* Dedicated namespace for Insights endpoints, separate from
* `newspack/v1` (which is reserved for wizard infrastructure).
*
* @var string
*/
protected $namespace = 'newspack-insights/v1';

/**
* Route base under the namespace.
*
* @var string
*/
protected $rest_base = 'subscribers';

/**
* Register the single Tab 6 route.
*
* @return void
*/
public function register_routes(): void {
register_rest_route(
$this->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();
}
}
Loading
Loading