tuitbot_core/storage/analytics/
best_times.rs1use super::super::accounts::DEFAULT_ACCOUNT_ID;
4use super::super::DbPool;
5use crate::error::StorageError;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct BestTimeSlot {
11 pub hour: i32,
12 pub day_of_week: i32,
13 pub day_name: String,
14 pub avg_engagement: f64,
15 pub confidence_score: f64, pub sample_size: i64,
17}
18
19pub async fn get_best_times_for(
21 pool: &DbPool,
22 account_id: &str,
23) -> Result<Vec<BestTimeSlot>, StorageError> {
24 let day_names = [
25 "Sunday",
26 "Monday",
27 "Tuesday",
28 "Wednesday",
29 "Thursday",
30 "Friday",
31 "Saturday",
32 ];
33
34 let rows = sqlx::query_as::<_, (i32, i32, f64, f64, i64)>(
35 "SELECT hour_of_day, day_of_week, avg_engagement, confidence_score, sample_size \
36 FROM best_times \
37 WHERE account_id = ? \
38 ORDER BY avg_engagement DESC",
39 )
40 .bind(account_id)
41 .fetch_all(pool)
42 .await
43 .map_err(|e| StorageError::Query { source: e })?;
44
45 Ok(rows
46 .into_iter()
47 .map(
48 |(hour, day, avg_engagement, confidence, sample_size)| BestTimeSlot {
49 hour,
50 day_of_week: day,
51 day_name: day_names[day as usize % 7].to_string(),
52 avg_engagement,
53 confidence_score: confidence,
54 sample_size,
55 },
56 )
57 .collect())
58}
59
60pub async fn get_best_times(pool: &DbPool) -> Result<Vec<BestTimeSlot>, StorageError> {
62 get_best_times_for(pool, DEFAULT_ACCOUNT_ID).await
63}
64
65pub async fn aggregate_best_times_for(pool: &DbPool, account_id: &str) -> Result<(), StorageError> {
68 let rows = sqlx::query_as::<_, (i32, i32, f64, i64)>(
70 "SELECT \
71 CAST(STRFTIME('%H', posted_at) AS INTEGER) as hour, \
72 (CAST(STRFTIME('%w', posted_at) AS INTEGER) + 6) % 7 as day_of_week, \
73 AVG(engagement_rate) as avg_engagement, \
74 COUNT(*) as sample_size \
75 FROM engagement_metrics \
76 WHERE account_id = ? AND posted_at IS NOT NULL \
77 GROUP BY hour, day_of_week",
78 )
79 .bind(account_id)
80 .fetch_all(pool)
81 .await
82 .map_err(|e| StorageError::Query { source: e })?;
83
84 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
87
88 for (hour, day_of_week, avg_engagement, sample_size) in rows {
89 let confidence_score = if sample_size >= 5 {
90 90.0 + (sample_size as f64 - 5.0).min(10.0)
91 } else if sample_size >= 2 {
92 50.0 + (sample_size as f64 - 2.0) * 15.0
93 } else {
94 (sample_size as f64) * 20.0
95 };
96
97 sqlx::query(
98 "INSERT INTO best_times \
99 (account_id, hour_of_day, day_of_week, avg_engagement, confidence_score, sample_size, last_updated) \
100 VALUES (?, ?, ?, ?, ?, ?, ?) \
101 ON CONFLICT(account_id, hour_of_day, day_of_week) DO UPDATE SET \
102 avg_engagement = excluded.avg_engagement, \
103 confidence_score = excluded.confidence_score, \
104 sample_size = excluded.sample_size, \
105 last_updated = excluded.last_updated",
106 )
107 .bind(account_id)
108 .bind(hour)
109 .bind(day_of_week)
110 .bind(avg_engagement)
111 .bind(confidence_score)
112 .bind(sample_size)
113 .bind(&now)
114 .execute(pool)
115 .await
116 .map_err(|e| StorageError::Query { source: e })?;
117 }
118
119 Ok(())
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct FollowerGrowthSnapshot {
125 pub date: String,
126 pub follower_count: i64,
127 pub daily_delta: i64,
128 pub weekly_delta: i64,
129}
130
131pub async fn get_follower_growth_for(
133 pool: &DbPool,
134 account_id: &str,
135 days: u32,
136) -> Result<Vec<FollowerGrowthSnapshot>, StorageError> {
137 let rows = sqlx::query_as::<_, (String, i64)>(
139 "SELECT snapshot_date, follower_count \
140 FROM follower_snapshots \
141 WHERE account_id = ? \
142 AND snapshot_date >= date('now', '-' || ? || ' days') \
143 ORDER BY snapshot_date ASC",
144 )
145 .bind(account_id)
146 .bind(days as i64)
147 .fetch_all(pool)
148 .await
149 .map_err(|e| StorageError::Query { source: e })?;
150
151 let mut result = Vec::new();
152 for (i, (date, follower_count)) in rows.iter().enumerate() {
153 let daily_delta = if i > 0 {
154 follower_count - rows[i - 1].1
155 } else {
156 0
157 };
158
159 let weekly_delta = if i >= 7 {
160 follower_count - rows[i - 7].1
161 } else {
162 0
163 };
164
165 result.push(FollowerGrowthSnapshot {
166 date: date.clone(),
167 follower_count: *follower_count,
168 daily_delta,
169 weekly_delta,
170 });
171 }
172
173 Ok(result)
174}
175
176pub async fn get_follower_growth(
178 pool: &DbPool,
179 days: u32,
180) -> Result<Vec<FollowerGrowthSnapshot>, StorageError> {
181 get_follower_growth_for(pool, DEFAULT_ACCOUNT_ID, days).await
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct HeatmapCell {
187 pub day_of_week: i32,
188 pub hour: i32,
189 pub avg_engagement: f64,
190 pub sample_size: i64,
191}
192
193pub async fn get_heatmap_for(
196 pool: &DbPool,
197 account_id: &str,
198) -> Result<Vec<HeatmapCell>, StorageError> {
199 let rows = sqlx::query_as::<_, (i32, i32, f64, i64)>(
200 "SELECT day_of_week, hour_of_day, avg_engagement, sample_size \
201 FROM best_times \
202 WHERE account_id = ? \
203 ORDER BY day_of_week ASC, hour_of_day ASC",
204 )
205 .bind(account_id)
206 .fetch_all(pool)
207 .await
208 .map_err(|e| StorageError::Query { source: e })?;
209
210 let mut grid: Vec<HeatmapCell> = Vec::with_capacity(7 * 24);
212 let mut lookup = std::collections::HashMap::new();
213 for (day, hour, avg, size) in &rows {
214 lookup.insert((*day, *hour), (*avg, *size));
215 }
216 for day in 0..7 {
217 for hour in 0..24 {
218 let (avg_engagement, sample_size) =
219 lookup.get(&(day, hour)).copied().unwrap_or((0.0, 0));
220 grid.push(HeatmapCell {
221 day_of_week: day,
222 hour,
223 avg_engagement,
224 sample_size,
225 });
226 }
227 }
228 Ok(grid)
229}
230
231pub async fn get_heatmap(pool: &DbPool) -> Result<Vec<HeatmapCell>, StorageError> {
233 get_heatmap_for(pool, DEFAULT_ACCOUNT_ID).await
234}
235
236#[cfg(test)]
237mod tests {
238
239 #[test]
240 fn confidence_score_high() {
241 let sample_size = 10i64;
242 let confidence = if sample_size >= 5 {
243 90.0 + (sample_size as f64 - 5.0).min(10.0)
244 } else {
245 0.0
246 };
247 assert!(confidence >= 95.0);
248 }
249
250 #[test]
251 fn confidence_score_medium() {
252 let sample_size = 3i64;
253 let confidence = if sample_size >= 2 {
254 50.0 + (sample_size as f64 - 2.0) * 15.0
255 } else {
256 0.0
257 };
258 assert_eq!(confidence, 65.0);
259 }
260
261 #[test]
262 fn confidence_score_low() {
263 let sample_size = 1i64;
264 let confidence = (sample_size as f64) * 20.0;
265 assert_eq!(confidence, 20.0);
266 }
267
268 #[test]
269 fn follower_delta_calculation() {
270 let prev_count = 1000i64;
271 let curr_count = 1050i64;
272 let daily_delta = curr_count - prev_count;
273 assert_eq!(daily_delta, 50);
274 }
275
276 #[test]
277 fn day_name_mapping() {
278 let day_names = [
279 "Sunday",
280 "Monday",
281 "Tuesday",
282 "Wednesday",
283 "Thursday",
284 "Friday",
285 "Saturday",
286 ];
287 assert_eq!(day_names[0], "Sunday");
288 assert_eq!(day_names[6], "Saturday");
289 }
290}