implemented model, service and dto for countdown. TODO: calculate

start_epoch_ms and target_epoch_ms
This commit is contained in:
2026-02-20 22:37:39 +01:00
parent 42b15a5966
commit 8517e073ba
8 changed files with 309 additions and 51 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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,
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod dto;
pub mod model;
pub mod service;

View File

@@ -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());
}
}

View File

@@ -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)
}
}

View File

@@ -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 {