Plugin Directory

Changeset 3798238


Ignore:
Timestamp:
11/12/2026 11:56:22 AM (45 hours ago)
Author:
jakubkanna
Message:

Release Headless GA4 0.2.1

Location:
headless-ga4
Files:
16 edited
1 copied

Legend:

Unmodified
Added
Removed
  • headless-ga4/tags/0.2.1/assets/admin.css

    r3504948 r3798238  
    7373}
    7474
     75.headless-ga4-widget-period-header {
     76    display: flex;
     77    align-items: center;
     78    justify-content: space-between;
     79    gap: 14px;
     80    margin-bottom: 16px;
     81}
     82
     83.headless-ga4-widget-period-header strong {
     84    display: block;
     85    margin-top: 4px;
     86    font-size: 18px;
     87}
     88
     89.headless-ga4-widget-period-filter {
     90    display: flex;
     91    flex-wrap: wrap;
     92    gap: 6px;
     93}
     94
     95.headless-ga4-widget-period-link {
     96    border: 1px solid #c3c4c7;
     97    border-radius: 999px;
     98    background: #f6f7f7;
     99    color: #1d2327;
     100    padding: 5px 10px;
     101    text-decoration: none;
     102}
     103
     104.headless-ga4-widget-period-link:hover,
     105.headless-ga4-widget-period-link:focus {
     106    border-color: #2271b1;
     107    color: #0a4b78;
     108}
     109
     110.headless-ga4-widget-period-link.is-active {
     111    border-color: #1d2327;
     112    background: #1d2327;
     113    color: #fff;
     114}
     115
    75116.headless-ga4-widget-card,
    76117.headless-ga4-widget-panel {
    … …  
    109150.headless-ga4-widget-list {
    110151    margin: 10px 0 0;
     152    max-height: 230px;
     153    overflow: auto;
    111154}
    112155
    … …  
    128171    margin: 0;
    129172    font-size: 14px;
     173}
     174
     175.headless-ga4-widget-rank-bar {
     176    display: block;
     177    width: min(180px, 100%);
     178    height: 10px;
     179    margin-top: 8px;
     180}
     181
     182.headless-ga4-widget-rank-bar-track {
     183    fill: #edf0f2;
     184}
     185
     186.headless-ga4-widget-rank-bar-value {
     187    fill: #2271b1;
    130188}
    131189
    … …  
    145203.headless-ga4-widget-chart {
    146204    margin-top: 10px;
     205    border: 1px solid #ececec;
     206    background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
     207    padding: 10px;
    147208}
    148209
    … …  
    151212    width: 100%;
    152213    height: auto;
     214}
     215
     216.headless-ga4-widget-chart-bars rect {
     217    fill: #d7e8f7;
     218}
     219
     220.headless-ga4-widget-chart-line {
     221    stroke: #111111;
     222    stroke-linecap: round;
     223    stroke-linejoin: round;
     224    stroke-width: 3;
    153225}
    154226
    … …  
    170242
    171243@media (max-width: 782px) {
     244    .headless-ga4-widget-period-header {
     245        align-items: flex-start;
     246        flex-direction: column;
     247    }
     248
    172249    .headless-ga4-widget-grid,
    173250    .headless-ga4-widget-layout {
  • headless-ga4/tags/0.2.1/headless-ga4.php

    r3504948 r3798238  
    44 * Plugin URI: https://studio.jakubkanna.com/
    55 * Description: Display lightweight GA4 data in the WordPress dashboard for headless frontends.
    6  * Version: 0.1.1
     6 * Version: 0.2.1
    77 * Requires at least: 6.4
    88 * Requires PHP: 7.4
    … …  
    1919}
    2020
    21 define('HEADLESS_GA4_VERSION', '0.1.1');
     21define('HEADLESS_GA4_VERSION', '0.2.1');
    2222define('HEADLESS_GA4_PLUGIN_FILE', __FILE__);
    2323define('HEADLESS_GA4_PLUGIN_PATH', plugin_dir_path(__FILE__));
  • headless-ga4/tags/0.2.1/includes/cache.php

    r3504948 r3798238  
    5555
    5656/**
     57 * Returns the transient name for a period-specific report cache.
     58 *
     59 * @param string $period Dashboard period key.
     60 *
     61 * @return string
     62 */
     63function headless_ga4_get_report_transient_name($period = 'weekly')
     64{
     65    $period = is_string($period) ? sanitize_key($period) : 'weekly';
     66
     67    if (!in_array($period, array('weekly', 'monthly', 'yearly', 'all_time'), true)) {
     68        $period = 'weekly';
     69    }
     70
     71    return HEADLESS_GA4_REPORT_TRANSIENT . '_' . $period;
     72}
     73
     74/**
    5775 * Returns the cached report payload.
     76 *
     77 * @param string $period Dashboard period key.
    5878 *
    5979 * @return array<string, mixed>|null
    6080 */
    61 function headless_ga4_get_cached_report()
     81function headless_ga4_get_cached_report($period = 'weekly')
    6282{
    63     $cached = get_transient(HEADLESS_GA4_REPORT_TRANSIENT);
     83    $cached = get_transient(headless_ga4_get_report_transient_name($period));
    6484
    6585    return is_array($cached) ? $cached : null;
    … …  
    7090 *
    7191 * @param array<string, mixed> $report Report payload.
     92 * @param string               $period Dashboard period key.
    7293 *
    7394 * @return void
    7495 */
    75 function headless_ga4_set_cached_report($report)
     96function headless_ga4_set_cached_report($report, $period = 'weekly')
    7697{
    7798    set_transient(
    78         HEADLESS_GA4_REPORT_TRANSIENT,
     99        headless_ga4_get_report_transient_name($period),
    79100        $report,
    80101        headless_ga4_get_cache_ttl_seconds()
    … …  
    89110function headless_ga4_clear_cached_data()
    90111{
     112    $periods = array('weekly', 'monthly', 'yearly', 'all_time');
     113
    91114    delete_transient(HEADLESS_GA4_ACCESS_TOKEN_TRANSIENT);
    92115    delete_transient(HEADLESS_GA4_REPORT_TRANSIENT);
     116
     117    foreach ($periods as $period) {
     118        delete_transient(headless_ga4_get_report_transient_name($period));
     119    }
    93120}
    94 
  • headless-ga4/tags/0.2.1/includes/dashboard-widget.php

    r3504948 r3798238  
    4343
    4444/**
    45  * Builds a simple SVG polyline chart for the sessions trend.
    46  *
    47  * @param array<int, array<string, mixed>> $trend Trend rows.
    48  *
    49  * @return string
    50  */
    51 function headless_ga4_render_trend_svg($trend)
     45 * Returns the dashboard period requested in the URL.
     46 *
     47 * @return string
     48 */
     49function headless_ga4_get_requested_dashboard_period()
     50{
     51    $period = filter_input(INPUT_GET, 'headless_ga4_period', FILTER_SANITIZE_SPECIAL_CHARS);
     52
     53    return headless_ga4_normalize_dashboard_period(is_string($period) ? $period : 'weekly');
     54}
     55
     56/**
     57 * Returns the dashboard period posted by the refresh form.
     58 *
     59 * @return string
     60 */
     61function headless_ga4_get_posted_dashboard_period()
     62{
     63    $period = filter_input(INPUT_POST, 'headless_ga4_period', FILTER_SANITIZE_SPECIAL_CHARS);
     64
     65    return headless_ga4_normalize_dashboard_period(is_string($period) ? $period : 'weekly');
     66}
     67
     68/**
     69 * Returns allowed SVG markup for dashboard charts.
     70 *
     71 * @return array<string, array<string, bool>>
     72 */
     73function headless_ga4_get_allowed_chart_svg_html()
     74{
     75    return array(
     76        'svg' => array(
     77            'aria-label' => true,
     78            'class'      => true,
     79            'role'       => true,
     80            'viewbox'    => true,
     81            'viewBox'    => true,
     82        ),
     83        'g' => array(
     84            'class' => true,
     85        ),
     86        'polyline' => array(
     87            'class'  => true,
     88            'fill'   => true,
     89            'points' => true,
     90        ),
     91        'rect' => array(
     92            'class'  => true,
     93            'height' => true,
     94            'rx'     => true,
     95            'width'  => true,
     96            'x'      => true,
     97            'y'      => true,
     98        ),
     99    );
     100}
     101
     102/**
     103 * Builds an SVG bar and line diagram for the sessions trend.
     104 *
     105 * @param array<int, array<string, mixed>> $trend      Trend rows.
     106 * @param string                           $chart_label Accessible chart label.
     107 *
     108 * @return string
     109 */
     110function headless_ga4_render_trend_svg($trend, $chart_label = '')
    52111{
    53112    if (empty($trend)) {
    … …  
    55114    }
    56115
    57     $width = 320;
    58     $height = 92;
    59     $padding = 8;
     116    $width = 520;
     117    $height = 180;
     118    $padding_x = 18;
     119    $padding_y = 16;
    60120    $values = array_map(
    61121        static function ($point) {
    … …  
    65125    );
    66126    $max_value = max(1, max($values));
    67     $min_value = min($values);
    68     $range = max(1, $max_value - $min_value);
     127    $range = max(1, $max_value);
    69128    $count = count($trend);
    70     $step_x = $count > 1 ? ($width - ($padding * 2)) / ($count - 1) : 0;
     129    $inner_width = $width - ($padding_x * 2);
     130    $inner_height = $height - ($padding_y * 2);
     131    $step_x = $count > 1 ? $inner_width / ($count - 1) : 0;
     132    $bar_slot = $count > 0 ? $inner_width / $count : $inner_width;
     133    $bar_width = max(3, min(22, $bar_slot * 0.55));
    71134    $points = array();
     135    $bars = array();
     136    $chart_label = $chart_label !== '' ? $chart_label : __('Sessions trend', 'headless-ga4');
    72137
    73138    foreach ($trend as $index => $point) {
    74139        $value = isset($point['value']) ? (int) $point['value'] : 0;
    75         $x = $padding + ($step_x * $index);
    76         $y = $height - $padding - ((($value - $min_value) / $range) * ($height - ($padding * 2)));
     140        $x = $padding_x + ($step_x * $index);
     141        $bar_height = max(1, ($value / $range) * $inner_height);
     142        $bar_x = $x - ($bar_width / 2);
     143        $bar_y = $height - $padding_y - $bar_height;
     144        $y = $height - $padding_y - (($value / $range) * $inner_height);
    77145        $points[] = round($x, 2) . ',' . round($y, 2);
    78     }
    79 
    80     return '<svg viewBox="0 0 ' . esc_attr((string) $width) . ' ' . esc_attr((string) $height) . '" role="img" aria-label="' . esc_attr__('Sessions trend for the last 7 days', 'headless-ga4') . '"><polyline fill="none" stroke="#111111" stroke-width="2" points="' . esc_attr(implode(' ', $points)) . '" /></svg>';
     146        $bars[] = '<rect x="' . esc_attr((string) round($bar_x, 2)) . '" y="' . esc_attr((string) round($bar_y, 2)) . '" width="' . esc_attr((string) round($bar_width, 2)) . '" height="' . esc_attr((string) round($bar_height, 2)) . '" rx="3" />';
     147    }
     148
     149    return '<svg viewBox="0 0 ' . esc_attr((string) $width) . ' ' . esc_attr((string) $height) . '" role="img" aria-label="' . esc_attr($chart_label) . '"><g class="headless-ga4-widget-chart-bars">' . implode('', $bars) . '</g><polyline class="headless-ga4-widget-chart-line" fill="none" points="' . esc_attr(implode(' ', $points)) . '" /></svg>';
     150}
     151
     152/**
     153 * Builds a compact horizontal bar SVG for ranked metric rows.
     154 *
     155 * @param int    $value Metric value.
     156 * @param int    $max_value Largest value in the list.
     157 * @param string $label Accessible chart label.
     158 *
     159 * @return string
     160 */
     161function headless_ga4_render_horizontal_bar_svg($value, $max_value, $label)
     162{
     163    $value = max(0, (int) $value);
     164    $max_value = max(1, (int) $max_value);
     165    $width = 180;
     166    $height = 10;
     167    $bar_width = max(1, min($width, ($value / $max_value) * $width));
     168
     169    return '<svg class="headless-ga4-widget-rank-bar" viewBox="0 0 ' . esc_attr((string) $width) . ' ' . esc_attr((string) $height) . '" role="img" aria-label="' . esc_attr($label) . '"><rect class="headless-ga4-widget-rank-bar-track" x="0" y="0" width="' . esc_attr((string) $width) . '" height="' . esc_attr((string) $height) . '" rx="5" /><rect class="headless-ga4-widget-rank-bar-value" x="0" y="0" width="' . esc_attr((string) round($bar_width, 2)) . '" height="' . esc_attr((string) $height) . '" rx="5" /></svg>';
    81170}
    82171
    … …  
    93182
    94183    check_admin_referer('headless_ga4_refresh');
     184    $period = headless_ga4_get_posted_dashboard_period();
    95185    headless_ga4_clear_cached_data();
    96     $report = headless_ga4_fetch_dashboard_report(true);
     186    $report = headless_ga4_fetch_dashboard_report(true, $period);
    97187
    98188    if (is_wp_error($report)) {
    … …  
    102192    }
    103193
    104     wp_safe_redirect(admin_url('index.php'));
     194    wp_safe_redirect(add_query_arg('headless_ga4_period', $period, admin_url('index.php')));
    105195    exit;
    106196}
    … …  
    122212    $top_sources = array();
    123213    $last_refreshed_at = 0;
     214    $current_period = headless_ga4_get_requested_dashboard_period();
     215    $period_choices = headless_ga4_get_dashboard_period_choices();
     216    $period_label = isset($period_choices[$current_period]) ? $period_choices[$current_period] : __('Weekly', 'headless-ga4');
     217    $trend_label = __('Daily sessions', 'headless-ga4');
     218    $headless_ga4_top_pages_max_views = 0;
     219    $headless_ga4_top_sources_max_sessions = 0;
    124220    $cards = array(
    125221        'users'            => __('Users', 'headless-ga4'),
    … …  
    135231    }
    136232
    137     $report = headless_ga4_fetch_dashboard_report();
     233    $report = headless_ga4_fetch_dashboard_report(false, $current_period);
    138234
    139235    if (is_wp_error($report)) {
    … …  
    150246    $top_sources = isset($report['top_sources']) && is_array($report['top_sources']) ? $report['top_sources'] : array();
    151247    $last_refreshed_at = isset($report['last_refreshed_at']) ? (int) $report['last_refreshed_at'] : 0;
     248    $trend_label = isset($report['trend_label']) ? (string) $report['trend_label'] : $trend_label;
     249    $period_label = isset($report['period_label']) ? (string) $report['period_label'] : $period_label;
     250
     251    foreach ($top_pages as $headless_ga4_top_page) {
     252        $headless_ga4_top_pages_max_views = max(
     253            $headless_ga4_top_pages_max_views,
     254            isset($headless_ga4_top_page['views']) ? (int) $headless_ga4_top_page['views'] : 0
     255        );
     256    }
     257
     258    foreach ($top_sources as $headless_ga4_top_source) {
     259        $headless_ga4_top_sources_max_sessions = max(
     260            $headless_ga4_top_sources_max_sessions,
     261            isset($headless_ga4_top_source['sessions']) ? (int) $headless_ga4_top_source['sessions'] : 0
     262        );
     263    }
    152264
    153265    require HEADLESS_GA4_PLUGIN_PATH . 'templates/dashboard-widget.php';
  • headless-ga4/tags/0.2.1/includes/ga4-api.php

    r3504948 r3798238  
    118118
    119119/**
     120 * Returns the available dashboard reporting periods.
     121 *
     122 * @return array<string, string>
     123 */
     124function headless_ga4_get_dashboard_period_choices()
     125{
     126    return array(
     127        'weekly'   => __('Weekly', 'headless-ga4'),
     128        'monthly'  => __('Monthly', 'headless-ga4'),
     129        'yearly'   => __('Yearly', 'headless-ga4'),
     130        'all_time' => __('All time', 'headless-ga4'),
     131    );
     132}
     133
     134/**
     135 * Normalizes the dashboard period key.
     136 *
     137 * @param string $period Raw period key.
     138 *
     139 * @return string
     140 */
     141function headless_ga4_normalize_dashboard_period($period)
     142{
     143    $period = is_string($period) ? sanitize_key($period) : 'weekly';
     144    $choices = headless_ga4_get_dashboard_period_choices();
     145
     146    return isset($choices[$period]) ? $period : 'weekly';
     147}
     148
     149/**
     150 * Returns report configuration for a dashboard period.
     151 *
     152 * @param string $period Dashboard period key.
     153 *
     154 * @return array<string, mixed>
     155 */
     156function headless_ga4_get_dashboard_period_config($period)
     157{
     158    $period = headless_ga4_normalize_dashboard_period($period);
     159    $choices = headless_ga4_get_dashboard_period_choices();
     160    $configs = array(
     161        'weekly' => array(
     162            'current_range'   => array('startDate' => '6daysAgo', 'endDate' => 'today'),
     163            'previous_range'  => array('startDate' => '13daysAgo', 'endDate' => '7daysAgo'),
     164            'trend_dimension' => 'date',
     165            'trend_label'     => __('Daily sessions', 'headless-ga4'),
     166            'trend_limit'     => 7,
     167        ),
     168        'monthly' => array(
     169            'current_range'   => array('startDate' => '29daysAgo', 'endDate' => 'today'),
     170            'previous_range'  => array('startDate' => '59daysAgo', 'endDate' => '30daysAgo'),
     171            'trend_dimension' => 'date',
     172            'trend_label'     => __('Daily sessions', 'headless-ga4'),
     173            'trend_limit'     => 30,
     174        ),
     175        'yearly' => array(
     176            'current_range'   => array('startDate' => '365daysAgo', 'endDate' => 'today'),
     177            'previous_range'  => array('startDate' => '731daysAgo', 'endDate' => '366daysAgo'),
     178            'trend_dimension' => 'yearMonth',
     179            'trend_label'     => __('Monthly sessions', 'headless-ga4'),
     180            'trend_limit'     => 13,
     181        ),
     182        'all_time' => array(
     183            'current_range'   => array('startDate' => '2020-10-14', 'endDate' => 'today'),
     184            'previous_range'  => null,
     185            'trend_dimension' => 'year',
     186            'trend_label'     => __('Yearly sessions', 'headless-ga4'),
     187            'trend_limit'     => 100,
     188        ),
     189    );
     190
     191    $config = $configs[$period];
     192    $config['key'] = $period;
     193    $config['label'] = $choices[$period];
     194
     195    return $config;
     196}
     197
     198/**
     199 * Formats a trend dimension value for display.
     200 *
     201 * @param string $value     Raw GA4 dimension value.
     202 * @param string $dimension GA4 dimension name.
     203 *
     204 * @return string
     205 */
     206function headless_ga4_format_trend_label($value, $dimension)
     207{
     208    if ($dimension === 'date') {
     209        $date = DateTime::createFromFormat('Ymd', $value, wp_timezone());
     210
     211        return $date instanceof DateTime ? wp_date('M j', $date->getTimestamp()) : $value;
     212    }
     213
     214    if ($dimension === 'yearMonth') {
     215        $date = DateTime::createFromFormat('Ym', $value, wp_timezone());
     216
     217        return $date instanceof DateTime ? wp_date('M Y', $date->getTimestamp()) : $value;
     218    }
     219
     220    return $value;
     221}
     222
     223/**
     224 * Returns an empty metric summary.
     225 *
     226 * @return array<string, int>
     227 */
     228function headless_ga4_get_empty_metric_summary()
     229{
     230    return array(
     231        'activeUsers'     => 0,
     232        'sessions'        => 0,
     233        'screenPageViews' => 0,
     234        'engagedSessions' => 0,
     235    );
     236}
     237
     238/**
     239 * Builds normalized trend points and fills empty buckets for the selected period.
     240 *
     241 * @param array<string, mixed> $trend_report  Trend report.
     242 * @param array<string, mixed> $period_config Period configuration.
     243 *
     244 * @return array<int, array<string, int|string>>
     245 */
     246function headless_ga4_build_trend_points($trend_report, $period_config)
     247{
     248    $trend_rows = isset($trend_report['rows']) && is_array($trend_report['rows']) ? $trend_report['rows'] : array();
     249    $trend_dimension = isset($period_config['trend_dimension']) ? (string) $period_config['trend_dimension'] : 'date';
     250    $period = isset($period_config['key']) ? (string) $period_config['key'] : 'weekly';
     251    $values = array();
     252
     253    foreach ($trend_rows as $row) {
     254        $dimension_value = isset($row['dimensionValues'][0]['value']) ? (string) $row['dimensionValues'][0]['value'] : '';
     255
     256        if ($dimension_value === '') {
     257            continue;
     258        }
     259
     260        $values[$dimension_value] = isset($row['metricValues'][0]['value']) ? (int) $row['metricValues'][0]['value'] : 0;
     261    }
     262
     263    if ($period === 'weekly' || $period === 'monthly') {
     264        $days = $period === 'weekly' ? 7 : 30;
     265        $date = new DateTime('-' . ($days - 1) . ' days', wp_timezone());
     266        $trend = array();
     267
     268        for ($index = 0; $index < $days; $index++) {
     269            $key = $date->format('Ymd');
     270            $trend[] = array(
     271                'label' => wp_date('M j', $date->getTimestamp()),
     272                'value' => isset($values[$key]) ? (int) $values[$key] : 0,
     273            );
     274            $date->modify('+1 day');
     275        }
     276
     277        return $trend;
     278    }
     279
     280    if ($period === 'yearly') {
     281        $date = new DateTime('first day of this month', wp_timezone());
     282        $date->modify('-12 months');
     283        $trend = array();
     284
     285        for ($index = 0; $index < 13; $index++) {
     286            $key = $date->format('Ym');
     287            $trend[] = array(
     288                'label' => wp_date('M Y', $date->getTimestamp()),
     289                'value' => isset($values[$key]) ? (int) $values[$key] : 0,
     290            );
     291            $date->modify('+1 month');
     292        }
     293
     294        return $trend;
     295    }
     296
     297    if (empty($values)) {
     298        return array(
     299            array(
     300                'label' => wp_date('Y'),
     301                'value' => 0,
     302            ),
     303        );
     304    }
     305
     306    $trend = array();
     307
     308    foreach ($values as $dimension_value => $session_value) {
     309        $trend[] = array(
     310            'label' => headless_ga4_format_trend_label((string) $dimension_value, $trend_dimension),
     311            'value' => (int) $session_value,
     312        );
     313    }
     314
     315    return $trend;
     316}
     317
     318/**
    120319 * Builds the normalized widget payload from GA4 report responses.
    121320 *
    … …  
    125324 * @param array<string, mixed>     $pages_report     Top pages report.
    126325 * @param array<string, mixed>     $sources_report   Top sources report.
     326 * @param array<string, mixed>     $period_config    Period configuration.
    127327 *
    128328 * @return array<string, mixed>
    129329 */
    130 function headless_ga4_build_dashboard_payload($current_summary, $previous_summary, $trend_report, $pages_report, $sources_report)
     330function headless_ga4_build_dashboard_payload($current_summary, $previous_summary, $trend_report, $pages_report, $sources_report, $period_config)
    131331{
    132332    $summary = array(
    … …  
    168368    );
    169369
    170     $trend = array();
    171     $trend_rows = isset($trend_report['rows']) && is_array($trend_report['rows']) ? $trend_report['rows'] : array();
    172 
    173     foreach ($trend_rows as $row) {
    174         $date_value = isset($row['dimensionValues'][0]['value']) ? (string) $row['dimensionValues'][0]['value'] : '';
    175         $session_value = isset($row['metricValues'][0]['value']) ? (int) $row['metricValues'][0]['value'] : 0;
    176 
    177         if ($date_value === '') {
    178             continue;
    179         }
    180 
    181         $date = DateTime::createFromFormat('Ymd', $date_value, wp_timezone());
    182 
    183         $trend[] = array(
    184             'label' => $date instanceof DateTime ? wp_date('M j', $date->getTimestamp()) : $date_value,
    185             'value' => $session_value,
    186         );
    187     }
     370    $trend = headless_ga4_build_trend_points($trend_report, $period_config);
    188371
    189372    $top_pages = array();
    … …  
    221404        'comparison'        => $comparison,
    222405        'trend'             => $trend,
     406        'period'            => isset($period_config['key']) ? (string) $period_config['key'] : 'weekly',
     407        'period_label'      => isset($period_config['label']) ? (string) $period_config['label'] : __('Weekly', 'headless-ga4'),
     408        'trend_label'       => isset($period_config['trend_label']) ? (string) $period_config['trend_label'] : __('Daily sessions', 'headless-ga4'),
    223409        'top_pages'         => $top_pages,
    224410        'top_sources'       => $top_sources,
    … …  
    231417 *
    232418 * @param bool $force_refresh Whether to bypass the report cache.
     419 * @param string $period       Dashboard period key.
    233420 *
    234421 * @return array<string, mixed>|WP_Error
    235422 */
    236 function headless_ga4_fetch_dashboard_report($force_refresh = false)
     423function headless_ga4_fetch_dashboard_report($force_refresh = false, $period = 'weekly')
    237424{
    238425    if (!headless_ga4_is_configured()) {
    … …  
    243430    }
    244431
     432    $period = headless_ga4_normalize_dashboard_period($period);
     433    $period_config = headless_ga4_get_dashboard_period_config($period);
     434    $current_range = isset($period_config['current_range']) && is_array($period_config['current_range']) ? $period_config['current_range'] : array('startDate' => '6daysAgo', 'endDate' => 'today');
     435    $previous_range = isset($period_config['previous_range']) && is_array($period_config['previous_range']) ? $period_config['previous_range'] : null;
     436    $trend_dimension = isset($period_config['trend_dimension']) ? (string) $period_config['trend_dimension'] : 'date';
     437    $trend_limit = isset($period_config['trend_limit']) ? (int) $period_config['trend_limit'] : 30;
     438
    245439    if (!$force_refresh) {
    246         $cached_report = headless_ga4_get_cached_report();
     440        $cached_report = headless_ga4_get_cached_report($period);
    247441
    248442        if (is_array($cached_report)) {
    … …  
    254448        array(
    255449            'dateRanges' => array(
    256                 array(
    257                     'startDate' => '6daysAgo',
    258                     'endDate'   => 'today',
    259                 ),
     450                $current_range,
    260451            ),
    261452            'metrics' => array(
    … …  
    272463    }
    273464
    274     $summary_previous = headless_ga4_run_report(
    275         array(
    276             'dateRanges' => array(
    277                 array(
    278                     'startDate' => '13daysAgo',
    279                     'endDate'   => '7daysAgo',
     465    if (is_array($previous_range)) {
     466        $summary_previous = headless_ga4_run_report(
     467            array(
     468                'dateRanges' => array(
     469                    $previous_range,
    280470                ),
    281             ),
    282             'metrics' => array(
    283                 array('name' => 'activeUsers'),
    284                 array('name' => 'sessions'),
    285                 array('name' => 'screenPageViews'),
    286                 array('name' => 'engagedSessions'),
    287             ),
    288         )
    289     );
    290 
    291     if (is_wp_error($summary_previous)) {
    292         return $summary_previous;
     471                'metrics' => array(
     472                    array('name' => 'activeUsers'),
     473                    array('name' => 'sessions'),
     474                    array('name' => 'screenPageViews'),
     475                    array('name' => 'engagedSessions'),
     476                ),
     477            )
     478        );
     479
     480        if (is_wp_error($summary_previous)) {
     481            return $summary_previous;
     482        }
     483
     484        $previous_summary = headless_ga4_map_metric_row($summary_previous);
     485    } else {
     486        $previous_summary = headless_ga4_get_empty_metric_summary();
    293487    }
    294488
    … …  
    296490        array(
    297491            'dateRanges' => array(
    298                 array(
    299                     'startDate' => '6daysAgo',
    300                     'endDate'   => 'today',
    301                 ),
     492                $current_range,
    302493            ),
    303494            'dimensions' => array(
    304                 array('name' => 'date'),
     495                array('name' => $trend_dimension),
    305496            ),
    306497            'metrics' => array(
    … …  
    310501                array(
    311502                    'dimension' => array(
    312                         'dimensionName' => 'date',
     503                        'dimensionName' => $trend_dimension,
    313504                    ),
    314505                ),
    315506            ),
     507            'limit' => $trend_limit,
    316508        )
    317509    );
    … …  
    324516        array(
    325517            'dateRanges' => array(
    326                 array(
    327                     'startDate' => '6daysAgo',
    328                     'endDate'   => 'today',
    329                 ),
     518                $current_range,
    330519            ),
    331520            'dimensions' => array(
    … …  
    355544        array(
    356545            'dateRanges' => array(
    357                 array(
    358                     'startDate' => '6daysAgo',
    359                     'endDate'   => 'today',
    360                 ),
     546                $current_range,
    361547            ),
    362548            'dimensions' => array(
    … …  
    385571    $report = headless_ga4_build_dashboard_payload(
    386572        headless_ga4_map_metric_row($summary_current),
    387         headless_ga4_map_metric_row($summary_previous),
     573        $previous_summary,
    388574        $trend_report,
    389575        $pages_report,
    390         $sources_report
    391     );
    392 
    393     headless_ga4_set_cached_report($report);
     576        $sources_report,
     577        $period_config
     578    );
     579
     580    headless_ga4_set_cached_report($report, $period);
    394581
    395582    return $report;
    396583}
    397 
  • headless-ga4/tags/0.2.1/includes/settings-page.php

    r3504948 r3798238  
    116116
    117117    if ($value === '') {
    118         headless_ga4_set_option_autoload_no(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
    119118        return $current;
    120119    }
    … …  
    134133
    135134    headless_ga4_clear_cached_data();
    136     headless_ga4_set_option_autoload_no(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
    137135
    138136    return (string) wp_json_encode(
    … …  
    304302
    305303    check_admin_referer('headless_ga4_clear_credentials');
    306     update_option(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION, '');
    307     headless_ga4_set_option_autoload_no(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
     304    delete_option(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
     305    add_option(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION, '', '', false);
    308306    headless_ga4_clear_cached_data();
    309307    headless_ga4_set_admin_notice(__('Stored service account credentials were cleared.', 'headless-ga4'));
  • headless-ga4/tags/0.2.1/readme.txt

    r3504948 r3798238  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 0.1.1
     7Stable tag: 0.2.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    121121== Changelog ==
    122122
     123= 0.2.1 =
     124
     125* Fix recursive service account JSON sanitization when saving settings
     126* Fix clearing stored service account credentials
     127
     128= 0.2.0 =
     129
     130* Add dashboard period filters for weekly, monthly, yearly, and all-time reports
     131* Add period-aware GA4 report caching
     132* Improve the sessions trend diagram with combined bar and line chart display
     133
    123134= 0.1.1 =
    124135
    … …  
    137148== Upgrade Notice ==
    138149
     150= 0.2.1 =
     151
     152Fixes a critical error when saving service account JSON credentials.
     153
     154= 0.2.0 =
     155
     156Adds dashboard report period filters and an improved sessions diagram.
     157
    139158= 0.1.1 =
    140159
  • headless-ga4/tags/0.2.1/templates/dashboard-widget.php

    r3504948 r3798238  
    55 * @var array<string, string>          $cards
    66 * @var array<string, mixed>           $comparison
     7 * @var string                         $current_period
    78 * @var int                            $last_refreshed_at
     9 * @var string                         $period_label
     10 * @var array<string, string>          $period_choices
     11 * @var int                            $headless_ga4_top_pages_max_views
     12 * @var int                            $headless_ga4_top_sources_max_sessions
    813 * @var string                         $state
    914 * @var array<int, array<string, mixed>> $top_pages
    … …  
    1116 * @var array<string, mixed>           $summary
    1217 * @var array<int, array<string, mixed>> $trend
     18 * @var string                         $trend_label
    1319 * @var string                         $error_message
    1420 */
    … …  
    4248endif;
    4349?>
     50<div class="headless-ga4-widget-period-header">
     51    <div>
     52        <span class="headless-ga4-widget-label"><?php esc_html_e('Report period', 'headless-ga4'); ?></span>
     53        <strong><?php echo esc_html($period_label); ?></strong>
     54    </div>
     55    <div class="headless-ga4-widget-period-filter" aria-label="<?php esc_attr_e('Choose report period', 'headless-ga4'); ?>">
     56        <?php foreach ($period_choices as $headless_ga4_period_key => $headless_ga4_period_choice_label) : ?>
     57            <a
     58                class="headless-ga4-widget-period-link<?php echo $current_period === $headless_ga4_period_key ? ' is-active' : ''; ?>"
     59                href="<?php echo esc_url(add_query_arg('headless_ga4_period', $headless_ga4_period_key, admin_url('index.php'))); ?>"
     60                <?php echo $current_period === $headless_ga4_period_key ? 'aria-current="true"' : ''; ?>
     61            >
     62                <?php echo esc_html($headless_ga4_period_choice_label); ?>
     63            </a>
     64        <?php endforeach; ?>
     65    </div>
     66</div>
     67
    4468<div class="headless-ga4-widget-grid">
    4569    <?php foreach ($cards as $headless_ga4_card_key => $headless_ga4_card_label) : ?>
    … …  
    5983<div class="headless-ga4-widget-layout">
    6084    <div class="headless-ga4-widget-panel">
    61         <span class="headless-ga4-widget-label"><?php esc_html_e('Sessions trend', 'headless-ga4'); ?></span>
     85        <span class="headless-ga4-widget-label"><?php echo esc_html($trend_label); ?></span>
    6286        <?php if (!empty($trend)) : ?>
    6387            <div class="headless-ga4-widget-chart">
    64                 <?php echo headless_ga4_render_trend_svg($trend); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
     88                <?php echo wp_kses(headless_ga4_render_trend_svg($trend, $trend_label), headless_ga4_get_allowed_chart_svg_html()); ?>
    6589            </div>
    6690            <div class="headless-ga4-widget-list">
    … …  
    86110                            <strong class="headless-ga4-widget-item-title"><?php echo esc_html($headless_ga4_page['title'] !== '' ? (string) $headless_ga4_page['title'] : (string) $headless_ga4_page['path']); ?></strong>
    87111                            <div class="headless-ga4-widget-muted"><?php echo esc_html((string) $headless_ga4_page['path']); ?></div>
     112                            <?php
     113                            echo wp_kses(
     114                                headless_ga4_render_horizontal_bar_svg(
     115                                    (int) $headless_ga4_page['views'],
     116                                    $headless_ga4_top_pages_max_views,
     117                                    sprintf(
     118                                        /* translators: %s: number of page views. */
     119                                        __('%s page views', 'headless-ga4'),
     120                                        number_format_i18n((int) $headless_ga4_page['views'])
     121                                    )
     122                                ),
     123                                headless_ga4_get_allowed_chart_svg_html()
     124                            );
     125                            ?>
    88126                        </div>
    89127                        <span class="headless-ga4-widget-muted"><?php echo esc_html(number_format_i18n((int) $headless_ga4_page['views'])); ?></span>
    … …  
    106144                        <strong class="headless-ga4-widget-item-title"><?php echo esc_html((string) $headless_ga4_source['source']); ?></strong>
    107145                        <div class="headless-ga4-widget-muted"><?php echo esc_html((string) $headless_ga4_source['medium']); ?></div>
     146                        <?php
     147                        echo wp_kses(
     148                            headless_ga4_render_horizontal_bar_svg(
     149                                (int) $headless_ga4_source['sessions'],
     150                                $headless_ga4_top_sources_max_sessions,
     151                                sprintf(
     152                                    /* translators: %s: number of sessions. */
     153                                    __('%s sessions', 'headless-ga4'),
     154                                    number_format_i18n((int) $headless_ga4_source['sessions'])
     155                                )
     156                            ),
     157                            headless_ga4_get_allowed_chart_svg_html()
     158                        );
     159                        ?>
    108160                    </div>
    109161                    <span class="headless-ga4-widget-muted"><?php echo esc_html(number_format_i18n((int) $headless_ga4_source['sessions'])); ?></span>
    … …  
    135187            <?php wp_nonce_field('headless_ga4_refresh'); ?>
    136188            <input type="hidden" name="action" value="headless_ga4_refresh" />
     189            <input type="hidden" name="headless_ga4_period" value="<?php echo esc_attr($current_period); ?>" />
    137190            <?php submit_button(__('Refresh data', 'headless-ga4'), 'secondary', 'submit', false); ?>
    138191        </form>
  • headless-ga4/trunk/assets/admin.css

    r3504948 r3798238  
    7373}
    7474
     75.headless-ga4-widget-period-header {
     76    display: flex;
     77    align-items: center;
     78    justify-content: space-between;
     79    gap: 14px;
     80    margin-bottom: 16px;
     81}
     82
     83.headless-ga4-widget-period-header strong {
     84    display: block;
     85    margin-top: 4px;
     86    font-size: 18px;
     87}
     88
     89.headless-ga4-widget-period-filter {
     90    display: flex;
     91    flex-wrap: wrap;
     92    gap: 6px;
     93}
     94
     95.headless-ga4-widget-period-link {
     96    border: 1px solid #c3c4c7;
     97    border-radius: 999px;
     98    background: #f6f7f7;
     99    color: #1d2327;
     100    padding: 5px 10px;
     101    text-decoration: none;
     102}
     103
     104.headless-ga4-widget-period-link:hover,
     105.headless-ga4-widget-period-link:focus {
     106    border-color: #2271b1;
     107    color: #0a4b78;
     108}
     109
     110.headless-ga4-widget-period-link.is-active {
     111    border-color: #1d2327;
     112    background: #1d2327;
     113    color: #fff;
     114}
     115
    75116.headless-ga4-widget-card,
    76117.headless-ga4-widget-panel {
    … …  
    109150.headless-ga4-widget-list {
    110151    margin: 10px 0 0;
     152    max-height: 230px;
     153    overflow: auto;
    111154}
    112155
    … …  
    128171    margin: 0;
    129172    font-size: 14px;
     173}
     174
     175.headless-ga4-widget-rank-bar {
     176    display: block;
     177    width: min(180px, 100%);
     178    height: 10px;
     179    margin-top: 8px;
     180}
     181
     182.headless-ga4-widget-rank-bar-track {
     183    fill: #edf0f2;
     184}
     185
     186.headless-ga4-widget-rank-bar-value {
     187    fill: #2271b1;
    130188}
    131189
    … …  
    145203.headless-ga4-widget-chart {
    146204    margin-top: 10px;
     205    border: 1px solid #ececec;
     206    background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
     207    padding: 10px;
    147208}
    148209
    … …  
    151212    width: 100%;
    152213    height: auto;
     214}
     215
     216.headless-ga4-widget-chart-bars rect {
     217    fill: #d7e8f7;
     218}
     219
     220.headless-ga4-widget-chart-line {
     221    stroke: #111111;
     222    stroke-linecap: round;
     223    stroke-linejoin: round;
     224    stroke-width: 3;
    153225}
    154226
    … …  
    170242
    171243@media (max-width: 782px) {
     244    .headless-ga4-widget-period-header {
     245        align-items: flex-start;
     246        flex-direction: column;
     247    }
     248
    172249    .headless-ga4-widget-grid,
    173250    .headless-ga4-widget-layout {
  • headless-ga4/trunk/headless-ga4.php

    r3504948 r3798238  
    44 * Plugin URI: https://studio.jakubkanna.com/
    55 * Description: Display lightweight GA4 data in the WordPress dashboard for headless frontends.
    6  * Version: 0.1.1
     6 * Version: 0.2.1
    77 * Requires at least: 6.4
    88 * Requires PHP: 7.4
    … …  
    1919}
    2020
    21 define('HEADLESS_GA4_VERSION', '0.1.1');
     21define('HEADLESS_GA4_VERSION', '0.2.1');
    2222define('HEADLESS_GA4_PLUGIN_FILE', __FILE__);
    2323define('HEADLESS_GA4_PLUGIN_PATH', plugin_dir_path(__FILE__));
  • headless-ga4/trunk/includes/cache.php

    r3504948 r3798238  
    5555
    5656/**
     57 * Returns the transient name for a period-specific report cache.
     58 *
     59 * @param string $period Dashboard period key.
     60 *
     61 * @return string
     62 */
     63function headless_ga4_get_report_transient_name($period = 'weekly')
     64{
     65    $period = is_string($period) ? sanitize_key($period) : 'weekly';
     66
     67    if (!in_array($period, array('weekly', 'monthly', 'yearly', 'all_time'), true)) {
     68        $period = 'weekly';
     69    }
     70
     71    return HEADLESS_GA4_REPORT_TRANSIENT . '_' . $period;
     72}
     73
     74/**
    5775 * Returns the cached report payload.
     76 *
     77 * @param string $period Dashboard period key.
    5878 *
    5979 * @return array<string, mixed>|null
    6080 */
    61 function headless_ga4_get_cached_report()
     81function headless_ga4_get_cached_report($period = 'weekly')
    6282{
    63     $cached = get_transient(HEADLESS_GA4_REPORT_TRANSIENT);
     83    $cached = get_transient(headless_ga4_get_report_transient_name($period));
    6484
    6585    return is_array($cached) ? $cached : null;
    … …  
    7090 *
    7191 * @param array<string, mixed> $report Report payload.
     92 * @param string               $period Dashboard period key.
    7293 *
    7394 * @return void
    7495 */
    75 function headless_ga4_set_cached_report($report)
     96function headless_ga4_set_cached_report($report, $period = 'weekly')
    7697{
    7798    set_transient(
    78         HEADLESS_GA4_REPORT_TRANSIENT,
     99        headless_ga4_get_report_transient_name($period),
    79100        $report,
    80101        headless_ga4_get_cache_ttl_seconds()
    … …  
    89110function headless_ga4_clear_cached_data()
    90111{
     112    $periods = array('weekly', 'monthly', 'yearly', 'all_time');
     113
    91114    delete_transient(HEADLESS_GA4_ACCESS_TOKEN_TRANSIENT);
    92115    delete_transient(HEADLESS_GA4_REPORT_TRANSIENT);
     116
     117    foreach ($periods as $period) {
     118        delete_transient(headless_ga4_get_report_transient_name($period));
     119    }
    93120}
    94 
  • headless-ga4/trunk/includes/dashboard-widget.php

    r3504948 r3798238  
    4343
    4444/**
    45  * Builds a simple SVG polyline chart for the sessions trend.
    46  *
    47  * @param array<int, array<string, mixed>> $trend Trend rows.
    48  *
    49  * @return string
    50  */
    51 function headless_ga4_render_trend_svg($trend)
     45 * Returns the dashboard period requested in the URL.
     46 *
     47 * @return string
     48 */
     49function headless_ga4_get_requested_dashboard_period()
     50{
     51    $period = filter_input(INPUT_GET, 'headless_ga4_period', FILTER_SANITIZE_SPECIAL_CHARS);
     52
     53    return headless_ga4_normalize_dashboard_period(is_string($period) ? $period : 'weekly');
     54}
     55
     56/**
     57 * Returns the dashboard period posted by the refresh form.
     58 *
     59 * @return string
     60 */
     61function headless_ga4_get_posted_dashboard_period()
     62{
     63    $period = filter_input(INPUT_POST, 'headless_ga4_period', FILTER_SANITIZE_SPECIAL_CHARS);
     64
     65    return headless_ga4_normalize_dashboard_period(is_string($period) ? $period : 'weekly');
     66}
     67
     68/**
     69 * Returns allowed SVG markup for dashboard charts.
     70 *
     71 * @return array<string, array<string, bool>>
     72 */
     73function headless_ga4_get_allowed_chart_svg_html()
     74{
     75    return array(
     76        'svg' => array(
     77            'aria-label' => true,
     78            'class'      => true,
     79            'role'       => true,
     80            'viewbox'    => true,
     81            'viewBox'    => true,
     82        ),
     83        'g' => array(
     84            'class' => true,
     85        ),
     86        'polyline' => array(
     87            'class'  => true,
     88            'fill'   => true,
     89            'points' => true,
     90        ),
     91        'rect' => array(
     92            'class'  => true,
     93            'height' => true,
     94            'rx'     => true,
     95            'width'  => true,
     96            'x'      => true,
     97            'y'      => true,
     98        ),
     99    );
     100}
     101
     102/**
     103 * Builds an SVG bar and line diagram for the sessions trend.
     104 *
     105 * @param array<int, array<string, mixed>> $trend      Trend rows.
     106 * @param string                           $chart_label Accessible chart label.
     107 *
     108 * @return string
     109 */
     110function headless_ga4_render_trend_svg($trend, $chart_label = '')
    52111{
    53112    if (empty($trend)) {
    … …  
    55114    }
    56115
    57     $width = 320;
    58     $height = 92;
    59     $padding = 8;
     116    $width = 520;
     117    $height = 180;
     118    $padding_x = 18;
     119    $padding_y = 16;
    60120    $values = array_map(
    61121        static function ($point) {
    … …  
    65125    );
    66126    $max_value = max(1, max($values));
    67     $min_value = min($values);
    68     $range = max(1, $max_value - $min_value);
     127    $range = max(1, $max_value);
    69128    $count = count($trend);
    70     $step_x = $count > 1 ? ($width - ($padding * 2)) / ($count - 1) : 0;
     129    $inner_width = $width - ($padding_x * 2);
     130    $inner_height = $height - ($padding_y * 2);
     131    $step_x = $count > 1 ? $inner_width / ($count - 1) : 0;
     132    $bar_slot = $count > 0 ? $inner_width / $count : $inner_width;
     133    $bar_width = max(3, min(22, $bar_slot * 0.55));
    71134    $points = array();
     135    $bars = array();
     136    $chart_label = $chart_label !== '' ? $chart_label : __('Sessions trend', 'headless-ga4');
    72137
    73138    foreach ($trend as $index => $point) {
    74139        $value = isset($point['value']) ? (int) $point['value'] : 0;
    75         $x = $padding + ($step_x * $index);
    76         $y = $height - $padding - ((($value - $min_value) / $range) * ($height - ($padding * 2)));
     140        $x = $padding_x + ($step_x * $index);
     141        $bar_height = max(1, ($value / $range) * $inner_height);
     142        $bar_x = $x - ($bar_width / 2);
     143        $bar_y = $height - $padding_y - $bar_height;
     144        $y = $height - $padding_y - (($value / $range) * $inner_height);
    77145        $points[] = round($x, 2) . ',' . round($y, 2);
    78     }
    79 
    80     return '<svg viewBox="0 0 ' . esc_attr((string) $width) . ' ' . esc_attr((string) $height) . '" role="img" aria-label="' . esc_attr__('Sessions trend for the last 7 days', 'headless-ga4') . '"><polyline fill="none" stroke="#111111" stroke-width="2" points="' . esc_attr(implode(' ', $points)) . '" /></svg>';
     146        $bars[] = '<rect x="' . esc_attr((string) round($bar_x, 2)) . '" y="' . esc_attr((string) round($bar_y, 2)) . '" width="' . esc_attr((string) round($bar_width, 2)) . '" height="' . esc_attr((string) round($bar_height, 2)) . '" rx="3" />';
     147    }
     148
     149    return '<svg viewBox="0 0 ' . esc_attr((string) $width) . ' ' . esc_attr((string) $height) . '" role="img" aria-label="' . esc_attr($chart_label) . '"><g class="headless-ga4-widget-chart-bars">' . implode('', $bars) . '</g><polyline class="headless-ga4-widget-chart-line" fill="none" points="' . esc_attr(implode(' ', $points)) . '" /></svg>';
     150}
     151
     152/**
     153 * Builds a compact horizontal bar SVG for ranked metric rows.
     154 *
     155 * @param int    $value Metric value.
     156 * @param int    $max_value Largest value in the list.
     157 * @param string $label Accessible chart label.
     158 *
     159 * @return string
     160 */
     161function headless_ga4_render_horizontal_bar_svg($value, $max_value, $label)
     162{
     163    $value = max(0, (int) $value);
     164    $max_value = max(1, (int) $max_value);
     165    $width = 180;
     166    $height = 10;
     167    $bar_width = max(1, min($width, ($value / $max_value) * $width));
     168
     169    return '<svg class="headless-ga4-widget-rank-bar" viewBox="0 0 ' . esc_attr((string) $width) . ' ' . esc_attr((string) $height) . '" role="img" aria-label="' . esc_attr($label) . '"><rect class="headless-ga4-widget-rank-bar-track" x="0" y="0" width="' . esc_attr((string) $width) . '" height="' . esc_attr((string) $height) . '" rx="5" /><rect class="headless-ga4-widget-rank-bar-value" x="0" y="0" width="' . esc_attr((string) round($bar_width, 2)) . '" height="' . esc_attr((string) $height) . '" rx="5" /></svg>';
    81170}
    82171
    … …  
    93182
    94183    check_admin_referer('headless_ga4_refresh');
     184    $period = headless_ga4_get_posted_dashboard_period();
    95185    headless_ga4_clear_cached_data();
    96     $report = headless_ga4_fetch_dashboard_report(true);
     186    $report = headless_ga4_fetch_dashboard_report(true, $period);
    97187
    98188    if (is_wp_error($report)) {
    … …  
    102192    }
    103193
    104     wp_safe_redirect(admin_url('index.php'));
     194    wp_safe_redirect(add_query_arg('headless_ga4_period', $period, admin_url('index.php')));
    105195    exit;
    106196}
    … …  
    122212    $top_sources = array();
    123213    $last_refreshed_at = 0;
     214    $current_period = headless_ga4_get_requested_dashboard_period();
     215    $period_choices = headless_ga4_get_dashboard_period_choices();
     216    $period_label = isset($period_choices[$current_period]) ? $period_choices[$current_period] : __('Weekly', 'headless-ga4');
     217    $trend_label = __('Daily sessions', 'headless-ga4');
     218    $headless_ga4_top_pages_max_views = 0;
     219    $headless_ga4_top_sources_max_sessions = 0;
    124220    $cards = array(
    125221        'users'            => __('Users', 'headless-ga4'),
    … …  
    135231    }
    136232
    137     $report = headless_ga4_fetch_dashboard_report();
     233    $report = headless_ga4_fetch_dashboard_report(false, $current_period);
    138234
    139235    if (is_wp_error($report)) {
    … …  
    150246    $top_sources = isset($report['top_sources']) && is_array($report['top_sources']) ? $report['top_sources'] : array();
    151247    $last_refreshed_at = isset($report['last_refreshed_at']) ? (int) $report['last_refreshed_at'] : 0;
     248    $trend_label = isset($report['trend_label']) ? (string) $report['trend_label'] : $trend_label;
     249    $period_label = isset($report['period_label']) ? (string) $report['period_label'] : $period_label;
     250
     251    foreach ($top_pages as $headless_ga4_top_page) {
     252        $headless_ga4_top_pages_max_views = max(
     253            $headless_ga4_top_pages_max_views,
     254            isset($headless_ga4_top_page['views']) ? (int) $headless_ga4_top_page['views'] : 0
     255        );
     256    }
     257
     258    foreach ($top_sources as $headless_ga4_top_source) {
     259        $headless_ga4_top_sources_max_sessions = max(
     260            $headless_ga4_top_sources_max_sessions,
     261            isset($headless_ga4_top_source['sessions']) ? (int) $headless_ga4_top_source['sessions'] : 0
     262        );
     263    }
    152264
    153265    require HEADLESS_GA4_PLUGIN_PATH . 'templates/dashboard-widget.php';
  • headless-ga4/trunk/includes/ga4-api.php

    r3504948 r3798238  
    118118
    119119/**
     120 * Returns the available dashboard reporting periods.
     121 *
     122 * @return array<string, string>
     123 */
     124function headless_ga4_get_dashboard_period_choices()
     125{
     126    return array(
     127        'weekly'   => __('Weekly', 'headless-ga4'),
     128        'monthly'  => __('Monthly', 'headless-ga4'),
     129        'yearly'   => __('Yearly', 'headless-ga4'),
     130        'all_time' => __('All time', 'headless-ga4'),
     131    );
     132}
     133
     134/**
     135 * Normalizes the dashboard period key.
     136 *
     137 * @param string $period Raw period key.
     138 *
     139 * @return string
     140 */
     141function headless_ga4_normalize_dashboard_period($period)
     142{
     143    $period = is_string($period) ? sanitize_key($period) : 'weekly';
     144    $choices = headless_ga4_get_dashboard_period_choices();
     145
     146    return isset($choices[$period]) ? $period : 'weekly';
     147}
     148
     149/**
     150 * Returns report configuration for a dashboard period.
     151 *
     152 * @param string $period Dashboard period key.
     153 *
     154 * @return array<string, mixed>
     155 */
     156function headless_ga4_get_dashboard_period_config($period)
     157{
     158    $period = headless_ga4_normalize_dashboard_period($period);
     159    $choices = headless_ga4_get_dashboard_period_choices();
     160    $configs = array(
     161        'weekly' => array(
     162            'current_range'   => array('startDate' => '6daysAgo', 'endDate' => 'today'),
     163            'previous_range'  => array('startDate' => '13daysAgo', 'endDate' => '7daysAgo'),
     164            'trend_dimension' => 'date',
     165            'trend_label'     => __('Daily sessions', 'headless-ga4'),
     166            'trend_limit'     => 7,
     167        ),
     168        'monthly' => array(
     169            'current_range'   => array('startDate' => '29daysAgo', 'endDate' => 'today'),
     170            'previous_range'  => array('startDate' => '59daysAgo', 'endDate' => '30daysAgo'),
     171            'trend_dimension' => 'date',
     172            'trend_label'     => __('Daily sessions', 'headless-ga4'),
     173            'trend_limit'     => 30,
     174        ),
     175        'yearly' => array(
     176            'current_range'   => array('startDate' => '365daysAgo', 'endDate' => 'today'),
     177            'previous_range'  => array('startDate' => '731daysAgo', 'endDate' => '366daysAgo'),
     178            'trend_dimension' => 'yearMonth',
     179            'trend_label'     => __('Monthly sessions', 'headless-ga4'),
     180            'trend_limit'     => 13,
     181        ),
     182        'all_time' => array(
     183            'current_range'   => array('startDate' => '2020-10-14', 'endDate' => 'today'),
     184            'previous_range'  => null,
     185            'trend_dimension' => 'year',
     186            'trend_label'     => __('Yearly sessions', 'headless-ga4'),
     187            'trend_limit'     => 100,
     188        ),
     189    );
     190
     191    $config = $configs[$period];
     192    $config['key'] = $period;
     193    $config['label'] = $choices[$period];
     194
     195    return $config;
     196}
     197
     198/**
     199 * Formats a trend dimension value for display.
     200 *
     201 * @param string $value     Raw GA4 dimension value.
     202 * @param string $dimension GA4 dimension name.
     203 *
     204 * @return string
     205 */
     206function headless_ga4_format_trend_label($value, $dimension)
     207{
     208    if ($dimension === 'date') {
     209        $date = DateTime::createFromFormat('Ymd', $value, wp_timezone());
     210
     211        return $date instanceof DateTime ? wp_date('M j', $date->getTimestamp()) : $value;
     212    }
     213
     214    if ($dimension === 'yearMonth') {
     215        $date = DateTime::createFromFormat('Ym', $value, wp_timezone());
     216
     217        return $date instanceof DateTime ? wp_date('M Y', $date->getTimestamp()) : $value;
     218    }
     219
     220    return $value;
     221}
     222
     223/**
     224 * Returns an empty metric summary.
     225 *
     226 * @return array<string, int>
     227 */
     228function headless_ga4_get_empty_metric_summary()
     229{
     230    return array(
     231        'activeUsers'     => 0,
     232        'sessions'        => 0,
     233        'screenPageViews' => 0,
     234        'engagedSessions' => 0,
     235    );
     236}
     237
     238/**
     239 * Builds normalized trend points and fills empty buckets for the selected period.
     240 *
     241 * @param array<string, mixed> $trend_report  Trend report.
     242 * @param array<string, mixed> $period_config Period configuration.
     243 *
     244 * @return array<int, array<string, int|string>>
     245 */
     246function headless_ga4_build_trend_points($trend_report, $period_config)
     247{
     248    $trend_rows = isset($trend_report['rows']) && is_array($trend_report['rows']) ? $trend_report['rows'] : array();
     249    $trend_dimension = isset($period_config['trend_dimension']) ? (string) $period_config['trend_dimension'] : 'date';
     250    $period = isset($period_config['key']) ? (string) $period_config['key'] : 'weekly';
     251    $values = array();
     252
     253    foreach ($trend_rows as $row) {
     254        $dimension_value = isset($row['dimensionValues'][0]['value']) ? (string) $row['dimensionValues'][0]['value'] : '';
     255
     256        if ($dimension_value === '') {
     257            continue;
     258        }
     259
     260        $values[$dimension_value] = isset($row['metricValues'][0]['value']) ? (int) $row['metricValues'][0]['value'] : 0;
     261    }
     262
     263    if ($period === 'weekly' || $period === 'monthly') {
     264        $days = $period === 'weekly' ? 7 : 30;
     265        $date = new DateTime('-' . ($days - 1) . ' days', wp_timezone());
     266        $trend = array();
     267
     268        for ($index = 0; $index < $days; $index++) {
     269            $key = $date->format('Ymd');
     270            $trend[] = array(
     271                'label' => wp_date('M j', $date->getTimestamp()),
     272                'value' => isset($values[$key]) ? (int) $values[$key] : 0,
     273            );
     274            $date->modify('+1 day');
     275        }
     276
     277        return $trend;
     278    }
     279
     280    if ($period === 'yearly') {
     281        $date = new DateTime('first day of this month', wp_timezone());
     282        $date->modify('-12 months');
     283        $trend = array();
     284
     285        for ($index = 0; $index < 13; $index++) {
     286            $key = $date->format('Ym');
     287            $trend[] = array(
     288                'label' => wp_date('M Y', $date->getTimestamp()),
     289                'value' => isset($values[$key]) ? (int) $values[$key] : 0,
     290            );
     291            $date->modify('+1 month');
     292        }
     293
     294        return $trend;
     295    }
     296
     297    if (empty($values)) {
     298        return array(
     299            array(
     300                'label' => wp_date('Y'),
     301                'value' => 0,
     302            ),
     303        );
     304    }
     305
     306    $trend = array();
     307
     308    foreach ($values as $dimension_value => $session_value) {
     309        $trend[] = array(
     310            'label' => headless_ga4_format_trend_label((string) $dimension_value, $trend_dimension),
     311            'value' => (int) $session_value,
     312        );
     313    }
     314
     315    return $trend;
     316}
     317
     318/**
    120319 * Builds the normalized widget payload from GA4 report responses.
    121320 *
    … …  
    125324 * @param array<string, mixed>     $pages_report     Top pages report.
    126325 * @param array<string, mixed>     $sources_report   Top sources report.
     326 * @param array<string, mixed>     $period_config    Period configuration.
    127327 *
    128328 * @return array<string, mixed>
    129329 */
    130 function headless_ga4_build_dashboard_payload($current_summary, $previous_summary, $trend_report, $pages_report, $sources_report)
     330function headless_ga4_build_dashboard_payload($current_summary, $previous_summary, $trend_report, $pages_report, $sources_report, $period_config)
    131331{
    132332    $summary = array(
    … …  
    168368    );
    169369
    170     $trend = array();
    171     $trend_rows = isset($trend_report['rows']) && is_array($trend_report['rows']) ? $trend_report['rows'] : array();
    172 
    173     foreach ($trend_rows as $row) {
    174         $date_value = isset($row['dimensionValues'][0]['value']) ? (string) $row['dimensionValues'][0]['value'] : '';
    175         $session_value = isset($row['metricValues'][0]['value']) ? (int) $row['metricValues'][0]['value'] : 0;
    176 
    177         if ($date_value === '') {
    178             continue;
    179         }
    180 
    181         $date = DateTime::createFromFormat('Ymd', $date_value, wp_timezone());
    182 
    183         $trend[] = array(
    184             'label' => $date instanceof DateTime ? wp_date('M j', $date->getTimestamp()) : $date_value,
    185             'value' => $session_value,
    186         );
    187     }
     370    $trend = headless_ga4_build_trend_points($trend_report, $period_config);
    188371
    189372    $top_pages = array();
    … …  
    221404        'comparison'        => $comparison,
    222405        'trend'             => $trend,
     406        'period'            => isset($period_config['key']) ? (string) $period_config['key'] : 'weekly',
     407        'period_label'      => isset($period_config['label']) ? (string) $period_config['label'] : __('Weekly', 'headless-ga4'),
     408        'trend_label'       => isset($period_config['trend_label']) ? (string) $period_config['trend_label'] : __('Daily sessions', 'headless-ga4'),
    223409        'top_pages'         => $top_pages,
    224410        'top_sources'       => $top_sources,
    … …  
    231417 *
    232418 * @param bool $force_refresh Whether to bypass the report cache.
     419 * @param string $period       Dashboard period key.
    233420 *
    234421 * @return array<string, mixed>|WP_Error
    235422 */
    236 function headless_ga4_fetch_dashboard_report($force_refresh = false)
     423function headless_ga4_fetch_dashboard_report($force_refresh = false, $period = 'weekly')
    237424{
    238425    if (!headless_ga4_is_configured()) {
    … …  
    243430    }
    244431
     432    $period = headless_ga4_normalize_dashboard_period($period);
     433    $period_config = headless_ga4_get_dashboard_period_config($period);
     434    $current_range = isset($period_config['current_range']) && is_array($period_config['current_range']) ? $period_config['current_range'] : array('startDate' => '6daysAgo', 'endDate' => 'today');
     435    $previous_range = isset($period_config['previous_range']) && is_array($period_config['previous_range']) ? $period_config['previous_range'] : null;
     436    $trend_dimension = isset($period_config['trend_dimension']) ? (string) $period_config['trend_dimension'] : 'date';
     437    $trend_limit = isset($period_config['trend_limit']) ? (int) $period_config['trend_limit'] : 30;
     438
    245439    if (!$force_refresh) {
    246         $cached_report = headless_ga4_get_cached_report();
     440        $cached_report = headless_ga4_get_cached_report($period);
    247441
    248442        if (is_array($cached_report)) {
    … …  
    254448        array(
    255449            'dateRanges' => array(
    256                 array(
    257                     'startDate' => '6daysAgo',
    258                     'endDate'   => 'today',
    259                 ),
     450                $current_range,
    260451            ),
    261452            'metrics' => array(
    … …  
    272463    }
    273464
    274     $summary_previous = headless_ga4_run_report(
    275         array(
    276             'dateRanges' => array(
    277                 array(
    278                     'startDate' => '13daysAgo',
    279                     'endDate'   => '7daysAgo',
     465    if (is_array($previous_range)) {
     466        $summary_previous = headless_ga4_run_report(
     467            array(
     468                'dateRanges' => array(
     469                    $previous_range,
    280470                ),
    281             ),
    282             'metrics' => array(
    283                 array('name' => 'activeUsers'),
    284                 array('name' => 'sessions'),
    285                 array('name' => 'screenPageViews'),
    286                 array('name' => 'engagedSessions'),
    287             ),
    288         )
    289     );
    290 
    291     if (is_wp_error($summary_previous)) {
    292         return $summary_previous;
     471                'metrics' => array(
     472                    array('name' => 'activeUsers'),
     473                    array('name' => 'sessions'),
     474                    array('name' => 'screenPageViews'),
     475                    array('name' => 'engagedSessions'),
     476                ),
     477            )
     478        );
     479
     480        if (is_wp_error($summary_previous)) {
     481            return $summary_previous;
     482        }
     483
     484        $previous_summary = headless_ga4_map_metric_row($summary_previous);
     485    } else {
     486        $previous_summary = headless_ga4_get_empty_metric_summary();
    293487    }
    294488
    … …  
    296490        array(
    297491            'dateRanges' => array(
    298                 array(
    299                     'startDate' => '6daysAgo',
    300                     'endDate'   => 'today',
    301                 ),
     492                $current_range,
    302493            ),
    303494            'dimensions' => array(
    304                 array('name' => 'date'),
     495                array('name' => $trend_dimension),
    305496            ),
    306497            'metrics' => array(
    … …  
    310501                array(
    311502                    'dimension' => array(
    312                         'dimensionName' => 'date',
     503                        'dimensionName' => $trend_dimension,
    313504                    ),
    314505                ),
    315506            ),
     507            'limit' => $trend_limit,
    316508        )
    317509    );
    … …  
    324516        array(
    325517            'dateRanges' => array(
    326                 array(
    327                     'startDate' => '6daysAgo',
    328                     'endDate'   => 'today',
    329                 ),
     518                $current_range,
    330519            ),
    331520            'dimensions' => array(
    … …  
    355544        array(
    356545            'dateRanges' => array(
    357                 array(
    358                     'startDate' => '6daysAgo',
    359                     'endDate'   => 'today',
    360                 ),
     546                $current_range,
    361547            ),
    362548            'dimensions' => array(
    … …  
    385571    $report = headless_ga4_build_dashboard_payload(
    386572        headless_ga4_map_metric_row($summary_current),
    387         headless_ga4_map_metric_row($summary_previous),
     573        $previous_summary,
    388574        $trend_report,
    389575        $pages_report,
    390         $sources_report
    391     );
    392 
    393     headless_ga4_set_cached_report($report);
     576        $sources_report,
     577        $period_config
     578    );
     579
     580    headless_ga4_set_cached_report($report, $period);
    394581
    395582    return $report;
    396583}
    397 
  • headless-ga4/trunk/includes/settings-page.php

    r3504948 r3798238  
    116116
    117117    if ($value === '') {
    118         headless_ga4_set_option_autoload_no(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
    119118        return $current;
    120119    }
    … …  
    134133
    135134    headless_ga4_clear_cached_data();
    136     headless_ga4_set_option_autoload_no(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
    137135
    138136    return (string) wp_json_encode(
    … …  
    304302
    305303    check_admin_referer('headless_ga4_clear_credentials');
    306     update_option(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION, '');
    307     headless_ga4_set_option_autoload_no(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
     304    delete_option(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION);
     305    add_option(HEADLESS_GA4_SERVICE_ACCOUNT_OPTION, '', '', false);
    308306    headless_ga4_clear_cached_data();
    309307    headless_ga4_set_admin_notice(__('Stored service account credentials were cleared.', 'headless-ga4'));
  • headless-ga4/trunk/readme.txt

    r3504948 r3798238  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 0.1.1
     7Stable tag: 0.2.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    121121== Changelog ==
    122122
     123= 0.2.1 =
     124
     125* Fix recursive service account JSON sanitization when saving settings
     126* Fix clearing stored service account credentials
     127
     128= 0.2.0 =
     129
     130* Add dashboard period filters for weekly, monthly, yearly, and all-time reports
     131* Add period-aware GA4 report caching
     132* Improve the sessions trend diagram with combined bar and line chart display
     133
    123134= 0.1.1 =
    124135
    … …  
    137148== Upgrade Notice ==
    138149
     150= 0.2.1 =
     151
     152Fixes a critical error when saving service account JSON credentials.
     153
     154= 0.2.0 =
     155
     156Adds dashboard report period filters and an improved sessions diagram.
     157
    139158= 0.1.1 =
    140159
  • headless-ga4/trunk/templates/dashboard-widget.php

    r3504948 r3798238  
    55 * @var array<string, string>          $cards
    66 * @var array<string, mixed>           $comparison
     7 * @var string                         $current_period
    78 * @var int                            $last_refreshed_at
     9 * @var string                         $period_label
     10 * @var array<string, string>          $period_choices
     11 * @var int                            $headless_ga4_top_pages_max_views
     12 * @var int                            $headless_ga4_top_sources_max_sessions
    813 * @var string                         $state
    914 * @var array<int, array<string, mixed>> $top_pages
    … …  
    1116 * @var array<string, mixed>           $summary
    1217 * @var array<int, array<string, mixed>> $trend
     18 * @var string                         $trend_label
    1319 * @var string                         $error_message
    1420 */
    … …  
    4248endif;
    4349?>
     50<div class="headless-ga4-widget-period-header">
     51    <div>
     52        <span class="headless-ga4-widget-label"><?php esc_html_e('Report period', 'headless-ga4'); ?></span>
     53        <strong><?php echo esc_html($period_label); ?></strong>
     54    </div>
     55    <div class="headless-ga4-widget-period-filter" aria-label="<?php esc_attr_e('Choose report period', 'headless-ga4'); ?>">
     56        <?php foreach ($period_choices as $headless_ga4_period_key => $headless_ga4_period_choice_label) : ?>
     57            <a
     58                class="headless-ga4-widget-period-link<?php echo $current_period === $headless_ga4_period_key ? ' is-active' : ''; ?>"
     59                href="<?php echo esc_url(add_query_arg('headless_ga4_period', $headless_ga4_period_key, admin_url('index.php'))); ?>"
     60                <?php echo $current_period === $headless_ga4_period_key ? 'aria-current="true"' : ''; ?>
     61            >
     62                <?php echo esc_html($headless_ga4_period_choice_label); ?>
     63            </a>
     64        <?php endforeach; ?>
     65    </div>
     66</div>
     67
    4468<div class="headless-ga4-widget-grid">
    4569    <?php foreach ($cards as $headless_ga4_card_key => $headless_ga4_card_label) : ?>
    … …  
    5983<div class="headless-ga4-widget-layout">
    6084    <div class="headless-ga4-widget-panel">
    61         <span class="headless-ga4-widget-label"><?php esc_html_e('Sessions trend', 'headless-ga4'); ?></span>
     85        <span class="headless-ga4-widget-label"><?php echo esc_html($trend_label); ?></span>
    6286        <?php if (!empty($trend)) : ?>
    6387            <div class="headless-ga4-widget-chart">
    64                 <?php echo headless_ga4_render_trend_svg($trend); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
     88                <?php echo wp_kses(headless_ga4_render_trend_svg($trend, $trend_label), headless_ga4_get_allowed_chart_svg_html()); ?>
    6589            </div>
    6690            <div class="headless-ga4-widget-list">
    … …  
    86110                            <strong class="headless-ga4-widget-item-title"><?php echo esc_html($headless_ga4_page['title'] !== '' ? (string) $headless_ga4_page['title'] : (string) $headless_ga4_page['path']); ?></strong>
    87111                            <div class="headless-ga4-widget-muted"><?php echo esc_html((string) $headless_ga4_page['path']); ?></div>
     112                            <?php
     113                            echo wp_kses(
     114                                headless_ga4_render_horizontal_bar_svg(
     115                                    (int) $headless_ga4_page['views'],
     116                                    $headless_ga4_top_pages_max_views,
     117                                    sprintf(
     118                                        /* translators: %s: number of page views. */
     119                                        __('%s page views', 'headless-ga4'),
     120                                        number_format_i18n((int) $headless_ga4_page['views'])
     121                                    )
     122                                ),
     123                                headless_ga4_get_allowed_chart_svg_html()
     124                            );
     125                            ?>
    88126                        </div>
    89127                        <span class="headless-ga4-widget-muted"><?php echo esc_html(number_format_i18n((int) $headless_ga4_page['views'])); ?></span>
    … …  
    106144                        <strong class="headless-ga4-widget-item-title"><?php echo esc_html((string) $headless_ga4_source['source']); ?></strong>
    107145                        <div class="headless-ga4-widget-muted"><?php echo esc_html((string) $headless_ga4_source['medium']); ?></div>
     146                        <?php
     147                        echo wp_kses(
     148                            headless_ga4_render_horizontal_bar_svg(
     149                                (int) $headless_ga4_source['sessions'],
     150                                $headless_ga4_top_sources_max_sessions,
     151                                sprintf(
     152                                    /* translators: %s: number of sessions. */
     153                                    __('%s sessions', 'headless-ga4'),
     154                                    number_format_i18n((int) $headless_ga4_source['sessions'])
     155                                )
     156                            ),
     157                            headless_ga4_get_allowed_chart_svg_html()
     158                        );
     159                        ?>
    108160                    </div>
    109161                    <span class="headless-ga4-widget-muted"><?php echo esc_html(number_format_i18n((int) $headless_ga4_source['sessions'])); ?></span>
    … …  
    135187            <?php wp_nonce_field('headless_ga4_refresh'); ?>
    136188            <input type="hidden" name="action" value="headless_ga4_refresh" />
     189            <input type="hidden" name="headless_ga4_period" value="<?php echo esc_attr($current_period); ?>" />
    137190            <?php submit_button(__('Refresh data', 'headless-ga4'), 'secondary', 'submit', false); ?>
    138191        </form>
Note: See TracChangeset for help on using the changeset viewer.