Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
df7ccc7
feat(insights): add Donors_Storage_Interface for Tab 7 (NPPD-1617)
kmwilkerson Jun 4, 2026
54e4225
feat(insights): HPOS_Donors_Storage implementation (NPPD-1617)
kmwilkerson Jun 4, 2026
c6b47e7
feat(insights): Legacy_Donors_Storage implementation (NPPD-1617)
kmwilkerson Jun 4, 2026
3c99eb8
feat(insights): Donors_Metric orchestrator for Tab 7 (NPPD-1617)
kmwilkerson Jun 4, 2026
8efee3d
feat(insights): wire Tab 7 REST controller + section + autoload (NPPD…
kmwilkerson Jun 4, 2026
5aa7a46
feat(insights): dynamic Donors tab visibility (NPPD-1617)
kmwilkerson Jun 4, 2026
044c176
feat(insights): Tab 7 React UI — DonorsTab + 4 sections (NPPD-1617)
kmwilkerson Jun 4, 2026
00f5426
fix(insights): scope Tab 7 visibility to donation activity, not produ…
kmwilkerson Jun 4, 2026
ab41efe
fix(insights): wire Tab 7 DonorsTab orchestrator + refine copy
kmwilkerson Jun 4, 2026
4e4aa3a
feat(insights): consolidate Tab 7 scorecards 13 → 8
kmwilkerson Jun 4, 2026
653f5e5
feat(insights): refine Tab 7 copy + restrict Average Gift to one-time
kmwilkerson Jun 4, 2026
da10458
feat(insights): graceful empty state for Tab 7 retention section
kmwilkerson Jun 4, 2026
4a075a8
feat(insights): Tab 7 tier table caption + sort by lifetime revenue
kmwilkerson Jun 4, 2026
3ec5a2b
feat(insights): show non-applicable Tab 7 table cells as em-dashes
kmwilkerson Jun 4, 2026
b958076
feat(insights): structured retention rates + small-cohort denominator
kmwilkerson Jun 4, 2026
c375cd9
feat(insights): mirror Tab 7 visual patterns onto Tab 6
kmwilkerson Jun 4, 2026
1532526
feat(insights): structured Tab 6 rate metrics + denominator context
kmwilkerson Jun 4, 2026
012c78d
feat(insights): copy refinements on Tab 6 + 7 (readers, refund empty)
kmwilkerson Jun 4, 2026
c4c26d1
fix(insights): handle WCS '0' cancel-meta sentinel in retention denom…
kmwilkerson Jun 4, 2026
9286940
feat(insights): per-tier Lapsed Donors column on Tab 7
kmwilkerson Jun 4, 2026
9127bef
feat(insights): Upcoming renewals card on Tab 7 + Subscriptions MRR r…
kmwilkerson Jun 4, 2026
c8f3625
feat(insights): Upcoming cancellations card + Subscriptions by product
kmwilkerson Jun 4, 2026
aef9662
feat(insights): rename "Upcoming cancellations" card → "Upcoming endi…
kmwilkerson Jun 4, 2026
47fed09
feat(insights): scaffold Gates tab Phase 1 (feature flag + REST stub)
kmwilkerson Jun 5, 2026
15d6047
feat(insights): MetricCard pending state for Tab 4 Phase 1
kmwilkerson Jun 5, 2026
e5dac03
feat(insights): Gates tab UI (sections, viz, banner, explainer)
kmwilkerson Jun 5, 2026
cf9cded
feat(insights): clarify Influenced copy + lift explainer to tab top
kmwilkerson Jun 5, 2026
bd2e927
feat(insights): sortable Performance by gate table on Tab 4
kmwilkerson Jun 5, 2026
a4fe47c
feat(insights): align Gates React strings with session-scoped attribu…
kmwilkerson Jun 5, 2026
cb6c7e1
Merge branch 'main' into nppd-1604-insights-tab-4-gates
dkoo Jun 8, 2026
7264a72
fix(insights): address donors PR review (MRR, tab visibility, copy)
kmwilkerson Jun 8, 2026
36d15b0
feat(insights): scaffold Gates tab Phase 1 (feature flag + REST stub)
kmwilkerson Jun 5, 2026
37ac646
feat(insights): MetricCard pending state for Tab 4 Phase 1
kmwilkerson Jun 5, 2026
58a94d4
feat(insights): Gates tab UI (sections, viz, banner, explainer)
kmwilkerson Jun 5, 2026
ac4c585
feat(insights): clarify Influenced copy + lift explainer to tab top
kmwilkerson Jun 5, 2026
db261c1
feat(insights): sortable Performance by gate table on Tab 4
kmwilkerson Jun 5, 2026
de01578
feat(insights): align Gates React strings with session-scoped attribu…
kmwilkerson Jun 5, 2026
f2526ab
Merge branch 'nppd-1604-insights-tab-4-gates' of https://github.com/A…
dkoo Jun 8, 2026
22796e2
Merge branch 'main' into nppd-1604-insights-tab-4-gates
dkoo Jun 8, 2026
b107395
Merge branch 'nppd-1616-insights-tab-6-subscribers' into nppd-1617-in…
dkoo Jun 8, 2026
d16fea4
Merge branch 'nppd-1617-insights-tab-7-donors' into nppd-1604-insight…
dkoo Jun 8, 2026
286c89e
Merge pull request #229 from Automattic/nppd-1604-insights-tab-4-gates
kmwilkerson Jun 9, 2026
0ca5000
docs(insights): correct rate-method @return shapes + donors-visibilit…
kmwilkerson Jun 9, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
<?php
/**
* Newspack Insights — Tab 7 Donors REST controller (NPPD-1617).
*
* Single endpoint: `GET /newspack-insights/v1/donors`. Same date-arg
* validation, permissions, and response-shape conventions as
* {@see Subscribers_REST_Controller}.
*
* @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_Server;

/**
* Donors REST controller.
*/
class Donors_REST_Controller extends WP_REST_Controller {

/**
* Dedicated namespace shared with Tab 6.
*
* @var string
*/
protected $namespace = 'newspack-insights/v1';

/**
* Route base.
*
* @var string
*/
protected $rest_base = 'donors';

/**
* Register the Tab 7 route.
*
* @return void
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_donors_data' ],
'permission_callback' => [ $this, 'permissions_check' ],
'args' => $this->get_collection_params(),
],
]
);
}

/**
* Permission check.
*
* @return bool|WP_Error
*/
public function permissions_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'newspack_insights_rest_forbidden',
__( 'You do not have permission to view Insights data.', 'newspack-plugin' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}

/**
* GET handler.
*
* @param WP_REST_Request $request Request.
* @return \WP_REST_Response|WP_Error
*/
public function get_donors_data( WP_REST_Request $request ) {
$tz = $this->site_timezone();

try {
$start = $this->parse_date( $request->get_param( 'start' ), $tz, false );
$end = $this->parse_date( $request->get_param( 'end' ), $tz, true );
} catch ( Exception $e ) {
return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] );
}
if ( $start > $end ) {
return new WP_Error(
'newspack_insights_invalid_window',
__( 'Start date must be on or before end date.', 'newspack-plugin' ),
[ 'status' => 400 ]
);
}

$compare_start_param = $request->get_param( 'compare_start' );
$compare_end_param = $request->get_param( 'compare_end' );
$compare_start = null;
$compare_end = null;
if ( $compare_start_param || $compare_end_param ) {
if ( ! $compare_start_param || ! $compare_end_param ) {
return new WP_Error(
'newspack_insights_invalid_comparison',
__( 'Both compare_start and compare_end must be provided to enable comparison mode.', 'newspack-plugin' ),
[ 'status' => 400 ]
);
}
try {
$compare_start = $this->parse_date( $compare_start_param, $tz, false );
$compare_end = $this->parse_date( $compare_end_param, $tz, true );
} catch ( Exception $e ) {
return new WP_Error( 'newspack_insights_invalid_date', $e->getMessage(), [ 'status' => 400 ] );
}
if ( $compare_start > $compare_end ) {
return new WP_Error(
'newspack_insights_invalid_comparison_window',
__( 'compare_start must be on or before compare_end.', 'newspack-plugin' ),
[ 'status' => 400 ]
);
}
}

$metric = new Donors_Metric();
return rest_ensure_response( $this->build_response( $metric, $start, $end, $compare_start, $compare_end ) );
}

