diff --git a/src/main.rs b/src/main.rs index 7a3169e..b91bde6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,5 +4,6 @@ //! then this only declares the modules written so far. #![allow(dead_code)] +mod time; fn main() {} diff --git a/src/time/clock.rs b/src/time/clock.rs new file mode 100644 index 0000000..37c031e --- /dev/null +++ b/src/time/clock.rs @@ -0,0 +1,137 @@ +//! Where "now" comes from. +//! +//! skyshift reads the system clock, optionally corrected by a fixed +//! adjustment (for a clock you know is off) and shown in a chosen time zone +//! (instead of the one the operating system is set to). + +use jiff::tz::{Offset, TimeZone}; +use jiff::{RoundMode, SignedDuration, Timestamp, Unit, Zoned, ZonedRound}; + +/// How precisely Start and Pause record the time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PunchPrecision { + /// Whole minutes, like a punch clock. The times you see and the + /// durations you see always add up exactly. + #[default] + Minute, + /// Whole seconds. + Second, +} + +/// The app's clock: system time plus an adjustment, in the chosen time zone. +#[derive(Debug, Clone)] +pub struct Clock { + zone: TimeZone, + adjustment: SignedDuration, + precision: PunchPrecision, +} + +impl Clock { + pub fn new(zone: TimeZone, adjustment: SignedDuration, precision: PunchPrecision) -> Self { + Clock { zone, adjustment, precision } + } + + /// The exact current time. + pub fn now(&self) -> Zoned { + let system = Timestamp::now(); + let adjusted = system.checked_add(self.adjustment).unwrap_or(system); + adjusted.to_zoned(self.zone.clone()) + } + + /// The time to record when you press Start or Pause: now, cut down to + /// whole minutes (or whole seconds, depending on the settings). + pub fn punch(&self) -> Zoned { + let unit = match self.precision { + PunchPrecision::Minute => Unit::Minute, + PunchPrecision::Second => Unit::Second, + }; + let now = self.now(); + now.round(ZonedRound::new().smallest(unit).mode(RoundMode::Trunc)).unwrap_or(now) + } + + /// A stored moment, seen in the clock's time zone. + pub fn local(&self, instant: Timestamp) -> Zoned { + instant.to_zoned(self.zone.clone()) + } + + pub fn zone(&self) -> &TimeZone { + &self.zone + } +} + +/// Reads the `timezone` setting. It can be: +/// - `system`: whatever the operating system is set to, +/// - a time zone name like `America/Toronto`, `Europe/Paris` or `UTC`, +/// - a fixed offset from UTC like `-05:00`, `+5:30` or `+2` +/// (a fixed offset never changes for daylight saving time). +pub fn parse_zone(setting: &str) -> Result { + let setting = setting.trim(); + if setting.is_empty() || setting.eq_ignore_ascii_case("system") { + return TimeZone::try_system().map_err(|error| { + format!("can't read the system time zone ({error}); set one by name, like America/Toronto") + }); + } + if setting.starts_with(['+', '-']) { + return parse_offset(setting).map(TimeZone::fixed); + } + TimeZone::get(setting).map_err(|_| { + format!("unknown time zone \"{setting}\"; use a name like America/Toronto or UTC, or an offset like -05:00") + }) +} + +/// Reads an offset like "+05:30", "-5", "+0530" or "-05". +fn parse_offset(text: &str) -> Result { + let error = || format!("\"{text}\" isn't a UTC offset; use something like -05:00 or +5:30"); + let (sign, rest) = text.split_at(1); + let sign = if sign == "-" { -1 } else { 1 }; + let (hours, minutes) = match rest.split_once(':') { + Some(parts) => parts, + None if rest.len() > 2 => rest.split_at(rest.len() - 2), + None => (rest, "0"), + }; + let hours: i32 = hours.parse().map_err(|_| error())?; + let minutes: i32 = minutes.parse().map_err(|_| error())?; + if hours > 18 || minutes > 59 { + return Err(error()); + } + Offset::from_seconds(sign * (hours * 3600 + minutes * 60)).map_err(|_| error()) +} + +/// A short, readable name for a time zone: "America/Toronto", or +/// "UTC-05:00" for a fixed offset. +pub fn zone_name(zone: &TimeZone) -> String { + match zone.iana_name() { + Some(name) => name.to_string(), + None => { + let seconds = zone.to_offset(Timestamp::now()).seconds(); + let sign = if seconds < 0 { '-' } else { '+' }; + let seconds = seconds.abs(); + format!("UTC{sign}{:02}:{:02}", seconds / 3600, seconds / 60 % 60) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_zone_settings() { + assert!(parse_zone("system").is_ok()); + assert_eq!(zone_name(&parse_zone("America/Toronto").unwrap()), "America/Toronto"); + assert_eq!(zone_name(&parse_zone("-05:00").unwrap()), "UTC-05:00"); + assert_eq!(zone_name(&parse_zone("+5:30").unwrap()), "UTC+05:30"); + assert_eq!(zone_name(&parse_zone("+0545").unwrap()), "UTC+05:45"); + assert_eq!(zone_name(&parse_zone("-3").unwrap()), "UTC-03:00"); + assert!(parse_zone("Mars/Olympus").is_err()); + assert!(parse_zone("+25:00").is_err()); + } + + #[test] + fn punch_cuts_to_whole_minutes() { + let clock = Clock::new(TimeZone::UTC, SignedDuration::ZERO, PunchPrecision::Minute); + let punch = clock.punch(); + assert_eq!(punch.second(), 0); + assert_eq!(punch.subsec_nanosecond(), 0); + } +} diff --git a/src/time/format.rs b/src/time/format.rs new file mode 100644 index 0000000..d249b17 --- /dev/null +++ b/src/time/format.rs @@ -0,0 +1,350 @@ +//! 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}; +use jiff::{SignedDuration, 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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"); + } +} diff --git a/src/time/mod.rs b/src/time/mod.rs new file mode 100644 index 0000000..50efc29 --- /dev/null +++ b/src/time/mod.rs @@ -0,0 +1,14 @@ +//! Everything about time: +//! - [`clock`]: where "now" comes from (system clock, time zone, adjustment), +//! - [`format`]: how times, dates and durations look as text (12h/24h…), +//! - [`resolve`]: turning a typed "01:30" into the exact moment you meant, +//! - [`range`]: ranges of dates (a week, a month, the last 30 days). + +pub mod clock; +pub mod format; +pub mod range; +pub mod resolve; + +pub use clock::{Clock, PunchPrecision}; +pub use format::{DecimalSeparator, DurationStyle, Formatter, TimeFormat}; +pub use range::DateRange; diff --git a/src/time/range.rs b/src/time/range.rs new file mode 100644 index 0000000..43c396f --- /dev/null +++ b/src/time/range.rs @@ -0,0 +1,89 @@ +//! Ranges of dates: a week, a month, the last 30 days... + +use jiff::ToSpan; +use jiff::civil::{Date, Weekday}; + +/// Every date from `first` to `last`, both included. +/// +/// `first <= last` always holds: [`DateRange::new`] puts the two dates in +/// order, so there is no such thing as an "inverted" range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DateRange { + first: Date, + last: Date, +} + +impl DateRange { + /// The dates between `a` and `b` (in either order), both included. + pub fn new(a: Date, b: Date) -> Self { + let (first, last) = if a <= b { (a, b) } else { (b, a) }; + DateRange { first, last } + } + + /// The week that contains `date`, starting on `week_start`. + pub fn week_of(date: Date, week_start: Weekday) -> Self { + let first = date.saturating_sub(date.weekday().since(week_start).days()); + DateRange::new(first, first.saturating_add(6.days())) + } + + /// The calendar month that contains `date`. + pub fn month_of(date: Date) -> Self { + DateRange::new(date.first_of_month(), date.last_of_month()) + } + + /// The `count` days ending on `last`, so `last_days(today, 7)` is the + /// past week including today. + pub fn last_days(last: Date, count: i32) -> Self { + DateRange::new(last.saturating_sub((count.max(1) - 1).days()), last) + } + + pub fn first(self) -> Date { + self.first + } + + pub fn last(self) -> Date { + self.last + } + + pub fn contains(self, date: Date) -> bool { + self.first <= date && date <= self.last + } + + /// How many dates the range holds (a single day counts as 1). + pub fn day_count(self) -> i32 { + let hours = self.last.duration_since(self.first).as_hours(); + (hours / 24) as i32 + 1 + } + + /// The range of the same length that ends the day before this one starts. + pub fn previous(self) -> Self { + let last = self.first.saturating_sub(1.day()); + DateRange::last_days(last, self.day_count()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jiff::civil::date; + + #[test] + fn weeks_start_on_the_chosen_day() { + // 2026-09-24 is a Thursday. + let thursday = date(2026, 9, 24); + let monday_week = DateRange::week_of(thursday, Weekday::Monday); + assert_eq!((monday_week.first(), monday_week.last()), (date(2026, 9, 21), date(2026, 9, 27))); + let sunday_week = DateRange::week_of(thursday, Weekday::Sunday); + assert_eq!(sunday_week.first(), date(2026, 9, 20)); + assert_eq!(sunday_week.day_count(), 7); + } + + #[test] + fn counts_and_previous() { + let range = DateRange::last_days(date(2026, 9, 30), 30); + assert_eq!(range.first(), date(2026, 9, 1)); + assert_eq!(range.day_count(), 30); + assert_eq!(range.previous().last(), date(2026, 8, 31)); + assert_eq!(DateRange::new(date(2026, 9, 5), date(2026, 9, 1)).first(), date(2026, 9, 1)); + } +} diff --git a/src/time/resolve.rs b/src/time/resolve.rs new file mode 100644 index 0000000..2267624 --- /dev/null +++ b/src/time/resolve.rs @@ -0,0 +1,102 @@ +//! Turning a typed clock time (like "01:30") into an exact moment, using what +//! we already know about the work day. +//! +//! People type "01:30", not "2026-09-25T01:30-04:00". These two rules pick +//! the day they mean: +//! - a **start** time is the occurrence closest to a reference moment +//! (for example the end of the previous segment); +//! - an **end** time is the first occurrence after the start, so a segment +//! starting at 22:00 and ending at "01:30" ends the next day. + +use jiff::civil::{Date, Time}; +use jiff::tz::TimeZone; +use jiff::{ToSpan, Zoned}; + +/// The moment the clock shows `time` that is closest to `reference`. +/// +/// Fixing a night shift that started Thu 22:00, typing "02:00" as the next +/// start means Fri 02:00 (4 hours away), not Thu 02:00 (20 hours away). +pub fn nearest(time: Time, reference: &Zoned) -> Zoned { + let zone = reference.time_zone(); + let candidates = [-1, 0, 1] + .into_iter() + .map(|days| reference.date().saturating_add(days.days())) + .map(|date| on_date(date, time, zone)); + candidates + .min_by_key(|candidate| candidate.duration_since(reference).abs()) + .unwrap_or_else(|| on_date(reference.date(), time, zone)) +} + +/// The first moment strictly after `after` when the clock shows `time`. +/// +/// A segment that starts Thu 22:00 and ends at "01:30" ends Fri 01:30. +pub fn next_after(time: Time, after: &Zoned) -> Zoned { + let zone = after.time_zone(); + let same_day = on_date(after.date(), time, zone); + if same_day.timestamp() > after.timestamp() { + same_day + } else { + on_date(after.date().saturating_add(1.day()), time, zone) + } +} + +/// The latest moment, at or before `now`, when the clock shows `time`. +/// +/// For "I paused at 17:30" or "I started at 9:00": typed at 22:14, "17:30" +/// is today; typed at 00:30 (after midnight), it's yesterday. +pub fn latest_until(time: Time, now: &Zoned) -> Zoned { + let zone = now.time_zone(); + let same_day = on_date(now.date(), time, zone); + if same_day.timestamp() <= now.timestamp() { + same_day + } else { + on_date(now.date().saturating_sub(1.day()), time, zone) + } +} + +/// `time` on `date` in `zone`. +/// +/// When a daylight saving change makes that time happen twice (or not at +/// all), the usual choice of calendar apps is made: the earlier of the two, +/// or the time just after the skipped hour. +pub fn on_date(date: Date, time: Time, zone: &TimeZone) -> Zoned { + // This only fails for years near ±9999, which skyshift never produces + // (dates come from the clock or from files it validated). + date.to_datetime(time).to_zoned(zone.clone()).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use jiff::civil::date; + + fn at(day: i8, hour: i8, minute: i8) -> Zoned { + on_date(date(2026, 9, day), Time::new(hour, minute, 0, 0).unwrap(), &TimeZone::UTC) + } + + fn clock(hour: i8, minute: i8) -> Time { + Time::new(hour, minute, 0, 0).unwrap() + } + + #[test] + fn end_times_roll_past_midnight() { + assert_eq!(next_after(clock(1, 30), &at(24, 22, 0)), at(25, 1, 30)); + assert_eq!(next_after(clock(23, 0), &at(24, 22, 0)), at(24, 23, 0)); + // Same time as the start means a full day later, never zero length. + assert_eq!(next_after(clock(22, 0), &at(24, 22, 0)), at(25, 22, 0)); + } + + #[test] + fn past_times_never_land_in_the_future() { + assert_eq!(latest_until(clock(17, 30), &at(24, 22, 14)), at(24, 17, 30)); + assert_eq!(latest_until(clock(17, 30), &at(25, 0, 30)), at(24, 17, 30)); + assert_eq!(latest_until(clock(0, 30), &at(25, 0, 30)), at(25, 0, 30)); + } + + #[test] + fn start_times_pick_the_closest_day() { + assert_eq!(nearest(clock(2, 0), &at(25, 1, 30)), at(25, 2, 0)); + assert_eq!(nearest(clock(23, 30), &at(25, 1, 30)), at(24, 23, 30)); + assert_eq!(nearest(clock(9, 0), &at(24, 12, 0)), at(24, 9, 0)); + } +}