use super::super::accounts::DEFAULT_ACCOUNT_ID;
use super::super::DbPool;
use crate::error::StorageError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BestTimeSlot {
pub hour: i32,
pub day_of_week: i32,
pub day_name: String,
pub avg_engagement: f64,
pub confidence_score: f64, pub sample_size: i64,
}
pub async fn get_best_times_for(
pool: &DbPool,
account_id: &str,
) -> Result<Vec<BestTimeSlot>, StorageError> {
let day_names = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
let rows = sqlx::query_as::<_, (i32, i32, f64, f64, i64)>(
"SELECT hour_of_day, day_of_week, avg_engagement, confidence_score, sample_size \
FROM best_times \
WHERE account_id = ? \
ORDER BY avg_engagement DESC",
)
.bind(account_id)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
Ok(rows
.into_iter()
.map(
|(hour, day, avg_engagement, confidence, sample_size)| BestTimeSlot {
hour,
day_of_week: day,
day_name: day_names[day as usize % 7].to_string(),
avg_engagement,
confidence_score: confidence,
sample_size,
},
)
.collect())
}
pub async fn get_best_times(pool: &DbPool) -> Result<Vec<BestTimeSlot>, StorageError> {
get_best_times_for(pool, DEFAULT_ACCOUNT_ID).await
}
pub async fn aggregate_best_times_for(pool: &DbPool, account_id: &str) -> Result<(), StorageError> {
let rows = sqlx::query_as::<_, (i32, i32, f64, i64)>(
"SELECT \
CAST(STRFTIME('%H', posted_at) AS INTEGER) as hour, \
(CAST(STRFTIME('%w', posted_at) AS INTEGER) + 6) % 7 as day_of_week, \
AVG(engagement_rate) as avg_engagement, \
COUNT(*) as sample_size \
FROM engagement_metrics \
WHERE account_id = ? AND posted_at IS NOT NULL \
GROUP BY hour, day_of_week",
)
.bind(account_id)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
for (hour, day_of_week, avg_engagement, sample_size) in rows {
let confidence_score = if sample_size >= 5 {
90.0 + (sample_size as f64 - 5.0).min(10.0)
} else if sample_size >= 2 {
50.0 + (sample_size as f64 - 2.0) * 15.0
} else {
(sample_size as f64) * 20.0
};
sqlx::query(
"INSERT INTO best_times \
(account_id, hour_of_day, day_of_week, avg_engagement, confidence_score, sample_size, last_updated) \
VALUES (?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(account_id, hour_of_day, day_of_week) DO UPDATE SET \
avg_engagement = excluded.avg_engagement, \
confidence_score = excluded.confidence_score, \
sample_size = excluded.sample_size, \
last_updated = excluded.last_updated",
)
.bind(account_id)
.bind(hour)
.bind(day_of_week)
.bind(avg_engagement)
.bind(confidence_score)
.bind(sample_size)
.bind(&now)
.execute(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FollowerGrowthSnapshot {
pub date: String,
pub follower_count: i64,
pub daily_delta: i64,
pub weekly_delta: i64,
}
pub async fn get_follower_growth_for(
pool: &DbPool,
account_id: &str,
days: u32,
) -> Result<Vec<FollowerGrowthSnapshot>, StorageError> {
let rows = sqlx::query_as::<_, (String, i64)>(
"SELECT snapshot_date, follower_count \
FROM follower_snapshots \
WHERE account_id = ? \
AND snapshot_date >= date('now', '-' || ? || ' days') \
ORDER BY snapshot_date ASC",
)
.bind(account_id)
.bind(days as i64)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
let mut result = Vec::new();
for (i, (date, follower_count)) in rows.iter().enumerate() {
let daily_delta = if i > 0 {
follower_count - rows[i - 1].1
} else {
0
};
let weekly_delta = if i >= 7 {
follower_count - rows[i - 7].1
} else {
0
};
result.push(FollowerGrowthSnapshot {
date: date.clone(),
follower_count: *follower_count,
daily_delta,
weekly_delta,
});
}
Ok(result)
}
pub async fn get_follower_growth(
pool: &DbPool,
days: u32,
) -> Result<Vec<FollowerGrowthSnapshot>, StorageError> {
get_follower_growth_for(pool, DEFAULT_ACCOUNT_ID, days).await
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapCell {
pub day_of_week: i32,
pub hour: i32,
pub avg_engagement: f64,
pub sample_size: i64,
}
pub async fn get_heatmap_for(
pool: &DbPool,
account_id: &str,
) -> Result<Vec<HeatmapCell>, StorageError> {
let rows = sqlx::query_as::<_, (i32, i32, f64, i64)>(
"SELECT day_of_week, hour_of_day, avg_engagement, sample_size \
FROM best_times \
WHERE account_id = ? \
ORDER BY day_of_week ASC, hour_of_day ASC",
)
.bind(account_id)
.fetch_all(pool)
.await
.map_err(|e| StorageError::Query { source: e })?;
let mut grid: Vec<HeatmapCell> = Vec::with_capacity(7 * 24);
let mut lookup = std::collections::HashMap::new();
for (day, hour, avg, size) in &rows {
lookup.insert((*day, *hour), (*avg, *size));
}
for day in 0..7 {
for hour in 0..24 {
let (avg_engagement, sample_size) =
lookup.get(&(day, hour)).copied().unwrap_or((0.0, 0));
grid.push(HeatmapCell {
day_of_week: day,
hour,
avg_engagement,
sample_size,
});
}
}
Ok(grid)
}
pub async fn get_heatmap(pool: &DbPool) -> Result<Vec<HeatmapCell>, StorageError> {
get_heatmap_for(pool, DEFAULT_ACCOUNT_ID).await
}
#[cfg(test)]
mod tests {
#[test]
fn confidence_score_high() {
let sample_size = 10i64;
let confidence = if sample_size >= 5 {
90.0 + (sample_size as f64 - 5.0).min(10.0)
} else {
0.0
};
assert!(confidence >= 95.0);
}
#[test]
fn confidence_score_medium() {
let sample_size = 3i64;
let confidence = if sample_size >= 2 {
50.0 + (sample_size as f64 - 2.0) * 15.0
} else {
0.0
};
assert_eq!(confidence, 65.0);
}
#[test]
fn confidence_score_low() {
let sample_size = 1i64;
let confidence = (sample_size as f64) * 20.0;
assert_eq!(confidence, 20.0);
}
#[test]
fn follower_delta_calculation() {
let prev_count = 1000i64;
let curr_count = 1050i64;
let daily_delta = curr_count - prev_count;
assert_eq!(daily_delta, 50);
}
#[test]
fn day_name_mapping() {
let day_names = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
assert_eq!(day_names[0], "Sunday");
assert_eq!(day_names[6], "Saturday");
}
}