/**
* Assemble response.
*
* @param Donors_Metric $metric Orchestrator.
* @param DateTimeImmutable $start Current window start.
* @param DateTimeImmutable $end Current window end.
* @param DateTimeImmutable|null $compare_start Prior window start.
* @param DateTimeImmutable|null $compare_end Prior window end.
* @return array
*/
private function build_response(
Donors_Metric $metric,
DateTimeImmutable $start,
DateTimeImmutable $end,
?DateTimeImmutable $compare_start,
?DateTimeImmutable $compare_end
): array {
$response = [
'classification' => $metric->get_classification_metadata(),
'snapshot' => [
'active_donors' => $metric->get_active_donors(),
'active_recurring_donors' => $metric->get_active_recurring_donors(),
'donation_mrr' => $metric->get_donation_mrr(),
'donation_arr' => $metric->get_donation_arr(),
'upcoming_donation_renewals_30d' => $metric->get_upcoming_donation_renewals_30d(),
'upcoming_donation_cancellations_30d' => $metric->get_upcoming_donation_cancellations_30d(),
],
'current' => $this->build_window( $metric, $start, $end ),
'previous' => null,
];
if ( $compare_start && $compare_end ) {
$response['previous'] = $this->build_window( $metric, $compare_start, $compare_end );
}
return $response;
}

