diff --git a/assets/css/analytics.scss b/assets/css/analytics.scss
index f4470f9..3a974fe 100644
--- a/assets/css/analytics.scss
+++ b/assets/css/analytics.scss
@@ -312,24 +312,11 @@ $mq-xs: 600px;
}
// -----------------------------------------------------------------------------
-// Subscriber activity section
+// Shared analytics chart-card
// -----------------------------------------------------------------------------
+.mailchimp-sf-fp,
.mailchimp-sf-sa {
- // Body grid — chart column + totals column
- &__body {
- align-items: stretch;
- display: grid;
- gap: 32px;
- grid-template-columns: minmax(0, 1fr) 320px;
- margin-top: 32px;
-
- &[hidden] {
- display: none;
- }
- }
-
- // Chart column
&__chart {
min-width: 0;
}
@@ -349,7 +336,6 @@ $mq-xs: 600px;
margin: 0 0 16px;
}
- // Canvas area (hosts the real canvas + skeleton + overlay + background grid)
&__canvas-wrap {
height: 340px;
position: relative;
@@ -368,6 +354,199 @@ $mq-xs: 600px;
z-index: 1;
}
+ &__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; }
+ }
+
+ &__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;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// 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,
+ &.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);
+ }
+ }
+
+ &.is-loading,
+ &.is-empty {
+
+ .mailchimp-sf-fp__overlay {
+ display: flex;
+ }
+ }
+
+ &.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;
+ }
+ }
+
+ button#mailchimp-sf-fp-error-retry {
+ background-color: #fff;
+ border: 1px solid #ccd6dc;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: #f6f7f7;
+ }
+ }
+}
+
+@media screen and (max-width: $mq-xs) {
+
+ .mailchimp-sf-fp__canvas-wrap {
+ height: 240px;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Subscriber activity section
+// -----------------------------------------------------------------------------
+.mailchimp-sf-sa {
+
+ // Body grid — chart column + totals column
+ &__body {
+ align-items: stretch;
+ display: grid;
+ gap: 32px;
+ grid-template-columns: minmax(0, 1fr) 320px;
+ margin-top: 32px;
+
+ &[hidden] {
+ display: none;
+ }
+ }
+
// Totals column
&__totals {
border-left: 1px solid var(--mc-sa-border);
@@ -474,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);
@@ -556,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
@@ -696,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 {
diff --git a/assets/js/analytics.js b/assets/js/analytics.js
index 31c0a7e..85209e7 100644
--- a/assets/js/analytics.js
+++ b/assets/js/analytics.js
@@ -359,50 +359,383 @@ 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 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 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 = {
+ 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',
+ ),
+ views: __('Form Views', 'mailchimp'),
+ submissions: __('Submissions', 'mailchimp'),
+ conversionRate: __('Conversion Rate', 'mailchimp'),
+ };
+
+ const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error'];
+
+ let chart = 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 setOverlay(text) {
+ if (overlayEl) {
+ overlayEl.textContent = text || '';
+ }
+ }
+
+ function setSubtitle(text) {
+ if (dateRangeEl) {
+ dateRangeEl.textContent = text || '';
+ }
+ }
+
+ function destroyCharts() {
+ if (chart) {
+ chart.destroy();
+ chart = 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');
+ }
- const { data } = response;
- contentArea.innerHTML = JSON.stringify(data);
+ function showError(message) {
+ destroyCharts();
+ setOverlay('');
+ if (lastDetail && lastDetail.from && lastDetail.to) {
+ setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to));
+ }
+ setErrorBanner(true, message);
+ setState('error');
+ }
+
+ /**
+ * @param {Array} rows Payload `data` rows from the API.
+ */
+ function renderChart(rows) {
+ if (!chartCanvas || typeof window.Chart === 'undefined') {
+ return;
+ }
+
+ 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;
+ });
+
+ 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,
+ interaction: { mode: 'index', intersect: false },
+ plugins: {
+ legend: {
+ position: 'top',
+ align: 'center',
+ labels: {
+ usePointStyle: true,
+ 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}`;
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ color: COLORS.gridLine,
+ drawBorder: false,
+ drawTicks: false,
+ },
+ 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}%`;
+ },
+ },
+ },
+ },
+ },
+ });
+ }
+
+ function render(payload, fromLabel, toLabel) {
+ destroyCharts();
+ setErrorBanner(false);
+
+ const rows = Array.isArray(payload.data) ? payload.data : [];
+ const totalViews = payload.total_views || 0;
+ const totalSubs = payload.total_submissions || 0;
+
+ // Empty when there's literally no tracked activity for the range.
+ if (rows.length === 0 || (totalViews === 0 && totalSubs === 0)) {
+ showEmpty();
+ return;
+ }
+
+ setSubtitle(formatRangeLabel(fromLabel, toLabel));
+ setOverlay('');
+ setState('ready');
+ renderChart(rows);
+ }
+
+ 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/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/includes/admin/templates/analytics.php b/includes/admin/templates/analytics.php
index efd1bd4..b944399 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 ( null === $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 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|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 {
+ 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 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 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
+
+ if ( null === $results || ! empty( $wpdb->last_error ) ) {
+ return null;
+ }
+
+ return is_array( $results ) ? $results : array();
+ }
+
+ /**
+ * 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`.
+ * @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.
+ $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 );
+ 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( $buckets );
+
+ $total_views = 0;
+ $total_submissions = 0;
+
+ foreach ( $rows as $row ) {
+ $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 ( ! isset( $buckets[ $key ] ) ) {
+ continue;
+ }
+
+ $buckets[ $key ]['views'] += $views;
+ $buckets[ $key ]['submissions'] += $submissions;
+ $total_views += $views;
+ $total_submissions += $submissions;
+ }
+
+ $data = array();
+ foreach ( $buckets as $bucket ) {
+ $bucket['conversion_rate'] = $this->conversion_rate( $bucket['submissions'], $bucket['views'] );
+ $data[] = $bucket;
+ }
+
+ return array(
+ 'interval' => $interval,
+ 'data' => $data,
+ 'total_views' => $total_views,
+ 'total_submissions' => $total_submissions,
+ 'total_conversion_rate' => $this->conversion_rate( $total_submissions, $total_views ),
+ );
+ }
+
+ /**
+ * Submissions ÷ views, as a percentage (0–100, two decimals).
+ *
+ * @param int $submissions Submission count.
+ * @param int $views View count.
+ * @return float
+ */
+ private function conversion_rate( int $submissions, int $views ): float {
+ if ( $views <= 0 ) {
+ return 0.0;
+ }
+ $rate = ( $submissions / $views ) * 100;
+ return round( min( 100.0, $rate ), 2 );
+ }
+
+}
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;
- }
}
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;
+ }
+}
diff --git a/mailchimp.php b/mailchimp.php
index 40b1a2d..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();
@@ -125,6 +128,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';
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', () => {
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,
+};