ppoppo-clock 0.1.1

Universal Clock + Timer port for ppoppo workspace — chat-as-infrastructure primitive
Documentation
// WASM arm: WasmClock + WasmTimer.
//
// WasmClock sources "now" from `js_sys::Date::now()` (f64 epoch millis) — the
// only clock readout the browser offers synchronously. All zone math happens
// in jiff against the bundled tzdb (identical to the native arm; RFC
// RFC_202607130309_jiff-migration D5 overturned the zero-bundle Intl design).
// The one remaining Intl call is `browser_local_tz()` — a *name* probe
// (`resolvedOptions().timeZone`), resolved against jiff's tzdb.
//
// 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).

use crate::{Clock, Timer};
use futures::channel::oneshot;
use futures::future::BoxFuture;
use jiff::tz::TimeZone;
use jiff::Timestamp;
use std::time::Duration;
use wasm_bindgen::JsCast;

pub struct WasmClock;

impl Clock for WasmClock {
    fn now(&self) -> Timestamp {
        let ms = js_sys::Date::now(); // f64 millis since epoch
        Timestamp::from_millisecond(ms as i64).unwrap_or(Timestamp::UNIX_EPOCH)
    }
}

/// Read the browser's local IANA timezone name via
/// `Intl.DateTimeFormat.resolvedOptions()` and resolve it in jiff's tzdb.
///
/// Falls back to `TimeZone::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() -> TimeZone {
    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| TimeZone::get(&s).ok())
        .unwrap_or(TimeZone::UTC)
}

pub struct WasmTimer;

impl Timer for WasmTimer {
    fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()> {
        let ms = dur.as_millis().min(i32::MAX as u128) 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)
    }
}