diff --git a/AGENTS.md b/AGENTS.md index bd2b1a2..827c9c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ This is a learning project. The assistant is a guide, not an implementer. - The assistant may provide step-by-step guidance, debugging help, reviews, architecture feedback, and hints. - The assistant may edit documentation files when explicitly requested. - If asked to implement code, the assistant should refuse and provide a clear plan the developer can execute. +- If asked about a file, function, module, or crate, the assistant must read the current code first before answering. +- Reviews and guidance must be based strictly on the current file contents, not earlier snapshots. ## Project Structure & Module Organization This repository is a Tauri app with a TypeScript frontend and Rust backend. @@ -30,7 +32,7 @@ This repository is a Tauri app with a TypeScript frontend and Rust backend. ## Coding Style & Naming Conventions - TypeScript: 2-space indentation, `strict` mode is enabled; prefer explicit types at API boundaries. - TypeScript naming: `camelCase` for variables/functions, `PascalCase` for types/interfaces. -- Rust: follow `rustfmt` defaults (4-space indentation); use `snake_case` for functions/modules. +- Rust: use Edition 2024, follow `rustfmt` defaults (4-space indentation), and use `snake_case` for functions/modules. - Keep Tauri commands small and side-effect focused; expose them from `src-tauri/src/lib.rs`. - Use descriptive file names by feature (for example, `src/settings-panel.ts`). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 251b12e..e65c29b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -488,8 +488,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -2441,6 +2443,7 @@ name = "owlerlay" version = "0.1.0" dependencies = [ "axum", + "chrono", "minijinja", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f3b53bd..85483bd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,8 +2,8 @@ name = "owlerlay" version = "0.1.0" description = "A Tauri App" -authors = ["you"] -edition = "2021" +authors = ["s0wlz"] +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -30,4 +30,4 @@ tokio = { version = "1.49.0", features = ["full"] } tower-http = { version = "0.6.8", features = ["fs", "cors"] } minijinja = "2.15.1" toml = "1.0.3" - +chrono = "0.4.43" diff --git a/src-tauri/src/countdown/dto.rs b/src-tauri/src/countdown/dto.rs index e69de29..e5a7c3e 100644 --- a/src-tauri/src/countdown/dto.rs +++ b/src-tauri/src/countdown/dto.rs @@ -0,0 +1,33 @@ +use tokio::time::{Duration, Instant}; + +use crate::countdown::model::CountdownState; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct CountdownSnapshotDto { + pub id: u64, + pub label: String, + pub duration: Duration, + pub state: CountdownState, + pub start_epoch_ms: Option, + pub target_epoch_ms: Option, +} + +impl CountdownSnapshotDto { + pub fn new( + id: u64, + label: String, + duration: Duration, + state: CountdownState, + start_epoch_ms: Option, + target_epoch_ms: Option, + ) -> Self { + Self { + id, + label, + duration, + state, + start_epoch_ms, + target_epoch_ms, + } + } +} diff --git a/src-tauri/src/countdown/mod.rs b/src-tauri/src/countdown/mod.rs index e69de29..779e645 100644 --- a/src-tauri/src/countdown/mod.rs +++ b/src-tauri/src/countdown/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod model; +pub mod service; diff --git a/src-tauri/src/countdown/model.rs b/src-tauri/src/countdown/model.rs index 9ff10cd..22f96da 100644 --- a/src-tauri/src/countdown/model.rs +++ b/src-tauri/src/countdown/model.rs @@ -1,16 +1,27 @@ -use std::time::{Instant, Duration} +use serde::{Deserialize, Serialize}; +use tokio::time::{Duration, Instant}; -enum CountdownState{ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum CountdownState { Idle, Running, Paused, - Finished + Finished, } -#[derive(Debug)] -struct Countdown { - id: uint, - label: &str, +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CountdownError { + InvalidTransition { + from: CountdownState, + action: &'static str, + }, + TimeOverflow, +} + +#[derive(Debug, Clone)] +pub struct Countdown { + pub id: u64, + pub label: String, initial_duration: Duration, remaining_duration_stored: Option, state: CountdownState, @@ -18,63 +29,214 @@ struct Countdown { target_timestamp: Option, } -impl Countdown{ - - fn create(&self, id: uint, label: &str, duration: Duration) -> Option{ - self.id = id; - self.label = label; - self.initial_duration = duration; - self.state = CountdownState::Idle; - self.remaining_duration_stored = None; - self.start_timestamp = None; - self.target_timestamp = None; +impl Countdown { + pub fn new(id: u64, label: impl Into, duration: Duration) -> Self { + Self { + id, + label: label.into(), + initial_duration: duration, + remaining_duration_stored: None, + state: CountdownState::Idle, + start_timestamp: None, + target_timestamp: None, + } } - fn remaining_at(&self, timestamp: Instant) -> Duration{ - timestamp.saturating_duration_since(target_timestamp) + pub fn state(&self) -> CountdownState { + self.state } - fn remaining(&self) -> Duration{ - self.remaining_at(Instant::Now()) + pub fn initial_duration(&self) -> Duration { + self.initial_duration } - fn is_finished(&self) -> bool { + pub fn remaining_at(&self, now: Instant) -> Duration { + match self.state { + CountdownState::Idle => self.initial_duration, + CountdownState::Running => self + .target_timestamp + .map(|target| target.saturating_duration_since(now)) + .unwrap_or(self.initial_duration), + CountdownState::Paused => self + .remaining_duration_stored + .unwrap_or(self.initial_duration), + CountdownState::Finished => Duration::from_secs(0), + } + } + + pub fn remaining(&self) -> Duration { + self.remaining_at(Instant::now()) + } + + pub fn is_finished(&self) -> bool { self.state == CountdownState::Finished } - fn start(&self) -> Result { - match self.state{ - CountdownState::Idle => { - start_timestamp = Instant::Now(); - target_timestamp = start.timestamp.checked_add(self.initial_duration).unwrap(); - }, - CountdownState::Paused => { - target_timestamp = Instant::Now().checked_add(self.remaining_duration_stored).unwrap(); - }, - _ => Err("not startable") - } - self.remaining_duration_stored = None; - self.State = Running; - Ok() - } - - fn pause(&self) -> Result { + pub fn start(&mut self, now: Instant) -> Result<(), CountdownError> { match self.state { - CountdownState::Running => { - self.remaining_duration_stored = Instant::Now().saturating_duration_since(self.target_timestamp); - self.state = CountdownState::Paused; - Ok() - }, - _ => Err("not pausable") + CountdownState::Idle => { + let target = now + .checked_add(self.initial_duration) + .ok_or(CountdownError::TimeOverflow)?; + self.start_timestamp = Some(now); + self.target_timestamp = Some(target); + self.remaining_duration_stored = None; + self.state = CountdownState::Running; + Ok(()) + } + CountdownState::Paused => { + let remaining = + self.remaining_duration_stored + .ok_or(CountdownError::InvalidTransition { + from: self.state, + action: "start", + })?; + + if remaining.is_zero() { + self.mark_finished(); + return Ok(()); + } + + let target = now + .checked_add(remaining) + .ok_or(CountdownError::TimeOverflow)?; + self.start_timestamp = Some(now); + self.target_timestamp = Some(target); + self.remaining_duration_stored = None; + self.state = CountdownState::Running; + Ok(()) + } + _ => Err(CountdownError::InvalidTransition { + from: self.state, + action: "start", + }), } } - fn reset(&self) -> Result{ + pub fn pause(&mut self, now: Instant) -> Result<(), CountdownError> { + if self.state != CountdownState::Running { + return Err(CountdownError::InvalidTransition { + from: self.state, + action: "pause", + }); + } + + let remaining = self.remaining_at(now); + if remaining.is_zero() { + self.mark_finished(); + return Ok(()); + } + + self.remaining_duration_stored = Some(remaining); + self.start_timestamp = None; + self.target_timestamp = None; + self.state = CountdownState::Paused; + Ok(()) + } + + pub fn resume(&mut self, now: Instant) -> Result<(), CountdownError> { + if self.state != CountdownState::Paused { + return Err(CountdownError::InvalidTransition { + from: self.state, + action: "resume", + }); + } + + self.start(now) + } + + pub fn reset(&mut self) { self.state = CountdownState::Idle; self.remaining_duration_stored = None; self.start_timestamp = None; self.target_timestamp = None; - Ok() } + pub fn sync_finished_at(&mut self, now: Instant) { + if self.state == CountdownState::Running && self.remaining_at(now).is_zero() { + self.mark_finished(); + } + } + + fn mark_finished(&mut self) { + self.state = CountdownState::Finished; + self.remaining_duration_stored = Some(Duration::from_secs(0)); + self.start_timestamp = None; + self.target_timestamp = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_from_idle_enters_running() { + let now = Instant::now(); + let mut countdown = Countdown::new(1, "test", Duration::from_secs(10)); + countdown.start(now).expect("start should succeed"); + + assert_eq!(countdown.state(), CountdownState::Running); + assert_eq!(countdown.remaining_at(now), Duration::from_secs(10)); + } + + #[test] + fn pause_and_resume_preserve_remaining_time() { + let now = Instant::now(); + let mut countdown = Countdown::new(1, "test", Duration::from_secs(10)); + countdown.start(now).expect("start should succeed"); + + let after_three_seconds = now.checked_add(Duration::from_secs(3)).unwrap(); + countdown + .pause(after_three_seconds) + .expect("pause should succeed"); + assert_eq!(countdown.state(), CountdownState::Paused); + assert_eq!( + countdown.remaining_at(after_three_seconds), + Duration::from_secs(7) + ); + + let resume_time = after_three_seconds + .checked_add(Duration::from_secs(1)) + .unwrap(); + countdown + .resume(resume_time) + .expect("resume should succeed"); + assert_eq!(countdown.state(), CountdownState::Running); + assert_eq!(countdown.remaining_at(resume_time), Duration::from_secs(7)); + } + + #[test] + fn invalid_transition_returns_error() { + let now = Instant::now(); + let mut countdown = Countdown::new(1, "test", Duration::from_secs(5)); + + let err = countdown + .pause(now) + .expect_err("pause should fail from idle"); + assert_eq!( + err, + CountdownError::InvalidTransition { + from: CountdownState::Idle, + action: "pause", + } + ); + } + + #[test] + fn running_countdown_reaches_finished() { + let now = Instant::now(); + let mut countdown = Countdown::new(1, "test", Duration::from_secs(2)); + countdown.start(now).expect("start should succeed"); + + let after_two_seconds = now.checked_add(Duration::from_secs(2)).unwrap(); + countdown.sync_finished_at(after_two_seconds); + + assert_eq!(countdown.state(), CountdownState::Finished); + assert_eq!( + countdown.remaining_at(after_two_seconds), + Duration::from_secs(0) + ); + assert!(countdown.is_finished()); + } } diff --git a/src-tauri/src/countdown/service.rs b/src-tauri/src/countdown/service.rs index e69de29..267318d 100644 --- a/src-tauri/src/countdown/service.rs +++ b/src-tauri/src/countdown/service.rs @@ -0,0 +1,53 @@ +use chrono::Utc; +use tokio::sync::Mutex; +use tokio::time::{Duration, Instant}; + +use crate::countdown::dto::CountdownSnapshotDto; +use crate::countdown::model::{Countdown, CountdownError}; + +pub struct CountdownService { + countdown: Mutex, + next_id: u64, +} + +impl CountdownService { + pub fn new() -> Self { + Self { + countdown: Mutex::new(Countdown::new(0, "Countdown0", Duration::new(600, 0))), + next_id: 1, + } + } + + pub async fn snapshot(&self, now: Instant) -> CountdownSnapshotDto { + let countdown = self.countdown.lock().await; + let instant_now = Instant::now(); + CountdownSnapshotDto { + id: countdown.id, + label: countdown.label.to_string(), + state: countdown.state(), + duration: countdown.remaining(), + start_epoch_ms: countdown.start_epoch_ms(), + target_epoch_ms: countdown.target_epoch_ms(), + } + } + + pub async fn start(&self, now: Instant) -> Result<(), CountdownError> { + let mut countdown = self.countdown.lock().await; + countdown.start(now) + } + + pub async fn reset(&self) { + let mut countdown = self.countdown.lock().await; + countdown.reset() + } + + pub async fn resume(&self, now: Instant) -> Result<(), CountdownError> { + let mut countdown = self.countdown.lock().await; + countdown.resume(now) + } + + pub async fn pause(&self, now: Instant) -> Result<(), CountdownError> { + let mut countdown = self.countdown.lock().await; + countdown.pause(now) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..4a66b4f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod countdown; + // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] fn greet(name: &str) -> String {