jobber 1.1.5-alpha

Minimalistic console work time tracker
use crate::entities::{Error, Result, WorkingInterval};
use chrono::{Datelike, TimeZone, Timelike};
use serde::{Deserialize, Serialize};
use std::str::FromStr;

pub(crate) type Duration = chrono::Duration;
pub(crate) type DelayedFormat<'a> =
    chrono::format::DelayedFormat<chrono::format::StrftimeItems<'a>>;
pub(crate) type NaiveDate = chrono::NaiveDate;
pub(crate) type NaiveTime = chrono::NaiveTime;
pub(crate) type NaiveDateTime = chrono::NaiveDateTime;

#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq)]
pub(crate) struct DateTime(chrono::DateTime<chrono::Utc>);

impl std::ops::Sub<DateTime> for DateTime {
    type Output = Duration;

    fn sub(self, rhs: Self) -> Self::Output {
        self.0 - rhs.0
    }
}

impl std::ops::Sub<&DateTime> for DateTime {
    type Output = Duration;

    fn sub(self, rhs: &Self) -> Self::Output {
        self.0 - rhs.0
    }
}

impl std::ops::Add<Duration> for DateTime {
    type Output = DateTime;

    fn add(self, rhs: Duration) -> Self::Output {
        Self(self.0 + rhs)
    }
}

impl std::ops::Sub<Duration> for DateTime {
    type Output = DateTime;

    fn sub(self, rhs: Duration) -> Self::Output {
        Self(self.0 - rhs)
    }
}

impl<T: chrono::TimeZone> From<chrono::DateTime<T>> for DateTime {
    fn from(value: chrono::DateTime<T>) -> Self {
        Self(value.to_utc())
    }
}

impl FromStr for DateTime {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        if let Some(local) = chrono::Local
            .from_local_datetime(&chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M")?)
            .single()
        {
            Ok(Self(local.with_timezone(&chrono::Utc)))
        } else {
            Err(Error::CouldNotParseDateTime(s.to_string()))
        }
    }
}

impl std::fmt::Display for DateTime {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let local = self.0.with_timezone(&chrono::Local).naive_local();
        write!(f, "{local}")
    }
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum RoundMode {
    Future,
    Past,
}

impl DateTime {
    pub(crate) fn now() -> Self {
        Self(chrono::Utc::now())
    }

    pub(crate) const fn min() -> Self {
        Self(chrono::DateTime::<chrono::Utc>::MIN_UTC)
    }

    pub(crate) const fn max() -> Self {
        Self(chrono::DateTime::<chrono::Utc>::MAX_UTC)
    }

    pub(crate) fn from_naive(datetime: chrono::NaiveDateTime) -> Self {
        let local = chrono::Local
            .from_local_datetime(&datetime)
            .single()
            .expect("Ungültiges Datum für lokale Zeitzone");
        Self(local.with_timezone(&chrono::Utc))
    }

    pub(crate) fn from_naive_date(date: NaiveDate) -> Self {
        let datetime = date.and_hms_opt(0, 0, 0).expect("invalid date");
        Self::from_naive(datetime)
    }

    pub(crate) fn from_ymd(year: i32, month: u32, day: u32) -> Result<Self> {
        match chrono::NaiveDate::from_ymd_opt(year, month, day) {
            Some(date) => Ok(Self::from_naive_date(date)),
            None => Err(Error::InvalidDateValues(year, month, day)),
        }
    }

    pub(crate) fn with_time(&self, time: NaiveTime) -> Self {
        let local_datetime = self
            .0
            .with_timezone(&chrono::Local)
            .date_naive()
            .and_time(time);
        Self::from_naive(local_datetime)
    }

    pub(crate) fn with_delta(&self, delta: Duration) -> Self {
        Self(self.0 + delta)
    }

    pub(crate) fn start_of_day(&self) -> Self {
        self.with_time(chrono::NaiveTime::MIN)
    }

