use chrono::{Datelike, Local, NaiveDate, TimeZone};
use crate::queue::unix_seconds;
pub(crate) fn available_day_label(available_at: &str, now_seconds: i64) -> String {
let Some(available_seconds) = unix_seconds(available_at) else {
return "later".to_string();
};
let Some(available_date) = local_date(available_seconds) else {
return "later".to_string();
};
let Some(today) = local_date(now_seconds) else {
return "later".to_string();
};
let days = (available_date - today).num_days();
match days {
i64::MIN..=-1 => "ready".to_string(),
0 => "today".to_string(),
1 => "tomorrow".to_string(),
2..=6 => available_date.format("%A").to_string().to_lowercase(),
_ if available_date.year() == today.year() => available_date.format("%b %-d").to_string(),
_ => available_date.format("%b %-d, %Y").to_string(),
}
}
pub(crate) fn due_state_at(due_on: &str, now_seconds: i64) -> crate::due::DueState {
local_date(now_seconds)
.map(|today| crate::due::due_state(due_on, today))
.unwrap_or(crate::due::DueState::None)
}
pub(crate) fn due_label(due_on: &str, now_seconds: i64) -> Option<String> {
let due = NaiveDate::parse_from_str(due_on, "%Y-%m-%d").ok()?;
let today = local_date(now_seconds)?;
Some(match crate::due::due_state(due_on, today) {
crate::due::DueState::Overdue(days) => format!("{days}d late"),
crate::due::DueState::Today => "due today".to_string(),
crate::due::DueState::Future(1) => "due tomorrow".to_string(),
crate::due::DueState::Future(days @ 2..=7) => format!("due in {days}d"),
crate::due::DueState::Future(_) if due.year() == today.year() => {
due.format("%b %-d").to_string()
}
crate::due::DueState::Future(_) => due.format("%b %-d, %Y").to_string(),
crate::due::DueState::None => return None,
})
}
pub(crate) fn compact_due_label(due_on: &str, now_seconds: i64) -> Option<String> {
let due = NaiveDate::parse_from_str(due_on, "%Y-%m-%d").ok()?;
let today = local_date(now_seconds)?;
Some(match crate::due::due_state(due_on, today) {
crate::due::DueState::Overdue(days @ 1..=999) => format!("{days}d!"),
crate::due::DueState::Overdue(_) => "late!".to_string(),
crate::due::DueState::Today => "today".to_string(),
crate::due::DueState::Future(days @ 1..=7) => format!("+{days}d"),
crate::due::DueState::Future(_) => due.format("%b%-d").to_string(),
crate::due::DueState::None => return None,
})
}
pub(crate) fn due_summary_lines(due_on: &str, now_seconds: i64) -> Option<[String; 2]> {
let due = NaiveDate::parse_from_str(due_on, "%Y-%m-%d").ok()?;
Some([
due_label(due_on, now_seconds)?,
due.format("%A, %b %-d, %Y").to_string(),
])
}
pub(crate) fn availability_summary_lines(
available_at: &str,
ready: bool,
now_seconds: i64,
) -> Option<[String; 2]> {
let available_seconds = unix_seconds(available_at)?;
let local = local_datetime_label(available_seconds)?;
let relative = if ready || available_seconds <= now_seconds {
format!(
"ready since {}",
compact_duration(now_seconds.saturating_sub(available_seconds))
)
} else {
format!(
"available in {}",
compact_duration(available_seconds.saturating_sub(now_seconds))
)
};
Some([relative, local])
}
pub(crate) fn available_in_label(available_at: &str, now_seconds: i64) -> Option<String> {
let available_seconds = unix_seconds(available_at)?;
if available_seconds <= now_seconds {
return Some("now".to_string());
}
Some(format!(
"in{}",
compact_duration(available_seconds - now_seconds)
))
}
pub(crate) fn local_datetime_label(seconds: i64) -> Option<String> {
let local = Local.timestamp_opt(seconds, 0).single()?;
Some(local.format("%a %b %-d %-I:%M %p %Z").to_string())
}
pub(crate) fn compact_duration(seconds: i64) -> String {
let minutes = seconds.max(0) / 60;
if minutes < 60 {
return format!("{minutes}m");
}
let hours = minutes / 60;
if hours < 24 {
return format!("{hours}h");
}
let days = hours / 24;
if days < 14 {
return format!("{days}d");
}
let weeks = days / 7;
if weeks < 13 {
return format!("{weeks}w");
}
format!("{}mo", days / 30)
}
fn local_date(seconds: i64) -> Option<chrono::NaiveDate> {
Some(Local.timestamp_opt(seconds, 0).single()?.date_naive())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_duration_formats_minutes_hours_days_weeks_and_months() {
assert_eq!(compact_duration(-1), "0m");
assert_eq!(compact_duration(0), "0m");
assert_eq!(compact_duration(59), "0m");
assert_eq!(compact_duration(60), "1m");
assert_eq!(compact_duration(3_599), "59m");
assert_eq!(compact_duration(3_600), "1h");
assert_eq!(compact_duration(86_399), "23h");
assert_eq!(compact_duration(13 * 86_400), "13d");
assert_eq!(compact_duration(9 * 7 * 86_400), "9w");
assert_eq!(compact_duration(122 * 86_400), "4mo");
}
#[test]
fn compact_due_labels_fit_the_task_list_column() {
let now = Local
.with_ymd_and_hms(2026, 7, 16, 12, 0, 0)
.single()
.unwrap()
.timestamp();
assert_eq!(compact_due_label("2026-07-13", now).unwrap(), "3d!");
assert_eq!(compact_due_label("2026-07-16", now).unwrap(), "today");
assert_eq!(compact_due_label("2026-07-17", now).unwrap(), "+1d");
assert_eq!(compact_due_label("2026-07-19", now).unwrap(), "+3d");
assert_eq!(compact_due_label("2026-07-24", now).unwrap(), "Jul24");
assert_eq!(compact_due_label("2027-01-01", now).unwrap(), "Jan1");
}
#[test]
fn available_in_label_formats_future_values() {
assert_eq!(
available_in_label("1970-01-02T00:00:00Z", 0).unwrap(),
"in1d"
);
}
}