use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono_tz::Tz;
use std::collections::HashMap;
use std::time::Duration;
use crate::config::ScheduleConfig;
use super::recurrence::PostingSlot;
pub const AUTO_PREFERRED_TIMES: &[&str] = &["09:15", "12:30", "17:00"];
pub(super) fn parse_weekday(s: &str) -> Option<chrono::Weekday> {
match s.trim() {
"Mon" => Some(chrono::Weekday::Mon),
"Tue" => Some(chrono::Weekday::Tue),
"Wed" => Some(chrono::Weekday::Wed),
"Thu" => Some(chrono::Weekday::Thu),
"Fri" => Some(chrono::Weekday::Fri),
"Sat" => Some(chrono::Weekday::Sat),
"Sun" => Some(chrono::Weekday::Sun),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct ActiveSchedule {
pub(super) tz: Tz,
pub(super) start_hour: u8,
pub(super) end_hour: u8,
pub(super) active_weekdays: Vec<chrono::Weekday>,
pub(super) preferred_times: Vec<PostingSlot>,
pub(super) preferred_times_override: HashMap<chrono::Weekday, Vec<PostingSlot>>,
pub(super) thread_preferred_day: Option<chrono::Weekday>,
pub(super) thread_preferred_time: PostingSlot,
}
impl ActiveSchedule {
pub fn from_config(config: &ScheduleConfig) -> Option<Self> {
let tz: Tz = config.timezone.parse().ok()?;
let active_weekdays: Vec<chrono::Weekday> = config
.active_days
.iter()
.filter_map(|d| parse_weekday(d))
.collect();
let mut preferred_times: Vec<PostingSlot> = Vec::new();
for time_str in &config.preferred_times {
if time_str == "auto" {
for auto_time in AUTO_PREFERRED_TIMES {
if let Some(slot) = PostingSlot::parse(auto_time) {
preferred_times.push(slot);
}
}
} else if let Some(slot) = PostingSlot::parse(time_str) {
preferred_times.push(slot);
}
}
preferred_times.sort();
preferred_times.dedup();
let mut preferred_times_override: HashMap<chrono::Weekday, Vec<PostingSlot>> =
HashMap::new();
for (day_str, times) in &config.preferred_times_override {
if let Some(weekday) = parse_weekday(day_str) {
let mut slots: Vec<PostingSlot> =
times.iter().filter_map(|t| PostingSlot::parse(t)).collect();
slots.sort();
slots.dedup();
preferred_times_override.insert(weekday, slots);
}
}
let thread_preferred_day = config
.thread_preferred_day
.as_deref()
.and_then(parse_weekday);
let thread_preferred_time =
PostingSlot::parse(&config.thread_preferred_time).unwrap_or(PostingSlot {
hour: 10,
minute: 0,
});
Some(Self {
tz,
start_hour: config.active_hours_start,
end_hour: config.active_hours_end,
active_weekdays,
preferred_times,
preferred_times_override,
thread_preferred_day,
thread_preferred_time,
})
}
pub fn has_preferred_times(&self) -> bool {
!self.preferred_times.is_empty()
}
pub fn has_thread_preferred_schedule(&self) -> bool {
self.thread_preferred_day.is_some()
}
pub fn slots_for_today(&self) -> Vec<PostingSlot> {
let now = Utc::now().with_timezone(&self.tz);
let weekday = now.weekday();
if let Some(override_slots) = self.preferred_times_override.get(&weekday) {
override_slots.clone()
} else {
self.preferred_times.clone()
}
}
pub fn next_unused_slot(
&self,
today_post_times: &[DateTime<Utc>],
) -> Option<(Duration, PostingSlot)> {
let now = Utc::now().with_timezone(&self.tz);
let slots = self.slots_for_today();
for slot in &slots {
let slot_time = slot.to_naive_time();
let slot_used = today_post_times.iter().any(|post_time| {
let post_local = post_time.with_timezone(&self.tz);
let post_naive = post_local.time();
let diff = (post_naive.num_seconds_from_midnight() as i64)
- (slot_time.num_seconds_from_midnight() as i64);
diff.unsigned_abs() <= 30 * 60
});
if slot_used {
continue;
}
let now_time = now.time();
if slot_time > now_time {
let diff_secs = (slot_time.num_seconds_from_midnight() as i64)
- (now_time.num_seconds_from_midnight() as i64);
return Some((Duration::from_secs(diff_secs as u64), slot.clone()));
}
}
None
}
pub fn next_thread_slot(&self) -> Option<Duration> {
let target_day = self.thread_preferred_day?;
let target_time = self.thread_preferred_time.to_naive_time();
let now = Utc::now().with_timezone(&self.tz);
let now_weekday = now.weekday();
let now_time = now.time();
if now_weekday == target_day && now_time < target_time {
let diff_secs = (target_time.num_seconds_from_midnight() as i64)
- (now_time.num_seconds_from_midnight() as i64);
return Some(Duration::from_secs(diff_secs as u64));
}
let now_num = now_weekday.num_days_from_monday();
let target_num = target_day.num_days_from_monday();
let days_ahead = if target_num > now_num {
target_num - now_num
} else {
7 - (now_num - target_num)
};
let secs_remaining_today = (86400 - now_time.num_seconds_from_midnight()) as u64;
let full_days_between = (days_ahead as u64 - 1) * 86400;
let secs_into_target_day = target_time.num_seconds_from_midnight() as u64;
Some(Duration::from_secs(
secs_remaining_today + full_days_between + secs_into_target_day,
))
}
pub fn is_active(&self) -> bool {
let now = Utc::now().with_timezone(&self.tz);
let hour = now.hour() as u8;
let weekday = now.weekday();
if !self.active_weekdays.is_empty() && !self.active_weekdays.contains(&weekday) {
return false;
}
if self.start_hour <= self.end_hour {
hour >= self.start_hour && hour < self.end_hour
} else {
hour >= self.start_hour || hour < self.end_hour
}
}
pub fn time_until_active(&self) -> Duration {
if self.is_active() {
return Duration::ZERO;
}
let now = Utc::now().with_timezone(&self.tz);
let hour = now.hour() as u8;
let weekday = now.weekday();
let hours_until_start = if hour < self.start_hour {
(self.start_hour - hour) as u64
} else {
(24 - hour + self.start_hour) as u64
};
let is_today_active =
self.active_weekdays.is_empty() || self.active_weekdays.contains(&weekday);
if is_today_active && hour < self.start_hour {
let wait_secs =
hours_until_start * 3600 - (now.minute() as u64 * 60) - now.second() as u64;
return Duration::from_secs(wait_secs.max(1));
}
for day_offset in 1u64..=8 {
let future_day = now + chrono::Duration::days(day_offset as i64);
let future_weekday = future_day.weekday();
if self.active_weekdays.is_empty() || self.active_weekdays.contains(&future_weekday) {
let secs_remaining_today =
(24 - hour as u64) * 3600 - (now.minute() as u64 * 60) - now.second() as u64;
let full_days_between = (day_offset - 1) * 86400;
let secs_into_target_day = self.start_hour as u64 * 3600;
let total = secs_remaining_today + full_days_between + secs_into_target_day;
return Duration::from_secs(total.max(1));
}
}
Duration::from_secs(3600)
}
}