ppoppo-clock 0.1.0

Universal Clock + Timer port for ppoppo workspace — chat-as-infrastructure primitive
Documentation
// WASM arm: WasmClock + WasmTimer.
//
// WasmClock uses `Intl.DateTimeFormat.formatToParts` for TZ-aware local parts
// (browser-native, zero bundle cost). Pattern lifted from CTW clock.rs.
//
// WasmTimer uses `Window.setTimeout` wired through a `futures::channel::oneshot`
// so that the returned `BoxFuture` is `Send` (the JsFuture itself is !Send, but
// awaiting a oneshot::Receiver is Send).
//
// Runtime tests are deferred to S1.10 (wasm-bindgen-test infra).
// Acceptance for this slice: `cargo build --features wasm -p ppoppo-clock` green.

use crate::{Clock, Timer, Tz, ZonedDateTime};
#[cfg(not(feature = "native"))]
use crate::LocalParts;
use futures::channel::oneshot;
use futures::future::BoxFuture;
use time::{Date, Duration, Month, OffsetDateTime};
use wasm_bindgen::JsCast;

pub struct WasmClock;

impl Clock for WasmClock {
    fn now_utc(&self) -> OffsetDateTime {
        let ms = js_sys::Date::now(); // f64 millis since epoch
        let secs = (ms / 1000.0) as i64;
        let nanos = ((ms % 1000.0) * 1_000_000.0) as u32;
        OffsetDateTime::from_unix_timestamp(secs)
            .ok()
            .and_then(|dt| dt.replace_nanosecond(nanos).ok())
            .unwrap_or(OffsetDateTime::UNIX_EPOCH)
    }

    fn now_in(&self, tz: &Tz) -> ZonedDateTime {
        let utc = self.now_utc();
        ZonedDateTime::new(utc, tz.clone())
    }

    fn today_in(&self, tz: &Tz) -> Date {
        let p = self.now_in(tz).local_parts();
        Month::try_from(p.month_1)
            .ok()
            .and_then(|m| Date::from_calendar_date(p.year, m, p.day).ok())
            .unwrap_or_else(|| {
                // Fallback: use raw JS Date for the current local day
                let js_date = js_sys::Date::new_0();
                let year = js_date.get_full_year() as i32;
                let month_0 = js_date.get_month() as u8; // 0-indexed
                let day = js_date.get_date() as u8;
                Month::try_from(month_0 + 1)
                    .ok()
                    .and_then(|m| Date::from_calendar_date(year, m, day).ok())
                    .unwrap_or(OffsetDateTime::UNIX_EPOCH.date())
            })
    }

    fn now_unix_millis(&self) -> i64 {
        js_sys::Date::now() as i64
    }
}

/// Extract local calendar parts for a given UTC instant + IANA timezone
/// using `Intl.DateTimeFormat.formatToParts` — browser-native, zero bundle.
/// Called by `ZonedDateTime::local_parts()` when only `wasm` is active (not `native`).
#[cfg(not(feature = "native"))]
pub(crate) fn intl_local_parts(utc_ms: f64, iana: &str) -> Option<LocalParts> {
    use js_sys::{Array, Date, Object, Reflect};
    use wasm_bindgen::JsValue;

    let opts = Object::new();
    Reflect::set(&opts, &JsValue::from("timeZone"), &JsValue::from(iana)).ok()?;
    for key in &["year", "month", "day", "hour", "minute", "second"] {
        Reflect::set(&opts, &JsValue::from(*key), &JsValue::from("numeric")).ok()?;
    }

    let fmt = js_sys::Intl::DateTimeFormat::new(&Array::new(), &opts);
    let date = Date::new(&JsValue::from_f64(utc_ms));
    let parts_arr = fmt.format_to_parts(&date);

    let mut year = None::<i32>;
    let mut month_1 = None::<u8>;
    let mut day = None::<u8>;
    let mut hour = None::<u8>;
    let mut minute = None::<u8>;
    let mut second = None::<u8>;

    for item in parts_arr.iter() {
        let obj: Object = item.dyn_into().ok()?;
        let typ = Reflect::get(&obj, &JsValue::from("type"))
            .ok()?
            .as_string()?;
        let val = Reflect::get(&obj, &JsValue::from("value"))
            .ok()?
            .as_string()?;
        let n: i64 = val.parse().ok()?;
        match typ.as_str() {
            "year" => year = Some(n as i32),
            "month" => month_1 = Some(n as u8),
            "day" => day = Some(n as u8),
            "hour" => hour = Some(n as u8),
            "minute" => minute = Some(n as u8),
            "second" => second = Some(n as u8),
            _ => {}
        }
    }

    let instant_for_dow =
        OffsetDateTime::from_unix_timestamp_nanos((utc_ms * 1_000_000.0) as i128).ok()?;

    Some(LocalParts {
        year: year?,
        month_1: month_1?,
        day: day?,
        hour: hour?,
        minute: minute?,
        second: second?,
        nano: 0,
        dow_monday0: instant_for_dow.weekday().number_days_from_monday(),
    })
}

/// Read the browser's local IANA timezone name via `Intl.DateTimeFormat.resolvedOptions()`.
///
/// Falls back to `Tz::utc()` if the browser returns an unrecognised name.
/// Call once at app startup and provide the result via Leptos context.
pub fn browser_local_tz() -> Tz {
    use js_sys::{Array, Intl, Object, Reflect};
    use wasm_bindgen::JsValue;

    let fmt = Intl::DateTimeFormat::new(&Array::new(), &Object::new());
    let resolved = fmt.resolved_options();
    Reflect::get(&resolved, &JsValue::from_str("timeZone"))
        .ok()
        .and_then(|v| v.as_string())
        .and_then(|s| Tz::parse(&s).ok())
        .unwrap_or_else(Tz::utc)
}

pub struct WasmTimer;

impl Timer for WasmTimer {
    fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()> {
        let ms = dur.whole_milliseconds().max(0) as i32;
        let (tx, rx) = oneshot::channel::<()>();

        // Wire setTimeout → oneshot sender. The closure captures `tx` (which is Send).
        // `Closure::once` is the approved wasm-bindgen pattern for fire-once callbacks.
        let closure = wasm_bindgen::closure::Closure::once(move || {
            let _ = tx.send(());
        });
        if let Some(win) = web_sys::window() {
            let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
                closure.as_ref().unchecked_ref(),
                ms,
            );
        }
        // Leak the closure so JS can call it after Rust drops ownership.
        // Memory is reclaimed by JS GC once the callback fires.
        closure.forget();

        Box::pin(async move {
            // rx is Send; this future is Send.
            let _ = rx.await;
        })
    }

    fn next_tick(&self) -> BoxFuture<'static, ()> {
        self.sleep(Duration::ZERO)
    }
}