Skip to main content

tuitbot_core/storage/analytics/
summary.rs

1use super::super::accounts::DEFAULT_ACCOUNT_ID;
2use super::super::DbPool;
3use super::content_scores::{
4    get_avg_reply_engagement_for, get_avg_tweet_engagement_for, get_performance_counts_for,
5    get_top_topics_for, ContentScore,
6};
7use super::snapshots::get_follower_snapshots_for;
8use crate::error::StorageError;
9use chrono::{NaiveDate, Utc};
10
11/// Follower growth metrics.
12#[derive(Debug, Clone, serde::Serialize)]
13pub struct FollowerSummary {
14    pub current: i64,
15    pub change_7d: i64,
16    pub change_30d: i64,
17}
18
19/// Today's action breakdown.
20#[derive(Debug, Clone, serde::Serialize)]
21pub struct ActionsSummary {
22    pub replies: i64,
23    pub tweets: i64,
24    pub threads: i64,
25}
26
27/// Engagement overview.
28#[derive(Debug, Clone, serde::Serialize)]
29pub struct EngagementSummary {
30    pub avg_reply_score: f64,
31    pub avg_tweet_score: f64,
32    pub total_replies_sent: i64,
33    pub total_tweets_posted: i64,
34}
35
36/// Combined analytics summary for the dashboard.
37#[derive(Debug, Clone, serde::Serialize)]
38pub struct AnalyticsSummary {
39    pub followers: FollowerSummary,
40    pub actions_today: ActionsSummary,
41    pub engagement: EngagementSummary,
42    pub top_topics: Vec<ContentScore>,
43}
44
45/// Get a combined analytics summary for the dashboard for a specific account.
46///
47/// Aggregates follower deltas, today's action counts, and engagement stats
48/// into a single struct to minimise round-trips from the frontend.
49pub async fn get_analytics_summary_for(
50    pool: &DbPool,
51    account_id: &str,
52) -> Result<AnalyticsSummary, StorageError> {
53    // --- Follower data ---
54    let snapshots = get_follower_snapshots_for(pool, account_id, 90).await?;
55    let current = snapshots.first().map_or(0, |s| s.follower_count);
56
57    // Find the first snapshot whose date is at least N days ago (handles gaps from
58    // downtime or weekends).  Snapshots are ordered newest-first.
59    let today = Utc::now().date_naive();
60    let follower_at_or_before = |days: i64| -> i64 {
61        snapshots
62            .iter()
63            .find(|s| {
64                NaiveDate::parse_from_str(&s.snapshot_date, "%Y-%m-%d")
65                    .map(|d| (today - d).num_days() >= days)
66                    .unwrap_or(false)
67            })
68            .map_or(current, |s| s.follower_count)
69    };
70
71    let change_7d = if snapshots.len() >= 2 {
72        current - follower_at_or_before(7)
73    } else {
74        0
75    };
76    let change_30d = if snapshots.len() >= 2 {
77        current - follower_at_or_before(30)
78    } else {
79        0
80    };
81
82    // --- Today's actions (from action_log) ---
83    let today = Utc::now().format("%Y-%m-%dT00:00:00Z").to_string();
84    let counts = super::super::action_log::get_action_counts_since(pool, &today).await?;
85    let actions_today = ActionsSummary {
86        replies: *counts.get("reply").unwrap_or(&0),
87        tweets: *counts.get("tweet").unwrap_or(&0),
88        threads: *counts.get("thread").unwrap_or(&0),
89    };
90
91    // --- Engagement ---
92    let avg_reply_score = get_avg_reply_engagement_for(pool, account_id).await?;
93    let avg_tweet_score = get_avg_tweet_engagement_for(pool, account_id).await?;
94    let (total_replies_sent, total_tweets_posted) =
95        get_performance_counts_for(pool, account_id).await?;
96
97    // --- Top topics ---
98    let top_topics = get_top_topics_for(pool, account_id, 5).await?;
99
100    Ok(AnalyticsSummary {
101        followers: FollowerSummary {
102            current,
103            change_7d,
104            change_30d,
105        },
106        actions_today,
107        engagement: EngagementSummary {
108            avg_reply_score,
109            avg_tweet_score,
110            total_replies_sent,
111            total_tweets_posted,
112        },
113        top_topics,
114    })
115}
116
117/// Get a combined analytics summary for the dashboard.
118///
119/// Aggregates follower deltas, today's action counts, and engagement stats
120/// into a single struct to minimise round-trips from the frontend.
121pub async fn get_analytics_summary(pool: &DbPool) -> Result<AnalyticsSummary, StorageError> {
122    get_analytics_summary_for(pool, DEFAULT_ACCOUNT_ID).await
123}