diff --git a/crates/zx_document_core/src/lib.rs b/crates/zx_document_core/src/lib.rs index d0abe52..ea005eb 100644 --- a/crates/zx_document_core/src/lib.rs +++ b/crates/zx_document_core/src/lib.rs @@ -16,3 +16,4 @@ pub mod reading_material; pub mod search; pub mod session_v2; pub mod text; +pub mod time_tracker; diff --git a/crates/zx_document_core/src/time_tracker.rs b/crates/zx_document_core/src/time_tracker.rs new file mode 100644 index 0000000..6fc3a66 --- /dev/null +++ b/crates/zx_document_core/src/time_tracker.rs @@ -0,0 +1,166 @@ +/// Tracks active reading time and calculates delta seconds. +/// +/// iOS controls when to call tick (every 15s), pause (background), resume (foreground). +/// Rust does NOT create its own timer. +#[derive(Debug, Clone)] +pub struct ActiveTimeTracker { + last_tick_ms: Option, + is_active: bool, + remainder_ms: i64, +} + +impl ActiveTimeTracker { + pub fn new() -> Self { + Self { + last_tick_ms: None, + is_active: false, + remainder_ms: 0, + } + } + + /// Start tracking. Returns delta = 0. + pub fn start(&mut self, timestamp_ms: i64) -> u32 { + self.last_tick_ms = Some(timestamp_ms); + self.is_active = true; + self.remainder_ms = 0; + 0 + } + + /// Pause tracking. Returns accumulated delta since last tick. + pub fn pause(&mut self, timestamp_ms: i64) -> u32 { + let delta = self.tick(timestamp_ms); + self.is_active = false; + delta + } + + /// Resume after pause. No delta produced. + pub fn resume(&mut self, timestamp_ms: i64) { + self.is_active = true; + self.last_tick_ms = Some(timestamp_ms); + } + + /// Regular tick. Returns delta seconds since last tick (or pause). + /// Returns 0 if not active or time goes backwards. + pub fn tick(&mut self, timestamp_ms: i64) -> u32 { + if !self.is_active { + return 0; + } + let last = match self.last_tick_ms { + Some(t) => t, + None => { + self.last_tick_ms = Some(timestamp_ms); + return 0; + } + }; + if timestamp_ms <= last { + return 0; + } + let elapsed_ms = timestamp_ms - last; + let total_ms = elapsed_ms + self.remainder_ms; + let delta_seconds = (total_ms / 1000) as u32; + self.remainder_ms = total_ms % 1000; + self.last_tick_ms = Some(timestamp_ms); + delta_seconds + } + + /// Close tracking. Returns final residual delta. + pub fn close(&mut self, timestamp_ms: i64) -> u32 { + let delta = self.tick(timestamp_ms); + self.is_active = false; + delta + } + + pub fn is_active(&self) -> bool { + self.is_active + } +} + +impl Default for ActiveTimeTracker { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_start_returns_zero() { + let mut t = ActiveTimeTracker::new(); + assert_eq!(t.start(1000), 0); + assert!(t.is_active()); + } + + #[test] + fn test_43_seconds_total() { + let mut t = ActiveTimeTracker::new(); + t.start(0); + let d1 = t.tick(15_000); // 15s + let d2 = t.tick(30_000); // 15s + let d3 = t.close(43_000); // 13s residual + assert_eq!(d1, 15); + assert_eq!(d2, 15); + assert_eq!(d3, 13); + assert_eq!(d1 + d2 + d3, 43); + } + + #[test] + fn test_pause_stops_time() { + let mut t = ActiveTimeTracker::new(); + t.start(0); + t.tick(15_000); // 15s + t.pause(15_000); + t.resume(45_000); // 30s pause + let d = t.tick(60_000); // 15s after resume + assert_eq!(d, 15); // only 15s counted, not 45s + } + + #[test] + fn test_time_goes_backwards() { + let mut t = ActiveTimeTracker::new(); + t.start(10000); + assert_eq!(t.tick(5000), 0); // backwards + assert_eq!(t.tick(25000), 15); // normal after backwards reset + } + + #[test] + fn test_inactive_tick_returns_zero() { + let mut t = ActiveTimeTracker::new(); + assert_eq!(t.tick(5000), 0); // not started + t.start(0); + t.pause(5000); + assert_eq!(t.tick(10000), 0); // paused + } + + #[test] + fn test_close_returns_residual() { + let mut t = ActiveTimeTracker::new(); + t.start(0); + t.tick(15000); + let d = t.close(20500); // 5.5s → 5s, 500ms remainder + assert_eq!(d, 5); + assert!(!t.is_active()); + } + + #[test] + fn test_remainder_accumulates() { + let mut t = ActiveTimeTracker::new(); + t.start(0); + // Each tick: 1500ms → 1s + 500ms remainder + let d1 = t.tick(1500); + let d2 = t.tick(3000); + let d3 = t.tick(4500); + // Total: 3 ticks → d1=1, d2=2(with remainder), d3=1 + // 1500ms = 1s, remainder 500ms + // tick2: 1500ms + 500ms remainder = 2000ms = 2s + // tick3: 1500ms = 1s, remainder 500ms + assert_eq!(d1 + d2 + d3, 4); // 1 + 2 + 1 = 4 + } + + #[test] + fn test_close_inactive_returns_zero() { + let mut t = ActiveTimeTracker::new(); + assert_eq!(t.close(1000), 0); + } +}