/**
* Window-bound payload.
*
* @param Donors_Metric $metric Orchestrator.
* @param DateTimeImmutable $start Start.
* @param DateTimeImmutable $end End.
* @return array
*/
private function build_window( Donors_Metric $metric, DateTimeImmutable $start, DateTimeImmutable $end ): array {
return [
'window' => [
'start' => $start->format( 'Y-m-d' ),
'end' => $end->format( 'Y-m-d' ),
],
'new_donors' => $metric->get_new_donors_in_window( $start, $end ),
'lapsed_donors' => $metric->get_lapsed_donors_in_window( $start, $end ),
'one_time_revenue' => $metric->get_one_time_donation_revenue( $start, $end ),
'recurring_revenue' => $metric->get_recurring_donation_revenue( $start, $end ),
'total_revenue' => $metric->get_total_donation_revenue( $start, $end ),
'average_gift' => $metric->get_average_donation_gift( $start, $end ),
'lapsed_donor_recovery_rate' => $metric->get_lapsed_donor_recovery_rate( $start, $end ),
'recurring_donor_retention' => $metric->get_recurring_donor_retention( $start, $end ),
'donations_by_tier' => $metric->get_donations_by_tier( $start, $end ),
];
}

/**
* Args spec.
*
* @return array
*/
public function get_collection_params() {
$base = [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => [ $this, 'validate_date_string' ],
];
return [
'start' => array_merge(
$base,
[
'description' => __( 'Inclusive window start date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ),
'required' => true,
]
),
'end' => array_merge(
$base,
[
'description' => __( 'Inclusive window end date (YYYY-MM-DD, site timezone).', 'newspack-plugin' ),
'required' => true,
]
),
'compare_start' => array_merge(
$base,
[
'description' => __( 'Optional comparison window start. Must pair with compare_end.', 'newspack-plugin' ),
'required' => false,
]
),
'compare_end' => array_merge(
$base,
[
'description' => __( 'Optional comparison window end. Must pair with compare_start.', 'newspack-plugin' ),
'required' => false,
]
),
];
}

/**
* REST validate_callback.
*
* @param mixed $value Value.
* @return bool|WP_Error
*/
public function validate_date_string( $value ) {
if ( ! is_string( $value ) || '' === $value ) {
return new WP_Error(
'newspack_insights_invalid_date',
__( 'Date must be a non-empty YYYY-MM-DD string.', 'newspack-plugin' ),
[ 'status' => 400 ]
);
}
$parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $this->site_timezone() );
if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) {
return new WP_Error(
'newspack_insights_invalid_date',
/* translators: %s: the invalid date string */
sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ),
[ 'status' => 400 ]
);
}
return true;
}

/**
* Parse a Y-m-d string into a DateTimeImmutable.
*
* @param mixed $value Raw value.
* @param DateTimeZone $tz Timezone.
* @param bool $end_of_day If true, 23:59:59; else 00:00:00.
* @return DateTimeImmutable
* @throws Exception On parse failure.
*/
private function parse_date( $value, DateTimeZone $tz, bool $end_of_day ): DateTimeImmutable {
if ( ! is_string( $value ) || '' === $value ) {
throw new Exception( esc_html__( 'Missing date value.', 'newspack-plugin' ) );
}
$parsed = DateTimeImmutable::createFromFormat( 'Y-m-d', $value, $tz );
if ( ! $parsed || $parsed->format( 'Y-m-d' ) !== $value ) {
/* translators: %s: the invalid date string */
throw new Exception( esc_html( sprintf( __( 'Invalid date "%s". Expected YYYY-MM-DD.', 'newspack-plugin' ), $value ) ) );
}
return $end_of_day ? $parsed->setTime( 23, 59, 59 ) : $parsed->setTime( 0, 0, 0 );
}

/**
* Site timezone.
*
* @return DateTimeZone
*/
private function site_timezone(): DateTimeZone {
return wp_timezone();
}
}
Loading
Loading