From e720127286239da9567263412618365bde977e1d Mon Sep 17 00:00:00 2001 From: Dharmesh Patel Date: Sat, 18 Apr 2026 11:51:09 +0530 Subject: [PATCH 01/11] Add subscriber activity class for the data provider. --- .../class-mailchimp-subscriber-activity.php | 328 ++++++++++++++++++ mailchimp.php | 5 + 2 files changed, 333 insertions(+) create mode 100644 includes/class-mailchimp-subscriber-activity.php diff --git a/includes/class-mailchimp-subscriber-activity.php b/includes/class-mailchimp-subscriber-activity.php new file mode 100644 index 0000000..05f061d --- /dev/null +++ b/includes/class-mailchimp-subscriber-activity.php @@ -0,0 +1,328 @@ + 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 ( '' === $list_id || ! preg_match( '/^[a-zA-Z0-9]+$/', $list_id ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Invalid list ID.', '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 ); + } + + $raw = $this->fetch_activity( $list_id ); + + if ( is_wp_error( $raw ) ) { + wp_send_json_error( array( 'message' => $raw->get_error_message() ), 502 ); + } + + if ( ! is_array( $raw ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Unable to reach Mailchimp.', 'mailchimp' ) ), 502 ); + } + + $response = $this->filter_and_aggregate( $raw, $date_from, $date_to ); + + wp_send_json_success( $response ); + } + + /** + * Fetch (and cache) the raw list activity for a list. + * + * The Mailchimp Activity API always returns the most recent 180 days for a + * given list regardless of query params, so a single transient can serve + * multiple date-range requests within its TTL. + * + * @param string $list_id List ID. + * @return array|WP_Error Normalized list of `{ date, new_subscribers, unsubscribes }` or error. + */ + public function fetch_activity( string $list_id ) { + $cache_key = self::CACHE_PREFIX . md5( $list_id ); + $cached = get_transient( $cache_key ); + if ( is_array( $cached ) ) { + return $cached; + } + + $api = mailchimp_sf_get_api(); + if ( ! $api ) { + return new WP_Error( + 'mailchimp_sf_not_connected', + esc_html__( 'Mailchimp account is not connected.', 'mailchimp' ) + ); + } + + $response = $api->get( 'lists/' . rawurlencode( $list_id ) . '/activity', self::API_WINDOW_DAYS ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( ! is_array( $response ) || ! isset( $response['activity'] ) || ! is_array( $response['activity'] ) ) { + return new WP_Error( + 'mailchimp_sf_invalid_response', + esc_html__( 'Unexpected response from Mailchimp.', 'mailchimp' ) + ); + } + + $normalized = array(); + foreach ( $response['activity'] as $row ) { + if ( empty( $row['day'] ) ) { + continue; + } + $normalized[] = array( + 'date' => sanitize_text_field( $row['day'] ), + 'new_subscribers' => isset( $row['subs'] ) ? (int) $row['subs'] : 0, + 'unsubscribes' => isset( $row['unsubs'] ) ? (int) $row['unsubs'] : 0, + ); + } + + set_transient( $cache_key, $normalized, self::CACHE_TTL ); + + return $normalized; + } + + /** + * Filter the raw (180 day) dataset to the selected range and aggregate it + * into buckets appropriate for that range. + * + * When the requested range exceeds the API's 180-day window we intersect + * with `[today - 179, today]` (inclusive, site timezone) and flag the + * response as `limited`. + * + * @param array $raw Normalized rows from `fetch_activity()`. + * @param string $date_from `Y-m-d`. + * @param string $date_to `Y-m-d`. + * @return array Response payload. + */ + public function filter_and_aggregate( array $raw, string $date_from, string $date_to ): array { + $tz = wp_timezone(); + $from_dt = new DateTimeImmutable( $date_from, $tz ); + $to_dt = new DateTimeImmutable( $date_to, $tz ); + $today = ( new DateTimeImmutable( 'now', $tz ) )->setTime( 0, 0, 0 ); + $api_oldest = $today->modify( '-' . ( self::API_WINDOW_DAYS - 1 ) . ' days' ); + + $requested_days = (int) $from_dt->diff( $to_dt )->days + 1; + $limited = $requested_days > self::API_WINDOW_DAYS; + + $effective_from = $from_dt < $api_oldest ? $api_oldest : $from_dt; + $effective_to = $to_dt > $today ? $today : $to_dt; + + $interval = $this->get_interval( $requested_days ); + + $buckets = array(); + $total_new = 0; + $total_unsubs = 0; + + if ( $effective_from <= $effective_to ) { + // Index API rows by date so missing days can be back-filled with zeros. + // The Mailchimp Activity API omits days with no subscribe/unsubscribe + // activity, which would otherwise leave gaps in the chart timeline. + $by_date = array(); + foreach ( $raw as $row ) { + $by_date[ $row['date'] ] = $row; + } + + $one_day = new DateInterval( 'P1D' ); + $cursor = $effective_from; + + while ( $cursor <= $effective_to ) { + $date = $cursor->format( 'Y-m-d' ); + $new_subscribers = isset( $by_date[ $date ]['new_subscribers'] ) + ? (int) $by_date[ $date ]['new_subscribers'] + : 0; + $unsubscribes = isset( $by_date[ $date ]['unsubscribes'] ) + ? (int) $by_date[ $date ]['unsubscribes'] + : 0; + + $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 ), + 'new_subscribers' => 0, + 'unsubscribes' => 0, + ); + } + + $buckets[ $key ]['new_subscribers'] += $new_subscribers; + $buckets[ $key ]['unsubscribes'] += $unsubscribes; + + $total_new += $new_subscribers; + $total_unsubs += $unsubscribes; + + $cursor = $cursor->add( $one_day ); + } + + ksort( $buckets ); + } + + return array( + 'interval' => $interval, + 'data' => array_values( $buckets ), + 'total_new' => $total_new, + 'total_unsubs' => $total_unsubs, + 'net_change' => $total_new - $total_unsubs, + 'limited' => $limited, + ); + } + + /** + * 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 6e47fa9..40b1a2d 100644 --- a/mailchimp.php +++ b/mailchimp.php @@ -120,6 +120,11 @@ function () { $analytics_data = new Mailchimp_Analytics_Data(); $analytics_data->init(); +// Subscriber activity (Mailchimp Activity API) data class. +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-subscriber-activity.php'; +$subscriber_activity = new Mailchimp_Subscriber_Activity(); +$subscriber_activity->init(); + // Deprecated functions. require_once plugin_dir_path( __FILE__ ) . 'includes/mailchimp-deprecated-functions.php'; From 1f0bd879aa3a9b6277fecb0d08fbf7fc9bc37213 Mon Sep 17 00:00:00 2001 From: Dharmesh Patel Date: Sat, 18 Apr 2026 12:33:06 +0530 Subject: [PATCH 02/11] Add styling, JS and chart template for the loading the graph. --- assets/css/analytics.css | 571 ++++++++++++++++++++++--- assets/js/analytics.js | 419 ++++++++++++++++++ includes/admin/templates/analytics.php | 135 +++++- 3 files changed, 1062 insertions(+), 63 deletions(-) diff --git a/assets/css/analytics.css b/assets/css/analytics.css index 6a02891..4c674df 100644 --- a/assets/css/analytics.css +++ b/assets/css/analytics.css @@ -5,9 +5,9 @@ */ .mailchimp-sf-analytics-filters { + align-items: flex-end; display: flex; flex-wrap: wrap; - align-items: flex-end; gap: 16px; margin-bottom: 20px; } @@ -19,20 +19,20 @@ } .mailchimp-sf-analytics-filter-group > label { + color: #1d2327; font-size: 14px; font-weight: 400; - color: #1d2327; } .mailchimp-sf-analytics-filter-group select { - min-width: 180px; - height: 36px; - padding: 0 8px; + background-color: #fff; border: 1px solid #8c8f94; border-radius: 4px; - font-size: 14px; color: #1d2327; - background-color: #fff; + font-size: 14px; + height: 36px; + min-width: 180px; + padding: 0 8px; } .mailchimp-sf-analytics-filter-group select:focus { @@ -47,19 +47,19 @@ } .mailchimp-sf-date-picker-trigger { - display: inline-flex; align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 220px; - height: 40px; - padding: 0 10px; + background-color: #fff; border: 1px solid #c3ced5; - color: var(--mailchimp-color-text); border-radius: 6px; - font-size: 14px; - background-color: #fff; + color: var(--mailchimp-color-text); cursor: pointer; + display: inline-flex; + font-size: 14px; + gap: 8px; + height: 40px; + justify-content: space-between; + min-width: 220px; + padding: 0 10px; text-align: left; transition: all 0.2s; } @@ -75,44 +75,44 @@ } .mailchimp-sf-date-picker.is-open .mailchimp-sf-date-picker-trigger { + background-color: rgba(1, 126, 137, 0.08); border-color: var(--mailchimp-color-link, #017e89); box-shadow: 0 0 0 1px var(--mailchimp-color-link, #017e89); - background-color: rgba(1, 126, 137, 0.08); } .mailchimp-sf-date-picker-trigger .dashicons { + color: #50575e; font-size: 18px; - width: 18px; height: 18px; line-height: 18px; - color: #50575e; + width: 18px; } .mailchimp-sf-date-picker-trigger .indicator-date-picker { + bottom: 0; display: flex; - position: absolute; - top: 0; - bottom: 0; - right: 0; - pointer-events: none; - justify-content: center; - padding: 0 8px; - width: 24px; + justify-content: center; + padding: 0 8px; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + width: 24px; } /* Date picker popover */ .mailchimp-sf-date-picker-popover { - display: none; - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 100; - min-width: 480px; - padding: 20px; background: #fff; border: 1px solid #dcdcde; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + display: none; + left: 0; + min-width: 480px; + padding: 20px; + position: absolute; + top: calc(100% + 6px); + z-index: 100; } .mailchimp-sf-date-picker-popover.is-open { @@ -127,15 +127,15 @@ .mailchimp-sf-date-picker-field { display: flex; + flex: 1; flex-direction: column; gap: 6px; - flex: 1; } .mailchimp-sf-date-picker-field label { + color: #4a5565; font-size: 14px; - font-weight: 400; - color: #4A5565; + font-weight: 400; } .mailchimp-sf-date-picker-input-wrap { @@ -164,10 +164,10 @@ #mailchimp-sf-settings-page .mailchimp-sf-date-picker-field select, #mailchimp-sf-settings-page .mailchimp-sf-date-picker-field input[type="text"] { - height: 36px; - font-size: 14px; - color: #1d2327; background-color: #fff; + color: #1d2327; + font-size: 14px; + height: 36px; width: 100%; } @@ -185,31 +185,31 @@ /* Popover actions — .mailchimp-sf-button.btn-primary / .btn-secondary.btn-small; pill shape to match design */ .mailchimp-sf-date-picker-actions { display: flex; - justify-content: flex-end; gap: 8px; + justify-content: flex-end; } /* Content area */ .mailchimp-sf-analytics-content { - border: 1px solid #D3D0C8; - border-radius: 8px; - width: 100%; - overflow: auto; - padding: 24px; background: #fff; - min-height: 200px; + border: 1px solid #d3d0c8; + border-radius: 8px; + box-sizing: border-box; margin-bottom: 20px; - box-sizing: border-box; + min-height: 200px; + overflow: auto; + padding: 24px; + width: 100%; } .mailchimp-sf-analytics-placeholder { - display: flex; align-items: center; - justify-content: center; - min-height: 200px; color: #8c8f94; + display: flex; font-size: 15px; + justify-content: center; + min-height: 200px; } /* Deep link */ @@ -218,8 +218,8 @@ } .mailchimp-sf-analytics-deep-link a { - display: inline-flex; align-items: center; + display: inline-flex; gap: 6px; } @@ -229,22 +229,23 @@ .mailchimp-sf-analytics-deep-link .dashicons { font-size: 16px; - width: 16px; height: 16px; line-height: 16px; + width: 16px; } /* Responsive */ @media screen and (max-width: 782px) { + .mailchimp-sf-analytics-filters { - flex-direction: column; align-items: stretch; + flex-direction: column; } .mailchimp-sf-date-picker-popover { + left: 0; min-width: auto; width: calc(100vw - 60px); - left: 0; } .mailchimp-sf-date-picker-popover-row { @@ -252,8 +253,8 @@ } .mailchimp-sf-date-picker-trigger { - width: 100%; min-width: auto; + width: 100%; } .mailchimp-sf-analytics-filter-group select { @@ -263,7 +264,463 @@ } /* Datepicker */ -.datepicker-cell.selected, .datepicker-cell.selected:hover { +.datepicker-cell.selected, +.datepicker-cell.selected:hover { background-color: var(--mailchimp-color-link, #017e89); color: #fff; } + +/* ----------------------------------------------------------------------------- + * Analytics card (shared shell for chart sections) + * -------------------------------------------------------------------------- */ +.mailchimp-sf-analytics-card { + --mc-sa-text-strong: #241C15; + --mc-sa-text-muted: #464E54; + --mc-sa-text-body: #374151; + --mc-sa-border: #DDDDDD; + --mc-sa-skeleton-grid: #f3f4f6; + --mc-sa-blue: #2B72FB; + --mc-sa-red: #FA4B42; + --mc-sa-grey: #9ca3af; + --mc-sa-notice-bg: #eff6ff; + --mc-sa-notice-border: #bfdbfe; + --mc-sa-notice-text: #1e3a8a; + --mc-sa-error-bg: #fef2f2; + --mc-sa-error-border: #fecaca; + --mc-sa-error-text: #b91c1c; + --mc-sa-error-title: #991b1b; + + background: #fff; + border: 1px solid var(--mc-sa-border); + border-radius: 12px; + box-sizing: border-box; + margin-bottom: 20px; + padding: 24px; + position: relative; +} + +.mailchimp-sf-analytics-card__header { + border-bottom: 1px solid var(--mc-sa-border); + margin-bottom: 20px; + padding-bottom: 16px; +} + +.mailchimp-sf-analytics-card__title { + color: var(--mc-sa-text-strong); + font-size: 24px; + font-weight: 500; + line-height: 1.25; + margin: 0 0 4px; +} + +.mailchimp-sf-analytics-card__subtitle { + color: var(--mc-sa-text-muted); + font-size: 14px; + line-height: 1.35; + margin: 0; +} + +/* ----------------------------------------------------------------------------- + * Subscriber activity section + * -------------------------------------------------------------------------- */ +.mailchimp-sf-sa__body { + align-items: stretch; + display: grid; + gap: 32px; + grid-template-columns: minmax(0, 1fr) 320px; + margin-top: 32px; +} + +.mailchimp-sf-sa__body[hidden] { + display: none; +} + +.mailchimp-sf-sa__chart { + min-width: 0; +} + +.mailchimp-sf-sa__chart-title { + color: var(--mc-sa-text-strong); + font-size: 16px; + font-weight: 500; + margin: 0 0 4px; +} + +.mailchimp-sf-sa__chart-subtitle { + color: var(--mc-sa-text-muted); + font-size: 12px; + font-weight: 400; + line-height: 1.35; + margin: 0 0 16px; +} + +.mailchimp-sf-sa__canvas-wrap { + height: 340px; + position: relative; + width: 100%; +} + +.mailchimp-sf-sa__canvas-wrap canvas { + display: block; + height: 100% !important; + width: 100% !important; +} + +/* Totals panel */ +.mailchimp-sf-sa__totals { + border-left: 1px solid var(--mc-sa-border); + display: flex; + flex-direction: column; + padding-left: 32px; +} + +.mailchimp-sf-sa__totals-title { + border-bottom: 1px solid var(--mc-sa-border); + color: var(--mc-sa-text-strong); + font-size: 14px; + font-weight: 500; + margin: 0 0 20px; + padding-bottom: 12px; +} + +.mailchimp-sf-sa__donut-wrap { + height: 160px; + margin: 8px auto 24px; + position: relative; + width: 160px; +} + +.mailchimp-sf-sa__donut-wrap canvas { + display: block; + height: 100% !important; + width: 100% !important; +} + +.mailchimp-sf-sa__donut-center { + align-items: center; + display: flex; + inset: 0; + justify-content: center; + pointer-events: none; + position: absolute; +} + +.mailchimp-sf-sa__net { + color: var(--mc-sa-text-strong); + font-size: 32px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1; +} + +/* Legend */ +.mailchimp-sf-sa__legend { + display: flex; + flex-direction: column; + gap: 10px; + list-style: none; + margin: 0; + padding: 0; +} + +.mailchimp-sf-sa__legend-item { + align-items: center; + color: var(--mc-sa-text-body); + display: grid; + font-size: 13px; + gap: 10px; + grid-template-columns: 14px 1fr auto; + margin: 0; +} + +.mailchimp-sf-sa__legend-swatch { + background: var(--mc-sa-grey); + border-radius: 3px; + display: inline-block; + height: 14px; + width: 14px; +} + +.mailchimp-sf-sa__legend-item.is-new .mailchimp-sf-sa__legend-swatch { + background: var(--mc-sa-blue); +} + +.mailchimp-sf-sa__legend-item.is-unsub .mailchimp-sf-sa__legend-swatch { + background: var(--mc-sa-red); +} + +.mailchimp-sf-sa__legend-label { + color: var(--mc-sa-text-body); +} + +.mailchimp-sf-sa__legend-value { + color: var(--mc-sa-text-strong); + font-weight: 600; + text-align: right; +} + +/* Notice (e.g. 180-day limited) */ +.mailchimp-sf-sa__notice { + background: var(--mc-sa-notice-bg); + border: 1px solid var(--mc-sa-notice-border); + border-left-width: 4px; + border-radius: 6px; + color: var(--mc-sa-notice-text); + font-size: 13px; + line-height: 1.4; + margin-bottom: 20px; + padding: 10px 14px; +} + +/* + * Skeleton — bar chart + * + * Occupies the top ~60% of the canvas-wrap so the overlay text sits below the + * bars. Includes a subtle background grid that mirrors (but does not need to + * exactly match) the gridlines Chart.js draws when real data loads. + */ +.mailchimp-sf-sa__skeleton-bars { + align-items: flex-end; + 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% / 4); + bottom: 40%; + display: none; + gap: 22px; + justify-content: center; + left: 0; + padding: 4px 0 0; + pointer-events: none; + position: absolute; + right: 0; + top: 0; +} + +.mailchimp-sf-sa__skeleton-bars span { + background: var(--mc-sa-border); + border-radius: 3px; + display: block; + width: 24px; +} + +.mailchimp-sf-sa__skeleton-bars span:nth-child(1) { + height: 44%; +} + +.mailchimp-sf-sa__skeleton-bars span:nth-child(2) { + height: 68%; +} + +.mailchimp-sf-sa__skeleton-bars span:nth-child(3) { + height: 88%; +} + +.mailchimp-sf-sa__skeleton-bars span:nth-child(4) { + height: 60%; +} + +.mailchimp-sf-sa__skeleton-bars span:nth-child(5) { + height: 34%; +} + +/* Skeleton — donut */ +.mailchimp-sf-sa__skeleton-donut { + border: 20px solid var(--mc-sa-border); + border-radius: 50%; + box-sizing: border-box; + display: none; + height: 100%; + inset: 0; + pointer-events: none; + position: absolute; + width: 100%; +} + +/* + * Overlay message sits in the bottom portion of the chart area, so the + * copy appears below the skeleton bars (not overlapping them). + */ +.mailchimp-sf-sa__overlay { + align-items: flex-start; + bottom: 0; + color: var(--mc-sa-text-strong); + display: none; + font-size: 14px; + font-weight: 500; + height: 40%; + justify-content: center; + left: 0; + padding: 12px 16px 0; + pointer-events: none; + position: absolute; + right: 0; + text-align: center; + z-index: 2; +} + +/* Canvas helpers */ +.mailchimp-sf-sa__canvas { + position: relative; + transition: opacity 150ms ease; + z-index: 1; +} + +/* ----------------------------------------------------------------------------- + * State modifiers: loading / empty / error + * Loading and empty share the same skeleton presentation; error uses the same + * structure with a red overlay text. + * -------------------------------------------------------------------------- */ +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__canvas, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__canvas, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__canvas { + opacity: 0; +} + +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__skeleton-bars, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__skeleton-bars, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__skeleton-bars, +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__skeleton-donut, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__skeleton-donut, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__skeleton-donut { + display: flex; +} + +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__overlay, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__overlay { + display: flex; +} + +/* Muted legend + center value while the chart has no real data to show */ +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__net, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__net, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__net, +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__legend-label, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__legend-label, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__legend-label, +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__legend-value, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__legend-value, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__legend-value { + color: var(--mc-sa-grey); + opacity: 0.85; +} + +.mailchimp-sf-sa.is-loading .mailchimp-sf-sa__legend-swatch, +.mailchimp-sf-sa.is-empty .mailchimp-sf-sa__legend-swatch, +.mailchimp-sf-sa.is-error .mailchimp-sf-sa__legend-swatch { + background: var(--mc-sa-border) !important; +} + +/* Loading pulse — applied to skeletons so the card "flashes" while fetching. */ +@keyframes mailchimp-sf-sa-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.45; + } +} + +.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 { + animation: mailchimp-sf-sa-pulse 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + + .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 { + animation: none; + } +} + +/* Error banner (shown instead of the chart overlay in the error state). */ +.mailchimp-sf-sa__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; +} + +.mailchimp-sf-sa__error-banner[hidden] { + display: none; +} + +.mailchimp-sf-sa__error-banner-icon { + align-items: center; + color: var(--mc-sa-error-text); + display: inline-flex; + flex-shrink: 0; + justify-content: center; + line-height: 0; +} + +.mailchimp-sf-sa__error-banner-body { + flex: 1 1 auto; + min-width: 0; +} + +.mailchimp-sf-sa__error-banner-title { + color: var(--mc-sa-error-title); + font-size: 13px; + font-weight: 600; + line-height: 1.3; + margin: 0 0 2px; +} + +.mailchimp-sf-sa__error-banner-message { + color: var(--mc-sa-error-text); + font-size: 13px; + line-height: 1.4; + margin: 0; +} + +.mailchimp-sf-sa__error-banner-action { + flex-shrink: 0; +} + +/* Responsive */ +@media screen and (max-width: 900px) { + + .mailchimp-sf-sa__body { + gap: 24px; + grid-template-columns: 1fr; + } + + .mailchimp-sf-sa__totals { + border-left: none; + border-top: 1px solid var(--mc-sa-border); + padding-left: 0; + padding-top: 24px; + } +} + +@media screen and (max-width: 600px) { + + .mailchimp-sf-analytics-card { + padding: 16px; + } + + .mailchimp-sf-sa__canvas-wrap { + height: 240px; + } + + .mailchimp-sf-sa__donut-wrap { + height: 160px; + width: 160px; + } + + .mailchimp-sf-sa__net { + font-size: 28px; + } +} diff --git a/assets/js/analytics.js b/assets/js/analytics.js index 7f1440a..1b574ec 100644 --- a/assets/js/analytics.js +++ b/assets/js/analytics.js @@ -397,6 +397,425 @@ import '../css/analytics.css'; fetchAnalyticsData(e.detail); }); + /** + * Subscriber change over time — diverging bar + totals donut. + * Loads independently from other analytics sections so an API error in + * this section does not affect KPIs or Form Performance. + */ + (function subscriberActivityModule() { + const section = document.querySelector('[data-section="subscriber-activity"]'); + if (!section) { + return; + } + + const barCanvas = document.getElementById('mailchimp-sf-sa-bar'); + const donutCanvas = document.getElementById('mailchimp-sf-sa-donut'); + const netEl = document.getElementById('mailchimp-sf-sa-net'); + const totalNewEl = document.getElementById('mailchimp-sf-sa-total-new'); + const totalUnsubsEl = document.getElementById('mailchimp-sf-sa-total-unsubs'); + const dateRangeEl = document.getElementById('mailchimp-sf-sa-daterange'); + const noticeEl = document.getElementById('mailchimp-sf-sa-notice'); + const overlayEl = document.getElementById('mailchimp-sf-sa-overlay'); + const errorBannerEl = document.getElementById('mailchimp-sf-sa-error-banner'); + const errorMessageEl = document.getElementById('mailchimp-sf-sa-error-message'); + const retryBtnEl = document.getElementById('mailchimp-sf-sa-error-retry'); + + const COLORS = { + newFill: 'rgba(96, 165, 250, 0.85)', + newBorder: '#3B82F6', + unsubFill: 'rgba(248, 113, 113, 0.85)', + unsubBorder: '#EF4444', + gridLine: 'rgba(0, 0, 0, 0.06)', + zeroLine: 'rgba(0, 0, 0, 0.25)', + text: '#6B7280', + }; + + const EM_DASH = '\u2014'; + + const STRINGS = { + loadingSubtitle: 'Loading subscriber activity…', + loadingOverlay: 'Loading subscriber activity…', + emptySubtitle: 'No data available for the selected date range', + emptyOverlay: 'No data available for this date range', + errorDefault: + 'Unable to load data for the selected date range. Please check your connection and try again.', + limited: + 'Mailchimp subscriber activity is only available for the last 180 days. Showing available data.', + newSubscribers: 'New Subscribers', + unsubscribes: 'Unsubscribes', + }; + + const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error']; + + let barChart = null; + let donutChart = null; + let inFlight = null; + let lastDetail = null; + + function setState(state) { + STATE_CLASSES.forEach(function (cls) { + section.classList.toggle(cls, cls === `is-${state}`); + }); + } + + function setPlaceholderTotals() { + if (netEl) { + netEl.textContent = EM_DASH; + netEl.classList.remove('is-positive', 'is-negative'); + } + if (totalNewEl) { + totalNewEl.textContent = EM_DASH; + } + if (totalUnsubsEl) { + totalUnsubsEl.textContent = EM_DASH; + } + } + + function showNotice(message) { + if (!noticeEl) { + return; + } + if (message) { + noticeEl.textContent = message; + noticeEl.hidden = false; + } else { + noticeEl.textContent = ''; + noticeEl.hidden = true; + } + } + + function setOverlay(text) { + if (overlayEl) { + overlayEl.textContent = text || ''; + } + } + + function setSubtitle(text) { + if (dateRangeEl) { + dateRangeEl.textContent = text || ''; + } + } + + function destroyCharts() { + if (barChart) { + barChart.destroy(); + barChart = null; + } + if (donutChart) { + donutChart.destroy(); + donutChart = 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(); + showNotice(''); + setErrorBanner(false); + setOverlay(STRINGS.loadingOverlay); + setSubtitle(STRINGS.loadingSubtitle); + setPlaceholderTotals(); + setState('loading'); + } + + function showEmpty() { + destroyCharts(); + setErrorBanner(false); + setOverlay(STRINGS.emptyOverlay); + setSubtitle(STRINGS.emptySubtitle); + setPlaceholderTotals(); + setState('empty'); + } + + function showError(message) { + destroyCharts(); + showNotice(''); + setOverlay(''); + // Keep subtitle showing the last attempted date range if we have one. + if (lastDetail && lastDetail.from && lastDetail.to) { + setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to)); + } + setPlaceholderTotals(); + setErrorBanner(true, message); + setState('error'); + } + + function renderBar(data) { + if (!barCanvas || typeof window.Chart === 'undefined') { + return; + } + + const labels = data.map(function (row) { + return row.label; + }); + const newSeries = data.map(function (row) { + return row.new_subscribers || 0; + }); + const unsubSeries = data.map(function (row) { + return -Math.abs(row.unsubscribes || 0); + }); + + const config = { + type: 'bar', + data: { + labels, + datasets: [ + { + label: STRINGS.unsubscribes, + data: unsubSeries, + backgroundColor: COLORS.unsubFill, + borderColor: COLORS.unsubBorder, + borderWidth: 0, + borderRadius: 0, + borderSkipped: false, + maxBarThickness: 32, + }, + { + label: STRINGS.newSubscribers, + data: newSeries, + backgroundColor: COLORS.newFill, + borderColor: COLORS.newBorder, + borderWidth: 0, + borderRadius: 0, + borderSkipped: false, + maxBarThickness: 32, + }, + ], + }, + 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 = Math.abs(ctx.parsed.y || 0); + return `${ctx.dataset.label}: ${value}`; + }, + }, + }, + }, + scales: { + x: { + grid: { + color: COLORS.gridLine, + drawBorder: false, + drawOnChartArea: true, + drawTicks: false, + }, + ticks: { color: COLORS.text }, + }, + y: { + beginAtZero: true, + grid: { + color(ctx) { + return ctx.tick && ctx.tick.value === 0 + ? COLORS.zeroLine + : COLORS.gridLine; + }, + drawBorder: false, + drawOnChartArea: true, + }, + ticks: { + color: COLORS.text, + callback(value) { + return value; + }, + }, + }, + }, + }, + }; + + barChart = new window.Chart(barCanvas.getContext('2d'), config); + } + + function renderDonut(totalNew, totalUnsubs) { + if (!donutCanvas || typeof window.Chart === 'undefined') { + return; + } + const total = (totalNew || 0) + (totalUnsubs || 0); + const data = total > 0 ? [totalNew || 0, totalUnsubs || 0] : [1, 0]; + const colors = + total > 0 + ? [COLORS.newBorder, COLORS.unsubBorder] + : ['rgba(0, 0, 0, 0.08)', 'rgba(0, 0, 0, 0.08)']; + + donutChart = new window.Chart(donutCanvas.getContext('2d'), { + type: 'doughnut', + data: { + labels: [STRINGS.newSubscribers, STRINGS.unsubscribes], + datasets: [ + { + data, + backgroundColor: colors, + borderWidth: 0, + cutout: '78%', + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { enabled: total > 0 }, + }, + }, + }); + } + + function renderTotals(payload) { + const net = payload.net_change || 0; + if (netEl) { + const sign = net > 0 ? '+' : ''; + netEl.textContent = `${sign}${net}`; + netEl.classList.toggle('is-positive', net > 0); + netEl.classList.toggle('is-negative', net < 0); + } + if (totalNewEl) { + totalNewEl.textContent = String(payload.total_new || 0); + } + if (totalUnsubsEl) { + totalUnsubsEl.textContent = String(payload.total_unsubs || 0); + } + } + + function render(payload, fromLabel, toLabel) { + destroyCharts(); + setErrorBanner(false); + + if (!Array.isArray(payload.data) || payload.data.length === 0) { + showEmpty(); + return; + } + + showNotice(payload.limited ? STRINGS.limited : ''); + setSubtitle(formatRangeLabel(fromLabel, toLabel)); + setOverlay(''); + setState('ready'); + renderBar(payload.data); + renderDonut(payload.total_new, payload.total_unsubs); + renderTotals(payload); + } + + function fetchActivity(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_subscriber_activity'); + 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, + }) + .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(); + }); + } + + if (retryBtnEl) { + retryBtnEl.addEventListener('click', function () { + if (lastDetail) { + fetchActivity(lastDetail); + } + }); + } + + document.addEventListener('mailchimp-analytics-refresh', function (e) { + fetchActivity(e.detail); + }); + })(); + // Initialize. updateTriggerLabel(); syncDateInputs(); diff --git a/includes/admin/templates/analytics.php b/includes/admin/templates/analytics.php index 9e744ee..b29a12f 100644 --- a/includes/admin/templates/analytics.php +++ b/includes/admin/templates/analytics.php @@ -100,14 +100,137 @@ -
-
+
+
+

+ +

+

+
+ + -
-
-

+
+

+ +

+

+ +

-
+ + + +
+
+
+ + +
+
+
+ + +
+