diff --git a/activitypub.php b/activitypub.php
index 984cd62076..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();
@@ -140,6 +141,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\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/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..4fbbdc7147
--- /dev/null
+++ b/build/dashboard-stats/index.asset.php
@@ -0,0 +1 @@
+ 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
new file mode 100644
index 0000000000..265974de1f
--- /dev/null
+++ b/build/dashboard-stats/index.js
@@ -0,0 +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,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 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(){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/build/dashboard-stats/style-index-rtl.css b/build/dashboard-stats/style-index-rtl.css
new file mode 100644
index 0000000000..7bac4397ba
--- /dev/null
+++ b/build/dashboard-stats/style-index-rtl.css
@@ -0,0 +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%}}
diff --git a/build/dashboard-stats/style-index.css b/build/dashboard-stats/style-index.css
new file mode 100644
index 0000000000..29258d00d0
--- /dev/null
+++ b/build/dashboard-stats/style-index.css
@@ -0,0 +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%}}
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-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/class-mailer.php b/includes/class-mailer.php
index ad2ba60d6c..7be76afecc 100644
--- a/includes/class-mailer.php
+++ b/includes/class-mailer.php
@@ -427,6 +427,66 @@ 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.
+ * @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(), $alt_body = '' ) {
+ // 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' );
+ }
+
+ // Load the HTML template.
+ $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . \sanitize_file_name( $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 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;
+ };
+ \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;
+ }
+
/**
* Apply defaults to the actor object.
*
diff --git a/includes/class-migration.php b/includes/class-migration.php
index 1ed946f9a4..4b6eab816d 100644
--- a/includes/class-migration.php
+++ b/includes/class-migration.php
@@ -33,6 +33,7 @@ public static function init() {
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_migrate_actor_emoji', array( self::class, 'migrate_actor_emoji' ) );
+ Scheduler::register_async_batch_callback( 'activitypub_backfill_statistics', array( Statistics::class, 'backfill_historical_stats' ) );
}
/**
@@ -218,6 +219,8 @@ public static function maybe_migrate() {
if ( \version_compare( $version_from_db, '7.9.0', '<' ) ) {
\wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' );
+ // 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/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/class-scheduler.php b/includes/class-scheduler.php
index d42e8c8ecb..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.
@@ -68,6 +69,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' ) );
@@ -97,6 +101,7 @@ public static function register_schedulers() {
Actor::init();
Collection_Sync::init();
Comment::init();
+ Statistics::init();
/**
* Register additional schedulers.
@@ -106,6 +111,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.
*
@@ -137,6 +163,19 @@ public static function register_schedules() {
\wp_schedule_event( time(), $recurrence, $hook );
}
}
+
+ // 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 December 1st (wrapped notification).
+ if ( ! \wp_next_scheduled( 'activitypub_compile_annual_stats' ) ) {
+ $next_december = self::get_next_december_first();
+ \wp_schedule_event( $next_december, 'yearly', 'activitypub_compile_annual_stats' );
+ }
}
/**
@@ -148,6 +187,42 @@ public static function deregister_schedules() {
foreach ( array_keys( self::SCHEDULES ) as $hook ) {
\wp_unschedule_hook( $hook );
}
+
+ // Statistics 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 December 1st timestamp for wrapped notification.
+ *
+ * @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 );
+
+ // 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 $this_year_dec_first;
}
/**
diff --git a/includes/class-statistics.php b/includes/class-statistics.php
new file mode 100644
index 0000000000..ba504e7135
--- /dev/null
+++ b/includes/class-statistics.php
@@ -0,0 +1,939 @@
+ self::count_federated_posts_in_range( $user_id, $start, $end ),
+ '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' ),
+ );
+
+ // 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.
+ // 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' => $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,
+ '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',
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ '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;
+
+ // Use a subquery to avoid loading all post IDs into memory.
+ $post_subquery = self::get_post_ids_subquery( $user_id );
+
+ $type_clause = '';
+ if ( $type ) {
+ $type_clause = $wpdb->prepare( ' AND c.comment_type = %s', $type );
+ } else {
+ // 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
+ $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
+ $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 ({$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}",
+ $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;
+
+ // 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();
+ if ( empty( $comment_types ) ) {
+ return array();
+ }
+
+ $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
+ $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 ({$post_subquery})
+ AND cm.meta_key = 'protocol'
+ AND cm.meta_value = 'activitypub'
+ {$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",
+ $start,
+ $end,
+ $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;
+
+ // 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
+ $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 ({$post_subquery})
+ 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",
+ $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 int[] Array of user IDs including BLOG_USER_ID if enabled.
+ */
+ public static function get_active_user_ids() {
+ return Actors::get_all_ids();
+ }
+
+ /**
+ * Get statistics for the current period.
+ *
+ * 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').
+ *
+ * @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 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() );
+
+ // 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 );
+
+ $month_data = self::get_month_data( $user_id, $year, $month, $comment_types );
+ $month_data['year'] = $year;
+ $month_data['month'] = $month;
+
+ $months[] = $month_data;
+ }
+
+ 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 ) {
+ $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.
+ $engagement = 0;
+ foreach ( $comment_types as $type ) {
+ $engagement += $stored_stats[ $type . '_count' ] ?? 0;
+ }
+
+ $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.
+ $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 );
+
+ $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 $month_data;
+ }
+
+ /**
+ * Get period-over-period comparison (current month vs previous month).
+ *
+ * Always queries live data for current month, uses stored stats for previous month.
+ *
+ * @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 date range.
+ $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 (only for previous month - current month is always live).
+ $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month );
+
+ // 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 ) {
+ $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(
+ 'current' => $current_posts,
+ 'change' => $current_posts - $prev_posts,
+ ),
+ 'followers' => array(
+ 'current' => $current_followers,
+ 'change' => $current_followers - $prev_followers,
+ ),
+ );
+
+ // 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 );
+
+ // Use stored stats for previous month if available.
+ 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,
+ );
+ }
+
+ 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 ),
+ );
+ }
+
+ // Add federated comments (replies) which use the standard 'comment' type.
+ if ( ! isset( $result['comment'] ) ) {
+ $result['comment'] = array(
+ 'slug' => 'comment',
+ 'label' => \__( 'Comments', 'activitypub' ),
+ 'singular' => \__( 'Comment', 'activitypub' ),
+ );
+ }
+
+ /**
+ * Filter the comment types tracked in statistics.
+ *
+ * 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.
+ */
+ return \apply_filters( 'activitypub_stats_comment_types', $result );
+ }
+
+ /**
+ * 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.
+ * @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 ];
+ $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 ) {
+ $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 ) {
+ // 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,
+ '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 a prepared SQL subquery that returns post IDs for a user.
+ *
+ * This avoids loading all post IDs into PHP memory by using a SQL subquery
+ * that can be embedded in other queries via IN (...).
+ *
+ * @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_post_ids_subquery( $user_id, $start = null, $end = null ) {
+ global $wpdb;
+
+ $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 ) {
+ $author_clause = ' AND post_author = %d';
+ $params[] = $user_id;
+ }
+
+ $date_clause = '';
+ if ( $start && $end ) {
+ $date_clause = ' AND post_date_gmt >= %s AND post_date_gmt <= %s';
+ $params[] = $start;
+ $params[] = $end;
+ }
+
+ // 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
+ $earliest_date = $wpdb->get_var(
+ "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
+
+ 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/cli/class-stats-command.php b/includes/cli/class-stats-command.php
new file mode 100644
index 0000000000..337fc2e53b
--- /dev/null
+++ b/includes/cli/class-stats-command.php
@@ -0,0 +1,172 @@
+]
+ * : 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})." );
+ }
+
+ /**
+ * 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/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/includes/rest/admin/class-statistics-controller.php b/includes/rest/admin/class-statistics-controller.php
new file mode 100644
index 0000000000..45b07ca166
--- /dev/null
+++ b/includes/rest/admin/class-statistics-controller.php
@@ -0,0 +1,124 @@
+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 = (int) $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 = (int) $request->get_param( 'user_id' );
+ $transient_key = 'activitypub_stats_' . $user_id;
+
+ $response = \get_transient( $transient_key );
+
+ 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
new file mode 100644
index 0000000000..187f145064
--- /dev/null
+++ b/includes/scheduler/class-statistics.php
@@ -0,0 +1,172 @@
+ \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'] );
+ 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;
+ }
+
+ $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'] ) ) );
+ }
+
+ $subject = \sprintf(
+ /* translators: 1: Blog name, 2: Year */
+ \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ),
+ \esc_html( \get_option( 'blogname' ) ),
+ $year
+ );
+
+ // 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 9d6806491e..dbfe330c7c 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;
use Activitypub\Tombstone;
@@ -74,8 +73,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' ) );
}
@@ -252,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 ) {
@@ -954,78 +952,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-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php
index a011651e8f..dac752ce98 100644
--- a/includes/wp-admin/class-user-settings-fields.php
+++ b/includes/wp-admin/class-user-settings-fields.php
@@ -250,6 +250,12 @@ public static function notifications_callback() {
+
+
+
+ * : The action to perform.
+ * ---
+ * options:
+ * - populate
+ * - clear
+ * - collect
+ * - compile
+ * ---
+ *
+ * [--user_id=]
+ * : 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.
+ *
+ * ## 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
+ *
+ * # 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
+ * $ wp activitypub stats compile --year=2024
+ *
+ * @synopsis [--user_id=] [--year=] [--month=] [--force]
+ *
+ * @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'] : 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':
+ $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':
+ $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 = $this->collect_monthly_stats( $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':
+ $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:
+ \WP_CLI::error( 'Unknown action. Use "populate", "clear", "collect", or "compile".' );
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @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/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..d9d41be26b
--- /dev/null
+++ b/src/dashboard-stats/components/line-chart/index.tsx
@@ -0,0 +1,247 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { MonthData, CommentType } from '../../types';
+
+interface Props {
+ monthly: MonthData[] | null;
+ commentTypes: Record< string, CommentType > | null;
+}
+
+// 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' },
+];
+
+/**
+ * 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.
+ * Color assignment is deterministic based on type slug hash.
+ * @param typeSlug The comment type slug for deterministic color.
+ */
+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 })`;
+}
+
+/**
+ * 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.
+ *
+ * Renders an SVG line chart for monthly engagement data.
+ *
+ * @param {Props} props Component props.
+ */
+export default function LineChart( { monthly, commentTypes }: Props ): ReactNode {
+ if ( ! monthly?.length ) {
+ 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 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 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 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 =
+ 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 = [
+ __( '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: engagementColor },
+ ];
+
+ if ( commentTypes ) {
+ Object.entries( commentTypes ).forEach( ( [ slug, type ] ) => {
+ legendItems.push( { key: slug, label: type.label, color: getColorForType( slug ) } );
+ } );
+ }
+
+ return (
+
+
{ __( 'Engagement Over Time', 'activitypub' ) }
+
+
+
+ { /* 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..7425d29547
--- /dev/null
+++ b/src/dashboard-stats/components/stat-highlights/index.tsx
@@ -0,0 +1,146 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+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;
+}
+
+/**
+ * Get the admin URL for a stat type.
+ *
+ * @param {string} type The stat type (followers, posts, etc.).
+ * @return {string|null} The admin URL or null if no link.
+ */
+function getStatUrl( type: string ): string | null {
+ switch ( type ) {
+ 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:
+ // Likes, reposts, comments filter by comment type.
+ return `edit-comments.php?comment_type=${ type }`;
+ }
+}
+
+/**
+ * Stat Highlights Component.
+ *
+ * Displays key statistics with month-over-month comparison.
+ * Shows follower change and engagement stats for available actors.
+ *
+ * @param {Props} props Component props.
+ */
+export default function StatHighlights( {
+ comparison,
+ userComparison,
+ blogComparison,
+ commentTypes,
+ canUseUserActor,
+ canUseBlogActor,
+}: Props ): ReactNode {
+ if ( ! comparison ) {
+ return null;
+ }
+
+ // Build stats array dynamically.
+ 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: __( '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: __( 'New Followers (Blog)', 'activitypub' ),
+ value: blogComparison.followers.current ?? 0,
+ change: blogComparison.followers.change ?? 0,
+ } );
+ }
+
+ // Add posts.
+ stats.push( {
+ key: 'posts',
+ label: __( 'Posts', 'activitypub' ),
+ value: comparison.posts?.current ?? 0,
+ change: comparison.posts?.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 (
+
+
{ __( 'This month vs. last month', 'activitypub' ) }
+
+ { stats.map( ( stat ) => {
+ const url = getStatUrl( stat.key );
+ const content = (
+ <>
+ { stat.value.toLocaleString() } { stat.label }
+ >
+ );
+ return (
+ -
+ { url ? { content } : { content } }
+ { stat.change !== 0 && ' ' }
+ { 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..80f46bc18d
--- /dev/null
+++ b/src/dashboard-stats/components/stats-widget/index.tsx
@@ -0,0 +1,143 @@
+/**
+ * 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';
+import TopPosts from '../top-posts';
+import type { StatsResponse } from '../../types';
+
+// 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.
+ *
+ * Displays global engagement stats and follower counts for available actors.
+ */
+export default function StatsWidget(): ReactNode {
+ 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 && !! currentUser?.id;
+
+ // 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;
+
+ 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 for blog (global engagement) and separate follower stats per actor.
+ useEffect( () => {
+ if ( isResolving ) {
+ return;
+ }
+
+ setIsLoading( true );
+
+ // Fetch blog stats (global engagement data) - only if user has blog capability.
+ const blogStatsPromise = canUseBlogActor
+ ? apiFetch< StatsResponse >( {
+ path: `/activitypub/1.0/admin/stats/${ BLOG_USER_ID }`,
+ } ).catch( () => null )
+ : Promise.resolve( null );
+
+ // Fetch user-specific stats if user actor is available.
+ const userStatsPromise =
+ canUseUserActor && currentUser?.id
+ ? apiFetch< StatsResponse >( {
+ path: `/activitypub/1.0/admin/stats/${ currentUser.id }`,
+ } ).catch( () => null )
+ : Promise.resolve( null );
+
+ Promise.all( [ blogStatsPromise, userStatsPromise ] )
+ .then( ( [ blogData, userData ] ) => {
+ // 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, canUseBlogActor, currentUser?.id ] );
+
+ // Show loading while resolving user data.
+ if ( isResolving || isLoading ) {
+ return (
+
+ );
+ }
+
+ if ( ! stats ) {
+ return (
+
+
{ __( 'No statistics available yet.', 'activitypub' ) }
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
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..091b46ed6d
--- /dev/null
+++ b/src/dashboard-stats/components/top-posts/index.tsx
@@ -0,0 +1,63 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { TopPost } from '../../types';
+
+interface Props {
+ posts: TopPost[] | null | undefined;
+}
+
+/**
+ * Top Posts Component.
+ *
+ * @param {Props} props Component props.
+ */
+export default function TopPosts( { posts }: Props ): ReactNode {
+ if ( ! posts?.length ) {
+ return null;
+ }
+
+ return (
+
+
{ __( 'Top Posts', 'activitypub' ) }
+
+ { 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
new file mode 100644
index 0000000000..c901a3c492
--- /dev/null
+++ b/src/dashboard-stats/components/top-supporter/index.tsx
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { __, _n, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { Multiplicator } from '../../types';
+
+interface Props {
+ multiplicator: Multiplicator | null | undefined;
+}
+
+/**
+ * Top Supporter Component.
+ *
+ * @param {Props} props Component props.
+ */
+export default function TopSupporter( { multiplicator }: Props ): ReactNode {
+ 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..76c7fbdd59
--- /dev/null
+++ b/src/dashboard-stats/index.tsx
@@ -0,0 +1,40 @@
+/**
+ * WordPress dependencies
+ */
+import { createRoot } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import StatsWidget from './components/stats-widget';
+import './style.scss';
+
+declare global {
+ interface Window {
+ activitypub: {
+ dashboardStats?: {
+ initialize: ( id: string ) => void;
+ };
+ };
+ }
+}
+
+/**
+ * Initialize the dashboard stats widget.
+ *
+ * @param {string} id The container element ID.
+ */
+export function initialize( id: string ) {
+ const container = document.getElementById( id );
+
+ if ( ! container ) {
+ return;
+ }
+
+ 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..07a2f97ac4
--- /dev/null
+++ b/src/dashboard-stats/style.scss
@@ -0,0 +1,255 @@
+// ActivityPub Dashboard Stats Widget
+// Styled to match WordPress core dashboard widgets exactly.
+
+.activitypub-stats-widget {
+ // Match WordPress dashboard postbox inner padding.
+ margin: -6px -12px -12px;
+
+ // Match #dashboard-widgets h3 styling.
+ h3 {
+ margin: 0 12px 8px;
+ padding: 0;
+ font-size: 14px;
+ font-weight: 400;
+ color: #1d2327;
+ }
+}
+
+.activitypub-stats-loading {
+ display: flex;
+ justify-content: center;
+ padding: 24px 12px;
+}
+
+.activitypub-stats-empty {
+ color: #646970;
+ text-align: center;
+ padding: 24px 12px;
+ margin: 0;
+}
+
+// Highlights section - matches #dashboard_right_now .main exactly.
+.activitypub-stats-highlights {
+ padding: 0 12px 11px;
+
+ h3 {
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ // Match #dashboard_right_now ul exactly.
+ ul {
+ margin: 0;
+ display: inline-block;
+ width: 100%;
+ }
+
+ // Match #dashboard_right_now li exactly.
+ li {
+ width: 50%;
+ float: left;
+ margin-bottom: 10px;
+ }
+
+ // Match #dashboard_right_now li a:before exactly.
+ li a::before,
+ li > span:not(.stat-change)::before {
+ content: "\f159";
+ color: #646970;
+ font: 400 20px/1 dashicons, sans-serif;
+ padding: 0 5px 0 0;
+ display: inline-block;
+ position: relative;
+ vertical-align: top;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-decoration: none;
+ }
+
+ // Icon mappings - same as WordPress dashboard icons.
+ .activitypub-followers-count a::before {
+ content: "\f307"; // groups icon.
+ }
+
+ .activitypub-posts-count a::before {
+ content: "\f109"; // admin-post icon.
+ }
+
+ .activitypub-like-count a::before,
+ .activitypub-likes-count a::before {
+ content: "\f155"; // star-filled icon.
+ }
+
+ .activitypub-repost-count a::before,
+ .activitypub-reposts-count a::before {
+ content: "\f488"; // share icon.
+ }
+
+ .activitypub-comment-count a::before,
+ .activitypub-comments-count a::before {
+ content: "\f101"; // admin-comments icon.
+ }
+
+ .stat-change {
+ color: #646970;
+
+ &.positive {
+ color: #00a32a;
+ }
+
+ &.negative {
+ color: #d63638;
+ }
+ }
+}
+
+// Sub-section style - matches #dashboard_right_now .sub exactly.
+.activitypub-stats-sub {
+ color: #50575e;
+ background: #f6f7f7;
+ border-top: 1px solid #f0f0f1;
+ padding: 10px 12px 6px;
+
+ h3 {
+ color: #50575e;
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
+// Chart section.
+.activitypub-stats-chart {
+ border-top: 1px solid #f0f0f1;
+ padding: 8px 12px 4px;
+
+ 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 {
+ width: 100%;
+ height: auto;
+ display: block;
+
+ .chart-label {
+ font-size: 10px;
+ fill: #646970;
+ }
+}
+
+.activitypub-chart-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid #f0f0f1;
+}
+
+.activitypub-legend-item {
+ display: flex;
+ align-items: center;
+ font-size: 11px;
+ color: #50575e;
+
+ .legend-color {
+ width: 12px;
+ height: 3px;
+ border-radius: 1px;
+ margin-right: 6px;
+ }
+}
+
+// Top supporter section.
+.activitypub-stats-multiplicator {
+ border-top: 1px solid #f0f0f1;
+ padding: 12px;
+
+ h3 {
+ margin-left: 0;
+ margin-right: 0;
+ margin-bottom: 4px;
+ }
+
+ p {
+ margin: 0;
+ color: #50575e;
+ }
+
+ /* stylelint-disable-next-line no-descending-specificity */
+ a {
+ text-decoration: none;
+ font-weight: 600;
+ color: #2271b1;
+
+ &:hover {
+ text-decoration: underline;
+ color: #135e96;
+ }
+ }
+}
+
+// Top posts section.
+.activitypub-stats-top-posts {
+ border-top: 1px solid #f0f0f1;
+ padding: 12px;
+
+ h3 {
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ li {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 4px 0;
+ color: #50575e;
+ }
+
+ /* stylelint-disable-next-line no-descending-specificity */
+ a {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-decoration: none;
+ margin-right: 10px;
+ color: #2271b1;
+
+ &:hover {
+ text-decoration: underline;
+ color: #135e96;
+ }
+ }
+
+ .engagement-count {
+ font-size: 12px;
+ color: #646970;
+ white-space: nowrap;
+ }
+}
+
+// Responsive adjustments.
+
+@media screen and (max-width: 782px) {
+
+ .activitypub-stats-highlights li {
+ width: 100%;
+ }
+}
diff --git a/src/dashboard-stats/types/index.ts b/src/dashboard-stats/types/index.ts
new file mode 100644
index 0000000000..fb8fbb60ce
--- /dev/null
+++ b/src/dashboard-stats/types/index.ts
@@ -0,0 +1,53 @@
+export interface StatComparison {
+ current: number;
+ change: number;
+}
+
+export interface Comparison {
+ followers?: StatComparison;
+ posts?: StatComparison;
+ // Dynamic keys for engagement types (like, repost, quote, comment, etc.)
+ [ key: string ]: StatComparison | undefined;
+}
+
+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.)
+ [ key: string ]: number | undefined;
+}
+
+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 >;
+}
diff --git a/templates/emails/annual-wrapped.php b/templates/emails/annual-wrapped.php
new file mode 100644
index 0000000000..e5e93532d7
--- /dev/null
+++ b/templates/emails/annual-wrapped.php
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $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 ) ) . ''
+ ),
+ array( 'strong' => array() )
+ );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+