    pub(crate) fn end_of_day(&self) -> Self {
        self.start_of_day() + Duration::days(1)
    }

    pub(crate) fn start_of_month(&self) -> DateTime {
        DateTime(
            self.0
                .with_timezone(&chrono::Local)
                .with_day(1)
                .unwrap()
                .with_time(chrono::NaiveTime::default())
                .unwrap()
                .to_utc(),
        )
    }

    pub(crate) fn format<'a>(&self, fmt: &'a str) -> DelayedFormat<'a> {
        self.format_tz(fmt, &chrono::Local)
    }

    pub(crate) fn format_tz<'a>(
        &self,
        fmt: &'a str,
        tz: &impl chrono::TimeZone,
    ) -> DelayedFormat<'a> {
        self.0.with_timezone(tz).naive_local().format(fmt)
    }

    #[cfg(test)]
    pub(crate) fn as_chrono_utc(&self) -> chrono::DateTime<chrono::Utc> {
        self.0
    }

    pub(crate) fn round_to_interval(&self, interval: WorkingInterval, mode: RoundMode) -> DateTime {
        let interval = interval.num_minutes();
        let local = self.0.with_timezone(&chrono::Local);
        let total_minutes = local.hour() * 60 + local.minute();
        let rounded = match mode {
            RoundMode::Future => total_minutes.div_ceil(interval) * interval % 1440,
            RoundMode::Past => total_minutes / interval * interval,
        };
        local
            .with_hour(rounded / 60)
            .and_then(|dt| dt.with_minute(rounded % 60))
            .and_then(|dt| dt.with_second(0))
            .and_then(|dt| dt.with_nanosecond(0))
            .map(|dt| dt.with_timezone(&chrono::Utc))
            .expect("invalid time")
            .into()
    }

    pub(crate) fn day(&self) -> u32 {
        self.0.with_timezone(&chrono::Local).day()
    }

    pub(crate) fn month(&self) -> u32 {
        self.0.with_timezone(&chrono::Local).month()
    }

    pub(crate) fn year(&self) -> i32 {
        self.0.with_timezone(&chrono::Local).year()
    }

    pub(crate) fn day_of_week(&self) -> u32 {
        self.0
            .with_timezone(&chrono::Local)
            .weekday()
            .num_days_from_sunday()
    }

    pub(crate) fn next_month(&self) -> Self {
        Self(
            self.0
                .with_timezone(&chrono::Local)
                .checked_add_months(chrono::Months::new(1))
                .expect("internal date/time error")
                .to_utc(),
        )
    }

    pub(crate) fn next_day(&self) -> Self {
        Self(
            self.0
                .with_timezone(&chrono::Local)
                .checked_add_days(chrono::Days::new(1))
                .expect("internal date/time error")
                .to_utc(),
        )
    }
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::entities::DateTime;

    fn naive_time(s: &str) -> chrono::NaiveTime {
        chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap()
    }

    #[test]
    fn with_time() {
        assert_eq!(
            DateTime::from_str("2026-1-1 12:00")
                .unwrap()
                .with_time(naive_time("14:00")),
            DateTime::from_str("2026-1-1 14:00").unwrap()
        );
    }

    #[test]
    fn naive_date() {
        assert_eq!(
            chrono::NaiveDateTime::parse_from_str("2026-01-01 00:00", "%Y-%m-%d %H:%M")
                .unwrap()
                .and_local_timezone(chrono::Local)
                .unwrap(),
            DateTime::from_str("2026-1-1 00:00").unwrap().0
        )
    }

    #[test]
    fn from_naive() {
        assert_eq!(
            DateTime::from_naive(chrono::NaiveDateTime::new(
                chrono::NaiveDate::from_str("2026-01-01").unwrap(),
                chrono::NaiveTime::from_str("00:00").unwrap(),
            ))
            .format("%Y-%m-%d %H:%M")
            .to_string(),
            "2026-01-01 00:00"
        );
    }
}