Add the time module: clock, 12h/24h text and typed times
- clock: the system clock, with a time zone override (name or fixed offset) and a manual adjustment; Start/Pause record whole minutes so displayed times and totals always add up. - format: every time, date and duration shown in the app goes through Formatter (12h/24h, 7h 45m / 7:45 / 7.75); parse_time reads both 12h and 24h input (22:30, 2230, 22h30, 10:30pm, 10p). - resolve: turns a typed "01:30" into the moment meant, so an end time before the start is the next day (night shifts). - range: date ranges (week, month, last N days). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f646da0a8e
commit
7bbc1ee0fa
6 files changed
+693
No files matched your search
@@ -4,5 +4,6 @@
|
|||||||
//! then this only declares the modules written so far.
|
//! then this only declares the modules written so far.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod time;
|
||||||
|
|
||||||
fn main() {}
|
fn main() {}
|
||||||
@@ -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<TimeZone, String> {
|
||||||
|
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<Offset, String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user