ppoppo-clock 0.1.0

Universal Clock + Timer port for ppoppo workspace — chat-as-infrastructure primitive
Documentation
use crate::Tz;
use time::{Duration, OffsetDateTime};

/// A UTC instant paired with the timezone it should be displayed in.
///
/// The `instant` field always carries UTC offset +00:00 internally.
/// `local_parts()` performs the TZ conversion on demand.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZonedDateTime {
    instant: OffsetDateTime,
    tz: Tz,
}

/// Calendar / wall-clock components in a specific timezone.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalParts {
    pub year: i32,
    pub month_1: u8,     // 1..=12
    pub day: u8,         // 1..=31
    pub hour: u8,        // 0..=23
    pub minute: u8,      // 0..=59
    pub second: u8,      // 0..=60 (leap second)
    pub nano: u32,
    pub dow_monday0: u8, // 0=Mon … 6=Sun
}

impl ZonedDateTime {
    pub fn new(instant: OffsetDateTime, tz: Tz) -> Self {
        // Normalise to UTC so all stored instants are comparable.
        let utc = instant.to_offset(time::UtcOffset::UTC);
        Self { instant: utc, tz }
    }

    pub fn instant(&self) -> OffsetDateTime {
        self.instant
    }

    pub fn tz(&self) -> &Tz {
        &self.tz
    }

    /// Returns a new `ZonedDateTime` shifted forward (or backward) by `dur`.
    /// The timezone is preserved; only the instant changes.
    pub fn add(&self, dur: Duration) -> Self {
        Self {
            instant: self.instant + dur,
            tz: self.tz.clone(),
        }
    }

    /// Extract wall-clock components in the stored timezone.
    ///
    /// On the `native` feature, this uses `time-tz` to apply the correct UTC
    /// offset including DST. Without `native`, components are returned in UTC
    /// (mock / WASM build contexts where tests don't exercise DST correctness).
    pub fn local_parts(&self) -> LocalParts {
        #[cfg(all(feature = "wasm", not(feature = "native")))]
        {
            let utc_ms = (self.instant.unix_timestamp_nanos() / 1_000_000) as f64;
            if let Some(parts) = crate::wasm::intl_local_parts(utc_ms, self.tz.as_iana()) {
                return parts;
            }
        }

        let local = self.to_local_offset();
        LocalParts {
            year: local.year(),
            month_1: local.month() as u8,
            day: local.day(),
            hour: local.hour(),
            minute: local.minute(),
            second: local.second(),
            nano: local.nanosecond(),
            dow_monday0: local.weekday().number_days_from_monday(),
        }
    }

    #[cfg(feature = "native")]
    fn to_local_offset(&self) -> OffsetDateTime {
        use time_tz::OffsetDateTimeExt;
        if let Some(tz) = time_tz::timezones::get_by_name(self.tz.as_iana()) {
            self.instant.to_timezone(tz)
        } else {
            // Tz was parsed, so this should never happen — fall back to UTC.
            self.instant
        }
    }

    #[cfg(not(feature = "native"))]
    fn to_local_offset(&self) -> OffsetDateTime {
        // Without time-tz, return UTC. Tests requiring DST correctness need --features native.
        self.instant
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use time::macros::datetime;

    fn seoul() -> Tz {
        Tz::parse("Asia/Seoul").unwrap_or_else(|_| Tz::seoul())
    }

    #[test]
    fn instant_roundtrip_preserves_utc() {
        let utc = datetime!(2026-05-10 00:00:00 UTC);
        let zdt = ZonedDateTime::new(utc, Tz::utc());
        assert_eq!(zdt.instant(), utc);
    }

    #[test]
    fn add_shifts_instant_preserves_tz() {
        let utc = datetime!(2026-05-10 00:00:00 UTC);
        let tz = seoul();
        let zdt = ZonedDateTime::new(utc, tz.clone());
        let shifted = zdt.add(Duration::hours(15));
        assert_eq!(shifted.instant(), utc + Duration::hours(15));
        assert_eq!(shifted.tz(), &tz);
    }

    #[test]
    fn local_parts_utc_midnight() {
        let utc = datetime!(2026-05-10 00:00:00 UTC);
        let zdt = ZonedDateTime::new(utc, Tz::utc());
        let parts = zdt.local_parts();
        assert_eq!(parts.hour, 0);
        assert_eq!(parts.year, 2026);
        assert_eq!(parts.month_1, 5);
        assert_eq!(parts.day, 10);
    }

    #[cfg(feature = "native")]
    #[test]
    fn seoul_utc_midnight_is_hour_9() {
        // UTC 2026-05-10 00:00 → KST 09:00 (UTC+9, no DST in Korea)
        let utc = datetime!(2026-05-10 00:00:00 UTC);
        let zdt = ZonedDateTime::new(utc, seoul());
        assert_eq!(zdt.local_parts().hour, 9);
    }

    #[cfg(feature = "native")]
    #[test]
    fn kst_09_stored_as_utc_00() {
        // KST 2026-05-10 09:00 = UTC 00:00 — instant stored as UTC
        let utc = datetime!(2026-05-10 00:00:00 UTC);
        let zdt = ZonedDateTime::new(utc, seoul());
        assert_eq!(zdt.instant(), utc);
        assert_eq!(zdt.local_parts().hour, 9);
    }

    #[cfg(feature = "native")]
    #[test]
    fn dst_transition_new_york_2024() {
        // America/New_York spring-forward 2024-03-10 02:00 → 03:00
        // 2024-03-10 06:59 UTC = 01:59 EST; 2024-03-10 07:00 UTC = 03:00 EDT
        let tz = Tz::parse("America/New_York").expect("valid");
        let before = datetime!(2024-03-10 06:59:00 UTC);
        let after = datetime!(2024-03-10 07:00:00 UTC);
        let zdt_before = ZonedDateTime::new(before, tz.clone());
        let zdt_after = ZonedDateTime::new(after, tz);
        assert_eq!(zdt_before.local_parts().hour, 1);
        assert_eq!(zdt_after.local_parts().hour, 3);
    }

    #[test]
    fn dow_monday0_sunday() {
        // 2026-05-10 is a Sunday → dow_monday0 == 6
        let utc = datetime!(2026-05-10 12:00:00 UTC);
        let zdt = ZonedDateTime::new(utc, Tz::utc());
        assert_eq!(zdt.local_parts().dow_monday0, 6);
    }

    #[test]
    fn dow_monday0_monday() {
        // 2026-05-11 is a Monday → dow_monday0 == 0
        let utc = datetime!(2026-05-11 12:00:00 UTC);
        let zdt = ZonedDateTime::new(utc, Tz::utc());
        assert_eq!(zdt.local_parts().dow_monday0, 0);
    }

    #[cfg(feature = "native")]
    mod proptest_zoned {
        use super::*;
        use proptest::prelude::*;
        use time::OffsetDateTime;

        proptest! {
            #[test]
            fn roundtrip_instant_preserved(
                unix_secs in -2_000_000_000i64..2_000_000_000i64
            ) {
                let instant = OffsetDateTime::from_unix_timestamp(unix_secs)
                    .unwrap_or(OffsetDateTime::UNIX_EPOCH);
                let zdt = ZonedDateTime::new(instant, Tz::utc());
                let recovered = zdt.instant();
                prop_assert_eq!(recovered.unix_timestamp(), instant.unix_timestamp());
            }
        }
    }
}