Reports. Matches the real the reference product Reports screen's
* structure: Overview / Payments / Submissions sub-tabs, a form + date-range
* filter, stat tiles, and real charts computed from our own entries/payments
* data — including Completion Rates (a genuine started-vs-submitted signal,
* see Form_Start), Submissions By Country (from each form's own Country List
* field answers, not IP geolocation — ChargeForms has no GeoIP data source),
* and a Submission Timeline Patterns heatmap (derived straight from
* entries.created_at, no new tracking needed).
*/
class Reports_Page {
const SUBTABS = [ 'overview', 'payments', 'submissions' ];
public function __construct() {
add_action( 'admin_menu', [ $this, 'register' ] );
add_action( 'admin_post_chargeforms_export_report', [ $this, 'handle_export' ] );
}
public function register() {
add_submenu_page(
'chargeforms',
__( 'Reports', 'chargeforms' ),
__( 'Reports', 'chargeforms' ),
'chargeforms_manage_forms',
'chargeforms-reports',
[ $this, 'render' ]
);
}
private function current_subtab() {
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return in_array( $tab, self::SUBTABS, true ) ? $tab : 'overview';
}
/**
* Same preset resolution used on Entries/Forms — default here is
* 'last_month' to match the real Reports screen's default filter.
*
* roadmap issue #27 ("Date Handling Mixes GMT and Site Time"): `$today`
* must be computed in GMT, not site-local time, because every actual
* date-range comparison this resolves into (Entry::for_form()'s
* date_from/date_to filters, Form_Start's counts) is built with
* gmdate() against `created_at` columns whose value comes from
* MySQL's own CURRENT_TIMESTAMP default — a site-local "today" from
* current_time('Y-m-d') would silently shift which entries fall in
* "today"/"this week"/etc. by the site's UTC offset, worse the further
* a site's timezone is from UTC.
*/
private function resolve_date_preset( $preset, $custom_from, $custom_to ) {
$today = current_time( 'Y-m-d', true );
switch ( $preset ) {
case 'today':
return [ $today, $today ];
case 'yesterday':
$y = gmdate( 'Y-m-d', strtotime( $today . ' -1 day' ) );
return [ $y, $y ];
case 'last_week':
return [ gmdate( 'Y-m-d', strtotime( $today . ' -7 days' ) ), $today ];
// the reference product's real Reports date-range quick-select (DateRangeControls.vue)
// also offers 3/6-month and 1-year presets — ChargeForms previously
// stopped at "Last month", a real parity gap for anyone reviewing a
// longer trend than 30 days.
case 'last_3_months':
return [ gmdate( 'Y-m-d', strtotime( $today . ' -90 days' ) ), $today ];
case 'last_6_months':
return [ gmdate( 'Y-m-d', strtotime( $today . ' -182 days' ) ), $today ];
case 'last_year':
return [ gmdate( 'Y-m-d', strtotime( $today . ' -365 days' ) ), $today ];
case 'custom':
return [ $custom_from, $custom_to ];
case 'all':
return [ '', '' ];
default: // 'last_month'
return [ gmdate( 'Y-m-d', strtotime( $today . ' -30 days' ) ), $today ];
}
}
/**
* Returns the equal-length window immediately before the given range,
* used for period-over-period change badges. When no real range is
* active (e.g. "All time"), returns [ '', '' ] so callers can skip
* the comparison.
*/
private function prior_period( $date_from, $date_to ) {
if ( ! $date_from || ! $date_to ) {
return [ '', '' ];
}
$days = max( 1, ( strtotime( $date_to ) - strtotime( $date_from ) ) / DAY_IN_SECONDS + 1 );
$prior_to = gmdate( 'Y-m-d', strtotime( $date_from . ' -1 day' ) );
return [ gmdate( 'Y-m-d', strtotime( $prior_to . ' -' . ( $days - 1 ) . ' days' ) ), $prior_to ];
}
public function render() {
$subtab = $this->current_subtab();
$forms = Form::all();
$form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$date_preset = isset( $_GET['date_preset'] ) ? sanitize_key( wp_unslash( $_GET['date_preset'] ) ) : 'last_month'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
list( $date_from, $date_to ) = $this->resolve_date_preset(
$date_preset,
isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '' // phpcs:ignore WordPress.Security.NonceVerification.Recommended
);
?>
__( 'A brief look at your overall form performance', 'chargeforms' ),
'payments' => __( 'A brief look at all payments', 'chargeforms' ),
'submissions' => __( 'A brief look at submission performance', 'chargeforms' ),
];
?>
Miscellaneous to see Reports again.', 'chargeforms' ); ?>
render_category_header( 'bar-chart', __( 'Key Metrics', 'chargeforms' ), __( 'The headline numbers for this period.', 'chargeforms' ), 'primary' ); ?>
render_change_badge( $total_change ); ?>
render_change_badge( $views_change ); ?>
render_change_badge( $conversion_change ); ?>
render_change_badge( $revenue_change ); ?>
render_category_header( 'trending-up', __( 'Submission Trends', 'chargeforms' ), __( 'Volume over time, and how far visitors get before submitting.', 'chargeforms' ), 'teal' ); ?>
render_category_header( 'users', __( 'Audience Insights', 'chargeforms' ), __( 'Who is submitting, and from what kind of device.', 'chargeforms' ), 'purple' ); ?>
render_category_header( 'list', __( 'Field Analytics', 'chargeforms' ), __( 'Every chartable field on the form, shown at once.', 'chargeforms' ), 'blue' ); ?>
render_field_analytics( $form_id, $forms ); ?>
render_category_header( 'shield', __( 'Integration Deliveries', 'chargeforms' ), __( 'How outbound integrations — Slack, webhooks, and the rest — performed.', 'chargeforms' ), 'green' ); ?>
render_integration_deliveries_card(); ?>
$count ) : ?>
'',
'shield' => '',
'inbox' => '',
'document' => '',
'trending-up' => '',
'trending-down' => '',
'trophy' => '',
'users' => '',
'clock' => '',
'check-ring' => '',
'money' => '',
'list' => '',
];
return '';
}
/**
* Renders a Key Metrics stat tile's trend sparkline as a real
* polyline over $values (chronological order) — approved-redesign
* detail, but only drawn where a real daily series backs it
* (Submissions has one via counts_by_day_and_status(); Form Views/
* Conversion/Revenue don't have a daily series computed anywhere in
* this codebase, so no sparkline is faked for them rather than
* inventing numbers to fill the shape).
*/
private function render_sparkline( array $values, $color ) {
$values = array_values( $values );
$count = count( $values );
if ( $count < 2 ) {
return;
}
$width = 64;
$height = 26;
$max = max( 1, ...$values );
$min = min( $values );
$range = max( 1, $max - $min );
$points = [];
foreach ( $values as $i => $v ) {
$x = round( ( $i / ( $count - 1 ) ) * $width, 1 );
$y = round( $height - ( ( $v - $min ) / $range ) * ( $height - 4 ) - 2, 1 );
$points[] = "{$x} {$y}";
}
printf(
'',
(int) $width,
(int) $height,
esc_attr( implode( ' L', $points ) ),
esc_attr( $color )
);
}
/**
* A labeled section header — grouping the report widgets below it by
* what they're actually about (trends, performance, audience,
* timing) instead of one undifferentiated stack of cards. Each
* category gets its own accent color so the sections read as visually
* distinct at a glance, not just separated by a text label.
*/
/**
* Restored the icon chip the mockup's own "Key Metrics"/"Submission
* Trends" section headers actually use (30x30, 8px radius, ~10%-opacity
* accent tint, icon in the solid accent color) — a prior pass dropped
* this in favor of "the reference product's real" plain-text-with-underline heading,
* which is the wrong authority per the .dc.html-over-everything-else
* rule. Colors for the two mockup-confirmed accents (primary, teal)
* are exact matches; amber/purple/indigo back this plugin's own extra
* category headers (Performance/Audience/Activity) that group cards
* beyond what the mockup itself splits out, so those three are a
* reasonable extension of the same system rather than a sourced value.
*/
private function render_category_header( $icon, $title, $description = '', $accent = 'primary' ) {
$colors = [
'primary' => '#6c4ef5',
'teal' => '#00b27f',
'amber' => '#fcbe2d',
'purple' => '#8b5cf6',
'indigo' => '#6366f1',
'blue' => '#1a7efb',
'green' => '#00a86b',
];
$color = $colors[ $accent ] ?? $colors['primary'];
?>
· ' . esc_html__( 'No prior period', 'chargeforms' ) . '';
return;
}
$is_up = $change >= 0;
printf(
'%s %s%%',
esc_attr( $is_up ? 'is-up' : 'is-down' ),
wp_kses( self::icon_svg( $is_up ? 'trending-up' : 'trending-down' ), Icon::allowed_svg_html() ),
esc_html( ( $is_up ? '+' : '' ) . $change )
);
}
/**
* Completion Rate - a real submitted-vs-started ratio, not a
* placeholder. "Starts" come from Form_Start (see class-form-start.php
* and the tracking snippet in class-shortcode.php), a real signal
* recorded the first time a visitor actually interacts with a field -
* distinct from a plain page view.
*/
private function render_completion_rate_card( $rate, $starts, $submissions ) {
// Horizontal bar (matches the design handoff: a 40px violet rate,
// a 10px track/fill, and started/submitted counts beneath), not a
// semi-circle gauge. The rate stays a real submitted-vs-started
// ratio from Form_Start tracking, never a view-count guess.
?>
' . esc_html__( 'No forms yet.', 'chargeforms' ) . '
';
return;
}
// roadmap issue #36 ("Reports May Become Expensive on Large Entry
// Tables"): this used to run Entry::query() + Form_Start::
// count_for_form() ONCE PER FORM to find the top 5 — two GROUP BY
// queries covering every form on the site, regardless of how many
// there are, replaces what used to be 2 * count($forms) queries.
$form_ids = array_column( $forms, 'id' );
$submission_counts = Entry::count_by_form( $form_ids, $date_from, $date_to );
$view_counts = \ChargeForms\Includes\Form_Start::count_by_form( $form_ids, 'view', $date_from, $date_to );
$ranked = array_map( function ( $form ) use ( $submission_counts, $view_counts ) {
$form['submissions'] = $submission_counts[ $form['id'] ] ?? 0;
$form['views'] = $view_counts[ $form['id'] ] ?? 0;
return $form;
}, $forms );
usort( $ranked, fn( $a, $b ) => $b['submissions'] <=> $a['submissions'] );
$ranked = array_slice( $ranked, 0, 5 );
if ( 0 === array_sum( array_column( $ranked, 'submissions' ) ) && 0 === array_sum( array_column( $ranked, 'views' ) ) ) {
echo '