From 1c9dca3d192d1d8db897c888ea93a2a3005d2122 Mon Sep 17 00:00:00 2001 From: alaca Date: Tue, 21 Apr 2026 16:12:46 +0200 Subject: [PATCH 01/11] initial commit --- assets/css/analytics.scss | 231 +++++++++++++ assets/js/analytics.js | 317 +++++++++++++++-- includes/admin/templates/analytics.php | 85 +++++ includes/class-mailchimp-form-performance.php | 318 ++++++++++++++++++ mailchimp.php | 5 + 5 files changed, 922 insertions(+), 34 deletions(-) create mode 100644 includes/class-mailchimp-form-performance.php diff --git a/assets/css/analytics.scss b/assets/css/analytics.scss index f4470f9..da45db8 100644 --- a/assets/css/analytics.scss +++ b/assets/css/analytics.scss @@ -311,6 +311,237 @@ $mq-xs: 600px; } } +// ----------------------------------------------------------------------------- +// Form performance section +// ----------------------------------------------------------------------------- +.mailchimp-sf-fp { + + &__body { + margin-top: 24px; + + &[hidden] { + display: none; + } + } + + &__chart { + min-width: 0; + } + + &__chart-heading { + margin-bottom: 8px; + } + + &__chart-title { + color: var(--mc-sa-text-strong); + font-size: 16px; + font-weight: 500; + margin: 0 0 4px; + } + + &__chart-subtitle { + color: var(--mc-sa-text-muted); + font-size: 12px; + font-weight: 400; + line-height: 1.35; + margin: 0 0 16px; + } + + &__canvas-wrap { + height: 340px; + position: relative; + width: 100%; + + canvas { + display: block; + height: 100% !important; + width: 100% !important; + } + } + + &__canvas { + position: relative; + transition: opacity 150ms ease; + z-index: 1; + } + + /* + * Skeleton bars — mirror the subscriber activity loading pattern for + * visual consistency between the two analytics cards. Bars live in the + * top ~60% so the overlay copy below isn't covered; the chart-area grid + * lives on canvas-wrap itself. + */ + &__skeleton-bars { + align-items: flex-end; + bottom: 42%; + display: none; + gap: 16px; + justify-content: center; + left: 0; + padding: 4px 0 0; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + + span { + background: var(--mc-sa-border); + border-radius: 2px; + display: block; + width: 18px; + } + + span:nth-child(1) { + height: 96px; + } + + span:nth-child(2) { + height: 53px; + } + + span:nth-child(3) { + height: 122px; + } + + span:nth-child(4) { + height: 80px; + } + + span:nth-child(5) { + height: 40px; + } + } + + &__overlay { + align-items: flex-start; + bottom: 0; + color: #666666; + display: none; + font-size: 14px; + font-weight: 500; + height: 40%; + justify-content: center; + left: 0; + padding: 28px 16px 0; + pointer-events: none; + position: absolute; + right: 0; + text-align: center; + z-index: 2; + } + + &__error-banner { + align-items: center; + background: var(--mc-sa-error-bg); + border: 1px solid var(--mc-sa-error-border); + border-radius: 8px; + display: flex; + gap: 12px; + margin: 4px 0 16px; + padding: 12px 14px; + + &[hidden] { + display: none; + } + } + + &__error-banner-icon { + align-items: center; + color: var(--mc-sa-error-text); + display: inline-flex; + flex-shrink: 0; + justify-content: center; + line-height: 0; + } + + &__error-banner-body { + flex: 1 1 auto; + min-width: 0; + } + + &__error-banner-title { + color: var(--mc-sa-error-title); + font-size: 13px; + font-weight: 600; + line-height: 1.3; + margin: 0 0 2px; + } + + &__error-banner-message { + color: var(--mc-sa-error-text); + font-size: 13px; + line-height: 1.4; + margin: 0; + } + + &__error-banner-action { + flex-shrink: 0; + } + + &.is-loading, + &.is-empty, + &.is-error { + + .mailchimp-sf-fp__canvas { + opacity: 0; + } + + .mailchimp-sf-fp__canvas-wrap { + background-image: + linear-gradient(to right, var(--mc-sa-skeleton-grid) 1px, transparent 1px), + linear-gradient(to bottom, var(--mc-sa-skeleton-grid) 1px, transparent 1px); + background-position: 0 100%; + background-size: calc(100% / 7) calc(100% / 8); + } + + .mailchimp-sf-fp__skeleton-bars { + display: flex; + } + } + + &.is-loading, + &.is-empty { + + .mailchimp-sf-fp__overlay { + display: flex; + } + } + + &.is-loading { + + .mailchimp-sf-fp__skeleton-bars span, + .mailchimp-sf-fp__overlay { + animation: mailchimp-sf-sa-pulse 1.4s ease-in-out infinite; + } + } + + button#mailchimp-sf-fp-error-retry { + background-color: #fff; + border: 1px solid #ccd6dc; + + &:hover, + &:focus, + &:active { + background-color: #f6f7f7; + } + } +} + +@media (prefers-reduced-motion: reduce) { + + .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__skeleton-bars span, + .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__overlay { + animation: none; + } +} + +@media screen and (max-width: $mq-xs) { + + .mailchimp-sf-fp__canvas-wrap { + height: 240px; + } +} + // ----------------------------------------------------------------------------- // Subscriber activity section // ----------------------------------------------------------------------------- diff --git a/assets/js/analytics.js b/assets/js/analytics.js index 31c0a7e..3039d87 100644 --- a/assets/js/analytics.js +++ b/assets/js/analytics.js @@ -359,50 +359,299 @@ import { __ } from '@wordpress/i18n'; }); /** - * Fetch analytics data via AJAX and update the content area. - * - * @param {object} detail Event detail with from, to, listId. + * Forms performance over time */ - function fetchAnalyticsData(detail) { - if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) { + (function formPerformanceModule() { + const section = document.querySelector('[data-section="form-performance"]'); + if (!section) { return; } - const contentArea = document.getElementById('mailchimp-sf-analytics-content'); - if (!contentArea) { - return; + const lineCanvas = document.getElementById('mailchimp-sf-fp-line'); + const dateRangeEl = document.getElementById('mailchimp-sf-fp-daterange'); + const overlayEl = document.getElementById('mailchimp-sf-fp-overlay'); + const errorBannerEl = document.getElementById('mailchimp-sf-fp-error-banner'); + const errorMessageEl = document.getElementById('mailchimp-sf-fp-error-message'); + const retryBtnEl = document.getElementById('mailchimp-sf-fp-error-retry'); + + const SERIES_COLORS = [ + { border: '#2B72FB', fill: 'rgba(43, 114, 251, 0.12)' }, + { border: '#17B890', fill: 'rgba(23, 184, 144, 0.12)' }, + { border: '#F59E0B', fill: 'rgba(245, 158, 11, 0.12)' }, + { border: '#8B5CF6', fill: 'rgba(139, 92, 246, 0.12)' }, + { border: '#EC4899', fill: 'rgba(236, 72, 153, 0.12)' }, + { border: '#14B8A6', fill: 'rgba(20, 184, 166, 0.12)' }, + ]; + + const COLORS = { + gridLine: 'rgba(0, 0, 0, 0.06)', + text: '#6B7280', + }; + + const STRINGS = { + loadingSubtitle: __('Loading form performance…', 'mailchimp'), + loadingOverlay: __('Loading form performance…', 'mailchimp'), + emptySubtitle: __('No submissions recorded for the selected date range', 'mailchimp'), + emptyOverlay: __('No data available for this date range', 'mailchimp'), + errorDefault: __( + 'Unable to load data for the selected date range. Please check your connection and try again.', + 'mailchimp', + ), + }; + + const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error']; + + let lineChart = null; + let inFlight = null; + let lastDetail = null; + + function setState(state) { + STATE_CLASSES.forEach(function (cls) { + section.classList.toggle(cls, cls === `is-${state}`); + }); } - const formData = new FormData(); - formData.append('action', 'mailchimp_sf_get_analytics'); - formData.append('nonce', window.mailchimpSFAnalytics.nonce); - formData.append('list_id', detail.listId); - formData.append('start_date', detail.from); - formData.append('end_date', detail.to); - - fetch(window.mailchimpSFAnalytics.ajax_url, { - method: 'POST', - body: formData, - credentials: 'same-origin', - }) - .then(function (response) { - return response.json(); - }) - .then(function (response) { - if (!response.success) { - return; + function colorForIndex(i) { + return SERIES_COLORS[i % SERIES_COLORS.length]; + } + + function setOverlay(text) { + if (overlayEl) { + overlayEl.textContent = text || ''; + } + } + + function setSubtitle(text) { + if (dateRangeEl) { + dateRangeEl.textContent = text || ''; + } + } + + function destroyCharts() { + if (lineChart) { + lineChart.destroy(); + lineChart = null; + } + } + + function formatRangeLabel(from, to) { + try { + const fromDate = new Date(`${from}T00:00:00`); + const toDate = new Date(`${to}T00:00:00`); + const fmt = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + return `${fmt.format(fromDate)} – ${fmt.format(toDate)}`; + } catch (err) { + return `${from} – ${to}`; + } + } + + function setErrorBanner(visible, message) { + if (!errorBannerEl) { + return; + } + if (visible) { + if (errorMessageEl) { + errorMessageEl.textContent = message || STRINGS.errorDefault; } + errorBannerEl.hidden = false; + } else { + errorBannerEl.hidden = true; + } + } + + function showLoading() { + destroyCharts(); + setErrorBanner(false); + setOverlay(STRINGS.loadingOverlay); + setSubtitle(STRINGS.loadingSubtitle); + setState('loading'); + } + + function showEmpty() { + destroyCharts(); + setErrorBanner(false); + setOverlay(STRINGS.emptyOverlay); + setSubtitle(STRINGS.emptySubtitle); + setState('empty'); + } + + function showError(message) { + destroyCharts(); + setOverlay(''); + if (lastDetail && lastDetail.from && lastDetail.to) { + setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to)); + } + setErrorBanner(true, message); + setState('error'); + } + + function renderLine(labels, series) { + if (!lineCanvas || typeof window.Chart === 'undefined') { + return; + } + + const datasets = series.map(function (s, i) { + const color = colorForIndex(i); + return { + label: s.label, + data: s.values, + borderColor: color.border, + backgroundColor: color.fill, + borderWidth: 2, + pointBackgroundColor: color.border, + pointBorderColor: color.border, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.1, + fill: false, + }; + }); + + lineChart = new window.Chart(lineCanvas.getContext('2d'), { + type: 'line', + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + position: 'top', + align: 'center', + labels: { + usePointStyle: true, + pointStyle: 'rectRounded', + boxWidth: 10, + boxHeight: 10, + padding: 16, + color: COLORS.text, + }, + }, + tooltip: { + callbacks: { + label(ctx) { + const value = ctx.parsed.y || 0; + return `${ctx.dataset.label}: ${value}`; + }, + }, + }, + }, + scales: { + x: { + grid: { + color: COLORS.gridLine, + drawBorder: false, + drawTicks: false, + }, + ticks: { color: COLORS.text }, + }, + y: { + beginAtZero: true, + grid: { color: COLORS.gridLine, drawBorder: false }, + ticks: { color: COLORS.text, precision: 0 }, + }, + }, + }, + }); + } + + function render(payload, fromLabel, toLabel) { + destroyCharts(); + setErrorBanner(false); + + const series = Array.isArray(payload.series) ? payload.series : []; + const totalSubs = payload.total_subs || 0; + + if (series.length === 0 || totalSubs === 0) { + showEmpty(); + return; + } + + setSubtitle(formatRangeLabel(fromLabel, toLabel)); + setOverlay(''); + setState('ready'); + renderLine(payload.labels || [], series); + } - const { data } = response; - contentArea.innerHTML = JSON.stringify(data); + function fetchPerformance(detail) { + if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) { + showError(); + return; + } + if (!detail || !detail.listId || !detail.from || !detail.to) { + showEmpty(); + return; + } + + lastDetail = { + listId: detail.listId, + from: detail.from, + to: detail.to, + }; + + if (inFlight && typeof inFlight.abort === 'function') { + inFlight.abort(); + } + + const controller = + typeof window.AbortController !== 'undefined' ? new AbortController() : null; + inFlight = controller; + + const formData = new FormData(); + formData.append('action', 'mailchimp_sf_get_form_performance'); + formData.append('nonce', window.mailchimpSFAnalytics.nonce); + formData.append('list_id', detail.listId); + formData.append('date_from', detail.from); + formData.append('date_to', detail.to); + + showLoading(); + + fetch(window.mailchimpSFAnalytics.ajax_url, { + method: 'POST', + body: formData, + credentials: 'same-origin', + signal: controller ? controller.signal : undefined, }) - .catch(function () {}); - } + .then(function (response) { + return response.json().catch(function () { + return null; + }); + }) + .then(function (body) { + inFlight = null; + if (!body || body.success !== true || !body.data) { + const message = + body && body.data && body.data.message ? body.data.message : ''; + showError(message); + return; + } + render(body.data, detail.from, detail.to); + }) + .catch(function (err) { + if (err && err.name === 'AbortError') { + return; + } + inFlight = null; + showError(); + }); + } - // Listen for analytics refresh events. - document.addEventListener('mailchimp-analytics-refresh', function (e) { - fetchAnalyticsData(e.detail); - }); + if (retryBtnEl) { + retryBtnEl.addEventListener('click', function () { + if (lastDetail) { + fetchPerformance(lastDetail); + } + }); + } + + document.addEventListener('mailchimp-analytics-refresh', function (e) { + fetchPerformance(e.detail); + }); + })(); /** * Subscriber change over time — diverging bar + totals donut. diff --git a/includes/admin/templates/analytics.php b/includes/admin/templates/analytics.php index efd1bd4..fab0418 100644 --- a/includes/admin/templates/analytics.php +++ b/includes/admin/templates/analytics.php @@ -100,6 +100,91 @@ +
+
+

