From a161a642ea51f909bd072200f78034b0a8e35bc9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 15 Dec 2025 16:26:03 +0100 Subject: [PATCH 01/46] Add Fediverse Wrapped statistics feature (WIP) Implements monthly/annual statistics collection and display: - Core Statistics class for collecting engagement metrics - Scheduler for monthly stats collection and annual compilation - Dashboard widget showing stats with charts - Annual email template for year-end summaries - Shareable wrapped card for public sharing Known issues: - URL rewrite rules for wrapped card need debugging (year parameter not captured) - Stats display shows 0 values on HTTP requests despite correct query var registration Related to #2597 --- activitypub.php | 2 + assets/css/activitypub-statistics.css | 288 +++++ assets/js/activitypub-statistics.js | 342 ++++++ includes/class-migration.php | 6 + includes/class-scheduler.php | 6 + includes/class-statistics.php | 1086 +++++++++++++++++ includes/scheduler/class-statistics.php | 259 ++++ .../wp-admin/class-statistics-dashboard.php | 547 +++++++++ templates/emails/annual-wrapped.php | 222 ++++ templates/statistics/wrapped-card.php | 341 ++++++ 10 files changed, 3099 insertions(+) create mode 100644 assets/css/activitypub-statistics.css create mode 100644 assets/js/activitypub-statistics.js create mode 100644 includes/class-statistics.php create mode 100644 includes/scheduler/class-statistics.php create mode 100644 includes/wp-admin/class-statistics-dashboard.php create mode 100644 templates/emails/annual-wrapped.php create mode 100644 templates/statistics/wrapped-card.php diff --git a/activitypub.php b/activitypub.php index 85f509cc0e..331131c9fd 100644 --- a/activitypub.php +++ b/activitypub.php @@ -87,6 +87,7 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Search', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Signature', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Statistics', 'init' ) ); if ( site_supports_blocks() ) { \add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) ); @@ -130,6 +131,7 @@ function plugin_admin_init() { \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Health_Check', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings_Fields', 'init' ) ); + \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Statistics_Dashboard', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Welcome_Fields', 'init' ) ); diff --git a/assets/css/activitypub-statistics.css b/assets/css/activitypub-statistics.css new file mode 100644 index 0000000000..b1d8fb1d27 --- /dev/null +++ b/assets/css/activitypub-statistics.css @@ -0,0 +1,288 @@ +/** + * ActivityPub Statistics Dashboard Widget Styles + * + * @package Activitypub + */ + +/* Widget container */ +.activitypub-stats-widget { + padding: 0; +} + +/* Loading state */ +.activitypub-stats-widget.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Header with actor selector */ +.activitypub-stats-header { + margin-bottom: 16px; +} + +.activitypub-stats-actor-select { + width: 100%; + padding: 8px 12px; + border: 1px solid #8c8f94; + border-radius: 4px; + background: #fff; + font-size: 14px; +} + +/* Comparison highlights section */ +.activitypub-stats-highlights { + margin-bottom: 20px; +} + +.activitypub-stats-highlights h4 { + margin: 0 0 12px; + font-size: 13px; + color: #1d2327; + font-weight: 600; +} + +.activitypub-highlights-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.activitypub-highlight { + flex: 1 1 auto; + min-width: calc(20% - 8px); + text-align: center; + padding: 10px 8px; + background: #f0f0f1; + border-radius: 6px; +} + +.activitypub-highlight-value { + display: block; + font-size: 20px; + font-weight: 700; + color: #1d2327; + line-height: 1.2; +} + +.activitypub-highlight-change { + display: inline-block; + font-size: 11px; + margin-top: 2px; + color: #50575e; +} + +.activitypub-highlight-change.positive { + color: #00a32a; +} + +.activitypub-highlight-change.negative { + color: #d63638; +} + +.activitypub-highlight-label { + display: block; + font-size: 10px; + color: #50575e; + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* Yearly activity graph */ +.activitypub-stats-graph { + margin-bottom: 20px; +} + +.activitypub-stats-graph h4 { + margin: 0 0 12px; + font-size: 13px; + color: #1d2327; + font-weight: 600; +} + +.activitypub-graph-container { + background: #f6f7f7; + border-radius: 6px; + padding: 12px; + position: relative; +} + +/* Line chart styles */ +.activitypub-line-chart { + width: 100%; + height: auto; + display: block; +} + +.activitypub-line-chart .data-points circle { + transition: r 0.15s ease; + cursor: pointer; +} + +.activitypub-line-chart .data-points circle:hover { + r: 5; +} + +.activitypub-graph-labels { + position: relative; + height: 20px; + margin-top: 4px; +} + +.activitypub-graph-labels span { + position: absolute; + transform: translateX(-50%); + font-size: 9px; + color: #50575e; + text-transform: uppercase; +} + +.activitypub-graph-empty { + text-align: center; + padding: 30px 20px; + color: #50575e; + font-size: 13px; +} + +/* Legend */ +.activitypub-graph-legend { + display: flex; + justify-content: center; + gap: 16px; + margin-top: 12px; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #50575e; +} + +.legend-item::before { + content: ''; + display: inline-block; + width: 12px; + height: 3px; + border-radius: 2px; + background: var(--legend-color, #8c8f94); +} + +/* Top multiplicator section */ +.activitypub-stats-multiplicator { + background: #fff8e5; + border-left: 4px solid #dba617; + padding: 12px; + margin-bottom: 16px; + border-radius: 0 4px 4px 0; +} + +.activitypub-stats-multiplicator h4 { + margin: 0 0 8px; + font-size: 13px; + color: #1d2327; +} + +.activitypub-stats-multiplicator p { + margin: 0; + font-size: 13px; +} + +.activitypub-stats-multiplicator a { + color: #2271b1; + text-decoration: none; + font-weight: 600; +} + +.activitypub-stats-multiplicator a:hover { + text-decoration: underline; +} + +/* Top posts section */ +.activitypub-stats-top-posts { + margin-bottom: 16px; +} + +.activitypub-stats-top-posts h4 { + margin: 0 0 8px; + font-size: 13px; + color: #1d2327; +} + +.activitypub-stats-top-posts ul { + margin: 0; + padding: 0; + list-style: none; +} + +.activitypub-stats-top-posts li { + padding: 8px 0; + border-bottom: 1px solid #f0f0f1; + font-size: 13px; +} + +.activitypub-stats-top-posts li:last-child { + border-bottom: none; +} + +.activitypub-stats-top-posts a { + color: #2271b1; + text-decoration: none; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.activitypub-stats-top-posts a:hover { + text-decoration: underline; +} + +.activitypub-stats-top-posts .engagement-count { + display: block; + font-size: 11px; + color: #50575e; + margin-top: 2px; +} + +/* Actions section */ +.activitypub-stats-actions { + padding-top: 12px; + border-top: 1px solid #f0f0f1; +} + +.activitypub-stats-actions .button { + width: 100%; + text-align: center; +} + +/* Responsive adjustments */ +@media screen and (max-width: 782px) { + .activitypub-highlight { + min-width: calc(33.333% - 8px); + } + + .activitypub-highlight-value { + font-size: 18px; + } +} + +@media screen and (max-width: 600px) { + .activitypub-highlight { + min-width: calc(50% - 8px); + } + + .activitypub-graph-labels span { + font-size: 8px; + } + + .activitypub-graph-legend { + gap: 10px; + } + + .legend-item { + font-size: 10px; + } +} diff --git a/assets/js/activitypub-statistics.js b/assets/js/activitypub-statistics.js new file mode 100644 index 0000000000..25fd740634 --- /dev/null +++ b/assets/js/activitypub-statistics.js @@ -0,0 +1,342 @@ +/** + * ActivityPub Statistics Dashboard Widget JavaScript + * + * @package Activitypub + */ + +/* global jQuery, activitypubStats */ + +( function( $ ) { + 'use strict'; + + /** + * Initialize statistics widgets. + */ + function init() { + $( '.activitypub-stats-widget' ).each( function() { + var $widget = $( this ); + var $actorSelect = $widget.find( '.activitypub-stats-actor-select' ); + + // Actor selector change event. + $actorSelect.on( 'change', function() { + loadStats( $widget, $( this ).val() ); + } ); + } ); + } + + /** + * Load statistics via AJAX. + * + * @param {jQuery} $widget The widget container. + * @param {string} userId The user ID to load stats for. + */ + function loadStats( $widget, userId ) { + $widget.addClass( 'loading' ); + + $.ajax( { + url: activitypubStats.ajaxUrl, + type: 'POST', + data: { + action: 'activitypub_get_stats', + nonce: activitypubStats.nonce, + user_id: userId + }, + success: function( response ) { + if ( response.success ) { + updateWidget( $widget, response.data, userId ); + } + }, + complete: function() { + $widget.removeClass( 'loading' ); + } + } ); + } + + /** + * Update widget with new data. + * + * @param {jQuery} $widget The widget container. + * @param {Object} data The statistics data. + * @param {string} userId The user ID. + */ + function updateWidget( $widget, data, userId ) { + // Update user ID data attribute. + $widget.attr( 'data-user-id', userId ); + + // Update comparison highlights. + updateHighlights( $widget, data.comparison ); + + // Update line chart. + updateLineChart( $widget, data.monthly ); + + // Update top multiplicator. + updateMultiplicator( $widget, data.stats.top_multiplicator ); + + // Update top posts. + updateTopPosts( $widget, data.stats.top_posts ); + + // Update wrapped card link. + $widget.find( '.activitypub-wrapped-link' ).attr( 'href', data.wrapped_url ); + } + + /** + * Update highlight stats with comparison. + * + * @param {jQuery} $widget The widget container. + * @param {Object} comparison The comparison data. + */ + function updateHighlights( $widget, comparison ) { + // Build list of stats to update: posts, all comment types, and followers. + var stats = [ 'posts' ]; + + // Add dynamic comment types from settings. + if ( activitypubStats.commentTypes ) { + Object.keys( activitypubStats.commentTypes ).forEach( function( type ) { + stats.push( type ); + } ); + } + + stats.push( 'followers' ); + + stats.forEach( function( stat ) { + var $highlight = $widget.find( '.activitypub-highlight[data-stat="' + stat + '"]' ); + var data = comparison[ stat ]; + + if ( ! data ) { + return; + } + + $highlight.find( '.activitypub-highlight-value' ).text( data.current ); + + var $change = $highlight.find( '.activitypub-highlight-change' ); + if ( data.change_formatted ) { + if ( $change.length === 0 ) { + $highlight.find( '.activitypub-highlight-value' ).after( + '(' + escapeHtml( data.change_formatted ) + ')' + ); + $change = $highlight.find( '.activitypub-highlight-change' ); + } else { + $change.text( '(' + data.change_formatted + ')' ); + } + + $change.removeClass( 'positive negative' ); + if ( data.change > 0 ) { + $change.addClass( 'positive' ); + } else if ( data.change < 0 ) { + $change.addClass( 'negative' ); + } + } else { + $change.remove(); + } + } ); + } + + /** + * Update the line chart with new data. + * + * @param {jQuery} $widget The widget container. + * @param {Array} monthly The monthly data array. + */ + function updateLineChart( $widget, monthly ) { + var $container = $widget.find( '.activitypub-graph-container' ); + + if ( monthly.length < 2 ) { + $container.html( '
' + ( activitypubStats.i18n.notEnoughData || 'Not enough data yet' ) + '
' ); + return; + } + + var width = 400; + var height = 120; + var padding = 10; + var chartW = width - ( padding * 2 ); + var chartH = height - ( padding * 2 ); + var numMonths = monthly.length; + + // Get comment types and colors from settings. + var commentTypes = activitypubStats.commentTypes || {}; + var chartColors = activitypubStats.chartColors || {}; + var typeKeys = Object.keys( commentTypes ); + + // Find max value across all comment types. + var maxValue = 1; + typeKeys.forEach( function( type ) { + var key = type + '_count'; + var max = Math.max.apply( null, monthly.map( function( m ) { return m[ key ] || 0; } ) ); + if ( max > maxValue ) { + maxValue = max; + } + } ); + + // Generate points for each comment type. + var allPoints = {}; + typeKeys.forEach( function( type ) { + allPoints[ type ] = []; + } ); + var xLabels = []; + + monthly.forEach( function( data, i ) { + var x = padding + ( i / ( numMonths - 1 ) ) * chartW; + + typeKeys.forEach( function( type ) { + var key = type + '_count'; + var count = data[ key ] || 0; + var y = padding + chartH - ( ( count / maxValue ) * chartH ); + allPoints[ type ].push( x.toFixed( 1 ) + ',' + y.toFixed( 1 ) ); + } ); + + xLabels.push( { + x: x, + label: activitypubStats.monthNames[ data.month - 1 ] || '' + } ); + } ); + + // Build SVG. + var baseY = padding + chartH; + var svg = ''; + + // Grid lines. + svg += ''; + for ( var i = 0; i <= 4; i++ ) { + var y = padding + ( i / 4 ) * chartH; + svg += ''; + } + svg += ''; + + // Area fills for each comment type. + typeKeys.forEach( function( type ) { + var color = chartColors[ type ] || '#8c8f94'; + var fillRgba = hexToRgba( color, 0.1 ); + svg += ''; + } ); + + // Lines for each comment type. + typeKeys.forEach( function( type ) { + var color = chartColors[ type ] || '#8c8f94'; + svg += ''; + } ); + + // Data points. + svg += ''; + monthly.forEach( function( data, i ) { + var x = padding + ( i / ( numMonths - 1 ) ) * chartW; + + typeKeys.forEach( function( type ) { + var key = type + '_count'; + var count = data[ key ] || 0; + var y = padding + chartH - ( ( count / maxValue ) * chartH ); + var color = chartColors[ type ] || '#8c8f94'; + var label = commentTypes[ type ] ? ( commentTypes[ type ].singular || commentTypes[ type ].label ).toLowerCase() : type; + + svg += '' + count + ' ' + label + ''; + } ); + } ); + svg += ''; + svg += ''; + + // Month labels. + svg += '
'; + xLabels.forEach( function( label ) { + var left = ( label.x / width ) * 100; + svg += '' + escapeHtml( label.label ) + ''; + } ); + svg += '
'; + + $container.html( svg ); + } + + /** + * Convert hex color to rgba. + * + * @param {string} hex The hex color. + * @param {number} alpha The alpha value (0-1). + * @return {string} The rgba color string. + */ + function hexToRgba( hex, alpha ) { + hex = hex.replace( '#', '' ); + + if ( hex.length === 3 ) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + + var r = parseInt( hex.substring( 0, 2 ), 16 ); + var g = parseInt( hex.substring( 2, 4 ), 16 ); + var b = parseInt( hex.substring( 4, 6 ), 16 ); + + return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'; + } + + /** + * Update top multiplicator section. + * + * @param {jQuery} $widget The widget container. + * @param {Object|null} multiplicator The multiplicator data or null. + */ + function updateMultiplicator( $widget, multiplicator ) { + var $section = $widget.find( '.activitypub-stats-multiplicator' ); + + if ( multiplicator && multiplicator.name ) { + var html = '

' + activitypubStats.i18n.topSupporter + '

' + + '

' + + escapeHtml( multiplicator.name ) + ' (' + multiplicator.count + ' boosts)

'; + + if ( $section.length === 0 ) { + $widget.find( '.activitypub-stats-graph' ).after( + '
' + html + '
' + ); + } else { + $section.html( html ); + } + } else { + $section.remove(); + } + } + + /** + * Update top posts section. + * + * @param {jQuery} $widget The widget container. + * @param {Array} topPosts The top posts array. + */ + function updateTopPosts( $widget, topPosts ) { + var $section = $widget.find( '.activitypub-stats-top-posts' ); + + if ( topPosts && topPosts.length > 0 ) { + var html = '

' + activitypubStats.i18n.topPosts + '

'; + + if ( $section.length === 0 ) { + var $insertAfter = $widget.find( '.activitypub-stats-multiplicator' ); + if ( $insertAfter.length === 0 ) { + $insertAfter = $widget.find( '.activitypub-stats-graph' ); + } + $insertAfter.after( '
' + html + '
' ); + } else { + $section.html( html ); + } + } else { + $section.remove(); + } + } + + /** + * Escape HTML entities. + * + * @param {string} text The text to escape. + * @return {string} The escaped text. + */ + function escapeHtml( text ) { + var div = document.createElement( 'div' ); + div.textContent = text; + return div.innerHTML; + } + + // Initialize on document ready. + $( document ).ready( init ); + +}( jQuery ) ); diff --git a/includes/class-migration.php b/includes/class-migration.php index 7c35eafabb..26d4cbf70e 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -32,6 +32,7 @@ public static function init() { Scheduler::register_async_batch_callback( 'activitypub_create_post_outbox_items', array( self::class, 'create_post_outbox_items' ) ); Scheduler::register_async_batch_callback( 'activitypub_create_comment_outbox_items', array( self::class, 'create_comment_outbox_items' ) ); Scheduler::register_async_batch_callback( 'activitypub_migrate_avatar_to_remote_actors', array( self::class, 'migrate_avatar_to_remote_actors' ) ); + Scheduler::register_async_batch_callback( 'activitypub_backfill_statistics', array( Statistics::class, 'backfill_historical_stats' ) ); } /** @@ -215,6 +216,11 @@ public static function maybe_migrate() { \wp_schedule_single_event( \time(), 'activitypub_migrate_avatar_to_remote_actors' ); } + if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + // Backfill historical statistics data. + \wp_schedule_single_event( \time(), 'activitypub_backfill_statistics' ); + } + // Ensure all required cron schedules are registered. Scheduler::register_schedules(); diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 19ea1ba22b..bfdfc75961 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -18,6 +18,7 @@ use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; use Activitypub\Scheduler\Post; +use Activitypub\Scheduler\Statistics; /** * Scheduler class. @@ -82,6 +83,7 @@ public static function register_schedulers() { Actor::init(); Collection_Sync::init(); Comment::init(); + Statistics::init(); /** * Register additional schedulers. @@ -144,6 +146,8 @@ public static function register_schedules() { if ( ! \wp_next_scheduled( 'activitypub_sync_blocklist_subscriptions' ) ) { \wp_schedule_event( time(), 'weekly', 'activitypub_sync_blocklist_subscriptions' ); } + + Statistics::register_schedules(); } /** @@ -159,6 +163,8 @@ public static function deregister_schedules() { \wp_unschedule_hook( 'activitypub_inbox_purge' ); \wp_unschedule_hook( 'activitypub_ap_post_purge' ); \wp_unschedule_hook( 'activitypub_sync_blocklist_subscriptions' ); + + Statistics::deregister_schedules(); } /** diff --git a/includes/class-statistics.php b/includes/class-statistics.php new file mode 100644 index 0000000000..dea34f814e --- /dev/null +++ b/includes/class-statistics.php @@ -0,0 +1,1086 @@ + 'comment', + 'label' => \__( 'Comments', 'activitypub' ), + 'singular' => \__( 'Comment', 'activitypub' ), + ); + + return $types; + } + + /** + * Add rewrite rules for nice wrapped URLs. + */ + public static function add_rewrite_rules() { + // /fediverse-wrapped or /fediverse-wrapped/2024. + \add_rewrite_rule( + '^fediverse-wrapped/?$', + 'index.php?activitypub_wrapped=1', + 'top' + ); + + \add_rewrite_rule( + '^fediverse-wrapped/(\d{4})/?$', + 'index.php?activitypub_wrapped=1&activitypub_wrapped_year=$matches[1]', + 'top' + ); + + // /@username/wrapped or /@username/wrapped/2024. + \add_rewrite_rule( + '^@([\w\-\.]+)/wrapped/?$', + 'index.php?activitypub_wrapped=1&actor=$matches[1]', + 'top' + ); + + \add_rewrite_rule( + '^@([\w\-\.]+)/wrapped/(\d{4})/?$', + 'index.php?activitypub_wrapped=1&actor=$matches[1]&activitypub_wrapped_year=$matches[2]', + 'top' + ); + } + + /** + * Add custom query vars. + * + * @param array $vars The existing query vars. + * + * @return array The modified query vars. + */ + public static function add_query_vars( $vars ) { + $vars[] = 'activitypub_wrapped'; + $vars[] = 'activitypub_wrapped_year'; + return $vars; + } + + /** + * Render the wrapped card if requested. + */ + public static function render_wrapped_card() { + if ( ! \get_query_var( 'activitypub_wrapped' ) ) { + return; + } + + // Get user ID from actor query var or fallback to GET param. + $actor = \get_query_var( 'actor' ); + if ( $actor ) { + $actor_object = Actors::get_by_username( $actor ); + $user_id = $actor_object && ! \is_wp_error( $actor_object ) ? $actor_object->get__id() : Actors::BLOG_USER_ID; + } else { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $user_id = isset( $_GET['user_id'] ) ? \intval( $_GET['user_id'] ) : Actors::BLOG_USER_ID; + } + + // Get year from query var or GET param. + $year = \get_query_var( 'activitypub_wrapped_year' ); + if ( ! $year ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $year = isset( $_GET['year'] ) ? \intval( $_GET['year'] ) : (int) \gmdate( 'Y' ); + } + $year = (int) $year; + + // Get annual summary if available. + $stats = self::get_annual_summary( $user_id, $year ); + + if ( ! $stats ) { + // No stored stats - get real-time data for the requested year. + $start = \gmdate( 'Y-01-01 00:00:00', \strtotime( $year . '-01-01' ) ); + $end = \gmdate( 'Y-12-31 23:59:59', \strtotime( $year . '-12-31' ) ); + + $stats = array( + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'followers_total' => self::get_follower_count( $user_id ), + 'top_posts' => self::get_top_posts( $user_id, $start, $end, 5 ), + 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), + ); + + // Add counts for each comment type dynamically. + foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) { + $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + + // Add annual-specific fields. + $stats['most_active_month'] = null; + $stats['followers_net_change'] = 0; + } + + $args = \array_merge( + $stats, + array( + 'year' => $year, + 'user_id' => $user_id, + 'site_name' => \get_bloginfo( 'name' ), + 'followers_total' => self::get_follower_count( $user_id ), + ) + ); + + // Load the template. + \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/statistics/wrapped-card.php', true, array( 'args' => $args ) ); + exit; + } + + /** + * Get the wrapped card URL for a user. + * + * @param int $user_id The user ID. + * @param int $year Optional. The year. Defaults to current year. + * + * @return string The wrapped card URL. + */ + public static function get_wrapped_url( $user_id, $year = null ) { + if ( ! $year ) { + $year = (int) \gmdate( 'Y' ); + } + + // For blog actor, use /fediverse-wrapped/YEAR. + if ( Actors::BLOG_USER_ID === $user_id ) { + return \home_url( sprintf( '/fediverse-wrapped/%d/', $year ) ); + } + + // For user actors, use /@username/wrapped/YEAR. + $actor = Actors::get_by_id( $user_id ); + if ( $actor && ! \is_wp_error( $actor ) ) { + $username = $actor->get_preferred_username(); + return \home_url( sprintf( '/@%s/wrapped/%d/', $username, $year ) ); + } + + // Fallback to query string URL. + return \add_query_arg( + array( + 'activitypub_wrapped' => '1', + 'user_id' => $user_id, + 'year' => $year, + ), + \home_url() + ); + } + + /** + * Option prefix for statistics storage. + * + * @var string + */ + const OPTION_PREFIX = 'activitypub_stats_'; + + /** + * Get the option name for monthly stats. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * + * @return string The option name. + */ + public static function get_monthly_option_name( $user_id, $year, $month ) { + return sprintf( '%s%d_%d_%02d', self::OPTION_PREFIX, $user_id, $year, $month ); + } + + /** + * Get the option name for annual stats. + * + * @param int $user_id The user ID. + * @param int $year The year. + * + * @return string The option name. + */ + public static function get_annual_option_name( $user_id, $year ) { + return sprintf( '%s%d_%d_annual', self::OPTION_PREFIX, $user_id, $year ); + } + + /** + * Get monthly statistics. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * + * @return array|false The monthly stats array or false if not found. + */ + public static function get_monthly_stats( $user_id, $year, $month ) { + return \get_option( self::get_monthly_option_name( $user_id, $year, $month ), false ); + } + + /** + * Get annual summary statistics. + * + * @param int $user_id The user ID. + * @param int $year The year. + * + * @return array|false The annual stats array or false if not found. + */ + public static function get_annual_summary( $user_id, $year ) { + return \get_option( self::get_annual_option_name( $user_id, $year ), false ); + } + + /** + * Save monthly statistics. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * @param array $stats The stats array. + * + * @return bool True on success, false on failure. + */ + public static function save_monthly_stats( $user_id, $year, $month, $stats ) { + return \update_option( self::get_monthly_option_name( $user_id, $year, $month ), $stats, false ); + } + + /** + * Save annual summary statistics. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param array $stats The stats array. + * + * @return bool True on success, false on failure. + */ + public static function save_annual_summary( $user_id, $year, $stats ) { + return \update_option( self::get_annual_option_name( $user_id, $year ), $stats, false ); + } + + /** + * Collect monthly statistics for a user. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * + * @return array The collected stats. + */ + public static function collect_monthly_stats( $user_id, $year, $month ) { + $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); + $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + + // Get previous month's follower count for comparison. + $prev_month = $month - 1; + $prev_year = $year; + if ( $prev_month < 1 ) { + $prev_month = 12; + --$prev_year; + } + $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); + $prev_followers = $prev_stats ? $prev_stats['followers_total'] : 0; + $current_followers = self::get_follower_count( $user_id ); + + $stats = array( + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'followers_gained' => \max( 0, $current_followers - $prev_followers ), + 'followers_lost' => \max( 0, $prev_followers - $current_followers ), + 'followers_total' => $current_followers, + 'top_posts' => self::get_top_posts( $user_id, $start, $end, 5 ), + 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), + 'collected_at' => \gmdate( 'Y-m-d H:i:s' ), + ); + + // Add counts for each comment type dynamically. + foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) { + $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + + self::save_monthly_stats( $user_id, $year, $month, $stats ); + + return $stats; + } + + /** + * Compile annual summary from monthly stats. + * + * @param int $user_id The user ID. + * @param int $year The year. + * + * @return array The annual summary. + */ + public static function compile_annual_summary( $user_id, $year ) { + // Initialize totals dynamically based on registered comment types. + $comment_types = \array_keys( self::get_comment_types_for_stats() ); + $totals = array( 'posts_count' => 0 ); + foreach ( $comment_types as $type ) { + $totals[ $type . '_count' ] = 0; + } + + $most_active_month = null; + $most_active_engagement = 0; + $first_month_stats = null; + $last_month_stats = null; + $all_multiplicators = array(); + + for ( $month = 1; $month <= 12; $month++ ) { + $stats = self::get_monthly_stats( $user_id, $year, $month ); + + if ( ! $stats ) { + continue; + } + + // Track first and last months with data. + if ( ! $first_month_stats ) { + $first_month_stats = $stats; + } + $last_month_stats = $stats; + + // Sum totals dynamically. + $totals['posts_count'] += $stats['posts_count'] ?? 0; + foreach ( $comment_types as $type ) { + $key = $type . '_count'; + $totals[ $key ] += $stats[ $key ] ?? 0; + } + + // Calculate engagement for this month (sum of all comment type counts). + $engagement = 0; + foreach ( $comment_types as $type ) { + $engagement += $stats[ $type . '_count' ] ?? 0; + } + + if ( $engagement > $most_active_engagement ) { + $most_active_engagement = $engagement; + $most_active_month = $month; + } + + // Aggregate multiplicators. + if ( ! empty( $stats['top_multiplicator'] ) && ! empty( $stats['top_multiplicator']['url'] ) ) { + $url = $stats['top_multiplicator']['url']; + if ( ! isset( $all_multiplicators[ $url ] ) ) { + $all_multiplicators[ $url ] = array( + 'name' => $stats['top_multiplicator']['name'], + 'url' => $url, + 'count' => 0, + ); + } + $all_multiplicators[ $url ]['count'] += $stats['top_multiplicator']['count'] ?? 0; + } + } + + // Find top multiplicator for the year. + $top_multiplicator = null; + if ( ! empty( $all_multiplicators ) ) { + \usort( + $all_multiplicators, + function ( $a, $b ) { + return $b['count'] - $a['count']; + } + ); + $top_multiplicator = \reset( $all_multiplicators ); + } + + // Build summary with dynamic comment type counts. + $summary = array( + 'posts_count' => $totals['posts_count'], + 'most_active_month' => $most_active_month, + 'followers_start' => $first_month_stats ? ( $first_month_stats['followers_total'] ?? 0 ) - ( $first_month_stats['followers_gained'] ?? 0 ) + ( $first_month_stats['followers_lost'] ?? 0 ) : 0, + 'followers_end' => $last_month_stats ? ( $last_month_stats['followers_total'] ?? 0 ) : self::get_follower_count( $user_id ), + 'followers_net_change' => 0, + 'top_multiplicator' => $top_multiplicator, + 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ), + ); + + // Add comment type totals dynamically. + foreach ( $comment_types as $type ) { + $summary[ $type . '_count' ] = $totals[ $type . '_count' ]; + } + + $summary['followers_net_change'] = $summary['followers_end'] - $summary['followers_start']; + + self::save_annual_summary( $user_id, $year, $summary ); + + return $summary; + } + + /** + * Count federated posts in a date range. + * + * Counts posts sent via the outbox with activity type 'Create'. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * + * @return int The post count. + */ + public static function count_federated_posts_in_range( $user_id, $start, $end ) { + $meta_query = array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Create', + ), + ); + + // Filter by actor type for user stats. + if ( Actors::BLOG_USER_ID !== $user_id ) { + $meta_query[] = array( + 'key' => '_activitypub_activity_actor', + 'value' => 'user', + ); + } else { + $meta_query[] = array( + 'key' => '_activitypub_activity_actor', + 'value' => 'blog', + ); + } + + $args = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => array( 'publish', 'pending' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + 'date_query' => array( + array( + 'after' => $start, + 'before' => $end, + 'inclusive' => true, + ), + ), + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => $meta_query, + ); + + // Filter by post author for user-specific stats. + if ( Actors::BLOG_USER_ID !== $user_id ) { + $args['author'] = $user_id; + } + + $query = new \WP_Query( $args ); + + return $query->found_posts; + } + + /** + * Count engagement (likes, reposts, comments, quotes) in a date range. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * @param string|null $type Optional. The engagement type ('like', 'repost', 'comment', 'quote'). + * + * @return int The engagement count. + */ + public static function count_engagement_in_range( $user_id, $start, $end, $type = null ) { + global $wpdb; + + // Get post IDs for the user. + $post_args = array( + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'publish', + ); + + if ( Actors::BLOG_USER_ID !== $user_id ) { + $post_args['author'] = $user_id; + } + + $post_ids = \get_posts( $post_args ); + + if ( empty( $post_ids ) ) { + return 0; + } + + $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + + $type_clause = ''; + if ( $type ) { + $type_clause = $wpdb->prepare( ' AND c.comment_type = %s', $type ); + } else { + // Get all registered ActivityPub comment types dynamically. + $comment_types = Comment::get_comment_type_slugs(); + if ( ! empty( $comment_types ) ) { + $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $type_clause = $wpdb->prepare( " AND c.comment_type IN ($placeholders_types)", $comment_types ); + } + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT c.comment_ID) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$placeholders}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub' + AND c.comment_date_gmt >= %s + AND c.comment_date_gmt <= %s + {$type_clause}", + \array_merge( $post_ids, array( $start, $end ) ) + ) + ); + // phpcs:enable + + return (int) $count; + } + + /** + * Get top performing posts in a date range. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * @param int $limit Maximum number of posts to return. + * + * @return array Array of top posts with engagement data. + */ + public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { + global $wpdb; + + $post_args = array( + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'publish', + 'date_query' => array( + array( + 'after' => $start, + 'before' => $end, + 'inclusive' => true, + ), + ), + ); + + if ( Actors::BLOG_USER_ID !== $user_id ) { + $post_args['author'] = $user_id; + } + + $post_ids = \get_posts( $post_args ); + + if ( empty( $post_ids ) ) { + return array(); + } + + $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + + // Get registered comment types dynamically. + $comment_types = Comment::get_comment_type_slugs(); + if ( empty( $comment_types ) ) { + return array(); + } + + $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); + + // Get engagement counts per post. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT c.comment_post_ID as post_id, COUNT(c.comment_ID) as engagement_count + FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$placeholders}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub' + AND c.comment_type IN ({$placeholders_types}) + GROUP BY c.comment_post_ID + ORDER BY engagement_count DESC + LIMIT %d", + \array_merge( $post_ids, $comment_types, array( $limit ) ) + ), + ARRAY_A + ); + // phpcs:enable + + $top_posts = array(); + foreach ( $results as $result ) { + $post = \get_post( $result['post_id'] ); + if ( $post ) { + $top_posts[] = array( + 'post_id' => $result['post_id'], + 'title' => \get_the_title( $post ), + 'url' => \get_permalink( $post ), + 'engagement_count' => (int) $result['engagement_count'], + ); + } + } + + return $top_posts; + } + + /** + * Get the top multiplicator (actor who boosted content the most) in a date range. + * + * @param int $user_id The user ID. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * + * @return array|null Actor data or null if none found. + */ + public static function get_top_multiplicator( $user_id, $start, $end ) { + global $wpdb; + + $post_args = array( + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'publish', + ); + + if ( Actors::BLOG_USER_ID !== $user_id ) { + $post_args['author'] = $user_id; + } + + $post_ids = \get_posts( $post_args ); + + if ( empty( $post_ids ) ) { + return null; + } + + $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + + // Get actor who boosted the most. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $result = $wpdb->get_row( + $wpdb->prepare( + "SELECT c.comment_author as name, c.comment_author_url as url, COUNT(c.comment_ID) as boost_count + FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$placeholders}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub' + AND c.comment_type = 'repost' + AND c.comment_date_gmt >= %s + AND c.comment_date_gmt <= %s + GROUP BY c.comment_author_url + ORDER BY boost_count DESC + LIMIT 1", + \array_merge( $post_ids, array( $start, $end ) ) + ), + ARRAY_A + ); + // phpcs:enable + + if ( ! $result || empty( $result['url'] ) ) { + return null; + } + + return array( + 'name' => $result['name'], + 'url' => $result['url'], + 'count' => (int) $result['boost_count'], + ); + } + + /** + * Get current follower count for a user. + * + * @param int $user_id The user ID. + * + * @return int The follower count. + */ + public static function get_follower_count( $user_id ) { + return Followers::count( $user_id ); + } + + /** + * Get all active user IDs that have ActivityPub enabled. + * + * @return array Array of user IDs including BLOG_USER_ID if enabled. + */ + public static function get_active_user_ids() { + $user_ids = array(); + + // Check if blog actor is enabled. + $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); + if ( \in_array( $actor_mode, array( ACTIVITYPUB_BLOG_MODE, ACTIVITYPUB_ACTOR_AND_BLOG_MODE ), true ) ) { + $user_ids[] = Actors::BLOG_USER_ID; + } + + // Get users with ActivityPub enabled. + if ( \in_array( $actor_mode, array( ACTIVITYPUB_ACTOR_MODE, ACTIVITYPUB_ACTOR_AND_BLOG_MODE ), true ) ) { + $users = \get_users( + array( + 'capability__in' => array( 'activitypub' ), + 'fields' => 'ID', + ) + ); + + $user_ids = \array_merge( $user_ids, $users ); + } + + return $user_ids; + } + + /** + * Get statistics for the current period (real-time). + * + * @param int $user_id The user ID. + * @param string $period The period ('month', 'year', 'all'). + * + * @return array The statistics. + */ + public static function get_current_stats( $user_id, $period = 'month' ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + + switch ( $period ) { + case 'year': + $start = \gmdate( 'Y-01-01 00:00:00', $now ); + $end = \gmdate( 'Y-12-31 23:59:59', $now ); + break; + + case 'all': + $start = '1970-01-01 00:00:00'; + $end = \gmdate( 'Y-m-d 23:59:59', $now ); + break; + + case 'month': + default: + $start = \gmdate( 'Y-m-01 00:00:00', $now ); + $end = \gmdate( 'Y-m-t 23:59:59', $now ); + break; + } + + $stats = array( + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'followers_total' => self::get_follower_count( $user_id ), + 'top_posts' => self::get_top_posts( $user_id, $start, $end, 3 ), + 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), + 'period' => $period, + 'start' => $start, + 'end' => $end, + ); + + // Add counts for each comment type dynamically. + foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) { + $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + + return $stats; + } + + /** + * Get monthly breakdown for the current year (for graphs). + * + * @param int $user_id The user ID. + * @param int $year Optional. The year. Defaults to current year. + * + * @return array Array of monthly stats with month number as key. + */ + public static function get_yearly_monthly_breakdown( $user_id, $year = null ) { + if ( ! $year ) { + $year = (int) \gmdate( 'Y' ); + } + + $current_month = (int) \gmdate( 'n' ); + $current_year = (int) \gmdate( 'Y' ); + $months = array(); + + // Get all comment types tracked in stats (includes federated comments via filter). + $comment_types = \array_keys( self::get_comment_types_for_stats() ); + + // Only go up to current month if we're in the current year. + $max_month = ( $year === $current_year ) ? $current_month : 12; + + for ( $month = 1; $month <= $max_month; $month++ ) { + $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); + $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + + $engagement = self::count_engagement_in_range( $user_id, $start, $end ); + + $month_data = array( + 'month' => $month, + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'engagement' => $engagement, + ); + + // Add counts for each comment type tracked in stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } + + $months[ $month ] = $month_data; + } + + return $months; + } + + /** + * Get year-over-year comparison for current month. + * + * @param int $user_id The user ID. + * + * @return array Comparison data with current values and changes from last year. + */ + public static function get_year_comparison( $user_id ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $current_year = (int) \gmdate( 'Y', $now ); + $current_month = (int) \gmdate( 'n', $now ); + $last_year = $current_year - 1; + + // Current month this year. + $this_year_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $this_year_end = \gmdate( 'Y-m-t 23:59:59', $now ); + + // Same month last year. + $last_year_start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $last_year, $current_month ) ) ); + $last_year_end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $last_year, $current_month ) ) ); + + // Get current stats. + $current_posts = self::count_federated_posts_in_range( $user_id, $this_year_start, $this_year_end ); + $current_followers = self::get_follower_count( $user_id ); + + // Get last year stats. + $last_posts = self::count_federated_posts_in_range( $user_id, $last_year_start, $last_year_end ); + + // Get last year's follower count from stored stats. + $last_year_stats = self::get_monthly_stats( $user_id, $last_year, $current_month ); + $last_followers = $last_year_stats ? ( $last_year_stats['followers_total'] ?? 0 ) : 0; + + $comparison = array( + 'posts' => array( + 'current' => $current_posts, + 'change' => $current_posts - $last_posts, + ), + 'followers' => array( + 'current' => $current_followers, + 'change' => $last_followers > 0 ? $current_followers - $last_followers : 0, + ), + ); + + // Add comparison for each registered comment type dynamically. + $comment_types = Comment::get_comment_type_slugs(); + foreach ( $comment_types as $type ) { + $current_count = self::count_engagement_in_range( $user_id, $this_year_start, $this_year_end, $type ); + $last_count = self::count_engagement_in_range( $user_id, $last_year_start, $last_year_end, $type ); + + $comparison[ $type ] = array( + 'current' => $current_count, + 'change' => $current_count - $last_count, + ); + } + + return $comparison; + } + + /** + * Get comment types to track in statistics. + * + * By default includes all registered ActivityPub comment types. + * Use the 'activitypub_stats_comment_types' filter to add additional types. + * + * @return array Array of comment type data with slug, label, and singular. + */ + public static function get_comment_types_for_stats() { + $comment_types = Comment::get_comment_types(); + $result = array(); + + foreach ( $comment_types as $slug => $type ) { + $result[ $slug ] = array( + 'slug' => $slug, + 'label' => $type['label'] ?? \ucfirst( $slug ), + 'singular' => $type['singular'] ?? \ucfirst( $slug ), + ); + } + + /** + * Filter the comment types tracked in statistics. + * + * Allows adding additional comment types (like federated comments) + * to be tracked in the statistics dashboard. + * + * @param array $result Array of comment type data with slug, label, and singular. + */ + return \apply_filters( 'activitypub_stats_comment_types', $result ); + } + + /** + * Backfill historical statistics for all active users. + * + * This method processes statistics in batches to avoid timeouts. + * + * @param int $batch_size Optional. Number of months to process per batch. Default 12. + * @param int $user_index Optional. The current user index being processed. Default 0. + * @param int $year Optional. The year being processed. Default 0 (will determine earliest year). + * @param int $month Optional. The month being processed. Default 1. + * + * @return array|null Array with batch info if more processing needed, null if complete. + */ + public static function backfill_historical_stats( $batch_size = 12, $user_index = 0, $year = 0, $month = 1 ) { + $user_ids = self::get_active_user_ids(); + + if ( empty( $user_ids ) || $user_index >= \count( $user_ids ) ) { + return null; // All done. + } + + $user_id = $user_ids[ $user_index ]; + $current_year = (int) \gmdate( 'Y' ); + $current_month = (int) \gmdate( 'n' ); + + // Determine the earliest year with data if not set. + if ( 0 === $year ) { + $year = self::get_earliest_data_year( $user_id ); + if ( ! $year ) { + // No data for this user, move to next user. + return array( + 'batch_size' => $batch_size, + 'user_index' => $user_index + 1, + 'year' => 0, + 'month' => 1, + ); + } + } + + $months_processed = 0; + + // Process months for this user. + while ( $months_processed < $batch_size ) { + // Check if we've gone past the current month. + if ( $year > $current_year || ( $year === $current_year && $month > $current_month ) ) { + // Move to next user. + return array( + 'batch_size' => $batch_size, + 'user_index' => $user_index + 1, + 'year' => 0, + 'month' => 1, + ); + } + + // Check if stats already exist for this month. + $existing = self::get_monthly_stats( $user_id, $year, $month ); + if ( ! $existing ) { + // Collect stats for this month. + self::collect_monthly_stats( $user_id, $year, $month ); + } + + ++$months_processed; + ++$month; + + // Move to next year if needed. + if ( $month > 12 ) { + $month = 1; + ++$year; + } + } + + // More months to process for this user. + return array( + 'batch_size' => $batch_size, + 'user_index' => $user_index, + 'year' => $year, + 'month' => $month, + ); + } + + /** + * Get the earliest year that has ActivityPub data for a user. + * + * @param int $user_id The user ID. + * + * @return int|null The earliest year with data, or null if no data. + */ + private static function get_earliest_data_year( $user_id ) { + global $wpdb; + + // Get post IDs for the user. + $post_args = array( + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'publish', + ); + + if ( Actors::BLOG_USER_ID !== $user_id ) { + $post_args['author'] = $user_id; + } + + $post_ids = \get_posts( $post_args ); + + if ( empty( $post_ids ) ) { + return null; + } + + $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + + // Find earliest comment with ActivityPub protocol. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $earliest_date = $wpdb->get_var( + $wpdb->prepare( + "SELECT MIN(c.comment_date_gmt) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$placeholders}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub'", + $post_ids + ) + ); + // phpcs:enable + + if ( ! $earliest_date ) { + // No ActivityPub data, check outbox instead. + $outbox_args = array( + 'post_type' => Outbox::POST_TYPE, + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'ASC', + 'fields' => 'ids', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Create', + ), + ), + ); + + if ( Actors::BLOG_USER_ID !== $user_id ) { + $outbox_args['author'] = $user_id; + } + + $earliest_outbox = \get_posts( $outbox_args ); + + if ( empty( $earliest_outbox ) ) { + return null; + } + + $earliest_post = \get_post( $earliest_outbox[0] ); + $earliest_date = $earliest_post->post_date_gmt; + } + + return (int) \gmdate( 'Y', \strtotime( $earliest_date ) ); + } +} diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php new file mode 100644 index 0000000000..7c26a15d90 --- /dev/null +++ b/includes/scheduler/class-statistics.php @@ -0,0 +1,259 @@ + 30 * DAY_IN_SECONDS, + 'display' => \__( 'Once Monthly', 'activitypub' ), + ); + + $schedules['yearly'] = array( + 'interval' => 365 * DAY_IN_SECONDS, + 'display' => \__( 'Once Yearly', 'activitypub' ), + ); + + return $schedules; + } + + /** + * Register statistics schedules. + */ + public static function register_schedules() { + // Schedule monthly stats collection for the 1st of each month. + if ( ! \wp_next_scheduled( 'activitypub_collect_monthly_stats' ) ) { + // Calculate next 1st of month at 2:00 AM. + $next_first = self::get_next_first_of_month(); + \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' ); + } + + // Schedule annual stats compilation for January 1st. + if ( ! \wp_next_scheduled( 'activitypub_compile_annual_stats' ) ) { + $next_year = self::get_next_january_first(); + \wp_schedule_event( $next_year, 'yearly', 'activitypub_compile_annual_stats' ); + } + } + + /** + * Deregister statistics schedules. + */ + public static function deregister_schedules() { + \wp_unschedule_hook( 'activitypub_collect_monthly_stats' ); + \wp_unschedule_hook( 'activitypub_compile_annual_stats' ); + } + + /** + * Get the next 1st of month timestamp. + * + * @return int Unix timestamp of next 1st of month at 2:00 AM. + */ + private static function get_next_first_of_month() { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $next_month = \strtotime( 'first day of next month 02:00:00', $now ); + + return $next_month; + } + + /** + * Get the next January 1st timestamp. + * + * @return int Unix timestamp of next January 1st at 3:00 AM. + */ + private static function get_next_january_first() { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $year = (int) \gmdate( 'Y', $now ); + + // If we're past January 1st, schedule for next year. + $jan_first = \strtotime( sprintf( '%d-01-01 03:00:00', $year + 1 ) ); + + return $jan_first; + } + + /** + * Collect monthly statistics for all active users. + * + * This runs on the 1st of each month and collects stats for the previous month. + */ + public static function collect_all_monthly_stats() { + $user_ids = Statistics_Collector::get_active_user_ids(); + + // Get previous month. + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $prev_month = \strtotime( '-1 month', $now ); + $year = (int) \gmdate( 'Y', $prev_month ); + $month = (int) \gmdate( 'n', $prev_month ); + + foreach ( $user_ids as $user_id ) { + Statistics_Collector::collect_monthly_stats( $user_id, $year, $month ); + } + + /** + * Fires after monthly statistics have been collected for all users. + * + * @param int $year The year of the collected stats. + * @param int $month The month of the collected stats. + */ + \do_action( 'activitypub_monthly_stats_collected', $year, $month ); + } + + /** + * Compile annual statistics and send emails. + * + * This runs on January 1st and compiles stats for the previous year. + */ + public static function compile_and_send_annual_stats() { + $user_ids = Statistics_Collector::get_active_user_ids(); + + // Get previous year. + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $year = (int) \gmdate( 'Y', $now ) - 1; + + foreach ( $user_ids as $user_id ) { + $summary = Statistics_Collector::compile_annual_summary( $user_id, $year ); + + // Send email notification. + self::send_annual_email( $user_id, $year, $summary ); + } + + /** + * Fires after annual statistics have been compiled for all users. + * + * @param int $year The year of the compiled stats. + */ + \do_action( 'activitypub_annual_stats_compiled', $year ); + } + + /** + * Send the annual wrapped email. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param array $summary The annual summary data. + */ + private static function send_annual_email( $user_id, $year, $summary ) { + if ( empty( $summary ) ) { + return; + } + + // Don't send email if there's no activity. + if ( + empty( $summary['posts_count'] ) && + empty( $summary['likes_count'] ) && + empty( $summary['reposts_count'] ) && + empty( $summary['comments_count'] ) + ) { + return; + } + + $args = \array_merge( + $summary, + array( + 'year' => $year, + 'user_id' => $user_id, + ) + ); + + // Get month name for most_active_month. + if ( ! empty( $summary['most_active_month'] ) ) { + $args['most_active_month_name'] = \date_i18n( 'F', \strtotime( sprintf( '%d-%02d-01', $year, $summary['most_active_month'] ) ) ); + } + + Mailer::send( $user_id, 'annual_wrapped', $args ); + } + + /** + * Manually trigger monthly stats collection. + * + * Useful for CLI or testing purposes. + * + * @param int|null $user_id Optional. Specific user ID or null for all users. + * @param int|null $year Optional. Year to collect stats for. + * @param int|null $month Optional. Month to collect stats for. + * + * @return array Array of collected stats per user. + */ + public static function trigger_monthly_collection( $user_id = null, $year = null, $month = null ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + + if ( null === $year ) { + $year = (int) \gmdate( 'Y', $now ); + } + + if ( null === $month ) { + $month = (int) \gmdate( 'n', $now ); + } + + $user_ids = $user_id ? array( $user_id ) : Statistics_Collector::get_active_user_ids(); + $results = array(); + + foreach ( $user_ids as $uid ) { + $results[ $uid ] = Statistics_Collector::collect_monthly_stats( $uid, $year, $month ); + } + + return $results; + } + + /** + * Manually trigger annual stats compilation. + * + * Useful for CLI or testing purposes. + * + * @param int|null $user_id Optional. Specific user ID or null for all users. + * @param int|null $year Optional. Year to compile stats for. + * @param bool $send_email Optional. Whether to send the email. Default true. + * + * @return array Array of compiled summaries per user. + */ + public static function trigger_annual_compilation( $user_id = null, $year = null, $send_email = true ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + + if ( null === $year ) { + $year = (int) \gmdate( 'Y', $now ) - 1; + } + + $user_ids = $user_id ? array( $user_id ) : Statistics_Collector::get_active_user_ids(); + $results = array(); + + foreach ( $user_ids as $uid ) { + $summary = Statistics_Collector::compile_annual_summary( $uid, $year ); + $results[ $uid ] = $summary; + + if ( $send_email ) { + self::send_annual_email( $uid, $year, $summary ); + } + } + + return $results; + } +} diff --git a/includes/wp-admin/class-statistics-dashboard.php b/includes/wp-admin/class-statistics-dashboard.php new file mode 100644 index 0000000000..51d52199ef --- /dev/null +++ b/includes/wp-admin/class-statistics-dashboard.php @@ -0,0 +1,547 @@ + \admin_url( 'admin-ajax.php' ), + 'nonce' => \wp_create_nonce( 'activitypub_stats' ), + 'actors' => $actors, + 'defaultActor' => ! empty( $actors ) ? $actors[0]['id'] : null, + 'commentTypes' => $comment_types, + 'chartColors' => $chart_colors, + 'monthNames' => array( + \__( 'Jan', 'activitypub' ), + \__( 'Feb', 'activitypub' ), + \__( 'Mar', 'activitypub' ), + \__( 'Apr', 'activitypub' ), + \__( 'May', 'activitypub' ), + \__( 'Jun', 'activitypub' ), + \__( 'Jul', 'activitypub' ), + \__( 'Aug', 'activitypub' ), + \__( 'Sep', 'activitypub' ), + \__( 'Oct', 'activitypub' ), + \__( 'Nov', 'activitypub' ), + \__( 'Dec', 'activitypub' ), + ), + 'i18n' => array( + 'topSupporter' => \__( 'Top Supporter', 'activitypub' ), + 'topPosts' => \__( 'Top Posts', 'activitypub' ), + 'engagements' => \__( 'engagements', 'activitypub' ), + 'noTitle' => \__( '(no title)', 'activitypub' ), + 'vsLastYear' => \__( 'vs last year', 'activitypub' ), + 'thisMonth' => \__( 'This Month', 'activitypub' ), + 'yearlyActivity' => \__( 'Yearly Activity', 'activitypub' ), + 'notEnoughData' => \__( 'Not enough data yet', 'activitypub' ), + ), + ) + ); + } + + /** + * Get colors for the chart based on WordPress admin color scheme. + * + * Colors are assigned dynamically to all comment types from the stats system. + * No hardcoded type lists - any registered type gets a color automatically. + * + * @return array Associative array of comment type slugs to hex colors. + */ + private static function get_chart_colors() { + global $_wp_admin_css_colors; + + // Get all comment types from the statistics system. + $comment_types = Statistics::get_comment_types_for_stats(); + + // Build color palette from WordPress admin colors. + $palette = array( '#d63638', '#00a32a', '#2271b1', '#dba617', '#8c8f94', '#9b59b6', '#1abc9c', '#e74c3c' ); + + // Try to get the current admin color scheme. + $admin_color = \get_user_option( 'admin_color', \get_current_user_id() ); + + if ( $admin_color && isset( $_wp_admin_css_colors[ $admin_color ] ) ) { + $scheme = $_wp_admin_css_colors[ $admin_color ]; + + if ( ! empty( $scheme->colors ) && \count( $scheme->colors ) >= 4 ) { + // Use colors from the admin scheme as the primary palette. + $palette = \array_merge( $scheme->colors, $palette ); + $palette = \array_unique( $palette ); + } + } + + // Assign colors to each comment type dynamically. + $colors = array(); + $index = 0; + + foreach ( $comment_types as $slug => $type ) { + $colors[ $slug ] = $palette[ $index % \count( $palette ) ]; + ++$index; + } + + return $colors; + } + + /** + * Get available actors (user/blog) for the current user. + * + * @return array Array of available actors with id and label. + */ + private static function get_available_actors() { + $actors = array(); + + // Check if current user can access their own stats. + if ( user_can_activitypub( \get_current_user_id() ) && ! is_user_type_disabled( 'user' ) ) { + $actors[] = array( + 'id' => \get_current_user_id(), + 'label' => \__( 'Your Stats', 'activitypub' ), + ); + } + + // Check if blog stats are available. + if ( ! is_user_type_disabled( 'blog' ) && \current_user_can( 'manage_options' ) ) { + $actors[] = array( + 'id' => Actors::BLOG_USER_ID, + 'label' => \__( 'Blog Stats', 'activitypub' ), + ); + } + + return $actors; + } + + /** + * Add dashboard widgets. + */ + public static function add_dashboard_widgets() { + // Only add widget if user has access to at least one actor type. + $has_user_access = user_can_activitypub( \get_current_user_id() ) && ! is_user_type_disabled( 'user' ); + $has_blog_access = ! is_user_type_disabled( 'blog' ) && \current_user_can( 'manage_options' ); + + if ( ! $has_user_access && ! $has_blog_access ) { + return; + } + + \wp_add_dashboard_widget( + 'activitypub_stats', + \__( 'Fediverse Stats', 'activitypub' ), + array( self::class, 'render_stats_widget' ), + null, + null, + 'normal', + 'high' + ); + } + + /** + * Render the unified stats widget. + */ + public static function render_stats_widget() { + $actors = self::get_available_actors(); + + if ( empty( $actors ) ) { + return; + } + + $default_actor = $actors[0]['id']; + $stats = Statistics::get_current_stats( $default_actor, 'month' ); + $comparison = Statistics::get_year_comparison( $default_actor ); + $monthly_data = Statistics::get_yearly_monthly_breakdown( $default_actor ); + $comment_types = Statistics::get_comment_types_for_stats(); + $chart_colors = self::get_chart_colors(); + ?> +
+
+ 1 ) : ?> + + +
+ + +
+

+
+ + $type ) : + if ( isset( $comparison[ $slug ] ) ) : + self::render_highlight_stat( $slug, $type['label'], $comparison[ $slug ] ); + endif; + endforeach; + ?> + +
+
+ + +
+

+
+ +
+
+ $type ) : ?> + + + + +
+
+ + +
+

+

+ + + + +

+
+ + + +
+

+ +
+ + +
+ + + +
+
+ 0 ? 'positive' : 'negative'; + $change_text = $data['change'] > 0 ? '+' . \number_format_i18n( $data['change'] ) : \number_format_i18n( $data['change'] ); + } + ?> +
+ + + () + + +
+ ' . \esc_html__( 'Not enough data yet', 'activitypub' ) . ''; + } + + // Find max value across all comment types for scaling. + $max_value = 1; + foreach ( $comment_types as $slug => $type ) { + $key = $slug . '_count'; + $values = \array_column( $months, $key ); + if ( ! empty( $values ) ) { + $max = \max( $values ); + if ( $max > $max_value ) { + $max_value = $max; + } + } + } + + // Generate points for each comment type line. + $all_points = array(); + $x_labels = array(); + + foreach ( $comment_types as $slug => $type ) { + $all_points[ $slug ] = array(); + } + + foreach ( $months as $i => $data ) { + $x = $padding + ( $i / ( $num_months - 1 ) ) * $chart_w; + + foreach ( $comment_types as $slug => $type ) { + $key = $slug . '_count'; + $count = $data[ $key ] ?? 0; + $y = $padding + $chart_h - ( ( $count / $max_value ) * $chart_h ); + + $all_points[ $slug ][] = \round( $x, 1 ) . ',' . \round( $y, 1 ); + } + + $x_labels[] = array( + 'x' => $x, + 'label' => \date_i18n( 'M', \strtotime( \gmdate( 'Y' ) . '-' . $data['month'] . '-01' ) ), + ); + } + + // Build SVG. + $svg = ''; + + // Grid lines. + $svg .= ''; + for ( $i = 0; $i <= 4; $i++ ) { + $y = $padding + ( $i / 4 ) * $chart_h; + $svg .= ''; + } + $svg .= ''; + + // Area fills (semi-transparent) for each comment type. + $base_y = $padding + $chart_h; + foreach ( $comment_types as $slug => $type ) { + $color = $chart_colors[ $slug ] ?? '#8c8f94'; + $fill_rgba = self::hex_to_rgba( $color, 0.1 ); + $svg .= ''; + } + + // Lines for each comment type. + foreach ( $comment_types as $slug => $type ) { + $color = $chart_colors[ $slug ] ?? '#8c8f94'; + $svg .= ''; + } + + // Data points for each comment type. + $svg .= ''; + foreach ( $months as $i => $data ) { + $x = $padding + ( $i / ( $num_months - 1 ) ) * $chart_w; + + foreach ( $comment_types as $slug => $type ) { + $key = $slug . '_count'; + $count = $data[ $key ] ?? 0; + $y = $padding + $chart_h - ( ( $count / $max_value ) * $chart_h ); + $color = $chart_colors[ $slug ] ?? '#8c8f94'; + $label = $type['singular'] ?? $type['label']; + + $svg .= '' . \esc_html( $count ) . ' ' . \esc_attr( strtolower( $label ) ) . ''; + } + } + $svg .= ''; + + $svg .= ''; + + // Month labels below chart. + $svg .= '
'; + foreach ( $x_labels as $label ) { + $left = ( $label['x'] / $width ) * 100; + $svg .= '' . \esc_html( $label['label'] ) . ''; + } + $svg .= '
'; + + return $svg; + } + + /** + * Convert hex color to rgba. + * + * @param string $hex The hex color. + * @param float $alpha The alpha value (0-1). + * + * @return string The rgba color string. + */ + private static function hex_to_rgba( $hex, $alpha = 1 ) { + $hex = \ltrim( $hex, '#' ); + + if ( 3 === \strlen( $hex ) ) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + $r = \hexdec( \substr( $hex, 0, 2 ) ); + $g = \hexdec( \substr( $hex, 2, 2 ) ); + $b = \hexdec( \substr( $hex, 4, 2 ) ); + + return \sprintf( 'rgba(%d, %d, %d, %s)', $r, $g, $b, $alpha ); + } + + /** + * AJAX handler for getting stats. + */ + public static function ajax_get_stats() { + \check_ajax_referer( 'activitypub_stats', 'nonce' ); + + $user_id = isset( $_POST['user_id'] ) ? \intval( $_POST['user_id'] ) : \get_current_user_id(); + + // Check permissions. + if ( Actors::BLOG_USER_ID !== $user_id && \get_current_user_id() !== $user_id ) { + \wp_send_json_error( array( 'message' => \__( 'Unauthorized', 'activitypub' ) ) ); + } + + if ( Actors::BLOG_USER_ID === $user_id && ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Unauthorized', 'activitypub' ) ) ); + } + + $stats = Statistics::get_current_stats( $user_id, 'month' ); + $comparison = Statistics::get_year_comparison( $user_id ); + $monthly_data = Statistics::get_yearly_monthly_breakdown( $user_id ); + $comment_types = Statistics::get_comment_types_for_stats(); + + // Build comparison data dynamically based on registered comment types. + $comparison_data = array( + 'posts' => array( + 'current' => \number_format_i18n( $comparison['posts']['current'] ), + 'change' => $comparison['posts']['change'], + 'change_formatted' => self::format_change( $comparison['posts']['change'] ), + ), + 'followers' => array( + 'current' => \number_format_i18n( $comparison['followers']['current'] ), + 'change' => $comparison['followers']['change'], + 'change_formatted' => self::format_change( $comparison['followers']['change'] ), + ), + ); + + // Add comparison for each registered comment type. + foreach ( $comment_types as $slug => $type ) { + if ( isset( $comparison[ $slug ] ) ) { + $comparison_data[ $slug ] = array( + 'current' => \number_format_i18n( $comparison[ $slug ]['current'] ), + 'change' => $comparison[ $slug ]['change'], + 'change_formatted' => self::format_change( $comparison[ $slug ]['change'] ), + ); + } + } + + // Format numbers for display. + $formatted = array( + 'stats' => array( + 'posts_count' => \number_format_i18n( $stats['posts_count'] ), + 'followers_total' => \number_format_i18n( $stats['followers_total'] ), + 'top_posts' => $stats['top_posts'], + 'top_multiplicator' => $stats['top_multiplicator'], + ), + 'comparison' => $comparison_data, + 'monthly' => \array_values( $monthly_data ), + 'wrapped_url' => Statistics::get_wrapped_url( $user_id ), + ); + + \wp_send_json_success( $formatted ); + } + + /** + * Format a change value for display. + * + * @param int $change The change value. + * + * @return string The formatted change string. + */ + private static function format_change( $change ) { + if ( 0 === $change ) { + return ''; + } + + return $change > 0 ? '+' . \number_format_i18n( $change ) : \number_format_i18n( $change ); + } +} diff --git a/templates/emails/annual-wrapped.php b/templates/emails/annual-wrapped.php new file mode 100644 index 0000000000..f8dc9322da --- /dev/null +++ b/templates/emails/annual-wrapped.php @@ -0,0 +1,222 @@ + + + +

+ +

+ +

+ +

+ +
+
+ + +
+ $type_info ) : ?> +
+ + +
+ +
+ + +
+

+

+
+ + +
+

+ = 0 ? '' : 'negative'; + $change_sign = $net_change >= 0 ? '+' : ''; + ?> +

+ +

+

+ ' . esc_html( number_format_i18n( $args['followers_start'] ?? 0 ) ) . '', + '' . esc_html( number_format_i18n( $args['followers_end'] ?? 0 ) ) . '' + ); + ?> +

+
+ + +
+

+

+ ' . esc_html( $args['top_multiplicator']['name'] ) . '', + '' . esc_html( number_format_i18n( $args['top_multiplicator']['count'] ?? 0 ) ) . '' + ); + ?> +

+
+ + +

+ +

+ + gmdate( 'Y' ), + 'user_id' => 0, + 'site_name' => get_bloginfo( 'name' ), + 'posts_count' => 0, + 'followers_total' => 0, + 'followers_net_change' => 0, + 'most_active_month' => null, + 'top_multiplicator' => null, + ) +); + +// Get comment types for dynamic stats display. +$comment_types = Statistics::get_comment_types_for_stats(); + +$is_blog = Actors::BLOG_USER_ID === $args['user_id']; +$card_title = $is_blog ? $args['site_name'] : get_the_author_meta( 'display_name', $args['user_id'] ); + +// Get theme.json colors if available. +$theme_colors = array( + 'primary' => '#667eea', + 'secondary' => '#764ba2', + 'background' => '#ffffff', + 'text' => '#1d2327', + 'text_muted' => '#50575e', +); + +// Try to get colors from theme.json. +if ( function_exists( 'wp_get_global_settings' ) ) { + $global_settings = wp_get_global_settings(); + + // Get color palette. + if ( ! empty( $global_settings['color']['palette']['theme'] ) ) { + $palette = $global_settings['color']['palette']['theme']; + + foreach ( $palette as $color ) { + $slug = $color['slug'] ?? ''; + $hex = $color['color'] ?? ''; + + if ( ! $hex ) { + continue; + } + + // Map common theme.json color slugs to our colors. + switch ( $slug ) { + case 'primary': + case 'accent': + case 'brand': + $theme_colors['primary'] = $hex; + break; + case 'secondary': + case 'accent-2': + $theme_colors['secondary'] = $hex; + break; + case 'background': + case 'base': + $theme_colors['background'] = $hex; + break; + case 'foreground': + case 'contrast': + case 'text': + $theme_colors['text'] = $hex; + break; + } + } + } + + // If no secondary color found, derive from primary. + if ( '#764ba2' === $theme_colors['secondary'] && '#667eea' !== $theme_colors['primary'] ) { + $theme_colors['secondary'] = $theme_colors['primary']; + } +} + +// CSS custom properties for the theme colors. +$css_vars = sprintf( + '--ap-primary: %s; --ap-secondary: %s; --ap-background: %s; --ap-text: %s; --ap-text-muted: %s;', + esc_attr( $theme_colors['primary'] ), + esc_attr( $theme_colors['secondary'] ), + esc_attr( $theme_colors['background'] ), + esc_attr( $theme_colors['text'] ), + esc_attr( $theme_colors['text_muted'] ) +); +?> + + + + + + <?php echo esc_html( sprintf( '%s - Fediverse Wrapped %d', $card_title, $args['year'] ) ); ?> + + + +
+
+

+

+

+
+ +
+
+
+
+
+
+ $type_info ) : ?> +
+
+
+
+ +
+ + +
+
+
+
+ + + = 0 ? '' : 'negative'; + $change_sign = $net_change >= 0 ? '+' : ''; + ?> +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ +
+ + +
+ + From c3a683126a795fab88e3d02d1f0efb71d8996a1f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 17 Dec 2025 11:20:32 +0100 Subject: [PATCH 02/46] Remove Fediverse Wrapped shareable card feature Eliminated the Fediverse Wrapped shareable card functionality by deleting the template, removing related rewrite rules, query vars, and rendering logic from Statistics, and cleaning up associated JavaScript and dashboard UI elements. --- assets/js/activitypub-statistics.js | 3 - includes/class-statistics.php | 151 -------- .../wp-admin/class-statistics-dashboard.php | 13 +- templates/statistics/wrapped-card.php | 341 ------------------ 4 files changed, 3 insertions(+), 505 deletions(-) delete mode 100644 templates/statistics/wrapped-card.php diff --git a/assets/js/activitypub-statistics.js b/assets/js/activitypub-statistics.js index 25fd740634..de00d22dfc 100644 --- a/assets/js/activitypub-statistics.js +++ b/assets/js/activitypub-statistics.js @@ -74,9 +74,6 @@ // Update top posts. updateTopPosts( $widget, data.stats.top_posts ); - - // Update wrapped card link. - $widget.find( '.activitypub-wrapped-link' ).attr( 'href', data.wrapped_url ); } /** diff --git a/includes/class-statistics.php b/includes/class-statistics.php index dea34f814e..86f14abed4 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -25,11 +25,6 @@ class Statistics { * Initialize the class, registering WordPress hooks. */ public static function init() { - // Register rewrite rules directly since we're already on init. - self::add_rewrite_rules(); - - \add_action( 'template_redirect', array( self::class, 'render_wrapped_card' ) ); - \add_filter( 'query_vars', array( self::class, 'add_query_vars' ) ); \add_filter( 'activitypub_stats_comment_types', array( self::class, 'add_federated_comments_type' ) ); } @@ -50,152 +45,6 @@ public static function add_federated_comments_type( $types ) { return $types; } - /** - * Add rewrite rules for nice wrapped URLs. - */ - public static function add_rewrite_rules() { - // /fediverse-wrapped or /fediverse-wrapped/2024. - \add_rewrite_rule( - '^fediverse-wrapped/?$', - 'index.php?activitypub_wrapped=1', - 'top' - ); - - \add_rewrite_rule( - '^fediverse-wrapped/(\d{4})/?$', - 'index.php?activitypub_wrapped=1&activitypub_wrapped_year=$matches[1]', - 'top' - ); - - // /@username/wrapped or /@username/wrapped/2024. - \add_rewrite_rule( - '^@([\w\-\.]+)/wrapped/?$', - 'index.php?activitypub_wrapped=1&actor=$matches[1]', - 'top' - ); - - \add_rewrite_rule( - '^@([\w\-\.]+)/wrapped/(\d{4})/?$', - 'index.php?activitypub_wrapped=1&actor=$matches[1]&activitypub_wrapped_year=$matches[2]', - 'top' - ); - } - - /** - * Add custom query vars. - * - * @param array $vars The existing query vars. - * - * @return array The modified query vars. - */ - public static function add_query_vars( $vars ) { - $vars[] = 'activitypub_wrapped'; - $vars[] = 'activitypub_wrapped_year'; - return $vars; - } - - /** - * Render the wrapped card if requested. - */ - public static function render_wrapped_card() { - if ( ! \get_query_var( 'activitypub_wrapped' ) ) { - return; - } - - // Get user ID from actor query var or fallback to GET param. - $actor = \get_query_var( 'actor' ); - if ( $actor ) { - $actor_object = Actors::get_by_username( $actor ); - $user_id = $actor_object && ! \is_wp_error( $actor_object ) ? $actor_object->get__id() : Actors::BLOG_USER_ID; - } else { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $user_id = isset( $_GET['user_id'] ) ? \intval( $_GET['user_id'] ) : Actors::BLOG_USER_ID; - } - - // Get year from query var or GET param. - $year = \get_query_var( 'activitypub_wrapped_year' ); - if ( ! $year ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $year = isset( $_GET['year'] ) ? \intval( $_GET['year'] ) : (int) \gmdate( 'Y' ); - } - $year = (int) $year; - - // Get annual summary if available. - $stats = self::get_annual_summary( $user_id, $year ); - - if ( ! $stats ) { - // No stored stats - get real-time data for the requested year. - $start = \gmdate( 'Y-01-01 00:00:00', \strtotime( $year . '-01-01' ) ); - $end = \gmdate( 'Y-12-31 23:59:59', \strtotime( $year . '-12-31' ) ); - - $stats = array( - 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), - 'followers_total' => self::get_follower_count( $user_id ), - 'top_posts' => self::get_top_posts( $user_id, $start, $end, 5 ), - 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), - ); - - // Add counts for each comment type dynamically. - foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) { - $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); - } - - // Add annual-specific fields. - $stats['most_active_month'] = null; - $stats['followers_net_change'] = 0; - } - - $args = \array_merge( - $stats, - array( - 'year' => $year, - 'user_id' => $user_id, - 'site_name' => \get_bloginfo( 'name' ), - 'followers_total' => self::get_follower_count( $user_id ), - ) - ); - - // Load the template. - \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/statistics/wrapped-card.php', true, array( 'args' => $args ) ); - exit; - } - - /** - * Get the wrapped card URL for a user. - * - * @param int $user_id The user ID. - * @param int $year Optional. The year. Defaults to current year. - * - * @return string The wrapped card URL. - */ - public static function get_wrapped_url( $user_id, $year = null ) { - if ( ! $year ) { - $year = (int) \gmdate( 'Y' ); - } - - // For blog actor, use /fediverse-wrapped/YEAR. - if ( Actors::BLOG_USER_ID === $user_id ) { - return \home_url( sprintf( '/fediverse-wrapped/%d/', $year ) ); - } - - // For user actors, use /@username/wrapped/YEAR. - $actor = Actors::get_by_id( $user_id ); - if ( $actor && ! \is_wp_error( $actor ) ) { - $username = $actor->get_preferred_username(); - return \home_url( sprintf( '/@%s/wrapped/%d/', $username, $year ) ); - } - - // Fallback to query string URL. - return \add_query_arg( - array( - 'activitypub_wrapped' => '1', - 'user_id' => $user_id, - 'year' => $year, - ), - \home_url() - ); - } - /** * Option prefix for statistics storage. * diff --git a/includes/wp-admin/class-statistics-dashboard.php b/includes/wp-admin/class-statistics-dashboard.php index 51d52199ef..f23756c9e8 100644 --- a/includes/wp-admin/class-statistics-dashboard.php +++ b/includes/wp-admin/class-statistics-dashboard.php @@ -294,12 +294,6 @@ public static function render_stats_widget() { - -
- - - -
array( + 'stats' => array( 'posts_count' => \number_format_i18n( $stats['posts_count'] ), 'followers_total' => \number_format_i18n( $stats['followers_total'] ), 'top_posts' => $stats['top_posts'], 'top_multiplicator' => $stats['top_multiplicator'], ), - 'comparison' => $comparison_data, - 'monthly' => \array_values( $monthly_data ), - 'wrapped_url' => Statistics::get_wrapped_url( $user_id ), + 'comparison' => $comparison_data, + 'monthly' => \array_values( $monthly_data ), ); \wp_send_json_success( $formatted ); diff --git a/templates/statistics/wrapped-card.php b/templates/statistics/wrapped-card.php deleted file mode 100644 index 041bd1d0af..0000000000 --- a/templates/statistics/wrapped-card.php +++ /dev/null @@ -1,341 +0,0 @@ - gmdate( 'Y' ), - 'user_id' => 0, - 'site_name' => get_bloginfo( 'name' ), - 'posts_count' => 0, - 'followers_total' => 0, - 'followers_net_change' => 0, - 'most_active_month' => null, - 'top_multiplicator' => null, - ) -); - -// Get comment types for dynamic stats display. -$comment_types = Statistics::get_comment_types_for_stats(); - -$is_blog = Actors::BLOG_USER_ID === $args['user_id']; -$card_title = $is_blog ? $args['site_name'] : get_the_author_meta( 'display_name', $args['user_id'] ); - -// Get theme.json colors if available. -$theme_colors = array( - 'primary' => '#667eea', - 'secondary' => '#764ba2', - 'background' => '#ffffff', - 'text' => '#1d2327', - 'text_muted' => '#50575e', -); - -// Try to get colors from theme.json. -if ( function_exists( 'wp_get_global_settings' ) ) { - $global_settings = wp_get_global_settings(); - - // Get color palette. - if ( ! empty( $global_settings['color']['palette']['theme'] ) ) { - $palette = $global_settings['color']['palette']['theme']; - - foreach ( $palette as $color ) { - $slug = $color['slug'] ?? ''; - $hex = $color['color'] ?? ''; - - if ( ! $hex ) { - continue; - } - - // Map common theme.json color slugs to our colors. - switch ( $slug ) { - case 'primary': - case 'accent': - case 'brand': - $theme_colors['primary'] = $hex; - break; - case 'secondary': - case 'accent-2': - $theme_colors['secondary'] = $hex; - break; - case 'background': - case 'base': - $theme_colors['background'] = $hex; - break; - case 'foreground': - case 'contrast': - case 'text': - $theme_colors['text'] = $hex; - break; - } - } - } - - // If no secondary color found, derive from primary. - if ( '#764ba2' === $theme_colors['secondary'] && '#667eea' !== $theme_colors['primary'] ) { - $theme_colors['secondary'] = $theme_colors['primary']; - } -} - -// CSS custom properties for the theme colors. -$css_vars = sprintf( - '--ap-primary: %s; --ap-secondary: %s; --ap-background: %s; --ap-text: %s; --ap-text-muted: %s;', - esc_attr( $theme_colors['primary'] ), - esc_attr( $theme_colors['secondary'] ), - esc_attr( $theme_colors['background'] ), - esc_attr( $theme_colors['text'] ), - esc_attr( $theme_colors['text_muted'] ) -); -?> - - - - - - <?php echo esc_html( sprintf( '%s - Fediverse Wrapped %d', $card_title, $args['year'] ) ); ?> - - - -
-
-

-

-

-
- -
-
-
-
-
-
- $type_info ) : ?> -
-
-
-
- -
- - -
-
-
-
- - - = 0 ? '' : 'negative'; - $change_sign = $net_change >= 0 ? '+' : ''; - ?> -
-
-
-
- -
-
- - -
-
-
-
- -
-
- -
- - -
- - From 440035302957a87cba8102b3a48b019285d48795 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 17 Dec 2025 12:16:47 +0100 Subject: [PATCH 03/46] Convert statistics dashboard widget to React with TypeScript. - Replace jQuery-based widget with React components - Add REST API endpoint for statistics data - Add TypeScript types for stats data structures - Add chart legend showing engagement types - Support stored monthly stats for demo/historical data - Add CLI command for populating demo data (local dev only) --- activitypub.php | 1 + assets/css/activitypub-statistics.css | 288 ---------- assets/js/activitypub-statistics.js | 339 ------------ build/dashboard-stats/block.json | 10 + build/dashboard-stats/index.asset.php | 1 + build/dashboard-stats/index.js | 3 + build/dashboard-stats/style-index-rtl.css | 1 + build/dashboard-stats/style-index.css | 1 + includes/class-statistics.php | 189 +++++-- includes/rest/class-statistics-controller.php | 117 +++++ .../wp-admin/class-statistics-dashboard.php | 490 ++---------------- local/class-cli.php | 51 ++ src/dashboard-stats/block.json | 10 + .../components/line-chart/index.tsx | 153 ++++++ .../components/stat-highlights/index.tsx | 61 +++ .../components/stats-widget/index.tsx | 76 +++ .../components/top-posts/index.tsx | 37 ++ .../components/top-supporter/index.tsx | 31 ++ src/dashboard-stats/index.tsx | 36 ++ src/dashboard-stats/style.scss | 206 ++++++++ src/dashboard-stats/types/index.ts | 62 +++ 21 files changed, 1065 insertions(+), 1098 deletions(-) delete mode 100644 assets/css/activitypub-statistics.css delete mode 100644 assets/js/activitypub-statistics.js create mode 100644 build/dashboard-stats/block.json create mode 100644 build/dashboard-stats/index.asset.php create mode 100644 build/dashboard-stats/index.js create mode 100644 build/dashboard-stats/style-index-rtl.css create mode 100644 build/dashboard-stats/style-index.css create mode 100644 includes/rest/class-statistics-controller.php create mode 100644 src/dashboard-stats/block.json create mode 100644 src/dashboard-stats/components/line-chart/index.tsx create mode 100644 src/dashboard-stats/components/stat-highlights/index.tsx create mode 100644 src/dashboard-stats/components/stats-widget/index.tsx create mode 100644 src/dashboard-stats/components/top-posts/index.tsx create mode 100644 src/dashboard-stats/components/top-supporter/index.tsx create mode 100644 src/dashboard-stats/index.tsx create mode 100644 src/dashboard-stats/style.scss create mode 100644 src/dashboard-stats/types/index.ts diff --git a/activitypub.php b/activitypub.php index 331131c9fd..04c312f490 100644 --- a/activitypub.php +++ b/activitypub.php @@ -55,6 +55,7 @@ function rest_init() { ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Post_Controller() )->register_routes(); ( new Rest\Replies_Controller() )->register_routes(); + ( new Rest\Statistics_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); // Load NodeInfo endpoints only if blog is public. diff --git a/assets/css/activitypub-statistics.css b/assets/css/activitypub-statistics.css deleted file mode 100644 index b1d8fb1d27..0000000000 --- a/assets/css/activitypub-statistics.css +++ /dev/null @@ -1,288 +0,0 @@ -/** - * ActivityPub Statistics Dashboard Widget Styles - * - * @package Activitypub - */ - -/* Widget container */ -.activitypub-stats-widget { - padding: 0; -} - -/* Loading state */ -.activitypub-stats-widget.loading { - opacity: 0.6; - pointer-events: none; -} - -/* Header with actor selector */ -.activitypub-stats-header { - margin-bottom: 16px; -} - -.activitypub-stats-actor-select { - width: 100%; - padding: 8px 12px; - border: 1px solid #8c8f94; - border-radius: 4px; - background: #fff; - font-size: 14px; -} - -/* Comparison highlights section */ -.activitypub-stats-highlights { - margin-bottom: 20px; -} - -.activitypub-stats-highlights h4 { - margin: 0 0 12px; - font-size: 13px; - color: #1d2327; - font-weight: 600; -} - -.activitypub-highlights-grid { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.activitypub-highlight { - flex: 1 1 auto; - min-width: calc(20% - 8px); - text-align: center; - padding: 10px 8px; - background: #f0f0f1; - border-radius: 6px; -} - -.activitypub-highlight-value { - display: block; - font-size: 20px; - font-weight: 700; - color: #1d2327; - line-height: 1.2; -} - -.activitypub-highlight-change { - display: inline-block; - font-size: 11px; - margin-top: 2px; - color: #50575e; -} - -.activitypub-highlight-change.positive { - color: #00a32a; -} - -.activitypub-highlight-change.negative { - color: #d63638; -} - -.activitypub-highlight-label { - display: block; - font-size: 10px; - color: #50575e; - margin-top: 4px; - text-transform: uppercase; - letter-spacing: 0.3px; -} - -/* Yearly activity graph */ -.activitypub-stats-graph { - margin-bottom: 20px; -} - -.activitypub-stats-graph h4 { - margin: 0 0 12px; - font-size: 13px; - color: #1d2327; - font-weight: 600; -} - -.activitypub-graph-container { - background: #f6f7f7; - border-radius: 6px; - padding: 12px; - position: relative; -} - -/* Line chart styles */ -.activitypub-line-chart { - width: 100%; - height: auto; - display: block; -} - -.activitypub-line-chart .data-points circle { - transition: r 0.15s ease; - cursor: pointer; -} - -.activitypub-line-chart .data-points circle:hover { - r: 5; -} - -.activitypub-graph-labels { - position: relative; - height: 20px; - margin-top: 4px; -} - -.activitypub-graph-labels span { - position: absolute; - transform: translateX(-50%); - font-size: 9px; - color: #50575e; - text-transform: uppercase; -} - -.activitypub-graph-empty { - text-align: center; - padding: 30px 20px; - color: #50575e; - font-size: 13px; -} - -/* Legend */ -.activitypub-graph-legend { - display: flex; - justify-content: center; - gap: 16px; - margin-top: 12px; - flex-wrap: wrap; -} - -.legend-item { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: #50575e; -} - -.legend-item::before { - content: ''; - display: inline-block; - width: 12px; - height: 3px; - border-radius: 2px; - background: var(--legend-color, #8c8f94); -} - -/* Top multiplicator section */ -.activitypub-stats-multiplicator { - background: #fff8e5; - border-left: 4px solid #dba617; - padding: 12px; - margin-bottom: 16px; - border-radius: 0 4px 4px 0; -} - -.activitypub-stats-multiplicator h4 { - margin: 0 0 8px; - font-size: 13px; - color: #1d2327; -} - -.activitypub-stats-multiplicator p { - margin: 0; - font-size: 13px; -} - -.activitypub-stats-multiplicator a { - color: #2271b1; - text-decoration: none; - font-weight: 600; -} - -.activitypub-stats-multiplicator a:hover { - text-decoration: underline; -} - -/* Top posts section */ -.activitypub-stats-top-posts { - margin-bottom: 16px; -} - -.activitypub-stats-top-posts h4 { - margin: 0 0 8px; - font-size: 13px; - color: #1d2327; -} - -.activitypub-stats-top-posts ul { - margin: 0; - padding: 0; - list-style: none; -} - -.activitypub-stats-top-posts li { - padding: 8px 0; - border-bottom: 1px solid #f0f0f1; - font-size: 13px; -} - -.activitypub-stats-top-posts li:last-child { - border-bottom: none; -} - -.activitypub-stats-top-posts a { - color: #2271b1; - text-decoration: none; - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.activitypub-stats-top-posts a:hover { - text-decoration: underline; -} - -.activitypub-stats-top-posts .engagement-count { - display: block; - font-size: 11px; - color: #50575e; - margin-top: 2px; -} - -/* Actions section */ -.activitypub-stats-actions { - padding-top: 12px; - border-top: 1px solid #f0f0f1; -} - -.activitypub-stats-actions .button { - width: 100%; - text-align: center; -} - -/* Responsive adjustments */ -@media screen and (max-width: 782px) { - .activitypub-highlight { - min-width: calc(33.333% - 8px); - } - - .activitypub-highlight-value { - font-size: 18px; - } -} - -@media screen and (max-width: 600px) { - .activitypub-highlight { - min-width: calc(50% - 8px); - } - - .activitypub-graph-labels span { - font-size: 8px; - } - - .activitypub-graph-legend { - gap: 10px; - } - - .legend-item { - font-size: 10px; - } -} diff --git a/assets/js/activitypub-statistics.js b/assets/js/activitypub-statistics.js deleted file mode 100644 index de00d22dfc..0000000000 --- a/assets/js/activitypub-statistics.js +++ /dev/null @@ -1,339 +0,0 @@ -/** - * ActivityPub Statistics Dashboard Widget JavaScript - * - * @package Activitypub - */ - -/* global jQuery, activitypubStats */ - -( function( $ ) { - 'use strict'; - - /** - * Initialize statistics widgets. - */ - function init() { - $( '.activitypub-stats-widget' ).each( function() { - var $widget = $( this ); - var $actorSelect = $widget.find( '.activitypub-stats-actor-select' ); - - // Actor selector change event. - $actorSelect.on( 'change', function() { - loadStats( $widget, $( this ).val() ); - } ); - } ); - } - - /** - * Load statistics via AJAX. - * - * @param {jQuery} $widget The widget container. - * @param {string} userId The user ID to load stats for. - */ - function loadStats( $widget, userId ) { - $widget.addClass( 'loading' ); - - $.ajax( { - url: activitypubStats.ajaxUrl, - type: 'POST', - data: { - action: 'activitypub_get_stats', - nonce: activitypubStats.nonce, - user_id: userId - }, - success: function( response ) { - if ( response.success ) { - updateWidget( $widget, response.data, userId ); - } - }, - complete: function() { - $widget.removeClass( 'loading' ); - } - } ); - } - - /** - * Update widget with new data. - * - * @param {jQuery} $widget The widget container. - * @param {Object} data The statistics data. - * @param {string} userId The user ID. - */ - function updateWidget( $widget, data, userId ) { - // Update user ID data attribute. - $widget.attr( 'data-user-id', userId ); - - // Update comparison highlights. - updateHighlights( $widget, data.comparison ); - - // Update line chart. - updateLineChart( $widget, data.monthly ); - - // Update top multiplicator. - updateMultiplicator( $widget, data.stats.top_multiplicator ); - - // Update top posts. - updateTopPosts( $widget, data.stats.top_posts ); - } - - /** - * Update highlight stats with comparison. - * - * @param {jQuery} $widget The widget container. - * @param {Object} comparison The comparison data. - */ - function updateHighlights( $widget, comparison ) { - // Build list of stats to update: posts, all comment types, and followers. - var stats = [ 'posts' ]; - - // Add dynamic comment types from settings. - if ( activitypubStats.commentTypes ) { - Object.keys( activitypubStats.commentTypes ).forEach( function( type ) { - stats.push( type ); - } ); - } - - stats.push( 'followers' ); - - stats.forEach( function( stat ) { - var $highlight = $widget.find( '.activitypub-highlight[data-stat="' + stat + '"]' ); - var data = comparison[ stat ]; - - if ( ! data ) { - return; - } - - $highlight.find( '.activitypub-highlight-value' ).text( data.current ); - - var $change = $highlight.find( '.activitypub-highlight-change' ); - if ( data.change_formatted ) { - if ( $change.length === 0 ) { - $highlight.find( '.activitypub-highlight-value' ).after( - '(' + escapeHtml( data.change_formatted ) + ')' - ); - $change = $highlight.find( '.activitypub-highlight-change' ); - } else { - $change.text( '(' + data.change_formatted + ')' ); - } - - $change.removeClass( 'positive negative' ); - if ( data.change > 0 ) { - $change.addClass( 'positive' ); - } else if ( data.change < 0 ) { - $change.addClass( 'negative' ); - } - } else { - $change.remove(); - } - } ); - } - - /** - * Update the line chart with new data. - * - * @param {jQuery} $widget The widget container. - * @param {Array} monthly The monthly data array. - */ - function updateLineChart( $widget, monthly ) { - var $container = $widget.find( '.activitypub-graph-container' ); - - if ( monthly.length < 2 ) { - $container.html( '
' + ( activitypubStats.i18n.notEnoughData || 'Not enough data yet' ) + '
' ); - return; - } - - var width = 400; - var height = 120; - var padding = 10; - var chartW = width - ( padding * 2 ); - var chartH = height - ( padding * 2 ); - var numMonths = monthly.length; - - // Get comment types and colors from settings. - var commentTypes = activitypubStats.commentTypes || {}; - var chartColors = activitypubStats.chartColors || {}; - var typeKeys = Object.keys( commentTypes ); - - // Find max value across all comment types. - var maxValue = 1; - typeKeys.forEach( function( type ) { - var key = type + '_count'; - var max = Math.max.apply( null, monthly.map( function( m ) { return m[ key ] || 0; } ) ); - if ( max > maxValue ) { - maxValue = max; - } - } ); - - // Generate points for each comment type. - var allPoints = {}; - typeKeys.forEach( function( type ) { - allPoints[ type ] = []; - } ); - var xLabels = []; - - monthly.forEach( function( data, i ) { - var x = padding + ( i / ( numMonths - 1 ) ) * chartW; - - typeKeys.forEach( function( type ) { - var key = type + '_count'; - var count = data[ key ] || 0; - var y = padding + chartH - ( ( count / maxValue ) * chartH ); - allPoints[ type ].push( x.toFixed( 1 ) + ',' + y.toFixed( 1 ) ); - } ); - - xLabels.push( { - x: x, - label: activitypubStats.monthNames[ data.month - 1 ] || '' - } ); - } ); - - // Build SVG. - var baseY = padding + chartH; - var svg = ''; - - // Grid lines. - svg += ''; - for ( var i = 0; i <= 4; i++ ) { - var y = padding + ( i / 4 ) * chartH; - svg += ''; - } - svg += ''; - - // Area fills for each comment type. - typeKeys.forEach( function( type ) { - var color = chartColors[ type ] || '#8c8f94'; - var fillRgba = hexToRgba( color, 0.1 ); - svg += ''; - } ); - - // Lines for each comment type. - typeKeys.forEach( function( type ) { - var color = chartColors[ type ] || '#8c8f94'; - svg += ''; - } ); - - // Data points. - svg += ''; - monthly.forEach( function( data, i ) { - var x = padding + ( i / ( numMonths - 1 ) ) * chartW; - - typeKeys.forEach( function( type ) { - var key = type + '_count'; - var count = data[ key ] || 0; - var y = padding + chartH - ( ( count / maxValue ) * chartH ); - var color = chartColors[ type ] || '#8c8f94'; - var label = commentTypes[ type ] ? ( commentTypes[ type ].singular || commentTypes[ type ].label ).toLowerCase() : type; - - svg += '' + count + ' ' + label + ''; - } ); - } ); - svg += ''; - svg += ''; - - // Month labels. - svg += '
'; - xLabels.forEach( function( label ) { - var left = ( label.x / width ) * 100; - svg += '' + escapeHtml( label.label ) + ''; - } ); - svg += '
'; - - $container.html( svg ); - } - - /** - * Convert hex color to rgba. - * - * @param {string} hex The hex color. - * @param {number} alpha The alpha value (0-1). - * @return {string} The rgba color string. - */ - function hexToRgba( hex, alpha ) { - hex = hex.replace( '#', '' ); - - if ( hex.length === 3 ) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - - var r = parseInt( hex.substring( 0, 2 ), 16 ); - var g = parseInt( hex.substring( 2, 4 ), 16 ); - var b = parseInt( hex.substring( 4, 6 ), 16 ); - - return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'; - } - - /** - * Update top multiplicator section. - * - * @param {jQuery} $widget The widget container. - * @param {Object|null} multiplicator The multiplicator data or null. - */ - function updateMultiplicator( $widget, multiplicator ) { - var $section = $widget.find( '.activitypub-stats-multiplicator' ); - - if ( multiplicator && multiplicator.name ) { - var html = '

' + activitypubStats.i18n.topSupporter + '

' + - '

' + - escapeHtml( multiplicator.name ) + ' (' + multiplicator.count + ' boosts)

'; - - if ( $section.length === 0 ) { - $widget.find( '.activitypub-stats-graph' ).after( - '
' + html + '
' - ); - } else { - $section.html( html ); - } - } else { - $section.remove(); - } - } - - /** - * Update top posts section. - * - * @param {jQuery} $widget The widget container. - * @param {Array} topPosts The top posts array. - */ - function updateTopPosts( $widget, topPosts ) { - var $section = $widget.find( '.activitypub-stats-top-posts' ); - - if ( topPosts && topPosts.length > 0 ) { - var html = '

' + activitypubStats.i18n.topPosts + '

'; - - if ( $section.length === 0 ) { - var $insertAfter = $widget.find( '.activitypub-stats-multiplicator' ); - if ( $insertAfter.length === 0 ) { - $insertAfter = $widget.find( '.activitypub-stats-graph' ); - } - $insertAfter.after( '
' + html + '
' ); - } else { - $section.html( html ); - } - } else { - $section.remove(); - } - } - - /** - * Escape HTML entities. - * - * @param {string} text The text to escape. - * @return {string} The escaped text. - */ - function escapeHtml( text ) { - var div = document.createElement( 'div' ); - div.textContent = text; - return div.innerHTML; - } - - // Initialize on document ready. - $( document ).ready( init ); - -}( jQuery ) ); diff --git a/build/dashboard-stats/block.json b/build/dashboard-stats/block.json new file mode 100644 index 0000000000..6c55345671 --- /dev/null +++ b/build/dashboard-stats/block.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "activitypub/dashboard-stats", + "title": "ActivityPub Dashboard Stats", + "category": "widgets", + "description": "ActivityPub statistics dashboard widget", + "textdomain": "activitypub", + "editorScript": "file:./index.js" +} \ No newline at end of file diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php new file mode 100644 index 0000000000..3352e75d4f --- /dev/null +++ b/build/dashboard-stats/index.asset.php @@ -0,0 +1 @@ + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '07d042c2073fa1bcc466'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js new file mode 100644 index 0000000000..872e358a00 --- /dev/null +++ b/build/dashboard-stats/index.js @@ -0,0 +1,3 @@ +(()=>{"use strict";var t,e={192:(t,e,a)=>{const i=window.wp.element,n=window.wp.apiFetch;var s=a.n(n);const l=window.wp.components,r=window.wp.i18n,c=window.ReactJSXRuntime;function o({comparison:t}){var e,a,i,n,s,l,o,p;if(!t)return null;const u=[{key:"followers",label:(0,r.__)("Followers","activitypub"),value:null!==(e=t.followers?.current)&&void 0!==e?e:0,change:null!==(a=t.followers?.change)&&void 0!==a?a:0},{key:"posts",label:(0,r.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0},{key:"likes",label:(0,r.__)("Likes","activitypub"),value:null!==(s=t.like?.current)&&void 0!==s?s:0,change:null!==(l=t.like?.change)&&void 0!==l?l:0},{key:"reposts",label:(0,r.__)("Reposts","activitypub"),value:null!==(o=t.repost?.current)&&void 0!==o?o:0,change:null!==(p=t.repost?.change)&&void 0!==p?p:0}];return(0,c.jsx)("div",{className:"activitypub-stats-highlights",children:u.map(t=>(0,c.jsxs)("div",{className:"activitypub-stat-item",children:[(0,c.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,c.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,c.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:[t.change>0?"+":"",t.change.toLocaleString()]})]},t.key))})}const p={engagement:"#3858e9",like:"#d63638",repost:"#00a32a",comment:"#dba617"};function u({monthly:t,commentTypes:e}){if(!t?.length)return null;const a=20,i=150,n=Math.max(...t.map(t=>t.engagement||0),1),s=t.map((e,a)=>({x:40+a/(t.length-1||1)*540,y:170-(e.engagement||0)/n*i,month:e})),l=s.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),o=l+` L ${s[s.length-1].x} 170`+` L ${s[0].x} 170 Z`,u=[(0,r.__)("Jan","activitypub"),(0,r.__)("Feb","activitypub"),(0,r.__)("Mar","activitypub"),(0,r.__)("Apr","activitypub"),(0,r.__)("May","activitypub"),(0,r.__)("Jun","activitypub"),(0,r.__)("Jul","activitypub"),(0,r.__)("Aug","activitypub"),(0,r.__)("Sep","activitypub"),(0,r.__)("Oct","activitypub"),(0,r.__)("Nov","activitypub"),(0,r.__)("Dec","activitypub")],d=[{key:"engagement",label:(0,r.__)("Total Engagement","activitypub"),color:p.engagement}];return e&&Object.entries(e).forEach(([t,e])=>{const a=p[t]||"#8c8f94";d.push({key:t,label:e.label,color:a})}),(0,c.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,c.jsx)("h4",{children:(0,r.__)("Engagement Over Time","activitypub")}),(0,c.jsxs)("div",{className:"activitypub-chart-container",children:[(0,c.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,c.jsx)("defs",{children:(0,c.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,c.jsx)("stop",{offset:"0%",stopColor:p.engagement,stopOpacity:.3}),(0,c.jsx)("stop",{offset:"100%",stopColor:p.engagement,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,c.jsx)("line",{x1:40,y1:a+i*(1-t),x2:580,y2:a+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,c.jsx)("path",{d:o,fill:"url(#areaGradient)"}),(0,c.jsx)("path",{d:l,fill:"none",stroke:p.engagement,strokeWidth:"2"}),s.map((t,e)=>(0,c.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:p.engagement},e)),s.map((t,e)=>(0,c.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:u[t.month.month-1]},e)),[0,.5,1].map(t=>(0,c.jsx)("text",{x:35,y:a+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(n*t)},t))]}),(0,c.jsx)("div",{className:"activitypub-chart-legend",children:d.map(t=>(0,c.jsxs)("div",{className:"activitypub-legend-item",children:[(0,c.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function d({multiplicator:t}){return t?.name?(0,c.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Supporter","activitypub")}),(0,c.jsxs)("p",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,r.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,r._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function v({posts:t}){return t?.length?(0,c.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Posts","activitypub")}),(0,c.jsx)("ul",{children:t.map(t=>(0,c.jsxs)("li",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,r.__)("(no title)","activitypub")}),(0,c.jsx)("span",{className:"engagement-count",children:(0,r.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,r.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}function h(){const{actors:t=[]}=window.activitypubDashboardStats||{actors:[]},[e,a]=(0,i.useState)(()=>{var e;return null!==(e=t[0]?.id)&&void 0!==e?e:null}),[n,p]=(0,i.useState)(null),[h,m]=(0,i.useState)(!0);(0,i.useEffect)(()=>{null!==e?(m(!0),s()({path:`/activitypub/1.0/stats/${e}`}).then(t=>p(t)).catch(()=>p(null)).finally(()=>m(!1))):m(!1)},[e]);const y=t.map(t=>({label:t.label,value:t.id}));return(0,c.jsxs)("div",{className:"activitypub-stats-widget",children:[t.length>1&&null!==e&&(0,c.jsx)("div",{className:"activitypub-stats-header",children:(0,c.jsx)(l.SelectControl,{value:e,options:y,onChange:t=>a(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),h?(0,c.jsx)("div",{className:"activitypub-stats-loading",children:(0,c.jsx)(l.Spinner,{})}):n?(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(o,{comparison:n.comparison}),(0,c.jsx)(u,{monthly:n.monthly,commentTypes:n.comment_types}),(0,c.jsx)(d,{multiplicator:n.stats?.top_multiplicator}),(0,c.jsx)(v,{posts:n.stats?.top_posts})]}):(0,c.jsx)("p",{className:"activitypub-stats-empty",children:(0,r.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t,e){const a=document.getElementById(t);a&&(window.activitypubDashboardStats=e,(0,i.createRoot)(a).render((0,c.jsx)(h,{})))}}}},a={};function i(t){var n=a[t];if(void 0!==n)return n.exports;var s=a[t]={exports:{}};return e[t](s,s.exports,i),s.exports}i.m=e,t=[],i.O=(e,a,n,s)=>{if(!a){var l=1/0;for(p=0;p=s)&&Object.keys(i.O).every(t=>i.O[t](a[c]))?a.splice(c--,1):(r=!1,s0&&t[p-1][2]>s;p--)t[p]=t[p-1];t[p]=[a,n,s]},i.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return i.d(e,{a:e}),e},i.d=(t,e)=>{for(var a in e)i.o(e,a)&&!i.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};i.O.j=e=>0===t[e];var e=(e,a)=>{var n,s,[l,r,c]=a,o=0;if(l.some(e=>0!==t[e])){for(n in r)i.o(r,n)&&(i.m[n]=r[n]);if(c)var p=c(i)}for(e&&e(a);oi(192));n=i.O(n)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css new file mode 100644 index 0000000000..fdde8016c0 --- /dev/null +++ b/build/dashboard-stats/style-index-rtl.css @@ -0,0 +1 @@ +.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));margin-bottom:20px}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-right:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css new file mode 100644 index 0000000000..feb47f3267 --- /dev/null +++ b/build/dashboard-stats/style-index.css @@ -0,0 +1 @@ +.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));margin-bottom:20px}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-left:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} diff --git a/includes/class-statistics.php b/includes/class-statistics.php index 86f14abed4..ac211cda1e 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -593,7 +593,9 @@ public static function get_active_user_ids() { } /** - * Get statistics for the current period (real-time). + * Get statistics for the current period. + * + * Uses stored monthly stats if available, otherwise queries live data. * * @param int $user_id The user ID. * @param string $period The period ('month', 'year', 'all'). @@ -601,7 +603,26 @@ public static function get_active_user_ids() { * @return array The statistics. */ public static function get_current_stats( $user_id, $period = 'month' ) { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $current_year = (int) \gmdate( 'Y', $now ); + $current_month = (int) \gmdate( 'n', $now ); + + // For monthly period, check for stored stats first. + if ( 'month' === $period ) { + $stored_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); + + if ( $stored_stats ) { + return array( + 'posts_count' => $stored_stats['posts_count'] ?? 0, + 'followers_total' => $stored_stats['followers_total'] ?? self::get_follower_count( $user_id ), + 'top_posts' => $stored_stats['top_posts'] ?? array(), + 'top_multiplicator' => $stored_stats['top_multiplicator'] ?? null, + 'period' => $period, + 'start' => \gmdate( 'Y-m-01 00:00:00', $now ), + 'end' => \gmdate( 'Y-m-t 23:59:59', $now ), + ); + } + } switch ( $period ) { case 'year': @@ -642,6 +663,8 @@ public static function get_current_stats( $user_id, $period = 'month' ) { /** * Get monthly breakdown for the current year (for graphs). * + * Uses stored monthly stats if available, otherwise queries live data. + * * @param int $user_id The user ID. * @param int $year Optional. The year. Defaults to current year. * @@ -663,20 +686,43 @@ public static function get_yearly_monthly_breakdown( $user_id, $year = null ) { $max_month = ( $year === $current_year ) ? $current_month : 12; for ( $month = 1; $month <= $max_month; $month++ ) { - $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); - $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + // Check for stored monthly stats first. + $stored_stats = self::get_monthly_stats( $user_id, $year, $month ); + + if ( $stored_stats ) { + // Use stored data. + $engagement = 0; + foreach ( $comment_types as $type ) { + $engagement += $stored_stats[ $type . '_count' ] ?? 0; + } - $engagement = self::count_engagement_in_range( $user_id, $start, $end ); + $month_data = array( + 'month' => $month, + 'posts_count' => $stored_stats['posts_count'] ?? 0, + 'engagement' => $engagement, + ); - $month_data = array( - 'month' => $month, - 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), - 'engagement' => $engagement, - ); + // Add counts for each comment type from stored stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = $stored_stats[ $type . '_count' ] ?? 0; + } + } else { + // Query live data. + $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); + $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); - // Add counts for each comment type tracked in stats. - foreach ( $comment_types as $type ) { - $month_data[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + $engagement = self::count_engagement_in_range( $user_id, $start, $end ); + + $month_data = array( + 'month' => $month, + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'engagement' => $engagement, + ); + + // Add counts for each comment type tracked in stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } } $months[ $month ] = $month_data; @@ -688,6 +734,8 @@ public static function get_yearly_monthly_breakdown( $user_id, $year = null ) { /** * Get year-over-year comparison for current month. * + * Uses stored monthly stats if available, otherwise queries live data. + * * @param int $user_id The user ID. * * @return array Comparison data with current values and changes from last year. @@ -698,24 +746,24 @@ public static function get_year_comparison( $user_id ) { $current_month = (int) \gmdate( 'n', $now ); $last_year = $current_year - 1; - // Current month this year. - $this_year_start = \gmdate( 'Y-m-01 00:00:00', $now ); - $this_year_end = \gmdate( 'Y-m-t 23:59:59', $now ); - - // Same month last year. - $last_year_start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $last_year, $current_month ) ) ); - $last_year_end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $last_year, $current_month ) ) ); - - // Get current stats. - $current_posts = self::count_federated_posts_in_range( $user_id, $this_year_start, $this_year_end ); - $current_followers = self::get_follower_count( $user_id ); + // Check for stored stats first. + $current_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); + $last_year_stats = self::get_monthly_stats( $user_id, $last_year, $current_month ); - // Get last year stats. - $last_posts = self::count_federated_posts_in_range( $user_id, $last_year_start, $last_year_end ); + if ( $current_stats ) { + // Use stored data. + $current_posts = $current_stats['posts_count'] ?? 0; + $current_followers = $current_stats['followers_total'] ?? self::get_follower_count( $user_id ); + } else { + // Query live data. + $this_year_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $this_year_end = \gmdate( 'Y-m-t 23:59:59', $now ); + $current_posts = self::count_federated_posts_in_range( $user_id, $this_year_start, $this_year_end ); + $current_followers = self::get_follower_count( $user_id ); + } - // Get last year's follower count from stored stats. - $last_year_stats = self::get_monthly_stats( $user_id, $last_year, $current_month ); - $last_followers = $last_year_stats ? ( $last_year_stats['followers_total'] ?? 0 ) : 0; + $last_posts = $last_year_stats ? ( $last_year_stats['posts_count'] ?? 0 ) : 0; + $last_followers = $last_year_stats ? ( $last_year_stats['followers_total'] ?? 0 ) : 0; $comparison = array( 'posts' => array( @@ -731,8 +779,15 @@ public static function get_year_comparison( $user_id ) { // Add comparison for each registered comment type dynamically. $comment_types = Comment::get_comment_type_slugs(); foreach ( $comment_types as $type ) { - $current_count = self::count_engagement_in_range( $user_id, $this_year_start, $this_year_end, $type ); - $last_count = self::count_engagement_in_range( $user_id, $last_year_start, $last_year_end, $type ); + $current_count = $current_stats ? ( $current_stats[ $type . '_count' ] ?? 0 ) : 0; + $last_count = $last_year_stats ? ( $last_year_stats[ $type . '_count' ] ?? 0 ) : 0; + + // If no stored stats, query live data. + if ( ! $current_stats ) { + $this_year_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $this_year_end = \gmdate( 'Y-m-t 23:59:59', $now ); + $current_count = self::count_engagement_in_range( $user_id, $this_year_start, $this_year_end, $type ); + } $comparison[ $type ] = array( 'current' => $current_count, @@ -932,4 +987,76 @@ private static function get_earliest_data_year( $user_id ) { return (int) \gmdate( 'Y', \strtotime( $earliest_date ) ); } + + /** + * Populate demo statistics data for testing. + * + * @param int $user_id The user ID to populate data for. + * + * @return bool True on success. + */ + public static function populate_demo_data( $user_id ) { + $current_year = (int) \gmdate( 'Y' ); + $current_month = (int) \gmdate( 'n' ); + + // Base values that will grow over time. + $followers_base = 50; + + // Populate monthly stats for the current year. + for ( $month = 1; $month <= $current_month; $month++ ) { + // Create realistic growth patterns. + $growth_factor = $month / 12; + $seasonal_boost = \in_array( $month, array( 3, 9, 10 ), true ) ? 1.3 : 1.0; // Spring and fall boosts. + + $posts_count = (int) ( \wp_rand( 6, 14 ) * $seasonal_boost ); + $likes_count = (int) ( \wp_rand( 20, 50 ) * ( 1 + $growth_factor ) * $seasonal_boost ); + $reposts_count = (int) ( \wp_rand( 5, 20 ) * ( 1 + $growth_factor ) * $seasonal_boost ); + $comments_count = (int) ( \wp_rand( 3, 15 ) * ( 1 + $growth_factor ) * $seasonal_boost ); + + // Followers grow over time. + $followers_gained = (int) ( \wp_rand( 10, 30 ) * ( 1 + $growth_factor * 0.5 ) ); + $followers_lost = \wp_rand( 1, 5 ); + $followers_base += $followers_gained - $followers_lost; + + $stats = array( + 'posts_count' => $posts_count, + 'like_count' => $likes_count, + 'repost_count' => $reposts_count, + 'comment_count' => $comments_count, + 'followers_gained' => $followers_gained, + 'followers_lost' => $followers_lost, + 'followers_total' => $followers_base, + 'top_posts' => array(), + 'top_multiplicator' => array( + 'name' => '@supporter' . $month . '@mastodon.social', + 'url' => 'https://mastodon.social/@supporter' . $month, + 'count' => \wp_rand( 3, 10 ), + ), + 'collected_at' => \gmdate( 'Y-m-d H:i:s', \strtotime( "$current_year-$month-28" ) ), + ); + + self::save_monthly_stats( $user_id, $current_year, $month, $stats ); + } + + return true; + } + + /** + * Clear demo statistics data. + * + * @param int $user_id The user ID to clear data for. + * + * @return bool True on success. + */ + public static function clear_demo_data( $user_id ) { + $current_year = (int) \gmdate( 'Y' ); + + for ( $month = 1; $month <= 12; $month++ ) { + \delete_option( self::get_monthly_option_name( $user_id, $current_year, $month ) ); + } + + \delete_option( self::get_annual_option_name( $user_id, $current_year ) ); + + return true; + } } diff --git a/includes/rest/class-statistics-controller.php b/includes/rest/class-statistics-controller.php new file mode 100644 index 0000000000..6f4785f3e1 --- /dev/null +++ b/includes/rest/class-statistics-controller.php @@ -0,0 +1,117 @@ +namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'user_id' => array( + 'description' => 'The user ID to get stats for.', + 'type' => 'integer', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Checks if a given request has access to get stats. + * + * @param \WP_REST_Request $request The request object. + * + * @return true|\WP_Error True if the request has access, WP_Error otherwise. + */ + public function get_item_permissions_check( $request ) { + $user_id = $request->get_param( 'user_id' ); + + // Check if user can access stats for this actor. + if ( Actors::BLOG_USER_ID === $user_id ) { + if ( ! \current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'rest_forbidden', + \__( 'You do not have permission to view blog stats.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + } elseif ( \get_current_user_id() !== $user_id ) { + return new \WP_Error( + 'rest_forbidden', + \__( 'You do not have permission to view this user\'s stats.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Retrieves statistics for a user. + * + * @param \WP_REST_Request $request The request object. + * + * @return \WP_REST_Response|\WP_Error Response object or WP_Error object. + */ + public function get_item( $request ) { + $user_id = $request->get_param( 'user_id' ); + + $stats = Statistics::get_current_stats( $user_id, 'month' ); + $comparison = Statistics::get_year_comparison( $user_id ); + $monthly_data = Statistics::get_yearly_monthly_breakdown( $user_id ); + $comment_types = Statistics::get_comment_types_for_stats(); + + $response = array( + 'stats' => array( + 'posts_count' => $stats['posts_count'], + 'followers_total' => $stats['followers_total'], + 'top_posts' => $stats['top_posts'], + 'top_multiplicator' => $stats['top_multiplicator'], + ), + 'comparison' => $comparison, + 'monthly' => \array_values( $monthly_data ), + 'comment_types' => $comment_types, + ); + + return \rest_ensure_response( $response ); + } +} diff --git a/includes/wp-admin/class-statistics-dashboard.php b/includes/wp-admin/class-statistics-dashboard.php index f23756c9e8..18180d1b29 100644 --- a/includes/wp-admin/class-statistics-dashboard.php +++ b/includes/wp-admin/class-statistics-dashboard.php @@ -8,7 +8,6 @@ namespace Activitypub\WP_Admin; use Activitypub\Collection\Actors; -use Activitypub\Statistics; use function Activitypub\is_user_type_disabled; use function Activitypub\user_can_activitypub; @@ -16,7 +15,7 @@ /** * Statistics Dashboard Widget Class. * - * Provides dashboard widgets for ActivityPub statistics. + * Provides a React-based dashboard widget for ActivityPub statistics. */ class Statistics_Dashboard { @@ -25,122 +24,75 @@ class Statistics_Dashboard { */ public static function init() { \add_action( 'wp_dashboard_setup', array( self::class, 'add_dashboard_widgets' ) ); - \add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_styles' ) ); - \add_action( 'wp_ajax_activitypub_get_stats', array( self::class, 'ajax_get_stats' ) ); + \add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_scripts' ) ); } /** - * Enqueue styles for the dashboard widget. + * Enqueue scripts for the dashboard widget. * * @param string $hook The current admin page. */ - public static function enqueue_styles( $hook ) { + public static function enqueue_scripts( $hook ) { if ( 'index.php' !== $hook ) { return; } - \wp_enqueue_style( - 'activitypub-statistics', - \plugins_url( 'assets/css/activitypub-statistics.css', ACTIVITYPUB_PLUGIN_FILE ), - array(), - ACTIVITYPUB_PLUGIN_VERSION - ); + // Only enqueue if user has access. + if ( ! self::user_has_access() ) { + return; + } + + $asset_file = ACTIVITYPUB_PLUGIN_DIR . 'build/dashboard-stats/index.asset.php'; + + if ( ! \file_exists( $asset_file ) ) { + return; + } + + $asset = require $asset_file; + + $dependencies = $asset['dependencies']; + $dependencies[] = 'wp-dom-ready'; \wp_enqueue_script( - 'activitypub-statistics', - \plugins_url( 'assets/js/activitypub-statistics.js', ACTIVITYPUB_PLUGIN_FILE ), - array( 'jquery' ), - ACTIVITYPUB_PLUGIN_VERSION, + 'activitypub-dashboard-stats', + \plugins_url( 'build/dashboard-stats/index.js', ACTIVITYPUB_PLUGIN_FILE ), + $dependencies, + $asset['version'], true ); - // Get available actors for the widget. - $actors = self::get_available_actors(); - - // Get registered comment types for the chart. - $comment_types = Statistics::get_comment_types_for_stats(); + \wp_enqueue_style( + 'activitypub-dashboard-stats', + \plugins_url( 'build/dashboard-stats/style-index.css', ACTIVITYPUB_PLUGIN_FILE ), + array( 'wp-components' ), + $asset['version'] + ); - // Get WordPress admin colors for the chart. - $chart_colors = self::get_chart_colors(); + // Add inline script to initialize the widget. + $actors = self::get_available_actors(); + $settings = array( + 'actors' => $actors, + ); - \wp_localize_script( - 'activitypub-statistics', - 'activitypubStats', - array( - 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), - 'nonce' => \wp_create_nonce( 'activitypub_stats' ), - 'actors' => $actors, - 'defaultActor' => ! empty( $actors ) ? $actors[0]['id'] : null, - 'commentTypes' => $comment_types, - 'chartColors' => $chart_colors, - 'monthNames' => array( - \__( 'Jan', 'activitypub' ), - \__( 'Feb', 'activitypub' ), - \__( 'Mar', 'activitypub' ), - \__( 'Apr', 'activitypub' ), - \__( 'May', 'activitypub' ), - \__( 'Jun', 'activitypub' ), - \__( 'Jul', 'activitypub' ), - \__( 'Aug', 'activitypub' ), - \__( 'Sep', 'activitypub' ), - \__( 'Oct', 'activitypub' ), - \__( 'Nov', 'activitypub' ), - \__( 'Dec', 'activitypub' ), - ), - 'i18n' => array( - 'topSupporter' => \__( 'Top Supporter', 'activitypub' ), - 'topPosts' => \__( 'Top Posts', 'activitypub' ), - 'engagements' => \__( 'engagements', 'activitypub' ), - 'noTitle' => \__( '(no title)', 'activitypub' ), - 'vsLastYear' => \__( 'vs last year', 'activitypub' ), - 'thisMonth' => \__( 'This Month', 'activitypub' ), - 'yearlyActivity' => \__( 'Yearly Activity', 'activitypub' ), - 'notEnoughData' => \__( 'Not enough data yet', 'activitypub' ), - ), + \wp_add_inline_script( + 'activitypub-dashboard-stats', + sprintf( + 'wp.domReady( function() { activitypub.dashboardStats.initialize( "activitypub-stats-widget-root", %s ); } );', + \wp_json_encode( $settings ) ) ); } /** - * Get colors for the chart based on WordPress admin color scheme. - * - * Colors are assigned dynamically to all comment types from the stats system. - * No hardcoded type lists - any registered type gets a color automatically. + * Check if the current user has access to at least one actor type. * - * @return array Associative array of comment type slugs to hex colors. + * @return bool True if user has access. */ - private static function get_chart_colors() { - global $_wp_admin_css_colors; - - // Get all comment types from the statistics system. - $comment_types = Statistics::get_comment_types_for_stats(); - - // Build color palette from WordPress admin colors. - $palette = array( '#d63638', '#00a32a', '#2271b1', '#dba617', '#8c8f94', '#9b59b6', '#1abc9c', '#e74c3c' ); - - // Try to get the current admin color scheme. - $admin_color = \get_user_option( 'admin_color', \get_current_user_id() ); - - if ( $admin_color && isset( $_wp_admin_css_colors[ $admin_color ] ) ) { - $scheme = $_wp_admin_css_colors[ $admin_color ]; - - if ( ! empty( $scheme->colors ) && \count( $scheme->colors ) >= 4 ) { - // Use colors from the admin scheme as the primary palette. - $palette = \array_merge( $scheme->colors, $palette ); - $palette = \array_unique( $palette ); - } - } - - // Assign colors to each comment type dynamically. - $colors = array(); - $index = 0; - - foreach ( $comment_types as $slug => $type ) { - $colors[ $slug ] = $palette[ $index % \count( $palette ) ]; - ++$index; - } + private static function user_has_access() { + $has_user_access = user_can_activitypub( \get_current_user_id() ) && ! is_user_type_disabled( 'user' ); + $has_blog_access = ! is_user_type_disabled( 'blog' ) && \current_user_can( 'manage_options' ); - return $colors; + return $has_user_access || $has_blog_access; } /** @@ -174,18 +126,14 @@ private static function get_available_actors() { * Add dashboard widgets. */ public static function add_dashboard_widgets() { - // Only add widget if user has access to at least one actor type. - $has_user_access = user_can_activitypub( \get_current_user_id() ) && ! is_user_type_disabled( 'user' ); - $has_blog_access = ! is_user_type_disabled( 'blog' ) && \current_user_can( 'manage_options' ); - - if ( ! $has_user_access && ! $has_blog_access ) { + if ( ! self::user_has_access() ) { return; } \wp_add_dashboard_widget( 'activitypub_stats', \__( 'Fediverse Stats', 'activitypub' ), - array( self::class, 'render_stats_widget' ), + array( self::class, 'render_widget' ), null, null, 'normal', @@ -194,347 +142,9 @@ public static function add_dashboard_widgets() { } /** - * Render the unified stats widget. + * Render the widget container. */ - public static function render_stats_widget() { - $actors = self::get_available_actors(); - - if ( empty( $actors ) ) { - return; - } - - $default_actor = $actors[0]['id']; - $stats = Statistics::get_current_stats( $default_actor, 'month' ); - $comparison = Statistics::get_year_comparison( $default_actor ); - $monthly_data = Statistics::get_yearly_monthly_breakdown( $default_actor ); - $comment_types = Statistics::get_comment_types_for_stats(); - $chart_colors = self::get_chart_colors(); - ?> -
-
- 1 ) : ?> - - -
- - -
-

-
- - $type ) : - if ( isset( $comparison[ $slug ] ) ) : - self::render_highlight_stat( $slug, $type['label'], $comparison[ $slug ] ); - endif; - endforeach; - ?> - -
-
- - -
-

-
- -
-
- $type ) : ?> - - - - -
-
- - -
-

-

- - - - -

-
- - - -
-

- -
- -
- 0 ? 'positive' : 'negative'; - $change_text = $data['change'] > 0 ? '+' . \number_format_i18n( $data['change'] ) : \number_format_i18n( $data['change'] ); - } - ?> -
- - - () - - -
- ' . \esc_html__( 'Not enough data yet', 'activitypub' ) . ''; - } - - // Find max value across all comment types for scaling. - $max_value = 1; - foreach ( $comment_types as $slug => $type ) { - $key = $slug . '_count'; - $values = \array_column( $months, $key ); - if ( ! empty( $values ) ) { - $max = \max( $values ); - if ( $max > $max_value ) { - $max_value = $max; - } - } - } - - // Generate points for each comment type line. - $all_points = array(); - $x_labels = array(); - - foreach ( $comment_types as $slug => $type ) { - $all_points[ $slug ] = array(); - } - - foreach ( $months as $i => $data ) { - $x = $padding + ( $i / ( $num_months - 1 ) ) * $chart_w; - - foreach ( $comment_types as $slug => $type ) { - $key = $slug . '_count'; - $count = $data[ $key ] ?? 0; - $y = $padding + $chart_h - ( ( $count / $max_value ) * $chart_h ); - - $all_points[ $slug ][] = \round( $x, 1 ) . ',' . \round( $y, 1 ); - } - - $x_labels[] = array( - 'x' => $x, - 'label' => \date_i18n( 'M', \strtotime( \gmdate( 'Y' ) . '-' . $data['month'] . '-01' ) ), - ); - } - - // Build SVG. - $svg = ''; - - // Grid lines. - $svg .= ''; - for ( $i = 0; $i <= 4; $i++ ) { - $y = $padding + ( $i / 4 ) * $chart_h; - $svg .= ''; - } - $svg .= ''; - - // Area fills (semi-transparent) for each comment type. - $base_y = $padding + $chart_h; - foreach ( $comment_types as $slug => $type ) { - $color = $chart_colors[ $slug ] ?? '#8c8f94'; - $fill_rgba = self::hex_to_rgba( $color, 0.1 ); - $svg .= ''; - } - - // Lines for each comment type. - foreach ( $comment_types as $slug => $type ) { - $color = $chart_colors[ $slug ] ?? '#8c8f94'; - $svg .= ''; - } - - // Data points for each comment type. - $svg .= ''; - foreach ( $months as $i => $data ) { - $x = $padding + ( $i / ( $num_months - 1 ) ) * $chart_w; - - foreach ( $comment_types as $slug => $type ) { - $key = $slug . '_count'; - $count = $data[ $key ] ?? 0; - $y = $padding + $chart_h - ( ( $count / $max_value ) * $chart_h ); - $color = $chart_colors[ $slug ] ?? '#8c8f94'; - $label = $type['singular'] ?? $type['label']; - - $svg .= '' . \esc_html( $count ) . ' ' . \esc_attr( strtolower( $label ) ) . ''; - } - } - $svg .= ''; - - $svg .= ''; - - // Month labels below chart. - $svg .= '
'; - foreach ( $x_labels as $label ) { - $left = ( $label['x'] / $width ) * 100; - $svg .= '' . \esc_html( $label['label'] ) . ''; - } - $svg .= '
'; - - return $svg; - } - - /** - * Convert hex color to rgba. - * - * @param string $hex The hex color. - * @param float $alpha The alpha value (0-1). - * - * @return string The rgba color string. - */ - private static function hex_to_rgba( $hex, $alpha = 1 ) { - $hex = \ltrim( $hex, '#' ); - - if ( 3 === \strlen( $hex ) ) { - $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; - } - - $r = \hexdec( \substr( $hex, 0, 2 ) ); - $g = \hexdec( \substr( $hex, 2, 2 ) ); - $b = \hexdec( \substr( $hex, 4, 2 ) ); - - return \sprintf( 'rgba(%d, %d, %d, %s)', $r, $g, $b, $alpha ); - } - - /** - * AJAX handler for getting stats. - */ - public static function ajax_get_stats() { - \check_ajax_referer( 'activitypub_stats', 'nonce' ); - - $user_id = isset( $_POST['user_id'] ) ? \intval( $_POST['user_id'] ) : \get_current_user_id(); - - // Check permissions. - if ( Actors::BLOG_USER_ID !== $user_id && \get_current_user_id() !== $user_id ) { - \wp_send_json_error( array( 'message' => \__( 'Unauthorized', 'activitypub' ) ) ); - } - - if ( Actors::BLOG_USER_ID === $user_id && ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( array( 'message' => \__( 'Unauthorized', 'activitypub' ) ) ); - } - - $stats = Statistics::get_current_stats( $user_id, 'month' ); - $comparison = Statistics::get_year_comparison( $user_id ); - $monthly_data = Statistics::get_yearly_monthly_breakdown( $user_id ); - $comment_types = Statistics::get_comment_types_for_stats(); - - // Build comparison data dynamically based on registered comment types. - $comparison_data = array( - 'posts' => array( - 'current' => \number_format_i18n( $comparison['posts']['current'] ), - 'change' => $comparison['posts']['change'], - 'change_formatted' => self::format_change( $comparison['posts']['change'] ), - ), - 'followers' => array( - 'current' => \number_format_i18n( $comparison['followers']['current'] ), - 'change' => $comparison['followers']['change'], - 'change_formatted' => self::format_change( $comparison['followers']['change'] ), - ), - ); - - // Add comparison for each registered comment type. - foreach ( $comment_types as $slug => $type ) { - if ( isset( $comparison[ $slug ] ) ) { - $comparison_data[ $slug ] = array( - 'current' => \number_format_i18n( $comparison[ $slug ]['current'] ), - 'change' => $comparison[ $slug ]['change'], - 'change_formatted' => self::format_change( $comparison[ $slug ]['change'] ), - ); - } - } - - // Format numbers for display. - $formatted = array( - 'stats' => array( - 'posts_count' => \number_format_i18n( $stats['posts_count'] ), - 'followers_total' => \number_format_i18n( $stats['followers_total'] ), - 'top_posts' => $stats['top_posts'], - 'top_multiplicator' => $stats['top_multiplicator'], - ), - 'comparison' => $comparison_data, - 'monthly' => \array_values( $monthly_data ), - ); - - \wp_send_json_success( $formatted ); - } - - /** - * Format a change value for display. - * - * @param int $change The change value. - * - * @return string The formatted change string. - */ - private static function format_change( $change ) { - if ( 0 === $change ) { - return ''; - } - - return $change > 0 ? '+' . \number_format_i18n( $change ) : \number_format_i18n( $change ); + public static function render_widget() { + echo '
'; } } diff --git a/local/class-cli.php b/local/class-cli.php index 87437043f3..b1a947873a 100644 --- a/local/class-cli.php +++ b/local/class-cli.php @@ -8,9 +8,11 @@ namespace Activitypub\Development; use Activitypub\Activity\Activity; +use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; use Activitypub\Collection\Inbox; use Activitypub\Comment; +use Activitypub\Statistics; use function Activitypub\camel_to_snake_case; use function WP_CLI\Utils\get_flag_value; @@ -239,4 +241,53 @@ public function reprocess_inbox( $args ) { \WP_CLI::success( sprintf( 'Inbox item %d has been reprocessed as %s activity.', $post_id, $activity_data['type'] ) ); } + + /** + * Manage statistics demo data. + * + * ## OPTIONS + * + * + * : The action to perform. Either `populate` or `clear`. + * --- + * options: + * - populate + * - clear + * --- + * + * [--user_id=] + * : The user ID to populate/clear data for. Defaults to blog user (0). + * + * ## EXAMPLES + * + * # Populate demo stats for the blog + * $ wp activitypub stats populate + * + * # Populate demo stats for a specific user + * $ wp activitypub stats populate --user_id=1 + * + * # Clear demo stats for the blog + * $ wp activitypub stats clear + * + * @synopsis [--user_id=] + * + * @param array $args The positional arguments. + * @param array $assoc_args The associative arguments. + */ + public function stats( $args, $assoc_args = array() ) { + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : Actors::BLOG_USER_ID; + + switch ( $args[0] ) { + case 'populate': + Statistics::populate_demo_data( $user_id ); + \WP_CLI::success( "Demo statistics populated for user ID: {$user_id}" ); + break; + case 'clear': + Statistics::clear_demo_data( $user_id ); + \WP_CLI::success( "Demo statistics cleared for user ID: {$user_id}" ); + break; + default: + \WP_CLI::error( 'Unknown action. Use "populate" or "clear".' ); + } + } } diff --git a/src/dashboard-stats/block.json b/src/dashboard-stats/block.json new file mode 100644 index 0000000000..ec8ffbfe14 --- /dev/null +++ b/src/dashboard-stats/block.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "activitypub/dashboard-stats", + "title": "ActivityPub Dashboard Stats", + "category": "widgets", + "description": "ActivityPub statistics dashboard widget", + "textdomain": "activitypub", + "editorScript": "file:./index.js" +} diff --git a/src/dashboard-stats/components/line-chart/index.tsx b/src/dashboard-stats/components/line-chart/index.tsx new file mode 100644 index 0000000000..4cd67e3fa5 --- /dev/null +++ b/src/dashboard-stats/components/line-chart/index.tsx @@ -0,0 +1,153 @@ +import { __ } from '@wordpress/i18n'; +import type { MonthData, CommentType } from '../../types'; + +interface Props { + monthly: MonthData[] | null; + commentTypes: Record< string, CommentType > | null; +} + +// Colors for different engagement types. +const COLORS = { + engagement: '#3858e9', + like: '#d63638', + repost: '#00a32a', + comment: '#dba617', +}; + +/** + * Line Chart Component. + * + * Renders an SVG line chart for monthly engagement data. + */ +export default function LineChart( { monthly, commentTypes }: Props ) { + if ( ! monthly?.length ) { + return null; + } + + const width = 600; + const height = 200; + const padding = { top: 20, right: 20, bottom: 30, left: 40 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + // Get all engagement values to find max. + const maxEngagement = Math.max( ...monthly.map( ( m ) => m.engagement || 0 ), 1 ); + + // Calculate points for the line. + const points = monthly.map( ( month, index ) => { + const x = padding.left + ( index / ( monthly.length - 1 || 1 ) ) * chartWidth; + const y = padding.top + chartHeight - ( ( month.engagement || 0 ) / maxEngagement ) * chartHeight; + return { x, y, month }; + } ); + + // Create path for the line. + const linePath = points + .map( ( point, index ) => { + return index === 0 ? `M ${ point.x } ${ point.y }` : `L ${ point.x } ${ point.y }`; + } ) + .join( ' ' ); + + // Create path for the area fill. + const areaPath = + linePath + + ` L ${ points[ points.length - 1 ].x } ${ padding.top + chartHeight }` + + ` L ${ points[ 0 ].x } ${ padding.top + chartHeight } Z`; + + // Month labels. + const monthLabels = [ + __( 'Jan', 'activitypub' ), + __( 'Feb', 'activitypub' ), + __( 'Mar', 'activitypub' ), + __( 'Apr', 'activitypub' ), + __( 'May', 'activitypub' ), + __( 'Jun', 'activitypub' ), + __( 'Jul', 'activitypub' ), + __( 'Aug', 'activitypub' ), + __( 'Sep', 'activitypub' ), + __( 'Oct', 'activitypub' ), + __( 'Nov', 'activitypub' ), + __( 'Dec', 'activitypub' ), + ]; + + // Build legend items from comment types. + const legendItems = [ + { key: 'engagement', label: __( 'Total Engagement', 'activitypub' ), color: COLORS.engagement }, + ]; + + if ( commentTypes ) { + Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => { + const color = COLORS[ slug as keyof typeof COLORS ] || '#8c8f94'; + legendItems.push( { key: slug, label: type.label, color } ); + } ); + } + + return ( +
+

{ __( 'Engagement Over Time', 'activitypub' ) }

+
+ + + + + + + + + { /* Grid lines */ } + { [ 0, 0.25, 0.5, 0.75, 1 ].map( ( ratio ) => ( + + ) ) } + + { /* Area fill */ } + + + { /* Line */ } + + + { /* Data points */ } + { points.map( ( point, index ) => ( + + ) ) } + + { /* X-axis labels */ } + { points.map( ( point, index ) => ( + + { monthLabels[ point.month.month - 1 ] } + + ) ) } + + { /* Y-axis labels */ } + { [ 0, 0.5, 1 ].map( ( ratio ) => ( + + { Math.round( maxEngagement * ratio ) } + + ) ) } + + + { /* Legend */ } +
+ { legendItems.map( ( item ) => ( +
+ + { item.label } +
+ ) ) } +
+
+
+ ); +} diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx new file mode 100644 index 0000000000..543e5b9dba --- /dev/null +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -0,0 +1,61 @@ +import { __ } from '@wordpress/i18n'; +import type { Comparison } from '../../types'; + +interface Props { + comparison: Comparison | null; +} + +/** + * Stat Highlights Component. + * + * Displays key statistics with year-over-year comparison. + */ +export default function StatHighlights( { comparison }: Props ) { + if ( ! comparison ) { + return null; + } + + const stats = [ + { + key: 'followers', + label: __( 'Followers', 'activitypub' ), + value: comparison.followers?.current ?? 0, + change: comparison.followers?.change ?? 0, + }, + { + key: 'posts', + label: __( 'Posts', 'activitypub' ), + value: comparison.posts?.current ?? 0, + change: comparison.posts?.change ?? 0, + }, + { + key: 'likes', + label: __( 'Likes', 'activitypub' ), + value: comparison.like?.current ?? 0, + change: comparison.like?.change ?? 0, + }, + { + key: 'reposts', + label: __( 'Reposts', 'activitypub' ), + value: comparison.repost?.current ?? 0, + change: comparison.repost?.change ?? 0, + }, + ]; + + return ( +
+ { stats.map( ( stat ) => ( +
+ { stat.value.toLocaleString() } + { stat.label } + { stat.change !== 0 && ( + 0 ? 'positive' : 'negative' }` }> + { stat.change > 0 ? '+' : '' } + { stat.change.toLocaleString() } + + ) } +
+ ) ) } +
+ ); +} diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx new file mode 100644 index 0000000000..06751454cb --- /dev/null +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -0,0 +1,76 @@ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect } from '@wordpress/element'; +import { SelectControl, Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import StatHighlights from '../stat-highlights'; +import LineChart from '../line-chart'; +import TopSupporter from '../top-supporter'; +import TopPosts from '../top-posts'; +import type { Settings, StatsResponse } from '../../types'; + +/** + * Get dashboard stats settings from global window. + */ +function useSettings(): Settings { + return window.activitypubDashboardStats || { actors: [] }; +} + +/** + * Stats Widget Component. + */ +export default function StatsWidget() { + const { actors = [] } = useSettings(); + const [ selectedActor, setSelectedActor ] = useState< number | null >( () => actors[ 0 ]?.id ?? null ); + const [ stats, setStats ] = useState< StatsResponse | null >( null ); + const [ isLoading, setIsLoading ] = useState( true ); + + // Load stats when actor changes. + useEffect( () => { + if ( selectedActor === null ) { + setIsLoading( false ); + return; + } + + setIsLoading( true ); + + apiFetch< StatsResponse >( { path: `/activitypub/1.0/stats/${ selectedActor }` } ) + .then( ( data ) => setStats( data ) ) + .catch( () => setStats( null ) ) + .finally( () => setIsLoading( false ) ); + }, [ selectedActor ] ); + + const actorOptions = actors.map( ( actor ) => ( { + label: actor.label, + value: actor.id, + } ) ); + + return ( +
+ { actors.length > 1 && selectedActor !== null && ( +
+ setSelectedActor( parseInt( String( value ), 10 ) ) } + __nextHasNoMarginBottom + /> +
+ ) } + + { isLoading ? ( +
+ +
+ ) : stats ? ( + <> + + + + + + ) : ( +

{ __( 'No statistics available yet.', 'activitypub' ) }

+ ) } +
+ ); +} diff --git a/src/dashboard-stats/components/top-posts/index.tsx b/src/dashboard-stats/components/top-posts/index.tsx new file mode 100644 index 0000000000..01ad52b72f --- /dev/null +++ b/src/dashboard-stats/components/top-posts/index.tsx @@ -0,0 +1,37 @@ +import { __, sprintf } from '@wordpress/i18n'; +import type { TopPost } from '../../types'; + +interface Props { + posts: TopPost[] | null | undefined; +} + +/** + * Top Posts Component. + */ +export default function TopPosts( { posts }: Props ) { + if ( ! posts?.length ) { + return null; + } + + return ( +
+

{ __( 'Top Posts', 'activitypub' ) }

+ +
+ ); +} diff --git a/src/dashboard-stats/components/top-supporter/index.tsx b/src/dashboard-stats/components/top-supporter/index.tsx new file mode 100644 index 0000000000..f47e32461e --- /dev/null +++ b/src/dashboard-stats/components/top-supporter/index.tsx @@ -0,0 +1,31 @@ +import { __, _n, sprintf } from '@wordpress/i18n'; +import type { Multiplicator } from '../../types'; + +interface Props { + multiplicator: Multiplicator | null | undefined; +} + +/** + * Top Supporter Component. + */ +export default function TopSupporter( { multiplicator }: Props ) { + if ( ! multiplicator?.name ) { + return null; + } + + return ( +
+

{ __( 'Top Supporter', 'activitypub' ) }

+

+ + { multiplicator.name } + { ' ' } + { sprintf( + /* translators: %s: number of boosts */ + _n( '(%s boost)', '(%s boosts)', multiplicator.count, 'activitypub' ), + multiplicator.count.toLocaleString() + ) } +

+
+ ); +} diff --git a/src/dashboard-stats/index.tsx b/src/dashboard-stats/index.tsx new file mode 100644 index 0000000000..b8b56ca487 --- /dev/null +++ b/src/dashboard-stats/index.tsx @@ -0,0 +1,36 @@ +import { createRoot } from '@wordpress/element'; +import StatsWidget from './components/stats-widget'; +import type { Settings } from './types'; +import './style.scss'; + +declare global { + interface Window { + activitypubDashboardStats: Settings; + activitypub: { + dashboardStats?: { + initialize: ( id: string, settings: Settings ) => void; + }; + }; + } +} + +/** + * Initialize the dashboard stats widget. + */ +export function initialize( id: string, settings: Settings ) { + const container = document.getElementById( id ); + + if ( ! container ) { + return; + } + + // Store settings globally for the widget. + window.activitypubDashboardStats = settings; + + const root = createRoot( container ); + root.render( ); +} + +// Export to window for inline script access. +window.activitypub = window.activitypub || {}; +window.activitypub.dashboardStats = { initialize }; diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss new file mode 100644 index 0000000000..1543e63216 --- /dev/null +++ b/src/dashboard-stats/style.scss @@ -0,0 +1,206 @@ +.activitypub-stats-widget { + position: relative; +} + +.activitypub-stats-header { + margin-bottom: 16px; + + .components-select-control { + margin: 0; + } +} + +.activitypub-stats-loading { + display: flex; + justify-content: center; + padding: 32px; +} + +.activitypub-stats-empty { + color: #757575; + text-align: center; + padding: 24px; +} + +// Highlights section. +.activitypub-stats-highlights { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.activitypub-stat-item { + text-align: center; + padding: 12px 8px; + background: #f6f7f7; + border-radius: 4px; + + .stat-value { + display: block; + font-size: 24px; + font-weight: 600; + color: #1d2327; + line-height: 1.2; + } + + .stat-label { + display: block; + font-size: 11px; + color: #757575; + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + .stat-change { + display: inline-block; + font-size: 12px; + margin-top: 4px; + color: #757575; + + &.positive { + color: #00a32a; + } + + &.negative { + color: #d63638; + } + } +} + +// Chart section. +.activitypub-stats-chart { + margin-bottom: 20px; + + h4 { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + color: #1d2327; + } +} + +.activitypub-chart-container { + background: #f6f7f7; + border-radius: 4px; + padding: 12px; +} + +.activitypub-line-chart { + width: 100%; + height: auto; + display: block; + + .chart-label { + font-size: 10px; + fill: #757575; + } +} + +.activitypub-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.activitypub-legend-item { + display: flex; + align-items: center; + font-size: 12px; + color: #50575e; + + .legend-color { + width: 12px; + height: 3px; + border-radius: 2px; + margin-right: 6px; + } +} + +// Top supporter section. +.activitypub-stats-multiplicator { + margin-bottom: 16px; + padding: 12px; + background: #fef3cd; + border-radius: 4px; + border-left: 4px solid #f0c33c; + + h4 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + color: #856404; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + p { + margin: 0; + font-size: 13px; + color: #1d2327; + } + + a { + text-decoration: none; + font-weight: 500; + color: #2271b1; + + &:hover { + text-decoration: underline; + color: #135e96; + } + } +} + +// Top posts section. +.activitypub-stats-top-posts { + h4 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + color: #1d2327; + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #f0f0f1; + + &:last-child { + border-bottom: 0; + } + } + + a { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + margin-right: 12px; + color: #2271b1; + + &:hover { + text-decoration: underline; + color: #135e96; + } + } + + .engagement-count { + font-size: 12px; + color: #757575; + white-space: nowrap; + } +} diff --git a/src/dashboard-stats/types/index.ts b/src/dashboard-stats/types/index.ts new file mode 100644 index 0000000000..2ea70e9d3d --- /dev/null +++ b/src/dashboard-stats/types/index.ts @@ -0,0 +1,62 @@ +export interface Actor { + id: number; + label: string; +} + +export interface Settings { + actors: Actor[]; +} + +export interface StatComparison { + current: number; + change: number; +} + +export interface Comparison { + followers?: StatComparison; + posts?: StatComparison; + like?: StatComparison; + repost?: StatComparison; +} + +export interface MonthData { + month: number; + posts_count: number; + engagement: number; + like_count?: number; + repost_count?: number; + comment_count?: number; +} + +export interface CommentType { + slug: string; + label: string; + singular: string; +} + +export interface Multiplicator { + name: string; + url: string; + count: number; +} + +export interface TopPost { + post_id: number; + title: string; + url: string; + engagement_count: number; +} + +export interface Stats { + posts_count: number; + followers_total: number; + top_posts: TopPost[]; + top_multiplicator: Multiplicator | null; +} + +export interface StatsResponse { + stats: Stats; + comparison: Comparison; + monthly: MonthData[]; + comment_types: Record< string, CommentType >; +} From a0d775d87357304cd92bf62ff1a75e2415337952 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 17 Dec 2025 12:42:18 +0100 Subject: [PATCH 04/46] Improve statistics dashboard with dynamic data and WordPress colors - Make StatHighlights display engagement types dynamically from API - Add period label "This month vs. last year" to clarify comparison - Draw individual lines for each engagement type in the chart - Use WordPress default color palette CSS variables with fallbacks - Remove hardcoded comment types - all types now dynamic - Move demo data generation from Statistics class to local CLI --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- includes/class-statistics.php | 72 ----------- local/class-cli.php | 72 ++++++++++- .../components/line-chart/index.tsx | 121 +++++++++++++----- .../components/stat-highlights/index.tsx | 62 +++++---- .../components/stats-widget/index.tsx | 2 +- src/dashboard-stats/style.scss | 13 +- src/dashboard-stats/types/index.ts | 9 +- 11 files changed, 216 insertions(+), 147 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 3352e75d4f..0bbb444922 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '07d042c2073fa1bcc466'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '577ffe263a06b1d49801'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 872e358a00..4e526d5cc2 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var t,e={192:(t,e,a)=>{const i=window.wp.element,n=window.wp.apiFetch;var s=a.n(n);const l=window.wp.components,r=window.wp.i18n,c=window.ReactJSXRuntime;function o({comparison:t}){var e,a,i,n,s,l,o,p;if(!t)return null;const u=[{key:"followers",label:(0,r.__)("Followers","activitypub"),value:null!==(e=t.followers?.current)&&void 0!==e?e:0,change:null!==(a=t.followers?.change)&&void 0!==a?a:0},{key:"posts",label:(0,r.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0},{key:"likes",label:(0,r.__)("Likes","activitypub"),value:null!==(s=t.like?.current)&&void 0!==s?s:0,change:null!==(l=t.like?.change)&&void 0!==l?l:0},{key:"reposts",label:(0,r.__)("Reposts","activitypub"),value:null!==(o=t.repost?.current)&&void 0!==o?o:0,change:null!==(p=t.repost?.change)&&void 0!==p?p:0}];return(0,c.jsx)("div",{className:"activitypub-stats-highlights",children:u.map(t=>(0,c.jsxs)("div",{className:"activitypub-stat-item",children:[(0,c.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,c.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,c.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:[t.change>0?"+":"",t.change.toLocaleString()]})]},t.key))})}const p={engagement:"#3858e9",like:"#d63638",repost:"#00a32a",comment:"#dba617"};function u({monthly:t,commentTypes:e}){if(!t?.length)return null;const a=20,i=150,n=Math.max(...t.map(t=>t.engagement||0),1),s=t.map((e,a)=>({x:40+a/(t.length-1||1)*540,y:170-(e.engagement||0)/n*i,month:e})),l=s.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),o=l+` L ${s[s.length-1].x} 170`+` L ${s[0].x} 170 Z`,u=[(0,r.__)("Jan","activitypub"),(0,r.__)("Feb","activitypub"),(0,r.__)("Mar","activitypub"),(0,r.__)("Apr","activitypub"),(0,r.__)("May","activitypub"),(0,r.__)("Jun","activitypub"),(0,r.__)("Jul","activitypub"),(0,r.__)("Aug","activitypub"),(0,r.__)("Sep","activitypub"),(0,r.__)("Oct","activitypub"),(0,r.__)("Nov","activitypub"),(0,r.__)("Dec","activitypub")],d=[{key:"engagement",label:(0,r.__)("Total Engagement","activitypub"),color:p.engagement}];return e&&Object.entries(e).forEach(([t,e])=>{const a=p[t]||"#8c8f94";d.push({key:t,label:e.label,color:a})}),(0,c.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,c.jsx)("h4",{children:(0,r.__)("Engagement Over Time","activitypub")}),(0,c.jsxs)("div",{className:"activitypub-chart-container",children:[(0,c.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,c.jsx)("defs",{children:(0,c.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,c.jsx)("stop",{offset:"0%",stopColor:p.engagement,stopOpacity:.3}),(0,c.jsx)("stop",{offset:"100%",stopColor:p.engagement,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,c.jsx)("line",{x1:40,y1:a+i*(1-t),x2:580,y2:a+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,c.jsx)("path",{d:o,fill:"url(#areaGradient)"}),(0,c.jsx)("path",{d:l,fill:"none",stroke:p.engagement,strokeWidth:"2"}),s.map((t,e)=>(0,c.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:p.engagement},e)),s.map((t,e)=>(0,c.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:u[t.month.month-1]},e)),[0,.5,1].map(t=>(0,c.jsx)("text",{x:35,y:a+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(n*t)},t))]}),(0,c.jsx)("div",{className:"activitypub-chart-legend",children:d.map(t=>(0,c.jsxs)("div",{className:"activitypub-legend-item",children:[(0,c.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function d({multiplicator:t}){return t?.name?(0,c.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Supporter","activitypub")}),(0,c.jsxs)("p",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,r.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,r._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function v({posts:t}){return t?.length?(0,c.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Posts","activitypub")}),(0,c.jsx)("ul",{children:t.map(t=>(0,c.jsxs)("li",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,r.__)("(no title)","activitypub")}),(0,c.jsx)("span",{className:"engagement-count",children:(0,r.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,r.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}function h(){const{actors:t=[]}=window.activitypubDashboardStats||{actors:[]},[e,a]=(0,i.useState)(()=>{var e;return null!==(e=t[0]?.id)&&void 0!==e?e:null}),[n,p]=(0,i.useState)(null),[h,m]=(0,i.useState)(!0);(0,i.useEffect)(()=>{null!==e?(m(!0),s()({path:`/activitypub/1.0/stats/${e}`}).then(t=>p(t)).catch(()=>p(null)).finally(()=>m(!1))):m(!1)},[e]);const y=t.map(t=>({label:t.label,value:t.id}));return(0,c.jsxs)("div",{className:"activitypub-stats-widget",children:[t.length>1&&null!==e&&(0,c.jsx)("div",{className:"activitypub-stats-header",children:(0,c.jsx)(l.SelectControl,{value:e,options:y,onChange:t=>a(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),h?(0,c.jsx)("div",{className:"activitypub-stats-loading",children:(0,c.jsx)(l.Spinner,{})}):n?(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(o,{comparison:n.comparison}),(0,c.jsx)(u,{monthly:n.monthly,commentTypes:n.comment_types}),(0,c.jsx)(d,{multiplicator:n.stats?.top_multiplicator}),(0,c.jsx)(v,{posts:n.stats?.top_posts})]}):(0,c.jsx)("p",{className:"activitypub-stats-empty",children:(0,r.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t,e){const a=document.getElementById(t);a&&(window.activitypubDashboardStats=e,(0,i.createRoot)(a).render((0,c.jsx)(h,{})))}}}},a={};function i(t){var n=a[t];if(void 0!==n)return n.exports;var s=a[t]={exports:{}};return e[t](s,s.exports,i),s.exports}i.m=e,t=[],i.O=(e,a,n,s)=>{if(!a){var l=1/0;for(p=0;p=s)&&Object.keys(i.O).every(t=>i.O[t](a[c]))?a.splice(c--,1):(r=!1,s0&&t[p-1][2]>s;p--)t[p]=t[p-1];t[p]=[a,n,s]},i.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return i.d(e,{a:e}),e},i.d=(t,e)=>{for(var a in e)i.o(e,a)&&!i.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};i.O.j=e=>0===t[e];var e=(e,a)=>{var n,s,[l,r,c]=a,o=0;if(l.some(e=>0!==t[e])){for(n in r)i.o(r,n)&&(i.m[n]=r[n]);if(c)var p=c(i)}for(e&&e(a);oi(192));n=i.O(n)})(); \ No newline at end of file +(()=>{"use strict";var t,e={192:(t,e,a)=>{const i=window.wp.element,s=window.wp.apiFetch;var n=a.n(s);const l=window.wp.components,r=window.wp.i18n,c=window.ReactJSXRuntime;function o({comparison:t,commentTypes:e}){var a,i,s,n;if(!t)return null;const l=[{key:"followers",label:(0,r.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(i=t.followers?.change)&&void 0!==i?i:0},{key:"posts",label:(0,r.__)("Posts","activitypub"),value:null!==(s=t.posts?.current)&&void 0!==s?s:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const i=t[e];var s,n;i&&"object"==typeof i&&"current"in i&&l.push({key:e,label:a.label,value:null!==(s=i.current)&&void 0!==s?s:0,change:null!==(n=i.change)&&void 0!==n?n:0})}),(0,c.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,c.jsx)("p",{className:"activitypub-stats-period",children:(0,r.__)("This month vs. last year","activitypub")}),(0,c.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,c.jsxs)("div",{className:"activitypub-stat-item",children:[(0,c.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,c.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,c.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:[t.change>0?"+":"",t.change.toLocaleString()]})]},t.key))})]})}const p=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function u(t){const e=p[t%p.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function v({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",i=20,s=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),o=t.map((e,a)=>40+a/(t.length-1||1)*540),p=t.map((t,e)=>({x:o[e],y:170-(t.engagement||0)/l*s,month:t})),v=p.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=v+` L ${p[p.length-1].x} 170`+` L ${p[0].x} 170 Z`,h=e=>t.map((t,a)=>{const i=t[`${e}_count`]||0,n=o[a],r=170-i/l*s;return 0===a?`M ${n} ${r}`:`L ${n} ${r}`}).join(" "),m=[(0,r.__)("Jan","activitypub"),(0,r.__)("Feb","activitypub"),(0,r.__)("Mar","activitypub"),(0,r.__)("Apr","activitypub"),(0,r.__)("May","activitypub"),(0,r.__)("Jun","activitypub"),(0,r.__)("Jul","activitypub"),(0,r.__)("Aug","activitypub"),(0,r.__)("Sep","activitypub"),(0,r.__)("Oct","activitypub"),(0,r.__)("Nov","activitypub"),(0,r.__)("Dec","activitypub")],y=[{key:"engagement",label:(0,r.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{y.push({key:t,label:e.label,color:u(a)})}),(0,c.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,c.jsx)("h4",{children:(0,r.__)("Engagement Over Time","activitypub")}),(0,c.jsxs)("div",{className:"activitypub-chart-container",children:[(0,c.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,c.jsx)("defs",{children:(0,c.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,c.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,c.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,c.jsx)("line",{x1:40,y1:i+s*(1-t),x2:580,y2:i+s*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,c.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,c.jsx)("path",{d:h(t),fill:"none",stroke:u(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,c.jsx)("path",{d:v,fill:"none",stroke:a,strokeWidth:"2"}),p.map((t,e)=>(0,c.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),p.map((t,e)=>(0,c.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:m[t.month.month-1]},e)),[0,.5,1].map(t=>(0,c.jsx)("text",{x:35,y:i+s*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,c.jsx)("div",{className:"activitypub-chart-legend",children:y.map(t=>(0,c.jsxs)("div",{className:"activitypub-legend-item",children:[(0,c.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function d({multiplicator:t}){return t?.name?(0,c.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Supporter","activitypub")}),(0,c.jsxs)("p",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,r.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,r._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function h({posts:t}){return t?.length?(0,c.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Posts","activitypub")}),(0,c.jsx)("ul",{children:t.map(t=>(0,c.jsxs)("li",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,r.__)("(no title)","activitypub")}),(0,c.jsx)("span",{className:"engagement-count",children:(0,r.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,r.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}function m(){const{actors:t=[]}=window.activitypubDashboardStats||{actors:[]},[e,a]=(0,i.useState)(()=>{var e;return null!==(e=t[0]?.id)&&void 0!==e?e:null}),[s,p]=(0,i.useState)(null),[u,m]=(0,i.useState)(!0);(0,i.useEffect)(()=>{null!==e?(m(!0),n()({path:`/activitypub/1.0/stats/${e}`}).then(t=>p(t)).catch(()=>p(null)).finally(()=>m(!1))):m(!1)},[e]);const y=t.map(t=>({label:t.label,value:t.id}));return(0,c.jsxs)("div",{className:"activitypub-stats-widget",children:[t.length>1&&null!==e&&(0,c.jsx)("div",{className:"activitypub-stats-header",children:(0,c.jsx)(l.SelectControl,{value:e,options:y,onChange:t=>a(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),u?(0,c.jsx)("div",{className:"activitypub-stats-loading",children:(0,c.jsx)(l.Spinner,{})}):s?(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(o,{comparison:s.comparison,commentTypes:s.comment_types}),(0,c.jsx)(v,{monthly:s.monthly,commentTypes:s.comment_types}),(0,c.jsx)(d,{multiplicator:s.stats?.top_multiplicator}),(0,c.jsx)(h,{posts:s.stats?.top_posts})]}):(0,c.jsx)("p",{className:"activitypub-stats-empty",children:(0,r.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t,e){const a=document.getElementById(t);a&&(window.activitypubDashboardStats=e,(0,i.createRoot)(a).render((0,c.jsx)(m,{})))}}}},a={};function i(t){var s=a[t];if(void 0!==s)return s.exports;var n=a[t]={exports:{}};return e[t](n,n.exports,i),n.exports}i.m=e,t=[],i.O=(e,a,s,n)=>{if(!a){var l=1/0;for(p=0;p=n)&&Object.keys(i.O).every(t=>i.O[t](a[c]))?a.splice(c--,1):(r=!1,n0&&t[p-1][2]>n;p--)t[p]=t[p-1];t[p]=[a,s,n]},i.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return i.d(e,{a:e}),e},i.d=(t,e)=>{for(var a in e)i.o(e,a)&&!i.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};i.O.j=e=>0===t[e];var e=(e,a)=>{var s,n,[l,r,c]=a,o=0;if(l.some(e=>0!==t[e])){for(s in r)i.o(r,s)&&(i.m[s]=r[s]);if(c)var p=c(i)}for(e&&e(a);oi(192));s=i.O(s)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index fdde8016c0..50994897b4 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));margin-bottom:20px}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-right:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} +.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{margin-bottom:20px}.activitypub-stats-period{color:#757575;font-size:11px;letter-spacing:.3px;margin:0 0 12px;text-transform:uppercase}.activitypub-stats-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr))}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-right:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index feb47f3267..824e68fd63 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));margin-bottom:20px}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-left:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} +.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{margin-bottom:20px}.activitypub-stats-period{color:#757575;font-size:11px;letter-spacing:.3px;margin:0 0 12px;text-transform:uppercase}.activitypub-stats-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr))}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-left:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} diff --git a/includes/class-statistics.php b/includes/class-statistics.php index ac211cda1e..3052951f30 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -987,76 +987,4 @@ private static function get_earliest_data_year( $user_id ) { return (int) \gmdate( 'Y', \strtotime( $earliest_date ) ); } - - /** - * Populate demo statistics data for testing. - * - * @param int $user_id The user ID to populate data for. - * - * @return bool True on success. - */ - public static function populate_demo_data( $user_id ) { - $current_year = (int) \gmdate( 'Y' ); - $current_month = (int) \gmdate( 'n' ); - - // Base values that will grow over time. - $followers_base = 50; - - // Populate monthly stats for the current year. - for ( $month = 1; $month <= $current_month; $month++ ) { - // Create realistic growth patterns. - $growth_factor = $month / 12; - $seasonal_boost = \in_array( $month, array( 3, 9, 10 ), true ) ? 1.3 : 1.0; // Spring and fall boosts. - - $posts_count = (int) ( \wp_rand( 6, 14 ) * $seasonal_boost ); - $likes_count = (int) ( \wp_rand( 20, 50 ) * ( 1 + $growth_factor ) * $seasonal_boost ); - $reposts_count = (int) ( \wp_rand( 5, 20 ) * ( 1 + $growth_factor ) * $seasonal_boost ); - $comments_count = (int) ( \wp_rand( 3, 15 ) * ( 1 + $growth_factor ) * $seasonal_boost ); - - // Followers grow over time. - $followers_gained = (int) ( \wp_rand( 10, 30 ) * ( 1 + $growth_factor * 0.5 ) ); - $followers_lost = \wp_rand( 1, 5 ); - $followers_base += $followers_gained - $followers_lost; - - $stats = array( - 'posts_count' => $posts_count, - 'like_count' => $likes_count, - 'repost_count' => $reposts_count, - 'comment_count' => $comments_count, - 'followers_gained' => $followers_gained, - 'followers_lost' => $followers_lost, - 'followers_total' => $followers_base, - 'top_posts' => array(), - 'top_multiplicator' => array( - 'name' => '@supporter' . $month . '@mastodon.social', - 'url' => 'https://mastodon.social/@supporter' . $month, - 'count' => \wp_rand( 3, 10 ), - ), - 'collected_at' => \gmdate( 'Y-m-d H:i:s', \strtotime( "$current_year-$month-28" ) ), - ); - - self::save_monthly_stats( $user_id, $current_year, $month, $stats ); - } - - return true; - } - - /** - * Clear demo statistics data. - * - * @param int $user_id The user ID to clear data for. - * - * @return bool True on success. - */ - public static function clear_demo_data( $user_id ) { - $current_year = (int) \gmdate( 'Y' ); - - for ( $month = 1; $month <= 12; $month++ ) { - \delete_option( self::get_monthly_option_name( $user_id, $current_year, $month ) ); - } - - \delete_option( self::get_annual_option_name( $user_id, $current_year ) ); - - return true; - } } diff --git a/local/class-cli.php b/local/class-cli.php index b1a947873a..933ef22080 100644 --- a/local/class-cli.php +++ b/local/class-cli.php @@ -279,15 +279,83 @@ public function stats( $args, $assoc_args = array() ) { switch ( $args[0] ) { case 'populate': - Statistics::populate_demo_data( $user_id ); + $this->populate_demo_stats( $user_id ); \WP_CLI::success( "Demo statistics populated for user ID: {$user_id}" ); break; case 'clear': - Statistics::clear_demo_data( $user_id ); + $this->clear_demo_stats( $user_id ); \WP_CLI::success( "Demo statistics cleared for user ID: {$user_id}" ); break; default: \WP_CLI::error( 'Unknown action. Use "populate" or "clear".' ); } } + + /** + * Populate demo statistics data for testing. + * + * @param int $user_id The user ID to populate data for. + */ + private function populate_demo_stats( $user_id ) { + $current_year = (int) \gmdate( 'Y' ); + $current_month = (int) \gmdate( 'n' ); + + // Get registered comment types dynamically. + $comment_types = Comment::get_comment_type_slugs(); + + // Base values that will grow over time. + $followers_base = 50; + + // Populate monthly stats for the current year. + for ( $month = 1; $month <= $current_month; $month++ ) { + // Create realistic growth patterns. + $growth_factor = $month / 12; + $seasonal_boost = \in_array( $month, array( 3, 9, 10 ), true ) ? 1.3 : 1.0; + + $posts_count = (int) ( \wp_rand( 6, 14 ) * $seasonal_boost ); + + // Followers grow over time. + $followers_gained = (int) ( \wp_rand( 10, 30 ) * ( 1 + $growth_factor * 0.5 ) ); + $followers_lost = \wp_rand( 1, 5 ); + $followers_base += $followers_gained - $followers_lost; + + $stats = array( + 'posts_count' => $posts_count, + 'followers_gained' => $followers_gained, + 'followers_lost' => $followers_lost, + 'followers_total' => $followers_base, + 'top_posts' => array(), + 'top_multiplicator' => array( + 'name' => '@supporter' . $month . '@mastodon.social', + 'url' => 'https://mastodon.social/@supporter' . $month, + 'count' => \wp_rand( 3, 10 ), + ), + 'collected_at' => \gmdate( 'Y-m-d H:i:s', \strtotime( "$current_year-$month-28" ) ), + ); + + // Add counts for each registered comment type dynamically. + foreach ( $comment_types as $type ) { + $stats[ $type . '_count' ] = (int) ( \wp_rand( 5, 30 ) * ( 1 + $growth_factor ) * $seasonal_boost ); + } + + Statistics::save_monthly_stats( $user_id, $current_year, $month, $stats ); + } + } + + /** + * Clear demo statistics data. + * + * @param int $user_id The user ID to clear data for. + */ + private function clear_demo_stats( $user_id ) { + $current_year = (int) \gmdate( 'Y' ); + + for ( $month = 1; $month <= 12; $month++ ) { + $option_name = Statistics::get_monthly_option_name( $user_id, $current_year, $month ); + \delete_option( $option_name ); + } + + $annual_option = Statistics::get_annual_option_name( $user_id, $current_year ); + \delete_option( $annual_option ); + } } diff --git a/src/dashboard-stats/components/line-chart/index.tsx b/src/dashboard-stats/components/line-chart/index.tsx index 4cd67e3fa5..b1e70adcba 100644 --- a/src/dashboard-stats/components/line-chart/index.tsx +++ b/src/dashboard-stats/components/line-chart/index.tsx @@ -6,13 +6,32 @@ interface Props { commentTypes: Record< string, CommentType > | null; } -// Colors for different engagement types. -const COLORS = { - engagement: '#3858e9', - like: '#d63638', - repost: '#00a32a', - comment: '#dba617', -}; +// WordPress default color palette (always available). +// @see https://developer.wordpress.org/themes/global-settings-and-styles/settings/color/ +const WP_DEFAULT_COLORS = [ + { slug: 'vivid-red', hex: '#cf2e2e' }, + { slug: 'vivid-green-cyan', hex: '#00d084' }, + { slug: 'luminous-vivid-amber', hex: '#fcb900' }, + { slug: 'vivid-purple', hex: '#9b51e0' }, + { slug: 'vivid-cyan-blue', hex: '#0693e3' }, + { slug: 'luminous-vivid-orange', hex: '#ff6900' }, +]; + +/** + * Get CSS variable with fallback to hex value. + * Uses CSS var() with fallback for best compatibility. + */ +function getColor( index: number ): string { + const color = WP_DEFAULT_COLORS[ index % WP_DEFAULT_COLORS.length ]; + return `var(--wp--preset--color--${ color.slug }, ${ color.hex })`; +} + +/** + * Get the engagement color (primary/accent, uses vivid-cyan-blue). + */ +function getEngagementColor(): string { + return 'var(--wp--preset--color--vivid-cyan-blue, #0693e3)'; +} /** * Line Chart Component. @@ -24,34 +43,59 @@ export default function LineChart( { monthly, commentTypes }: Props ) { return null; } + // Get colors once at render time. + const engagementColor = getEngagementColor(); + const width = 600; const height = 200; const padding = { top: 20, right: 20, bottom: 30, left: 40 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; - // Get all engagement values to find max. - const maxEngagement = Math.max( ...monthly.map( ( m ) => m.engagement || 0 ), 1 ); + // Get engagement type slugs from commentTypes. + const typeKeys = commentTypes ? Object.keys( commentTypes ) : []; + + // Get max value across all engagement types for proper scaling. + const maxEngagement = Math.max( + ...monthly.map( ( m ) => m.engagement || 0 ), + ...typeKeys.flatMap( ( type ) => monthly.map( ( m ) => ( m[ `${ type }_count` ] as number ) || 0 ) ), + 1 + ); + + // Calculate x positions for each month. + const xPositions = monthly.map( ( _, index ) => { + return padding.left + ( index / ( monthly.length - 1 || 1 ) ) * chartWidth; + } ); - // Calculate points for the line. - const points = monthly.map( ( month, index ) => { - const x = padding.left + ( index / ( monthly.length - 1 || 1 ) ) * chartWidth; + // Calculate points for the total engagement line. + const engagementPoints = monthly.map( ( month, index ) => { + const x = xPositions[ index ]; const y = padding.top + chartHeight - ( ( month.engagement || 0 ) / maxEngagement ) * chartHeight; return { x, y, month }; } ); - // Create path for the line. - const linePath = points - .map( ( point, index ) => { - return index === 0 ? `M ${ point.x } ${ point.y }` : `L ${ point.x } ${ point.y }`; - } ) + // Create path for the engagement line. + const engagementPath = engagementPoints + .map( ( point, index ) => ( index === 0 ? `M ${ point.x } ${ point.y }` : `L ${ point.x } ${ point.y }` ) ) .join( ' ' ); // Create path for the area fill. const areaPath = - linePath + - ` L ${ points[ points.length - 1 ].x } ${ padding.top + chartHeight }` + - ` L ${ points[ 0 ].x } ${ padding.top + chartHeight } Z`; + engagementPath + + ` L ${ engagementPoints[ engagementPoints.length - 1 ].x } ${ padding.top + chartHeight }` + + ` L ${ engagementPoints[ 0 ].x } ${ padding.top + chartHeight } Z`; + + // Helper to create line path for a specific type. + const createLinePath = ( type: string ) => { + return monthly + .map( ( month, index ) => { + const value = ( month[ `${ type }_count` ] as number ) || 0; + const x = xPositions[ index ]; + const y = padding.top + chartHeight - ( value / maxEngagement ) * chartHeight; + return index === 0 ? `M ${ x } ${ y }` : `L ${ x } ${ y }`; + } ) + .join( ' ' ); + }; // Month labels. const monthLabels = [ @@ -71,13 +115,12 @@ export default function LineChart( { monthly, commentTypes }: Props ) { // Build legend items from comment types. const legendItems = [ - { key: 'engagement', label: __( 'Total Engagement', 'activitypub' ), color: COLORS.engagement }, + { key: 'engagement', label: __( 'Total Engagement', 'activitypub' ), color: engagementColor }, ]; if ( commentTypes ) { - Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => { - const color = COLORS[ slug as keyof typeof COLORS ] || '#8c8f94'; - legendItems.push( { key: slug, label: type.label, color } ); + Object.entries( commentTypes ).forEach( ( [ slug, type ], index ) => { + legendItems.push( { key: slug, label: type.label, color: getColor( index ) } ); } ); } @@ -88,8 +131,8 @@ export default function LineChart( { monthly, commentTypes }: Props ) { - - + + @@ -106,19 +149,31 @@ export default function LineChart( { monthly, commentTypes }: Props ) { /> ) ) } - { /* Area fill */ } + { /* Area fill for total engagement */ } - { /* Line */ } - + { /* Lines for each engagement type */ } + { typeKeys.map( ( type, index ) => ( + + ) ) } + + { /* Total engagement line */ } + - { /* Data points */ } - { points.map( ( point, index ) => ( - + { /* Data points for total engagement */ } + { engagementPoints.map( ( point, index ) => ( + ) ) } { /* X-axis labels */ } - { points.map( ( point, index ) => ( + { engagementPoints.map( ( point, index ) => ( { monthLabels[ point.month.month - 1 ] } diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index 543e5b9dba..5f79b892ee 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -1,20 +1,22 @@ import { __ } from '@wordpress/i18n'; -import type { Comparison } from '../../types'; +import type { Comparison, CommentType } from '../../types'; interface Props { comparison: Comparison | null; + commentTypes: Record< string, CommentType > | null; } /** * Stat Highlights Component. * - * Displays key statistics with year-over-year comparison. + * Displays key statistics with month-over-month comparison. */ -export default function StatHighlights( { comparison }: Props ) { +export default function StatHighlights( { comparison, commentTypes }: Props ) { if ( ! comparison ) { return null; } + // Build stats array dynamically from comparison data and comment types. const stats = [ { key: 'followers', @@ -28,34 +30,40 @@ export default function StatHighlights( { comparison }: Props ) { value: comparison.posts?.current ?? 0, change: comparison.posts?.change ?? 0, }, - { - key: 'likes', - label: __( 'Likes', 'activitypub' ), - value: comparison.like?.current ?? 0, - change: comparison.like?.change ?? 0, - }, - { - key: 'reposts', - label: __( 'Reposts', 'activitypub' ), - value: comparison.repost?.current ?? 0, - change: comparison.repost?.change ?? 0, - }, ]; + // Add engagement types dynamically from comment types. + if ( commentTypes ) { + Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => { + const comparisonData = comparison[ slug as keyof Comparison ]; + if ( comparisonData && typeof comparisonData === 'object' && 'current' in comparisonData ) { + stats.push( { + key: slug, + label: type.label, + value: comparisonData.current ?? 0, + change: comparisonData.change ?? 0, + } ); + } + } ); + } + return (
- { stats.map( ( stat ) => ( -
- { stat.value.toLocaleString() } - { stat.label } - { stat.change !== 0 && ( - 0 ? 'positive' : 'negative' }` }> - { stat.change > 0 ? '+' : '' } - { stat.change.toLocaleString() } - - ) } -
- ) ) } +

{ __( 'This month vs. last year', 'activitypub' ) }

+
+ { stats.map( ( stat ) => ( +
+ { stat.value.toLocaleString() } + { stat.label } + { stat.change !== 0 && ( + 0 ? 'positive' : 'negative' }` }> + { stat.change > 0 ? '+' : '' } + { stat.change.toLocaleString() } + + ) } +
+ ) ) } +
); } diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index 06751454cb..a9781fd190 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -63,7 +63,7 @@ export default function StatsWidget() { ) : stats ? ( <> - + diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index 1543e63216..ef19e43819 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -24,10 +24,21 @@ // Highlights section. .activitypub-stats-highlights { + margin-bottom: 20px; +} + +.activitypub-stats-period { + font-size: 11px; + color: #757575; + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.activitypub-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; - margin-bottom: 20px; } .activitypub-stat-item { diff --git a/src/dashboard-stats/types/index.ts b/src/dashboard-stats/types/index.ts index 2ea70e9d3d..b7f0f31af6 100644 --- a/src/dashboard-stats/types/index.ts +++ b/src/dashboard-stats/types/index.ts @@ -15,17 +15,16 @@ export interface StatComparison { export interface Comparison { followers?: StatComparison; posts?: StatComparison; - like?: StatComparison; - repost?: StatComparison; + // Dynamic keys for engagement types (like, repost, quote, comment, etc.) + [ key: string ]: StatComparison | undefined; } export interface MonthData { month: number; posts_count: number; engagement: number; - like_count?: number; - repost_count?: number; - comment_count?: number; + // Dynamic keys for engagement type counts (like_count, repost_count, quote_count, etc.) + [ key: string ]: number | undefined; } export interface CommentType { From dd5d2aa763637d8334e4eeb269dbed061f90ed0f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 18 Dec 2025 18:14:26 +0100 Subject: [PATCH 05/46] Address PR feedback: move cron schedules and get actors via JS - Move cron schedule methods (add_cron_schedules, register_schedules, deregister_schedules) from Scheduler\Statistics to main Scheduler - Move helper methods (get_next_first_of_month, get_next_january_first) to main Scheduler - Update dashboard stats widget to get actors on JS side like the reader - Use @wordpress/core-data to check actor mode and capabilities - Remove PHP-passed actors from Statistics_Dashboard --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +- includes/class-scheduler.php | 66 +++++++++++++- includes/scheduler/class-statistics.php | 75 ---------------- .../wp-admin/class-statistics-dashboard.php | 39 +-------- .../components/stats-widget/index.tsx | 85 +++++++++++++++++-- src/dashboard-stats/index.tsx | 9 +- src/dashboard-stats/types/index.ts | 4 - 8 files changed, 147 insertions(+), 139 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 0bbb444922..c9958061f1 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '577ffe263a06b1d49801'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '6957a47da81915434e2c'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 4e526d5cc2..6b112c1975 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var t,e={192:(t,e,a)=>{const i=window.wp.element,s=window.wp.apiFetch;var n=a.n(s);const l=window.wp.components,r=window.wp.i18n,c=window.ReactJSXRuntime;function o({comparison:t,commentTypes:e}){var a,i,s,n;if(!t)return null;const l=[{key:"followers",label:(0,r.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(i=t.followers?.change)&&void 0!==i?i:0},{key:"posts",label:(0,r.__)("Posts","activitypub"),value:null!==(s=t.posts?.current)&&void 0!==s?s:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const i=t[e];var s,n;i&&"object"==typeof i&&"current"in i&&l.push({key:e,label:a.label,value:null!==(s=i.current)&&void 0!==s?s:0,change:null!==(n=i.change)&&void 0!==n?n:0})}),(0,c.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,c.jsx)("p",{className:"activitypub-stats-period",children:(0,r.__)("This month vs. last year","activitypub")}),(0,c.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,c.jsxs)("div",{className:"activitypub-stat-item",children:[(0,c.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,c.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,c.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:[t.change>0?"+":"",t.change.toLocaleString()]})]},t.key))})]})}const p=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function u(t){const e=p[t%p.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function v({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",i=20,s=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),o=t.map((e,a)=>40+a/(t.length-1||1)*540),p=t.map((t,e)=>({x:o[e],y:170-(t.engagement||0)/l*s,month:t})),v=p.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=v+` L ${p[p.length-1].x} 170`+` L ${p[0].x} 170 Z`,h=e=>t.map((t,a)=>{const i=t[`${e}_count`]||0,n=o[a],r=170-i/l*s;return 0===a?`M ${n} ${r}`:`L ${n} ${r}`}).join(" "),m=[(0,r.__)("Jan","activitypub"),(0,r.__)("Feb","activitypub"),(0,r.__)("Mar","activitypub"),(0,r.__)("Apr","activitypub"),(0,r.__)("May","activitypub"),(0,r.__)("Jun","activitypub"),(0,r.__)("Jul","activitypub"),(0,r.__)("Aug","activitypub"),(0,r.__)("Sep","activitypub"),(0,r.__)("Oct","activitypub"),(0,r.__)("Nov","activitypub"),(0,r.__)("Dec","activitypub")],y=[{key:"engagement",label:(0,r.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{y.push({key:t,label:e.label,color:u(a)})}),(0,c.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,c.jsx)("h4",{children:(0,r.__)("Engagement Over Time","activitypub")}),(0,c.jsxs)("div",{className:"activitypub-chart-container",children:[(0,c.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,c.jsx)("defs",{children:(0,c.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,c.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,c.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,c.jsx)("line",{x1:40,y1:i+s*(1-t),x2:580,y2:i+s*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,c.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,c.jsx)("path",{d:h(t),fill:"none",stroke:u(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,c.jsx)("path",{d:v,fill:"none",stroke:a,strokeWidth:"2"}),p.map((t,e)=>(0,c.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),p.map((t,e)=>(0,c.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:m[t.month.month-1]},e)),[0,.5,1].map(t=>(0,c.jsx)("text",{x:35,y:i+s*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,c.jsx)("div",{className:"activitypub-chart-legend",children:y.map(t=>(0,c.jsxs)("div",{className:"activitypub-legend-item",children:[(0,c.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function d({multiplicator:t}){return t?.name?(0,c.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Supporter","activitypub")}),(0,c.jsxs)("p",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,r.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,r._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function h({posts:t}){return t?.length?(0,c.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,c.jsx)("h4",{children:(0,r.__)("Top Posts","activitypub")}),(0,c.jsx)("ul",{children:t.map(t=>(0,c.jsxs)("li",{children:[(0,c.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,r.__)("(no title)","activitypub")}),(0,c.jsx)("span",{className:"engagement-count",children:(0,r.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,r.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}function m(){const{actors:t=[]}=window.activitypubDashboardStats||{actors:[]},[e,a]=(0,i.useState)(()=>{var e;return null!==(e=t[0]?.id)&&void 0!==e?e:null}),[s,p]=(0,i.useState)(null),[u,m]=(0,i.useState)(!0);(0,i.useEffect)(()=>{null!==e?(m(!0),n()({path:`/activitypub/1.0/stats/${e}`}).then(t=>p(t)).catch(()=>p(null)).finally(()=>m(!1))):m(!1)},[e]);const y=t.map(t=>({label:t.label,value:t.id}));return(0,c.jsxs)("div",{className:"activitypub-stats-widget",children:[t.length>1&&null!==e&&(0,c.jsx)("div",{className:"activitypub-stats-header",children:(0,c.jsx)(l.SelectControl,{value:e,options:y,onChange:t=>a(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),u?(0,c.jsx)("div",{className:"activitypub-stats-loading",children:(0,c.jsx)(l.Spinner,{})}):s?(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(o,{comparison:s.comparison,commentTypes:s.comment_types}),(0,c.jsx)(v,{monthly:s.monthly,commentTypes:s.comment_types}),(0,c.jsx)(d,{multiplicator:s.stats?.top_multiplicator}),(0,c.jsx)(h,{posts:s.stats?.top_posts})]}):(0,c.jsx)("p",{className:"activitypub-stats-empty",children:(0,r.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t,e){const a=document.getElementById(t);a&&(window.activitypubDashboardStats=e,(0,i.createRoot)(a).render((0,c.jsx)(m,{})))}}}},a={};function i(t){var s=a[t];if(void 0!==s)return s.exports;var n=a[t]={exports:{}};return e[t](n,n.exports,i),n.exports}i.m=e,t=[],i.O=(e,a,s,n)=>{if(!a){var l=1/0;for(p=0;p=n)&&Object.keys(i.O).every(t=>i.O[t](a[c]))?a.splice(c--,1):(r=!1,n0&&t[p-1][2]>n;p--)t[p]=t[p-1];t[p]=[a,s,n]},i.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return i.d(e,{a:e}),e},i.d=(t,e)=>{for(var a in e)i.o(e,a)&&!i.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};i.O.j=e=>0===t[e];var e=(e,a)=>{var s,n,[l,r,c]=a,o=0;if(l.some(e=>0!==t[e])){for(s in r)i.o(r,s)&&(i.m[s]=r[s]);if(c)var p=c(i)}for(e&&e(a);oi(192));s=i.O(s)})(); \ No newline at end of file +(()=>{"use strict";var t,e={2320(t,e,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const l=window.wp.data,r=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:t,commentTypes:e}){var a,s,i,n;if(!t)return null;const l=[{key:"followers",label:(0,c.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(s=t.followers?.change)&&void 0!==s?s:0},{key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const s=t[e];var i,n;s&&"object"==typeof s&&"current"in s&&l.push({key:e,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,p.jsx)("p",{className:"activitypub-stats-period",children:(0,c.__)("This month vs. last year","activitypub")}),(0,p.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,p.jsxs)("div",{className:"activitypub-stat-item",children:[(0,p.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,p.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,p.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:[t.change>0?"+":"",t.change.toLocaleString()]})]},t.key))})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function v(t){const e=d[t%d.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function h({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),r=t.map((e,a)=>40+a/(t.length-1||1)*540),o=t.map((t,e)=>({x:r[e],y:170-(t.engagement||0)/l*i,month:t})),u=o.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,h=e=>t.map((t,a)=>{const s=t[`${e}_count`]||0,n=r[a],o=170-s/l*i;return 0===a?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{m.push({key:t,label:e.label,color:v(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h4",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-t),x2:580,y2:s+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,p.jsx)("path",{d:h(t),fill:"none",stroke:v(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),o.map((t,e)=>(0,p.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),o.map((t,e)=>(0,p.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:y[t.month.month-1]},e)),[0,.5,1].map(t=>(0,p.jsx)("text",{x:35,y:s+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(t=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function y({multiplicator:t}){return t?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h4",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function m({posts:t}){return t?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h4",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:t.map(t=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:t,actorMode:e,hasUserCap:a,hasBlogCap:i,isResolving:d}=(0,l.useSelect)(t=>{var e;return{currentUser:t(r.store).getCurrentUser(),actorMode:null!==(e=t(r.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==e?e:b,hasUserCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:t(r.store).isResolving("getCurrentUser",[])}},[]),v=("blog"===e||e===b)&&i,g=[];("actor"===e||e===b)&&a&&t?.id&&g.push({id:t.id,label:(0,c.__)("Your Stats","activitypub")}),v&&g.push({id:0,label:(0,c.__)("Blog Stats","activitypub")});const[x,_]=(0,s.useState)(null),[j,f]=(0,s.useState)(null),[w,N]=(0,s.useState)(!0);(0,s.useEffect)(()=>{g.length>0&&null===x&&_(g[0].id)},[g,x]),(0,s.useEffect)(()=>{null!==x?(N(!0),n()({path:`/activitypub/1.0/stats/${x}`}).then(t=>f(t)).catch(()=>f(null)).finally(()=>N(!1))):N(!1)},[x]);const k=g.map(t=>({label:t.label,value:t.id}));return d?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[g.length>1&&null!==x&&(0,p.jsx)("div",{className:"activitypub-stats-header",children:(0,p.jsx)(o.SelectControl,{value:x,options:k,onChange:t=>_(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),w?(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})}):j?(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(u,{comparison:j.comparison,commentTypes:j.comment_types}),(0,p.jsx)(h,{monthly:j.monthly,commentTypes:j.comment_types}),(0,p.jsx)(y,{multiplicator:j.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:j.stats?.top_posts})]}):(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t){const e=document.getElementById(t);e&&(0,s.createRoot)(e).render((0,p.jsx)(g,{}))}}}},a={};function s(t){var i=a[t];if(void 0!==i)return i.exports;var n=a[t]={exports:{}};return e[t](n,n.exports,s),n.exports}s.m=e,t=[],s.O=(e,a,i,n)=>{if(!a){var l=1/0;for(p=0;p=n)&&Object.keys(s.O).every(t=>s.O[t](a[o]))?a.splice(o--,1):(r=!1,n0&&t[p-1][2]>n;p--)t[p]=t[p-1];t[p]=[a,i,n]},s.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return s.d(e,{a:e}),e},s.d=(t,e)=>{for(var a in e)s.o(e,a)&&!s.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},s.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};s.O.j=e=>0===t[e];var e=(e,a)=>{var i,n,[l,r,o]=a,c=0;if(l.some(e=>0!==t[e])){for(i in r)s.o(r,i)&&(s.m[i]=r[i]);if(o)var p=o(s)}for(e&&e(a);cs(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index bfdfc75961..0c8607b41d 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -54,6 +54,9 @@ public static function get_retry_delay() { public static function init() { self::register_schedulers(); + // Custom cron schedules. + \add_filter( 'cron_schedules', array( self::class, 'add_cron_schedules' ) ); + // Follower Cleanups. \add_action( 'activitypub_update_remote_actors', array( self::class, 'update_remote_actors' ) ); \add_action( 'activitypub_cleanup_remote_actors', array( self::class, 'cleanup_remote_actors' ) ); @@ -93,6 +96,27 @@ public static function register_schedulers() { \do_action( 'activitypub_register_schedulers' ); } + /** + * Add custom cron schedules. + * + * @param array $schedules Existing cron schedules. + * + * @return array Modified cron schedules. + */ + public static function add_cron_schedules( $schedules ) { + $schedules['monthly'] = array( + 'interval' => 30 * DAY_IN_SECONDS, + 'display' => \__( 'Once Monthly', 'activitypub' ), + ); + + $schedules['yearly'] = array( + 'interval' => 365 * DAY_IN_SECONDS, + 'display' => \__( 'Once Yearly', 'activitypub' ), + ); + + return $schedules; + } + /** * Register a batch callback for async processing. * @@ -147,7 +171,18 @@ public static function register_schedules() { \wp_schedule_event( time(), 'weekly', 'activitypub_sync_blocklist_subscriptions' ); } - Statistics::register_schedules(); + // Schedule monthly stats collection for the 1st of each month. + if ( ! \wp_next_scheduled( 'activitypub_collect_monthly_stats' ) ) { + // Calculate next 1st of month at 2:00 AM. + $next_first = self::get_next_first_of_month(); + \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' ); + } + + // Schedule annual stats compilation for January 1st. + if ( ! \wp_next_scheduled( 'activitypub_compile_annual_stats' ) ) { + $next_year = self::get_next_january_first(); + \wp_schedule_event( $next_year, 'yearly', 'activitypub_compile_annual_stats' ); + } } /** @@ -163,8 +198,35 @@ public static function deregister_schedules() { \wp_unschedule_hook( 'activitypub_inbox_purge' ); \wp_unschedule_hook( 'activitypub_ap_post_purge' ); \wp_unschedule_hook( 'activitypub_sync_blocklist_subscriptions' ); + \wp_unschedule_hook( 'activitypub_collect_monthly_stats' ); + \wp_unschedule_hook( 'activitypub_compile_annual_stats' ); + } + + /** + * Get the next 1st of month timestamp. + * + * @return int Unix timestamp of next 1st of month at 2:00 AM. + */ + private static function get_next_first_of_month() { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $next_month = \strtotime( 'first day of next month 02:00:00', $now ); + + return $next_month; + } + + /** + * Get the next January 1st timestamp. + * + * @return int Unix timestamp of next January 1st at 3:00 AM. + */ + private static function get_next_january_first() { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $year = (int) \gmdate( 'Y', $now ); + + // If we're past January 1st, schedule for next year. + $jan_first = \strtotime( sprintf( '%d-01-01 03:00:00', $year + 1 ) ); - Statistics::deregister_schedules(); + return $jan_first; } /** diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 7c26a15d90..5519215c70 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -23,81 +23,6 @@ class Statistics { public static function init() { \add_action( 'activitypub_collect_monthly_stats', array( self::class, 'collect_all_monthly_stats' ) ); \add_action( 'activitypub_compile_annual_stats', array( self::class, 'compile_and_send_annual_stats' ) ); - \add_filter( 'cron_schedules', array( self::class, 'add_cron_schedules' ) ); - } - - /** - * Add custom cron schedules. - * - * @param array $schedules Existing cron schedules. - * - * @return array Modified cron schedules. - */ - public static function add_cron_schedules( $schedules ) { - $schedules['monthly'] = array( - 'interval' => 30 * DAY_IN_SECONDS, - 'display' => \__( 'Once Monthly', 'activitypub' ), - ); - - $schedules['yearly'] = array( - 'interval' => 365 * DAY_IN_SECONDS, - 'display' => \__( 'Once Yearly', 'activitypub' ), - ); - - return $schedules; - } - - /** - * Register statistics schedules. - */ - public static function register_schedules() { - // Schedule monthly stats collection for the 1st of each month. - if ( ! \wp_next_scheduled( 'activitypub_collect_monthly_stats' ) ) { - // Calculate next 1st of month at 2:00 AM. - $next_first = self::get_next_first_of_month(); - \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' ); - } - - // Schedule annual stats compilation for January 1st. - if ( ! \wp_next_scheduled( 'activitypub_compile_annual_stats' ) ) { - $next_year = self::get_next_january_first(); - \wp_schedule_event( $next_year, 'yearly', 'activitypub_compile_annual_stats' ); - } - } - - /** - * Deregister statistics schedules. - */ - public static function deregister_schedules() { - \wp_unschedule_hook( 'activitypub_collect_monthly_stats' ); - \wp_unschedule_hook( 'activitypub_compile_annual_stats' ); - } - - /** - * Get the next 1st of month timestamp. - * - * @return int Unix timestamp of next 1st of month at 2:00 AM. - */ - private static function get_next_first_of_month() { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $next_month = \strtotime( 'first day of next month 02:00:00', $now ); - - return $next_month; - } - - /** - * Get the next January 1st timestamp. - * - * @return int Unix timestamp of next January 1st at 3:00 AM. - */ - private static function get_next_january_first() { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $year = (int) \gmdate( 'Y', $now ); - - // If we're past January 1st, schedule for next year. - $jan_first = \strtotime( sprintf( '%d-01-01 03:00:00', $year + 1 ) ); - - return $jan_first; } /** diff --git a/includes/wp-admin/class-statistics-dashboard.php b/includes/wp-admin/class-statistics-dashboard.php index 18180d1b29..7174555073 100644 --- a/includes/wp-admin/class-statistics-dashboard.php +++ b/includes/wp-admin/class-statistics-dashboard.php @@ -7,8 +7,6 @@ namespace Activitypub\WP_Admin; -use Activitypub\Collection\Actors; - use function Activitypub\is_user_type_disabled; use function Activitypub\user_can_activitypub; @@ -69,17 +67,9 @@ public static function enqueue_scripts( $hook ) { ); // Add inline script to initialize the widget. - $actors = self::get_available_actors(); - $settings = array( - 'actors' => $actors, - ); - \wp_add_inline_script( 'activitypub-dashboard-stats', - sprintf( - 'wp.domReady( function() { activitypub.dashboardStats.initialize( "activitypub-stats-widget-root", %s ); } );', - \wp_json_encode( $settings ) - ) + 'wp.domReady( function() { activitypub.dashboardStats.initialize( "activitypub-stats-widget-root" ); } );' ); } @@ -95,33 +85,6 @@ private static function user_has_access() { return $has_user_access || $has_blog_access; } - /** - * Get available actors (user/blog) for the current user. - * - * @return array Array of available actors with id and label. - */ - private static function get_available_actors() { - $actors = array(); - - // Check if current user can access their own stats. - if ( user_can_activitypub( \get_current_user_id() ) && ! is_user_type_disabled( 'user' ) ) { - $actors[] = array( - 'id' => \get_current_user_id(), - 'label' => \__( 'Your Stats', 'activitypub' ), - ); - } - - // Check if blog stats are available. - if ( ! is_user_type_disabled( 'blog' ) && \current_user_can( 'manage_options' ) ) { - $actors[] = array( - 'id' => Actors::BLOG_USER_ID, - 'label' => \__( 'Blog Stats', 'activitypub' ), - ); - } - - return $actors; - } - /** * Add dashboard widgets. */ diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index a9781fd190..e9d2ec88ca 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -1,29 +1,85 @@ import apiFetch from '@wordpress/api-fetch'; import { useState, useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; import { SelectControl, Spinner } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import StatHighlights from '../stat-highlights'; import LineChart from '../line-chart'; import TopSupporter from '../top-supporter'; import TopPosts from '../top-posts'; -import type { Settings, StatsResponse } from '../../types'; +import type { Actor, StatsResponse } from '../../types'; -/** - * Get dashboard stats settings from global window. - */ -function useSettings(): Settings { - return window.activitypubDashboardStats || { actors: [] }; -} +// Actor mode constants matching PHP definitions. +const ACTOR_MODE = 'actor'; +const BLOG_MODE = 'blog'; +const ACTOR_AND_BLOG_MODE = 'actor_blog'; + +// Blog user ID constant matching PHP. +const BLOG_USER_ID = 0; /** * Stats Widget Component. */ export default function StatsWidget() { - const { actors = [] } = useSettings(); - const [ selectedActor, setSelectedActor ] = useState< number | null >( () => actors[ 0 ]?.id ?? null ); + const { currentUser, actorMode, hasUserCap, hasBlogCap, isResolving } = useSelect( + ( select ) => ( { + currentUser: select( coreStore ).getCurrentUser(), + actorMode: + ( + select( coreStore ).getEntityRecord( 'root', 'site' ) as + | { activitypub_actor_mode?: string } + | undefined + )?.activitypub_actor_mode ?? ACTOR_AND_BLOG_MODE, + // Check if user has the activitypub capability (can create user extra fields). + hasUserCap: select( coreStore ).canUser( 'create', { + kind: 'postType', + name: 'ap_extrafield', + } ), + // Check if user can manage options (can create blog extra fields). + hasBlogCap: select( coreStore ).canUser( 'create', { + kind: 'postType', + name: 'ap_extrafield_blog', + } ), + isResolving: select( coreStore ).isResolving( 'getCurrentUser', [] ), + } ), + [] + ); + + // User can use their actor if user mode is enabled AND they have the capability. + const userModeEnabled: boolean = actorMode === ACTOR_MODE || actorMode === ACTOR_AND_BLOG_MODE; + const canUseUserActor: boolean = userModeEnabled && hasUserCap; + + // User can use the blog actor if blog mode is enabled AND they have the capability. + const blogModeEnabled: boolean = actorMode === BLOG_MODE || actorMode === ACTOR_AND_BLOG_MODE; + const canUseBlogActor: boolean = blogModeEnabled && hasBlogCap; + + // Build actors list based on capabilities. + const actors: Actor[] = []; + if ( canUseUserActor && currentUser?.id ) { + actors.push( { + id: currentUser.id, + label: __( 'Your Stats', 'activitypub' ), + } ); + } + if ( canUseBlogActor ) { + actors.push( { + id: BLOG_USER_ID, + label: __( 'Blog Stats', 'activitypub' ), + } ); + } + + const [ selectedActor, setSelectedActor ] = useState< number | null >( null ); const [ stats, setStats ] = useState< StatsResponse | null >( null ); const [ isLoading, setIsLoading ] = useState( true ); + // Set initial selected actor when actors are determined. + useEffect( () => { + if ( actors.length > 0 && selectedActor === null ) { + setSelectedActor( actors[ 0 ].id ); + } + }, [ actors, selectedActor ] ); + // Load stats when actor changes. useEffect( () => { if ( selectedActor === null ) { @@ -44,6 +100,17 @@ export default function StatsWidget() { value: actor.id, } ) ); + // Show loading while resolving user data. + if ( isResolving ) { + return ( +
+
+ +
+
+ ); + } + return (
{ actors.length > 1 && selectedActor !== null && ( diff --git a/src/dashboard-stats/index.tsx b/src/dashboard-stats/index.tsx index b8b56ca487..c2fe5026cd 100644 --- a/src/dashboard-stats/index.tsx +++ b/src/dashboard-stats/index.tsx @@ -1,14 +1,12 @@ import { createRoot } from '@wordpress/element'; import StatsWidget from './components/stats-widget'; -import type { Settings } from './types'; import './style.scss'; declare global { interface Window { - activitypubDashboardStats: Settings; activitypub: { dashboardStats?: { - initialize: ( id: string, settings: Settings ) => void; + initialize: ( id: string ) => void; }; }; } @@ -17,16 +15,13 @@ declare global { /** * Initialize the dashboard stats widget. */ -export function initialize( id: string, settings: Settings ) { +export function initialize( id: string ) { const container = document.getElementById( id ); if ( ! container ) { return; } - // Store settings globally for the widget. - window.activitypubDashboardStats = settings; - const root = createRoot( container ); root.render( ); } diff --git a/src/dashboard-stats/types/index.ts b/src/dashboard-stats/types/index.ts index b7f0f31af6..6250d6331f 100644 --- a/src/dashboard-stats/types/index.ts +++ b/src/dashboard-stats/types/index.ts @@ -3,10 +3,6 @@ export interface Actor { label: string; } -export interface Settings { - actors: Actor[]; -} - export interface StatComparison { current: number; change: number; From c33faf38b83bc4bf3d669dff7fc4de323bf4feca Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 15:20:55 +0100 Subject: [PATCH 06/46] Update statistics scheduler to compile current year stats in December - Changed compile_and_send_annual_stats to get current year instead of previous year (since it now runs December 1st, not January 1st) - Updated docblock to reflect the December timing - Added TODO for shareable landing page feature (per @obenland feedback) --- includes/scheduler/class-statistics.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 5519215c70..0ab5ea30ad 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -53,16 +53,22 @@ public static function collect_all_monthly_stats() { } /** - * Compile annual statistics and send emails. + * Compile annual statistics and send notifications. * - * This runs on January 1st and compiles stats for the previous year. + * This runs on December 1st and compiles stats for the current year + * (through November), giving users time to share their "wrapped" stats + * before year-end. + * + * @todo Create a shareable landing page instead of just sending an email. + * The email should link to a public page where stats can be viewed + * and shared. Consider adding a summary image generator. */ public static function compile_and_send_annual_stats() { $user_ids = Statistics_Collector::get_active_user_ids(); - // Get previous year. + // Get current year (we're running in December, compiling Jan-Nov stats). $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $year = (int) \gmdate( 'Y', $now ) - 1; + $year = (int) \gmdate( 'Y', $now ); foreach ( $user_ids as $user_id ) { $summary = Statistics_Collector::compile_annual_summary( $user_id, $year ); From 4e10603999dd8190ca1361c7cd17bb5f4ddbd9c2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 15:59:26 +0100 Subject: [PATCH 07/46] Restyle dashboard stats widget to match WordPress core dashboard - Use WordPress admin color palette (#2c3338, #646970, #f6f7f7, etc.) - Add dashicons for stat types (followers, posts, likes, reposts, comments) - Match padding and spacing from #dashboard_right_now widget - Use border-top separators instead of background color blocks - Style top supporter section like WordPress admin notices - Style top posts section like activity widget list items - Add responsive breakpoint for mobile --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- .../components/stat-highlights/index.tsx | 24 +- src/dashboard-stats/style.scss | 205 ++++++++++++------ 6 files changed, 161 insertions(+), 80 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index c9958061f1..d2c2dca8c2 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '6957a47da81915434e2c'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'b4764d569824037538ab'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 6b112c1975..dd1452d214 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var t,e={2320(t,e,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const l=window.wp.data,r=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:t,commentTypes:e}){var a,s,i,n;if(!t)return null;const l=[{key:"followers",label:(0,c.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(s=t.followers?.change)&&void 0!==s?s:0},{key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const s=t[e];var i,n;s&&"object"==typeof s&&"current"in s&&l.push({key:e,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,p.jsx)("p",{className:"activitypub-stats-period",children:(0,c.__)("This month vs. last year","activitypub")}),(0,p.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,p.jsxs)("div",{className:"activitypub-stat-item",children:[(0,p.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,p.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,p.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:[t.change>0?"+":"",t.change.toLocaleString()]})]},t.key))})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function v(t){const e=d[t%d.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function h({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),r=t.map((e,a)=>40+a/(t.length-1||1)*540),o=t.map((t,e)=>({x:r[e],y:170-(t.engagement||0)/l*i,month:t})),u=o.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,h=e=>t.map((t,a)=>{const s=t[`${e}_count`]||0,n=r[a],o=170-s/l*i;return 0===a?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{m.push({key:t,label:e.label,color:v(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h4",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-t),x2:580,y2:s+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,p.jsx)("path",{d:h(t),fill:"none",stroke:v(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),o.map((t,e)=>(0,p.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),o.map((t,e)=>(0,p.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:y[t.month.month-1]},e)),[0,.5,1].map(t=>(0,p.jsx)("text",{x:35,y:s+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(t=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function y({multiplicator:t}){return t?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h4",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function m({posts:t}){return t?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h4",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:t.map(t=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:t,actorMode:e,hasUserCap:a,hasBlogCap:i,isResolving:d}=(0,l.useSelect)(t=>{var e;return{currentUser:t(r.store).getCurrentUser(),actorMode:null!==(e=t(r.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==e?e:b,hasUserCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:t(r.store).isResolving("getCurrentUser",[])}},[]),v=("blog"===e||e===b)&&i,g=[];("actor"===e||e===b)&&a&&t?.id&&g.push({id:t.id,label:(0,c.__)("Your Stats","activitypub")}),v&&g.push({id:0,label:(0,c.__)("Blog Stats","activitypub")});const[x,_]=(0,s.useState)(null),[j,f]=(0,s.useState)(null),[w,N]=(0,s.useState)(!0);(0,s.useEffect)(()=>{g.length>0&&null===x&&_(g[0].id)},[g,x]),(0,s.useEffect)(()=>{null!==x?(N(!0),n()({path:`/activitypub/1.0/stats/${x}`}).then(t=>f(t)).catch(()=>f(null)).finally(()=>N(!1))):N(!1)},[x]);const k=g.map(t=>({label:t.label,value:t.id}));return d?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[g.length>1&&null!==x&&(0,p.jsx)("div",{className:"activitypub-stats-header",children:(0,p.jsx)(o.SelectControl,{value:x,options:k,onChange:t=>_(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),w?(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})}):j?(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(u,{comparison:j.comparison,commentTypes:j.comment_types}),(0,p.jsx)(h,{monthly:j.monthly,commentTypes:j.comment_types}),(0,p.jsx)(y,{multiplicator:j.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:j.stats?.top_posts})]}):(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t){const e=document.getElementById(t);e&&(0,s.createRoot)(e).render((0,p.jsx)(g,{}))}}}},a={};function s(t){var i=a[t];if(void 0!==i)return i.exports;var n=a[t]={exports:{}};return e[t](n,n.exports,s),n.exports}s.m=e,t=[],s.O=(e,a,i,n)=>{if(!a){var l=1/0;for(p=0;p=n)&&Object.keys(s.O).every(t=>s.O[t](a[o]))?a.splice(o--,1):(r=!1,n0&&t[p-1][2]>n;p--)t[p]=t[p-1];t[p]=[a,i,n]},s.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return s.d(e,{a:e}),e},s.d=(t,e)=>{for(var a in e)s.o(e,a)&&!s.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},s.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};s.O.j=e=>0===t[e];var e=(e,a)=>{var i,n,[l,r,o]=a,c=0;if(l.some(e=>0!==t[e])){for(i in r)s.o(r,i)&&(s.m[i]=r[i]);if(o)var p=o(s)}for(e&&e(a);cs(2320));i=s.O(i)})(); \ No newline at end of file +(()=>{"use strict";var t,e={2320(t,e,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const l=window.wp.data,r=window.wp.coreData,c=window.wp.components,o=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:t,commentTypes:e}){var a,s,i,n;if(!t)return null;const l=[{key:"followers",label:(0,o.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(s=t.followers?.change)&&void 0!==s?s:0},{key:"posts",label:(0,o.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const s=t[e];var i,n;s&&"object"==typeof s&&"current"in s&&l.push({key:e,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,p.jsx)("p",{className:"activitypub-stats-period",children:(0,o.__)("This month vs. last year","activitypub")}),(0,p.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,p.jsx)("div",{className:"activitypub-stat-item","data-type":t.key,children:(0,p.jsxs)("span",{className:"stat-content",children:[(0,p.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,p.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,p.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:["(",t.change>0?"+":"",t.change.toLocaleString(),")"]})]})},t.key))})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function v(t){const e=d[t%d.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function h({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),r=t.map((e,a)=>40+a/(t.length-1||1)*540),c=t.map((t,e)=>({x:r[e],y:170-(t.engagement||0)/l*i,month:t})),u=c.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=u+` L ${c[c.length-1].x} 170`+` L ${c[0].x} 170 Z`,h=e=>t.map((t,a)=>{const s=t[`${e}_count`]||0,n=r[a],c=170-s/l*i;return 0===a?`M ${n} ${c}`:`L ${n} ${c}`}).join(" "),y=[(0,o.__)("Jan","activitypub"),(0,o.__)("Feb","activitypub"),(0,o.__)("Mar","activitypub"),(0,o.__)("Apr","activitypub"),(0,o.__)("May","activitypub"),(0,o.__)("Jun","activitypub"),(0,o.__)("Jul","activitypub"),(0,o.__)("Aug","activitypub"),(0,o.__)("Sep","activitypub"),(0,o.__)("Oct","activitypub"),(0,o.__)("Nov","activitypub"),(0,o.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,o.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{m.push({key:t,label:e.label,color:v(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h4",{children:(0,o.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-t),x2:580,y2:s+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,p.jsx)("path",{d:h(t),fill:"none",stroke:v(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),c.map((t,e)=>(0,p.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),c.map((t,e)=>(0,p.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:y[t.month.month-1]},e)),[0,.5,1].map(t=>(0,p.jsx)("text",{x:35,y:s+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(t=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function y({multiplicator:t}){return t?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h4",{children:(0,o.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,o.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,o._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function m({posts:t}){return t?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h4",{children:(0,o.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:t.map(t=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,o.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,o.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,o.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:t,actorMode:e,hasUserCap:a,hasBlogCap:i,isResolving:d}=(0,l.useSelect)(t=>{var e;return{currentUser:t(r.store).getCurrentUser(),actorMode:null!==(e=t(r.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==e?e:b,hasUserCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:t(r.store).isResolving("getCurrentUser",[])}},[]),v=("blog"===e||e===b)&&i,g=[];("actor"===e||e===b)&&a&&t?.id&&g.push({id:t.id,label:(0,o.__)("Your Stats","activitypub")}),v&&g.push({id:0,label:(0,o.__)("Blog Stats","activitypub")});const[x,_]=(0,s.useState)(null),[j,f]=(0,s.useState)(null),[w,N]=(0,s.useState)(!0);(0,s.useEffect)(()=>{g.length>0&&null===x&&_(g[0].id)},[g,x]),(0,s.useEffect)(()=>{null!==x?(N(!0),n()({path:`/activitypub/1.0/stats/${x}`}).then(t=>f(t)).catch(()=>f(null)).finally(()=>N(!1))):N(!1)},[x]);const k=g.map(t=>({label:t.label,value:t.id}));return d?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(c.Spinner,{})})}):(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[g.length>1&&null!==x&&(0,p.jsx)("div",{className:"activitypub-stats-header",children:(0,p.jsx)(c.SelectControl,{value:x,options:k,onChange:t=>_(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),w?(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(c.Spinner,{})}):j?(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(u,{comparison:j.comparison,commentTypes:j.comment_types}),(0,p.jsx)(h,{monthly:j.monthly,commentTypes:j.comment_types}),(0,p.jsx)(y,{multiplicator:j.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:j.stats?.top_posts})]}):(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,o.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t){const e=document.getElementById(t);e&&(0,s.createRoot)(e).render((0,p.jsx)(g,{}))}}}},a={};function s(t){var i=a[t];if(void 0!==i)return i.exports;var n=a[t]={exports:{}};return e[t](n,n.exports,s),n.exports}s.m=e,t=[],s.O=(e,a,i,n)=>{if(!a){var l=1/0;for(p=0;p=n)&&Object.keys(s.O).every(t=>s.O[t](a[c]))?a.splice(c--,1):(r=!1,n0&&t[p-1][2]>n;p--)t[p]=t[p-1];t[p]=[a,i,n]},s.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return s.d(e,{a:e}),e},s.d=(t,e)=>{for(var a in e)s.o(e,a)&&!s.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},s.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};s.O.j=e=>0===t[e];var e=(e,a)=>{var i,n,[l,r,c]=a,o=0;if(l.some(e=>0!==t[e])){for(i in r)s.o(r,i)&&(s.m[i]=r[i]);if(c)var p=c(s)}for(e&&e(a);os(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index 50994897b4..d4f445a4a9 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{margin-bottom:20px}.activitypub-stats-period{color:#757575;font-size:11px;letter-spacing:.3px;margin:0 0 12px;text-transform:uppercase}.activitypub-stats-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr))}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-right:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:baseline;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;font:400 20px/1 dashicons,sans-serif;padding-left:5px;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{align-items:baseline;display:flex;flex-wrap:wrap;gap:4px}.activitypub-stat-item .stat-value{color:#2c3338;font-size:14px;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338;font-size:14px}.activitypub-stat-item .stat-change{color:#646970;font-size:12px;margin-right:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7}.activitypub-stats-chart,.activitypub-stats-sub{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:2px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-right:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index 824e68fd63..f8fc68c2d4 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{position:relative}.activitypub-stats-header{margin-bottom:16px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:32px}.activitypub-stats-empty{color:#757575;padding:24px;text-align:center}.activitypub-stats-highlights{margin-bottom:20px}.activitypub-stats-period{color:#757575;font-size:11px;letter-spacing:.3px;margin:0 0 12px;text-transform:uppercase}.activitypub-stats-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(100px,1fr))}.activitypub-stat-item{background:#f6f7f7;border-radius:4px;padding:12px 8px;text-align:center}.activitypub-stat-item .stat-value{color:#1d2327;display:block;font-size:24px;font-weight:600;line-height:1.2}.activitypub-stat-item .stat-label{color:#757575;display:block;font-size:11px;letter-spacing:.3px;margin-top:4px;text-transform:uppercase}.activitypub-stat-item .stat-change{color:#757575;display:inline-block;font-size:12px;margin-top:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-chart{margin-bottom:20px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:4px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#757575;font-size:10px}.activitypub-chart-legend{border-top:1px solid #e0e0e0;display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;padding-top:12px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:12px}.activitypub-legend-item .legend-color{border-radius:2px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#fef3cd;border-left:4px solid #f0c33c;border-radius:4px;margin-bottom:16px;padding:12px}.activitypub-stats-multiplicator h4{color:#856404;font-size:11px;font-weight:600;letter-spacing:.5px;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-multiplicator p{color:#1d2327;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;border-bottom:1px solid #f0f0f1;display:flex;justify-content:space-between;padding:8px 0}.activitypub-stats-top-posts li:last-child{border-bottom:0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:12px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#757575;font-size:12px;white-space:nowrap} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:baseline;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;font:400 20px/1 dashicons,sans-serif;padding-right:5px;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{align-items:baseline;display:flex;flex-wrap:wrap;gap:4px}.activitypub-stat-item .stat-value{color:#2c3338;font-size:14px;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338;font-size:14px}.activitypub-stat-item .stat-change{color:#646970;font-size:12px;margin-left:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7}.activitypub-stats-chart,.activitypub-stats-sub{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:2px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-left:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index 5f79b892ee..b14f59612f 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -10,6 +10,10 @@ interface Props { * Stat Highlights Component. * * Displays key statistics with month-over-month comparison. + * + * @param {Props} props Component props. + * @param {Props} props.comparison Comparison data with current vs previous values. + * @param {Props} props.commentTypes Available comment types configuration. */ export default function StatHighlights( { comparison, commentTypes }: Props ) { if ( ! comparison ) { @@ -52,15 +56,17 @@ export default function StatHighlights( { comparison, commentTypes }: Props ) {

{ __( 'This month vs. last year', 'activitypub' ) }

{ stats.map( ( stat ) => ( -
- { stat.value.toLocaleString() } - { stat.label } - { stat.change !== 0 && ( - 0 ? 'positive' : 'negative' }` }> - { stat.change > 0 ? '+' : '' } - { stat.change.toLocaleString() } - - ) } +
+ + { stat.value.toLocaleString() } + { stat.label } + { stat.change !== 0 && ( + 0 ? 'positive' : 'negative' }` }> + ({ stat.change > 0 ? '+' : '' } + { stat.change.toLocaleString() }) + + ) } +
) ) }
diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index 6075102e7e..145c6c2887 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -1,74 +1,128 @@ +// ActivityPub Dashboard Stats Widget +// Styled to match WordPress core dashboard widgets. + .activitypub-stats-widget { - position: relative; + // Match WordPress dashboard postbox inner padding. + margin: -6px -12px -12px; } .activitypub-stats-header { - margin-bottom: 16px; + padding: 0 12px 12px; + border-bottom: 1px solid #f0f0f1; .components-select-control { margin: 0; } + + .components-base-control__field { + margin-bottom: 0; + } } .activitypub-stats-loading { display: flex; justify-content: center; - padding: 32px; + padding: 24px 12px; } .activitypub-stats-empty { - color: #757575; + color: #646970; text-align: center; - padding: 24px; + padding: 24px 12px; + margin: 0; } -// Highlights section. +// Main stats section - matches #dashboard_right_now .main style. +.activitypub-stats-main { + padding: 12px; +} + +// Highlights section - matches "At a Glance" style. .activitypub-stats-highlights { - margin-bottom: 20px; + padding: 0 12px 8px; } .activitypub-stats-period { font-size: 11px; - color: #757575; - margin: 0 0 12px; + color: #646970; + margin: 0 0 8px; text-transform: uppercase; - letter-spacing: 0.3px; + font-weight: 500; } .activitypub-stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); - gap: 12px; + display: flex; + flex-wrap: wrap; + margin: 0; } .activitypub-stat-item { - text-align: center; - padding: 12px 8px; - background: #f6f7f7; - border-radius: 4px; + width: 50%; + box-sizing: border-box; + padding: 4px 0; + display: flex; + align-items: baseline; + + // Dashicon-style icon placeholder. + &::before { + color: #646970; + font: 400 20px/1 dashicons, sans-serif; + padding-right: 5px; + vertical-align: top; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + // Icon mappings. + &[data-type="followers"]::before { + content: "\f307"; // groups icon. + } + + &[data-type="posts"]::before { + content: "\f109"; // post icon. + } + + &[data-type="likes"]::before, + &[data-type="like"]::before { + content: "\f155"; // star-filled icon. + } + + &[data-type="reposts"]::before, + &[data-type="repost"]::before { + content: "\f488"; // share icon. + } + + &[data-type="comments"]::before, + &[data-type="comment"]::before { + content: "\f101"; // comment icon. + } + + &[data-type="engagement"]::before { + content: "\f239"; // chart icon. + } + + .stat-content { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px; + } .stat-value { - display: block; - font-size: 24px; + font-size: 14px; font-weight: 600; - color: #1d2327; - line-height: 1.2; + color: #2c3338; } .stat-label { - display: block; - font-size: 11px; - color: #757575; - margin-top: 4px; - text-transform: uppercase; - letter-spacing: 0.3px; + font-size: 14px; + color: #2c3338; } .stat-change { - display: inline-block; font-size: 12px; - margin-top: 4px; - color: #757575; + color: #646970; + margin-left: 4px; &.positive { color: #00a32a; @@ -80,9 +134,17 @@ } } +// Sub-section style - matches #dashboard_right_now .sub. +.activitypub-stats-sub { + background: #f6f7f7; + border-top: 1px solid #f0f0f1; + padding: 12px; +} + // Chart section. .activitypub-stats-chart { - margin-bottom: 20px; + padding: 12px; + border-top: 1px solid #f0f0f1; h4 { margin: 0 0 12px; @@ -94,7 +156,7 @@ .activitypub-chart-container { background: #f6f7f7; - border-radius: 4px; + border-radius: 2px; padding: 12px; } @@ -105,56 +167,55 @@ .chart-label { font-size: 10px; - fill: #757575; + fill: #646970; } } .activitypub-chart-legend { display: flex; flex-wrap: wrap; - gap: 16px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid #e0e0e0; + gap: 12px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #dcdcde; } .activitypub-legend-item { display: flex; align-items: center; - font-size: 12px; + font-size: 11px; color: #50575e; .legend-color { width: 12px; height: 3px; - border-radius: 2px; + border-radius: 1px; margin-right: 6px; } } -// Top supporter section. +// Top supporter section - matches WordPress notice style. .activitypub-stats-multiplicator { - margin-bottom: 16px; + margin: 0; padding: 12px; - background: #fef3cd; - border-radius: 4px; - border-left: 4px solid #f0c33c; + background: #f6f7f7; + border-top: 1px solid #f0f0f1; + border-left: 4px solid #72aee6; h4 { - margin: 0 0 8px; - font-size: 11px; + margin: 0 0 4px; + font-size: 13px; font-weight: 600; - color: #856404; - text-transform: uppercase; - letter-spacing: 0.5px; + color: #1d2327; } p { margin: 0; font-size: 13px; - color: #1d2327; + color: #50575e; } + /* stylelint-disable-next-line no-descending-specificity */ a { text-decoration: none; font-weight: 500; @@ -167,8 +228,10 @@ } } -// Top posts section. +// Top posts section - matches activity widget style. .activitypub-stats-top-posts { + padding: 12px; + border-top: 1px solid #f0f0f1; h4 { margin: 0 0 8px; @@ -181,6 +244,21 @@ margin: 0; padding: 0; list-style: none; + background: #f6f7f7; + border-radius: 2px; + } + + li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + color: #2c3338; + } + + /* stylelint-disable-next-line no-descending-specificity */ + li + li { + border-top: 1px solid #f0f0f1; } /* stylelint-disable-next-line no-descending-specificity */ @@ -190,7 +268,7 @@ text-overflow: ellipsis; white-space: nowrap; text-decoration: none; - margin-right: 12px; + margin-right: 10px; color: #2271b1; &:hover { @@ -199,21 +277,18 @@ } } - li { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid #f0f0f1; - - &:last-child { - border-bottom: 0; - } - } - .engagement-count { font-size: 12px; - color: #757575; + color: #646970; white-space: nowrap; } } + +// Responsive adjustments. + +@media screen and (max-width: 782px) { + + .activitypub-stat-item { + width: 100%; + } +} From 86f2c7096dd6128a1bdf74dfc4c511ac15378027 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:02:36 +0100 Subject: [PATCH 08/46] Improve dashboard stats styling - Make chart container full width with background extending to edges - Better align icons with text using flex-start and margin adjustments - Simplify stat-content to inline display for natural text flow --- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- src/dashboard-stats/style.scss | 25 +++++++++-------------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index d4f445a4a9..7a30f838c7 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:baseline;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;font:400 20px/1 dashicons,sans-serif;padding-left:5px;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{align-items:baseline;display:flex;flex-wrap:wrap;gap:4px}.activitypub-stat-item .stat-value{color:#2c3338;font-size:14px;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338;font-size:14px}.activitypub-stat-item .stat-change{color:#646970;font-size:12px;margin-right:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7}.activitypub-stats-chart,.activitypub-stats-sub{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:2px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-right:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:flex-start;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;flex-shrink:0;font:400 20px/1 dashicons,sans-serif;margin-left:5px;margin-top:1px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{display:inline;line-height:1.4}.activitypub-stat-item .stat-value{color:#2c3338;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338}.activitypub-stat-item .stat-change{color:#646970;font-size:12px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{padding:12px}.activitypub-stats-chart,.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0;padding:12px 12px 0}.activitypub-chart-container{padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-right:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index f8fc68c2d4..bc2f94dedb 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:baseline;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;font:400 20px/1 dashicons,sans-serif;padding-right:5px;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{align-items:baseline;display:flex;flex-wrap:wrap;gap:4px}.activitypub-stat-item .stat-value{color:#2c3338;font-size:14px;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338;font-size:14px}.activitypub-stat-item .stat-change{color:#646970;font-size:12px;margin-left:4px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7}.activitypub-stats-chart,.activitypub-stats-sub{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 12px}.activitypub-chart-container{background:#f6f7f7;border-radius:2px;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-left:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:flex-start;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;flex-shrink:0;font:400 20px/1 dashicons,sans-serif;margin-right:5px;margin-top:1px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{display:inline;line-height:1.4}.activitypub-stat-item .stat-value{color:#2c3338;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338}.activitypub-stat-item .stat-change{color:#646970;font-size:12px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{padding:12px}.activitypub-stats-chart,.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0;padding:12px 12px 0}.activitypub-chart-container{padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-left:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index 145c6c2887..c2922f2cf6 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -61,14 +61,15 @@ box-sizing: border-box; padding: 4px 0; display: flex; - align-items: baseline; + align-items: flex-start; // Dashicon-style icon placeholder. &::before { color: #646970; font: 400 20px/1 dashicons, sans-serif; - padding-right: 5px; - vertical-align: top; + margin-right: 5px; + margin-top: 1px; + flex-shrink: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -102,27 +103,22 @@ } .stat-content { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px; + display: inline; + line-height: 1.4; } .stat-value { - font-size: 14px; font-weight: 600; color: #2c3338; } .stat-label { - font-size: 14px; color: #2c3338; } .stat-change { font-size: 12px; color: #646970; - margin-left: 4px; &.positive { color: #00a32a; @@ -141,13 +137,14 @@ padding: 12px; } -// Chart section. +// Chart section - full width background. .activitypub-stats-chart { - padding: 12px; border-top: 1px solid #f0f0f1; + background: #f6f7f7; h4 { - margin: 0 0 12px; + margin: 0; + padding: 12px 12px 0; font-size: 13px; font-weight: 600; color: #1d2327; @@ -155,8 +152,6 @@ } .activitypub-chart-container { - background: #f6f7f7; - border-radius: 2px; padding: 12px; } From 628d98bb416789e5f09791b0f34ca408e874fd60 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:05:13 +0100 Subject: [PATCH 09/46] Use h3 for section headlines and move chart title outside grey box Change all section headlines from h4 to h3 for proper heading hierarchy. Move the "Engagement Over Time" title outside the grey background area by applying the background only to the chart container. --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 4 ++-- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- src/dashboard-stats/components/line-chart/index.tsx | 6 +++++- src/dashboard-stats/components/top-posts/index.tsx | 4 +++- src/dashboard-stats/components/top-supporter/index.tsx | 4 +++- src/dashboard-stats/style.scss | 10 +++++----- 8 files changed, 21 insertions(+), 13 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index d2c2dca8c2..c80ad9bda6 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'b4764d569824037538ab'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '555fcaac3ffbd4b96fe6'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index dd1452d214..1908c0c6b1 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var t,e={2320(t,e,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const l=window.wp.data,r=window.wp.coreData,c=window.wp.components,o=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:t,commentTypes:e}){var a,s,i,n;if(!t)return null;const l=[{key:"followers",label:(0,o.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(s=t.followers?.change)&&void 0!==s?s:0},{key:"posts",label:(0,o.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const s=t[e];var i,n;s&&"object"==typeof s&&"current"in s&&l.push({key:e,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,p.jsx)("p",{className:"activitypub-stats-period",children:(0,o.__)("This month vs. last year","activitypub")}),(0,p.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,p.jsx)("div",{className:"activitypub-stat-item","data-type":t.key,children:(0,p.jsxs)("span",{className:"stat-content",children:[(0,p.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,p.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,p.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:["(",t.change>0?"+":"",t.change.toLocaleString(),")"]})]})},t.key))})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function v(t){const e=d[t%d.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function h({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),r=t.map((e,a)=>40+a/(t.length-1||1)*540),c=t.map((t,e)=>({x:r[e],y:170-(t.engagement||0)/l*i,month:t})),u=c.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=u+` L ${c[c.length-1].x} 170`+` L ${c[0].x} 170 Z`,h=e=>t.map((t,a)=>{const s=t[`${e}_count`]||0,n=r[a],c=170-s/l*i;return 0===a?`M ${n} ${c}`:`L ${n} ${c}`}).join(" "),y=[(0,o.__)("Jan","activitypub"),(0,o.__)("Feb","activitypub"),(0,o.__)("Mar","activitypub"),(0,o.__)("Apr","activitypub"),(0,o.__)("May","activitypub"),(0,o.__)("Jun","activitypub"),(0,o.__)("Jul","activitypub"),(0,o.__)("Aug","activitypub"),(0,o.__)("Sep","activitypub"),(0,o.__)("Oct","activitypub"),(0,o.__)("Nov","activitypub"),(0,o.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,o.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{m.push({key:t,label:e.label,color:v(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h4",{children:(0,o.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-t),x2:580,y2:s+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,p.jsx)("path",{d:h(t),fill:"none",stroke:v(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),c.map((t,e)=>(0,p.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),c.map((t,e)=>(0,p.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:y[t.month.month-1]},e)),[0,.5,1].map(t=>(0,p.jsx)("text",{x:35,y:s+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(t=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function y({multiplicator:t}){return t?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h4",{children:(0,o.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,o.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,o._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function m({posts:t}){return t?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h4",{children:(0,o.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:t.map(t=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,o.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,o.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(()=>{"use strict";var t,e={2320(t,e,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const l=window.wp.data,r=window.wp.coreData,c=window.wp.components,o=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:t,commentTypes:e}){var a,s,i,n;if(!t)return null;const l=[{key:"followers",label:(0,o.__)("Followers","activitypub"),value:null!==(a=t.followers?.current)&&void 0!==a?a:0,change:null!==(s=t.followers?.change)&&void 0!==s?s:0},{key:"posts",label:(0,o.__)("Posts","activitypub"),value:null!==(i=t.posts?.current)&&void 0!==i?i:0,change:null!==(n=t.posts?.change)&&void 0!==n?n:0}];return e&&Object.entries(e).forEach(([e,a])=>{const s=t[e];var i,n;s&&"object"==typeof s&&"current"in s&&l.push({key:e,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights",children:[(0,p.jsx)("p",{className:"activitypub-stats-period",children:(0,o.__)("This month vs. last year","activitypub")}),(0,p.jsx)("div",{className:"activitypub-stats-grid",children:l.map(t=>(0,p.jsx)("div",{className:"activitypub-stat-item","data-type":t.key,children:(0,p.jsxs)("span",{className:"stat-content",children:[(0,p.jsx)("span",{className:"stat-value",children:t.value.toLocaleString()}),(0,p.jsx)("span",{className:"stat-label",children:t.label}),0!==t.change&&(0,p.jsxs)("span",{className:"stat-change "+(t.change>0?"positive":"negative"),children:["(",t.change>0?"+":"",t.change.toLocaleString(),")"]})]})},t.key))})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function v(t){const e=d[t%d.length];return`var(--wp--preset--color--${e.slug}, ${e.hex})`}function h({monthly:t,commentTypes:e}){if(!t?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=e?Object.keys(e):[],l=Math.max(...t.map(t=>t.engagement||0),...n.flatMap(e=>t.map(t=>t[`${e}_count`]||0)),1),r=t.map((e,a)=>40+a/(t.length-1||1)*540),c=t.map((t,e)=>({x:r[e],y:170-(t.engagement||0)/l*i,month:t})),u=c.map((t,e)=>0===e?`M ${t.x} ${t.y}`:`L ${t.x} ${t.y}`).join(" "),d=u+` L ${c[c.length-1].x} 170`+` L ${c[0].x} 170 Z`,h=e=>t.map((t,a)=>{const s=t[`${e}_count`]||0,n=r[a],c=170-s/l*i;return 0===a?`M ${n} ${c}`:`L ${n} ${c}`}).join(" "),y=[(0,o.__)("Jan","activitypub"),(0,o.__)("Feb","activitypub"),(0,o.__)("Mar","activitypub"),(0,o.__)("Apr","activitypub"),(0,o.__)("May","activitypub"),(0,o.__)("Jun","activitypub"),(0,o.__)("Jul","activitypub"),(0,o.__)("Aug","activitypub"),(0,o.__)("Sep","activitypub"),(0,o.__)("Oct","activitypub"),(0,o.__)("Nov","activitypub"),(0,o.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,o.__)("Total Engagement","activitypub"),color:a}];return e&&Object.entries(e).forEach(([t,e],a)=>{m.push({key:t,label:e.label,color:v(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,o.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(t=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-t),x2:580,y2:s+i*(1-t),stroke:"#e0e0e0",strokeWidth:"1"},t)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((t,e)=>(0,p.jsx)("path",{d:h(t),fill:"none",stroke:v(e),strokeWidth:"2",strokeOpacity:"0.7"},t)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),c.map((t,e)=>(0,p.jsx)("circle",{cx:t.x,cy:t.y,r:"4",fill:a},e)),c.map((t,e)=>(0,p.jsx)("text",{x:t.x,y:195,textAnchor:"middle",className:"chart-label",children:y[t.month.month-1]},e)),[0,.5,1].map(t=>(0,p.jsx)("text",{x:35,y:s+i*(1-t)+4,textAnchor:"end",className:"chart-label",children:Math.round(l*t)},t))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(t=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:t.color}}),t.label]},t.key))})]})]})}function y({multiplicator:t}){return t?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,o.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.name})," ",(0,o.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,o._n)("(%s boost)","(%s boosts)",t.count,"activitypub"),t.count.toLocaleString())]})]}):null}function m({posts:t}){return t?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,o.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:t.map(t=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",children:t.title||(0,o.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,o.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ (0,o.__)("%s engagements","activitypub"),t.engagement_count.toLocaleString())})]},t.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:t,actorMode:e,hasUserCap:a,hasBlogCap:i,isResolving:d}=(0,l.useSelect)(t=>{var e;return{currentUser:t(r.store).getCurrentUser(),actorMode:null!==(e=t(r.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==e?e:b,hasUserCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:t(r.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:t(r.store).isResolving("getCurrentUser",[])}},[]),v=("blog"===e||e===b)&&i,g=[];("actor"===e||e===b)&&a&&t?.id&&g.push({id:t.id,label:(0,o.__)("Your Stats","activitypub")}),v&&g.push({id:0,label:(0,o.__)("Blog Stats","activitypub")});const[x,_]=(0,s.useState)(null),[j,f]=(0,s.useState)(null),[w,N]=(0,s.useState)(!0);(0,s.useEffect)(()=>{g.length>0&&null===x&&_(g[0].id)},[g,x]),(0,s.useEffect)(()=>{null!==x?(N(!0),n()({path:`/activitypub/1.0/stats/${x}`}).then(t=>f(t)).catch(()=>f(null)).finally(()=>N(!1))):N(!1)},[x]);const k=g.map(t=>({label:t.label,value:t.id}));return d?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(c.Spinner,{})})}):(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[g.length>1&&null!==x&&(0,p.jsx)("div",{className:"activitypub-stats-header",children:(0,p.jsx)(c.SelectControl,{value:x,options:k,onChange:t=>_(parseInt(String(t),10)),__nextHasNoMarginBottom:!0})}),w?(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(c.Spinner,{})}):j?(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(u,{comparison:j.comparison,commentTypes:j.comment_types}),(0,p.jsx)(h,{monthly:j.monthly,commentTypes:j.comment_types}),(0,p.jsx)(y,{multiplicator:j.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:j.stats?.top_posts})]}):(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,o.__)("No statistics available yet.","activitypub")})]})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(t){const e=document.getElementById(t);e&&(0,s.createRoot)(e).render((0,p.jsx)(g,{}))}}}},a={};function s(t){var i=a[t];if(void 0!==i)return i.exports;var n=a[t]={exports:{}};return e[t](n,n.exports,s),n.exports}s.m=e,t=[],s.O=(e,a,i,n)=>{if(!a){var l=1/0;for(p=0;p=n)&&Object.keys(s.O).every(t=>s.O[t](a[c]))?a.splice(c--,1):(r=!1,n0&&t[p-1][2]>n;p--)t[p]=t[p-1];t[p]=[a,i,n]},s.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return s.d(e,{a:e}),e},s.d=(t,e)=>{for(var a in e)s.o(e,a)&&!s.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:e[a]})},s.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{var t={306:0,598:0};s.O.j=e=>0===t[e];var e=(e,a)=>{var i,n,[l,r,c]=a,o=0;if(l.some(e=>0!==t[e])){for(i in r)s.o(r,i)&&(s.m[i]=r[i]);if(c)var p=c(s)}for(e&&e(a);os(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index 7a30f838c7..14357baeb5 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:flex-start;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;flex-shrink:0;font:400 20px/1 dashicons,sans-serif;margin-left:5px;margin-top:1px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{display:inline;line-height:1.4}.activitypub-stat-item .stat-value{color:#2c3338;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338}.activitypub-stat-item .stat-change{color:#646970;font-size:12px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{padding:12px}.activitypub-stats-chart,.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0;padding:12px 12px 0}.activitypub-chart-container{padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-right:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:flex-start;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;flex-shrink:0;font:400 20px/1 dashicons,sans-serif;margin-left:5px;margin-top:1px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{display:inline;line-height:1.4}.activitypub-stat-item .stat-value{color:#2c3338;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338}.activitypub-stat-item .stat-change{color:#646970;font-size:12px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;padding:12px}.activitypub-stats-chart,.activitypub-stats-sub{border-top:1px solid #f0f0f1}.activitypub-stats-chart h3{color:#1d2327;font-size:13px;font-weight:600;margin:0;padding:12px}.activitypub-chart-container{background:#f6f7f7;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-right:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h3{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index bc2f94dedb..dee09669fb 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:flex-start;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;flex-shrink:0;font:400 20px/1 dashicons,sans-serif;margin-right:5px;margin-top:1px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{display:inline;line-height:1.4}.activitypub-stat-item .stat-value{color:#2c3338;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338}.activitypub-stat-item .stat-change{color:#646970;font-size:12px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{padding:12px}.activitypub-stats-chart,.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1}.activitypub-stats-chart h4{color:#1d2327;font-size:13px;font-weight:600;margin:0;padding:12px 12px 0}.activitypub-chart-container{padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-left:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h4{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-header{border-bottom:1px solid #f0f0f1;padding:0 12px 12px}.activitypub-stats-header .components-select-control{margin:0}.activitypub-stats-header .components-base-control__field{margin-bottom:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-main{padding:12px}.activitypub-stats-highlights{padding:0 12px 8px}.activitypub-stats-period{color:#646970;font-size:11px;font-weight:500;margin:0 0 8px;text-transform:uppercase}.activitypub-stats-grid{display:flex;flex-wrap:wrap;margin:0}.activitypub-stat-item{align-items:flex-start;box-sizing:border-box;display:flex;padding:4px 0;width:50%}.activitypub-stat-item:before{color:#646970;flex-shrink:0;font:400 20px/1 dashicons,sans-serif;margin-right:5px;margin-top:1px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.activitypub-stat-item[data-type=followers]:before{content:""}.activitypub-stat-item[data-type=posts]:before{content:""}.activitypub-stat-item[data-type=like]:before,.activitypub-stat-item[data-type=likes]:before{content:""}.activitypub-stat-item[data-type=repost]:before,.activitypub-stat-item[data-type=reposts]:before{content:""}.activitypub-stat-item[data-type=comment]:before,.activitypub-stat-item[data-type=comments]:before{content:""}.activitypub-stat-item[data-type=engagement]:before{content:""}.activitypub-stat-item .stat-content{display:inline;line-height:1.4}.activitypub-stat-item .stat-value{color:#2c3338;font-weight:600}.activitypub-stat-item .stat-label{color:#2c3338}.activitypub-stat-item .stat-change{color:#646970;font-size:12px}.activitypub-stat-item .stat-change.positive{color:#00a32a}.activitypub-stat-item .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;padding:12px}.activitypub-stats-chart,.activitypub-stats-sub{border-top:1px solid #f0f0f1}.activitypub-stats-chart h3{color:#1d2327;font-size:13px;font-weight:600;margin:0;padding:12px}.activitypub-chart-container{background:#f6f7f7;padding:12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;gap:12px;margin-top:10px;padding-top:10px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{background:#f6f7f7;border-left:4px solid #72aee6;border-top:1px solid #f0f0f1;margin:0;padding:12px}.activitypub-stats-multiplicator h3{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 4px}.activitypub-stats-multiplicator p{color:#50575e;font-size:13px;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:500;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{color:#1d2327;font-size:13px;font-weight:600;margin:0 0 8px}.activitypub-stats-top-posts ul{background:#f6f7f7;border-radius:2px;list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#2c3338;display:flex;justify-content:space-between;padding:8px 10px}.activitypub-stats-top-posts li+li{border-top:1px solid #f0f0f1}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stat-item{width:100%}} diff --git a/src/dashboard-stats/components/line-chart/index.tsx b/src/dashboard-stats/components/line-chart/index.tsx index b1e70adcba..aee73fb6db 100644 --- a/src/dashboard-stats/components/line-chart/index.tsx +++ b/src/dashboard-stats/components/line-chart/index.tsx @@ -20,6 +20,7 @@ const WP_DEFAULT_COLORS = [ /** * Get CSS variable with fallback to hex value. * Uses CSS var() with fallback for best compatibility. + * @param index */ function getColor( index: number ): string { const color = WP_DEFAULT_COLORS[ index % WP_DEFAULT_COLORS.length ]; @@ -37,6 +38,9 @@ function getEngagementColor(): string { * Line Chart Component. * * Renders an SVG line chart for monthly engagement data. + * @param root0 + * @param root0.monthly + * @param root0.commentTypes */ export default function LineChart( { monthly, commentTypes }: Props ) { if ( ! monthly?.length ) { @@ -126,7 +130,7 @@ export default function LineChart( { monthly, commentTypes }: Props ) { return (
-

{ __( 'Engagement Over Time', 'activitypub' ) }

+

{ __( 'Engagement Over Time', 'activitypub' ) }

diff --git a/src/dashboard-stats/components/top-posts/index.tsx b/src/dashboard-stats/components/top-posts/index.tsx index 01ad52b72f..6830d09609 100644 --- a/src/dashboard-stats/components/top-posts/index.tsx +++ b/src/dashboard-stats/components/top-posts/index.tsx @@ -7,6 +7,8 @@ interface Props { /** * Top Posts Component. + * @param root0 + * @param root0.posts */ export default function TopPosts( { posts }: Props ) { if ( ! posts?.length ) { @@ -15,7 +17,7 @@ export default function TopPosts( { posts }: Props ) { return (
-

{ __( 'Top Posts', 'activitypub' ) }

+

{ __( 'Top Posts', 'activitypub' ) }

); } diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index 2078879ab7..d1b838208f 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -15,19 +15,6 @@ } } -.activitypub-stats-header { - padding: 0 12px 12px; - border-bottom: 1px solid #f0f0f1; - - .components-select-control { - margin: 0; - } - - .components-base-control__field { - margin-bottom: 0; - } -} - .activitypub-stats-loading { display: flex; justify-content: center; @@ -49,22 +36,24 @@ margin-left: 0; margin-right: 0; } -} -.activitypub-stats-grid { - margin: 0; - display: inline-block; - width: 100%; -} + // Match #dashboard_right_now ul exactly. + ul { + margin: 0; + display: inline-block; + width: 100%; + } -// Match #dashboard_right_now li styling exactly. -.activitypub-stat-item { - width: 50%; - float: left; - margin-bottom: 10px; + // Match #dashboard_right_now li exactly. + li { + width: 50%; + float: left; + margin-bottom: 10px; + } // Match #dashboard_right_now li a:before exactly. - &::before { + li a::before, + li > span::before { content: "\f159"; color: #646970; font: 400 20px/1 dashicons, sans-serif; @@ -78,42 +67,31 @@ } // Icon mappings - same as WordPress dashboard icons. - &[data-type="followers"]::before { + .activitypub-followers-count a::before, + .activitypub-followers-count > span::before { content: "\f307"; // groups icon. } - &[data-type="posts"]::before { + .activitypub-posts-count a::before, + .activitypub-posts-count > span::before { content: "\f109"; // admin-post icon. } - &[data-type="likes"]::before, - &[data-type="like"]::before { + .activitypub-like-count a::before, + .activitypub-like-count > span::before { content: "\f155"; // star-filled icon. } - &[data-type="reposts"]::before, - &[data-type="repost"]::before { + .activitypub-repost-count a::before, + .activitypub-repost-count > span::before { content: "\f488"; // share icon. } - &[data-type="comments"]::before, - &[data-type="comment"]::before { + .activitypub-comment-count a::before, + .activitypub-comment-count > span::before { content: "\f101"; // admin-comments icon. } - &[data-type="engagement"]::before { - content: "\f239"; // chart-area icon. - } - - .stat-content { - display: inline; - vertical-align: top; - } - - .stat-value { - font-weight: 600; - } - .stat-change { color: #646970; @@ -141,7 +119,7 @@ } } -// Chart section - matches .activity-block pattern. +// Chart section. .activitypub-stats-chart { border-top: 1px solid #f0f0f1; padding: 8px 12px 4px; @@ -273,7 +251,7 @@ @media screen and (max-width: 782px) { - .activitypub-stat-item { + .activitypub-stats-highlights li { width: 100%; } } From 8b71328d193b38972c3384ed6eaa55b22d069164 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:31:43 +0100 Subject: [PATCH 22/46] Fix icon appearing on stat-change span --- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- src/dashboard-stats/style.scss | 14 ++++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index cce5c34e1b..7bac4397ba 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before,.activitypub-stats-highlights .activitypub-followers-count>span:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before,.activitypub-stats-highlights .activitypub-posts-count>span:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-like-count>span:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-repost-count>span:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comment-count>span:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index 95538a40d8..29258d00d0 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before,.activitypub-stats-highlights .activitypub-followers-count>span:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before,.activitypub-stats-highlights .activitypub-posts-count>span:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-like-count>span:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-repost-count>span:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comment-count>span:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index d1b838208f..07a2f97ac4 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -53,7 +53,7 @@ // Match #dashboard_right_now li a:before exactly. li a::before, - li > span::before { + li > span:not(.stat-change)::before { content: "\f159"; color: #646970; font: 400 20px/1 dashicons, sans-serif; @@ -67,28 +67,26 @@ } // Icon mappings - same as WordPress dashboard icons. - .activitypub-followers-count a::before, - .activitypub-followers-count > span::before { + .activitypub-followers-count a::before { content: "\f307"; // groups icon. } - .activitypub-posts-count a::before, - .activitypub-posts-count > span::before { + .activitypub-posts-count a::before { content: "\f109"; // admin-post icon. } .activitypub-like-count a::before, - .activitypub-like-count > span::before { + .activitypub-likes-count a::before { content: "\f155"; // star-filled icon. } .activitypub-repost-count a::before, - .activitypub-repost-count > span::before { + .activitypub-reposts-count a::before { content: "\f488"; // share icon. } .activitypub-comment-count a::before, - .activitypub-comment-count > span::before { + .activitypub-comments-count a::before { content: "\f101"; // admin-comments icon. } From 08414e1e67f00f386460072f1ce434ecb05b7157 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:38:29 +0100 Subject: [PATCH 23/46] Change to rolling monthly data and month-over-month comparison - Add get_rolling_monthly_breakdown() for last 12 months across year boundaries - Add get_period_comparison() comparing current vs previous month - Deprecate get_year_comparison() in favor of get_period_comparison() - Add get_month_data() helper to reduce code duplication - Update MonthData type to include optional year field - Update heading to "This month vs. last month" --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +- includes/class-statistics.php | 166 ++++++++++++------ includes/rest/class-statistics-controller.php | 4 +- .../components/stat-highlights/index.tsx | 7 +- src/dashboard-stats/types/index.ts | 1 + 6 files changed, 125 insertions(+), 61 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 1bd8276ef6..55379ad73a 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '9a34d3d4630209c1ddb3'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '9ebbf9ae4545814cc84e'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index eb06c01120..bbf4c97b92 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,u=window.ReactJSXRuntime;function p({comparison:e,commentTypes:t,followerCounts:s,canUseUserActor:a,canUseBlogActor:i}){var n,r;if(!e)return null;const l=[];var o;return a&&null!==s.user&&l.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:s.user,change:null!==(o=e.followers?.change)&&void 0!==o?o:0}),i&&null!==s.blog&&l.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:s.blog,change:0}),l.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(n=e.posts?.current)&&void 0!==n?n:0,change:null!==(r=e.posts?.change)&&void 0!==r?r:0}),t&&Object.entries(t).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&l.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,u.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,u.jsx)("h3",{children:(0,c.__)("This month vs. last year","activitypub")}),(0,u.jsx)("ul",{children:l.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,u.jsxs)(u.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,u.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,u.jsx)("a",{href:t,children:s}):(0,u.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,u.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=h[e%h.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,s)=>40+s/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),p=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=p+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=l[s],o=170-a/r*i;return 0===s?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],b=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{b.push({key:e,label:t.label,color:d(s)})}),(0,u.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,u.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,u.jsxs)("div",{className:"activitypub-chart-container",children:[(0,u.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,u.jsx)("defs",{children:(0,u.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,u.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,u.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,u.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,u.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,u.jsx)("path",{d:v(e),fill:"none",stroke:d(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,u.jsx)("path",{d:p,fill:"none",stroke:s,strokeWidth:"2"}),o.map((e,t)=>(0,u.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),o.map((e,t)=>(0,u.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,u.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,u.jsx)("div",{className:"activitypub-chart-legend",children:b.map(e=>(0,u.jsxs)("div",{className:"activitypub-legend-item",children:[(0,u.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,u.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,u.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,u.jsxs)("p",{children:[(0,u.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function b({posts:e}){return e?.length?(0,u.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,u.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,u.jsx)("ul",{children:e.map(e=>(0,u.jsxs)("li",{children:[(0,u.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,u.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const m="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:s,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:m,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),d=("actor"===t||t===m)&&s&&!!e?.id,g=("blog"===t||t===m)&&i,[x,f]=(0,a.useState)(null),[_,j]=(0,a.useState)({user:null,blog:null}),[w,k]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(h)return;k(!0);const t=n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null),s=d&&e?.id?n()({path:`/activitypub/1.0/stats/${e.id}`}).then(e=>{var t;return null!==(t=e?.comparison?.followers?.current)&&void 0!==t?t:null}).catch(()=>null):Promise.resolve(null),a=g?n()({path:"/activitypub/1.0/stats/0"}).then(e=>{var t;return null!==(t=e?.comparison?.followers?.current)&&void 0!==t?t:null}).catch(()=>null):Promise.resolve(null);Promise.all([t,s,a]).then(([e,t,s])=>{f(e),j({user:t,blog:s})}).finally(()=>k(!1))},[h,d,g,e?.id]),h||w?(0,u.jsx)("div",{className:"activitypub-stats-widget",children:(0,u.jsx)("div",{className:"activitypub-stats-loading",children:(0,u.jsx)(o.Spinner,{})})}):x?(0,u.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,u.jsx)(p,{comparison:x.comparison,commentTypes:x.comment_types,followerCounts:_,canUseUserActor:d,canUseBlogActor:g}),(0,u.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,u.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,u.jsx)(b,{posts:x.stats?.top_posts})]}):(0,u.jsx)("div",{className:"activitypub-stats-widget",children:(0,u.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,u.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(u=0;u=n)&&Object.keys(a.O).every(e=>a.O[e](s[o]))?s.splice(o--,1):(l=!1,n0&&e[u-1][2]>n;u--)e[u]=e[u-1];e[u]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,l,o]=s,c=0;if(r.some(t=>0!==e[t])){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(o)var u=o(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file +(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,u=window.ReactJSXRuntime;function p({comparison:e,commentTypes:t,followerCounts:s,canUseUserActor:a,canUseBlogActor:i}){var n,r;if(!e)return null;const l=[];var o;return a&&null!==s.user&&l.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:s.user,change:null!==(o=e.followers?.change)&&void 0!==o?o:0}),i&&null!==s.blog&&l.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:s.blog,change:0}),l.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(n=e.posts?.current)&&void 0!==n?n:0,change:null!==(r=e.posts?.change)&&void 0!==r?r:0}),t&&Object.entries(t).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&l.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,u.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,u.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,u.jsx)("ul",{children:l.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,u.jsxs)(u.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,u.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,u.jsx)("a",{href:t,children:s}):(0,u.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,u.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=h[e%h.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,s)=>40+s/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),p=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=p+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=l[s],o=170-a/r*i;return 0===s?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{m.push({key:e,label:t.label,color:d(s)})}),(0,u.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,u.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,u.jsxs)("div",{className:"activitypub-chart-container",children:[(0,u.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,u.jsx)("defs",{children:(0,u.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,u.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,u.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,u.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,u.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,u.jsx)("path",{d:v(e),fill:"none",stroke:d(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,u.jsx)("path",{d:p,fill:"none",stroke:s,strokeWidth:"2"}),o.map((e,t)=>(0,u.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),o.map((e,t)=>(0,u.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,u.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,u.jsx)("div",{className:"activitypub-chart-legend",children:m.map(e=>(0,u.jsxs)("div",{className:"activitypub-legend-item",children:[(0,u.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,u.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,u.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,u.jsxs)("p",{children:[(0,u.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function m({posts:e}){return e?.length?(0,u.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,u.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,u.jsx)("ul",{children:e.map(e=>(0,u.jsxs)("li",{children:[(0,u.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,u.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:s,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),d=("actor"===t||t===b)&&s&&!!e?.id,g=("blog"===t||t===b)&&i,[x,f]=(0,a.useState)(null),[_,j]=(0,a.useState)({user:null,blog:null}),[w,k]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(h)return;k(!0);const t=n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null),s=d&&e?.id?n()({path:`/activitypub/1.0/stats/${e.id}`}).then(e=>{var t;return null!==(t=e?.comparison?.followers?.current)&&void 0!==t?t:null}).catch(()=>null):Promise.resolve(null),a=g?n()({path:"/activitypub/1.0/stats/0"}).then(e=>{var t;return null!==(t=e?.comparison?.followers?.current)&&void 0!==t?t:null}).catch(()=>null):Promise.resolve(null);Promise.all([t,s,a]).then(([e,t,s])=>{f(e),j({user:t,blog:s})}).finally(()=>k(!1))},[h,d,g,e?.id]),h||w?(0,u.jsx)("div",{className:"activitypub-stats-widget",children:(0,u.jsx)("div",{className:"activitypub-stats-loading",children:(0,u.jsx)(o.Spinner,{})})}):x?(0,u.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,u.jsx)(p,{comparison:x.comparison,commentTypes:x.comment_types,followerCounts:_,canUseUserActor:d,canUseBlogActor:g}),(0,u.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,u.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,u.jsx)(m,{posts:x.stats?.top_posts})]}):(0,u.jsx)("div",{className:"activitypub-stats-widget",children:(0,u.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,u.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(u=0;u=n)&&Object.keys(a.O).every(e=>a.O[e](s[o]))?s.splice(o--,1):(l=!1,n0&&e[u-1][2]>n;u--)e[u]=e[u-1];e[u]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,l,o]=s,c=0;if(r.some(t=>0!==e[t])){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(o)var u=o(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file diff --git a/includes/class-statistics.php b/includes/class-statistics.php index 3052951f30..8190bccb91 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -686,69 +686,133 @@ public static function get_yearly_monthly_breakdown( $user_id, $year = null ) { $max_month = ( $year === $current_year ) ? $current_month : 12; for ( $month = 1; $month <= $max_month; $month++ ) { - // Check for stored monthly stats first. - $stored_stats = self::get_monthly_stats( $user_id, $year, $month ); + $months[ $month ] = self::get_month_data( $user_id, $year, $month, $comment_types ); + } - if ( $stored_stats ) { - // Use stored data. - $engagement = 0; - foreach ( $comment_types as $type ) { - $engagement += $stored_stats[ $type . '_count' ] ?? 0; - } + return $months; + } - $month_data = array( - 'month' => $month, - 'posts_count' => $stored_stats['posts_count'] ?? 0, - 'engagement' => $engagement, - ); + /** + * Get rolling monthly breakdown (last X months). + * + * Returns stats for the last X months, crossing year boundaries as needed. + * + * @param int $user_id The user ID. + * @param int $num_months Optional. Number of months to return. Defaults to 12. + * + * @return array Array of monthly stats ordered chronologically. + */ + public static function get_rolling_monthly_breakdown( $user_id, $num_months = 12 ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $months = array(); + $comment_types = \array_keys( self::get_comment_types_for_stats() ); - // Add counts for each comment type from stored stats. - foreach ( $comment_types as $type ) { - $month_data[ $type . '_count' ] = $stored_stats[ $type . '_count' ] ?? 0; - } - } else { - // Query live data. - $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); - $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + // Start from (num_months - 1) months ago and go to current month. + for ( $i = $num_months - 1; $i >= 0; $i-- ) { + $timestamp = \strtotime( "-{$i} months", $now ); + $year = (int) \gmdate( 'Y', $timestamp ); + $month = (int) \gmdate( 'n', $timestamp ); - $engagement = self::count_engagement_in_range( $user_id, $start, $end ); + $month_data = self::get_month_data( $user_id, $year, $month, $comment_types ); + $month_data['year'] = $year; + $month_data['month'] = $month; - $month_data = array( - 'month' => $month, - 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), - 'engagement' => $engagement, - ); + $months[] = $month_data; + } - // Add counts for each comment type tracked in stats. - foreach ( $comment_types as $type ) { - $month_data[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); - } + return $months; + } + + /** + * Get data for a single month. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * @param array $comment_types Array of comment type slugs. + * + * @return array Month data with posts_count, engagement, and type counts. + */ + private static function get_month_data( $user_id, $year, $month, $comment_types ) { + // Check for stored monthly stats first. + $stored_stats = self::get_monthly_stats( $user_id, $year, $month ); + + if ( $stored_stats ) { + // Use stored data. + $engagement = 0; + foreach ( $comment_types as $type ) { + $engagement += $stored_stats[ $type . '_count' ] ?? 0; } - $months[ $month ] = $month_data; + $month_data = array( + 'month' => $month, + 'posts_count' => $stored_stats['posts_count'] ?? 0, + 'engagement' => $engagement, + ); + + // Add counts for each comment type from stored stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = $stored_stats[ $type . '_count' ] ?? 0; + } + } else { + // Query live data. + $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); + $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + + $engagement = self::count_engagement_in_range( $user_id, $start, $end ); + + $month_data = array( + 'month' => $month, + 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), + 'engagement' => $engagement, + ); + + // Add counts for each comment type tracked in stats. + foreach ( $comment_types as $type ) { + $month_data[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type ); + } } - return $months; + return $month_data; } /** * Get year-over-year comparison for current month. * - * Uses stored monthly stats if available, otherwise queries live data. + * @deprecated Use get_period_comparison() instead. * * @param int $user_id The user ID. * - * @return array Comparison data with current values and changes from last year. + * @return array Comparison data. */ public static function get_year_comparison( $user_id ) { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + return self::get_period_comparison( $user_id ); + } + + /** + * Get period-over-period comparison (current month vs previous month). + * + * Uses stored monthly stats if available, otherwise queries live data. + * + * @param int $user_id The user ID. + * + * @return array Comparison data with current values and changes from previous month. + */ + public static function get_period_comparison( $user_id ) { + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + + // Current month. $current_year = (int) \gmdate( 'Y', $now ); $current_month = (int) \gmdate( 'n', $now ); - $last_year = $current_year - 1; + + // Previous month (handles year boundary). + $prev_timestamp = \strtotime( '-1 month', $now ); + $prev_year = (int) \gmdate( 'Y', $prev_timestamp ); + $prev_month = (int) \gmdate( 'n', $prev_timestamp ); // Check for stored stats first. - $current_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); - $last_year_stats = self::get_monthly_stats( $user_id, $last_year, $current_month ); + $current_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); + $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); if ( $current_stats ) { // Use stored data. @@ -756,23 +820,23 @@ public static function get_year_comparison( $user_id ) { $current_followers = $current_stats['followers_total'] ?? self::get_follower_count( $user_id ); } else { // Query live data. - $this_year_start = \gmdate( 'Y-m-01 00:00:00', $now ); - $this_year_end = \gmdate( 'Y-m-t 23:59:59', $now ); - $current_posts = self::count_federated_posts_in_range( $user_id, $this_year_start, $this_year_end ); + $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); + $current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end ); $current_followers = self::get_follower_count( $user_id ); } - $last_posts = $last_year_stats ? ( $last_year_stats['posts_count'] ?? 0 ) : 0; - $last_followers = $last_year_stats ? ( $last_year_stats['followers_total'] ?? 0 ) : 0; + $prev_posts = $prev_stats ? ( $prev_stats['posts_count'] ?? 0 ) : 0; + $prev_followers = $prev_stats ? ( $prev_stats['followers_total'] ?? 0 ) : 0; $comparison = array( 'posts' => array( 'current' => $current_posts, - 'change' => $current_posts - $last_posts, + 'change' => $current_posts - $prev_posts, ), 'followers' => array( 'current' => $current_followers, - 'change' => $last_followers > 0 ? $current_followers - $last_followers : 0, + 'change' => $prev_followers > 0 ? $current_followers - $prev_followers : 0, ), ); @@ -780,18 +844,18 @@ public static function get_year_comparison( $user_id ) { $comment_types = Comment::get_comment_type_slugs(); foreach ( $comment_types as $type ) { $current_count = $current_stats ? ( $current_stats[ $type . '_count' ] ?? 0 ) : 0; - $last_count = $last_year_stats ? ( $last_year_stats[ $type . '_count' ] ?? 0 ) : 0; + $prev_count = $prev_stats ? ( $prev_stats[ $type . '_count' ] ?? 0 ) : 0; // If no stored stats, query live data. if ( ! $current_stats ) { - $this_year_start = \gmdate( 'Y-m-01 00:00:00', $now ); - $this_year_end = \gmdate( 'Y-m-t 23:59:59', $now ); - $current_count = self::count_engagement_in_range( $user_id, $this_year_start, $this_year_end, $type ); + $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); + $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type ); } $comparison[ $type ] = array( 'current' => $current_count, - 'change' => $current_count - $last_count, + 'change' => $current_count - $prev_count, ); } diff --git a/includes/rest/class-statistics-controller.php b/includes/rest/class-statistics-controller.php index 6f4785f3e1..20d421d659 100644 --- a/includes/rest/class-statistics-controller.php +++ b/includes/rest/class-statistics-controller.php @@ -96,8 +96,8 @@ public function get_item( $request ) { $user_id = $request->get_param( 'user_id' ); $stats = Statistics::get_current_stats( $user_id, 'month' ); - $comparison = Statistics::get_year_comparison( $user_id ); - $monthly_data = Statistics::get_yearly_monthly_breakdown( $user_id ); + $comparison = Statistics::get_period_comparison( $user_id ); + $monthly_data = Statistics::get_rolling_monthly_breakdown( $user_id ); $comment_types = Statistics::get_comment_types_for_stats(); $response = array( diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index 5d5dbe6d02..9ebd7fbfa1 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -17,11 +17,10 @@ interface Props { /** * Get the admin URL for a stat type. * - * @param {string} type The stat type (followers, posts, etc.). - * @param {boolean} isBlog Whether this is for the blog actor. + * @param {string} type The stat type (followers, posts, etc.). * @return {string|null} The admin URL or null if no link. */ -function getStatUrl( type: string, isBlog: boolean = false ): string | null { +function getStatUrl( type: string ): string | null { switch ( type ) { case 'followers': case 'followers-user': @@ -108,7 +107,7 @@ export default function StatHighlights( { return (
-

{ __( 'This month vs. last year', 'activitypub' ) }

+

{ __( 'This month vs. last month', 'activitypub' ) }

    { stats.map( ( stat ) => { const url = getStatUrl( stat.key ); diff --git a/src/dashboard-stats/types/index.ts b/src/dashboard-stats/types/index.ts index 387bd71192..fb8fbb60ce 100644 --- a/src/dashboard-stats/types/index.ts +++ b/src/dashboard-stats/types/index.ts @@ -12,6 +12,7 @@ export interface Comparison { export interface MonthData { month: number; + year?: number; posts_count: number; engagement: number; // Dynamic keys for engagement type counts (like_count, repost_count, quote_count, etc.) From 161d6527df22be1d9668b928f288ef12cb8e2747 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:42:11 +0100 Subject: [PATCH 24/46] Show follower change instead of absolute count Display "+X Followers" or "-X Followers" to show month-over-month change with positive/negative color styling. Simplify stats-widget by removing separate follower count fetching since comparison data has what we need. --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- .../components/stat-highlights/index.tsx | 62 ++++++++++--------- .../components/stats-widget/index.tsx | 43 ++----------- src/dashboard-stats/style.scss | 11 ++++ 7 files changed, 56 insertions(+), 72 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 55379ad73a..3f23f8b8f2 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '9ebbf9ae4545814cc84e'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '50733000195a023cdc04'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index bbf4c97b92..8b6322fee4 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,u=window.ReactJSXRuntime;function p({comparison:e,commentTypes:t,followerCounts:s,canUseUserActor:a,canUseBlogActor:i}){var n,r;if(!e)return null;const l=[];var o;return a&&null!==s.user&&l.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:s.user,change:null!==(o=e.followers?.change)&&void 0!==o?o:0}),i&&null!==s.blog&&l.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:s.blog,change:0}),l.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(n=e.posts?.current)&&void 0!==n?n:0,change:null!==(r=e.posts?.change)&&void 0!==r?r:0}),t&&Object.entries(t).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&l.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,u.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,u.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,u.jsx)("ul",{children:l.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,u.jsxs)(u.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,u.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,u.jsx)("a",{href:t,children:s}):(0,u.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,u.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=h[e%h.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,s)=>40+s/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),p=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=p+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=l[s],o=170-a/r*i;return 0===s?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{m.push({key:e,label:t.label,color:d(s)})}),(0,u.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,u.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,u.jsxs)("div",{className:"activitypub-chart-container",children:[(0,u.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,u.jsx)("defs",{children:(0,u.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,u.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,u.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,u.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,u.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,u.jsx)("path",{d:v(e),fill:"none",stroke:d(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,u.jsx)("path",{d:p,fill:"none",stroke:s,strokeWidth:"2"}),o.map((e,t)=>(0,u.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),o.map((e,t)=>(0,u.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,u.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,u.jsx)("div",{className:"activitypub-chart-legend",children:m.map(e=>(0,u.jsxs)("div",{className:"activitypub-legend-item",children:[(0,u.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,u.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,u.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,u.jsxs)("p",{children:[(0,u.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function m({posts:e}){return e?.length?(0,u.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,u.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,u.jsx)("ul",{children:e.map(e=>(0,u.jsxs)("li",{children:[(0,u.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,u.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:s,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),d=("actor"===t||t===b)&&s&&!!e?.id,g=("blog"===t||t===b)&&i,[x,f]=(0,a.useState)(null),[_,j]=(0,a.useState)({user:null,blog:null}),[w,k]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(h)return;k(!0);const t=n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null),s=d&&e?.id?n()({path:`/activitypub/1.0/stats/${e.id}`}).then(e=>{var t;return null!==(t=e?.comparison?.followers?.current)&&void 0!==t?t:null}).catch(()=>null):Promise.resolve(null),a=g?n()({path:"/activitypub/1.0/stats/0"}).then(e=>{var t;return null!==(t=e?.comparison?.followers?.current)&&void 0!==t?t:null}).catch(()=>null):Promise.resolve(null);Promise.all([t,s,a]).then(([e,t,s])=>{f(e),j({user:t,blog:s})}).finally(()=>k(!1))},[h,d,g,e?.id]),h||w?(0,u.jsx)("div",{className:"activitypub-stats-widget",children:(0,u.jsx)("div",{className:"activitypub-stats-loading",children:(0,u.jsx)(o.Spinner,{})})}):x?(0,u.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,u.jsx)(p,{comparison:x.comparison,commentTypes:x.comment_types,followerCounts:_,canUseUserActor:d,canUseBlogActor:g}),(0,u.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,u.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,u.jsx)(m,{posts:x.stats?.top_posts})]}):(0,u.jsx)("div",{className:"activitypub-stats-widget",children:(0,u.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,u.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(u=0;u=n)&&Object.keys(a.O).every(e=>a.O[e](s[o]))?s.splice(o--,1):(l=!1,n0&&e[u-1][2]>n;u--)e[u]=e[u-1];e[u]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,l,o]=s,c=0;if(r.some(t=>0!==e[t])){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(o)var u=o(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file +(()=>{"use strict";var e,t={2320(e,t,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,commentTypes:t,canUseUserActor:a,canUseBlogActor:s}){var i,n;if(!e)return null;const r=[];if(a&&e.followers){var l;const t=null!==(l=e.followers.change)&&void 0!==l?l:0;r.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:t,change:t,isChangeOnly:!0})}if(s&&e.followers){var o;const t=null!==(o=e.followers.change)&&void 0!==o?o:0;r.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:t,change:t,isChangeOnly:!0})}return r.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(i=e.posts?.current)&&void 0!==i?i:0,change:null!==(n=e.posts?.change)&&void 0!==n?n:0}),t&&Object.entries(t).forEach(([t,a])=>{const s=e[t];var i,n;s&&"object"==typeof s&&"current"in s&&r.push({key:t,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:r.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),a=e.isChangeOnly?`${e.change>=0?"+":""}${e.change.toLocaleString()}`:e.value.toLocaleString(),s=(0,p.jsxs)(p.Fragment,{children:[a," ",e.label]}),i=e.isChangeOnly&&0!==e.change?" "+(e.change>0?"positive":"negative"):"";return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,className:i.trim(),children:s}):(0,p.jsx)("span",{className:i.trim(),children:s}),!e.isChangeOnly&&0!==e.change&&" ",!e.isChangeOnly&&0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=h[e%h.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,a)=>40+a/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),u=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,a)=>{const s=e[`${t}_count`]||0,n=l[a],o=170-s/r*i;return 0===a?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],g=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return t&&Object.entries(t).forEach(([e,t],a)=>{g.push({key:e,label:t.label,color:d(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-e),x2:580,y2:s+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:d(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),o.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:a},t)),o.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:s+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:g.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function g({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const m="actor_blog";function b(){const{currentUser:e,actorMode:t,hasUserCap:a,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:m,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),d=("actor"===t||t===m)&&a&&!!e?.id,b=("blog"===t||t===m)&&i,[x,f]=(0,s.useState)(null),[_,j]=(0,s.useState)(!0);return(0,s.useEffect)(()=>{h||(j(!0),n()({path:"/activitypub/1.0/stats/0"}).then(e=>f(e)).catch(()=>f(null)).finally(()=>j(!1)))},[h]),h||_?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):x?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:x.comparison,commentTypes:x.comment_types,canUseUserActor:d,canUseBlogActor:b}),(0,p.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,p.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,p.jsx)(g,{posts:x.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,s.createRoot)(t).render((0,p.jsx)(b,{}))}}}},a={};function s(e){var i=a[e];if(void 0!==i)return i.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,s),n.exports}s.m=t,e=[],s.O=(t,a,i,n)=>{if(!a){var r=1/0;for(p=0;p=n)&&Object.keys(s.O).every(e=>s.O[e](a[o]))?a.splice(o--,1):(l=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[a,i,n]},s.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return s.d(t,{a:t}),t},s.d=(e,t)=>{for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};s.O.j=t=>0===e[t];var t=(t,a)=>{var i,n,[r,l,o]=a,c=0;if(r.some(t=>0!==e[t])){for(i in l)s.o(l,i)&&(s.m[i]=l[i]);if(o)var p=o(s)}for(t&&t(a);cs(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index 7bac4397ba..9c5b270f6a 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-highlights li a.positive,.activitypub-stats-highlights li>span.positive{color:#00a32a}.activitypub-stats-highlights li a.negative,.activitypub-stats-highlights li>span.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index 29258d00d0..c7a09231c3 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-highlights li a.positive,.activitypub-stats-highlights li>span.positive{color:#00a32a}.activitypub-stats-highlights li a.negative,.activitypub-stats-highlights li>span.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index 9ebd7fbfa1..6efef9cdc1 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -1,15 +1,9 @@ import { __ } from '@wordpress/i18n'; import type { Comparison, CommentType } from '../../types'; -interface FollowerCounts { - user: number | null; - blog: number | null; -} - interface Props { comparison: Comparison | null; commentTypes: Record< string, CommentType > | null; - followerCounts: FollowerCounts; canUseUserActor: boolean; canUseBlogActor: boolean; } @@ -39,46 +33,45 @@ function getStatUrl( type: string ): string | null { * Stat Highlights Component. * * Displays key statistics with month-over-month comparison. - * Shows follower counts for both user and blog actors if available. + * Shows follower change and engagement stats for available actors. * * @param {Props} props Component props. * @param {Props} props.comparison Comparison data with current vs previous values. * @param {Props} props.commentTypes Available comment types configuration. - * @param {Props} props.followerCounts Follower counts for user and blog. * @param {Props} props.canUseUserActor Whether user actor is available. * @param {Props} props.canUseBlogActor Whether blog actor is available. */ -export default function StatHighlights( { - comparison, - commentTypes, - followerCounts, - canUseUserActor, - canUseBlogActor, -}: Props ) { +export default function StatHighlights( { comparison, commentTypes, canUseUserActor, canUseBlogActor }: Props ) { if ( ! comparison ) { return null; } // Build stats array dynamically. - const stats: Array< { key: string; label: string; value: number; change: number } > = []; + // isChangeOnly: true means the value IS the change (for followers). + const stats: Array< { key: string; label: string; value: number; change: number; isChangeOnly?: boolean } > = []; - // Add user followers if available. - if ( canUseUserActor && followerCounts.user !== null ) { + // Add user followers change if available. + if ( canUseUserActor && comparison.followers ) { + const change = comparison.followers.change ?? 0; stats.push( { key: 'followers-user', label: __( 'Followers', 'activitypub' ), - value: followerCounts.user, - change: comparison.followers?.change ?? 0, + value: change, + change, + isChangeOnly: true, } ); } - // Add blog followers if available. - if ( canUseBlogActor && followerCounts.blog !== null ) { + // Add blog followers change if available. + if ( canUseBlogActor && comparison.followers ) { + // TODO: Track blog followers separately when we have blog-specific comparison. + const change = comparison.followers.change ?? 0; stats.push( { key: 'followers-blog', label: __( 'Followers (Blog)', 'activitypub' ), - value: followerCounts.blog, - change: 0, // Blog followers change tracked separately. + value: change, + change, + isChangeOnly: true, } ); } @@ -111,11 +104,18 @@ export default function StatHighlights( {
      { stats.map( ( stat ) => { const url = getStatUrl( stat.key ); + // For change-only stats (followers), show with +/- prefix. + const displayValue = stat.isChangeOnly + ? `${ stat.change >= 0 ? '+' : '' }${ stat.change.toLocaleString() }` + : stat.value.toLocaleString(); const content = ( <> - { stat.value.toLocaleString() } { stat.label } + { displayValue } { stat.label } ); + // For change-only stats, apply color class to the link/span. + const changeClass = + stat.isChangeOnly && stat.change !== 0 ? ` ${ stat.change > 0 ? 'positive' : 'negative' }` : ''; return (
    • - { url ? { content } : { content } } - { stat.change !== 0 && ' ' } - { stat.change !== 0 && ( + { url ? ( + + { content } + + ) : ( + { content } + ) } + { ! stat.isChangeOnly && stat.change !== 0 && ' ' } + { ! stat.isChangeOnly && stat.change !== 0 && ( 0 ? 'positive' : 'negative' }` }> ({ stat.change > 0 ? '+' : '' } { stat.change.toLocaleString() }) diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index c9685898eb..7ce013af1a 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -18,11 +18,6 @@ const ACTOR_AND_BLOG_MODE = 'actor_blog'; // Blog user ID constant matching PHP. const BLOG_USER_ID = 0; -interface FollowerCounts { - user: number | null; - blog: number | null; -} - /** * Stats Widget Component. * @@ -62,7 +57,6 @@ export default function StatsWidget() { const canUseBlogActor: boolean = blogModeEnabled && hasBlogCap; const [ stats, setStats ] = useState< StatsResponse | null >( null ); - const [ followerCounts, setFollowerCounts ] = useState< FollowerCounts >( { user: null, blog: null } ); const [ isLoading, setIsLoading ] = useState( true ); // Load stats - engagement is global, so we fetch from blog endpoint. @@ -74,39 +68,13 @@ export default function StatsWidget() { setIsLoading( true ); // Fetch global stats (from blog endpoint). - const statsPromise = apiFetch< StatsResponse >( { + apiFetch< StatsResponse >( { path: `/activitypub/1.0/stats/${ BLOG_USER_ID }`, - } ).catch( () => null ); - - // Fetch user follower count if available. - const userFollowersPromise = - canUseUserActor && currentUser?.id - ? apiFetch< StatsResponse >( { - path: `/activitypub/1.0/stats/${ currentUser.id }`, - } ) - .then( ( data ) => data?.comparison?.followers?.current ?? null ) - .catch( () => null ) - : Promise.resolve( null ); - - // Fetch blog follower count if available. - const blogFollowersPromise = canUseBlogActor - ? apiFetch< StatsResponse >( { - path: `/activitypub/1.0/stats/${ BLOG_USER_ID }`, - } ) - .then( ( data ) => data?.comparison?.followers?.current ?? null ) - .catch( () => null ) - : Promise.resolve( null ); - - Promise.all( [ statsPromise, userFollowersPromise, blogFollowersPromise ] ) - .then( ( [ statsData, userFollowers, blogFollowers ] ) => { - setStats( statsData ); - setFollowerCounts( { - user: userFollowers, - blog: blogFollowers, - } ); - } ) + } ) + .then( ( data ) => setStats( data ) ) + .catch( () => setStats( null ) ) .finally( () => setIsLoading( false ) ); - }, [ isResolving, canUseUserActor, canUseBlogActor, currentUser?.id ] ); + }, [ isResolving ] ); // Show loading while resolving user data. if ( isResolving || isLoading ) { @@ -132,7 +100,6 @@ export default function StatsWidget() { diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index 07a2f97ac4..294659b67e 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -101,6 +101,17 @@ color: #d63638; } } + + // Color classes for change-only stats (followers). + li a.positive, + li > span.positive { + color: #00a32a; + } + + li a.negative, + li > span.negative { + color: #d63638; + } } // Sub-section style - matches #dashboard_right_now .sub exactly. From 4264a4ab6af5ed16f5e2bbe7d7a04d5218cb49e4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:43:31 +0100 Subject: [PATCH 25/46] Show followers with absolute count and change in parentheses Display followers the same way as other stats: "10 Followers (-12)" showing the current count with the change from last month in parentheses. --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +-- build/dashboard-stats/style-index-rtl.css | 2 +- build/dashboard-stats/style-index.css | 2 +- .../components/stat-highlights/index.tsx | 40 +++++-------------- src/dashboard-stats/style.scss | 11 ----- 6 files changed, 17 insertions(+), 46 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 3f23f8b8f2..88175da50e 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '50733000195a023cdc04'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '127563950b46f745d4a4'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 8b6322fee4..56043c2cbc 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var e,t={2320(e,t,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,commentTypes:t,canUseUserActor:a,canUseBlogActor:s}){var i,n;if(!e)return null;const r=[];if(a&&e.followers){var l;const t=null!==(l=e.followers.change)&&void 0!==l?l:0;r.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:t,change:t,isChangeOnly:!0})}if(s&&e.followers){var o;const t=null!==(o=e.followers.change)&&void 0!==o?o:0;r.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:t,change:t,isChangeOnly:!0})}return r.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(i=e.posts?.current)&&void 0!==i?i:0,change:null!==(n=e.posts?.change)&&void 0!==n?n:0}),t&&Object.entries(t).forEach(([t,a])=>{const s=e[t];var i,n;s&&"object"==typeof s&&"current"in s&&r.push({key:t,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:r.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),a=e.isChangeOnly?`${e.change>=0?"+":""}${e.change.toLocaleString()}`:e.value.toLocaleString(),s=(0,p.jsxs)(p.Fragment,{children:[a," ",e.label]}),i=e.isChangeOnly&&0!==e.change?" "+(e.change>0?"positive":"negative"):"";return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,className:i.trim(),children:s}):(0,p.jsx)("span",{className:i.trim(),children:s}),!e.isChangeOnly&&0!==e.change&&" ",!e.isChangeOnly&&0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=h[e%h.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const a="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",s=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,a)=>40+a/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),u=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,a)=>{const s=e[`${t}_count`]||0,n=l[a],o=170-s/r*i;return 0===a?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],g=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return t&&Object.entries(t).forEach(([e,t],a)=>{g.push({key:e,label:t.label,color:d(a)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-e),x2:580,y2:s+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:d(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),o.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:a},t)),o.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:s+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:g.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function g({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const m="actor_blog";function b(){const{currentUser:e,actorMode:t,hasUserCap:a,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:m,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),d=("actor"===t||t===m)&&a&&!!e?.id,b=("blog"===t||t===m)&&i,[x,f]=(0,s.useState)(null),[_,j]=(0,s.useState)(!0);return(0,s.useEffect)(()=>{h||(j(!0),n()({path:"/activitypub/1.0/stats/0"}).then(e=>f(e)).catch(()=>f(null)).finally(()=>j(!1)))},[h]),h||_?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):x?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:x.comparison,commentTypes:x.comment_types,canUseUserActor:d,canUseBlogActor:b}),(0,p.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,p.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,p.jsx)(g,{posts:x.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,s.createRoot)(t).render((0,p.jsx)(b,{}))}}}},a={};function s(e){var i=a[e];if(void 0!==i)return i.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,s),n.exports}s.m=t,e=[],s.O=(t,a,i,n)=>{if(!a){var r=1/0;for(p=0;p=n)&&Object.keys(s.O).every(e=>s.O[e](a[o]))?a.splice(o--,1):(l=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[a,i,n]},s.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return s.d(t,{a:t}),t},s.d=(e,t)=>{for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};s.O.j=t=>0===e[t];var t=(t,a)=>{var i,n,[r,l,o]=a,c=0;if(r.some(t=>0!==e[t])){for(i in l)s.o(l,i)&&(s.m[i]=l[i]);if(o)var p=o(s)}for(t&&t(a);cs(2320));i=s.O(i)})(); \ No newline at end of file +(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,o=window.wp.coreData,l=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,commentTypes:t,canUseUserActor:s,canUseBlogActor:a}){var i,n;if(!e)return null;const r=[];var o,l,u,d;return s&&e.followers&&r.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:null!==(o=e.followers.current)&&void 0!==o?o:0,change:null!==(l=e.followers.change)&&void 0!==l?l:0}),a&&e.followers&&r.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:null!==(u=e.followers.current)&&void 0!==u?u:0,change:null!==(d=e.followers.change)&&void 0!==d?d:0}),r.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(i=e.posts?.current)&&void 0!==i?i:0,change:null!==(n=e.posts?.change)&&void 0!==n?n:0}),t&&Object.entries(t).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&r.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:r.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:s}):(0,p.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function h(e){const t=d[e%d.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),o=e.map((t,s)=>40+s/(e.length-1||1)*540),l=e.map((e,t)=>({x:o[t],y:170-(e.engagement||0)/r*i,month:e})),u=l.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),d=u+` L ${l[l.length-1].x} 170`+` L ${l[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=o[s],l=170-a/r*i;return 0===s?`M ${n} ${l}`:`L ${n} ${l}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{m.push({key:e,label:t.label,color:h(s)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:h(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:s,strokeWidth:"2"}),l.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),l.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function m({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:s,hasBlogCap:i,isResolving:d}=(0,r.useSelect)(e=>{var t;return{currentUser:e(o.store).getCurrentUser(),actorMode:null!==(t=e(o.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(o.store).isResolving("getCurrentUser",[])}},[]),h=("actor"===t||t===b)&&s&&!!e?.id,g=("blog"===t||t===b)&&i,[x,f]=(0,a.useState)(null),[_,j]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{d||(j(!0),n()({path:"/activitypub/1.0/stats/0"}).then(e=>f(e)).catch(()=>f(null)).finally(()=>j(!1)))},[d]),d||_?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(l.Spinner,{})})}):x?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:x.comparison,commentTypes:x.comment_types,canUseUserActor:h,canUseBlogActor:g}),(0,p.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,p.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:x.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,p.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(p=0;p=n)&&Object.keys(a.O).every(e=>a.O[e](s[l]))?s.splice(l--,1):(o=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,o,l]=s,c=0;if(r.some(t=>0!==e[t])){for(i in o)a.o(o,i)&&(a.m[i]=o[i]);if(l)var p=l(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file diff --git a/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css index 9c5b270f6a..7bac4397ba 100644 --- a/build/dashboard-stats/style-index-rtl.css +++ b/build/dashboard-stats/style-index-rtl.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-highlights li a.positive,.activitypub-stats-highlights li>span.positive{color:#00a32a}.activitypub-stats-highlights li a.negative,.activitypub-stats-highlights li>span.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-right:0;margin-left:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:right;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 0 0 5px;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-right:0;margin-left:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-right:0;margin-left:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-left:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-right:0;margin-left:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-right:0;margin-left:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-left:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css index c7a09231c3..29258d00d0 100644 --- a/build/dashboard-stats/style-index.css +++ b/build/dashboard-stats/style-index.css @@ -1 +1 @@ -.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-highlights li a.positive,.activitypub-stats-highlights li>span.positive{color:#00a32a}.activitypub-stats-highlights li a.negative,.activitypub-stats-highlights li>span.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} +.activitypub-stats-widget{margin:-6px -12px -12px}.activitypub-stats-widget h3{color:#1d2327;font-size:14px;font-weight:400;margin:0 12px 8px;padding:0}.activitypub-stats-loading{display:flex;justify-content:center;padding:24px 12px}.activitypub-stats-empty{color:#646970;margin:0;padding:24px 12px;text-align:center}.activitypub-stats-highlights{padding:0 12px 11px}.activitypub-stats-highlights h3{margin-left:0;margin-right:0}.activitypub-stats-highlights ul{display:inline-block;margin:0;width:100%}.activitypub-stats-highlights li{float:left;margin-bottom:10px;width:50%}.activitypub-stats-highlights li a:before,.activitypub-stats-highlights li>span:not(.stat-change):before{color:#646970;content:"";display:inline-block;font:400 20px/1 dashicons,sans-serif;padding:0 5px 0 0;position:relative;vertical-align:top;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-decoration:none}.activitypub-stats-highlights .activitypub-followers-count a:before{content:""}.activitypub-stats-highlights .activitypub-posts-count a:before{content:""}.activitypub-stats-highlights .activitypub-like-count a:before,.activitypub-stats-highlights .activitypub-likes-count a:before{content:""}.activitypub-stats-highlights .activitypub-repost-count a:before,.activitypub-stats-highlights .activitypub-reposts-count a:before{content:""}.activitypub-stats-highlights .activitypub-comment-count a:before,.activitypub-stats-highlights .activitypub-comments-count a:before{content:""}.activitypub-stats-highlights .stat-change{color:#646970}.activitypub-stats-highlights .stat-change.positive{color:#00a32a}.activitypub-stats-highlights .stat-change.negative{color:#d63638}.activitypub-stats-sub{background:#f6f7f7;border-top:1px solid #f0f0f1;color:#50575e;padding:10px 12px 6px}.activitypub-stats-sub h3{color:#50575e;margin-left:0;margin-right:0}.activitypub-stats-chart{border-top:1px solid #f0f0f1;padding:8px 12px 4px}.activitypub-stats-chart h3{margin-left:0;margin-right:0}.activitypub-chart-container{background:#f6f7f7;border-bottom:1px solid #f0f0f1;margin:0 -12px 6px;padding:8px 12px}.activitypub-line-chart{display:block;height:auto;width:100%}.activitypub-line-chart .chart-label{fill:#646970;font-size:10px}.activitypub-chart-legend{border-top:1px solid #f0f0f1;display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-top:8px}.activitypub-legend-item{align-items:center;color:#50575e;display:flex;font-size:11px}.activitypub-legend-item .legend-color{border-radius:1px;height:3px;margin-right:6px;width:12px}.activitypub-stats-multiplicator{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-multiplicator h3{margin-bottom:4px;margin-left:0;margin-right:0}.activitypub-stats-multiplicator p{color:#50575e;margin:0}.activitypub-stats-multiplicator a{color:#2271b1;font-weight:600;text-decoration:none}.activitypub-stats-multiplicator a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts{border-top:1px solid #f0f0f1;padding:12px}.activitypub-stats-top-posts h3{margin-left:0;margin-right:0}.activitypub-stats-top-posts ul{list-style:none;margin:0;padding:0}.activitypub-stats-top-posts li{align-items:center;color:#50575e;display:flex;justify-content:space-between;padding:4px 0}.activitypub-stats-top-posts a{color:#2271b1;flex:1;margin-right:10px;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}.activitypub-stats-top-posts a:hover{color:#135e96;text-decoration:underline}.activitypub-stats-top-posts .engagement-count{color:#646970;font-size:12px;white-space:nowrap}@media screen and (max-width:782px){.activitypub-stats-highlights li{width:100%}} diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index 6efef9cdc1..81030b32d9 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -47,31 +47,26 @@ export default function StatHighlights( { comparison, commentTypes, canUseUserAc } // Build stats array dynamically. - // isChangeOnly: true means the value IS the change (for followers). - const stats: Array< { key: string; label: string; value: number; change: number; isChangeOnly?: boolean } > = []; + const stats: Array< { key: string; label: string; value: number; change: number } > = []; - // Add user followers change if available. + // Add user followers if available. if ( canUseUserActor && comparison.followers ) { - const change = comparison.followers.change ?? 0; stats.push( { key: 'followers-user', label: __( 'Followers', 'activitypub' ), - value: change, - change, - isChangeOnly: true, + value: comparison.followers.current ?? 0, + change: comparison.followers.change ?? 0, } ); } - // Add blog followers change if available. + // Add blog followers if available. if ( canUseBlogActor && comparison.followers ) { // TODO: Track blog followers separately when we have blog-specific comparison. - const change = comparison.followers.change ?? 0; stats.push( { key: 'followers-blog', label: __( 'Followers (Blog)', 'activitypub' ), - value: change, - change, - isChangeOnly: true, + value: comparison.followers.current ?? 0, + change: comparison.followers.change ?? 0, } ); } @@ -104,18 +99,11 @@ export default function StatHighlights( { comparison, commentTypes, canUseUserAc
        { stats.map( ( stat ) => { const url = getStatUrl( stat.key ); - // For change-only stats (followers), show with +/- prefix. - const displayValue = stat.isChangeOnly - ? `${ stat.change >= 0 ? '+' : '' }${ stat.change.toLocaleString() }` - : stat.value.toLocaleString(); const content = ( <> - { displayValue } { stat.label } + { stat.value.toLocaleString() } { stat.label } ); - // For change-only stats, apply color class to the link/span. - const changeClass = - stat.isChangeOnly && stat.change !== 0 ? ` ${ stat.change > 0 ? 'positive' : 'negative' }` : ''; return (
      • - { url ? ( - - { content } - - ) : ( - { content } - ) } - { ! stat.isChangeOnly && stat.change !== 0 && ' ' } - { ! stat.isChangeOnly && stat.change !== 0 && ( + { url ? { content } : { content } } + { stat.change !== 0 && ' ' } + { stat.change !== 0 && ( 0 ? 'positive' : 'negative' }` }> ({ stat.change > 0 ? '+' : '' } { stat.change.toLocaleString() }) diff --git a/src/dashboard-stats/style.scss b/src/dashboard-stats/style.scss index 294659b67e..07a2f97ac4 100644 --- a/src/dashboard-stats/style.scss +++ b/src/dashboard-stats/style.scss @@ -101,17 +101,6 @@ color: #d63638; } } - - // Color classes for change-only stats (followers). - li a.positive, - li > span.positive { - color: #00a32a; - } - - li a.negative, - li > span.negative { - color: #d63638; - } } // Sub-section style - matches #dashboard_right_now .sub exactly. From 3d7e698fad3eb49ccc8a8593f3a39305458726bd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 16:50:37 +0100 Subject: [PATCH 26/46] Show new followers per month with separate user/blog counts - Add Followers::count_in_range() to count followers gained in a date range - Update Statistics to count new followers per month (not total) - Fetch separate stats for user and blog actors - Compare current month with previous month for all stats --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +- includes/class-statistics.php | 62 +++++++++---------- includes/collection/class-followers.php | 28 +++++++++ .../components/stat-highlights/index.tsx | 34 +++++----- .../components/stats-widget/index.tsx | 31 +++++++--- 6 files changed, 106 insertions(+), 57 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 88175da50e..f170492538 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '127563950b46f745d4a4'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'fea8e25386f405663022'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 56043c2cbc..9ca0c14b6b 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,o=window.wp.coreData,l=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,commentTypes:t,canUseUserActor:s,canUseBlogActor:a}){var i,n;if(!e)return null;const r=[];var o,l,u,d;return s&&e.followers&&r.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:null!==(o=e.followers.current)&&void 0!==o?o:0,change:null!==(l=e.followers.change)&&void 0!==l?l:0}),a&&e.followers&&r.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:null!==(u=e.followers.current)&&void 0!==u?u:0,change:null!==(d=e.followers.change)&&void 0!==d?d:0}),r.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(i=e.posts?.current)&&void 0!==i?i:0,change:null!==(n=e.posts?.change)&&void 0!==n?n:0}),t&&Object.entries(t).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&r.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:r.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:s}):(0,p.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function h(e){const t=d[e%d.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),o=e.map((t,s)=>40+s/(e.length-1||1)*540),l=e.map((e,t)=>({x:o[t],y:170-(e.engagement||0)/r*i,month:e})),u=l.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),d=u+` L ${l[l.length-1].x} 170`+` L ${l[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=o[s],l=170-a/r*i;return 0===s?`M ${n} ${l}`:`L ${n} ${l}`}).join(" "),y=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{m.push({key:e,label:t.label,color:h(s)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:h(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:s,strokeWidth:"2"}),l.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),l.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:y[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function y({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function m({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:s,hasBlogCap:i,isResolving:d}=(0,r.useSelect)(e=>{var t;return{currentUser:e(o.store).getCurrentUser(),actorMode:null!==(t=e(o.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(o.store).isResolving("getCurrentUser",[])}},[]),h=("actor"===t||t===b)&&s&&!!e?.id,g=("blog"===t||t===b)&&i,[x,f]=(0,a.useState)(null),[_,j]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{d||(j(!0),n()({path:"/activitypub/1.0/stats/0"}).then(e=>f(e)).catch(()=>f(null)).finally(()=>j(!1)))},[d]),d||_?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(l.Spinner,{})})}):x?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:x.comparison,commentTypes:x.comment_types,canUseUserActor:h,canUseBlogActor:g}),(0,p.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,p.jsx)(y,{multiplicator:x.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:x.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,p.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(p=0;p=n)&&Object.keys(a.O).every(e=>a.O[e](s[l]))?s.splice(l--,1):(o=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,o,l]=s,c=0;if(r.some(t=>0!==e[t])){for(i in o)a.o(o,i)&&(a.m[i]=o[i]);if(l)var p=l(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file +(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,o=window.wp.coreData,l=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:s,commentTypes:a,canUseUserActor:i,canUseBlogActor:n}){var r,o;if(!e)return null;const l=[];var u,d,h,v;return i&&t?.followers&&l.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:null!==(u=t.followers.current)&&void 0!==u?u:0,change:null!==(d=t.followers.change)&&void 0!==d?d:0}),n&&s?.followers&&l.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:null!==(h=s.followers.current)&&void 0!==h?h:0,change:null!==(v=s.followers.change)&&void 0!==v?v:0}),l.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(r=e.posts?.current)&&void 0!==r?r:0,change:null!==(o=e.posts?.change)&&void 0!==o?o:0}),a&&Object.entries(a).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&l.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:l.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:s}):(0,p.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function h(e){const t=d[e%d.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),o=e.map((t,s)=>40+s/(e.length-1||1)*540),l=e.map((e,t)=>({x:o[t],y:170-(e.engagement||0)/r*i,month:e})),u=l.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),d=u+` L ${l[l.length-1].x} 170`+` L ${l[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=o[s],l=170-a/r*i;return 0===s?`M ${n} ${l}`:`L ${n} ${l}`}).join(" "),m=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],y=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{y.push({key:e,label:t.label,color:h(s)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:h(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:s,strokeWidth:"2"}),l.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),l.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:m[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:y.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function m({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function y({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){var e,t;const{currentUser:s,actorMode:i,hasUserCap:d,hasBlogCap:h,isResolving:g}=(0,r.useSelect)(e=>{var t;return{currentUser:e(o.store).getCurrentUser(),actorMode:null!==(t=e(o.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(o.store).isResolving("getCurrentUser",[])}},[]),x=("actor"===i||i===b)&&d&&!!s?.id,f=("blog"===i||i===b)&&h,[_,j]=(0,a.useState)(null),[w,k]=(0,a.useState)(null),[N,O]=(0,a.useState)(null),[$,C]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(g)return;C(!0);const e=n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null),t=x&&s?.id?n()({path:`/activitypub/1.0/stats/${s.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([e,t]).then(([e,t])=>{j(e),O(e),k(t)}).finally(()=>C(!1))},[g,x,s?.id]),g||$?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(l.Spinner,{})})}):_?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:_.comparison,userComparison:null!==(e=w?.comparison)&&void 0!==e?e:null,blogComparison:null!==(t=N?.comparison)&&void 0!==t?t:null,commentTypes:_.comment_types,canUseUserActor:x,canUseBlogActor:f}),(0,p.jsx)(v,{monthly:_.monthly,commentTypes:_.comment_types}),(0,p.jsx)(m,{multiplicator:_.stats?.top_multiplicator}),(0,p.jsx)(y,{posts:_.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,p.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(p=0;p=n)&&Object.keys(a.O).every(e=>a.O[e](s[l]))?s.splice(l--,1):(o=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,o,l]=s,c=0;if(r.some(t=>0!==e[t])){for(i in o)a.o(o,i)&&(a.m[i]=o[i]);if(l)var p=l(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file diff --git a/includes/class-statistics.php b/includes/class-statistics.php index 8190bccb91..fde4296389 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -142,22 +142,13 @@ public static function collect_monthly_stats( $user_id, $year, $month ) { $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); - // Get previous month's follower count for comparison. - $prev_month = $month - 1; - $prev_year = $year; - if ( $prev_month < 1 ) { - $prev_month = 12; - --$prev_year; - } - $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); - $prev_followers = $prev_stats ? $prev_stats['followers_total'] : 0; - $current_followers = self::get_follower_count( $user_id ); + // Count new followers gained this month (by post_date in followers table). + $followers_count = Followers::count_in_range( $user_id, $start, $end ); $stats = array( 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ), - 'followers_gained' => \max( 0, $current_followers - $prev_followers ), - 'followers_lost' => \max( 0, $prev_followers - $current_followers ), - 'followers_total' => $current_followers, + 'followers_count' => $followers_count, + 'followers_total' => self::get_follower_count( $user_id ), 'top_posts' => self::get_top_posts( $user_id, $start, $end, 5 ), 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ), 'collected_at' => \gmdate( 'Y-m-d H:i:s' ), @@ -801,33 +792,40 @@ public static function get_year_comparison( $user_id ) { public static function get_period_comparison( $user_id ) { $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - // Current month. + // Current month date range. $current_year = (int) \gmdate( 'Y', $now ); $current_month = (int) \gmdate( 'n', $now ); + $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); + $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); // Previous month (handles year boundary). $prev_timestamp = \strtotime( '-1 month', $now ); $prev_year = (int) \gmdate( 'Y', $prev_timestamp ); $prev_month = (int) \gmdate( 'n', $prev_timestamp ); + $prev_start = \gmdate( 'Y-m-01 00:00:00', $prev_timestamp ); + $prev_end = \gmdate( 'Y-m-t 23:59:59', $prev_timestamp ); - // Check for stored stats first. + // Check for stored stats. $current_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); + // Get current month data (from stored stats or live query). if ( $current_stats ) { - // Use stored data. $current_posts = $current_stats['posts_count'] ?? 0; - $current_followers = $current_stats['followers_total'] ?? self::get_follower_count( $user_id ); + $current_followers = $current_stats['followers_count'] ?? Followers::count_in_range( $user_id, $current_start, $current_end ); } else { - // Query live data. - $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); - $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); $current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end ); - $current_followers = self::get_follower_count( $user_id ); + $current_followers = Followers::count_in_range( $user_id, $current_start, $current_end ); } - $prev_posts = $prev_stats ? ( $prev_stats['posts_count'] ?? 0 ) : 0; - $prev_followers = $prev_stats ? ( $prev_stats['followers_total'] ?? 0 ) : 0; + // Get previous month data (from stored stats or live query). + if ( $prev_stats ) { + $prev_posts = $prev_stats['posts_count'] ?? 0; + $prev_followers = $prev_stats['followers_count'] ?? 0; + } else { + $prev_posts = self::count_federated_posts_in_range( $user_id, $prev_start, $prev_end ); + $prev_followers = Followers::count_in_range( $user_id, $prev_start, $prev_end ); + } $comparison = array( 'posts' => array( @@ -836,23 +834,25 @@ public static function get_period_comparison( $user_id ) { ), 'followers' => array( 'current' => $current_followers, - 'change' => $prev_followers > 0 ? $current_followers - $prev_followers : 0, + 'change' => $current_followers - $prev_followers, ), ); // Add comparison for each registered comment type dynamically. $comment_types = Comment::get_comment_type_slugs(); foreach ( $comment_types as $type ) { - $current_count = $current_stats ? ( $current_stats[ $type . '_count' ] ?? 0 ) : 0; - $prev_count = $prev_stats ? ( $prev_stats[ $type . '_count' ] ?? 0 ) : 0; - - // If no stored stats, query live data. - if ( ! $current_stats ) { - $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); - $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); + if ( $current_stats ) { + $current_count = $current_stats[ $type . '_count' ] ?? 0; + } else { $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type ); } + if ( $prev_stats ) { + $prev_count = $prev_stats[ $type . '_count' ] ?? 0; + } else { + $prev_count = self::count_engagement_in_range( $user_id, $prev_start, $prev_end, $type ); + } + $comparison[ $type ] = array( 'current' => $current_count, 'change' => $current_count - $prev_count, diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index bcad4a0ab0..d1eee117f3 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -313,6 +313,34 @@ public static function count( $user_id ) { return self::query( $user_id, 1 )['total']; } + /** + * Count followers gained in a date range. + * + * @param int $user_id The ID of the WordPress User. + * @param string $start Start date (Y-m-d H:i:s). + * @param string $end End date (Y-m-d H:i:s). + * + * @return int The number of new followers in the date range. + */ + public static function count_in_range( $user_id, $start, $end ) { + $result = self::query( + $user_id, + 1, // We only need the count. + null, + array( + 'date_query' => array( + array( + 'after' => $start, + 'before' => $end, + 'inclusive' => true, + ), + ), + ) + ); + + return $result['total']; + } + /** * Count the total number of followers. * diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index 81030b32d9..c6dac14195 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -3,6 +3,8 @@ import type { Comparison, CommentType } from '../../types'; interface Props { comparison: Comparison | null; + userComparison: Comparison | null; + blogComparison: Comparison | null; commentTypes: Record< string, CommentType > | null; canUseUserActor: boolean; canUseBlogActor: boolean; @@ -35,13 +37,16 @@ function getStatUrl( type: string ): string | null { * Displays key statistics with month-over-month comparison. * Shows follower change and engagement stats for available actors. * - * @param {Props} props Component props. - * @param {Props} props.comparison Comparison data with current vs previous values. - * @param {Props} props.commentTypes Available comment types configuration. - * @param {Props} props.canUseUserActor Whether user actor is available. - * @param {Props} props.canUseBlogActor Whether blog actor is available. + * @param {Props} props Component props. */ -export default function StatHighlights( { comparison, commentTypes, canUseUserActor, canUseBlogActor }: Props ) { +export default function StatHighlights( { + comparison, + userComparison, + blogComparison, + commentTypes, + canUseUserActor, + canUseBlogActor, +}: Props ) { if ( ! comparison ) { return null; } @@ -49,24 +54,23 @@ export default function StatHighlights( { comparison, commentTypes, canUseUserAc // Build stats array dynamically. const stats: Array< { key: string; label: string; value: number; change: number } > = []; - // Add user followers if available. - if ( canUseUserActor && comparison.followers ) { + // Add user followers if available (from user-specific stats). + if ( canUseUserActor && userComparison?.followers ) { stats.push( { key: 'followers-user', label: __( 'Followers', 'activitypub' ), - value: comparison.followers.current ?? 0, - change: comparison.followers.change ?? 0, + value: userComparison.followers.current ?? 0, + change: userComparison.followers.change ?? 0, } ); } - // Add blog followers if available. - if ( canUseBlogActor && comparison.followers ) { - // TODO: Track blog followers separately when we have blog-specific comparison. + // Add blog followers if available (from blog-specific stats). + if ( canUseBlogActor && blogComparison?.followers ) { stats.push( { key: 'followers-blog', label: __( 'Followers (Blog)', 'activitypub' ), - value: comparison.followers.current ?? 0, - change: comparison.followers.change ?? 0, + value: blogComparison.followers.current ?? 0, + change: blogComparison.followers.change ?? 0, } ); } diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index 7ce013af1a..f2d2d5219f 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -57,9 +57,11 @@ export default function StatsWidget() { const canUseBlogActor: boolean = blogModeEnabled && hasBlogCap; const [ stats, setStats ] = useState< StatsResponse | null >( null ); + const [ userStats, setUserStats ] = useState< StatsResponse | null >( null ); + const [ blogStats, setBlogStats ] = useState< StatsResponse | null >( null ); const [ isLoading, setIsLoading ] = useState( true ); - // Load stats - engagement is global, so we fetch from blog endpoint. + // Load stats for blog (global engagement) and separate follower stats per actor. useEffect( () => { if ( isResolving ) { return; @@ -67,14 +69,27 @@ export default function StatsWidget() { setIsLoading( true ); - // Fetch global stats (from blog endpoint). - apiFetch< StatsResponse >( { + // Fetch blog stats (global engagement data). + const blogStatsPromise = apiFetch< StatsResponse >( { path: `/activitypub/1.0/stats/${ BLOG_USER_ID }`, - } ) - .then( ( data ) => setStats( data ) ) - .catch( () => setStats( null ) ) + } ).catch( () => null ); + + // Fetch user-specific stats if user actor is available. + const userStatsPromise = + canUseUserActor && currentUser?.id + ? apiFetch< StatsResponse >( { + path: `/activitypub/1.0/stats/${ currentUser.id }`, + } ).catch( () => null ) + : Promise.resolve( null ); + + Promise.all( [ blogStatsPromise, userStatsPromise ] ) + .then( ( [ blogData, userData ] ) => { + setStats( blogData ); + setBlogStats( blogData ); + setUserStats( userData ); + } ) .finally( () => setIsLoading( false ) ); - }, [ isResolving ] ); + }, [ isResolving, canUseUserActor, currentUser?.id ] ); // Show loading while resolving user data. if ( isResolving || isLoading ) { @@ -99,6 +114,8 @@ export default function StatsWidget() {
        Date: Tue, 27 Jan 2026 16:56:58 +0100 Subject: [PATCH 27/46] Always query live data for current month statistics - Current month stats are now always queried live to include recent engagement (likes, reposts, comments) - Backfill migration now skips the current month since it's in progress - Previous months use stored stats for performance - Add delete_monthly_stats() and recollect_monthly_stats() methods - Add force option to trigger_monthly_collection() scheduler method This fixes an issue where new reposts wouldn't show in stats because the current month data was being served from cached options. --- includes/class-statistics.php | 104 +++++++++++++----------- includes/scheduler/class-statistics.php | 9 +- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/includes/class-statistics.php b/includes/class-statistics.php index fde4296389..badd1caa3e 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -129,6 +129,37 @@ public static function save_annual_summary( $user_id, $year, $stats ) { return \update_option( self::get_annual_option_name( $user_id, $year ), $stats, false ); } + /** + * Delete monthly statistics for a user. + * + * Useful for recollecting stale data or clearing incorrect entries. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * + * @return bool True on success, false on failure. + */ + public static function delete_monthly_stats( $user_id, $year, $month ) { + return \delete_option( self::get_monthly_option_name( $user_id, $year, $month ) ); + } + + /** + * Recollect monthly statistics for a user. + * + * Deletes existing stats and collects fresh data. + * + * @param int $user_id The user ID. + * @param int $year The year. + * @param int $month The month. + * + * @return array The newly collected stats. + */ + public static function recollect_monthly_stats( $user_id, $year, $month ) { + self::delete_monthly_stats( $user_id, $year, $month ); + return self::collect_monthly_stats( $user_id, $year, $month ); + } + /** * Collect monthly statistics for a user. * @@ -586,7 +617,7 @@ public static function get_active_user_ids() { /** * Get statistics for the current period. * - * Uses stored monthly stats if available, otherwise queries live data. + * Always queries live data for the current period to include recent engagement. * * @param int $user_id The user ID. * @param string $period The period ('month', 'year', 'all'). @@ -594,26 +625,7 @@ public static function get_active_user_ids() { * @return array The statistics. */ public static function get_current_stats( $user_id, $period = 'month' ) { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $current_year = (int) \gmdate( 'Y', $now ); - $current_month = (int) \gmdate( 'n', $now ); - - // For monthly period, check for stored stats first. - if ( 'month' === $period ) { - $stored_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); - - if ( $stored_stats ) { - return array( - 'posts_count' => $stored_stats['posts_count'] ?? 0, - 'followers_total' => $stored_stats['followers_total'] ?? self::get_follower_count( $user_id ), - 'top_posts' => $stored_stats['top_posts'] ?? array(), - 'top_multiplicator' => $stored_stats['top_multiplicator'] ?? null, - 'period' => $period, - 'start' => \gmdate( 'Y-m-01 00:00:00', $now ), - 'end' => \gmdate( 'Y-m-t 23:59:59', $now ), - ); - } - } + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested switch ( $period ) { case 'year': @@ -725,8 +737,15 @@ public static function get_rolling_monthly_breakdown( $user_id, $num_months = 12 * @return array Month data with posts_count, engagement, and type counts. */ private static function get_month_data( $user_id, $year, $month, $comment_types ) { - // Check for stored monthly stats first. - $stored_stats = self::get_monthly_stats( $user_id, $year, $month ); + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $current_year = (int) \gmdate( 'Y', $now ); + $current_month = (int) \gmdate( 'n', $now ); + + // Always query live for the current month to include recent engagement. + $is_current_month = ( $year === $current_year && $month === $current_month ); + + // Check for stored monthly stats first (but not for current month). + $stored_stats = $is_current_month ? false : self::get_monthly_stats( $user_id, $year, $month ); if ( $stored_stats ) { // Use stored data. @@ -783,7 +802,7 @@ public static function get_year_comparison( $user_id ) { /** * Get period-over-period comparison (current month vs previous month). * - * Uses stored monthly stats if available, otherwise queries live data. + * Always queries live data for current month, uses stored stats for previous month. * * @param int $user_id The user ID. * @@ -793,8 +812,6 @@ public static function get_period_comparison( $user_id ) { $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested // Current month date range. - $current_year = (int) \gmdate( 'Y', $now ); - $current_month = (int) \gmdate( 'n', $now ); $current_start = \gmdate( 'Y-m-01 00:00:00', $now ); $current_end = \gmdate( 'Y-m-t 23:59:59', $now ); @@ -805,18 +822,12 @@ public static function get_period_comparison( $user_id ) { $prev_start = \gmdate( 'Y-m-01 00:00:00', $prev_timestamp ); $prev_end = \gmdate( 'Y-m-t 23:59:59', $prev_timestamp ); - // Check for stored stats. - $current_stats = self::get_monthly_stats( $user_id, $current_year, $current_month ); - $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); + // Check for stored stats (only for previous month - current month is always live). + $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month ); - // Get current month data (from stored stats or live query). - if ( $current_stats ) { - $current_posts = $current_stats['posts_count'] ?? 0; - $current_followers = $current_stats['followers_count'] ?? Followers::count_in_range( $user_id, $current_start, $current_end ); - } else { - $current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end ); - $current_followers = Followers::count_in_range( $user_id, $current_start, $current_end ); - } + // Always query live for current month to include recent engagement. + $current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end ); + $current_followers = Followers::count_in_range( $user_id, $current_start, $current_end ); // Get previous month data (from stored stats or live query). if ( $prev_stats ) { @@ -841,12 +852,10 @@ public static function get_period_comparison( $user_id ) { // Add comparison for each registered comment type dynamically. $comment_types = Comment::get_comment_type_slugs(); foreach ( $comment_types as $type ) { - if ( $current_stats ) { - $current_count = $current_stats[ $type . '_count' ] ?? 0; - } else { - $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type ); - } + // Always query live for current month. + $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type ); + // Use stored stats for previous month if available. if ( $prev_stats ) { $prev_count = $prev_stats[ $type . '_count' ] ?? 0; } else { @@ -897,6 +906,7 @@ public static function get_comment_types_for_stats() { * Backfill historical statistics for all active users. * * This method processes statistics in batches to avoid timeouts. + * It only collects stats for completed months (not the current month). * * @param int $batch_size Optional. Number of months to process per batch. Default 12. * @param int $user_index Optional. The current user index being processed. Default 0. @@ -913,8 +923,9 @@ public static function backfill_historical_stats( $batch_size = 12, $user_index } $user_id = $user_ids[ $user_index ]; - $current_year = (int) \gmdate( 'Y' ); - $current_month = (int) \gmdate( 'n' ); + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $current_year = (int) \gmdate( 'Y', $now ); + $current_month = (int) \gmdate( 'n', $now ); // Determine the earliest year with data if not set. if ( 0 === $year ) { @@ -934,8 +945,9 @@ public static function backfill_historical_stats( $batch_size = 12, $user_index // Process months for this user. while ( $months_processed < $batch_size ) { - // Check if we've gone past the current month. - if ( $year > $current_year || ( $year === $current_year && $month > $current_month ) ) { + // Skip the current month - it's still in progress and should always be queried live. + // Only process completed months (before the current month). + if ( $year > $current_year || ( $year === $current_year && $month >= $current_month ) ) { // Move to next user. return array( 'batch_size' => $batch_size, diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 0ab5ea30ad..867c7b4801 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -131,10 +131,11 @@ private static function send_annual_email( $user_id, $year, $summary ) { * @param int|null $user_id Optional. Specific user ID or null for all users. * @param int|null $year Optional. Year to collect stats for. * @param int|null $month Optional. Month to collect stats for. + * @param bool $force Optional. Force recollection even if stats exist. Default false. * * @return array Array of collected stats per user. */ - public static function trigger_monthly_collection( $user_id = null, $year = null, $month = null ) { + public static function trigger_monthly_collection( $user_id = null, $year = null, $month = null, $force = false ) { $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested if ( null === $year ) { @@ -149,7 +150,11 @@ public static function trigger_monthly_collection( $user_id = null, $year = null $results = array(); foreach ( $user_ids as $uid ) { - $results[ $uid ] = Statistics_Collector::collect_monthly_stats( $uid, $year, $month ); + if ( $force ) { + $results[ $uid ] = Statistics_Collector::recollect_monthly_stats( $uid, $year, $month ); + } else { + $results[ $uid ] = Statistics_Collector::collect_monthly_stats( $uid, $year, $month ); + } } return $results; From 76248a128b6dae47be030991f57f91df48d16df7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 17:03:24 +0100 Subject: [PATCH 28/46] Add collect and compile actions to local stats CLI command Expose the scheduler's trigger_monthly_collection() and trigger_annual_compilation() methods via the local development CLI: - stats collect: Trigger monthly stats collection - stats compile: Trigger annual stats compilation Options include --year, --month, --force, and --no-email for flexibility. --- local/class-cli.php | 66 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/local/class-cli.php b/local/class-cli.php index 933ef22080..6f809167e7 100644 --- a/local/class-cli.php +++ b/local/class-cli.php @@ -12,6 +12,7 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Inbox; use Activitypub\Comment; +use Activitypub\Scheduler\Statistics as Statistics_Scheduler; use Activitypub\Statistics; use function Activitypub\camel_to_snake_case; @@ -248,15 +249,29 @@ public function reprocess_inbox( $args ) { * ## OPTIONS * * - * : The action to perform. Either `populate` or `clear`. + * : The action to perform. * --- * options: * - populate * - clear + * - collect + * - compile * --- * * [--user_id=] - * : The user ID to populate/clear data for. Defaults to blog user (0). + * : The user ID to operate on. Defaults to blog user (0). + * + * [--year=] + * : The year to collect/compile stats for. Defaults to current year. + * + * [--month=] + * : The month to collect stats for (1-12). Defaults to current month. + * + * [--force] + * : Force recollection even if stats already exist. + * + * [--no-email] + * : Skip sending email when compiling annual stats. * * ## EXAMPLES * @@ -269,25 +284,58 @@ public function reprocess_inbox( $args ) { * # Clear demo stats for the blog * $ wp activitypub stats clear * - * @synopsis [--user_id=] + * # Collect real stats for current month + * $ wp activitypub stats collect + * + * # Collect stats for a specific month (force recollect) + * $ wp activitypub stats collect --year=2024 --month=6 --force + * + * # Compile annual stats without sending email + * $ wp activitypub stats compile --year=2024 --no-email + * + * @synopsis [--user_id=] [--year=] [--month=] [--force] [--no-email] * * @param array $args The positional arguments. * @param array $assoc_args The associative arguments. */ public function stats( $args, $assoc_args = array() ) { - $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : Actors::BLOG_USER_ID; + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : null; + $month = isset( $assoc_args['month'] ) ? (int) $assoc_args['month'] : null; + $force = isset( $assoc_args['force'] ); switch ( $args[0] ) { case 'populate': - $this->populate_demo_stats( $user_id ); - \WP_CLI::success( "Demo statistics populated for user ID: {$user_id}" ); + $target_user = $user_id ?? Actors::BLOG_USER_ID; + $this->populate_demo_stats( $target_user ); + \WP_CLI::success( "Demo statistics populated for user ID: {$target_user}" ); break; + case 'clear': - $this->clear_demo_stats( $user_id ); - \WP_CLI::success( "Demo statistics cleared for user ID: {$user_id}" ); + $target_user = $user_id ?? Actors::BLOG_USER_ID; + $this->clear_demo_stats( $target_user ); + \WP_CLI::success( "Demo statistics cleared for user ID: {$target_user}" ); + break; + + case 'collect': + $results = Statistics_Scheduler::trigger_monthly_collection( $user_id, $year, $month, $force ); + $count = count( $results ); + $y = $year ?? gmdate( 'Y' ); + $m = $month ?? gmdate( 'n' ); + \WP_CLI::success( "Monthly stats collected for {$count} user(s) ({$y}-{$m})." ); break; + + case 'compile': + $send_email = ! isset( $assoc_args['no-email'] ); + $results = Statistics_Scheduler::trigger_annual_compilation( $user_id, $year, $send_email ); + $count = count( $results ); + $y = $year ?? ( gmdate( 'Y' ) - 1 ); + $email_msg = $send_email ? ' (emails sent)' : ' (no emails)'; + \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$y}){$email_msg}." ); + break; + default: - \WP_CLI::error( 'Unknown action. Use "populate" or "clear".' ); + \WP_CLI::error( 'Unknown action. Use "populate", "clear", "collect", or "compile".' ); } } From 163a5dbd52d386f1d871a50da062e0857e44aa59 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 17:05:15 +0100 Subject: [PATCH 29/46] Remove unused Statistics methods - Remove get_yearly_monthly_breakdown() (replaced by get_rolling_monthly_breakdown) - Remove deprecated get_year_comparison() (use get_period_comparison instead) --- includes/class-statistics.php | 45 ----------------------------------- 1 file changed, 45 deletions(-) diff --git a/includes/class-statistics.php b/includes/class-statistics.php index badd1caa3e..ade7719f24 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -663,38 +663,6 @@ public static function get_current_stats( $user_id, $period = 'month' ) { return $stats; } - /** - * Get monthly breakdown for the current year (for graphs). - * - * Uses stored monthly stats if available, otherwise queries live data. - * - * @param int $user_id The user ID. - * @param int $year Optional. The year. Defaults to current year. - * - * @return array Array of monthly stats with month number as key. - */ - public static function get_yearly_monthly_breakdown( $user_id, $year = null ) { - if ( ! $year ) { - $year = (int) \gmdate( 'Y' ); - } - - $current_month = (int) \gmdate( 'n' ); - $current_year = (int) \gmdate( 'Y' ); - $months = array(); - - // Get all comment types tracked in stats (includes federated comments via filter). - $comment_types = \array_keys( self::get_comment_types_for_stats() ); - - // Only go up to current month if we're in the current year. - $max_month = ( $year === $current_year ) ? $current_month : 12; - - for ( $month = 1; $month <= $max_month; $month++ ) { - $months[ $month ] = self::get_month_data( $user_id, $year, $month, $comment_types ); - } - - return $months; - } - /** * Get rolling monthly breakdown (last X months). * @@ -786,19 +754,6 @@ private static function get_month_data( $user_id, $year, $month, $comment_types return $month_data; } - /** - * Get year-over-year comparison for current month. - * - * @deprecated Use get_period_comparison() instead. - * - * @param int $user_id The user ID. - * - * @return array Comparison data. - */ - public static function get_year_comparison( $user_id ) { - return self::get_period_comparison( $user_id ); - } - /** * Get period-over-period comparison (current month vs previous month). * From d7e49b4156a5e2f577849ec366f3b01bf14a7899 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 17:08:51 +0100 Subject: [PATCH 30/46] Move CLI-specific stats functions to local CLI class - Remove trigger_monthly_collection() from scheduler - Remove trigger_annual_compilation() from scheduler - Remove delete_monthly_stats() and recollect_monthly_stats() from Statistics - Implement collect and compile logic directly in local CLI class --- includes/class-statistics.php | 31 ----------- includes/scheduler/class-statistics.php | 70 ------------------------ local/class-cli.php | 73 ++++++++++++++++++++----- 3 files changed, 58 insertions(+), 116 deletions(-) diff --git a/includes/class-statistics.php b/includes/class-statistics.php index ade7719f24..fa12dd4679 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -129,37 +129,6 @@ public static function save_annual_summary( $user_id, $year, $stats ) { return \update_option( self::get_annual_option_name( $user_id, $year ), $stats, false ); } - /** - * Delete monthly statistics for a user. - * - * Useful for recollecting stale data or clearing incorrect entries. - * - * @param int $user_id The user ID. - * @param int $year The year. - * @param int $month The month. - * - * @return bool True on success, false on failure. - */ - public static function delete_monthly_stats( $user_id, $year, $month ) { - return \delete_option( self::get_monthly_option_name( $user_id, $year, $month ) ); - } - - /** - * Recollect monthly statistics for a user. - * - * Deletes existing stats and collects fresh data. - * - * @param int $user_id The user ID. - * @param int $year The year. - * @param int $month The month. - * - * @return array The newly collected stats. - */ - public static function recollect_monthly_stats( $user_id, $year, $month ) { - self::delete_monthly_stats( $user_id, $year, $month ); - return self::collect_monthly_stats( $user_id, $year, $month ); - } - /** * Collect monthly statistics for a user. * diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 867c7b4801..e25fc9c31d 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -122,74 +122,4 @@ private static function send_annual_email( $user_id, $year, $summary ) { Mailer::send( $user_id, 'annual_wrapped', $args ); } - - /** - * Manually trigger monthly stats collection. - * - * Useful for CLI or testing purposes. - * - * @param int|null $user_id Optional. Specific user ID or null for all users. - * @param int|null $year Optional. Year to collect stats for. - * @param int|null $month Optional. Month to collect stats for. - * @param bool $force Optional. Force recollection even if stats exist. Default false. - * - * @return array Array of collected stats per user. - */ - public static function trigger_monthly_collection( $user_id = null, $year = null, $month = null, $force = false ) { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - - if ( null === $year ) { - $year = (int) \gmdate( 'Y', $now ); - } - - if ( null === $month ) { - $month = (int) \gmdate( 'n', $now ); - } - - $user_ids = $user_id ? array( $user_id ) : Statistics_Collector::get_active_user_ids(); - $results = array(); - - foreach ( $user_ids as $uid ) { - if ( $force ) { - $results[ $uid ] = Statistics_Collector::recollect_monthly_stats( $uid, $year, $month ); - } else { - $results[ $uid ] = Statistics_Collector::collect_monthly_stats( $uid, $year, $month ); - } - } - - return $results; - } - - /** - * Manually trigger annual stats compilation. - * - * Useful for CLI or testing purposes. - * - * @param int|null $user_id Optional. Specific user ID or null for all users. - * @param int|null $year Optional. Year to compile stats for. - * @param bool $send_email Optional. Whether to send the email. Default true. - * - * @return array Array of compiled summaries per user. - */ - public static function trigger_annual_compilation( $user_id = null, $year = null, $send_email = true ) { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - - if ( null === $year ) { - $year = (int) \gmdate( 'Y', $now ) - 1; - } - - $user_ids = $user_id ? array( $user_id ) : Statistics_Collector::get_active_user_ids(); - $results = array(); - - foreach ( $user_ids as $uid ) { - $summary = Statistics_Collector::compile_annual_summary( $uid, $year ); - $results[ $uid ] = $summary; - - if ( $send_email ) { - self::send_annual_email( $uid, $year, $summary ); - } - } - - return $results; - } } diff --git a/local/class-cli.php b/local/class-cli.php index 6f809167e7..012808cc7f 100644 --- a/local/class-cli.php +++ b/local/class-cli.php @@ -12,7 +12,6 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Inbox; use Activitypub\Comment; -use Activitypub\Scheduler\Statistics as Statistics_Scheduler; use Activitypub\Statistics; use function Activitypub\camel_to_snake_case; @@ -244,7 +243,7 @@ public function reprocess_inbox( $args ) { } /** - * Manage statistics demo data. + * Manage statistics data. * * ## OPTIONS * @@ -270,9 +269,6 @@ public function reprocess_inbox( $args ) { * [--force] * : Force recollection even if stats already exist. * - * [--no-email] - * : Skip sending email when compiling annual stats. - * * ## EXAMPLES * * # Populate demo stats for the blog @@ -290,10 +286,10 @@ public function reprocess_inbox( $args ) { * # Collect stats for a specific month (force recollect) * $ wp activitypub stats collect --year=2024 --month=6 --force * - * # Compile annual stats without sending email - * $ wp activitypub stats compile --year=2024 --no-email + * # Compile annual stats + * $ wp activitypub stats compile --year=2024 * - * @synopsis [--user_id=] [--year=] [--month=] [--force] [--no-email] + * @synopsis [--user_id=] [--year=] [--month=] [--force] * * @param array $args The positional arguments. * @param array $assoc_args The associative arguments. @@ -318,7 +314,7 @@ public function stats( $args, $assoc_args = array() ) { break; case 'collect': - $results = Statistics_Scheduler::trigger_monthly_collection( $user_id, $year, $month, $force ); + $results = $this->collect_monthly_stats( $user_id, $year, $month, $force ); $count = count( $results ); $y = $year ?? gmdate( 'Y' ); $m = $month ?? gmdate( 'n' ); @@ -326,12 +322,10 @@ public function stats( $args, $assoc_args = array() ) { break; case 'compile': - $send_email = ! isset( $assoc_args['no-email'] ); - $results = Statistics_Scheduler::trigger_annual_compilation( $user_id, $year, $send_email ); - $count = count( $results ); - $y = $year ?? ( gmdate( 'Y' ) - 1 ); - $email_msg = $send_email ? ' (emails sent)' : ' (no emails)'; - \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$y}){$email_msg}." ); + $results = $this->compile_annual_stats( $user_id, $year ); + $count = count( $results ); + $y = $year ?? ( gmdate( 'Y' ) - 1 ); + \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$y})." ); break; default: @@ -339,6 +333,55 @@ public function stats( $args, $assoc_args = array() ) { } } + /** + * Collect monthly statistics. + * + * @param int|null $user_id The user ID or null for all users. + * @param int|null $year The year. + * @param int|null $month The month. + * @param bool $force Force recollection even if stats exist. + * + * @return array Results per user. + */ + private function collect_monthly_stats( $user_id, $year, $month, $force ) { + $year = $year ?? (int) gmdate( 'Y' ); + $month = $month ?? (int) gmdate( 'n' ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + $results = array(); + + foreach ( $user_ids as $uid ) { + if ( $force ) { + $option_name = Statistics::get_monthly_option_name( $uid, $year, $month ); + \delete_option( $option_name ); + } + $results[ $uid ] = Statistics::collect_monthly_stats( $uid, $year, $month ); + } + + return $results; + } + + /** + * Compile annual statistics. + * + * @param int|null $user_id The user ID or null for all users. + * @param int|null $year The year. + * + * @return array Results per user. + */ + private function compile_annual_stats( $user_id, $year ) { + $year = $year ?? ( (int) gmdate( 'Y' ) - 1 ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + $results = array(); + + foreach ( $user_ids as $uid ) { + $results[ $uid ] = Statistics::compile_annual_summary( $uid, $year ); + } + + return $results; + } + /** * Populate demo statistics data for testing. * From 026b0e29dbac6416f2f0b78254f9d7350b401a82 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 17:12:48 +0100 Subject: [PATCH 31/46] Fix statistics to include all supported post types The queries were defaulting to only 'post' type, missing engagement on pages and custom post types enabled for ActivityPub. Now uses activitypub_support_post_types option in all queries: - count_engagement_in_range() - get_top_posts() - get_top_multiplicator() - get_earliest_data_year() --- includes/class-statistics.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/includes/class-statistics.php b/includes/class-statistics.php index fa12dd4679..de421d0cc8 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -337,11 +337,12 @@ public static function count_federated_posts_in_range( $user_id, $start, $end ) public static function count_engagement_in_range( $user_id, $start, $end, $type = null ) { global $wpdb; - // Get post IDs for the user. + // Get post IDs for the user (all supported post types). $post_args = array( 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'publish', + 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), ); if ( Actors::BLOG_USER_ID !== $user_id ) { @@ -408,6 +409,7 @@ public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'publish', + 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), 'date_query' => array( array( 'after' => $start, @@ -493,6 +495,7 @@ public static function get_top_multiplicator( $user_id, $start, $end ) { 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'publish', + 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), ); if ( Actors::BLOG_USER_ID !== $user_id ) { @@ -917,11 +920,12 @@ public static function backfill_historical_stats( $batch_size = 12, $user_index private static function get_earliest_data_year( $user_id ) { global $wpdb; - // Get post IDs for the user. + // Get post IDs for the user (all supported post types). $post_args = array( 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'publish', + 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), ); if ( Actors::BLOG_USER_ID !== $user_id ) { From 0afc836ad7eba4f8aa2354d81c41f4506ef21cd0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 17:58:24 +0100 Subject: [PATCH 32/46] Fix statistics collection and dashboard widget issues - Fix annual email guard to check all registered comment types dynamically instead of hardcoded plural keys (likes_count vs like_count) - Fix follower growth calculation to use followers_count (gained) instead of non-existent followers_gained/followers_lost keys - Add comment date filter to get_top_posts() to only count engagement within the specified date range - Use get_comment_types_for_stats() in count_engagement_in_range() and get_period_comparison() to include federated comments in totals - Fix dashboard widget to fall back to user stats when blog stats are forbidden (non-admin users) - Fix December scheduling to correctly check if past Dec 1st 3:00 AM --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 4 ++-- includes/class-scheduler.php | 16 +++++++------ includes/class-statistics.php | 23 +++++++++++++------ includes/scheduler/class-statistics.php | 19 ++++++++++----- .../components/stats-widget/index.tsx | 15 +++++++----- 6 files changed, 50 insertions(+), 29 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index f170492538..5614ca60e7 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'fea8e25386f405663022'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'b79c0b8e7a2a1f553592'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 9ca0c14b6b..853fac0843 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,3 @@ -(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,o=window.wp.coreData,l=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:s,commentTypes:a,canUseUserActor:i,canUseBlogActor:n}){var r,o;if(!e)return null;const l=[];var u,d,h,v;return i&&t?.followers&&l.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:null!==(u=t.followers.current)&&void 0!==u?u:0,change:null!==(d=t.followers.change)&&void 0!==d?d:0}),n&&s?.followers&&l.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:null!==(h=s.followers.current)&&void 0!==h?h:0,change:null!==(v=s.followers.change)&&void 0!==v?v:0}),l.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(r=e.posts?.current)&&void 0!==r?r:0,change:null!==(o=e.posts?.change)&&void 0!==o?o:0}),a&&Object.entries(a).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&l.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:l.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:s}):(0,p.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function h(e){const t=d[e%d.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),o=e.map((t,s)=>40+s/(e.length-1||1)*540),l=e.map((e,t)=>({x:o[t],y:170-(e.engagement||0)/r*i,month:e})),u=l.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),d=u+` L ${l[l.length-1].x} 170`+` L ${l[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=o[s],l=170-a/r*i;return 0===s?`M ${n} ${l}`:`L ${n} ${l}`}).join(" "),m=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],y=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{y.push({key:e,label:t.label,color:h(s)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:h(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:s,strokeWidth:"2"}),l.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),l.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:m[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:y.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function m({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:s,commentTypes:a,canUseUserActor:i,canUseBlogActor:n}){var r,l;if(!e)return null;const o=[];var u,d,h,v;return i&&t?.followers&&o.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:null!==(u=t.followers.current)&&void 0!==u?u:0,change:null!==(d=t.followers.change)&&void 0!==d?d:0}),n&&s?.followers&&o.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:null!==(h=s.followers.current)&&void 0!==h?h:0,change:null!==(v=s.followers.change)&&void 0!==v?v:0}),o.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(r=e.posts?.current)&&void 0!==r?r:0,change:null!==(l=e.posts?.change)&&void 0!==l?l:0}),a&&Object.entries(a).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&o.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:o.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:s}):(0,p.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function h(e){const t=d[e%d.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,s)=>40+s/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),u=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),d=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=l[s],o=170-a/r*i;return 0===s?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),m=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],y=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{y.push({key:e,label:t.label,color:h(s)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:h(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:s,strokeWidth:"2"}),o.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),o.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:m[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:y.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function m({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ (0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function y({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){var e,t;const{currentUser:s,actorMode:i,hasUserCap:d,hasBlogCap:h,isResolving:g}=(0,r.useSelect)(e=>{var t;return{currentUser:e(o.store).getCurrentUser(),actorMode:null!==(t=e(o.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(o.store).isResolving("getCurrentUser",[])}},[]),x=("actor"===i||i===b)&&d&&!!s?.id,f=("blog"===i||i===b)&&h,[_,j]=(0,a.useState)(null),[w,k]=(0,a.useState)(null),[N,O]=(0,a.useState)(null),[$,C]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(g)return;C(!0);const e=n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null),t=x&&s?.id?n()({path:`/activitypub/1.0/stats/${s.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([e,t]).then(([e,t])=>{j(e),O(e),k(t)}).finally(()=>C(!1))},[g,x,s?.id]),g||$?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(l.Spinner,{})})}):_?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:_.comparison,userComparison:null!==(e=w?.comparison)&&void 0!==e?e:null,blogComparison:null!==(t=N?.comparison)&&void 0!==t?t:null,commentTypes:_.comment_types,canUseUserActor:x,canUseBlogActor:f}),(0,p.jsx)(v,{monthly:_.monthly,commentTypes:_.comment_types}),(0,p.jsx)(m,{multiplicator:_.stats?.top_multiplicator}),(0,p.jsx)(y,{posts:_.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,p.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(p=0;p=n)&&Object.keys(a.O).every(e=>a.O[e](s[l]))?s.splice(l--,1):(o=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,o,l]=s,c=0;if(r.some(t=>0!==e[t])){for(i in o)a.o(o,i)&&(a.m[i]=o[i]);if(l)var p=l(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){var e,t;const{currentUser:s,actorMode:i,hasUserCap:d,hasBlogCap:h,isResolving:g}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),x=("actor"===i||i===b)&&d&&!!s?.id,f=("blog"===i||i===b)&&h,[_,j]=(0,a.useState)(null),[w,k]=(0,a.useState)(null),[N,O]=(0,a.useState)(null),[$,C]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(g)return;C(!0);const e=f?n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null):Promise.resolve(null),t=x&&s?.id?n()({path:`/activitypub/1.0/stats/${s.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([e,t]).then(([e,t])=>{j(null!=e?e:t),O(e),k(t)}).finally(()=>C(!1))},[g,x,f,s?.id]),g||$?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):_?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:_.comparison,userComparison:null!==(e=w?.comparison)&&void 0!==e?e:null,blogComparison:null!==(t=N?.comparison)&&void 0!==t?t:null,commentTypes:_.comment_types,canUseUserActor:x,canUseBlogActor:f}),(0,p.jsx)(v,{monthly:_.monthly,commentTypes:_.comment_types}),(0,p.jsx)(m,{multiplicator:_.stats?.top_multiplicator}),(0,p.jsx)(y,{posts:_.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,p.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(p=0;p=n)&&Object.keys(a.O).every(e=>a.O[e](s[o]))?s.splice(o--,1):(l=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,l,o]=s,c=0;if(r.some(t=>0!==e[t])){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(o)var p=o(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index fff404b0ad..8a69be646e 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -211,16 +211,18 @@ private static function get_next_first_of_month() { * @return int Unix timestamp of next December 1st at 3:00 AM. */ private static function get_next_december_first() { - $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested - $year = (int) \gmdate( 'Y', $now ); - $month = (int) \gmdate( 'n', $now ); + $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested + $year = (int) \gmdate( 'Y', $now ); - // If we're past December 1st, schedule for next year. - if ( $month >= 12 ) { - ++$year; + // Get December 1st 3:00 AM for this year. + $this_year_dec_first = \strtotime( sprintf( '%d-12-01 03:00:00', $year ) ); + + // If we're already past this year's December 1st, schedule for next year. + if ( $now >= $this_year_dec_first ) { + return \strtotime( sprintf( '%d-12-01 03:00:00', $year + 1 ) ); } - return \strtotime( sprintf( '%d-12-01 03:00:00', $year ) ); + return $this_year_dec_first; } /** diff --git a/includes/class-statistics.php b/includes/class-statistics.php index de421d0cc8..b62a386329 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -244,10 +244,17 @@ function ( $a, $b ) { } // Build summary with dynamic comment type counts. + // Calculate followers_start: total at start of first month (total minus gained that month). + // Monthly stats store: followers_count (gained this month), followers_total (total at end of month). + $followers_start = 0; + if ( $first_month_stats ) { + $followers_start = ( $first_month_stats['followers_total'] ?? 0 ) - ( $first_month_stats['followers_count'] ?? 0 ); + } + $summary = array( 'posts_count' => $totals['posts_count'], 'most_active_month' => $most_active_month, - 'followers_start' => $first_month_stats ? ( $first_month_stats['followers_total'] ?? 0 ) - ( $first_month_stats['followers_gained'] ?? 0 ) + ( $first_month_stats['followers_lost'] ?? 0 ) : 0, + 'followers_start' => $followers_start, 'followers_end' => $last_month_stats ? ( $last_month_stats['followers_total'] ?? 0 ) : self::get_follower_count( $user_id ), 'followers_net_change' => 0, 'top_multiplicator' => $top_multiplicator, @@ -361,8 +368,8 @@ public static function count_engagement_in_range( $user_id, $start, $end, $type if ( $type ) { $type_clause = $wpdb->prepare( ' AND c.comment_type = %s', $type ); } else { - // Get all registered ActivityPub comment types dynamically. - $comment_types = Comment::get_comment_type_slugs(); + // Get all comment types tracked in statistics (includes federated comments via filter). + $comment_types = \array_keys( self::get_comment_types_for_stats() ); if ( ! empty( $comment_types ) ) { $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare @@ -439,7 +446,7 @@ public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); - // Get engagement counts per post. + // Get engagement counts per post (only engagement within the date range). // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -454,10 +461,12 @@ public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { AND cm.meta_key = 'protocol' AND cm.meta_value = 'activitypub' AND c.comment_type IN ({$placeholders_types}) + AND c.comment_date_gmt >= %s + AND c.comment_date_gmt <= %s GROUP BY c.comment_post_ID ORDER BY engagement_count DESC LIMIT %d", - \array_merge( $post_ids, $comment_types, array( $limit ) ) + \array_merge( $post_ids, $comment_types, array( $start, $end, $limit ) ) ), ARRAY_A ); @@ -776,8 +785,8 @@ public static function get_period_comparison( $user_id ) { ), ); - // Add comparison for each registered comment type dynamically. - $comment_types = Comment::get_comment_type_slugs(); + // Add comparison for each comment type tracked in statistics (includes federated comments). + $comment_types = \array_keys( self::get_comment_types_for_stats() ); foreach ( $comment_types as $type ) { // Always query live for current month. $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type ); diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index e25fc9c31d..57f58a3931 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -98,12 +98,19 @@ private static function send_annual_email( $user_id, $year, $summary ) { } // Don't send email if there's no activity. - if ( - empty( $summary['posts_count'] ) && - empty( $summary['likes_count'] ) && - empty( $summary['reposts_count'] ) && - empty( $summary['comments_count'] ) - ) { + // Check posts and all registered comment types dynamically. + $has_activity = ! empty( $summary['posts_count'] ); + if ( ! $has_activity ) { + $comment_types = \array_keys( Statistics_Collector::get_comment_types_for_stats() ); + foreach ( $comment_types as $type ) { + if ( ! empty( $summary[ $type . '_count' ] ) ) { + $has_activity = true; + break; + } + } + } + + if ( ! $has_activity ) { return; } diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index f2d2d5219f..09d717d67c 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -69,10 +69,12 @@ export default function StatsWidget() { setIsLoading( true ); - // Fetch blog stats (global engagement data). - const blogStatsPromise = apiFetch< StatsResponse >( { - path: `/activitypub/1.0/stats/${ BLOG_USER_ID }`, - } ).catch( () => null ); + // Fetch blog stats (global engagement data) - only if user has blog capability. + const blogStatsPromise = canUseBlogActor + ? apiFetch< StatsResponse >( { + path: `/activitypub/1.0/stats/${ BLOG_USER_ID }`, + } ).catch( () => null ) + : Promise.resolve( null ); // Fetch user-specific stats if user actor is available. const userStatsPromise = @@ -84,12 +86,13 @@ export default function StatsWidget() { Promise.all( [ blogStatsPromise, userStatsPromise ] ) .then( ( [ blogData, userData ] ) => { - setStats( blogData ); + // Use blog stats as primary if available, otherwise fall back to user stats. + setStats( blogData ?? userData ); setBlogStats( blogData ); setUserStats( userData ); } ) .finally( () => setIsLoading( false ) ); - }, [ isResolving, canUseUserActor, currentUser?.id ] ); + }, [ isResolving, canUseUserActor, canUseBlogActor, currentUser?.id ] ); // Show loading while resolving user data. if ( isResolving || isLoading ) { From 2364156a91e22096974b877027b1a7f5e501ce7d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:15:04 +0100 Subject: [PATCH 33/46] Address PR review feedback for Fediverse Wrapped - Add generic Mailer::send() method for email dispatching - Change "Followers" to "New Followers" in dashboard highlights (clarity) - Add SVG accessibility (role="img", aria-labelledby, title element) - Add aria-label for external links (opens in new tab indicator) - Use deterministic color assignment via djb2 hash for chart lines - Delay statistics backfill by 1 hour to avoid immediate load after upgrade --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 8 +- includes/class-mailer.php | 120 ++++++++++++++++++ includes/class-migration.php | 4 +- .../components/line-chart/index.tsx | 40 ++++-- .../components/stat-highlights/index.tsx | 6 +- .../components/top-posts/index.tsx | 40 ++++-- .../components/top-supporter/index.tsx | 11 +- 8 files changed, 200 insertions(+), 31 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 5614ca60e7..7ca95b64db 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'b79c0b8e7a2a1f553592'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '98072ec053dbe5ebed71'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 853fac0843..1f752c0c49 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,3 +1,5 @@ -(()=>{"use strict";var e,t={2320(e,t,s){const a=window.wp.element,i=window.wp.apiFetch;var n=s.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:s,commentTypes:a,canUseUserActor:i,canUseBlogActor:n}){var r,l;if(!e)return null;const o=[];var u,d,h,v;return i&&t?.followers&&o.push({key:"followers-user",label:(0,c.__)("Followers","activitypub"),value:null!==(u=t.followers.current)&&void 0!==u?u:0,change:null!==(d=t.followers.change)&&void 0!==d?d:0}),n&&s?.followers&&o.push({key:"followers-blog",label:(0,c.__)("Followers (Blog)","activitypub"),value:null!==(h=s.followers.current)&&void 0!==h?h:0,change:null!==(v=s.followers.change)&&void 0!==v?v:0}),o.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(r=e.posts?.current)&&void 0!==r?r:0,change:null!==(l=e.posts?.change)&&void 0!==l?l:0}),a&&Object.entries(a).forEach(([t,s])=>{const a=e[t];var i,n;a&&"object"==typeof a&&"current"in a&&o.push({key:t,label:s.label,value:null!==(i=a.current)&&void 0!==i?i:0,change:null!==(n=a.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:o.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),s=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:s}):(0,p.jsx)("span",{children:s}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const d=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function h(e){const t=d[e%d.length];return`var(--wp--preset--color--${t.slug}, ${t.hex})`}function v({monthly:e,commentTypes:t}){if(!e?.length)return null;const s="var(--wp--preset--color--vivid-cyan-blue, #0693e3)",a=20,i=150,n=t?Object.keys(t):[],r=Math.max(...e.map(e=>e.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,s)=>40+s/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),u=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),d=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,s)=>{const a=e[`${t}_count`]||0,n=l[s],o=170-a/r*i;return 0===s?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),m=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],y=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:s}];return t&&Object.entries(t).forEach(([e,t],s)=>{y.push({key:e,label:t.label,color:h(s)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",children:[(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:s,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:s,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:a+i*(1-e),x2:580,y2:a+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d,fill:"url(#areaGradient)"}),n.map((e,t)=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:h(t),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:s,strokeWidth:"2"}),o.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:s},t)),o.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:m[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:a+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:y.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function m({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function y({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer",children:e.title||(0,c.__)("(no title)","activitypub")}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id))})]}):null}const b="actor_blog";function g(){var e,t;const{currentUser:s,actorMode:i,hasUserCap:d,hasBlogCap:h,isResolving:g}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:b,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),x=("actor"===i||i===b)&&d&&!!s?.id,f=("blog"===i||i===b)&&h,[_,j]=(0,a.useState)(null),[w,k]=(0,a.useState)(null),[N,O]=(0,a.useState)(null),[$,C]=(0,a.useState)(!0);return(0,a.useEffect)(()=>{if(g)return;C(!0);const e=f?n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null):Promise.resolve(null),t=x&&s?.id?n()({path:`/activitypub/1.0/stats/${s.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([e,t]).then(([e,t])=>{j(null!=e?e:t),O(e),k(t)}).finally(()=>C(!1))},[g,x,f,s?.id]),g||$?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):_?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:_.comparison,userComparison:null!==(e=w?.comparison)&&void 0!==e?e:null,blogComparison:null!==(t=N?.comparison)&&void 0!==t?t:null,commentTypes:_.comment_types,canUseUserActor:x,canUseBlogActor:f}),(0,p.jsx)(v,{monthly:_.monthly,commentTypes:_.comment_types}),(0,p.jsx)(m,{multiplicator:_.stats?.top_multiplicator}),(0,p.jsx)(y,{posts:_.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,a.createRoot)(t).render((0,p.jsx)(g,{}))}}}},s={};function a(e){var i=s[e];if(void 0!==i)return i.exports;var n=s[e]={exports:{}};return t[e](n,n.exports,a),n.exports}a.m=t,e=[],a.O=(t,s,i,n)=>{if(!s){var r=1/0;for(p=0;p=n)&&Object.keys(a.O).every(e=>a.O[e](s[o]))?s.splice(o--,1):(l=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[s,i,n]},a.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return a.d(t,{a:t}),t},a.d=(e,t)=>{for(var s in t)a.o(t,s)&&!a.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};a.O.j=t=>0===e[t];var t=(t,s)=>{var i,n,[r,l,o]=s,c=0;if(r.some(t=>0!==e[t])){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(o)var p=o(a)}for(t&&t(s);ca(2320));i=a.O(i)})(); \ No newline at end of file +(()=>{"use strict";var e,t={2320(e,t,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:a,commentTypes:s,canUseUserActor:i,canUseBlogActor:n}){var r,l;if(!e)return null;const o=[];var u,h,d,v;return i&&t?.followers&&o.push({key:"followers-user",label:(0,c.__)("New Followers","activitypub"),value:null!==(u=t.followers.current)&&void 0!==u?u:0,change:null!==(h=t.followers.change)&&void 0!==h?h:0}),n&&a?.followers&&o.push({key:"followers-blog",label:(0,c.__)("New Followers (Blog)","activitypub"),value:null!==(d=a.followers.current)&&void 0!==d?d:0,change:null!==(v=a.followers.change)&&void 0!==v?v:0}),o.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(r=e.posts?.current)&&void 0!==r?r:0,change:null!==(l=e.posts?.change)&&void 0!==l?l:0}),s&&Object.entries(s).forEach(([t,a])=>{const s=e[t];var i,n;s&&"object"==typeof s&&"current"in s&&o.push({key:t,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:o.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),a=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:a}):(0,p.jsx)("span",{children:a}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=function(e){let t=5381;for(let a=0;ae.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,a)=>40+a/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),u=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,a)=>{const s=e[`${t}_count`]||0,n=l[a],o=170-s/r*i;return 0===a?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),b=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return t&&Object.entries(t).forEach(([e,t])=>{m.push({key:e,label:t.label,color:d(e)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",role:"img","aria-labelledby":"activitypub-chart-title",children:[(0,p.jsx)("title",{id:"activitypub-chart-title",children:(0,c.__)("Line chart showing engagement trends over the past 12 months","activitypub")}),(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-e),x2:580,y2:s+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map(e=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:d(e),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),o.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:a},t)),o.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:b[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:s+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function b({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: supporter name */ /* translators: %s: supporter name */ +(0,c.__)("%s (opens in a new tab)","activitypub"),e.name),children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function m({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>{const t=e.title||(0,c.__)("(no title)","activitypub");return(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: post title */ /* translators: %s: post title */ +(0,c.__)("%s (opens in a new tab)","activitypub"),t),children:t}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id)})})]}):null}const y="actor_blog";function g(){var e,t;const{currentUser:a,actorMode:i,hasUserCap:h,hasBlogCap:d,isResolving:g}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:y,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),x=("actor"===i||i===y)&&h&&!!a?.id,_=("blog"===i||i===y)&&d,[f,j]=(0,s.useState)(null),[w,k]=(0,s.useState)(null),[N,O]=(0,s.useState)(null),[$,C]=(0,s.useState)(!0);return(0,s.useEffect)(()=>{if(g)return;C(!0);const e=_?n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null):Promise.resolve(null),t=x&&a?.id?n()({path:`/activitypub/1.0/stats/${a.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([e,t]).then(([e,t])=>{j(null!=e?e:t),O(e),k(t)}).finally(()=>C(!1))},[g,x,_,a?.id]),g||$?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):f?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:f.comparison,userComparison:null!==(e=w?.comparison)&&void 0!==e?e:null,blogComparison:null!==(t=N?.comparison)&&void 0!==t?t:null,commentTypes:f.comment_types,canUseUserActor:x,canUseBlogActor:_}),(0,p.jsx)(v,{monthly:f.monthly,commentTypes:f.comment_types}),(0,p.jsx)(b,{multiplicator:f.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:f.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,s.createRoot)(t).render((0,p.jsx)(g,{}))}}}},a={};function s(e){var i=a[e];if(void 0!==i)return i.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,s),n.exports}s.m=t,e=[],s.O=(t,a,i,n)=>{if(!a){var r=1/0;for(p=0;p=n)&&Object.keys(s.O).every(e=>s.O[e](a[o]))?a.splice(o--,1):(l=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[a,i,n]},s.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return s.d(t,{a:t}),t},s.d=(e,t)=>{for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};s.O.j=t=>0===e[t];var t=(t,a)=>{var i,n,[r,l,o]=a,c=0;if(r.some(t=>0!==e[t])){for(i in l)s.o(l,i)&&(s.m[i]=l[i]);if(o)var p=o(s)}for(t&&t(a);cs(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 06731784b4..5bdfb4f76a 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -427,6 +427,126 @@ public static function mention( $activity, $user_ids ) { } } + /** + * Send a templated email to a user. + * + * @param int $user_id The user ID (or BLOG_USER_ID for blog actor). + * @param string $template The template name (without path/extension). + * @param array $args Template arguments. + * + * @return bool True if email was sent, false otherwise. + */ + public static function send( $user_id, $template, $args = array() ) { + // Get the recipient email address. + if ( $user_id > Actors::BLOG_USER_ID ) { + $user = \get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + return false; + } + $email = $user->user_email; + } else { + $email = \get_option( 'admin_email' ); + } + + // Build subject based on template type. + $subject = self::get_email_subject( $template, $args ); + + // Load the HTML template. + $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . $template . '.php'; + + /** + * Filter the email template file path. + * + * @param string $template_file The template file path. + * @param string $template The template name. + * @param int $user_id The user ID. + * @param array $args Template arguments. + */ + $template_file = \apply_filters( 'activitypub_email_template', $template_file, $template, $user_id, $args ); + + if ( ! \file_exists( $template_file ) ) { + return false; + } + + \ob_start(); + \load_template( $template_file, false, $args ); + $html_message = \ob_get_clean(); + + // Build plain text alternative. + $alt_body = self::get_plain_text_body( $template, $args ); + $alt_function = static function ( $mailer ) use ( $alt_body ) { + $mailer->{'AltBody'} = $alt_body; + }; + \add_action( 'phpmailer_init', $alt_function ); + + $result = \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) ); + + \remove_action( 'phpmailer_init', $alt_function ); + + return $result; + } + + /** + * Get the email subject for a template. + * + * @param string $template The template name. + * @param array $args Template arguments. + * + * @return string The email subject. + */ + private static function get_email_subject( $template, $args ) { + $blogname = \esc_html( \get_option( 'blogname' ) ); + + switch ( $template ) { + case 'annual-wrapped': + /* translators: 1: Blog name, 2: Year */ + return \sprintf( + \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ), + $blogname, + $args['year'] ?? \gmdate( 'Y' ) + ); + default: + /* translators: %s: Blog name */ + return \sprintf( \__( '[%s] ActivityPub Notification', 'activitypub' ), $blogname ); + } + } + + /** + * Get the plain text body for a template. + * + * @param string $template The template name. + * @param array $args Template arguments. + * + * @return string The plain text body. + */ + private static function get_plain_text_body( $template, $args ) { + switch ( $template ) { + case 'annual-wrapped': + $year = $args['year'] ?? \gmdate( 'Y' ); + /* translators: %d: Year */ + $message = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year ); + + if ( ! empty( $args['posts_count'] ) ) { + /* translators: %d: Number of posts */ + $message .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] ); + } + + if ( ! empty( $args['followers_net_change'] ) ) { + /* translators: %d: Net follower change */ + $message .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] ); + } + + if ( ! empty( $args['most_active_month_name'] ) ) { + /* translators: %s: Month name */ + $message .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $args['most_active_month_name'] ); + } + + return $message; + default: + return ''; + } + } + /** * Apply defaults to the actor object. * diff --git a/includes/class-migration.php b/includes/class-migration.php index d42eb7613b..5b9a0dd778 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -219,8 +219,8 @@ public static function maybe_migrate() { if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { \wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' ); - // Backfill historical statistics data. - \wp_schedule_single_event( \time(), 'activitypub_backfill_statistics' ); + // Backfill historical statistics data (delay to avoid load immediately after upgrade). + \wp_schedule_single_event( \time() + HOUR_IN_SECONDS, 'activitypub_backfill_statistics' ); } // Ensure all required cron schedules are registered. diff --git a/src/dashboard-stats/components/line-chart/index.tsx b/src/dashboard-stats/components/line-chart/index.tsx index aee73fb6db..4d2885bab9 100644 --- a/src/dashboard-stats/components/line-chart/index.tsx +++ b/src/dashboard-stats/components/line-chart/index.tsx @@ -17,13 +17,29 @@ const WP_DEFAULT_COLORS = [ { slug: 'luminous-vivid-orange', hex: '#ff6900' }, ]; +/** + * Simple string hash function for deterministic color assignment. + * Uses djb2 algorithm for consistent results across page loads. + * @param str The string to hash. + */ +function hashString( str: string ): number { + let hash = 5381; + for ( let i = 0; i < str.length; i++ ) { + // eslint-disable-next-line no-bitwise -- djb2 hash algorithm requires XOR. + hash = ( hash * 33 ) ^ str.charCodeAt( i ); + } + return Math.abs( hash ); +} + /** * Get CSS variable with fallback to hex value. * Uses CSS var() with fallback for best compatibility. - * @param index + * Color assignment is deterministic based on type slug hash. + * @param typeSlug The comment type slug for deterministic color. */ -function getColor( index: number ): string { - const color = WP_DEFAULT_COLORS[ index % WP_DEFAULT_COLORS.length ]; +function getColorForType( typeSlug: string ): string { + const index = hashString( typeSlug ) % WP_DEFAULT_COLORS.length; + const color = WP_DEFAULT_COLORS[ index ]; return `var(--wp--preset--color--${ color.slug }, ${ color.hex })`; } @@ -123,8 +139,8 @@ export default function LineChart( { monthly, commentTypes }: Props ) { ]; if ( commentTypes ) { - Object.entries( commentTypes ).forEach( ( [ slug, type ], index ) => { - legendItems.push( { key: slug, label: type.label, color: getColor( index ) } ); + Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => { + legendItems.push( { key: slug, label: type.label, color: getColorForType( slug ) } ); } ); } @@ -132,7 +148,15 @@ export default function LineChart( { monthly, commentTypes }: Props ) {

        { __( 'Engagement Over Time', 'activitypub' ) }

        - + + + { __( 'Line chart showing engagement trends over the past 12 months', 'activitypub' ) } + @@ -157,12 +181,12 @@ export default function LineChart( { monthly, commentTypes }: Props ) { { /* Lines for each engagement type */ } - { typeKeys.map( ( type, index ) => ( + { typeKeys.map( ( type ) => ( diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index c6dac14195..f226a4b240 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -55,20 +55,22 @@ export default function StatHighlights( { const stats: Array< { key: string; label: string; value: number; change: number } > = []; // Add user followers if available (from user-specific stats). + // Note: This shows new followers gained this month, not total followers. if ( canUseUserActor && userComparison?.followers ) { stats.push( { key: 'followers-user', - label: __( 'Followers', 'activitypub' ), + label: __( 'New Followers', 'activitypub' ), value: userComparison.followers.current ?? 0, change: userComparison.followers.change ?? 0, } ); } // Add blog followers if available (from blog-specific stats). + // Note: This shows new followers gained this month, not total followers. if ( canUseBlogActor && blogComparison?.followers ) { stats.push( { key: 'followers-blog', - label: __( 'Followers (Blog)', 'activitypub' ), + label: __( 'New Followers (Blog)', 'activitypub' ), value: blogComparison.followers.current ?? 0, change: blogComparison.followers.change ?? 0, } ); diff --git a/src/dashboard-stats/components/top-posts/index.tsx b/src/dashboard-stats/components/top-posts/index.tsx index 6830d09609..d02a3f8d8f 100644 --- a/src/dashboard-stats/components/top-posts/index.tsx +++ b/src/dashboard-stats/components/top-posts/index.tsx @@ -19,20 +19,32 @@ export default function TopPosts( { posts }: Props ) {

        { __( 'Top Posts', 'activitypub' ) }

          - { posts.map( ( post ) => ( -
        • - - { post.title || __( '(no title)', 'activitypub' ) } - - - { sprintf( - /* translators: %s: engagement count */ - __( '%s engagements', 'activitypub' ), - post.engagement_count.toLocaleString() - ) } - -
        • - ) ) } + { posts.map( ( post ) => { + const title = post.title || __( '(no title)', 'activitypub' ); + return ( +
        • + + { title } + + + { sprintf( + /* translators: %s: engagement count */ + __( '%s engagements', 'activitypub' ), + post.engagement_count.toLocaleString() + ) } + +
        • + ); + } ) }
        ); diff --git a/src/dashboard-stats/components/top-supporter/index.tsx b/src/dashboard-stats/components/top-supporter/index.tsx index 9b5ca29f6d..e4982ef2cc 100644 --- a/src/dashboard-stats/components/top-supporter/index.tsx +++ b/src/dashboard-stats/components/top-supporter/index.tsx @@ -19,7 +19,16 @@ export default function TopSupporter( { multiplicator }: Props ) {

        { __( 'Top Supporter', 'activitypub' ) }

        - + { multiplicator.name } { ' ' } { sprintf( From febdfc7191c22ddfdfb6a693de21e682f2ed93e6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:17:03 +0100 Subject: [PATCH 34/46] Fix translators comment placement in Mailer --- includes/class-mailer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 5bdfb4f76a..d3ceb9bc25 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -499,8 +499,8 @@ private static function get_email_subject( $template, $args ) { switch ( $template ) { case 'annual-wrapped': - /* translators: 1: Blog name, 2: Year */ return \sprintf( + /* translators: 1: Blog name, 2: Year */ \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ), $blogname, $args['year'] ?? \gmdate( 'Y' ) From b3493b687f573042a017f6f6232049656d653b66 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:21:43 +0100 Subject: [PATCH 35/46] Refactor get_active_user_ids to use Actors::get_all_ids --- includes/class-statistics.php | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/includes/class-statistics.php b/includes/class-statistics.php index b62a386329..481d18789a 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -569,30 +569,10 @@ public static function get_follower_count( $user_id ) { /** * Get all active user IDs that have ActivityPub enabled. * - * @return array Array of user IDs including BLOG_USER_ID if enabled. + * @return int[] Array of user IDs including BLOG_USER_ID if enabled. */ public static function get_active_user_ids() { - $user_ids = array(); - - // Check if blog actor is enabled. - $actor_mode = \get_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_MODE ); - if ( \in_array( $actor_mode, array( ACTIVITYPUB_BLOG_MODE, ACTIVITYPUB_ACTOR_AND_BLOG_MODE ), true ) ) { - $user_ids[] = Actors::BLOG_USER_ID; - } - - // Get users with ActivityPub enabled. - if ( \in_array( $actor_mode, array( ACTIVITYPUB_ACTOR_MODE, ACTIVITYPUB_ACTOR_AND_BLOG_MODE ), true ) ) { - $users = \get_users( - array( - 'capability__in' => array( 'activitypub' ), - 'fields' => 'ID', - ) - ); - - $user_ids = \array_merge( $user_ids, $users ); - } - - return $user_ids; + return Actors::get_all_ids(); } /** From b12712e07659b7bf325b6f53f029e28a8c5f3c68 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:23:17 +0100 Subject: [PATCH 36/46] Add federated comments directly in get_comment_types_for_stats --- includes/class-statistics.php | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/includes/class-statistics.php b/includes/class-statistics.php index 481d18789a..a70bb24aba 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -25,24 +25,7 @@ class Statistics { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_filter( 'activitypub_stats_comment_types', array( self::class, 'add_federated_comments_type' ) ); - } - - /** - * Add federated comments to the statistics comment types. - * - * @param array $types The comment types array. - * - * @return array The modified comment types array. - */ - public static function add_federated_comments_type( $types ) { - $types['comment'] = array( - 'slug' => 'comment', - 'label' => \__( 'Comments', 'activitypub' ), - 'singular' => \__( 'Comment', 'activitypub' ), - ); - - return $types; + // Statistics class currently has no hooks to register. } /** @@ -807,11 +790,18 @@ public static function get_comment_types_for_stats() { ); } + // Add federated comments (replies) which use the standard 'comment' type. + $result['comment'] = array( + 'slug' => 'comment', + 'label' => \__( 'Comments', 'activitypub' ), + 'singular' => \__( 'Comment', 'activitypub' ), + ); + /** * Filter the comment types tracked in statistics. * - * Allows adding additional comment types (like federated comments) - * to be tracked in the statistics dashboard. + * Allows adding additional comment types to be tracked + * in the statistics dashboard. * * @param array $result Array of comment type data with slug, label, and singular. */ From e7172cb92a37104f7f2c2467fe38612ab1cff76c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:24:25 +0100 Subject: [PATCH 37/46] Remove unused Statistics::init method --- includes/class-scheduler.php | 2 -- includes/class-statistics.php | 7 ------- 2 files changed, 9 deletions(-) diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 8a69be646e..f567778f2e 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -18,7 +18,6 @@ use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; use Activitypub\Scheduler\Post; -use Activitypub\Scheduler\Statistics; /** * Scheduler class. @@ -101,7 +100,6 @@ public static function register_schedulers() { Actor::init(); Collection_Sync::init(); Comment::init(); - Statistics::init(); /** * Register additional schedulers. diff --git a/includes/class-statistics.php b/includes/class-statistics.php index a70bb24aba..f00b37826d 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -21,13 +21,6 @@ */ class Statistics { - /** - * Initialize the class, registering WordPress hooks. - */ - public static function init() { - // Statistics class currently has no hooks to register. - } - /** * Option prefix for statistics storage. * From 7d82ea718d5d1518226f904b92f2c7165c5ec0cf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:27:49 +0100 Subject: [PATCH 38/46] Consolidate all dashboard widgets into dedicated Dashboard class --- activitypub.php | 2 +- includes/wp-admin/class-admin.php | 75 ------- includes/wp-admin/class-dashboard.php | 199 ++++++++++++++++++ .../wp-admin/class-statistics-dashboard.php | 113 ---------- 4 files changed, 200 insertions(+), 189 deletions(-) create mode 100644 includes/wp-admin/class-dashboard.php delete mode 100644 includes/wp-admin/class-statistics-dashboard.php diff --git a/activitypub.php b/activitypub.php index b729238f37..e6b392b9c6 100644 --- a/activitypub.php +++ b/activitypub.php @@ -142,7 +142,7 @@ function plugin_admin_init() { \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Health_Check', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Settings_Fields', 'init' ) ); - \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Statistics_Dashboard', 'init' ) ); + \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Dashboard', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Welcome_Fields', 'init' ) ); diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index a04b0dd201..77c8f1657f 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -11,7 +11,6 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Extra_Fields; use Activitypub\Comment; -use Activitypub\Model\Blog; use Activitypub\Moderation; use Activitypub\Scheduler\Actor; @@ -73,8 +72,6 @@ public static function init() { \add_action( 'admin_print_scripts-settings_page_activitypub', array( self::class, 'enqueue_moderation_scripts' ) ); \add_action( 'admin_print_footer_scripts-settings_page_activitypub', array( self::class, 'open_help_tab' ) ); - \add_action( 'wp_dashboard_setup', array( self::class, 'add_dashboard_widgets' ) ); - \add_action( 'wp_ajax_activitypub_moderation_settings', array( self::class, 'ajax_moderation_settings' ) ); \add_action( 'wp_ajax_activitypub_blocklist_subscription', array( self::class, 'ajax_blocklist_subscription' ) ); } @@ -947,78 +944,6 @@ function activitypub_open_help_tab(event) { '; - \wp_widget_rss_output( - array( - 'url' => 'https://activitypub.blog/feed/', - 'items' => 3, - 'show_summary' => 1, - 'show_author' => 0, - 'show_date' => 1, - ) - ); - echo '

        '; - } - - /** - * Add the ActivityPub Author profile as a Dashboard widget. - */ - public static function profile_dashboard_widget() { - $user = Actors::get_by_id( \get_current_user_id() ); - ?> -

        - -

        -

        -

        -

        - - - - -

        - -

        - -

        -

        -

        -

        - - - - - - -

        - '; + \wp_widget_rss_output( + array( + 'url' => 'https://activitypub.blog/feed/', + 'items' => 3, + 'show_summary' => 1, + 'show_author' => 0, + 'show_date' => 1, + ) + ); + echo '
        '; + } + + /** + * Render the ActivityPub Author profile widget. + */ + public static function render_author_profile_widget() { + $user = Actors::get_by_id( \get_current_user_id() ); + ?> +

        + +

        +

        +

        +

        + + + + +

        + +

        + +

        +

        +

        +

        + + + + + + +

        +
        '; + } +} diff --git a/includes/wp-admin/class-statistics-dashboard.php b/includes/wp-admin/class-statistics-dashboard.php deleted file mode 100644 index 7174555073..0000000000 --- a/includes/wp-admin/class-statistics-dashboard.php +++ /dev/null @@ -1,113 +0,0 @@ -
        '; - } -} From a5995166e3e4fe304e9fa8099204672fbee4c9c9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 27 Jan 2026 18:29:08 +0100 Subject: [PATCH 39/46] Remove orphaned Statistics::init call --- activitypub.php | 1 - 1 file changed, 1 deletion(-) diff --git a/activitypub.php b/activitypub.php index e6b392b9c6..6da1d330a5 100644 --- a/activitypub.php +++ b/activitypub.php @@ -97,7 +97,6 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ), 0 ); \add_action( 'init', array( __NAMESPACE__ . '\Search', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Signature', 'init' ) ); - \add_action( 'init', array( __NAMESPACE__ . '\Statistics', 'init' ) ); if ( site_supports_blocks() ) { \add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) ); From 3a0d1099551e4dc9dd78efdf97938da8e9a84f2f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 28 Jan 2026 11:15:18 +0100 Subject: [PATCH 40/46] Fix annual wrapped email template name --- includes/scheduler/class-statistics.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 57f58a3931..94fb729f4a 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -127,6 +127,6 @@ private static function send_annual_email( $user_id, $year, $summary ) { $args['most_active_month_name'] = \date_i18n( 'F', \strtotime( sprintf( '%d-%02d-01', $year, $summary['most_active_month'] ) ) ); } - Mailer::send( $user_id, 'annual_wrapped', $args ); + Mailer::send( $user_id, 'annual-wrapped', $args ); } } From 9eb291b3fe6c06d2a3050995a5fe81c0c2273318 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 28 Jan 2026 11:18:16 +0100 Subject: [PATCH 41/46] Refactor Mailer::send() to accept subject as parameter Move subject generation to caller instead of determining it internally based on template name. This simplifies the Mailer class and gives callers more control over email subjects. --- includes/class-mailer.php | 31 ++----------------------- includes/scheduler/class-statistics.php | 9 ++++++- 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index d3ceb9bc25..4e656cbc68 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -431,12 +431,13 @@ public static function mention( $activity, $user_ids ) { * Send a templated email to a user. * * @param int $user_id The user ID (or BLOG_USER_ID for blog actor). + * @param string $subject The email subject. * @param string $template The template name (without path/extension). * @param array $args Template arguments. * * @return bool True if email was sent, false otherwise. */ - public static function send( $user_id, $template, $args = array() ) { + public static function send( $user_id, $subject, $template, $args = array() ) { // Get the recipient email address. if ( $user_id > Actors::BLOG_USER_ID ) { $user = \get_userdata( $user_id ); @@ -448,9 +449,6 @@ public static function send( $user_id, $template, $args = array() ) { $email = \get_option( 'admin_email' ); } - // Build subject based on template type. - $subject = self::get_email_subject( $template, $args ); - // Load the HTML template. $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . $template . '.php'; @@ -486,31 +484,6 @@ public static function send( $user_id, $template, $args = array() ) { return $result; } - /** - * Get the email subject for a template. - * - * @param string $template The template name. - * @param array $args Template arguments. - * - * @return string The email subject. - */ - private static function get_email_subject( $template, $args ) { - $blogname = \esc_html( \get_option( 'blogname' ) ); - - switch ( $template ) { - case 'annual-wrapped': - return \sprintf( - /* translators: 1: Blog name, 2: Year */ - \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ), - $blogname, - $args['year'] ?? \gmdate( 'Y' ) - ); - default: - /* translators: %s: Blog name */ - return \sprintf( \__( '[%s] ActivityPub Notification', 'activitypub' ), $blogname ); - } - } - /** * Get the plain text body for a template. * diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 94fb729f4a..2a7c96a1d8 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -127,6 +127,13 @@ private static function send_annual_email( $user_id, $year, $summary ) { $args['most_active_month_name'] = \date_i18n( 'F', \strtotime( sprintf( '%d-%02d-01', $year, $summary['most_active_month'] ) ) ); } - Mailer::send( $user_id, 'annual-wrapped', $args ); + $subject = \sprintf( + /* translators: 1: Blog name, 2: Year */ + \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ), + \esc_html( \get_option( 'blogname' ) ), + $year + ); + + Mailer::send( $user_id, $subject, 'annual-wrapped', $args ); } } From fe2cccd21a2efc6ed2a58651509f6868986391f0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 11:40:06 +0100 Subject: [PATCH 42/46] Add Stats_Command to production CLI Register collect and compile as proper subcommands following the existing CLI structure pattern. --- includes/class-cli.php | 9 ++ includes/cli/class-stats-command.php | 118 +++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 includes/cli/class-stats-command.php diff --git a/includes/class-cli.php b/includes/class-cli.php index 34a73565b4..15ce228709 100644 --- a/includes/class-cli.php +++ b/includes/class-cli.php @@ -30,6 +30,7 @@ class Cli { * - wp activitypub self-destruct [--status] [--yes] * - wp activitypub move * - wp activitypub follow + * - wp activitypub stats */ public static function register() { // Register parent command with version subcommand. @@ -96,5 +97,13 @@ public static function register() { 'shortdesc' => 'Follow a remote ActivityPub user.', ) ); + + \WP_CLI::add_command( + 'activitypub stats', + '\Activitypub\Cli\Stats_Command', + array( + 'shortdesc' => 'Manage ActivityPub statistics (collect or compile).', + ) + ); } } diff --git a/includes/cli/class-stats-command.php b/includes/cli/class-stats-command.php new file mode 100644 index 0000000000..372dc506c4 --- /dev/null +++ b/includes/cli/class-stats-command.php @@ -0,0 +1,118 @@ +] + * : The user ID to collect stats for. Omit to collect for all active users. + * + * [--year=] + * : The year to collect stats for. Defaults to current year. + * + * [--month=] + * : The month to collect stats for (1-12). Defaults to current month. + * + * [--force] + * : Force recollection even if stats already exist. + * + * ## EXAMPLES + * + * # Collect real stats for current month + * $ wp activitypub stats collect + * + * # Collect stats for a specific month + * $ wp activitypub stats collect --year=2024 --month=6 + * + * # Force recollect stats for a specific user + * $ wp activitypub stats collect --user_id=1 --force + * + * @subcommand collect + * + * @param array $args The positional arguments (unused). + * @param array $assoc_args The associative arguments. + */ + public function collect( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : (int) \gmdate( 'Y' ); + $month = isset( $assoc_args['month'] ) ? (int) $assoc_args['month'] : (int) \gmdate( 'n' ); + $force = isset( $assoc_args['force'] ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + + foreach ( $user_ids as $uid ) { + if ( $force ) { + $option_name = Statistics::get_monthly_option_name( $uid, $year, $month ); + \delete_option( $option_name ); + } + Statistics::collect_monthly_stats( $uid, $year, $month ); + } + + $count = count( $user_ids ); + \WP_CLI::success( "Monthly stats collected for {$count} user(s) ({$year}-{$month})." ); + } + + /** + * Compile annual statistics. + * + * Aggregates monthly statistics into an annual summary including totals, + * averages, and highlights for the year. + * + * ## OPTIONS + * + * [--user_id=] + * : The user ID to compile stats for. Omit to compile for all active users. + * + * [--year=] + * : The year to compile stats for. Defaults to previous year. + * + * ## EXAMPLES + * + * # Compile annual stats for previous year + * $ wp activitypub stats compile + * + * # Compile annual stats for a specific year + * $ wp activitypub stats compile --year=2024 + * + * # Compile for a specific user + * $ wp activitypub stats compile --user_id=1 --year=2024 + * + * @subcommand compile + * + * @param array $args The positional arguments (unused). + * @param array $assoc_args The associative arguments. + */ + public function compile( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : ( (int) \gmdate( 'Y' ) - 1 ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + + foreach ( $user_ids as $uid ) { + Statistics::compile_annual_summary( $uid, $year ); + } + + $count = count( $user_ids ); + \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$year})." ); + } +} From 1f4017d6343ffa35492b44d932b951e380b64b76 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 11:44:12 +0100 Subject: [PATCH 43/46] Move Statistics_Controller to admin REST namespace Move the stats endpoint under the admin subfolder and update the rest_base to admin/stats to match the admin controller pattern. --- activitypub.php | 2 +- includes/rest/{ => admin}/class-statistics-controller.php | 4 ++-- src/dashboard-stats/components/stats-widget/index.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename includes/rest/{ => admin}/class-statistics-controller.php (97%) diff --git a/activitypub.php b/activitypub.php index 5b2791e26c..357f298569 100644 --- a/activitypub.php +++ b/activitypub.php @@ -52,6 +52,7 @@ function rest_init() { ( new Rest\Actors_Controller() )->register_routes(); ( new Rest\Actors_Inbox_Controller() )->register_routes(); ( new Rest\Admin\Actions_Controller() )->register_routes(); + ( new Rest\Admin\Statistics_Controller() )->register_routes(); ( new Rest\Application_Controller() )->register_routes(); ( new Rest\Collections_Controller() )->register_routes(); ( new Rest\Comments_Controller() )->register_routes(); @@ -63,7 +64,6 @@ function rest_init() { ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Post_Controller() )->register_routes(); ( new Rest\Replies_Controller() )->register_routes(); - ( new Rest\Statistics_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); // Load NodeInfo endpoints only if blog is public. diff --git a/includes/rest/class-statistics-controller.php b/includes/rest/admin/class-statistics-controller.php similarity index 97% rename from includes/rest/class-statistics-controller.php rename to includes/rest/admin/class-statistics-controller.php index 20d421d659..babf60531a 100644 --- a/includes/rest/class-statistics-controller.php +++ b/includes/rest/admin/class-statistics-controller.php @@ -5,7 +5,7 @@ * @package Activitypub */ -namespace Activitypub\Rest; +namespace Activitypub\Rest\Admin; use Activitypub\Collection\Actors; use Activitypub\Statistics; @@ -29,7 +29,7 @@ class Statistics_Controller extends \WP_REST_Controller { * * @var string */ - protected $rest_base = 'stats'; + protected $rest_base = 'admin/stats'; /** * Register routes. diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index 09d717d67c..8c6bd32ce5 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -72,7 +72,7 @@ export default function StatsWidget() { // Fetch blog stats (global engagement data) - only if user has blog capability. const blogStatsPromise = canUseBlogActor ? apiFetch< StatsResponse >( { - path: `/activitypub/1.0/stats/${ BLOG_USER_ID }`, + path: `/activitypub/1.0/admin/stats/${ BLOG_USER_ID }`, } ).catch( () => null ) : Promise.resolve( null ); @@ -80,7 +80,7 @@ export default function StatsWidget() { const userStatsPromise = canUseUserActor && currentUser?.id ? apiFetch< StatsResponse >( { - path: `/activitypub/1.0/stats/${ currentUser.id }`, + path: `/activitypub/1.0/admin/stats/${ currentUser.id }`, } ).catch( () => null ) : Promise.resolve( null ); From c6465a6b7f078e61c5bcf737cd7dfbcaf26e4cf5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 11:56:33 +0100 Subject: [PATCH 44/46] Fix code review issues in statistics feature - Register Scheduler\Statistics::init() so cron callbacks work - Add import section comments to all dashboard-stats TSX files - Fix auto-generated @param root0 JSDoc with proper annotations - Add ReactNode return type to all component functions - Fix missing backslash on current_user_can in dashboard template --- build/dashboard-stats/index.asset.php | 2 +- build/dashboard-stats/index.js | 6 +++--- includes/class-scheduler.php | 2 ++ includes/wp-admin/class-dashboard.php | 2 +- .../components/line-chart/index.tsx | 19 +++++++++++++++---- .../components/stat-highlights/index.tsx | 14 +++++++++++++- .../components/stats-widget/index.tsx | 14 +++++++++++++- .../components/top-posts/index.tsx | 18 +++++++++++++++--- .../components/top-supporter/index.tsx | 18 +++++++++++++++--- src/dashboard-stats/index.tsx | 9 +++++++++ 10 files changed, 87 insertions(+), 17 deletions(-) diff --git a/build/dashboard-stats/index.asset.php b/build/dashboard-stats/index.asset.php index 7ca95b64db..4fbbdc7147 100644 --- a/build/dashboard-stats/index.asset.php +++ b/build/dashboard-stats/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '98072ec053dbe5ebed71'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '7fc94612f94e9a40bbb8'); diff --git a/build/dashboard-stats/index.js b/build/dashboard-stats/index.js index 1f752c0c49..265974de1f 100644 --- a/build/dashboard-stats/index.js +++ b/build/dashboard-stats/index.js @@ -1,5 +1,5 @@ -(()=>{"use strict";var e,t={2320(e,t,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const r=window.wp.data,l=window.wp.coreData,o=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:a,commentTypes:s,canUseUserActor:i,canUseBlogActor:n}){var r,l;if(!e)return null;const o=[];var u,h,d,v;return i&&t?.followers&&o.push({key:"followers-user",label:(0,c.__)("New Followers","activitypub"),value:null!==(u=t.followers.current)&&void 0!==u?u:0,change:null!==(h=t.followers.change)&&void 0!==h?h:0}),n&&a?.followers&&o.push({key:"followers-blog",label:(0,c.__)("New Followers (Blog)","activitypub"),value:null!==(d=a.followers.current)&&void 0!==d?d:0,change:null!==(v=a.followers.change)&&void 0!==v?v:0}),o.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:null!==(r=e.posts?.current)&&void 0!==r?r:0,change:null!==(l=e.posts?.change)&&void 0!==l?l:0}),s&&Object.entries(s).forEach(([t,a])=>{const s=e[t];var i,n;s&&"object"==typeof s&&"current"in s&&o.push({key:t,label:a.label,value:null!==(i=s.current)&&void 0!==i?i:0,change:null!==(n=s.change)&&void 0!==n?n:0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:o.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),a=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:a}):(0,p.jsx)("span",{children:a}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=function(e){let t=5381;for(let a=0;ae.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),l=e.map((t,a)=>40+a/(e.length-1||1)*540),o=e.map((e,t)=>({x:l[t],y:170-(e.engagement||0)/r*i,month:e})),u=o.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=u+` L ${o[o.length-1].x} 170`+` L ${o[0].x} 170 Z`,v=t=>e.map((e,a)=>{const s=e[`${t}_count`]||0,n=l[a],o=170-s/r*i;return 0===a?`M ${n} ${o}`:`L ${n} ${o}`}).join(" "),b=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],m=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return t&&Object.entries(t).forEach(([e,t])=>{m.push({key:e,label:t.label,color:d(e)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",role:"img","aria-labelledby":"activitypub-chart-title",children:[(0,p.jsx)("title",{id:"activitypub-chart-title",children:(0,c.__)("Line chart showing engagement trends over the past 12 months","activitypub")}),(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-e),x2:580,y2:s+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map(e=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:d(e),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),o.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:a},t)),o.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:b[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:s+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:m.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function b({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: supporter name */ /* translators: %s: supporter name */ +(()=>{"use strict";var e,t={2320(e,t,a){const s=window.wp.element,i=window.wp.apiFetch;var n=a.n(i);const r=window.wp.data,o=window.wp.coreData,l=window.wp.components,c=window.wp.i18n,p=window.ReactJSXRuntime;function u({comparison:e,userComparison:t,blogComparison:a,commentTypes:s,canUseUserActor:i,canUseBlogActor:n}){if(!e)return null;const r=[];return i&&t?.followers&&r.push({key:"followers-user",label:(0,c.__)("New Followers","activitypub"),value:t.followers.current??0,change:t.followers.change??0}),n&&a?.followers&&r.push({key:"followers-blog",label:(0,c.__)("New Followers (Blog)","activitypub"),value:a.followers.current??0,change:a.followers.change??0}),r.push({key:"posts",label:(0,c.__)("Posts","activitypub"),value:e.posts?.current??0,change:e.posts?.change??0}),s&&Object.entries(s).forEach(([t,a])=>{const s=e[t];s&&"object"==typeof s&&"current"in s&&r.push({key:t,label:a.label,value:s.current??0,change:s.change??0})}),(0,p.jsxs)("div",{className:"activitypub-stats-highlights main",children:[(0,p.jsx)("h3",{children:(0,c.__)("This month vs. last month","activitypub")}),(0,p.jsx)("ul",{children:r.map(e=>{const t=function(e){switch(e){case"followers":case"followers-user":return"users.php?page=activitypub-followers-list";case"followers-blog":return"options-general.php?page=activitypub&tab=followers";case"posts":return"edit.php";default:return`edit-comments.php?comment_type=${e}`}}(e.key),a=(0,p.jsxs)(p.Fragment,{children:[e.value.toLocaleString()," ",e.label]});return(0,p.jsxs)("li",{className:`activitypub-${e.key.replace("-user","").replace("-blog","")}-count`,children:[t?(0,p.jsx)("a",{href:t,children:a}):(0,p.jsx)("span",{children:a}),0!==e.change&&" ",0!==e.change&&(0,p.jsxs)("span",{className:"stat-change "+(e.change>0?"positive":"negative"),children:["(",e.change>0?"+":"",e.change.toLocaleString(),")"]})]},e.key)})})]})}const h=[{slug:"vivid-red",hex:"#cf2e2e"},{slug:"vivid-green-cyan",hex:"#00d084"},{slug:"luminous-vivid-amber",hex:"#fcb900"},{slug:"vivid-purple",hex:"#9b51e0"},{slug:"vivid-cyan-blue",hex:"#0693e3"},{slug:"luminous-vivid-orange",hex:"#ff6900"}];function d(e){const t=function(e){let t=5381;for(let a=0;ae.engagement||0),...n.flatMap(t=>e.map(e=>e[`${t}_count`]||0)),1),o=e.map((t,a)=>40+a/(e.length-1||1)*540),l=e.map((e,t)=>({x:o[t],y:170-(e.engagement||0)/r*i,month:e})),u=l.map((e,t)=>0===t?`M ${e.x} ${e.y}`:`L ${e.x} ${e.y}`).join(" "),h=u+` L ${l[l.length-1].x} 170`+` L ${l[0].x} 170 Z`,v=t=>e.map((e,a)=>{const s=e[`${t}_count`]||0,n=o[a],l=170-s/r*i;return 0===a?`M ${n} ${l}`:`L ${n} ${l}`}).join(" "),m=[(0,c.__)("Jan","activitypub"),(0,c.__)("Feb","activitypub"),(0,c.__)("Mar","activitypub"),(0,c.__)("Apr","activitypub"),(0,c.__)("May","activitypub"),(0,c.__)("Jun","activitypub"),(0,c.__)("Jul","activitypub"),(0,c.__)("Aug","activitypub"),(0,c.__)("Sep","activitypub"),(0,c.__)("Oct","activitypub"),(0,c.__)("Nov","activitypub"),(0,c.__)("Dec","activitypub")],b=[{key:"engagement",label:(0,c.__)("Total Engagement","activitypub"),color:a}];return t&&Object.entries(t).forEach(([e,t])=>{b.push({key:e,label:t.label,color:d(e)})}),(0,p.jsxs)("div",{className:"activitypub-stats-chart",children:[(0,p.jsx)("h3",{children:(0,c.__)("Engagement Over Time","activitypub")}),(0,p.jsxs)("div",{className:"activitypub-chart-container",children:[(0,p.jsxs)("svg",{viewBox:"0 0 600 200",className:"activitypub-line-chart",role:"img","aria-labelledby":"activitypub-chart-title",children:[(0,p.jsx)("title",{id:"activitypub-chart-title",children:(0,c.__)("Line chart showing engagement trends over the past 12 months","activitypub")}),(0,p.jsx)("defs",{children:(0,p.jsxs)("linearGradient",{id:"areaGradient",x1:"0%",y1:"0%",x2:"0%",y2:"100%",children:[(0,p.jsx)("stop",{offset:"0%",stopColor:a,stopOpacity:.3}),(0,p.jsx)("stop",{offset:"100%",stopColor:a,stopOpacity:.05})]})}),[0,.25,.5,.75,1].map(e=>(0,p.jsx)("line",{x1:40,y1:s+i*(1-e),x2:580,y2:s+i*(1-e),stroke:"#e0e0e0",strokeWidth:"1"},e)),(0,p.jsx)("path",{d:h,fill:"url(#areaGradient)"}),n.map(e=>(0,p.jsx)("path",{d:v(e),fill:"none",stroke:d(e),strokeWidth:"2",strokeOpacity:"0.7"},e)),(0,p.jsx)("path",{d:u,fill:"none",stroke:a,strokeWidth:"2"}),l.map((e,t)=>(0,p.jsx)("circle",{cx:e.x,cy:e.y,r:"4",fill:a},t)),l.map((e,t)=>(0,p.jsx)("text",{x:e.x,y:195,textAnchor:"middle",className:"chart-label",children:m[e.month.month-1]},t)),[0,.5,1].map(e=>(0,p.jsx)("text",{x:35,y:s+i*(1-e)+4,textAnchor:"end",className:"chart-label",children:Math.round(r*e)},e))]}),(0,p.jsx)("div",{className:"activitypub-chart-legend",children:b.map(e=>(0,p.jsxs)("div",{className:"activitypub-legend-item",children:[(0,p.jsx)("span",{className:"legend-color",style:{backgroundColor:e.color}}),e.label]},e.key))})]})]})}function m({multiplicator:e}){return e?.name?(0,p.jsxs)("div",{className:"activitypub-stats-multiplicator",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Supporter","activitypub")}),(0,p.jsxs)("p",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: supporter name */ /* translators: %s: supporter name */ (0,c.__)("%s (opens in a new tab)","activitypub"),e.name),children:e.name})," ",(0,c.sprintf)(/* translators: %s: number of boosts */ /* translators: %s: number of boosts */ -(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function m({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>{const t=e.title||(0,c.__)("(no title)","activitypub");return(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: post title */ /* translators: %s: post title */ +(0,c._n)("(%s boost)","(%s boosts)",e.count,"activitypub"),e.count.toLocaleString())]})]}):null}function b({posts:e}){return e?.length?(0,p.jsxs)("div",{className:"activitypub-stats-top-posts",children:[(0,p.jsx)("h3",{children:(0,c.__)("Top Posts","activitypub")}),(0,p.jsx)("ul",{children:e.map(e=>{const t=e.title||(0,c.__)("(no title)","activitypub");return(0,p.jsxs)("li",{children:[(0,p.jsx)("a",{href:e.url,target:"_blank",rel:"noopener noreferrer","aria-label":(0,c.sprintf)(/* translators: %s: post title */ /* translators: %s: post title */ (0,c.__)("%s (opens in a new tab)","activitypub"),t),children:t}),(0,p.jsx)("span",{className:"engagement-count",children:(0,c.sprintf)(/* translators: %s: engagement count */ /* translators: %s: engagement count */ -(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id)})})]}):null}const y="actor_blog";function g(){var e,t;const{currentUser:a,actorMode:i,hasUserCap:h,hasBlogCap:d,isResolving:g}=(0,r.useSelect)(e=>{var t;return{currentUser:e(l.store).getCurrentUser(),actorMode:null!==(t=e(l.store).getEntityRecord("root","site")?.activitypub_actor_mode)&&void 0!==t?t:y,hasUserCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(l.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(l.store).isResolving("getCurrentUser",[])}},[]),x=("actor"===i||i===y)&&h&&!!a?.id,_=("blog"===i||i===y)&&d,[f,j]=(0,s.useState)(null),[w,k]=(0,s.useState)(null),[N,O]=(0,s.useState)(null),[$,C]=(0,s.useState)(!0);return(0,s.useEffect)(()=>{if(g)return;C(!0);const e=_?n()({path:"/activitypub/1.0/stats/0"}).catch(()=>null):Promise.resolve(null),t=x&&a?.id?n()({path:`/activitypub/1.0/stats/${a.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([e,t]).then(([e,t])=>{j(null!=e?e:t),O(e),k(t)}).finally(()=>C(!1))},[g,x,_,a?.id]),g||$?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(o.Spinner,{})})}):f?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:f.comparison,userComparison:null!==(e=w?.comparison)&&void 0!==e?e:null,blogComparison:null!==(t=N?.comparison)&&void 0!==t?t:null,commentTypes:f.comment_types,canUseUserActor:x,canUseBlogActor:_}),(0,p.jsx)(v,{monthly:f.monthly,commentTypes:f.comment_types}),(0,p.jsx)(b,{multiplicator:f.stats?.top_multiplicator}),(0,p.jsx)(m,{posts:f.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,s.createRoot)(t).render((0,p.jsx)(g,{}))}}}},a={};function s(e){var i=a[e];if(void 0!==i)return i.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,s),n.exports}s.m=t,e=[],s.O=(t,a,i,n)=>{if(!a){var r=1/0;for(p=0;p=n)&&Object.keys(s.O).every(e=>s.O[e](a[o]))?a.splice(o--,1):(l=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[a,i,n]},s.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return s.d(t,{a:t}),t},s.d=(e,t)=>{for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};s.O.j=t=>0===e[t];var t=(t,a)=>{var i,n,[r,l,o]=a,c=0;if(r.some(t=>0!==e[t])){for(i in l)s.o(l,i)&&(s.m[i]=l[i]);if(o)var p=o(s)}for(t&&t(a);cs(2320));i=s.O(i)})(); \ No newline at end of file +(0,c.__)("%s engagements","activitypub"),e.engagement_count.toLocaleString())})]},e.post_id)})})]}):null}const y="actor_blog";function g(){const{currentUser:e,actorMode:t,hasUserCap:a,hasBlogCap:i,isResolving:h}=(0,r.useSelect)(e=>({currentUser:e(o.store).getCurrentUser(),actorMode:e(o.store).getEntityRecord("root","site")?.activitypub_actor_mode??y,hasUserCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield"}),hasBlogCap:e(o.store).canUser("create",{kind:"postType",name:"ap_extrafield_blog"}),isResolving:e(o.store).isResolving("getCurrentUser",[])}),[]),d=("actor"===t||t===y)&&a&&!!e?.id,g=("blog"===t||t===y)&&i,[x,_]=(0,s.useState)(null),[f,j]=(0,s.useState)(null),[w,k]=(0,s.useState)(null),[N,O]=(0,s.useState)(!0);return(0,s.useEffect)(()=>{if(h)return;O(!0);const t=g?n()({path:"/activitypub/1.0/admin/stats/0"}).catch(()=>null):Promise.resolve(null),a=d&&e?.id?n()({path:`/activitypub/1.0/admin/stats/${e.id}`}).catch(()=>null):Promise.resolve(null);Promise.all([t,a]).then(([e,t])=>{_(e??t),k(e),j(t)}).finally(()=>O(!1))},[h,d,g,e?.id]),h||N?(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("div",{className:"activitypub-stats-loading",children:(0,p.jsx)(l.Spinner,{})})}):x?(0,p.jsxs)("div",{className:"activitypub-stats-widget",children:[(0,p.jsx)(u,{comparison:x.comparison,userComparison:f?.comparison??null,blogComparison:w?.comparison??null,commentTypes:x.comment_types,canUseUserActor:d,canUseBlogActor:g}),(0,p.jsx)(v,{monthly:x.monthly,commentTypes:x.comment_types}),(0,p.jsx)(m,{multiplicator:x.stats?.top_multiplicator}),(0,p.jsx)(b,{posts:x.stats?.top_posts})]}):(0,p.jsx)("div",{className:"activitypub-stats-widget",children:(0,p.jsx)("p",{className:"activitypub-stats-empty",children:(0,c.__)("No statistics available yet.","activitypub")})})}window.activitypub=window.activitypub||{},window.activitypub.dashboardStats={initialize:function(e){const t=document.getElementById(e);t&&(0,s.createRoot)(t).render((0,p.jsx)(g,{}))}}}},a={};function s(e){var i=a[e];if(void 0!==i)return i.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,s),n.exports}s.m=t,e=[],s.O=(t,a,i,n)=>{if(!a){var r=1/0;for(p=0;p=n)&&Object.keys(s.O).every(e=>s.O[e](a[l]))?a.splice(l--,1):(o=!1,n0&&e[p-1][2]>n;p--)e[p]=e[p-1];e[p]=[a,i,n]},s.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return s.d(t,{a:t}),t},s.d=(e,t)=>{for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},s.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={306:0,598:0};s.O.j=t=>0===e[t];var t=(t,a)=>{var i,n,[r,o,l]=a,c=0;if(r.some(t=>0!==e[t])){for(i in o)s.o(o,i)&&(s.m[i]=o[i]);if(l)var p=l(s)}for(t&&t(a);cs(2320));i=s.O(i)})(); \ No newline at end of file diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index f567778f2e..8a69be646e 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -18,6 +18,7 @@ use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; use Activitypub\Scheduler\Post; +use Activitypub\Scheduler\Statistics; /** * Scheduler class. @@ -100,6 +101,7 @@ public static function register_schedulers() { Actor::init(); Collection_Sync::init(); Comment::init(); + Statistics::init(); /** * Register additional schedulers. diff --git a/includes/wp-admin/class-dashboard.php b/includes/wp-admin/class-dashboard.php index e104a5960a..2754b94f18 100644 --- a/includes/wp-admin/class-dashboard.php +++ b/includes/wp-admin/class-dashboard.php @@ -181,7 +181,7 @@ public static function render_blog_profile_widget() {

        - + diff --git a/src/dashboard-stats/components/line-chart/index.tsx b/src/dashboard-stats/components/line-chart/index.tsx index 4d2885bab9..d9d41be26b 100644 --- a/src/dashboard-stats/components/line-chart/index.tsx +++ b/src/dashboard-stats/components/line-chart/index.tsx @@ -1,4 +1,16 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ import type { MonthData, CommentType } from '../../types'; interface Props { @@ -54,11 +66,10 @@ function getEngagementColor(): string { * Line Chart Component. * * Renders an SVG line chart for monthly engagement data. - * @param root0 - * @param root0.monthly - * @param root0.commentTypes + * + * @param {Props} props Component props. */ -export default function LineChart( { monthly, commentTypes }: Props ) { +export default function LineChart( { monthly, commentTypes }: Props ): ReactNode { if ( ! monthly?.length ) { return null; } diff --git a/src/dashboard-stats/components/stat-highlights/index.tsx b/src/dashboard-stats/components/stat-highlights/index.tsx index f226a4b240..7425d29547 100644 --- a/src/dashboard-stats/components/stat-highlights/index.tsx +++ b/src/dashboard-stats/components/stat-highlights/index.tsx @@ -1,4 +1,16 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ import type { Comparison, CommentType } from '../../types'; interface Props { @@ -46,7 +58,7 @@ export default function StatHighlights( { commentTypes, canUseUserActor, canUseBlogActor, -}: Props ) { +}: Props ): ReactNode { if ( ! comparison ) { return null; } diff --git a/src/dashboard-stats/components/stats-widget/index.tsx b/src/dashboard-stats/components/stats-widget/index.tsx index 8c6bd32ce5..80f46bc18d 100644 --- a/src/dashboard-stats/components/stats-widget/index.tsx +++ b/src/dashboard-stats/components/stats-widget/index.tsx @@ -1,9 +1,21 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ import apiFetch from '@wordpress/api-fetch'; import { useState, useEffect } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { Spinner } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ import StatHighlights from '../stat-highlights'; import LineChart from '../line-chart'; import TopSupporter from '../top-supporter'; @@ -23,7 +35,7 @@ const BLOG_USER_ID = 0; * * Displays global engagement stats and follower counts for available actors. */ -export default function StatsWidget() { +export default function StatsWidget(): ReactNode { const { currentUser, actorMode, hasUserCap, hasBlogCap, isResolving } = useSelect( ( select ) => ( { currentUser: select( coreStore ).getCurrentUser(), diff --git a/src/dashboard-stats/components/top-posts/index.tsx b/src/dashboard-stats/components/top-posts/index.tsx index d02a3f8d8f..091b46ed6d 100644 --- a/src/dashboard-stats/components/top-posts/index.tsx +++ b/src/dashboard-stats/components/top-posts/index.tsx @@ -1,4 +1,16 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ import type { TopPost } from '../../types'; interface Props { @@ -7,10 +19,10 @@ interface Props { /** * Top Posts Component. - * @param root0 - * @param root0.posts + * + * @param {Props} props Component props. */ -export default function TopPosts( { posts }: Props ) { +export default function TopPosts( { posts }: Props ): ReactNode { if ( ! posts?.length ) { return null; } diff --git a/src/dashboard-stats/components/top-supporter/index.tsx b/src/dashboard-stats/components/top-supporter/index.tsx index e4982ef2cc..c901a3c492 100644 --- a/src/dashboard-stats/components/top-supporter/index.tsx +++ b/src/dashboard-stats/components/top-supporter/index.tsx @@ -1,4 +1,16 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * WordPress dependencies + */ import { __, _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ import type { Multiplicator } from '../../types'; interface Props { @@ -7,10 +19,10 @@ interface Props { /** * Top Supporter Component. - * @param root0 - * @param root0.multiplicator + * + * @param {Props} props Component props. */ -export default function TopSupporter( { multiplicator }: Props ) { +export default function TopSupporter( { multiplicator }: Props ): ReactNode { if ( ! multiplicator?.name ) { return null; } diff --git a/src/dashboard-stats/index.tsx b/src/dashboard-stats/index.tsx index c2fe5026cd..76c7fbdd59 100644 --- a/src/dashboard-stats/index.tsx +++ b/src/dashboard-stats/index.tsx @@ -1,4 +1,11 @@ +/** + * WordPress dependencies + */ import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ import StatsWidget from './components/stats-widget'; import './style.scss'; @@ -14,6 +21,8 @@ declare global { /** * Initialize the dashboard stats widget. + * + * @param {string} id The container element ID. */ export function initialize( id: string ) { const container = document.getElementById( id ); From 6b5e54fda68d7be6dc1cc6a37b0f843b5763bf2e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 11 Feb 2026 12:11:56 +0100 Subject: [PATCH 45/46] Improve annual report email: generic Mailer::send(), user opt-out, CLI send command - Make Mailer::send() accept an optional $alt_body parameter; auto-generate plain text from HTML via wp_strip_all_tags() when not provided - Remove tightly-coupled get_plain_text_body() from Mailer; move plain text generation into the Statistics scheduler caller - Add activitypub_mailer_annual_report user/blog preference with checkbox in both user profile and blog settings notification fieldsets - Fix esc_html() misuse on %d format specifier in annual-wrapped template - Add `wp activitypub stats send` CLI subcommand for testing the annual report email without waiting for cron - Make Statistics::send_annual_email() public for CLI access --- includes/class-activitypub.php | 12 +++++ includes/class-mailer.php | 45 +++------------- includes/class-options.php | 10 ++++ includes/cli/class-stats-command.php | 54 +++++++++++++++++++ includes/scheduler/class-statistics.php | 32 ++++++++++- includes/wp-admin/class-admin.php | 1 + .../wp-admin/class-blog-settings-fields.php | 6 +++ .../wp-admin/class-user-settings-fields.php | 6 +++ templates/emails/annual-wrapped.php | 4 +- 9 files changed, 127 insertions(+), 43 deletions(-) diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index e55a8f36e0..939bd6dcd4 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -292,6 +292,18 @@ public static function register_user_meta() { ); \add_filter( 'get_user_option_activitypub_mailer_new_mention', array( self::class, 'user_options_default' ) ); + \register_meta( + 'user', + $blog_prefix . 'activitypub_mailer_annual_report', + array( + 'type' => 'integer', + 'description' => 'Send the annual Fediverse Year in Review email.', + 'single' => true, + 'sanitize_callback' => 'absint', + ) + ); + \add_filter( 'get_user_option_activitypub_mailer_annual_report', array( self::class, 'user_options_default' ) ); + \register_meta( 'user', 'activitypub_show_welcome_tab', diff --git a/includes/class-mailer.php b/includes/class-mailer.php index c069dae8a0..1188d39942 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -434,10 +434,11 @@ public static function mention( $activity, $user_ids ) { * @param string $subject The email subject. * @param string $template The template name (without path/extension). * @param array $args Template arguments. + * @param string $alt_body Optional plain text alternative. Auto-generated from HTML if empty. * * @return bool True if email was sent, false otherwise. */ - public static function send( $user_id, $subject, $template, $args = array() ) { + public static function send( $user_id, $subject, $template, $args = array(), $alt_body = '' ) { // Get the recipient email address. if ( $user_id > Actors::BLOG_USER_ID ) { $user = \get_userdata( $user_id ); @@ -470,8 +471,10 @@ public static function send( $user_id, $subject, $template, $args = array() ) { \load_template( $template_file, false, $args ); $html_message = \ob_get_clean(); - // Build plain text alternative. - $alt_body = self::get_plain_text_body( $template, $args ); + // Build plain text alternative from HTML if not provided. + if ( empty( $alt_body ) ) { + $alt_body = \wp_strip_all_tags( $html_message ); + } $alt_function = static function ( $mailer ) use ( $alt_body ) { $mailer->{'AltBody'} = $alt_body; }; @@ -484,42 +487,6 @@ public static function send( $user_id, $subject, $template, $args = array() ) { return $result; } - /** - * Get the plain text body for a template. - * - * @param string $template The template name. - * @param array $args Template arguments. - * - * @return string The plain text body. - */ - private static function get_plain_text_body( $template, $args ) { - switch ( $template ) { - case 'annual-wrapped': - $year = $args['year'] ?? \gmdate( 'Y' ); - /* translators: %d: Year */ - $message = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year ); - - if ( ! empty( $args['posts_count'] ) ) { - /* translators: %d: Number of posts */ - $message .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] ); - } - - if ( ! empty( $args['followers_net_change'] ) ) { - /* translators: %d: Net follower change */ - $message .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] ); - } - - if ( ! empty( $args['most_active_month_name'] ) ) { - /* translators: %s: Month name */ - $message .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $args['most_active_month_name'] ); - } - - return $message; - default: - return ''; - } - } - /** * Apply defaults to the actor object. * diff --git a/includes/class-options.php b/includes/class-options.php index a89f667863..9a47641e63 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -414,6 +414,16 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub_blog', + 'activitypub_mailer_annual_report', + array( + 'type' => 'integer', + 'description' => 'Send the annual Fediverse Year in Review email.', + 'default' => 1, + ) + ); + \register_setting( 'activitypub_blog', 'activitypub_blog_user_also_known_as', diff --git a/includes/cli/class-stats-command.php b/includes/cli/class-stats-command.php index 372dc506c4..337fc2e53b 100644 --- a/includes/cli/class-stats-command.php +++ b/includes/cli/class-stats-command.php @@ -7,6 +7,7 @@ namespace Activitypub\Cli; +use Activitypub\Scheduler\Statistics as Statistics_Scheduler; use Activitypub\Statistics; /** @@ -115,4 +116,57 @@ public function compile( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis $count = count( $user_ids ); \WP_CLI::success( "Annual stats compiled for {$count} user(s) ({$year})." ); } + + /** + * Send the annual report email. + * + * Compiles annual statistics and sends the Fediverse Year in Review + * email for the specified year. + * + * ## OPTIONS + * + * [--user_id=] + * : The user ID to send the email for. Omit to send for all active users. + * + * [--year=] + * : The year to send the report for. Defaults to previous year. + * + * ## EXAMPLES + * + * # Send annual report for previous year + * $ wp activitypub stats send + * + * # Send annual report for a specific year + * $ wp activitypub stats send --year=2025 + * + * # Send for a specific user + * $ wp activitypub stats send --user_id=1 --year=2025 + * + * @subcommand send + * + * @param array $args The positional arguments (unused). + * @param array $assoc_args The associative arguments. + */ + public function send( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $user_id = isset( $assoc_args['user_id'] ) ? (int) $assoc_args['user_id'] : null; + $year = isset( $assoc_args['year'] ) ? (int) $assoc_args['year'] : ( (int) \gmdate( 'Y' ) - 1 ); + + $user_ids = $user_id ? array( $user_id ) : Statistics::get_active_user_ids(); + + $sent = 0; + foreach ( $user_ids as $uid ) { + $summary = Statistics::compile_annual_summary( $uid, $year ); + + if ( empty( $summary ) ) { + \WP_CLI::warning( "No stats found for user {$uid} ({$year}), skipping." ); + continue; + } + + Statistics_Scheduler::send_annual_email( $uid, $year, $summary ); + \WP_CLI::log( "Annual report email sent for user {$uid} ({$year})." ); + ++$sent; + } + + \WP_CLI::success( "Annual report email sent for {$sent} user(s) ({$year})." ); + } } diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index 2a7c96a1d8..e761702b88 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -92,11 +92,20 @@ public static function compile_and_send_annual_stats() { * @param int $year The year. * @param array $summary The annual summary data. */ - private static function send_annual_email( $user_id, $year, $summary ) { + public static function send_annual_email( $user_id, $year, $summary ) { if ( empty( $summary ) ) { return; } + // Check user preference for wrapped email. + if ( $user_id > \Activitypub\Collection\Actors::BLOG_USER_ID ) { + if ( ! \get_user_option( 'activitypub_mailer_annual_report', $user_id ) ) { + return; + } + } elseif ( '1' !== \get_option( 'activitypub_mailer_annual_report', '1' ) ) { + return; + } + // Don't send email if there's no activity. // Check posts and all registered comment types dynamically. $has_activity = ! empty( $summary['posts_count'] ); @@ -134,6 +143,25 @@ private static function send_annual_email( $user_id, $year, $summary ) { $year ); - Mailer::send( $user_id, $subject, 'annual-wrapped', $args ); + // Build plain text alternative. + /* translators: %d: Year */ + $alt_body = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year ); + + if ( ! empty( $args['posts_count'] ) ) { + /* translators: %d: Number of posts */ + $alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] ); + } + + if ( ! empty( $args['followers_net_change'] ) ) { + /* translators: %d: Net follower change */ + $alt_body .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] ); + } + + if ( ! empty( $args['most_active_month_name'] ) ) { + /* translators: %s: Month name */ + $alt_body .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $args['most_active_month_name'] ); + } + + Mailer::send( $user_id, $subject, 'annual-wrapped', $args, $alt_body ); } } diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 26b670283e..dbfe330c7c 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -249,6 +249,7 @@ public static function save_user_settings( $user_id ) { 'activitypub_mailer_new_dm', 'activitypub_mailer_new_follower', 'activitypub_mailer_new_mention', + 'activitypub_mailer_annual_report', ); foreach ( $required_user_options as $option ) { diff --git a/includes/wp-admin/class-blog-settings-fields.php b/includes/wp-admin/class-blog-settings-fields.php index 72535d2a7d..a95d4a605a 100644 --- a/includes/wp-admin/class-blog-settings-fields.php +++ b/includes/wp-admin/class-blog-settings-fields.php @@ -239,6 +239,12 @@ public static function notifications_callback() {

        +

        + +

        +

        + +

        From bfdf805961b4096b83278fe74941969dac99082a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 12:36:50 +0100 Subject: [PATCH 46/46] Fix performance, security, and rendering issues in statistics feature - Cast user_id to int in Statistics_Controller to fix strict comparison with BLOG_USER_ID that broke blog actor stats - Replace unbounded get_posts(-1) with SQL subqueries to prevent OOM on large sites; extract get_post_ids_subquery() helper - Add 15-minute transient caching to stats REST endpoint - Fix email template using esc_html__() with HTML arguments (wp_kses) - Fix timezone mismatch in date construction (gmmktime instead of strtotime/gmdate mix) - Reschedule monthly cron to exact first-of-month to prevent 30-day drift - Sanitize template path in Mailer::send() to prevent path traversal - Guard against duplicate 'comment' key in get_comment_types_for_stats() --- includes/class-mailer.php | 2 +- includes/class-statistics.php | 203 ++++++++---------- .../admin/class-statistics-controller.php | 41 ++-- includes/scheduler/class-statistics.php | 5 + templates/emails/annual-wrapped.php | 29 ++- 5 files changed, 137 insertions(+), 143 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 1188d39942..7be76afecc 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -451,7 +451,7 @@ public static function send( $user_id, $subject, $template, $args = array(), $al } // Load the HTML template. - $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . $template . '.php'; + $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . \sanitize_file_name( $template ) . '.php'; /** * Filter the email template file path. diff --git a/includes/class-statistics.php b/includes/class-statistics.php index f00b37826d..ba504e7135 100644 --- a/includes/class-statistics.php +++ b/includes/class-statistics.php @@ -115,8 +115,9 @@ public static function save_annual_summary( $user_id, $year, $stats ) { * @return array The collected stats. */ public static function collect_monthly_stats( $user_id, $year, $month ) { - $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); - $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + $last_day = (int) \gmdate( 't', \gmmktime( 0, 0, 0, $month, 1, $year ) ); + $start = \sprintf( '%d-%02d-01 00:00:00', $year, $month ); + $end = \sprintf( '%d-%02d-%02d 23:59:59', $year, $month, $last_day ); // Count new followers gained this month (by post_date in followers table). $followers_count = Followers::count_in_range( $user_id, $start, $end ); @@ -282,11 +283,13 @@ public static function count_federated_posts_in_range( $user_id, $start, $end ) } $args = array( - 'post_type' => Outbox::POST_TYPE, - 'post_status' => array( 'publish', 'pending' ), - 'posts_per_page' => -1, - 'fields' => 'ids', - 'date_query' => array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => array( 'publish', 'pending' ), + 'posts_per_page' => 1, + 'fields' => 'ids', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'date_query' => array( array( 'after' => $start, 'before' => $end, @@ -294,7 +297,7 @@ public static function count_federated_posts_in_range( $user_id, $start, $end ) ), ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => $meta_query, + 'meta_query' => $meta_query, ); // Filter by post author for user-specific stats. @@ -320,25 +323,8 @@ public static function count_federated_posts_in_range( $user_id, $start, $end ) public static function count_engagement_in_range( $user_id, $start, $end, $type = null ) { global $wpdb; - // Get post IDs for the user (all supported post types). - $post_args = array( - 'posts_per_page' => -1, - 'fields' => 'ids', - 'post_status' => 'publish', - 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), - ); - - if ( Actors::BLOG_USER_ID !== $user_id ) { - $post_args['author'] = $user_id; - } - - $post_ids = \get_posts( $post_args ); - - if ( empty( $post_ids ) ) { - return 0; - } - - $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + // Use a subquery to avoid loading all post IDs into memory. + $post_subquery = self::get_post_ids_subquery( $user_id ); $type_clause = ''; if ( $type ) { @@ -356,18 +342,18 @@ public static function count_engagement_in_range( $user_id, $start, $end, $type // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT c.comment_ID) FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id - WHERE c.comment_post_ID IN ({$placeholders}) + WHERE c.comment_post_ID IN ({$post_subquery}) AND cm.meta_key = 'protocol' AND cm.meta_value = 'activitypub' AND c.comment_date_gmt >= %s AND c.comment_date_gmt <= %s {$type_clause}", - \array_merge( $post_ids, array( $start, $end ) ) + $start, + $end ) ); // phpcs:enable @@ -388,31 +374,8 @@ public static function count_engagement_in_range( $user_id, $start, $end, $type public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { global $wpdb; - $post_args = array( - 'posts_per_page' => -1, - 'fields' => 'ids', - 'post_status' => 'publish', - 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), - 'date_query' => array( - array( - 'after' => $start, - 'before' => $end, - 'inclusive' => true, - ), - ), - ); - - if ( Actors::BLOG_USER_ID !== $user_id ) { - $post_args['author'] = $user_id; - } - - $post_ids = \get_posts( $post_args ); - - if ( empty( $post_ids ) ) { - return array(); - } - - $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + // Use a subquery with date range to only consider posts published in the period. + $post_subquery = self::get_post_ids_subquery( $user_id, $start, $end ); // Get registered comment types dynamically. $comment_types = Comment::get_comment_type_slugs(); @@ -422,27 +385,30 @@ public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $type_clause = $wpdb->prepare( "AND c.comment_type IN ({$placeholders_types})", $comment_types ); + // Get engagement counts per post (only engagement within the date range). // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared - // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $results = $wpdb->get_results( $wpdb->prepare( "SELECT c.comment_post_ID as post_id, COUNT(c.comment_ID) as engagement_count FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id - WHERE c.comment_post_ID IN ({$placeholders}) + WHERE c.comment_post_ID IN ({$post_subquery}) AND cm.meta_key = 'protocol' AND cm.meta_value = 'activitypub' - AND c.comment_type IN ({$placeholders_types}) + {$type_clause} AND c.comment_date_gmt >= %s AND c.comment_date_gmt <= %s GROUP BY c.comment_post_ID ORDER BY engagement_count DESC LIMIT %d", - \array_merge( $post_ids, $comment_types, array( $start, $end, $limit ) ) + $start, + $end, + $limit ), ARRAY_A ); @@ -476,36 +442,19 @@ public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) { public static function get_top_multiplicator( $user_id, $start, $end ) { global $wpdb; - $post_args = array( - 'posts_per_page' => -1, - 'fields' => 'ids', - 'post_status' => 'publish', - 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), - ); - - if ( Actors::BLOG_USER_ID !== $user_id ) { - $post_args['author'] = $user_id; - } - - $post_ids = \get_posts( $post_args ); - - if ( empty( $post_ids ) ) { - return null; - } - - $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + // Use a subquery to avoid loading all post IDs into memory. + $post_subquery = self::get_post_ids_subquery( $user_id ); // Get actor who boosted the most. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $result = $wpdb->get_row( $wpdb->prepare( "SELECT c.comment_author as name, c.comment_author_url as url, COUNT(c.comment_ID) as boost_count FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id - WHERE c.comment_post_ID IN ({$placeholders}) + WHERE c.comment_post_ID IN ({$post_subquery}) AND cm.meta_key = 'protocol' AND cm.meta_value = 'activitypub' AND c.comment_type = 'repost' @@ -514,7 +463,8 @@ public static function get_top_multiplicator( $user_id, $start, $end ) { GROUP BY c.comment_author_url ORDER BY boost_count DESC LIMIT 1", - \array_merge( $post_ids, array( $start, $end ) ) + $start, + $end ), ARRAY_A ); @@ -671,8 +621,9 @@ private static function get_month_data( $user_id, $year, $month, $comment_types } } else { // Query live data. - $start = \gmdate( 'Y-m-d 00:00:00', \strtotime( sprintf( '%d-%02d-01', $year, $month ) ) ); - $end = \gmdate( 'Y-m-d 23:59:59', \strtotime( 'last day of ' . sprintf( '%d-%02d', $year, $month ) ) ); + $last_day = (int) \gmdate( 't', \gmmktime( 0, 0, 0, $month, 1, $year ) ); + $start = \sprintf( '%d-%02d-01 00:00:00', $year, $month ); + $end = \sprintf( '%d-%02d-%02d 23:59:59', $year, $month, $last_day ); $engagement = self::count_engagement_in_range( $user_id, $start, $end ); @@ -784,11 +735,13 @@ public static function get_comment_types_for_stats() { } // Add federated comments (replies) which use the standard 'comment' type. - $result['comment'] = array( - 'slug' => 'comment', - 'label' => \__( 'Comments', 'activitypub' ), - 'singular' => \__( 'Comment', 'activitypub' ), - ); + if ( ! isset( $result['comment'] ) ) { + $result['comment'] = array( + 'slug' => 'comment', + 'label' => \__( 'Comments', 'activitypub' ), + 'singular' => \__( 'Comment', 'activitypub' ), + ); + } /** * Filter the comment types tracked in statistics. @@ -883,50 +836,70 @@ public static function backfill_historical_stats( $batch_size = 12, $user_index } /** - * Get the earliest year that has ActivityPub data for a user. + * Get a prepared SQL subquery that returns post IDs for a user. * - * @param int $user_id The user ID. + * This avoids loading all post IDs into PHP memory by using a SQL subquery + * that can be embedded in other queries via IN (...). * - * @return int|null The earliest year with data, or null if no data. + * @param int $user_id The user ID. + * @param string|null $start Optional start date (Y-m-d H:i:s). + * @param string|null $end Optional end date (Y-m-d H:i:s). + * + * @return string Prepared SQL subquery string. */ - private static function get_earliest_data_year( $user_id ) { + private static function get_post_ids_subquery( $user_id, $start = null, $end = null ) { global $wpdb; - // Get post IDs for the user (all supported post types). - $post_args = array( - 'posts_per_page' => -1, - 'fields' => 'ids', - 'post_status' => 'publish', - 'post_type' => \get_option( 'activitypub_support_post_types', array( 'post' ) ), - ); + $post_types = (array) \get_option( 'activitypub_support_post_types', array( 'post' ) ); + $type_placeholders = \implode( ', ', \array_fill( 0, \count( $post_types ), '%s' ) ); + $params = $post_types; + $author_clause = ''; if ( Actors::BLOG_USER_ID !== $user_id ) { - $post_args['author'] = $user_id; + $author_clause = ' AND post_author = %d'; + $params[] = $user_id; } - $post_ids = \get_posts( $post_args ); - - if ( empty( $post_ids ) ) { - return null; + $date_clause = ''; + if ( $start && $end ) { + $date_clause = ' AND post_date_gmt >= %s AND post_date_gmt <= %s'; + $params[] = $start; + $params[] = $end; } - $placeholders = \implode( ', ', \array_fill( 0, \count( $post_ids ), '%d' ) ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}){$author_clause}{$date_clause}", + $params + ); + // phpcs:enable + } + + /** + * Get the earliest year that has ActivityPub data for a user. + * + * @param int $user_id The user ID. + * + * @return int|null The earliest year with data, or null if no data. + */ + private static function get_earliest_data_year( $user_id ) { + global $wpdb; + + // Use a subquery to avoid loading all post IDs into memory. + $post_subquery = self::get_post_ids_subquery( $user_id ); // Find earliest comment with ActivityPub protocol. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber - // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $earliest_date = $wpdb->get_var( - $wpdb->prepare( - "SELECT MIN(c.comment_date_gmt) FROM {$wpdb->comments} c - INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id - WHERE c.comment_post_ID IN ({$placeholders}) - AND cm.meta_key = 'protocol' - AND cm.meta_value = 'activitypub'", - $post_ids - ) + "SELECT MIN(c.comment_date_gmt) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id + WHERE c.comment_post_ID IN ({$post_subquery}) + AND cm.meta_key = 'protocol' + AND cm.meta_value = 'activitypub'" ); // phpcs:enable diff --git a/includes/rest/admin/class-statistics-controller.php b/includes/rest/admin/class-statistics-controller.php index babf60531a..45b07ca166 100644 --- a/includes/rest/admin/class-statistics-controller.php +++ b/includes/rest/admin/class-statistics-controller.php @@ -63,7 +63,7 @@ public function register_routes() { * @return true|\WP_Error True if the request has access, WP_Error otherwise. */ public function get_item_permissions_check( $request ) { - $user_id = $request->get_param( 'user_id' ); + $user_id = (int) $request->get_param( 'user_id' ); // Check if user can access stats for this actor. if ( Actors::BLOG_USER_ID === $user_id ) { @@ -93,24 +93,31 @@ public function get_item_permissions_check( $request ) { * @return \WP_REST_Response|\WP_Error Response object or WP_Error object. */ public function get_item( $request ) { - $user_id = $request->get_param( 'user_id' ); + $user_id = (int) $request->get_param( 'user_id' ); + $transient_key = 'activitypub_stats_' . $user_id; - $stats = Statistics::get_current_stats( $user_id, 'month' ); - $comparison = Statistics::get_period_comparison( $user_id ); - $monthly_data = Statistics::get_rolling_monthly_breakdown( $user_id ); - $comment_types = Statistics::get_comment_types_for_stats(); + $response = \get_transient( $transient_key ); - $response = array( - 'stats' => array( - 'posts_count' => $stats['posts_count'], - 'followers_total' => $stats['followers_total'], - 'top_posts' => $stats['top_posts'], - 'top_multiplicator' => $stats['top_multiplicator'], - ), - 'comparison' => $comparison, - 'monthly' => \array_values( $monthly_data ), - 'comment_types' => $comment_types, - ); + if ( false === $response ) { + $stats = Statistics::get_current_stats( $user_id, 'month' ); + $comparison = Statistics::get_period_comparison( $user_id ); + $monthly_data = Statistics::get_rolling_monthly_breakdown( $user_id ); + $comment_types = Statistics::get_comment_types_for_stats(); + + $response = array( + 'stats' => array( + 'posts_count' => $stats['posts_count'], + 'followers_total' => $stats['followers_total'], + 'top_posts' => $stats['top_posts'], + 'top_multiplicator' => $stats['top_multiplicator'], + ), + 'comparison' => $comparison, + 'monthly' => \array_values( $monthly_data ), + 'comment_types' => $comment_types, + ); + + \set_transient( $transient_key, $response, 15 * MINUTE_IN_SECONDS ); + } return \rest_ensure_response( $response ); } diff --git a/includes/scheduler/class-statistics.php b/includes/scheduler/class-statistics.php index e761702b88..187f145064 100644 --- a/includes/scheduler/class-statistics.php +++ b/includes/scheduler/class-statistics.php @@ -43,6 +43,11 @@ public static function collect_all_monthly_stats() { Statistics_Collector::collect_monthly_stats( $user_id, $year, $month ); } + // Reschedule to the exact next 1st of month to prevent drift from the 30-day interval. + $next_first = \strtotime( 'first day of next month 02:00:00', $now ); + \wp_clear_scheduled_hook( 'activitypub_collect_monthly_stats' ); + \wp_schedule_event( $next_first, 'monthly', 'activitypub_collect_monthly_stats' ); + /** * Fires after monthly statistics have been collected for all users. * diff --git a/templates/emails/annual-wrapped.php b/templates/emails/annual-wrapped.php index 0eb2725d95..e5e93532d7 100644 --- a/templates/emails/annual-wrapped.php +++ b/templates/emails/annual-wrapped.php @@ -180,11 +180,14 @@

        ' . esc_html( number_format_i18n( $args['followers_start'] ?? 0 ) ) . '', - '' . esc_html( number_format_i18n( $args['followers_end'] ?? 0 ) ) . '' + echo wp_kses( + sprintf( + /* translators: 1: followers at start, 2: followers at end */ + __( 'You started the year with %1$s followers and ended with %2$s.', 'activitypub' ), + '' . esc_html( number_format_i18n( $args['followers_start'] ?? 0 ) ) . '', + '' . esc_html( number_format_i18n( $args['followers_end'] ?? 0 ) ) . '' + ), + array( 'strong' => array() ) ); ?>

        @@ -195,11 +198,17 @@

        ' . esc_html( $args['top_multiplicator']['name'] ) . '', - '' . esc_html( number_format_i18n( $args['top_multiplicator']['count'] ?? 0 ) ) . '' + echo wp_kses( + sprintf( + /* translators: 1: supporter name, 2: number of boosts */ + __( '%1$s boosted your content %2$s times this year!', 'activitypub' ), + '' . esc_html( $args['top_multiplicator']['name'] ) . '', + '' . esc_html( number_format_i18n( $args['top_multiplicator']['count'] ?? 0 ) ) . '' + ), + array( + 'strong' => array(), + 'a' => array( 'href' => array() ), + ) ); ?>