RTSDA-Website/church-core/src/utils/feed.rs

310 lines
9.8 KiB
Rust

use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
use chrono::{DateTime, Utc, NaiveDateTime};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedItem {
pub id: String,
pub feed_type: FeedItemType,
pub timestamp: String, // ISO8601 format
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum FeedItemType {
#[serde(rename = "event")]
Event {
event: ClientEvent,
},
#[serde(rename = "sermon")]
Sermon {
sermon: Sermon,
},
#[serde(rename = "bulletin")]
Bulletin {
bulletin: Bulletin,
},
#[serde(rename = "verse")]
Verse {
verse: BibleVerse,
},
}
/// Parse date string to DateTime<Utc>, with fallback to current time
fn parse_date_with_fallback(date_str: &str) -> DateTime<Utc> {
// Try ISO8601 format first
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
return dt.with_timezone(&Utc);
}
// Try naive datetime parsing
if let Ok(naive) = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
return DateTime::from_naive_utc_and_offset(naive, Utc);
}
// Fallback to current time
Utc::now()
}
/// Calculate priority for feed items based on type and recency
fn calculate_priority(feed_type: &FeedItemType, timestamp: &DateTime<Utc>) -> i32 {
let now = Utc::now();
let age_days = (now - *timestamp).num_days().max(0);
match feed_type {
FeedItemType::Event { .. } => {
// Events get highest priority, especially upcoming ones
if *timestamp > now {
1000 // Future events (upcoming)
} else {
800 - (age_days as i32) // Recent past events
}
},
FeedItemType::Sermon { .. } => {
// Sermons get high priority when recent
600 - (age_days as i32)
},
FeedItemType::Bulletin { .. } => {
// Bulletins get medium priority
400 - (age_days as i32)
},
FeedItemType::Verse { .. } => {
// Daily verse always gets consistent priority
300
},
}
}
/// Aggregate and sort home feed items
pub fn aggregate_home_feed(
events: &[ClientEvent],
sermons: &[Sermon],
bulletins: &[Bulletin],
daily_verse: Option<&BibleVerse>
) -> Vec<FeedItem> {
let mut feed_items = Vec::new();
// Add recent sermons (limit to 3)
for sermon in sermons.iter().take(3) {
let timestamp = sermon.date; // Already a DateTime<Utc>
let feed_type = FeedItemType::Sermon { sermon: sermon.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("sermon_{}", sermon.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add upcoming events (limit to 2)
for event in events.iter().take(2) {
let timestamp = parse_date_with_fallback(&event.created_at);
let feed_type = FeedItemType::Event { event: event.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("event_{}", event.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add most recent bulletin
if let Some(bulletin) = bulletins.first() {
let timestamp = parse_date_with_fallback(&bulletin.date.to_string());
let feed_type = FeedItemType::Bulletin { bulletin: bulletin.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("bulletin_{}", bulletin.id),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Add daily verse
if let Some(verse) = daily_verse {
let timestamp = Utc::now();
let feed_type = FeedItemType::Verse { verse: verse.clone() };
let priority = calculate_priority(&feed_type, &timestamp);
feed_items.push(FeedItem {
id: format!("verse_{}", verse.reference),
feed_type,
timestamp: timestamp.to_rfc3339(),
priority,
});
}
// Sort by priority (highest first), then by timestamp (newest first)
feed_items.sort_by(|a, b| {
b.priority.cmp(&a.priority)
.then_with(|| b.timestamp.cmp(&a.timestamp))
});
feed_items
}
/// Media type enumeration for content categorization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MediaType {
Sermons,
LiveStreams,
}
impl MediaType {
pub fn display_name(&self) -> &'static str {
match self {
MediaType::Sermons => "Sermons",
MediaType::LiveStreams => "Live Archives",
}
}
pub fn icon_name(&self) -> &'static str {
match self {
MediaType::Sermons => "play.rectangle.fill",
MediaType::LiveStreams => "dot.radiowaves.left.and.right",
}
}
}
/// Get sermons or livestreams based on media type
pub fn get_media_content(sermons: &[Sermon], media_type: &MediaType) -> Vec<Sermon> {
match media_type {
MediaType::Sermons => {
// Filter for regular sermons (non-livestream)
sermons.iter()
.filter(|sermon| !sermon.title.to_lowercase().contains("livestream"))
.cloned()
.collect()
},
MediaType::LiveStreams => {
// Filter for livestream archives
sermons.iter()
.filter(|sermon| sermon.title.to_lowercase().contains("livestream"))
.cloned()
.collect()
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
fn create_sample_event(id: &str, title: &str) -> ClientEvent {
ClientEvent {
id: id.to_string(),
title: title.to_string(),
description: "Sample description".to_string(),
date: "2025-01-15".to_string(),
start_time: "6:00 PM".to_string(),
end_time: "8:00 PM".to_string(),
location: "Sample Location".to_string(),
location_url: None,
image: None,
thumbnail: None,
category: "Social".to_string(),
is_featured: false,
recurring_type: None,
tags: None,
contact_email: None,
contact_phone: None,
registration_url: None,
max_attendees: None,
current_attendees: None,
created_at: "2025-01-10T10:00:00Z".to_string(),
updated_at: "2025-01-10T10:00:00Z".to_string(),
duration_minutes: 120,
has_registration: false,
is_full: false,
spots_remaining: None,
}
}
fn create_sample_sermon(id: &str, title: &str) -> Sermon {
Sermon {
id: id.to_string(),
title: title.to_string(),
description: Some("Sample sermon".to_string()),
date: Some("2025-01-10T10:00:00Z".to_string()),
video_url: Some("https://example.com/video".to_string()),
audio_url: None,
thumbnail_url: None,
duration: None,
speaker: Some("Pastor Smith".to_string()),
series: None,
scripture_references: None,
tags: None,
}
}
#[test]
fn test_aggregate_home_feed() {
let events = vec![
create_sample_event("1", "Event 1"),
create_sample_event("2", "Event 2"),
];
let sermons = vec![
create_sample_sermon("1", "Sermon 1"),
create_sample_sermon("2", "Sermon 2"),
];
let bulletins = vec![
Bulletin {
id: "1".to_string(),
title: "Weekly Bulletin".to_string(),
date: "2025-01-12T10:00:00Z".to_string(),
pdf_url: "https://example.com/bulletin.pdf".to_string(),
description: Some("This week's bulletin".to_string()),
thumbnail_url: None,
}
];
let verse = BibleVerse {
text: "For God so loved the world...".to_string(),
reference: "John 3:16".to_string(),
version: Some("KJV".to_string()),
};
let feed = aggregate_home_feed(&events, &sermons, &bulletins, Some(&verse));
assert!(feed.len() >= 4); // Should have events, sermons, bulletin, and verse
// Check that items are sorted by priority
for i in 1..feed.len() {
assert!(feed[i-1].priority >= feed[i].priority);
}
}
#[test]
fn test_media_type_display() {
assert_eq!(MediaType::Sermons.display_name(), "Sermons");
assert_eq!(MediaType::LiveStreams.display_name(), "Live Archives");
assert_eq!(MediaType::Sermons.icon_name(), "play.rectangle.fill");
assert_eq!(MediaType::LiveStreams.icon_name(), "dot.radiowaves.left.and.right");
}
#[test]
fn test_get_media_content() {
let sermons = vec![
create_sample_sermon("1", "Regular Sermon"),
create_sample_sermon("2", "Livestream Service"),
create_sample_sermon("3", "Another Sermon"),
];
let regular_sermons = get_media_content(&sermons, &MediaType::Sermons);
assert_eq!(regular_sermons.len(), 2);
let livestreams = get_media_content(&sermons, &MediaType::LiveStreams);
assert_eq!(livestreams.len(), 1);
assert!(livestreams[0].title.contains("Livestream"));
}
}