Files
skyshift/src/time/format.rs
T
skkeyeandClaude Opus 5.5 fe5e2d9a7f Accept date shortcuts in the Work day field
Adding work to a day you forgot to track meant typing its full date.
The Work day field now also takes 09-14 (this year), today, yesterday,
-3 (three days ago) and weekday names like mon or friday (the most
recent one). The editor shows the date it understood next to the field.

The parsing lives in time::format::parse_date, next to parse_time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:02:43 -04:00

411 lines
15 KiB
Rust

//! 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<Time, TimeInputError> {
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::<u16>().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<AmPm>) {
// 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<Date, String> {
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::<i64>().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");
}
}