use crate::error::RecommendResult;
use crate::{ContentMetadata, Recommendation, RecommendationReason};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrendingItem {
pub content_id: Uuid,
pub score: f32,
pub view_velocity: f32,
pub engagement_rate: f32,
pub time_window_hours: u32,
}
pub struct TrendingDetector {
trending_items: HashMap<Uuid, TrendingItem>,
content_metadata: HashMap<Uuid, ContentMetadata>,
view_counts: HashMap<Uuid, Vec<ViewCount>>,
}
#[derive(Debug, Clone)]
struct ViewCount {
timestamp: i64,
count: u32,
}
impl TrendingDetector {
#[must_use]
pub fn new() -> Self {
Self {
trending_items: HashMap::new(),
content_metadata: HashMap::new(),
view_counts: HashMap::new(),
}
}
pub fn record_view(&mut self, content_id: Uuid) {
let now = chrono::Utc::now().timestamp();
let counts = self.view_counts.entry(content_id).or_default();
if let Some(last) = counts.last_mut() {
let time_diff = now - last.timestamp;
if time_diff < 3600 {
last.count += 1;
return;
}
}
counts.push(ViewCount {
timestamp: now,
count: 1,
});
counts.retain(|vc| now - vc.timestamp < 86400);
}
pub fn update_scores(&mut self) -> RecommendResult<()> {
let now = chrono::Utc::now().timestamp();
for (content_id, counts) in &self.view_counts {
let score = self.calculate_trending_score(counts, now);
let velocity = self.calculate_view_velocity(counts, now, 6);
let engagement_rate = self.calculate_engagement_rate(*content_id);
let trending_item = TrendingItem {
content_id: *content_id,
score,
view_velocity: velocity,
engagement_rate,
time_window_hours: 24,
};
self.trending_items.insert(*content_id, trending_item);
}
Ok(())
}
fn calculate_trending_score(&self, counts: &[ViewCount], now: i64) -> f32 {
if counts.is_empty() {
return 0.0;
}
let mut score = 0.0;
for count in counts {
let age_hours = (now - count.timestamp) as f32 / 3600.0;
let decay = super::decay::exponential_decay(age_hours, 6.0);
score += count.count as f32 * decay;
}
score
}
fn calculate_view_velocity(&self, counts: &[ViewCount], now: i64, window_hours: i64) -> f32 {
let window_start = now - (window_hours * 3600);
let recent_views: u32 = counts
.iter()
.filter(|vc| vc.timestamp >= window_start)
.map(|vc| vc.count)
.sum();
recent_views as f32 / window_hours as f32
}
fn calculate_engagement_rate(&self, _content_id: Uuid) -> f32 {
0.5
}
pub fn get_trending(&self, limit: usize) -> RecommendResult<Vec<Recommendation>> {
let mut trending: Vec<&TrendingItem> = self.trending_items.values().collect();
trending.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let recommendations: Vec<Recommendation> = trending
.into_iter()
.take(limit)
.enumerate()
.filter_map(|(idx, item)| {
self.content_metadata
.get(&item.content_id)
.map(|metadata| Recommendation {
content_id: item.content_id,
score: item.score,
rank: idx + 1,
reasons: vec![RecommendationReason::Trending {
trending_score: item.score,
}],
metadata: metadata.clone(),
explanation: None,
})
})
.collect();
Ok(recommendations)
}
#[must_use]
pub fn get_trending_items(&self, limit: usize) -> Vec<TrendingItem> {
let mut items: Vec<TrendingItem> = self.trending_items.values().cloned().collect();
items.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
items.truncate(limit);
items
}
#[must_use]
pub fn is_trending(&self, content_id: Uuid, threshold: f32) -> bool {
self.trending_items
.get(&content_id)
.is_some_and(|item| item.score >= threshold)
}
pub fn add_content(&mut self, content_id: Uuid, metadata: ContentMetadata) {
self.content_metadata.insert(content_id, metadata);
}
}
impl Default for TrendingDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trending_detector_creation() {
let detector = TrendingDetector::new();
assert_eq!(detector.trending_items.len(), 0);
}
#[test]
fn test_record_view() {
let mut detector = TrendingDetector::new();
let content_id = Uuid::new_v4();
detector.record_view(content_id);
assert!(detector.view_counts.contains_key(&content_id));
}
#[test]
fn test_update_scores() {
let mut detector = TrendingDetector::new();
let content_id = Uuid::new_v4();
detector.record_view(content_id);
detector.record_view(content_id);
let result = detector.update_scores();
assert!(result.is_ok());
assert!(detector.trending_items.contains_key(&content_id));
}
#[test]
fn test_is_trending() {
let mut detector = TrendingDetector::new();
let content_id = Uuid::new_v4();
detector.record_view(content_id);
detector.update_scores().expect("should succeed in test");
assert!(detector.is_trending(content_id, 0.0));
}
}