use anyhow::Result;
use jiff::civil::{Date, Weekday};
use jiff::tz::TimeZone;
use jiff::{Timestamp, ToSpan};
use rust_i18n::t;
use crate::i18n::{self, Lang};
const WEEKDAY_INPUT: [(&str, Weekday); 14] = [
("mo", Weekday::Monday),
("di", Weekday::Tuesday),
("mi", Weekday::Wednesday),
("do", Weekday::Thursday),
("fr", Weekday::Friday),
("sa", Weekday::Saturday),
("so", Weekday::Sunday),
("mon", Weekday::Monday),
("tue", Weekday::Tuesday),
("wed", Weekday::Wednesday),
("thu", Weekday::Thursday),
("fri", Weekday::Friday),
("sat", Weekday::Saturday),
("sun", Weekday::Sunday),
];
pub fn today() -> Date {
jiff::Zoned::now().date()
}
pub fn parse_date(input: &str, today: Date) -> Result<Date> {
let s = input.trim().to_lowercase();
if s.is_empty() {
anyhow::bail!(t!("date.empty"));
}
match s.as_str() {
"heute" | "today" => return Ok(today),
"morgen" | "tomorrow" => return Ok(today.tomorrow()?),
"übermorgen" | "uebermorgen" => return Ok(today.checked_add(2.days())?),
_ => {}
}
if let Some((_, wd)) = WEEKDAY_INPUT.iter().find(|(name, _)| *name == s) {
let mut d = today.tomorrow()?;
while d.weekday() != *wd {
d = d.tomorrow()?;
}
return Ok(d);
}
if let Some(rel) = s.strip_prefix('+') {
return parse_relative(rel, today).ok_or_else(|| invalid(input));
}
if let Ok(d) = s.parse::<Date>() {
return Ok(d);
}
parse_german(&s, today)
.or_else(|| parse_us(&s, today))
.ok_or_else(|| invalid(input))
}
fn invalid(input: &str) -> anyhow::Error {
anyhow::anyhow!(t!("date.invalid", input = input.trim()))
}
fn parse_relative(rel: &str, today: Date) -> Option<Date> {
let unit = rel.chars().last()?;
let n: i64 = rel[..rel.len() - unit.len_utf8()].parse().ok()?;
let span = match unit {
'd' | 't' => n.days(),
'w' => n.weeks(),
'm' => n.months(),
_ => return None,
};
today.checked_add(span).ok()
}
fn day_and_month(day: i8, month: i8, year: Option<i16>, today: Date) -> Option<Date> {
match year {
Some(y) => {
let y = if y < 100 { 2000 + y } else { y };
Date::new(y, month, day).ok()
}
None => {
let d = Date::new(today.year(), month, day).ok()?;
if d < today {
Date::new(today.year() + 1, month, day).ok()
} else {
Some(d)
}
}
}
}
fn parse_german(s: &str, today: Date) -> Option<Date> {
let parts: Vec<&str> = s.split('.').collect();
let (day, month, year) = match parts.as_slice() {
[d, m] | [d, m, ""] => (d.parse().ok()?, m.parse().ok()?, None),
[d, m, y] => (d.parse().ok()?, m.parse().ok()?, Some(y.parse().ok()?)),
_ => return None,
};
day_and_month(day, month, year, today)
}
fn parse_us(s: &str, today: Date) -> Option<Date> {
let parts: Vec<&str> = s.split('/').collect();
let (month, day, year) = match parts.as_slice() {
[m, d] => (m.parse().ok()?, d.parse().ok()?, None),
[m, d, y] => (m.parse().ok()?, d.parse().ok()?, Some(y.parse().ok()?)),
_ => return None,
};
day_and_month(day, month, year, today)
}
pub fn weekday_abbr(wd: Weekday, lang: Lang) -> String {
let key = match wd {
Weekday::Monday => "date.wd.mon",
Weekday::Tuesday => "date.wd.tue",
Weekday::Wednesday => "date.wd.wed",
Weekday::Thursday => "date.wd.thu",
Weekday::Friday => "date.wd.fri",
Weekday::Saturday => "date.wd.sat",
Weekday::Sunday => "date.wd.sun",
};
t!(key, locale = lang.code()).into_owned()
}
fn days_between(from: Date, to: Date) -> i64 {
from.until(to).map(|s| s.get_days() as i64).unwrap_or(0)
}
fn short(d: Date, today: Date, lang: Lang) -> String {
let same_year = d.year() == today.year();
let yy = d.year() % 100;
match (lang, same_year) {
(Lang::De, true) => format!("{:02}.{:02}.", d.day(), d.month()),
(Lang::De, false) => format!("{:02}.{:02}.{yy:02}", d.day(), d.month()),
(Lang::En, true) => format!("{:02}/{:02}", d.month(), d.day()),
(Lang::En, false) => format!("{:02}/{:02}/{yy:02}", d.month(), d.day()),
}
}
pub fn fmt_day_month(d: Date) -> String {
match i18n::current() {
Lang::De => format!("{:02}.{:02}.", d.day(), d.month()),
Lang::En => format!("{:02}/{:02}", d.month(), d.day()),
}
}
pub fn fmt_date(d: Date) -> String {
fmt_date_in(d, i18n::current())
}
fn fmt_date_in(d: Date, lang: Lang) -> String {
match lang {
Lang::De => format!("{:02}.{:02}.{}", d.day(), d.month(), d.year()),
Lang::En => format!("{:02}/{:02}/{}", d.month(), d.day(), d.year()),
}
}
pub fn fmt_long(d: Date) -> String {
let lang = i18n::current();
format!(
"{} {}",
weekday_abbr(d.weekday(), lang),
fmt_date_in(d, lang)
)
}
pub fn fmt_due(due: Date, today: Date) -> String {
fmt_due_in(due, today, i18n::current())
}
fn fmt_due_in(due: Date, today: Date, lang: Lang) -> String {
let l = lang.code();
match days_between(today, due) {
0 => t!("date.today", locale = l).into_owned(),
1 => t!("date.tomorrow", locale = l).into_owned(),
-1 => t!("date.yesterday", locale = l).into_owned(),
2..=6 => format!(
"{} {}",
weekday_abbr(due.weekday(), lang),
short(due, today, lang)
),
_ => short(due, today, lang),
}
}
pub fn local_date(ts: Timestamp) -> Date {
ts.to_zoned(TimeZone::system()).date()
}
pub fn fmt_since(ts: Timestamp, today: Date) -> String {
fmt_since_in(local_date(ts), today, i18n::current())
}
fn fmt_since_in(d: Date, today: Date, lang: Lang) -> String {
let l = lang.code();
match days_between(d, today) {
i64::MIN..=0 => t!("date.since_today", locale = l).into_owned(),
1 => t!("date.since_yesterday", locale = l).into_owned(),
n => t!("date.since_days", locale = l, n = n).into_owned(),
}
}
pub fn fmt_ago(ts: Timestamp, today: Date) -> String {
fmt_ago_in(local_date(ts), today, i18n::current())
}
fn fmt_ago_in(d: Date, today: Date, lang: Lang) -> String {
let l = lang.code();
match days_between(d, today) {
i64::MIN..=0 => t!("date.today", locale = l).into_owned(),
1 => t!("date.yesterday", locale = l).into_owned(),
n @ 2..=6 => t!("date.days_ago", locale = l, n = n).into_owned(),
_ => short(d, today, lang),
}
}
pub fn fmt_timestamp(ts: Timestamp) -> String {
let zoned = ts.to_zoned(TimeZone::system());
let pattern = match i18n::current() {
Lang::De => "%d.%m.%Y %H:%M",
Lang::En => "%m/%d/%Y %-I:%M %p",
};
zoned.strftime(pattern).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use jiff::civil::date;
const TODAY: Date = date(2026, 10, 22);
#[test]
fn iso_and_keywords() {
assert_eq!(parse_date("2026-10-24", TODAY).unwrap(), date(2026, 10, 24));
assert_eq!(parse_date("heute", TODAY).unwrap(), TODAY);
assert_eq!(parse_date("Today", TODAY).unwrap(), TODAY);
assert_eq!(parse_date("Morgen", TODAY).unwrap(), date(2026, 10, 23));
assert_eq!(parse_date("tomorrow", TODAY).unwrap(), date(2026, 10, 23));
assert_eq!(parse_date("übermorgen", TODAY).unwrap(), date(2026, 10, 24));
}
#[test]
fn relative() {
assert_eq!(parse_date("+3d", TODAY).unwrap(), date(2026, 10, 25));
assert_eq!(parse_date("+2w", TODAY).unwrap(), date(2026, 11, 5));
assert_eq!(parse_date("+1m", TODAY).unwrap(), date(2026, 11, 22));
assert_eq!(parse_date("+0d", TODAY).unwrap(), TODAY);
}
#[test]
fn weekdays_next_occurrence_not_today() {
assert_eq!(parse_date("fr", TODAY).unwrap(), date(2026, 10, 23));
assert_eq!(parse_date("do", TODAY).unwrap(), date(2026, 10, 29));
assert_eq!(parse_date("mo", TODAY).unwrap(), date(2026, 10, 26));
assert_eq!(parse_date("so", TODAY).unwrap(), date(2026, 10, 25));
assert_eq!(parse_date("Fri", TODAY).unwrap(), date(2026, 10, 23));
assert_eq!(parse_date("thu", TODAY).unwrap(), date(2026, 10, 29));
}
#[test]
fn day_month_formats() {
assert_eq!(parse_date("24.10.", TODAY).unwrap(), date(2026, 10, 24));
assert_eq!(parse_date("1.3.", TODAY).unwrap(), date(2027, 3, 1));
assert_eq!(parse_date("24.12.2027", TODAY).unwrap(), date(2027, 12, 24));
assert_eq!(parse_date("24.12.27", TODAY).unwrap(), date(2027, 12, 24));
assert_eq!(parse_date("10/24", TODAY).unwrap(), date(2026, 10, 24));
assert_eq!(parse_date("3/1", TODAY).unwrap(), date(2027, 3, 1));
assert_eq!(parse_date("12/24/2027", TODAY).unwrap(), date(2027, 12, 24));
}
#[test]
fn errors() {
for bad in [
"",
"gestern",
"+3x",
"+d",
"32.10.",
"2026-13-01",
"freitag",
"fr.",
"13/1",
] {
assert!(parse_date(bad, TODAY).is_err(), "{bad:?} should fail");
}
}
#[test]
fn formatting_de() {
let f = |d| fmt_due_in(d, TODAY, Lang::De);
assert_eq!(f(TODAY), "heute");
assert_eq!(f(date(2026, 10, 23)), "morgen");
assert_eq!(f(date(2026, 10, 21)), "gestern");
assert_eq!(f(date(2026, 10, 24)), "SA 24.10.");
assert_eq!(f(date(2026, 11, 24)), "24.11.");
assert_eq!(f(date(2027, 1, 5)), "05.01.27");
assert_eq!(
fmt_since_in(date(2026, 10, 19), TODAY, Lang::De),
"seit 3 Tagen"
);
assert_eq!(
fmt_ago_in(date(2026, 10, 19), TODAY, Lang::De),
"vor 3 Tagen"
);
}
#[test]
fn formatting_en() {
let f = |d| fmt_due_in(d, TODAY, Lang::En);
assert_eq!(f(TODAY), "today");
assert_eq!(f(date(2026, 10, 23)), "tomorrow");
assert_eq!(f(date(2026, 10, 24)), "SAT 10/24");
assert_eq!(f(date(2027, 1, 5)), "01/05/27");
assert_eq!(
fmt_since_in(date(2026, 10, 19), TODAY, Lang::En),
"for 3 days"
);
assert_eq!(fmt_ago_in(date(2026, 10, 21), TODAY, Lang::En), "yesterday");
assert_eq!(fmt_date_in(date(2026, 10, 3), Lang::En), "10/03/2026");
}
}