//! Turning times, dates and durations into text, and typed text back into a //! clock time. //! //! Every piece of text the app shows about time goes through [`Formatter`], //! so the 12h/24h and duration settings apply everywhere at once. use std::fmt; use jiff::civil::{Date, Time, Weekday}; use jiff::{SignedDuration, ToSpan, Zoned}; /// How clock times are written. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TimeFormat { /// 22:30 #[default] Hour24, /// 10:30 PM Hour12, } /// How durations are written. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DurationStyle { /// 7h 45m #[default] HoursMinutes, /// 7:45 Clock, /// 7.75 (hours as a decimal number) Decimal, } /// The character between the whole and the fractional part of decimal hours. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DecimalSeparator { /// 7.75 #[default] Dot, /// 7,75 Comma, } /// Writes times, dates and durations the way the settings ask for. /// /// Durations are always shown in whole minutes, cut down (never rounded up), /// like a stopwatch: 59 seconds shows as "0m". #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Formatter { pub time_format: TimeFormat, pub duration_style: DurationStyle, pub decimal_places: u8, pub decimal_separator: DecimalSeparator, } impl Default for Formatter { fn default() -> Self { Formatter { time_format: TimeFormat::default(), duration_style: DurationStyle::default(), decimal_places: 2, decimal_separator: DecimalSeparator::default(), } } } impl Formatter { /// A clock time without seconds: "22:05" or "10:05 PM". pub fn time(&self, time: Time) -> String { let (hour, minute) = (time.hour(), time.minute()); match self.time_format { TimeFormat::Hour24 => format!("{hour:02}:{minute:02}"), TimeFormat::Hour12 => { format!("{}:{minute:02} {}", hour_on_12h_dial(hour), am_pm(hour)) } } } /// A clock time with seconds: "22:05:09" or "10:05:09 PM". pub fn time_with_seconds(&self, time: Time) -> String { let (hour, minute, second) = (time.hour(), time.minute(), time.second()); match self.time_format { TimeFormat::Hour24 => format!("{hour:02}:{minute:02}:{second:02}"), TimeFormat::Hour12 => { format!("{}:{minute:02}:{second:02} {}", hour_on_12h_dial(hour), am_pm(hour)) } } } /// A short hour mark for chart axes: "22" or "10p". pub fn hour_mark(&self, hour: i8) -> String { match self.time_format { TimeFormat::Hour24 => format!("{hour:02}"), TimeFormat::Hour12 => { let suffix = if hour < 12 { "a" } else { "p" }; format!("{}{suffix}", hour_on_12h_dial(hour)) } } } /// A duration in the style chosen in the settings. pub fn duration(&self, duration: SignedDuration) -> String { self.duration_as(self.duration_style, duration) } /// A duration in a specific style, whatever the settings say. pub fn duration_as(&self, style: DurationStyle, duration: SignedDuration) -> String { match style { DurationStyle::HoursMinutes => hours_and_minutes(duration), DurationStyle::Clock => clock_style(duration), DurationStyle::Decimal => self.decimal_hours(duration), } } /// Hours as a decimal number: 7h 45m → "7.75", 5h 40m → "5.67". pub fn decimal_hours(&self, duration: SignedDuration) -> String { let hours = whole_minutes(duration) as f64 / 60.0; let text = format!("{hours:.*}", usize::from(self.decimal_places)); match self.decimal_separator { DecimalSeparator::Dot => text, DecimalSeparator::Comma => text.replace('.', ","), } } /// A running timer: "1:05:09", or "1:05" without seconds. pub fn stopwatch(&self, duration: SignedDuration, with_seconds: bool) -> String { let seconds = duration.as_secs().max(0); let (hours, minutes) = (seconds / 3600, seconds / 60 % 60); if with_seconds { format!("{hours}:{minutes:02}:{:02}", seconds % 60) } else { format!("{hours}:{minutes:02}") } } /// "Thu Sep 24" pub fn date_short(&self, date: Date) -> String { date.strftime("%a %b %-d").to_string() } /// "Thursday, September 24, 2026" pub fn date_long(&self, date: Date) -> String { date.strftime("%A, %B %-d, %Y").to_string() } } /// A moment as an RFC 3339 timestamp with its UTC offset, the standard /// machine-readable format: "2026-09-24T22:00:00-04:00". Used in the data /// file and in JSON exports. pub fn rfc3339(moment: &Zoned) -> String { moment.strftime("%Y-%m-%dT%H:%M:%S%:z").to_string() } /// 0 → 12, 13 → 1, 12 → 12. fn hour_on_12h_dial(hour: i8) -> i8 { match hour % 12 { 0 => 12, other => other, } } fn am_pm(hour: i8) -> &'static str { if hour < 12 { "AM" } else { "PM" } } /// The duration in whole minutes, cut toward zero (1m 59s → 1). pub fn whole_minutes(duration: SignedDuration) -> i64 { duration.as_secs() / 60 } /// "7h 05m", "8h", "45m", "0m", "-1h 30m" fn hours_and_minutes(duration: SignedDuration) -> String { let minutes = whole_minutes(duration); let sign = if minutes < 0 { "-" } else { "" }; let (hours, minutes) = (minutes.abs() / 60, minutes.abs() % 60); match (hours, minutes) { (0, minutes) => format!("{sign}{minutes}m"), (hours, 0) => format!("{sign}{hours}h"), (hours, minutes) => format!("{sign}{hours}h {minutes:02}m"), } } /// "7:05", "0:45", "-1:30" fn clock_style(duration: SignedDuration) -> String { let minutes = whole_minutes(duration); let sign = if minutes < 0 { "-" } else { "" }; format!("{sign}{}:{:02}", minutes.abs() / 60, minutes.abs() % 60) } /// Why typed text isn't a clock time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TimeInputError { Empty, Unrecognized, HourOutOfRange, MinuteOutOfRange, SecondOutOfRange, } impl fmt::Display for TimeInputError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let message = match self { TimeInputError::Empty => "type a time, like 22:30 or 10:30pm", TimeInputError::Unrecognized => "not a time; try 22:30, 2230, 22h30, 10:30pm or 10pm", TimeInputError::HourOutOfRange => "the hour must be 0-23 (or 1-12 with am/pm)", TimeInputError::MinuteOutOfRange => "minutes must be between 00 and 59", TimeInputError::SecondOutOfRange => "seconds must be between 00 and 59", }; f.write_str(message) } } impl std::error::Error for TimeInputError {} /// Reads a clock time typed by the user. Both 12h and 24h styles are /// understood, whatever the display setting: /// /// `22:30`, `22h30`, `2230`, `22`, `7:05:30`, `10:30pm`, `10:30 PM`, `10pm`, /// `10p`, `10.30 p.m.` pub fn parse_time(input: &str) -> Result { let text = input.trim().to_ascii_lowercase(); if text.is_empty() { return Err(TimeInputError::Empty); } let (text, meridiem) = split_am_pm(&text); // What's left is numbers separated by ':', '.', 'h' or spaces. let numbers: Vec<&str> = text.split([':', '.', 'h', ' ']).filter(|part| !part.is_empty()).collect(); let all_digits = numbers.iter().all(|n| !n.is_empty() && n.len() <= 4 && n.bytes().all(|b| b.is_ascii_digit())); if !all_digits { return Err(TimeInputError::Unrecognized); } // Each part is 1 to 4 ASCII digits, so it always fits in a u16. let number = |digits: &str| digits.parse::().unwrap_or(u16::MAX); let (hour, minute, second) = match numbers.as_slice() { // "9", "22" [hour] if hour.len() <= 2 => (number(hour), 0, 0), // "930", "2230": the last two digits are the minutes. [digits] => { let (hour, minute) = digits.split_at(digits.len() - 2); (number(hour), number(minute), 0) } [hour, minute] => (number(hour), number(minute), 0), [hour, minute, second] => (number(hour), number(minute), number(second)), _ => return Err(TimeInputError::Unrecognized), }; let hour = match meridiem { None if hour <= 23 => hour, Some(AmPm::Am) if (1..=12).contains(&hour) => hour % 12, Some(AmPm::Pm) if (1..=12).contains(&hour) => hour % 12 + 12, _ => return Err(TimeInputError::HourOutOfRange), }; if minute > 59 { return Err(TimeInputError::MinuteOutOfRange); } if second > 59 { return Err(TimeInputError::SecondOutOfRange); } // All three values were range-checked above, so this can't fail. Time::new(hour as i8, minute as i8, second as i8, 0).map_err(|_| TimeInputError::Unrecognized) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AmPm { Am, Pm, } /// Splits "10:30 p.m." into ("10:30", Some(Pm)). Text without an am/pm /// suffix comes back unchanged. fn split_am_pm(text: &str) -> (&str, Option) { // Accept "pm", "p.m.", "p" (and the same for am), with or without a space. let trimmed = text.trim_end_matches(['.', ' ']); let trimmed = trimmed.strip_suffix('m').unwrap_or(trimmed).trim_end_matches(['.', ' ']); if let Some(rest) = trimmed.strip_suffix('a') { (rest.trim_end(), Some(AmPm::Am)) } else if let Some(rest) = trimmed.strip_suffix('p') { (rest.trim_end(), Some(AmPm::Pm)) } else { (text, None) } } /// Reads a date typed by the user, with shortcuts for recent days: /// /// `2026-09-14`, `09-14` or `9-14` (this year), `today`, `yesterday`, /// `-3` (3 days ago), or a weekday like `mon` or `friday` (the most recent /// one, today included). pub fn parse_date(input: &str, today: Date) -> Result { let text = input.trim().to_ascii_lowercase(); let error = || format!("\"{}\" isn't a date; try 2026-09-14, 09-14, yesterday, -3 or mon", input.trim()); match text.as_str() { "today" => return Ok(today), "yesterday" => return Ok(today.saturating_sub(1.day())), _ => {} } if let Some(days) = text.strip_prefix('-').and_then(|days| days.parse::().ok()) { return Ok(today.saturating_sub(days.days())); } // A weekday name, or its start ("mon", "thu"): the most recent one. const WEEKDAYS: [(&str, Weekday); 7] = [ ("monday", Weekday::Monday), ("tuesday", Weekday::Tuesday), ("wednesday", Weekday::Wednesday), ("thursday", Weekday::Thursday), ("friday", Weekday::Friday), ("saturday", Weekday::Saturday), ("sunday", Weekday::Sunday), ]; if text.len() >= 3 && let Some((_, weekday)) = WEEKDAYS.iter().find(|(name, _)| name.starts_with(&text)) { return Ok(today.saturating_sub(today.weekday().since(*weekday).days())); } // "09-14" or "9-14": month and day of this year. let numbers: Vec<&str> = text.split('-').collect(); if let [month, day] = numbers.as_slice() { let month: i8 = month.parse().map_err(|_| error())?; let day: i8 = day.parse().map_err(|_| error())?; return Date::new(today.year(), month, day).map_err(|_| error()); } text.parse().map_err(|_| error()) } #[cfg(test)] mod tests { use super::*; use jiff::civil::date; #[test] fn reads_dates_and_shortcuts() { let today = date(2026, 9, 24); // a Thursday assert_eq!(parse_date("2026-09-14", today), Ok(date(2026, 9, 14))); assert_eq!(parse_date("09-14", today), Ok(date(2026, 9, 14))); assert_eq!(parse_date("9-4", today), Ok(date(2026, 9, 4))); assert_eq!(parse_date("Today", today), Ok(today)); assert_eq!(parse_date("yesterday", today), Ok(date(2026, 9, 23))); assert_eq!(parse_date("-3", today), Ok(date(2026, 9, 21))); assert_eq!(parse_date("mon", today), Ok(date(2026, 9, 21))); assert_eq!(parse_date("thursday", today), Ok(today)); assert_eq!(parse_date("fri", today), Ok(date(2026, 9, 18))); assert!(parse_date("th", today).is_err()); assert!(parse_date("13-40", today).is_err()); assert!(parse_date("someday", today).is_err()); } fn time(hour: i8, minute: i8) -> Time { Time::new(hour, minute, 0, 0).unwrap() } #[test] fn parses_24h_styles() { assert_eq!(parse_time("22:30"), Ok(time(22, 30))); assert_eq!(parse_time("22h30"), Ok(time(22, 30))); assert_eq!(parse_time("2230"), Ok(time(22, 30))); assert_eq!(parse_time("930"), Ok(time(9, 30))); assert_eq!(parse_time("7"), Ok(time(7, 0))); assert_eq!(parse_time(" 0:05 "), Ok(time(0, 5))); assert_eq!(parse_time("7:05:30"), Ok(Time::new(7, 5, 30, 0).unwrap())); } #[test] fn parses_12h_styles() { assert_eq!(parse_time("10:30pm"), Ok(time(22, 30))); assert_eq!(parse_time("10:30 PM"), Ok(time(22, 30))); assert_eq!(parse_time("10.30 p.m."), Ok(time(22, 30))); assert_eq!(parse_time("10p"), Ok(time(22, 0))); assert_eq!(parse_time("12am"), Ok(time(0, 0))); assert_eq!(parse_time("12pm"), Ok(time(12, 0))); assert_eq!(parse_time("9am"), Ok(time(9, 0))); } #[test] fn rejects_nonsense() { assert_eq!(parse_time(""), Err(TimeInputError::Empty)); assert_eq!(parse_time("24:00"), Err(TimeInputError::HourOutOfRange)); assert_eq!(parse_time("13pm"), Err(TimeInputError::HourOutOfRange)); assert_eq!(parse_time("10:60"), Err(TimeInputError::MinuteOutOfRange)); assert_eq!(parse_time("noon"), Err(TimeInputError::Unrecognized)); assert_eq!(parse_time("12345"), Err(TimeInputError::Unrecognized)); } #[test] fn formats_both_clocks() { let mut fmt = Formatter::default(); assert_eq!(fmt.time(time(22, 5)), "22:05"); fmt.time_format = TimeFormat::Hour12; assert_eq!(fmt.time(time(22, 5)), "10:05 PM"); assert_eq!(fmt.time(time(0, 0)), "12:00 AM"); assert_eq!(fmt.hour_mark(0), "12a"); } #[test] fn formats_durations() { let mut fmt = Formatter::default(); let d = SignedDuration::from_mins(5 * 60 + 40) + SignedDuration::from_secs(59); assert_eq!(fmt.duration_as(DurationStyle::HoursMinutes, d), "5h 40m"); assert_eq!(fmt.duration_as(DurationStyle::Clock, d), "5:40"); assert_eq!(fmt.duration_as(DurationStyle::Decimal, d), "5.67"); fmt.decimal_separator = DecimalSeparator::Comma; assert_eq!(fmt.decimal_hours(d), "5,67"); assert_eq!(fmt.duration_as(DurationStyle::HoursMinutes, -d), "-5h 40m"); assert_eq!(fmt.duration_as(DurationStyle::HoursMinutes, SignedDuration::from_secs(59)), "0m"); assert_eq!(fmt.duration_as(DurationStyle::HoursMinutes, SignedDuration::from_hours(8)), "8h"); } }