implemented model, service and dto for countdown. TODO: calculate
start_epoch_ms and target_epoch_ms
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<u64>,
|
||||
pub target_epoch_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl CountdownSnapshotDto {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
label: String,
|
||||
duration: Duration,
|
||||
state: CountdownState,
|
||||
start_epoch_ms: Option<u64>,
|
||||
target_epoch_ms: Option<u64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
label,
|
||||
duration,
|
||||
state,
|
||||
start_epoch_ms,
|
||||
target_epoch_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod model;
|
||||
pub mod service;
|
||||
|
||||
@@ -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<Duration>,
|
||||
state: CountdownState,
|
||||
@@ -18,63 +29,214 @@ struct Countdown {
|
||||
target_timestamp: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Countdown{
|
||||
|
||||
fn create(&self, id: uint, label: &str, duration: Duration) -> Option<Countdown>{
|
||||
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<String>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Countdown>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user