+ +

+

+
+ +
+

+ +

+

+ +

+
+ + + +
+
+
+ + +
+
+
+
+
+
esc_html__( 'Unauthorized.', 'mailchimp' ) ), 403 ); + } + + check_ajax_referer( 'mailchimp_sf_analytics_admin_nonce', 'nonce' ); + + $list_id = isset( $_POST['list_id'] ) ? sanitize_text_field( wp_unslash( $_POST['list_id'] ) ) : ''; + $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( wp_unslash( $_POST['date_from'] ) ) : ''; + $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( wp_unslash( $_POST['date_to'] ) ) : ''; + + if ( empty( $list_id ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Please select a list.', 'mailchimp' ) ), 400 ); + } + + if ( ! $this->is_valid_date( $date_from ) || ! $this->is_valid_date( $date_to ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Invalid date range.', 'mailchimp' ) ), 400 ); + } + + if ( strtotime( $date_from ) > strtotime( $date_to ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Start date must be before end date.', 'mailchimp' ) ), 400 ); + } + + $rows = $this->fetch_rows( $list_id, $date_from, $date_to ); + + if ( ! is_array( $rows ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Unable to load form analytics.', 'mailchimp' ) ), 500 ); + } + + $response = $this->aggregate( $rows, $date_from, $date_to ); + + wp_send_json_success( $response ); + } + + /** + * Fetch raw daily rows from the analytics table grouped by form_id and date. + * + * @param string $list_id List ID. + * @param string $date_from `Y-m-d`. + * @param string $date_to `Y-m-d`. + * @return array Rows of `{ form_id, event_date, views, submissions }`. + */ + public function fetch_rows( string $list_id, string $date_from, string $date_to ): array { + global $wpdb; + + $table_name = Mailchimp_Analytics_Data::get_table_name(); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT form_id, event_date, SUM(views) AS views, SUM(submissions) AS submissions + FROM {$table_name} + WHERE list_id = %s AND event_date BETWEEN %s AND %s + GROUP BY form_id, event_date + ORDER BY event_date ASC", + $list_id, + $date_from, + $date_to + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + return is_array( $results ) ? $results : array(); + } + + /** + * Filter raw rows into per-form series bucketed by interval. + * + * Missing bucket/form intersections are back-filled with zeros so every + * series has the same length as the chart's x-axis — keeping line charts + * aligned even when a given form had no submissions on some days. + * + * @param array $rows Rows from `fetch_rows()`. + * @param string $date_from `Y-m-d`. + * @param string $date_to `Y-m-d`. + * @return array Response payload. + */ + public function aggregate( array $rows, string $date_from, string $date_to ): array { + $tz = wp_timezone(); + $from_dt = new DateTimeImmutable( $date_from, $tz ); + $to_dt = new DateTimeImmutable( $date_to, $tz ); + + $requested_days = (int) $from_dt->diff( $to_dt )->days + 1; + $interval = $this->get_interval( $requested_days ); + + // Build the complete ordered set of bucket keys covering the range so + // every form series aligns with the same x-axis labels. + $labels_by_key = array(); + $one_day = new DateInterval( 'P1D' ); + $cursor = $from_dt; + while ( $cursor <= $to_dt ) { + $date = $cursor->format( 'Y-m-d' ); + $key = $this->get_bucket_key( $date, $interval, $tz ); + $labels_by_key[ $key ] = $this->get_bucket_label( $date, $interval, $tz ); + $cursor = $cursor->add( $one_day ); + } + ksort( $labels_by_key ); + + $empty_buckets = array_fill_keys( array_keys( $labels_by_key ), 0 ); + + // Group rows by form_id, summing submissions into the appropriate bucket. + $forms = array(); + $total_subs = 0; + $total_views = 0; + foreach ( $rows as $row ) { + $form_id = isset( $row['form_id'] ) ? (string) $row['form_id'] : ''; + $date = isset( $row['event_date'] ) ? (string) $row['event_date'] : ''; + $views = isset( $row['views'] ) ? (int) $row['views'] : 0; + $submissions = isset( $row['submissions'] ) ? (int) $row['submissions'] : 0; + + if ( '' === $date ) { + continue; + } + + $key = $this->get_bucket_key( $date, $interval, $tz ); + if ( ! array_key_exists( $key, $empty_buckets ) ) { + continue; + } + + if ( ! isset( $forms[ $form_id ] ) ) { + $forms[ $form_id ] = array( + 'form_id' => $form_id, + 'label' => $this->get_form_label( $form_id ), + 'values' => $empty_buckets, + 'total_submissions' => 0, + 'total_views' => 0, + ); + } + + $forms[ $form_id ]['values'][ $key ] += $submissions; + $forms[ $form_id ]['total_submissions'] += $submissions; + $forms[ $form_id ]['total_views'] += $views; + + $total_subs += $submissions; + $total_views += $views; + } + + // Convert each form's bucket map into an ordered numeric array + $series = array(); + foreach ( $forms as $form ) { + $series[] = array( + 'form_id' => $form['form_id'], + 'label' => $form['label'], + 'total_submissions' => $form['total_submissions'], + 'total_views' => $form['total_views'], + 'values' => array_values( $form['values'] ), + ); + } + + usort( + $series, + function ( $a, $b ) { + return $b['total_submissions'] <=> $a['total_submissions']; + } + ); + + return array( + 'interval' => $interval, + 'labels' => array_values( $labels_by_key ), + 'series' => $series, + 'total_subs' => $total_subs, + 'total_views' => $total_views, + ); + } + + /** + * Human-readable label for a form_id. + * + * Per-form identification is tracked via `form_id` in the analytics table + * but the current tracking layer (shortcode + block forms) writes empty + * IDs, so existing rows collapse into a single "All forms" series. Once + * per-form tracking lands, this method is the single place to resolve a + * form ID to its display label (block title / shortcode caption / etc.). + * + * @param string $form_id Form identifier. + * @return string + */ + private function get_form_label( string $form_id ): string { + if ( '' === $form_id ) { + return esc_html__( 'All forms', 'mailchimp' ); + } + + return sprintf( + /* translators: %s: form identifier */ + esc_html__( 'Form %s', 'mailchimp' ), + $form_id + ); + } + + /** + * Pick an aggregation interval based on the requested range. + * + * @param int $days Inclusive day count of the requested range. + * @return string One of `daily|weekly|monthly|quarterly|yearly`. + */ + public function get_interval( int $days ): string { + if ( $days <= 30 ) { + return 'daily'; + } + if ( $days <= 90 ) { + return 'weekly'; + } + if ( $days <= 365 ) { + return 'monthly'; + } + if ( $days <= 365 * 3 ) { + return 'quarterly'; + } + return 'yearly'; + } + + /** + * Build a stable sort key for the bucket a given date falls into. + * + * @param string $date `Y-m-d`. + * @param string $interval Interval name. + * @param DateTimeZone|null $tz Timezone. + * @return string + */ + public function get_bucket_key( string $date, string $interval, $tz = null ): string { + $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); + $dt = new DateTimeImmutable( $date, $tz ); + + switch ( $interval ) { + case 'weekly': + // ISO week starts on Monday. + return $dt->format( 'o-\WW' ); + case 'monthly': + return $dt->format( 'Y-m' ); + case 'quarterly': + $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); + return $dt->format( 'Y' ) . '-Q' . $quarter; + case 'yearly': + return $dt->format( 'Y' ); + case 'daily': + default: + return $dt->format( 'Y-m-d' ); + } + } + + /** + * Build a human-readable label for the bucket a given date falls into. + * + * @param string $date `Y-m-d`. + * @param string $interval Interval name. + * @param DateTimeZone|null $tz Timezone. + * @return string + */ + public function get_bucket_label( string $date, string $interval, $tz = null ): string { + $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); + $dt = new DateTimeImmutable( $date, $tz ); + + switch ( $interval ) { + case 'weekly': + $monday = $dt->modify( 'monday this week' ); + if ( $monday > $dt ) { + $monday = $dt->modify( 'monday last week' ); + } + return wp_date( 'M j', $monday->getTimestamp(), $tz ); + case 'monthly': + return wp_date( 'M Y', $dt->getTimestamp(), $tz ); + case 'quarterly': + $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); + return 'Q' . $quarter . ' ' . $dt->format( 'Y' ); + case 'yearly': + return $dt->format( 'Y' ); + case 'daily': + default: + return wp_date( 'M j', $dt->getTimestamp(), $tz ); + } + } + + /** + * Validate a `Y-m-d` date string. + * + * @param string $date Candidate date string. + * @return bool + */ + private function is_valid_date( string $date ): bool { + if ( '' === $date ) { + return false; + } + $dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); + return $dt && $dt->format( 'Y-m-d' ) === $date; + } +} diff --git a/mailchimp.php b/mailchimp.php index 40b1a2d..8dc6298 100644 --- a/mailchimp.php +++ b/mailchimp.php @@ -125,6 +125,11 @@ function () { $subscriber_activity = new Mailchimp_Subscriber_Activity(); $subscriber_activity->init(); +// Form performance (local analytics DB) data class. +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-form-performance.php'; +$form_performance = new Mailchimp_Form_Performance(); +$form_performance->init(); + // Deprecated functions. require_once plugin_dir_path( __FILE__ ) . 'includes/mailchimp-deprecated-functions.php'; From 91670ca9e736575bd0b6ea593bd01fd9c28c0827 Mon Sep 17 00:00:00 2001 From: alaca Date: Wed, 22 Apr 2026 11:35:58 +0200 Subject: [PATCH 02/11] refactor: match design in figma --- assets/css/analytics.scss | 20 ++- assets/js/analytics.js | 176 ++++++++++++++++++------- includes/admin/templates/analytics.php | 4 +- 3 files changed, 145 insertions(+), 55 deletions(-) diff --git a/assets/css/analytics.scss b/assets/css/analytics.scss index da45db8..3c37c4c 100644 --- a/assets/css/analytics.scss +++ b/assets/css/analytics.scss @@ -413,20 +413,20 @@ $mq-xs: 600px; } &__overlay { - align-items: flex-start; + align-items: center; bottom: 0; color: #666666; display: none; font-size: 14px; font-weight: 500; - height: 40%; justify-content: center; left: 0; - padding: 28px 16px 0; + padding: 16px; pointer-events: none; position: absolute; right: 0; text-align: center; + top: 0; z-index: 2; } @@ -493,10 +493,6 @@ $mq-xs: 600px; background-position: 0 100%; background-size: calc(100% / 7) calc(100% / 8); } - - .mailchimp-sf-fp__skeleton-bars { - display: flex; - } } &.is-loading, @@ -509,6 +505,16 @@ $mq-xs: 600px; &.is-loading { + .mailchimp-sf-fp__skeleton-bars { + display: flex; + } + + .mailchimp-sf-fp__overlay { + align-items: flex-start; + padding-top: 28px; + top: 60%; + } + .mailchimp-sf-fp__skeleton-bars span, .mailchimp-sf-fp__overlay { animation: mailchimp-sf-sa-pulse 1.4s ease-in-out infinite; diff --git a/assets/js/analytics.js b/assets/js/analytics.js index 3039d87..85209e7 100644 --- a/assets/js/analytics.js +++ b/assets/js/analytics.js @@ -367,25 +367,25 @@ import { __ } from '@wordpress/i18n'; return; } - const lineCanvas = document.getElementById('mailchimp-sf-fp-line'); + const chartCanvas = document.getElementById('mailchimp-sf-fp-line'); const dateRangeEl = document.getElementById('mailchimp-sf-fp-daterange'); const overlayEl = document.getElementById('mailchimp-sf-fp-overlay'); const errorBannerEl = document.getElementById('mailchimp-sf-fp-error-banner'); const errorMessageEl = document.getElementById('mailchimp-sf-fp-error-message'); const retryBtnEl = document.getElementById('mailchimp-sf-fp-error-retry'); - const SERIES_COLORS = [ - { border: '#2B72FB', fill: 'rgba(43, 114, 251, 0.12)' }, - { border: '#17B890', fill: 'rgba(23, 184, 144, 0.12)' }, - { border: '#F59E0B', fill: 'rgba(245, 158, 11, 0.12)' }, - { border: '#8B5CF6', fill: 'rgba(139, 92, 246, 0.12)' }, - { border: '#EC4899', fill: 'rgba(236, 72, 153, 0.12)' }, - { border: '#14B8A6', fill: 'rgba(20, 184, 166, 0.12)' }, - ]; - const COLORS = { + viewsFill: '#3B82F6', + viewsBorder: '#2563EB', + submissionsFill: '#2DD4BF', + submissionsBorder: '#14B8A6', + rateBorder: '#EAB308', gridLine: 'rgba(0, 0, 0, 0.06)', text: '#6B7280', + // Legend chip fills — translucent version of each bar color so the + // legend markers match the outlined-chip style from the Figma spec. + viewsLegendFill: 'rgba(59, 130, 246, 0.35)', + submissionsLegendFill: 'rgba(45, 212, 191, 0.35)', }; const STRINGS = { @@ -397,11 +397,14 @@ import { __ } from '@wordpress/i18n'; 'Unable to load data for the selected date range. Please check your connection and try again.', 'mailchimp', ), + views: __('Form Views', 'mailchimp'), + submissions: __('Submissions', 'mailchimp'), + conversionRate: __('Conversion Rate', 'mailchimp'), }; const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error']; - let lineChart = null; + let chart = null; let inFlight = null; let lastDetail = null; @@ -411,10 +414,6 @@ import { __ } from '@wordpress/i18n'; }); } - function colorForIndex(i) { - return SERIES_COLORS[i % SERIES_COLORS.length]; - } - function setOverlay(text) { if (overlayEl) { overlayEl.textContent = text || ''; @@ -428,9 +427,9 @@ import { __ } from '@wordpress/i18n'; } function destroyCharts() { - if (lineChart) { - lineChart.destroy(); - lineChart = null; + if (chart) { + chart.destroy(); + chart = null; } } @@ -489,31 +488,74 @@ import { __ } from '@wordpress/i18n'; setState('error'); } - function renderLine(labels, series) { - if (!lineCanvas || typeof window.Chart === 'undefined') { + /** + * @param {Array} rows Payload `data` rows from the API. + */ + function renderChart(rows) { + if (!chartCanvas || typeof window.Chart === 'undefined') { return; } - const datasets = series.map(function (s, i) { - const color = colorForIndex(i); - return { - label: s.label, - data: s.values, - borderColor: color.border, - backgroundColor: color.fill, - borderWidth: 2, - pointBackgroundColor: color.border, - pointBorderColor: color.border, - pointRadius: 3, - pointHoverRadius: 5, - tension: 0.1, - fill: false, - }; + const labels = rows.map(function (r) { + return r.label; + }); + const views = rows.map(function (r) { + return r.views || 0; + }); + const submissions = rows.map(function (r) { + return r.submissions || 0; + }); + const rate = rows.map(function (r) { + return r.conversion_rate || 0; }); - lineChart = new window.Chart(lineCanvas.getContext('2d'), { - type: 'line', - data: { labels, datasets }, + chart = new window.Chart(chartCanvas.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [ + { + type: 'bar', + label: STRINGS.views, + data: views, + backgroundColor: COLORS.viewsFill, + borderColor: COLORS.viewsBorder, + borderWidth: 0, + borderRadius: 0, + maxBarThickness: 22, + order: 2, + yAxisID: 'y', + }, + { + type: 'bar', + label: STRINGS.submissions, + data: submissions, + backgroundColor: COLORS.submissionsFill, + borderColor: COLORS.submissionsBorder, + borderWidth: 0, + borderRadius: 0, + maxBarThickness: 22, + order: 2, + yAxisID: 'y', + }, + { + type: 'line', + label: STRINGS.conversionRate, + data: rate, + borderColor: COLORS.rateBorder, + backgroundColor: COLORS.rateBorder, + borderWidth: 2, + pointBackgroundColor: COLORS.rateBorder, + pointBorderColor: COLORS.rateBorder, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.1, + fill: false, + order: 1, + yAxisID: 'y1', + }, + ], + }, options: { responsive: true, maintainAspectRatio: false, @@ -524,17 +566,41 @@ import { __ } from '@wordpress/i18n'; align: 'center', labels: { usePointStyle: true, - pointStyle: 'rectRounded', - boxWidth: 10, - boxHeight: 10, - padding: 16, + pointStyleWidth: 36, + boxHeight: 20, + padding: 24, color: COLORS.text, + generateLabels(ci) { + const legendFills = [ + COLORS.viewsLegendFill, + COLORS.submissionsLegendFill, + ]; + return ci.data.datasets.map(function (dataset, i) { + const isLine = dataset.type === 'line'; + return { + text: dataset.label, + fillStyle: isLine + ? 'transparent' + : legendFills[i] || dataset.backgroundColor, + strokeStyle: isLine + ? dataset.borderColor + : dataset.backgroundColor, + lineWidth: 2, + pointStyle: isLine ? 'line' : 'rect', + hidden: !ci.isDatasetVisible(i), + datasetIndex: i, + }; + }); + }, }, }, tooltip: { callbacks: { label(ctx) { const value = ctx.parsed.y || 0; + if (ctx.dataset.yAxisID === 'y1') { + return `${ctx.dataset.label}: ${value.toFixed(1)}%`; + } return `${ctx.dataset.label}: ${value}`; }, }, @@ -550,10 +616,26 @@ import { __ } from '@wordpress/i18n'; ticks: { color: COLORS.text }, }, y: { + type: 'linear', + position: 'left', beginAtZero: true, grid: { color: COLORS.gridLine, drawBorder: false }, ticks: { color: COLORS.text, precision: 0 }, }, + y1: { + type: 'linear', + position: 'right', + beginAtZero: true, + max: 110, + grid: { drawOnChartArea: false }, + ticks: { + color: COLORS.text, + stepSize: 10, + callback(value) { + return value > 100 ? '' : `${value}%`; + }, + }, + }, }, }, }); @@ -563,10 +645,12 @@ import { __ } from '@wordpress/i18n'; destroyCharts(); setErrorBanner(false); - const series = Array.isArray(payload.series) ? payload.series : []; - const totalSubs = payload.total_subs || 0; + const rows = Array.isArray(payload.data) ? payload.data : []; + const totalViews = payload.total_views || 0; + const totalSubs = payload.total_submissions || 0; - if (series.length === 0 || totalSubs === 0) { + // Empty when there's literally no tracked activity for the range. + if (rows.length === 0 || (totalViews === 0 && totalSubs === 0)) { showEmpty(); return; } @@ -574,7 +658,7 @@ import { __ } from '@wordpress/i18n'; setSubtitle(formatRangeLabel(fromLabel, toLabel)); setOverlay(''); setState('ready'); - renderLine(payload.labels || [], series); + renderChart(rows); } function fetchPerformance(detail) { diff --git a/includes/admin/templates/analytics.php b/includes/admin/templates/analytics.php index fab0418..b944399 100644 --- a/includes/admin/templates/analytics.php +++ b/includes/admin/templates/analytics.php @@ -121,7 +121,7 @@ class="mailchimp-sf-analytics-card__subtitle"

- +

@@ -172,7 +172,7 @@ class="mailchimp-sf-button btn-secondary btn-small mailchimp-sf-fp__error-banner id="mailchimp-sf-fp-line" class="mailchimp-sf-fp__canvas" role="img" - aria-label="" + aria-label="" >
Date: Wed, 22 Apr 2026 11:36:28 +0200 Subject: [PATCH 03/11] refactor: suggestions from copilot --- includes/class-mailchimp-form-performance.php | 137 +++++++----------- 1 file changed, 53 insertions(+), 84 deletions(-) diff --git a/includes/class-mailchimp-form-performance.php b/includes/class-mailchimp-form-performance.php index d2fe072..6a9fd24 100644 --- a/includes/class-mailchimp-form-performance.php +++ b/includes/class-mailchimp-form-performance.php @@ -54,7 +54,7 @@ public function handle_get() { $rows = $this->fetch_rows( $list_id, $date_from, $date_to ); - if ( ! is_array( $rows ) ) { + if ( null === $rows ) { wp_send_json_error( array( 'message' => esc_html__( 'Unable to load form analytics.', 'mailchimp' ) ), 500 ); } @@ -64,14 +64,14 @@ public function handle_get() { } /** - * Fetch raw daily rows from the analytics table grouped by form_id and date. + * Fetch daily totals from the analytics table for the selected list/range. * * @param string $list_id List ID. * @param string $date_from `Y-m-d`. * @param string $date_to `Y-m-d`. - * @return array Rows of `{ form_id, event_date, views, submissions }`. + * @return array|null Rows of `{ event_date, views, submissions }`, or null on DB error. */ - public function fetch_rows( string $list_id, string $date_from, string $date_to ): array { + public function fetch_rows( string $list_id, string $date_from, string $date_to ): ?array { global $wpdb; $table_name = Mailchimp_Analytics_Data::get_table_name(); @@ -79,10 +79,10 @@ public function fetch_rows( string $list_id, string $date_from, string $date_to // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( $wpdb->prepare( - "SELECT form_id, event_date, SUM(views) AS views, SUM(submissions) AS submissions + "SELECT event_date, SUM(views) AS views, SUM(submissions) AS submissions FROM {$table_name} WHERE list_id = %s AND event_date BETWEEN %s AND %s - GROUP BY form_id, event_date + GROUP BY event_date ORDER BY event_date ASC", $list_id, $date_from, @@ -92,15 +92,16 @@ public function fetch_rows( string $list_id, string $date_from, string $date_to ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + if ( null === $results || ! empty( $wpdb->last_error ) ) { + return null; + } + return is_array( $results ) ? $results : array(); } /** - * Filter raw rows into per-form series bucketed by interval. - * - * Missing bucket/form intersections are back-filled with zeros so every - * series has the same length as the chart's x-axis — keeping line charts - * aligned even when a given form had no submissions on some days. + * Bucket daily rows into the chart's interval, back-filling missing days + * with zeros so every bucket on the x-axis has a value * * @param array $rows Rows from `fetch_rows()`. * @param string $date_from `Y-m-d`. @@ -115,27 +116,29 @@ public function aggregate( array $rows, string $date_from, string $date_to ): ar $requested_days = (int) $from_dt->diff( $to_dt )->days + 1; $interval = $this->get_interval( $requested_days ); - // Build the complete ordered set of bucket keys covering the range so - // every form series aligns with the same x-axis labels. - $labels_by_key = array(); - $one_day = new DateInterval( 'P1D' ); - $cursor = $from_dt; + // Build the complete ordered set of bucket keys covering the range. + $buckets = array(); + $one_day = new DateInterval( 'P1D' ); + $cursor = $from_dt; while ( $cursor <= $to_dt ) { - $date = $cursor->format( 'Y-m-d' ); - $key = $this->get_bucket_key( $date, $interval, $tz ); - $labels_by_key[ $key ] = $this->get_bucket_label( $date, $interval, $tz ); - $cursor = $cursor->add( $one_day ); + $date = $cursor->format( 'Y-m-d' ); + $key = $this->get_bucket_key( $date, $interval, $tz ); + if ( ! isset( $buckets[ $key ] ) ) { + $buckets[ $key ] = array( + 'key' => $key, + 'label' => $this->get_bucket_label( $date, $interval, $tz ), + 'views' => 0, + 'submissions' => 0, + ); + } + $cursor = $cursor->add( $one_day ); } - ksort( $labels_by_key ); + ksort( $buckets ); - $empty_buckets = array_fill_keys( array_keys( $labels_by_key ), 0 ); + $total_views = 0; + $total_submissions = 0; - // Group rows by form_id, summing submissions into the appropriate bucket. - $forms = array(); - $total_subs = 0; - $total_views = 0; foreach ( $rows as $row ) { - $form_id = isset( $row['form_id'] ) ? (string) $row['form_id'] : ''; $date = isset( $row['event_date'] ) ? (string) $row['event_date'] : ''; $views = isset( $row['views'] ) ? (int) $row['views'] : 0; $submissions = isset( $row['submissions'] ) ? (int) $row['submissions'] : 0; @@ -145,78 +148,44 @@ public function aggregate( array $rows, string $date_from, string $date_to ): ar } $key = $this->get_bucket_key( $date, $interval, $tz ); - if ( ! array_key_exists( $key, $empty_buckets ) ) { + if ( ! isset( $buckets[ $key ] ) ) { continue; } - if ( ! isset( $forms[ $form_id ] ) ) { - $forms[ $form_id ] = array( - 'form_id' => $form_id, - 'label' => $this->get_form_label( $form_id ), - 'values' => $empty_buckets, - 'total_submissions' => 0, - 'total_views' => 0, - ); - } - - $forms[ $form_id ]['values'][ $key ] += $submissions; - $forms[ $form_id ]['total_submissions'] += $submissions; - $forms[ $form_id ]['total_views'] += $views; - - $total_subs += $submissions; - $total_views += $views; + $buckets[ $key ]['views'] += $views; + $buckets[ $key ]['submissions'] += $submissions; + $total_views += $views; + $total_submissions += $submissions; } - // Convert each form's bucket map into an ordered numeric array - $series = array(); - foreach ( $forms as $form ) { - $series[] = array( - 'form_id' => $form['form_id'], - 'label' => $form['label'], - 'total_submissions' => $form['total_submissions'], - 'total_views' => $form['total_views'], - 'values' => array_values( $form['values'] ), - ); + $data = array(); + foreach ( $buckets as $bucket ) { + $bucket['conversion_rate'] = $this->conversion_rate( $bucket['submissions'], $bucket['views'] ); + $data[] = $bucket; } - usort( - $series, - function ( $a, $b ) { - return $b['total_submissions'] <=> $a['total_submissions']; - } - ); - return array( - 'interval' => $interval, - 'labels' => array_values( $labels_by_key ), - 'series' => $series, - 'total_subs' => $total_subs, - 'total_views' => $total_views, + 'interval' => $interval, + 'data' => $data, + 'total_views' => $total_views, + 'total_submissions' => $total_submissions, + 'total_conversion_rate' => $this->conversion_rate( $total_submissions, $total_views ), ); } /** - * Human-readable label for a form_id. + * Submissions ÷ views, as a percentage (0–100, two decimals). * - * Per-form identification is tracked via `form_id` in the analytics table - * but the current tracking layer (shortcode + block forms) writes empty - * IDs, so existing rows collapse into a single "All forms" series. Once - * per-form tracking lands, this method is the single place to resolve a - * form ID to its display label (block title / shortcode caption / etc.). - * - * @param string $form_id Form identifier. - * @return string + * @param int $submissions Submission count. + * @param int $views View count. + * @return float */ - private function get_form_label( string $form_id ): string { - if ( '' === $form_id ) { - return esc_html__( 'All forms', 'mailchimp' ); + private function conversion_rate( int $submissions, int $views ): float { + if ( $views <= 0 ) { + return 0.0; } - - return sprintf( - /* translators: %s: form identifier */ - esc_html__( 'Form %s', 'mailchimp' ), - $form_id - ); + $rate = ( $submissions / $views ) * 100; + return round( min( 100.0, $rate ), 2 ); } /** From 9fa31ead8ea26928de26b2213b55f29618b3e236 Mon Sep 17 00:00:00 2001 From: alaca Date: Wed, 22 Apr 2026 11:36:47 +0200 Subject: [PATCH 04/11] feature: form performance test --- tests/cypress/e2e/settings/analytics.test.js | 162 +++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/tests/cypress/e2e/settings/analytics.test.js b/tests/cypress/e2e/settings/analytics.test.js index 2a33d8a..83c5460 100644 --- a/tests/cypress/e2e/settings/analytics.test.js +++ b/tests/cypress/e2e/settings/analytics.test.js @@ -5,6 +5,12 @@ const { wpJsonSuccess, wpJsonError, } = require('../../support/functions/subscriberActivityAjax'); +const { + isFormPerformanceRequest, + buildSuccessData: buildFormPerformanceData, + wpJsonSuccess: wpJsonSuccessFp, + wpJsonError: wpJsonErrorFp, +} = require('../../support/functions/formPerformanceAjax'); describe('Analytics admin page', () => { before(() => { @@ -284,6 +290,162 @@ describe('Analytics admin page', () => { cy.get('#mailchimp-sf-date-picker-label').should('have.text', 'Last 7 days'); }); }); + + describe('Form performance chart (stubbed)', () => { + const analyticsUrl = '/wp-admin/admin.php?page=mailchimp_sf_analytics'; + + function stubFormPerformance(replyFn) { + cy.intercept('POST', '**/admin-ajax.php', (req) => { + if (!isFormPerformanceRequest(req)) { + req.continue(); + return; + } + const payload = typeof replyFn === 'function' ? replyFn(req) : replyFn; + req.reply({ + statusCode: 200, + headers: { 'content-type': 'application/json; charset=UTF-8' }, + body: payload, + }); + }).as('formPerformance'); + } + + it('Form performance section shell (title, subtitle, canvas)', () => { + stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData())); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('be.visible'); + cy.get('#mailchimp-sf-fp-title').contains('Forms performance over time'); + cy.get('.mailchimp-sf-fp__chart-title').contains('Form Activity'); + cy.get('#mailchimp-sf-fp-line').should('exist'); + }); + + it('Success payload shows ready state', () => { + stubFormPerformance(() => + wpJsonSuccessFp( + buildFormPerformanceData({ + total_views: 500, + total_submissions: 150, + total_conversion_rate: 30.0, + data: [ + { + key: '2026-04-01', + label: 'Apr 1', + views: 500, + submissions: 150, + conversion_rate: 30.0, + }, + ], + }), + ), + ); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-ready'); + cy.get('[data-section="form-performance"]').should( + 'not.have.class', + 'is-loading', + ); + cy.get('[data-section="form-performance"]').should('not.have.class', 'is-error'); + }); + + it('Empty data shows empty state', () => { + stubFormPerformance(() => + wpJsonSuccessFp( + buildFormPerformanceData({ + data: [], + total_views: 0, + total_submissions: 0, + total_conversion_rate: 0, + }), + ), + ); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-empty'); + cy.get('#mailchimp-sf-fp-daterange').contains( + 'No submissions recorded for the selected date range', + ); + cy.get('#mailchimp-sf-fp-overlay').contains( + 'No data available for this date range', + ); + }); + + it('Zero views and zero submissions shows empty state even when rows exist', () => { + stubFormPerformance(() => + wpJsonSuccessFp( + buildFormPerformanceData({ + data: [ + { + key: '2026-04-01', + label: 'Apr 1', + views: 0, + submissions: 0, + conversion_rate: 0, + }, + ], + total_views: 0, + total_submissions: 0, + total_conversion_rate: 0, + }), + ), + ); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-empty'); + }); + + it('API error shows error banner', () => { + stubFormPerformance(() => wpJsonErrorFp('Form performance stub failure')); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-error'); + cy.get('#mailchimp-sf-fp-error-banner').should('be.visible'); + cy.get('#mailchimp-sf-fp-error-message').contains('Form performance stub failure'); + }); + + it('Retry after error loads success', () => { + let n = 0; + stubFormPerformance(() => { + n += 1; + if (n === 1) { + return wpJsonErrorFp('First request fails'); + } + return wpJsonSuccessFp(buildFormPerformanceData()); + }); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-error'); + cy.get('#mailchimp-sf-fp-error-retry').click(); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-ready'); + cy.get('#mailchimp-sf-fp-error-banner').should('have.attr', 'hidden'); + }); + + it('Changing list filter triggers another form performance request', function () { + stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData())); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('#mailchimp-sf-list-filter option').then(function ($options) { + const values = [...$options].map((o) => o.value).filter(Boolean); + if (values.length < 2) { + this.skip(); + } + cy.get('#mailchimp-sf-list-filter').select(values[1]); + cy.wait('@formPerformance'); + }); + }); + + it('Applying a different date preset triggers another form performance request', () => { + stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData())); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('#mailchimp-sf-date-picker-trigger').click(); + cy.get('#mailchimp-sf-date-range').select('7'); + cy.get('#mailchimp-sf-date-picker-apply').click(); + cy.wait('@formPerformance'); + cy.get('#mailchimp-sf-date-picker-label').should('have.text', 'Last 7 days'); + }); + }); }); describe('When not connected', () => { From c19b87ed87067616d69118c9f7f32b9237f0717f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20La=C4=87a?= Date: Wed, 22 Apr 2026 11:48:53 +0200 Subject: [PATCH 05/11] Update includes/class-mailchimp-form-performance.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- includes/class-mailchimp-form-performance.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mailchimp-form-performance.php b/includes/class-mailchimp-form-performance.php index 6a9fd24..7dd09b7 100644 --- a/includes/class-mailchimp-form-performance.php +++ b/includes/class-mailchimp-form-performance.php @@ -1,6 +1,6 @@ Date: Wed, 22 Apr 2026 11:52:15 +0200 Subject: [PATCH 06/11] feature: add missing helper --- .../support/functions/formPerformanceAjax.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/cypress/support/functions/formPerformanceAjax.js diff --git a/tests/cypress/support/functions/formPerformanceAjax.js b/tests/cypress/support/functions/formPerformanceAjax.js new file mode 100644 index 0000000..27af12c --- /dev/null +++ b/tests/cypress/support/functions/formPerformanceAjax.js @@ -0,0 +1,80 @@ +/** + * Helpers for stubbing `mailchimp_sf_get_form_performance` admin-ajax calls. + * fetch() uses FormData (multipart); match by substring or parsed fields. + * + * @param {object} req Cypress intercepted request + * @returns {boolean} + */ +function isFormPerformanceRequest(req) { + const action = 'mailchimp_sf_get_form_performance'; + const { body } = req; + if (typeof body === 'string') { + return body.includes(action); + } + if (body && typeof body === 'object') { + if (body.action === action) { + return true; + } + } + return JSON.stringify(body ?? '').includes(action); +} + +/** + * Build a minimal successful form-performance payload for stubs. + * + * @param {object} overrides Partial payload.data from PHP aggregate(). + * @returns {object} wp_send_json_success-compatible inner `data` object. + */ +function buildSuccessData(overrides = {}) { + return { + interval: 'daily', + data: [ + { + key: '2026-04-01', + label: 'Apr 1', + views: 120, + submissions: 24, + conversion_rate: 20.0, + }, + ], + total_views: 120, + total_submissions: 24, + total_conversion_rate: 20.0, + ...overrides, + }; +} + +/** + * Wrap inner data as a WordPress `wp_send_json_success`-shaped response body. + * + * @param {object} data Inner success payload (from buildSuccessData). + * @returns {object} Full JSON body for the browser. + */ +function wpJsonSuccess(data) { + return { + success: true, + data, + }; +} + +/** + * Build a WordPress `wp_send_json_error`-shaped response body for stubs. + * + * @param {string} message Error message exposed to the UI. + * @returns {object} Full JSON body for wp_send_json_error shape. + */ +function wpJsonError(message) { + return { + success: false, + data: { + message, + }, + }; +} + +module.exports = { + isFormPerformanceRequest, + buildSuccessData, + wpJsonSuccess, + wpJsonError, +}; From 52b693adaf27fd24beb98a4b8dedf9a36f1ee9cc Mon Sep 17 00:00:00 2001 From: alaca Date: Wed, 22 Apr 2026 11:54:10 +0200 Subject: [PATCH 07/11] fix: php 7 --- includes/class-mailchimp-form-performance.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mailchimp-form-performance.php b/includes/class-mailchimp-form-performance.php index 7dd09b7..4d29ac7 100644 --- a/includes/class-mailchimp-form-performance.php +++ b/includes/class-mailchimp-form-performance.php @@ -71,7 +71,7 @@ public function handle_get() { * @param string $date_to `Y-m-d`. * @return array|null Rows of `{ event_date, views, submissions }`, or null on DB error. */ - public function fetch_rows( string $list_id, string $date_from, string $date_to ): ?array { + public function fetch_rows( string $list_id, string $date_from, string $date_to ) { global $wpdb; $table_name = Mailchimp_Analytics_Data::get_table_name(); From 7191a8d12af6b5af0c1f9cee95fd9001eecb9a10 Mon Sep 17 00:00:00 2001 From: alaca Date: Tue, 28 Apr 2026 10:00:49 +0200 Subject: [PATCH 08/11] refactor: bump php version to 7.4 --- composer.json | 2 +- mailchimp.php | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ef422f8..0bb5ded 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "prefer-stable": true, "require": { - "php": ">=7.0", + "php": ">=7.4", "woocommerce/action-scheduler": "3.8.2" }, "require-dev": { diff --git a/mailchimp.php b/mailchimp.php index 8dc6298..0ae5684 100644 --- a/mailchimp.php +++ b/mailchimp.php @@ -6,7 +6,7 @@ * Text Domain: mailchimp * Version: 2.0.1 * Requires at least: 6.4 - * Requires PHP: 7.0 + * Requires PHP: 7.4 * PHP tested up to: 8.3 * Author: Mailchimp * Author URI: https://mailchimp.com/ @@ -110,6 +110,9 @@ function () { $form_submission = new Mailchimp_Form_Submission(); $form_submission->init(); +// Shared bucketing helpers used by both analytics chart data providers. +require_once plugin_dir_path( __FILE__ ) . 'includes/trait-mailchimp-analytics-bucketing.php'; + // Init Analytics page. require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-analytics.php'; $analytics = new Mailchimp_Analytics(); From 9b3479f853ad1e6381deb266ca764d64d52ec522 Mon Sep 17 00:00:00 2001 From: alaca Date: Tue, 28 Apr 2026 10:01:32 +0200 Subject: [PATCH 09/11] refactor: merge shared styles --- assets/css/analytics.scss | 249 ++++++++------------------------------ 1 file changed, 48 insertions(+), 201 deletions(-) diff --git a/assets/css/analytics.scss b/assets/css/analytics.scss index 3c37c4c..3a974fe 100644 --- a/assets/css/analytics.scss +++ b/assets/css/analytics.scss @@ -312,26 +312,15 @@ $mq-xs: 600px; } // ----------------------------------------------------------------------------- -// Form performance section +// Shared analytics chart-card // ----------------------------------------------------------------------------- -.mailchimp-sf-fp { - - &__body { - margin-top: 24px; - - &[hidden] { - display: none; - } - } +.mailchimp-sf-fp, +.mailchimp-sf-sa { &__chart { min-width: 0; } - &__chart-heading { - margin-bottom: 8px; - } - &__chart-title { color: var(--mc-sa-text-strong); font-size: 16px; @@ -365,12 +354,6 @@ $mq-xs: 600px; z-index: 1; } - /* - * Skeleton bars — mirror the subscriber activity loading pattern for - * visual consistency between the two analytics cards. Bars live in the - * top ~60% so the overlay copy below isn't covered; the chart-area grid - * lives on canvas-wrap itself. - */ &__skeleton-bars { align-items: flex-end; bottom: 42%; @@ -391,43 +374,11 @@ $mq-xs: 600px; width: 18px; } - span:nth-child(1) { - height: 96px; - } - - span:nth-child(2) { - height: 53px; - } - - span:nth-child(3) { - height: 122px; - } - - span:nth-child(4) { - height: 80px; - } - - span:nth-child(5) { - height: 40px; - } - } - - &__overlay { - align-items: center; - bottom: 0; - color: #666666; - display: none; - font-size: 14px; - font-weight: 500; - justify-content: center; - left: 0; - padding: 16px; - pointer-events: none; - position: absolute; - right: 0; - text-align: center; - top: 0; - z-index: 2; + span:nth-child(1) { height: 96px; } + span:nth-child(2) { height: 53px; } + span:nth-child(3) { height: 122px; } + span:nth-child(4) { height: 80px; } + span:nth-child(5) { height: 40px; } } &__error-banner { @@ -477,6 +428,44 @@ $mq-xs: 600px; &__error-banner-action { flex-shrink: 0; } +} + +// ----------------------------------------------------------------------------- +// Form performance — card-specific rules +// ----------------------------------------------------------------------------- +.mailchimp-sf-fp { + + &__body { + margin-top: 24px; + + &[hidden] { + display: none; + } + } + + &__chart-heading { + margin-bottom: 8px; + } + + // Centered overlay (used for empty / error). Loading state below + // repositions it below the skeleton bars. + &__overlay { + align-items: center; + bottom: 0; + color: #666666; + display: none; + font-size: 14px; + font-weight: 500; + justify-content: center; + left: 0; + padding: 16px; + pointer-events: none; + position: absolute; + right: 0; + text-align: center; + top: 0; + z-index: 2; + } &.is-loading, &.is-empty, @@ -533,14 +522,6 @@ $mq-xs: 600px; } } -@media (prefers-reduced-motion: reduce) { - - .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__skeleton-bars span, - .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__overlay { - animation: none; - } -} - @media screen and (max-width: $mq-xs) { .mailchimp-sf-fp__canvas-wrap { @@ -566,45 +547,6 @@ $mq-xs: 600px; } } - // Chart column - &__chart { - min-width: 0; - } - - &__chart-title { - color: var(--mc-sa-text-strong); - font-size: 16px; - font-weight: 500; - margin: 0 0 4px; - } - - &__chart-subtitle { - color: var(--mc-sa-text-muted); - font-size: 12px; - font-weight: 400; - line-height: 1.35; - margin: 0 0 16px; - } - - // Canvas area (hosts the real canvas + skeleton + overlay + background grid) - &__canvas-wrap { - height: 340px; - position: relative; - width: 100%; - - canvas { - display: block; - height: 100% !important; - width: 100% !important; - } - } - - &__canvas { - position: relative; - transition: opacity 150ms ease; - z-index: 1; - } - // Totals column &__totals { border-left: 1px solid var(--mc-sa-border); @@ -711,52 +653,6 @@ $mq-xs: 600px; padding: 10px 14px; } - /* - * Skeleton bars — live in the top ~60% of the canvas-wrap so the overlay - * copy below isn't covered. No background grid here; the grid lives on - * canvas-wrap itself so it spans the full chart area, not just the bars. - */ - &__skeleton-bars { - align-items: flex-end; - bottom: 42%; - display: none; - gap: 16px; - justify-content: center; - left: 0; - padding: 4px 0 0; - pointer-events: none; - position: absolute; - right: 0; - top: 0; - - span { - background: var(--mc-sa-border); - border-radius: 2px; - display: block; - width: 18px; - } - - span:nth-child(1) { - height: 96px; - } - - span:nth-child(2) { - height: 53px; - } - - span:nth-child(3) { - height: 122px; - } - - span:nth-child(4) { - height: 80px; - } - - span:nth-child(5) { - height: 40px; - } - } - // Skeleton donut — outlined grey ring, same size as the real donut &__skeleton-donut { border: 20px solid var(--mc-sa-border); @@ -793,57 +689,6 @@ $mq-xs: 600px; z-index: 2; } - // ------------------------------------------------------------------------- - // Error banner (shown in place of the chart overlay in the error state). - // ------------------------------------------------------------------------- - &__error-banner { - align-items: center; - background: var(--mc-sa-error-bg); - border: 1px solid var(--mc-sa-error-border); - border-radius: 8px; - display: flex; - gap: 12px; - margin: 4px 0 16px; - padding: 12px 14px; - - &[hidden] { - display: none; - } - } - - &__error-banner-icon { - align-items: center; - color: var(--mc-sa-error-text); - display: inline-flex; - flex-shrink: 0; - justify-content: center; - line-height: 0; - } - - &__error-banner-body { - flex: 1 1 auto; - min-width: 0; - } - - &__error-banner-title { - color: var(--mc-sa-error-title); - font-size: 13px; - font-weight: 600; - line-height: 1.3; - margin: 0 0 2px; - } - - &__error-banner-message { - color: var(--mc-sa-error-text); - font-size: 13px; - line-height: 1.4; - margin: 0; - } - - &__error-banner-action { - flex-shrink: 0; - } - // ------------------------------------------------------------------------- // State modifiers: loading / empty / error // Loading + empty share the same skeleton presentation; error shows the @@ -933,6 +778,8 @@ $mq-xs: 600px; @media (prefers-reduced-motion: reduce) { + .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__skeleton-bars span, + .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__overlay, .mailchimp-sf-sa.is-loading .mailchimp-sf-sa__skeleton-bars span, .mailchimp-sf-sa.is-loading .mailchimp-sf-sa__skeleton-donut, .mailchimp-sf-sa.is-loading .mailchimp-sf-sa__overlay { From db0d56976865cbff5a1be8c95a1bca1bd17612f6 Mon Sep 17 00:00:00 2001 From: alaca Date: Tue, 28 Apr 2026 10:01:56 +0200 Subject: [PATCH 10/11] feature: add Mailchimp_Analytics_Bucketing trait --- .../trait-mailchimp-analytics-bucketing.php | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 includes/trait-mailchimp-analytics-bucketing.php diff --git a/includes/trait-mailchimp-analytics-bucketing.php b/includes/trait-mailchimp-analytics-bucketing.php new file mode 100644 index 0000000..613fe03 --- /dev/null +++ b/includes/trait-mailchimp-analytics-bucketing.php @@ -0,0 +1,113 @@ +format( 'o-\WW' ); + case 'monthly': + return $dt->format( 'Y-m' ); + case 'quarterly': + $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); + return $dt->format( 'Y' ) . '-Q' . $quarter; + case 'yearly': + return $dt->format( 'Y' ); + case 'daily': + default: + return $dt->format( 'Y-m-d' ); + } + } + + /** + * Build a human-readable label for the bucket a given date falls into. + * + * @param string $date `Y-m-d`. + * @param string $interval Interval name. + * @param DateTimeZone|null $tz Timezone. + * @return string + */ + public function get_bucket_label( string $date, string $interval, $tz = null ): string { + $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); + $dt = new DateTimeImmutable( $date, $tz ); + + switch ( $interval ) { + case 'weekly': + $monday = $dt->modify( 'monday this week' ); + if ( $monday > $dt ) { + $monday = $dt->modify( 'monday last week' ); + } + return wp_date( 'M j', $monday->getTimestamp(), $tz ); + case 'monthly': + return wp_date( 'M Y', $dt->getTimestamp(), $tz ); + case 'quarterly': + $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); + return 'Q' . $quarter . ' ' . $dt->format( 'Y' ); + case 'yearly': + return $dt->format( 'Y' ); + case 'daily': + default: + return wp_date( 'M j', $dt->getTimestamp(), $tz ); + } + } + + /** + * Validate a `Y-m-d` date string. + * + * @param string $date Candidate date string. + * @return bool + */ + protected function is_valid_date( string $date ): bool { + if ( '' === $date ) { + return false; + } + $dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); + return $dt && $dt->format( 'Y-m-d' ) === $date; + } +} From 329ba9476735e361f1f2f38001154231e056f71a Mon Sep 17 00:00:00 2001 From: alaca Date: Tue, 28 Apr 2026 10:02:13 +0200 Subject: [PATCH 11/11] refactor: use Mailchimp_Analytics_Bucketing trait --- includes/class-mailchimp-form-performance.php | 100 +----------------- .../class-mailchimp-subscriber-activity.php | 98 +---------------- 2 files changed, 5 insertions(+), 193 deletions(-) diff --git a/includes/class-mailchimp-form-performance.php b/includes/class-mailchimp-form-performance.php index 4d29ac7..9216ad9 100644 --- a/includes/class-mailchimp-form-performance.php +++ b/includes/class-mailchimp-form-performance.php @@ -15,6 +15,8 @@ */ class Mailchimp_Form_Performance { + use Mailchimp_Analytics_Bucketing; + /** * Register hooks. * @@ -71,7 +73,7 @@ public function handle_get() { * @param string $date_to `Y-m-d`. * @return array|null Rows of `{ event_date, views, submissions }`, or null on DB error. */ - public function fetch_rows( string $list_id, string $date_from, string $date_to ) { + public function fetch_rows( string $list_id, string $date_from, string $date_to ): ?array { global $wpdb; $table_name = Mailchimp_Analytics_Data::get_table_name(); @@ -188,100 +190,4 @@ private function conversion_rate( int $submissions, int $views ): float { return round( min( 100.0, $rate ), 2 ); } - /** - * Pick an aggregation interval based on the requested range. - * - * @param int $days Inclusive day count of the requested range. - * @return string One of `daily|weekly|monthly|quarterly|yearly`. - */ - public function get_interval( int $days ): string { - if ( $days <= 30 ) { - return 'daily'; - } - if ( $days <= 90 ) { - return 'weekly'; - } - if ( $days <= 365 ) { - return 'monthly'; - } - if ( $days <= 365 * 3 ) { - return 'quarterly'; - } - return 'yearly'; - } - - /** - * Build a stable sort key for the bucket a given date falls into. - * - * @param string $date `Y-m-d`. - * @param string $interval Interval name. - * @param DateTimeZone|null $tz Timezone. - * @return string - */ - public function get_bucket_key( string $date, string $interval, $tz = null ): string { - $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); - $dt = new DateTimeImmutable( $date, $tz ); - - switch ( $interval ) { - case 'weekly': - // ISO week starts on Monday. - return $dt->format( 'o-\WW' ); - case 'monthly': - return $dt->format( 'Y-m' ); - case 'quarterly': - $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); - return $dt->format( 'Y' ) . '-Q' . $quarter; - case 'yearly': - return $dt->format( 'Y' ); - case 'daily': - default: - return $dt->format( 'Y-m-d' ); - } - } - - /** - * Build a human-readable label for the bucket a given date falls into. - * - * @param string $date `Y-m-d`. - * @param string $interval Interval name. - * @param DateTimeZone|null $tz Timezone. - * @return string - */ - public function get_bucket_label( string $date, string $interval, $tz = null ): string { - $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); - $dt = new DateTimeImmutable( $date, $tz ); - - switch ( $interval ) { - case 'weekly': - $monday = $dt->modify( 'monday this week' ); - if ( $monday > $dt ) { - $monday = $dt->modify( 'monday last week' ); - } - return wp_date( 'M j', $monday->getTimestamp(), $tz ); - case 'monthly': - return wp_date( 'M Y', $dt->getTimestamp(), $tz ); - case 'quarterly': - $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); - return 'Q' . $quarter . ' ' . $dt->format( 'Y' ); - case 'yearly': - return $dt->format( 'Y' ); - case 'daily': - default: - return wp_date( 'M j', $dt->getTimestamp(), $tz ); - } - } - - /** - * Validate a `Y-m-d` date string. - * - * @param string $date Candidate date string. - * @return bool - */ - private function is_valid_date( string $date ): bool { - if ( '' === $date ) { - return false; - } - $dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); - return $dt && $dt->format( 'Y-m-d' ) === $date; - } } diff --git a/includes/class-mailchimp-subscriber-activity.php b/includes/class-mailchimp-subscriber-activity.php index 593e8e8..c4b957a 100644 --- a/includes/class-mailchimp-subscriber-activity.php +++ b/includes/class-mailchimp-subscriber-activity.php @@ -22,6 +22,8 @@ */ class Mailchimp_Subscriber_Activity { + use Mailchimp_Analytics_Bucketing; + /** * Transient key prefix for the cached raw API response. */ @@ -229,100 +231,4 @@ public function filter_and_aggregate( array $raw, string $date_from, string $dat ); } - /** - * Pick an aggregation interval based on the requested range. - * - * @param int $days Inclusive day count of the requested range. - * @return string One of `daily|weekly|monthly|quarterly|yearly`. - */ - public function get_interval( int $days ): string { - if ( $days <= 30 ) { - return 'daily'; - } - if ( $days <= 90 ) { - return 'weekly'; - } - if ( $days <= 365 ) { - return 'monthly'; - } - if ( $days <= 365 * 3 ) { - return 'quarterly'; - } - return 'yearly'; - } - - /** - * Build a stable sort key for the bucket a given date falls into. - * - * @param string $date `Y-m-d`. - * @param string $interval Interval name. - * @param DateTimeZone|null $tz Timezone. - * @return string - */ - public function get_bucket_key( string $date, string $interval, $tz = null ): string { - $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); - $dt = new DateTimeImmutable( $date, $tz ); - - switch ( $interval ) { - case 'weekly': - // ISO week starts on Monday. - return $dt->format( 'o-\WW' ); - case 'monthly': - return $dt->format( 'Y-m' ); - case 'quarterly': - $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); - return $dt->format( 'Y' ) . '-Q' . $quarter; - case 'yearly': - return $dt->format( 'Y' ); - case 'daily': - default: - return $dt->format( 'Y-m-d' ); - } - } - - /** - * Build a human-readable label for the bucket a given date falls into. - * - * @param string $date `Y-m-d`. - * @param string $interval Interval name. - * @param DateTimeZone|null $tz Timezone. - * @return string - */ - public function get_bucket_label( string $date, string $interval, $tz = null ): string { - $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); - $dt = new DateTimeImmutable( $date, $tz ); - - switch ( $interval ) { - case 'weekly': - $monday = $dt->modify( 'monday this week' ); - if ( $monday > $dt ) { - $monday = $dt->modify( 'monday last week' ); - } - return wp_date( 'M j', $monday->getTimestamp(), $tz ); - case 'monthly': - return wp_date( 'M Y', $dt->getTimestamp(), $tz ); - case 'quarterly': - $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); - return 'Q' . $quarter . ' ' . $dt->format( 'Y' ); - case 'yearly': - return $dt->format( 'Y' ); - case 'daily': - default: - return wp_date( 'M j', $dt->getTimestamp(), $tz ); - } - } - - /** - * Validate a `Y-m-d` date string. - * - * @param string $date Candidate date string. - * @return bool - */ - private function is_valid_date( string $date ): bool { - if ( '' === $date ) { - return false; - } - $dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); - return $dt && $dt->format( 'Y-m-d' ) === $date; - } }