use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
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;
const NO_PRIORITY_ORDER: u32 = u32::MAX;
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))
.filter(|p| p.active),
})
.collect()
}
#[derive(Debug)]
pub enum AgendaOutput {
Days(Vec<DayAgenda>),
Tasks(Vec<Task>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgendaScope {
Day,
Week,
Month,
Tasks,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct AgendaDates<'a> {
pub date: Option<&'a str>,
pub from: Option<&'a str>,
pub to: Option<&'a str>,
pub current_date: Option<&'a str>,
}
fn parse_date_arg(label: &str, value: &str) -> Result<NaiveDate, AppError> {
NaiveDate::parse_from_str(value, "%Y-%m-%d")
.map_err(|e| AppError::InvalidDate(format!("{label} '{value}': {e}")))
}
fn compute_today_in_tz(now_utc: chrono::DateTime<chrono::Utc>, tz: Tz) -> NaiveDate {
now_utc.with_timezone(&tz).date_naive()
}
fn parse_range(
from: Option<&str>,
to: Option<&str>,
current_date: NaiveDate,
) -> Result<Option<(NaiveDate, NaiveDate)>, AppError> {
let from_date = from.map(|s| parse_date_arg("from", s)).transpose()?;
let to_date = to.map(|s| parse_date_arg("to", s)).transpose()?;
let (start, end) = match (from_date, to_date) {
(None, None) => return Ok(None),
(Some(f), Some(t)) => (f, t),
(Some(f), None) => (f, current_date),
(None, Some(t)) => (current_date, t),
};
if start > end {
return Err(AppError::DateRange(format!(
"Start date {start} is after end date {end}"
)));
}
Ok(Some((start, end)))
}
pub fn filter_agenda(
tasks: Vec<Task>,
scope: AgendaScope,
dates: AgendaDates<'_>,
tz: &str,
include_done: bool,
include_cancelled: bool,
annotate_next: bool,
) -> Result<AgendaOutput, AppError> {
let AgendaDates {
date,
from,
to,
current_date: current_date_override,
} = dates;
let tz: Tz = tz
.parse()
.map_err(|_| AppError::InvalidTimezone(tz.to_string()))?;
let now_utc = chrono::Utc::now();
let today = match current_date_override {
Some(date_str) => parse_date_arg("current-date", date_str)?,
None => compute_today_in_tz(now_utc, tz),
};
tracing::debug!(
scope = ?scope,
date,
from,
to,
tz = %tz,
today = %today,
input_tasks = tasks.len(),
"filter_agenda input"
);
if scope == AgendaScope::Tasks
&& (date.is_some() || from.is_some() || to.is_some() || current_date_override.is_some())
{
return Err(AppError::DateRange(
"tasks mode does not accept date arguments (--date, --from, --to, --current-date)"
.to_string(),
));
}
let now_dt: NaiveDateTime = match current_date_override {
Some(_) => today.and_time(NaiveTime::MIN),
None => now_utc.with_timezone(&tz).naive_local(),
};
let mut tasks = tasks;
if annotate_next && scope != AgendaScope::Tasks {
annotate_next_occurrences(&mut tasks, now_dt);
}
match scope {
AgendaScope::Day => {
if let Some((start_date, end_date)) = parse_range(from, to, today)? {
Ok(AgendaOutput::Days(build_week_agenda(
&tasks, start_date, end_date, today,
)))
} else {
let target_date = match date {
Some(date_str) => parse_date_arg("date", date_str)?,
None => today,
};
Ok(AgendaOutput::Days(vec![build_day_agenda(
&tasks,
target_date,
today,
)]))
}
}
AgendaScope::Week => {
let (start_date, end_date) = if let Some(range) = parse_range(from, to, today)? {
range
} else if let Some(date_str) = date {
get_week_for_date(parse_date_arg("date", date_str)?)
} else {
get_week_for_date(today)
};
Ok(AgendaOutput::Days(build_week_agenda(
&tasks, start_date, end_date, today,
)))
}
AgendaScope::Month => {
let (start_date, end_date) = if let Some(range) = parse_range(from, to, today)? {
range
} else if let Some(date_str) = date {
get_month_for_date(parse_date_arg("date", date_str)?)
} else {
get_month_for_date(today)
};
Ok(AgendaOutput::Days(build_week_agenda(
&tasks, start_date, end_date, today,
)))
}
AgendaScope::Tasks => {
let mut filtered: Vec<Task> = tasks
.into_iter()
.filter(|t| {
matches!(t.task_type, Some(TaskType::Todo))
|| (include_done && matches!(t.task_type, Some(TaskType::Done)))
|| (include_cancelled
&& matches!(t.task_type, Some(TaskType::Cancelled(_))))
})
.collect();
filtered.sort_by(|a, b| {
let pa = a
.priority
.as_ref()
.map(|p| p.order())
.unwrap_or(NO_PRIORITY_ORDER);
let pb = b
.priority
.as_ref()
.map(|p| p.order())
.unwrap_or(NO_PRIORITY_ORDER);
pa.cmp(&pb)
.then_with(|| a.timestamp_date.is_none().cmp(&b.timestamp_date.is_none()))
.then_with(|| a.timestamp_date.cmp(&b.timestamp_date))
.then_with(|| a.timestamp_time.is_none().cmp(&b.timestamp_time.is_none()))
.then_with(|| a.timestamp_time.cmp(&b.timestamp_time))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
Ok(AgendaOutput::Tasks(filtered))
}
}
}
fn next_occurrence(
base: NaiveDate,
repeater: &crate::timestamp::Repeater,
time: Option<NaiveTime>,
now: NaiveDateTime,
) -> Option<NaiveDate> {
use crate::timestamp::{closest_date, DatePreference};
let today = now.date();
let next = closest_date(base, today, DatePreference::Future, repeater);
let slot_passed_today = next == Some(today) && time.is_some_and(|t| t < now.time());
if slot_passed_today {
if let Some(tomorrow) = today.succ_opt() {
return closest_date(base, tomorrow, DatePreference::Future, repeater);
}
}
next
}
fn annotate_next_occurrences(tasks: &mut [Task], now: NaiveDateTime) {
for task in tasks {
set_next_occurrence(task, now);
}
}
fn set_next_occurrence(task: &mut Task, now: NaiveDateTime) {
use crate::timestamp::parse_repeater;
let (Some(date_str), Some(rep_str)) = (
task.timestamp_date.as_deref(),
task.timestamp_repeater.as_deref(),
) else {
return;
};
let (Some(base), Some(rep)) = (
NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok(),
parse_repeater(rep_str),
) else {
tracing::debug!(
file = %task.file,
line = task.line,
date = date_str,
repeater = rep_str,
"timestamp_next skipped: date or repeater did not parse"
);
return;
};
let time = task
.timestamp_time
.as_deref()
.and_then(|t| NaiveTime::parse_from_str(t, "%H:%M").ok());
if time.is_none() && task.timestamp_time.is_some() {
tracing::debug!(
file = %task.file,
line = task.line,
time = task.timestamp_time.as_deref().unwrap_or_default(),
"timestamp_next: clock time did not parse, treating the occurrence as all-day"
);
}
task.timestamp_next =
next_occurrence(base, &rep, time, now).map(|d| d.format("%Y-%m-%d").to_string());
if task.timestamp_next.is_none() {
tracing::debug!(
file = %task.file,
line = task.line,
date = date_str,
repeater = rep_str,
"timestamp_next: repeater could not bracket an upcoming date"
);
}
}
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.scheduled_no_time.sort_by(|a, b| {
let pa = a
.task
.priority
.as_ref()
.map(|p| p.order())
.unwrap_or(NO_PRIORITY_ORDER);
let pb = b
.task
.priority
.as_ref()
.map(|p| p.order())
.unwrap_or(NO_PRIORITY_ORDER);
pa.cmp(&pb)
.then_with(|| a.task.file.cmp(&b.task.file))
.then_with(|| a.task.line.cmp(&b.task.line))
});
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 {
let window = parsed.warning_days.unwrap_or(DEADLINE_WARNING_DAYS);
if ts_type == "DEADLINE" && days_diff <= window {
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 {
closest_date(base_date, day_date, DatePreference::Past, repeater)
} 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);
}
let is_done = matches!(task.task_type, Some(TaskType::Done));
let is_closed_ts = matches!(task.timestamp_type.as_deref(), Some("CLOSED"));
if is_today && !is_done && !is_closed_ts {
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 repeat.is_none() && 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();
let window = parsed.warning_days.unwrap_or(DEADLINE_WARNING_DAYS);
if days_diff > 0 && days_diff <= window {
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_month_for_date(date: NaiveDate) -> (NaiveDate, NaiveDate) {
let first_day = NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("y/m valid");
let last_day = if date.month() == 12 {
NaiveDate::from_ymd_opt(date.year(), 12, 31).expect("Dec 31 always valid")
} else {
NaiveDate::from_ymd_opt(date.year(), date.month() + 1, 1)
.expect("next month 1st always valid")
- chrono::Duration::days(1)
};
(first_day, last_day)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timestamp::parse_repeater;
use crate::types::CancelledSpelling;
use chrono::TimeZone;
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn dt(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> NaiveDateTime {
ymd(y, m, d).and_hms_opt(hh, mm, 0).unwrap()
}
fn hm(hh: u32, mm: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hh, mm, 0).unwrap()
}
#[test]
fn next_occurrence_rolls_a_past_date_forward() {
let rep = parse_repeater("++7d").unwrap();
let next = next_occurrence(ymd(2026, 7, 21), &rep, None, dt(2026, 7, 24, 10, 0));
assert_eq!(next, Some(ymd(2026, 7, 28)));
}
#[test]
fn next_occurrence_monthly_rolls_month_by_month() {
let rep = parse_repeater("++1m").unwrap();
let next = next_occurrence(ymd(2026, 1, 15), &rep, None, dt(2026, 7, 24, 10, 0));
assert_eq!(next, Some(ymd(2026, 8, 15)));
}
#[test]
fn next_occurrence_all_day_today_stays_today_even_late() {
let rep = parse_repeater("++1w").unwrap();
let next = next_occurrence(ymd(2026, 7, 24), &rep, None, dt(2026, 7, 24, 22, 3));
assert_eq!(next, Some(ymd(2026, 7, 24)));
}
#[test]
fn next_occurrence_timed_today_before_its_time_stays_today() {
let rep = parse_repeater("++7d").unwrap();
let next = next_occurrence(
ymd(2026, 7, 24),
&rep,
Some(hm(14, 0)),
dt(2026, 7, 24, 9, 0),
);
assert_eq!(next, Some(ymd(2026, 7, 24)));
}
#[test]
fn next_occurrence_timed_today_after_its_time_rolls_forward() {
let rep = parse_repeater("++7d").unwrap();
let next = next_occurrence(
ymd(2026, 7, 24),
&rep,
Some(hm(14, 0)),
dt(2026, 7, 24, 22, 3),
);
assert_eq!(next, Some(ymd(2026, 7, 31)));
}
#[test]
fn next_occurrence_future_anchor_is_returned_as_is() {
let rep = parse_repeater("++7d").unwrap();
let next = next_occurrence(ymd(2026, 8, 12), &rep, None, dt(2026, 7, 24, 22, 3));
assert_eq!(next, Some(ymd(2026, 8, 12)));
}
#[test]
fn next_occurrence_treats_all_three_repeater_kinds_alike() {
for kind in ["+7d", "++7d", ".+7d"] {
let rep = parse_repeater(kind).expect(kind);
let next = next_occurrence(ymd(2026, 7, 21), &rep, None, dt(2026, 7, 24, 10, 0));
assert_eq!(next, Some(ymd(2026, 7, 28)), "repeater kind {kind}");
}
}
#[test]
fn next_occurrence_workday_unit_skips_the_weekend() {
let rep = parse_repeater("++1wd").unwrap();
let next = next_occurrence(ymd(2026, 7, 24), &rep, None, dt(2026, 7, 25, 10, 0));
assert_eq!(next, Some(ymd(2026, 7, 27)));
}
#[test]
fn next_occurrence_yearly_unit_rolls_to_the_next_anniversary() {
let rep = parse_repeater("++1y").unwrap();
let next = next_occurrence(ymd(2020, 3, 9), &rep, None, dt(2026, 7, 24, 10, 0));
assert_eq!(next, Some(ymd(2027, 3, 9)));
}
#[test]
fn next_occurrence_hourly_unit_projects_onto_the_day_grid() {
let rep = parse_repeater("+2h").unwrap();
let next = next_occurrence(
ymd(2026, 7, 24),
&rep,
Some(hm(9, 0)),
dt(2026, 7, 24, 10, 0),
);
assert_eq!(next, Some(ymd(2026, 7, 25)));
}
fn repeating_task(date_str: &str, repeater: &str, time: Option<&str>) -> Task {
let mut task = create_test_task(date_str, time, TaskType::Todo);
task.timestamp_repeater = Some(repeater.to_string());
task.timestamp = Some(match time {
Some(t) => format!("SCHEDULED: <{date_str} {t} {repeater}>"),
None => format!("SCHEDULED: <{date_str} {repeater}>"),
});
task
}
fn collect_next(output: &AgendaOutput) -> Vec<Option<String>> {
match output {
AgendaOutput::Days(days) => days
.iter()
.flat_map(|day| {
day.overdue
.iter()
.chain(&day.scheduled_timed)
.chain(&day.scheduled_no_time)
.chain(&day.upcoming)
})
.map(|item| item.task.timestamp_next.clone())
.collect(),
AgendaOutput::Tasks(tasks) => tasks.iter().map(|t| t.timestamp_next.clone()).collect(),
}
}
#[test]
fn timestamp_next_anchors_on_the_task_date_not_the_rendered_occurrence() {
let task = repeating_task("2026-01-31 Sat", "++1m", None);
let output = filter_agenda(
vec![task],
AgendaScope::Day,
AgendaDates {
current_date: Some("2026-07-25"),
..AgendaDates::default()
},
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
assert_eq!(
collect_next(&output),
vec![Some("2026-07-31".to_string())],
"monthly repeater anchored on the 31st must keep naming month-end"
);
}
#[test]
fn timestamp_next_is_identical_across_every_day_of_a_week_payload() {
let task = repeating_task("2026-07-21 Tue", "++7d", None);
let output = filter_agenda(
vec![task],
AgendaScope::Week,
AgendaDates {
current_date: Some("2026-07-22"),
..AgendaDates::default()
},
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
let values = collect_next(&output);
assert!(!values.is_empty(), "week payload must contain the task");
assert!(
values.iter().all(|v| v.as_deref() == Some("2026-07-28")),
"expected every cell to carry 2026-07-28, got {values:?}"
);
}
#[test]
fn timestamp_next_is_absent_without_a_repeater() {
let output = filter_agenda(
vec![create_test_task("2026-07-25 Sat", None, TaskType::Todo)],
AgendaScope::Day,
AgendaDates {
current_date: Some("2026-07-25"),
..AgendaDates::default()
},
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
assert_eq!(collect_next(&output), vec![None]);
}
#[test]
fn timestamp_next_is_absent_when_the_repeater_cannot_be_parsed() {
let output = filter_agenda(
vec![repeating_task("2026-07-25 Sat", "++0d", None)],
AgendaScope::Day,
AgendaDates {
current_date: Some("2026-07-25"),
..AgendaDates::default()
},
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
assert!(
collect_next(&output).iter().all(Option::is_none),
"a rejected repeater must leave the field absent, not guess a date"
);
}
#[test]
fn tasks_scope_is_never_annotated() {
let output = filter_agenda(
vec![repeating_task("2026-07-21 Tue", "++7d", None)],
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
assert_eq!(collect_next(&output), vec![None]);
}
#[test]
fn annotate_next_false_skips_the_field_entirely() {
let output = filter_agenda(
vec![repeating_task("2026-07-21 Tue", "++7d", None)],
AgendaScope::Day,
AgendaDates {
current_date: Some("2026-07-22"),
..AgendaDates::default()
},
"UTC",
false,
false,
false,
)
.expect("filter_agenda");
let values = collect_next(&output);
assert!(!values.is_empty(), "the task must still be rendered");
assert!(
values.iter().all(Option::is_none),
"no cell may carry the field when annotation is off; got {values:?}"
);
}
#[test]
fn compute_today_in_tz_crosses_midnight_eastward() {
let now_utc = chrono::Utc
.with_ymd_and_hms(2024, 12, 5, 22, 30, 0)
.unwrap();
let moscow: Tz = "Europe/Moscow".parse().unwrap();
let today = compute_today_in_tz(now_utc, moscow);
assert_eq!(
today,
NaiveDate::from_ymd_opt(2024, 12, 6).unwrap(),
"Europe/Moscow at 2024-12-05 22:30 UTC must read as 2024-12-06 local"
);
}
#[test]
fn compute_today_in_tz_crosses_midnight_westward() {
let now_utc = chrono::Utc.with_ymd_and_hms(2024, 12, 6, 2, 0, 0).unwrap();
let la: Tz = "America/Los_Angeles".parse().unwrap();
let today = compute_today_in_tz(now_utc, la);
assert_eq!(
today,
NaiveDate::from_ymd_opt(2024, 12, 5).unwrap(),
"America/Los_Angeles at 2024-12-06 02:00 UTC must read as 2024-12-05 local"
);
}
#[test]
fn compute_today_in_tz_same_day_midday() {
let now_utc = chrono::Utc.with_ymd_and_hms(2024, 12, 5, 12, 0, 0).unwrap();
let moscow: Tz = "Europe/Moscow".parse().unwrap();
assert_eq!(
compute_today_in_tz(now_utc, moscow),
NaiveDate::from_ymd_opt(2024, 12, 5).unwrap(),
);
}
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(),
root: None,
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_active: Some(true),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: time.map(|t| t.to_string()),
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: 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")
}
fn create_test_plain_task(timestamp: &str, date_str: &str) -> Task {
let active = timestamp.starts_with('<');
Task {
file: "test.md".to_string(),
root: None,
line: 1,
heading: "Plain timestamp task".to_string(),
content: String::new(),
task_type: Some(TaskType::Todo),
priority: None,
created: None,
timestamp: Some(timestamp.to_string()),
timestamp_type: Some("PLAIN".to_string()),
timestamp_active: Some(active),
timestamp_date: Some(date_str.to_string()),
timestamp_time: None,
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: None,
}
}
#[test]
fn agenda_excludes_plain_inactive_timestamp() {
let tasks = vec![create_test_plain_task("[2024-12-05 Thu]", "2024-12-05")];
let day = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day, day);
assert!(
agenda.scheduled_no_time.is_empty(),
"inactive plain timestamp must not appear in scheduled bucket"
);
assert!(agenda.scheduled_timed.is_empty());
assert!(agenda.overdue.is_empty());
assert!(agenda.upcoming.is_empty());
}
#[test]
fn agenda_includes_plain_active_timestamp() {
let tasks = vec![create_test_plain_task("<2024-12-05 Thu>", "2024-12-05")];
let day = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, day, day);
assert_eq!(agenda.scheduled_no_time.len(), 1);
}
#[test]
fn scheduled_no_time_sorts_by_priority_then_file_line() {
use crate::types::Priority;
let day = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let make = |heading: &str, prio: Option<Priority>, file: &str, line: u32| Task {
file: file.to_string(),
root: None,
line,
heading: heading.to_string(),
content: String::new(),
task_type: Some(TaskType::Todo),
priority: prio,
created: None,
timestamp: Some("SCHEDULED: <2024-12-05 Thu>".to_string()),
timestamp_type: Some("SCHEDULED".to_string()),
timestamp_active: Some(true),
timestamp_date: Some("2024-12-05".to_string()),
timestamp_time: None,
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: None,
};
let tasks = vec![
make("none-a1", None, "a.md", 1),
make("A-b5", Some(Priority::A), "b.md", 5),
make("B-a1", Some(Priority::B), "a.md", 1),
make("A-a9", Some(Priority::A), "a.md", 9),
];
let agenda = build_day_agenda(&tasks, day, day);
let order: Vec<&str> = agenda
.scheduled_no_time
.iter()
.map(|t| t.task.heading.as_str())
.collect();
assert_eq!(
order,
vec!["A-a9", "A-b5", "B-a1", "none-a1"],
"scheduled_no_time must sort by priority (high first), then file path, then line"
);
}
#[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(),
root: None,
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_active: Some(true),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: time.map(|t| t.to_string()),
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: 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(),
root: None,
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_active: Some(true),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: time.map(|t| t.to_string()),
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: 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(), true), (NaiveDate::from_ymd_opt(2024, 12, 2).unwrap(), false),
(NaiveDate::from_ymd_opt(2024, 12, 3).unwrap(), true), (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_week_agenda_daily_repeater_shows_each_past_occurrence() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-02 Mon",
None,
"+1d",
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);
for day in &week {
assert_eq!(
day.scheduled_no_time.len(),
1,
"+1d task must appear on {}",
day.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 repeating_deadline_past_occurrence_does_not_become_upcoming() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2024-12-01 Sun",
None,
"+1d",
TaskType::Todo,
)];
let current_date = NaiveDate::from_ymd_opt(2024, 12, 5).unwrap();
let agenda = build_day_agenda(&tasks, current_date, current_date);
assert!(
agenda.upcoming.is_empty(),
"repeating DEADLINE whose past occurrence is recorded must not surface in upcoming; got {:?}",
agenda.upcoming
);
}
#[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_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 tasks_scope_sorts_by_priority_with_no_priority_last() {
use crate::types::Priority;
let mut t_z = create_test_task("2024-12-05 Wed", None, TaskType::Todo);
t_z.priority = Some(Priority::Other('Z'));
t_z.heading = "Z-priority".to_string();
let mut t_a = create_test_task("2024-12-05 Wed", None, TaskType::Todo);
t_a.priority = Some(Priority::A);
t_a.heading = "A-priority".to_string();
let mut t_none = create_test_task("2024-12-05 Wed", None, TaskType::Todo);
t_none.priority = None;
t_none.heading = "no-priority".to_string();
let mut t_num0 = create_test_task("2024-12-05 Wed", None, TaskType::Todo);
t_num0.priority = Some(Priority::Numeric(0));
t_num0.heading = "numeric-0".to_string();
let input = vec![t_none.clone(), t_z.clone(), t_a.clone(), t_num0.clone()];
let result = filter_agenda(
input,
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
let headings: Vec<&str> = tasks.iter().map(|t| t.heading.as_str()).collect();
assert_eq!(
headings,
vec!["numeric-0", "A-priority", "Z-priority", "no-priority"],
"no-priority must sort strictly after every defined priority"
);
}
#[test]
fn tasks_scope_orders_one_priority_by_date_then_time() {
let mut later_day = create_test_task("2024-12-06 Fri", Some("08:00"), TaskType::Todo);
later_day.heading = "second day, early".to_string();
let mut same_day_late = create_test_task("2024-12-05 Thu", Some("14:00"), TaskType::Todo);
same_day_late.heading = "first day, afternoon".to_string();
let mut same_day_early = create_test_task("2024-12-05 Thu", Some("09:30"), TaskType::Todo);
same_day_early.heading = "first day, morning".to_string();
let mut same_day_no_time = create_test_task("2024-12-05 Thu", None, TaskType::Todo);
same_day_no_time.heading = "first day, all day".to_string();
let input = vec![
same_day_late.clone(),
later_day.clone(),
same_day_no_time.clone(),
same_day_early.clone(),
];
let result = filter_agenda(
input,
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
let headings: Vec<&str> = tasks.iter().map(|t| t.heading.as_str()).collect();
assert_eq!(
headings,
vec![
"first day, morning",
"first day, afternoon",
"first day, all day",
"second day, early",
],
"one day comes before the next, and inside a day the hours come before the whole day"
);
}
#[test]
fn tasks_scope_puts_a_dateless_task_after_every_dated_one() {
let mut dated = create_test_task("2024-12-05 Thu", None, TaskType::Todo);
dated.heading = "dated".to_string();
let mut dateless = create_test_task("2024-12-05 Thu", None, TaskType::Todo);
dateless.heading = "dateless".to_string();
dateless.timestamp = None;
dateless.timestamp_type = None;
dateless.timestamp_active = None;
dateless.timestamp_date = None;
let result = filter_agenda(
vec![dateless.clone(), dated.clone()],
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
let headings: Vec<&str> = tasks.iter().map(|t| t.heading.as_str()).collect();
assert_eq!(headings, vec!["dated", "dateless"]);
}
#[test]
fn tasks_scope_excludes_done_by_default() {
let input = vec![
create_test_task("2024-12-05 Wed", None, TaskType::Todo),
create_test_task("2024-12-06 Thu", None, TaskType::Done),
];
let result = filter_agenda(
input,
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
assert_eq!(tasks.len(), 1, "only the TODO task must remain");
assert_eq!(tasks[0].task_type, Some(TaskType::Todo));
}
#[test]
fn tasks_scope_includes_done_when_requested() {
let input = vec![
create_test_task("2024-12-05 Wed", None, TaskType::Todo),
create_test_task("2024-12-06 Thu", None, TaskType::Done),
];
let result = filter_agenda(
input,
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
true,
false,
true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
assert_eq!(tasks.len(), 2, "both TODO and DONE must be present");
assert!(
tasks
.iter()
.any(|t| matches!(t.task_type, Some(TaskType::Todo))),
"TODO task must be present"
);
assert!(
tasks
.iter()
.any(|t| matches!(t.task_type, Some(TaskType::Done))),
"DONE task must be present when include_done is set"
);
}
#[test]
fn tasks_scope_excludes_cancelled_by_default() {
let input = vec![
create_test_task("2024-12-05 Wed", None, TaskType::Todo),
create_test_task(
"2024-12-06 Thu",
None,
TaskType::Cancelled(CancelledSpelling::DoubleL),
),
];
let result = filter_agenda(
input,
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false,
false,
true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
assert_eq!(tasks.len(), 1, "only the TODO task must remain");
assert_eq!(tasks[0].task_type, Some(TaskType::Todo));
}
#[test]
fn tasks_scope_includes_cancelled_when_requested() {
let input = vec![
create_test_task("2024-12-05 Wed", None, TaskType::Todo),
create_test_task("2024-12-06 Thu", None, TaskType::Done),
create_test_task(
"2024-12-07 Fri",
None,
TaskType::Cancelled(CancelledSpelling::DoubleL),
),
];
let result = filter_agenda(
input,
AgendaScope::Tasks,
AgendaDates::default(),
"UTC",
false, true, true,
)
.expect("filter_agenda");
let tasks = match result {
AgendaOutput::Tasks(tasks) => tasks,
other => panic!("expected AgendaOutput::Tasks, got {other:?}"),
};
assert_eq!(tasks.len(), 2, "TODO and CANCELLED present, DONE excluded");
assert!(
tasks
.iter()
.any(|t| matches!(t.task_type, Some(TaskType::Todo))),
"TODO must be present"
);
assert!(
tasks
.iter()
.any(|t| matches!(t.task_type, Some(TaskType::Cancelled(_)))),
"CANCELLED must be present when include_cancelled is set"
);
assert!(
!tasks
.iter()
.any(|t| matches!(t.task_type, Some(TaskType::Done))),
"DONE must stay excluded: include_cancelled is independent of include_done"
);
}
#[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"));
}
fn create_test_task_with_repeater_and_ts_type(
date_str: &str,
repeater: &str,
task_type: TaskType,
ts_type: &str,
) -> Task {
let timestamp = format!("{ts_type}: <{date_str} {repeater}>");
Task {
file: "test.md".to_string(),
root: None,
line: 1,
heading: "Test task".to_string(),
content: String::new(),
task_type: Some(task_type),
priority: None,
created: None,
timestamp: Some(timestamp),
timestamp_type: Some(ts_type.to_string()),
timestamp_active: Some(true),
timestamp_date: Some(date_str.split_whitespace().next().unwrap().to_string()),
timestamp_time: None,
timestamp_end_time: None,
timestamp_repeater: None,
timestamp_next: None,
clocks: None,
total_clock_time: None,
properties: None,
}
}
#[test]
fn test_done_repeating_deadline_not_in_overdue() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2024-12-01 Sun",
None,
"+1w",
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.overdue.len(),
0,
"DONE repeating DEADLINE must not surface as overdue (matches upstream org-agenda.el L6424-6428)"
);
}
#[test]
fn test_done_repeating_deadline_not_in_upcoming() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2025-12-11 Thu",
None,
"+1y",
TaskType::Done,
)];
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(),
0,
"DONE repeating DEADLINE must not surface as prewarning (matches upstream org-agenda.el L6424-6428)"
);
}
#[test]
fn test_done_repeating_still_shows_on_occurrence_day() {
let tasks = vec![create_test_task_with_repeater(
"2024-12-01 Sun",
None,
"+1w",
TaskType::Done,
)];
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,
"DONE repeating task must still appear on its occurrence day"
);
assert_eq!(agenda.overdue.len(), 0);
assert_eq!(agenda.upcoming.len(), 0);
}
#[test]
fn test_closed_repeating_not_in_overdue() {
let tasks = vec![create_test_task_with_repeater_and_ts_type(
"2024-12-01 Sun",
"+1w",
TaskType::Todo,
"CLOSED",
)];
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(),
0,
"CLOSED-typed timestamps must not surface as overdue"
);
}
#[test]
fn test_deadline_with_minus_3d_not_in_upcoming_at_day_5() {
let tasks = vec![create_test_task_with_type(
"2025-12-10 Wed -3d",
None,
TaskType::Todo,
"DEADLINE",
)];
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 with -3d cookie must not appear in upcoming at day 5"
);
}
#[test]
fn test_deadline_with_minus_3d_in_upcoming_at_day_2() {
let tasks = vec![create_test_task_with_type(
"2025-12-10 Wed -3d",
None,
TaskType::Todo,
"DEADLINE",
)];
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.upcoming.len(), 1);
assert_eq!(agenda.upcoming[0].days_offset, Some(2));
}
#[test]
fn test_deadline_with_minus_30d_in_upcoming_beyond_default_14() {
let tasks = vec![create_test_task_with_type(
"2025-12-25 Thu -30d",
None,
TaskType::Todo,
"DEADLINE",
)];
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(),
1,
"DEADLINE with -30d must appear in upcoming at day 20 (default 14 would skip)"
);
assert_eq!(agenda.upcoming[0].days_offset, Some(20));
}
#[test]
fn test_repeating_deadline_with_minus_3d_not_in_upcoming_at_day_5() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2025-12-10 Wed -3d",
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,
"repeating DEADLINE with -3d cookie must not appear in upcoming at day 5"
);
}
#[test]
fn test_repeating_deadline_with_minus_3d_in_upcoming_at_day_2() {
let tasks = vec![create_test_task_with_repeater_deadline(
"2025-12-10 Wed -3d",
None,
"+1y",
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.upcoming.len(), 1);
assert_eq!(agenda.upcoming[0].days_offset, Some(2));
}
#[test]
fn test_closed_repeating_not_in_upcoming() {
let tasks = vec![create_test_task_with_repeater_and_ts_type(
"2025-12-11 Thu",
"+1y",
TaskType::Todo,
"CLOSED",
)];
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(),
0,
"CLOSED-typed timestamps must never enter the upcoming bucket"
);
}
}