use chrono::{Datelike, NaiveDate};
use chrono_tz::Tz;
use crate::error::AppError;
use crate::timestamp::{parse_org_timestamp, ParsedTimestamp};
use crate::types::{DayAgenda, Task, TaskType, TaskWithOffset};
const DEADLINE_WARNING_DAYS: i64 = 14;
struct PreparedTask<'a> {
task: &'a Task,
parsed: Option<ParsedTimestamp>,
}
fn prepare_tasks(tasks: &[Task]) -> Vec<PreparedTask<'_>> {
tasks
.iter()
.map(|t| PreparedTask {
task: t,
parsed: t
.timestamp
.as_deref()
.and_then(|ts| parse_org_timestamp(ts, None)),
})
.collect()
}
#[derive(Debug)]
pub enum AgendaOutput {
Days(Vec<DayAgenda>),
Tasks(Vec<Task>),
}
pub fn filter_agenda(
tasks: Vec<Task>,
mode: &str,
date: Option<&str>,
from: Option<&str>,
to: Option<&str>,
tz: &str,
current_date_override: Option<&str>,
) -> Result<AgendaOutput, AppError> {
let tz: Tz = tz
.parse()
.map_err(|_| AppError::InvalidTimezone(tz.to_string()))?;
let today = if let Some(date_str) = current_date_override {
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("current-date '{date_str}': {e}")))?
} else {
chrono::Utc::now().with_timezone(&tz).date_naive()
};
match mode {
"day" => {
let target_date = if let Some(date_str) = date {
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("date '{date_str}': {e}")))?
} else {
today
};
Ok(AgendaOutput::Days(vec![build_day_agenda(
&tasks,
target_date,
today,
)]))
}
"week" => {
let (start_date, end_date) = if let (Some(from_str), Some(to_str)) = (from, to) {
let start = NaiveDate::parse_from_str(from_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("from '{from_str}': {e}")))?;
let end = NaiveDate::parse_from_str(to_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("to '{to_str}': {e}")))?;
if start > end {
return Err(AppError::DateRange(format!(
"Start date {from_str} is after end date {to_str}"
)));
}
(start, end)
} else if let Some(date_str) = date {
let target_date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("date '{date_str}': {e}")))?;
get_week_for_date(target_date)
} else {
get_current_week(&tz)
};
Ok(AgendaOutput::Days(build_week_agenda(
&tasks, start_date, end_date, today,
)))
}
"month" => {
let (start_date, end_date) = if let (Some(from_str), Some(to_str)) = (from, to) {
let start = NaiveDate::parse_from_str(from_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("from '{from_str}': {e}")))?;
let end = NaiveDate::parse_from_str(to_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("to '{to_str}': {e}")))?;
if start > end {
return Err(AppError::DateRange(format!(
"Start date {from_str} is after end date {to_str}"
)));
}
(start, end)
} else if let Some(date_str) = date {
let target_date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("date '{date_str}': {e}")))?;
get_month_for_date(target_date)
} else {
get_current_month(&tz)
};
Ok(AgendaOutput::Days(build_week_agenda(
&tasks, start_date, end_date, today,
)))
}
"tasks" => {
let mut filtered: Vec<Task> = tasks
.into_iter()
.filter(|t| matches!(t.task_type, Some(TaskType::Todo)))
.collect();
filtered.sort_by_key(|t| t.priority.as_ref().map(|p| p.order()).unwrap_or(999));
Ok(AgendaOutput::Tasks(filtered))
}
_ => Err(AppError::InvalidDate(format!(
"Invalid agenda mode '{mode}'"
))),
}
}
fn build_day_agenda(tasks: &[Task], day_date: NaiveDate, current_date: NaiveDate) -> DayAgenda {
let prepared = prepare_tasks(tasks);
build_day_agenda_prepared(&prepared, day_date, current_date)
}
fn build_day_agenda_prepared(
prepared: &[PreparedTask<'_>],
day_date: NaiveDate,
current_date: NaiveDate,
) -> DayAgenda {
let mut agenda = DayAgenda::new(day_date);
for entry in prepared {
let task = entry.task;
if let Some(ref parsed) = entry.parsed {
if let Some(ref repeater) = parsed.repeater {
handle_repeating_task(task, parsed, repeater, day_date, current_date, &mut agenda);
} else {
handle_non_repeating_task(task, parsed, day_date, current_date, &mut agenda);
}
}
}
agenda.overdue.sort_by_key(|t| t.days_offset);
agenda
.scheduled_timed
.sort_by(|a, b| a.task.timestamp_time.cmp(&b.task.timestamp_time));
agenda.upcoming.sort_by_key(|t| t.days_offset);
agenda
}
fn handle_non_repeating_task(
task: &Task,
parsed: &crate::timestamp::ParsedTimestamp,
day_date: NaiveDate,
current_date: NaiveDate,
agenda: &mut DayAgenda,
) {
let task_date = parsed.date;
let days_diff = (task_date - day_date).num_days();
let is_done = matches!(task.task_type, Some(TaskType::Done));
let is_today = day_date == current_date;
let days_offset = if days_diff != 0 {
Some(days_diff)
} else {
None
};
if task_date == day_date {
let task_with_offset = TaskWithOffset {
task: task.clone(),
days_offset,
};
if task_with_offset.task.timestamp_time.is_some() {
agenda.scheduled_timed.push(task_with_offset);
} else {
agenda.scheduled_no_time.push(task_with_offset);
}
} else if days_diff < 0 && is_today && !is_done {
agenda
.overdue
.push(create_task_without_time(task, days_offset));
} else if days_diff > 0 && is_today {
if let Some(ref ts_type) = task.timestamp_type {
if ts_type == "DEADLINE" && days_diff <= DEADLINE_WARNING_DAYS {
agenda
.upcoming
.push(create_task_without_time(task, days_offset));
}
}
}
}
fn create_task_without_time(task: &Task, days_offset: Option<i64>) -> TaskWithOffset {
let mut task_copy = task.clone();
task_copy.timestamp_time = None;
task_copy.timestamp_end_time = None;
TaskWithOffset {
task: task_copy,
days_offset,
}
}
fn format_repeating_timestamp(
ts_type: &str,
date: NaiveDate,
time: Option<&str>,
repeater: &crate::timestamp::Repeater,
) -> String {
let weekday = date.format("%a");
let date_str = date.format("%Y-%m-%d");
let prefix = repeater.repeater_type.prefix();
let suffix = repeater.unit.suffix();
match time {
Some(t) => format!(
"{ts_type}: <{date_str} {weekday} {t} {prefix}{value}{suffix}>",
value = repeater.value
),
None => format!(
"{ts_type}: <{date_str} {weekday} {prefix}{value}{suffix}>",
value = repeater.value
),
}
}
fn push_scheduled_occurrence(
task: &Task,
repeater: &crate::timestamp::Repeater,
day_date: NaiveDate,
agenda: &mut DayAgenda,
) {
let mut task_copy = task.clone();
task_copy.timestamp_date = Some(day_date.format("%Y-%m-%d").to_string());
if let Some(ref ts_type) = task.timestamp_type {
task_copy.timestamp = Some(format_repeating_timestamp(
ts_type,
day_date,
task.timestamp_time.as_deref(),
repeater,
));
}
let task_with_offset = TaskWithOffset {
task: task_copy,
days_offset: None,
};
if task_with_offset.task.timestamp_time.is_some() {
agenda.scheduled_timed.push(task_with_offset);
} else {
agenda.scheduled_no_time.push(task_with_offset);
}
}
fn push_overdue_occurrence(
task: &Task,
repeater: &crate::timestamp::Repeater,
deadline_date: NaiveDate,
current_date: NaiveDate,
agenda: &mut DayAgenda,
) {
let days_diff = (deadline_date - current_date).num_days();
let mut task_copy = task.clone();
task_copy.timestamp_time = None;
task_copy.timestamp_end_time = None;
task_copy.timestamp_date = Some(deadline_date.format("%Y-%m-%d").to_string());
if let Some(ref ts_type) = task.timestamp_type {
task_copy.timestamp = Some(format_repeating_timestamp(
ts_type,
deadline_date,
None,
repeater,
));
}
agenda.overdue.push(TaskWithOffset {
task: task_copy,
days_offset: Some(days_diff),
});
}
fn handle_repeating_task(
task: &Task,
parsed: &crate::timestamp::ParsedTimestamp,
repeater: &crate::timestamp::Repeater,
day_date: NaiveDate,
current_date: NaiveDate,
agenda: &mut DayAgenda,
) {
use crate::timestamp::{closest_date, DatePreference};
let base_date = parsed.date;
let is_today = day_date == current_date;
let deadline = closest_date(base_date, current_date, DatePreference::Past, repeater);
let repeat = if day_date <= current_date {
deadline
} else {
closest_date(base_date, day_date, DatePreference::Future, repeater)
};
let mut shown_on_day = false;
if let Some(repeat_date) = repeat {
if day_date == repeat_date {
push_scheduled_occurrence(task, repeater, day_date, agenda);
shown_on_day = true;
}
}
if !shown_on_day && deadline.is_none() && current_date < base_date && day_date == base_date {
push_scheduled_occurrence(task, repeater, day_date, agenda);
}
if is_today {
if let Some(deadline_date) = deadline {
if deadline_date < current_date {
let should_show_overdue =
if repeater.unit == crate::timestamp::RepeaterUnit::Workday {
use crate::holidays::HolidayCalendar;
HolidayCalendar::global().is_workday(current_date)
} else {
true
};
if should_show_overdue {
push_overdue_occurrence(task, repeater, deadline_date, current_date, agenda);
}
}
}
if let Some(ref ts_type) = task.timestamp_type {
if ts_type == "DEADLINE" {
let next_due = if let Some(r) = repeat {
if r > current_date {
Some(r)
} else {
None
}
} else if current_date < base_date {
Some(base_date)
} else {
None
};
if let Some(next_date) = next_due {
let days_diff = (next_date - current_date).num_days();
if days_diff > 0 && days_diff <= DEADLINE_WARNING_DAYS {
let mut task_copy = task.clone();
task_copy.timestamp_time = None;
task_copy.timestamp_end_time = None;
agenda.upcoming.push(TaskWithOffset {
task: task_copy,
days_offset: Some(days_diff),
});
}
}
}
}
}
}
fn build_week_agenda(
tasks: &[Task],
start_date: NaiveDate,
end_date: NaiveDate,
current_date: NaiveDate,
) -> Vec<DayAgenda> {
let prepared = prepare_tasks(tasks);
let mut result = Vec::new();
let mut current = start_date;
while current <= end_date {
result.push(build_day_agenda_prepared(&prepared, current, current_date));
current += chrono::Duration::days(1);
}
result
}
fn get_week_for_date(date: NaiveDate) -> (NaiveDate, NaiveDate) {
let weekday = date.weekday();
let days_from_monday = weekday.num_days_from_monday();
let monday = date - chrono::Duration::days(days_from_monday as i64);
let sunday = monday + chrono::Duration::days(6);
(monday, sunday)
}
fn get_current_week(tz: &Tz) -> (NaiveDate, NaiveDate) {
let today = chrono::Utc::now().with_timezone(tz).date_naive();
get_week_for_date(today)
}
fn get_month_for_date(date: NaiveDate) -> (NaiveDate, NaiveDate) {
let first_day = NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap();
let last_day = if date.month() == 12 {
NaiveDate::from_ymd_opt(date.year(), 12, 31).unwrap()
} else {
NaiveDate::from_ymd_opt(date.year(), date.month() + 1, 1).unwrap()
- chrono::Duration::days(1)
};
(first_day, last_day)
}
fn get_current_month(tz: &Tz) -> (NaiveDate, NaiveDate) {
let today = chrono::Utc::now().with_timezone(tz).date_naive();
get_month_for_date(today)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_task_with_type(
date_str: &str,
time: Option<&str>,
task_type: TaskType,
ts_type: &str,
) -> Task {
let timestamp = if let Some(t) = time {
format!("{ts_type}: <{date_str} {t}>")
} else {
format!("{ts_type}: <{date_str}>")
};
Task {
file: "test.md".to_string(),
line: 1,
heading: "Test task".to_string(),
content: String::new(),
task_type: Some(task_type),
priority: None,
created: None,
timestamp: Some(timestamp.clone()),
timestamp_type: Some(ts_type.to_string()),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: time.map(|t| t.to_string()),
timestamp_end_time: None,
clocks: None,
total_clock_time: None,
}
}
fn create_test_task(date_str: &str, time: Option<&str>, task_type: TaskType) -> Task {
create_test_task_with_type(date_str, time, task_type, "SCHEDULED")
}
#[test]
fn test_scheduled_future_not_shown_as_upcoming() {
let tasks = vec![
create_test_task("2024-12-10 Tue", None, TaskType::Todo),
create_test_task("2024-12-20 Fri", None, TaskType::Todo),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
0,
"SCHEDULED tasks in future should not appear as upcoming"
);
assert_eq!(agenda.scheduled_timed.len(), 0);
assert_eq!(agenda.scheduled_no_time.len(), 0);
}
#[test]
fn test_deadline_within_14_days_shown_as_upcoming() {
let tasks = vec![
create_test_task_with_type("2024-12-10 Tue", None, TaskType::Todo, "DEADLINE"),
create_test_task_with_type("2024-12-15 Sun", None, TaskType::Todo, "DEADLINE"),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
2,
"DEADLINE within 14 days should appear as upcoming"
);
assert_eq!(agenda.upcoming[0].days_offset, Some(5));
assert_eq!(agenda.upcoming[1].days_offset, Some(10));
}
#[test]
fn test_deadline_beyond_14_days_not_shown() {
let tasks = vec![
create_test_task_with_type("2024-12-20 Fri", None, TaskType::Todo, "DEADLINE"),
create_test_task_with_type("2025-01-10 Fri", None, TaskType::Todo, "DEADLINE"),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
0,
"DEADLINE beyond 14 days should not appear"
);
}
#[test]
fn test_deadline_exactly_14_days_shown() {
let tasks = vec![create_test_task_with_type(
"2024-12-19 Thu",
None,
TaskType::Todo,
"DEADLINE",
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
1,
"DEADLINE exactly 14 days away should appear"
);
assert_eq!(agenda.upcoming[0].days_offset, Some(14));
}
#[test]
fn test_deadline_15_days_not_shown() {
let tasks = vec![create_test_task_with_type(
"2024-12-20 Fri",
None,
TaskType::Todo,
"DEADLINE",
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
0,
"DEADLINE 15 days away should not appear"
);
}
#[test]
fn test_overdue_only_on_current_date() {
let tasks = vec![
create_test_task("2024-12-01 Sun", None, TaskType::Todo),
create_test_task("2024-12-03 Tue", None, TaskType::Todo),
];
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, current_date, current_date);
assert_eq!(
agenda.overdue.len(),
2,
"Overdue tasks should appear on current date"
);
assert_eq!(agenda.overdue[0].days_offset, Some(-4));
assert_eq!(agenda.overdue[1].days_offset, Some(-2));
let past_date = NaiveDate::from_ymd_opt(2024, 12, 2).unwrap();
let agenda_past = build_day_agenda(&tasks, past_date, current_date);
assert_eq!(
agenda_past.overdue.len(),
0,
"Overdue should not appear on past dates"
);
}
#[test]
fn test_week_agenda_past_days_empty() {
let tasks = vec![
create_test_task("2024-12-02 Mon", Some("10:00"), TaskType::Todo),
create_test_task("2024-12-03 Tue", None, TaskType::Todo),
create_test_task("2024-12-05 Thu", Some("14:00"), TaskType::Todo),
];
let start_date = NaiveDate::from_ymd_opt(2024, 12, 2).unwrap(); let end_date = NaiveDate::from_ymd_opt(2024, 12, 8).unwrap(); let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let week = build_week_agenda(&tasks, start_date, end_date, current_date);
assert_eq!(week.len(), 7);
assert_eq!(week[0].date, "2024-12-02");
assert_eq!(week[0].scheduled_timed.len(), 1);
assert_eq!(week[0].scheduled_no_time.len(), 0);
assert_eq!(week[1].date, "2024-12-03");
assert_eq!(week[1].scheduled_timed.len(), 0);
assert_eq!(week[1].scheduled_no_time.len(), 1);
assert_eq!(week[2].date, "2024-12-04");
assert_eq!(week[2].scheduled_timed.len(), 0);
assert_eq!(week[3].date, "2024-12-05");
assert_eq!(week[3].scheduled_timed.len(), 1);
assert_eq!(week[3].overdue.len(), 2);
assert!(week[4].scheduled_timed.is_empty()); }
#[test]
fn test_build_day_agenda_scheduled_timed() {
let tasks = vec![
create_test_task("2024-12-05 Wed", Some("10:00"), TaskType::Todo),
create_test_task("2024-12-05 Wed", Some("14:00"), TaskType::Todo),
create_test_task("2024-12-05 Wed", None, TaskType::Todo),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 2);
assert_eq!(agenda.scheduled_no_time.len(), 1);
assert_eq!(agenda.upcoming.len(), 0);
assert_eq!(agenda.overdue.len(), 0);
assert_eq!(
agenda.scheduled_timed[0].task.timestamp_time,
Some("10:00".to_string())
);
assert_eq!(
agenda.scheduled_timed[1].task.timestamp_time,
Some("14:00".to_string())
);
}
#[test]
fn test_mixed_scheduled_and_deadline() {
let tasks = vec![
create_test_task("2024-12-10 Tue", None, TaskType::Todo), create_test_task_with_type("2024-12-10 Tue", None, TaskType::Todo, "DEADLINE"), create_test_task_with_type("2024-12-25 Wed", None, TaskType::Todo, "DEADLINE"), ];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
1,
"Only DEADLINE within 14 days should appear"
);
assert_eq!(
agenda.upcoming[0].task.timestamp_type,
Some("DEADLINE".to_string())
);
}
fn create_test_task_with_repeater(
date_str: &str,
time: Option<&str>,
repeater: &str,
task_type: TaskType,
) -> Task {
let timestamp = if let Some(t) = time {
format!("SCHEDULED: <{date_str} {t} {repeater}>")
} else {
format!("SCHEDULED: <{date_str} {repeater}>")
};
Task {
file: "test.md".to_string(),
line: 1,
heading: "Test task".to_string(),
content: String::new(),
task_type: Some(task_type),
priority: None,
created: None,
timestamp: Some(timestamp.clone()),
timestamp_type: Some("SCHEDULED".to_string()),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: time.map(|t| t.to_string()),
timestamp_end_time: None,
clocks: None,
total_clock_time: None,
}
}
fn create_test_task_with_repeater_deadline(
date_str: &str,
time: Option<&str>,
repeater: &str,
task_type: TaskType,
) -> Task {
let timestamp = if let Some(t) = time {
format!("DEADLINE: <{date_str} {t} {repeater}>")
} else {
format!("DEADLINE: <{date_str} {repeater}>")
};
Task {
file: "test.md".to_string(),
line: 1,
heading: "Test task".to_string(),
content: String::new(),
task_type: Some(task_type),
priority: None,
created: None,
timestamp: Some(timestamp.clone()),
timestamp_type: Some("DEADLINE".to_string()),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: time.map(|t| t.to_string()),
timestamp_end_time: None,
clocks: None,
total_clock_time: None,
}
}
#[test]
fn test_build_day_agenda_repeating_daily() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
Some("10:00"),
"+1d",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 1);
assert_eq!(
agenda.scheduled_timed[0].task.timestamp_time,
Some("10:00".to_string())
);
}
#[test]
fn test_build_day_agenda_repeating_not_occurrence_day() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
None,
"+2d",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 4).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 0);
assert_eq!(agenda.scheduled_no_time.len(), 0);
}
#[test]
fn test_build_day_agenda_repeating_weekly() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
None,
"+1w",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 8).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 8).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_no_time.len(), 1);
let day_date = NaiveDate::from_ymd_opt(2024, 12, 9).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_no_time.len(), 0);
}
#[test]
fn test_build_day_agenda_repeating_every_2_days() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
None,
"+2d",
TaskType::Todo,
)];
let test_dates = vec![
(NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(), false), (NaiveDate::from_ymd_opt(2024, 12, 2).unwrap(), false),
(NaiveDate::from_ymd_opt(2024, 12, 3).unwrap(), false), (NaiveDate::from_ymd_opt(2024, 12, 4).unwrap(), false),
(NaiveDate::from_ymd_opt(2024, 12, 5).unwrap(), true), ];
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
for (date, should_show) in test_dates {
let agenda = build_day_agenda(&tasks, date, current_date);
if should_show {
assert_eq!(agenda.scheduled_no_time.len(), 1, "Failed for date {date}");
} else {
assert_eq!(agenda.scheduled_no_time.len(), 0, "Failed for date {date}");
}
}
}
#[test]
fn test_overdue_repeating_task_on_non_occurrence_day() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
Some("10:00"),
"+2d",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 6).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 6).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert!(
!agenda.overdue.is_empty(),
"expected the +2d task to surface in overdue on a non-occurrence day; \
got scheduled_timed={} scheduled_no_time={}",
agenda.scheduled_timed.len(),
agenda.scheduled_no_time.len()
);
assert_eq!(agenda.overdue[0].task.timestamp_time, None);
}
#[test]
fn test_upcoming_repeating_task_has_no_time() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2024-12-10 Mon",
Some("15:00"),
"+1d",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.upcoming.len(), 1);
assert_eq!(agenda.upcoming[0].task.timestamp_time, None);
assert_eq!(agenda.upcoming[0].days_offset, Some(5));
}
#[test]
fn test_repeating_deadline_beyond_warning_not_shown() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2026-08-24 Mon",
None,
"+1y",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.upcoming.len(),
0,
"DEADLINE beyond 14 days should not appear in upcoming"
);
}
#[test]
fn test_build_day_agenda_mixed_repeating_and_regular() {
let tasks = vec![
create_test_task_with_repeater("2024-12-01 Sun", Some("10:00"), "+1d", TaskType::Todo),
create_test_task("2024-12-05 Wed", Some("14:00"), TaskType::Todo),
create_test_task_with_type("2024-12-06 Thu", None, TaskType::Todo, "DEADLINE"),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 2);
assert_eq!(agenda.upcoming.len(), 1); }
#[test]
fn test_build_day_agenda_repeating_with_time_sorting() {
let tasks = vec![
create_test_task_with_repeater("2024-12-01 Sun", Some("14:00"), "+1d", TaskType::Todo),
create_test_task_with_repeater("2024-12-01 Sun", Some("09:00"), "+1d", TaskType::Todo),
create_test_task("2024-12-05 Wed", Some("11:00"), TaskType::Todo),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 3);
assert_eq!(
agenda.scheduled_timed[0].task.timestamp_time,
Some("09:00".to_string())
);
assert_eq!(
agenda.scheduled_timed[1].task.timestamp_time,
Some("11:00".to_string())
);
assert_eq!(
agenda.scheduled_timed[2].task.timestamp_time,
Some("14:00".to_string())
);
}
#[test]
fn test_overdue_tasks_have_no_time() {
let tasks = vec![
create_test_task("2024-12-01 Mon", Some("10:00"), TaskType::Todo),
create_test_task("2024-12-02 Tue", Some("14:00"), TaskType::Todo),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.overdue.len(), 2);
assert_eq!(agenda.overdue[0].task.timestamp_time, None);
assert_eq!(agenda.overdue[1].task.timestamp_time, None);
}
#[test]
fn test_upcoming_deadline_tasks_have_no_time() {
let tasks = vec![
create_test_task_with_type("2024-12-06 Thu", Some("10:00"), TaskType::Todo, "DEADLINE"),
create_test_task_with_type("2024-12-07 Fri", Some("14:00"), TaskType::Todo, "DEADLINE"),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.upcoming.len(), 2);
assert_eq!(agenda.upcoming[0].task.timestamp_time, None);
assert_eq!(agenda.upcoming[1].task.timestamp_time, None);
}
#[test]
fn test_repeating_task_on_occurrence_day_not_in_overdue() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
Some("10:00"),
"+1d",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 1);
assert_eq!(
agenda.scheduled_timed[0].task.timestamp_time,
Some("10:00".to_string())
);
assert_eq!(agenda.scheduled_timed[0].days_offset, None);
assert_eq!(agenda.overdue.len(), 0);
}
#[test]
fn test_repeating_task_no_overdue_if_not_missed() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-05 Wed",
Some("10:00"),
"+1d",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_timed.len(), 1);
assert_eq!(agenda.overdue.len(), 0);
}
#[test]
fn test_get_current_week() {
let tz: Tz = "UTC".parse().unwrap();
let (monday, sunday) = get_current_week(&tz);
assert_eq!(monday.weekday(), chrono::Weekday::Mon);
assert_eq!(sunday.weekday(), chrono::Weekday::Sun);
assert_eq!((sunday - monday).num_days(), 6);
}
#[test]
fn test_get_current_month() {
let tz: Tz = "UTC".parse().unwrap();
let (first, last) = get_current_month(&tz);
assert_eq!(first.day(), 1);
assert_eq!(first.month(), last.month());
assert_eq!(first.year(), last.year());
assert!(last.day() >= 28 && last.day() <= 31);
}
#[test]
fn test_get_current_month_december() {
let today = NaiveDate::from_ymd_opt(2024, 12, 15).unwrap();
let first_day = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap();
let last_day = NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap();
assert_eq!(first_day, NaiveDate::from_ymd_opt(2024, 12, 1).unwrap());
assert_eq!(last_day, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
}
#[test]
fn test_get_current_month_february_leap() {
let first_day = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
let last_day = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap() - chrono::Duration::days(1);
assert_eq!(first_day, NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
assert_eq!(last_day, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
}
#[test]
fn test_get_current_month_february_non_leap() {
let first_day = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let last_day = NaiveDate::from_ymd_opt(2025, 3, 1).unwrap() - chrono::Duration::days(1);
assert_eq!(first_day, NaiveDate::from_ymd_opt(2025, 2, 1).unwrap());
assert_eq!(last_day, NaiveDate::from_ymd_opt(2025, 2, 28).unwrap());
}
#[test]
fn test_month_agenda_length() {
let tasks = vec![create_test_task("2024-12-15 Sun", None, TaskType::Todo)];
let start_date = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
let end_date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let month = build_week_agenda(&tasks, start_date, end_date, current_date);
assert_eq!(month.len(), 31, "December should have 31 days");
assert_eq!(month[0].date, "2024-12-01");
assert_eq!(month[30].date, "2024-12-31");
}
#[test]
fn test_month_agenda_past_days_empty() {
let tasks = vec![
create_test_task("2024-12-02 Mon", Some("10:00"), TaskType::Todo),
create_test_task("2024-12-03 Tue", None, TaskType::Todo),
create_test_task("2024-12-10 Tue", Some("14:00"), TaskType::Todo),
];
let start_date = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
let end_date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let month = build_week_agenda(&tasks, start_date, end_date, current_date);
assert_eq!(month[0].scheduled_timed.len(), 0);
assert_eq!(month[0].scheduled_no_time.len(), 0);
assert_eq!(month[1].scheduled_timed.len(), 1);
assert_eq!(month[2].scheduled_no_time.len(), 1);
assert_eq!(month[3].scheduled_timed.len(), 0);
assert_eq!(month[4].date, "2024-12-05");
assert!(
!month[4].overdue.is_empty(),
"Current day should have overdue tasks"
);
assert_eq!(
month[9].scheduled_timed.len(),
1,
"Day 10 should have scheduled task"
);
}
#[test]
fn test_month_agenda_february() {
let tasks = vec![create_test_task("2024-02-15 Thu", None, TaskType::Todo)];
let start_date = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
let end_date = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap(); let current_date = NaiveDate::from_ymd_opt(2024, 2, 10).unwrap();
let month = build_week_agenda(&tasks, start_date, end_date, current_date);
assert_eq!(
month.len(),
29,
"February 2024 (leap year) should have 29 days"
);
assert_eq!(month[0].date, "2024-02-01");
assert_eq!(month[28].date, "2024-02-29");
}
#[test]
fn test_month_agenda_custom_range() {
let tasks = vec![
create_test_task("2024-12-10 Tue", None, TaskType::Todo),
create_test_task("2024-12-15 Sun", None, TaskType::Todo),
];
let start_date = NaiveDate::from_ymd_opt(2024, 12, 10).unwrap();
let end_date = NaiveDate::from_ymd_opt(2024, 12, 20).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 12).unwrap();
let range = build_week_agenda(&tasks, start_date, end_date, current_date);
assert_eq!(
range.len(),
11,
"Range should have 11 days (10-20 inclusive)"
);
assert_eq!(range[0].date, "2024-12-10");
assert_eq!(range[10].date, "2024-12-20");
}
#[test]
fn test_done_tasks_not_in_overdue() {
let tasks = vec![
create_test_task("2024-12-01 Sun", None, TaskType::Done),
create_test_task("2024-12-02 Mon", Some("10:00"), TaskType::Done),
create_test_task("2024-12-03 Tue", None, TaskType::Todo),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.overdue.len(),
1,
"Only TODO tasks should appear in overdue"
);
assert_eq!(agenda.overdue[0].task.task_type, Some(TaskType::Todo));
}
#[test]
fn test_done_tasks_shown_on_their_date() {
let tasks = vec![
create_test_task("2024-12-05 Wed", None, TaskType::Done),
create_test_task("2024-12-05 Wed", Some("14:00"), TaskType::Done),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.scheduled_no_time.len(),
1,
"DONE task without time should appear on its date"
);
assert_eq!(
agenda.scheduled_timed.len(),
1,
"DONE task with time should appear on its date"
);
assert_eq!(
agenda.overdue.len(),
0,
"DONE tasks should not appear in overdue"
);
}
#[test]
fn test_done_deadline_not_in_overdue() {
let tasks = vec![
create_test_task_with_type("2024-12-01 Sun", None, TaskType::Done, "DEADLINE"),
create_test_task_with_type("2024-12-02 Mon", None, TaskType::Todo, "DEADLINE"),
];
let day_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.overdue.len(),
1,
"Only TODO deadline should appear in overdue"
);
assert_eq!(agenda.overdue[0].task.task_type, Some(TaskType::Todo));
}
#[test]
fn test_workday_repeater_not_overdue_on_weekend() {
let tasks = vec![create_test_task_with_repeater(
"2025-12-05 Fri",
None,
"+1wd",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 6).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 6).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.overdue.len(),
0,
"Task with +1wd should not be overdue on Saturday"
);
assert_eq!(agenda.scheduled_timed.len(), 0);
assert_eq!(agenda.scheduled_no_time.len(), 0);
}
#[test]
fn test_workday_repeater_not_overdue_on_sunday() {
let tasks = vec![create_test_task_with_repeater(
"2025-12-05 Fri",
None,
"+1wd",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.overdue.len(),
0,
"Task with +1wd should not be overdue on Sunday"
);
}
#[test]
fn test_year_repeater_shows_on_occurrence_day() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2025-12-11 Thu",
None,
"+1y",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 11).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 11).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_no_time.len(), 1);
assert_eq!(agenda.overdue.len(), 0);
}
#[test]
fn test_year_repeater_shows_in_upcoming() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2025-12-11 Thu",
None,
"+1y",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 6).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 6).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.upcoming.len(), 1);
assert_eq!(agenda.upcoming[0].days_offset, Some(5));
}
#[test]
fn test_year_repeater_not_in_upcoming_too_far() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2025-12-11 Thu",
None,
"+1y",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 11, 21).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 11, 21).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.upcoming.len(), 0);
}
#[test]
fn test_month_repeater_shows_on_occurrence_day() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-05 Thu",
None,
"+1m",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 1, 5).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.scheduled_no_time.len(), 1);
}
#[test]
fn test_workday_repeater_scheduled_on_monday() {
let tasks = vec![create_test_task_with_repeater(
"2025-12-05 Fri",
None,
"+1wd",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 8).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.scheduled_no_time.len(),
1,
"Task should be scheduled on Monday"
);
assert_eq!(
agenda.overdue.len(),
0,
"Task should not be overdue on its occurrence day"
);
}
#[test]
fn test_yearly_deadline_shows_on_occurrence_day() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2024-12-05 Thu",
None,
"+1y",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 5).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap(); let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(
agenda.scheduled_no_time.len(),
1,
"Task should be shown on deadline day (org-mode logic)"
);
assert_eq!(agenda.overdue.len(), 0);
let future_day = NaiveDate::from_ymd_opt(2026, 12, 5).unwrap();
let agenda_future = build_day_agenda(&tasks, future_day, current_date);
assert_eq!(
agenda_future.scheduled_no_time.len(),
1,
"Future occurrence day should show task"
);
assert_eq!(
agenda_future.scheduled_no_time[0].task.timestamp_date,
Some("2026-12-05".to_string())
);
assert!(agenda_future.scheduled_no_time[0]
.task
.timestamp
.as_ref()
.unwrap()
.contains("2026-12-05"));
}
#[test]
fn test_yearly_deadline_shows_as_overdue_after_occurrence() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2024-12-05 Thu",
None,
"+1y",
TaskType::Todo,
)];
let day_date = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap();
let current_date = NaiveDate::from_ymd_opt(2025, 12, 7).unwrap();
let agenda = build_day_agenda(&tasks, day_date, current_date);
assert_eq!(agenda.overdue.len(), 1, "Task should be overdue on Sunday");
assert_eq!(
agenda.overdue[0].days_offset,
Some(-2),
"Task should be 2 days overdue"
);
assert_eq!(
agenda.overdue[0].task.timestamp_date,
Some("2025-12-05".to_string())
);
assert!(agenda.overdue[0]
.task
.timestamp
.as_ref()
.unwrap()
.contains("2025-12-05"));
}
}