diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 2c21292..08e44f7 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -36,7 +36,7 @@ impl AppState { pub fn new() -> Self { Self { clock_anchor: ClockAnchor::new(), - countdown_service: CountdownService::default(), + countdown_service: CountdownService::new(), } } } diff --git a/src-tauri/src/countdown/commands.rs b/src-tauri/src/countdown/commands.rs index 831fbc2..58402a9 100644 --- a/src-tauri/src/countdown/commands.rs +++ b/src-tauri/src/countdown/commands.rs @@ -2,39 +2,98 @@ use crate::countdown::dto::CountdownSnapshotDto; use crate::countdown::errors::CountdownError; use crate::AppState; use tauri::{command, State}; -use tokio::time::Instant; +use tokio::time::{Duration, Instant}; #[command] -pub async fn countdown_start(state: State<'_, AppState>) -> Result<(), String> { +pub async fn countdown_create( + state: State<'_, AppState>, + label: String, + duration: u64, +) -> Result { + let duration = Duration::from_millis(duration); state .countdown_service - .start(Instant::now()) + .create_countdown(label, duration) + .await + .map_err(|e: CountdownError| e.to_string()) +} + +#[command] +pub async fn countdown_list( + state: State<'_, AppState>, +) -> Result, String> { + let snapshots = state + .countdown_service + .list_countdown() + .await + .map_err(|e: CountdownError| e.to_string())?; + let mut snapshot_dtos: Vec = Vec::new(); + for snapshot in snapshots { + let start = match snapshot.start_instant { + Some(instant) => Some(state.clock_anchor.instant_to_epoch_ms(instant)), + None => None, + }; + let target = match snapshot.target_instant { + Some(instant) => Some(state.clock_anchor.instant_to_epoch_ms(instant)), + None => None, + }; + snapshot_dtos.push(CountdownSnapshotDto { + id: snapshot.id, + label: snapshot.label, + duration: snapshot.duration.as_millis(), + state: snapshot.state, + start_epoch_ms: start, + target_epoch_ms: target, + }) + } + Ok(snapshot_dtos) +} + +#[command] +pub async fn countdown_delete(state: State<'_, AppState>, id: u64) -> Result<(), String> { + state + .countdown_service + .delete_countdown(id) .await .map_err(|e: CountdownError| e.to_string())?; Ok(()) } #[command] -pub async fn countdown_reset(state: State<'_, AppState>) -> Result<(), String> { - state.countdown_service.reset().await; - Ok(()) -} - -#[command] -pub async fn countdown_pause(state: State<'_, AppState>) -> Result<(), String> { +pub async fn countdown_start(state: State<'_, AppState>, id: u64) -> Result<(), String> { state .countdown_service - .pause(Instant::now()) + .start(id, Instant::now()) .await .map_err(|e: CountdownError| e.to_string())?; Ok(()) } #[command] -pub async fn countdown_resume(state: State<'_, AppState>) -> Result<(), String> { +pub async fn countdown_reset(state: State<'_, AppState>, id: u64) -> Result<(), String> { state .countdown_service - .resume(Instant::now()) + .reset(id) + .await + .map_err(|e: CountdownError| e.to_string())?; + Ok(()) +} + +#[command] +pub async fn countdown_pause(state: State<'_, AppState>, id: u64) -> Result<(), String> { + state + .countdown_service + .pause(id, Instant::now()) + .await + .map_err(|e: CountdownError| e.to_string())?; + Ok(()) +} + +#[command] +pub async fn countdown_resume(state: State<'_, AppState>, id: u64) -> Result<(), String> { + state + .countdown_service + .resume(id, Instant::now()) .await .map_err(|e: CountdownError| e.to_string())?; Ok(()) @@ -43,8 +102,13 @@ pub async fn countdown_resume(state: State<'_, AppState>) -> Result<(), String> #[command] pub async fn countdown_snapshot( state: State<'_, AppState>, + id: u64, ) -> Result { - let countdown_snapshot = state.countdown_service.snapshot(Instant::now()).await; + let countdown_snapshot = state + .countdown_service + .snapshot(id, Instant::now()) + .await + .map_err(|e: CountdownError| e.to_string())?; let start = match countdown_snapshot.start_instant { Some(instant) => Some(state.clock_anchor.instant_to_epoch_ms(instant)), None => None, diff --git a/src-tauri/src/countdown/dto.rs b/src-tauri/src/countdown/dto.rs index 8bbfa50..2b2ba55 100644 --- a/src-tauri/src/countdown/dto.rs +++ b/src-tauri/src/countdown/dto.rs @@ -9,23 +9,3 @@ pub struct CountdownSnapshotDto { pub start_epoch_ms: Option, pub target_epoch_ms: Option, } - -impl CountdownSnapshotDto { - pub fn new( - id: u64, - label: String, - duration: u128, - 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/errors.rs b/src-tauri/src/countdown/errors.rs index f1aa9b2..c5510f1 100644 --- a/src-tauri/src/countdown/errors.rs +++ b/src-tauri/src/countdown/errors.rs @@ -10,4 +10,16 @@ pub enum CountdownError { }, #[error("Time overflow")] TimeOverflow, + #[error("Id not found")] + IdNotFound, + #[error("Label not found")] + LabelNotFound, + #[error("Invalid duration")] + InvalidDuration, + #[error("Invalid state")] + InvalidState, + #[error("Invalid action")] + InvalidAction, + #[error("Max countdowns reached")] + MaxCountdownsReached, } diff --git a/src-tauri/src/countdown/model.rs b/src-tauri/src/countdown/model.rs index 4198034..a0709b5 100644 --- a/src-tauri/src/countdown/model.rs +++ b/src-tauri/src/countdown/model.rs @@ -12,7 +12,6 @@ pub enum CountdownState { #[derive(Debug, Clone)] pub struct Countdown { - pub id: u64, pub label: String, initial_duration: Duration, remaining_duration_stored: Option, @@ -22,9 +21,8 @@ pub struct Countdown { } impl Countdown { - pub fn new(id: u64, label: impl Into, duration: Duration) -> Self { + pub fn new(label: impl Into, duration: Duration) -> Self { Self { - id, label: label.into(), initial_duration: duration, remaining_duration_stored: None, @@ -34,10 +32,6 @@ impl Countdown { } } - pub fn id(&self) -> u64 { - self.id - } - pub fn label(&self) -> &str { &self.label } @@ -46,10 +40,6 @@ impl Countdown { self.state } - pub fn initial_duration(&self) -> Duration { - self.initial_duration - } - pub fn start_timestamp(&self) -> Option { self.start_timestamp } @@ -72,10 +62,6 @@ impl Countdown { } } - pub fn remaining(&self) -> Duration { - self.remaining_at(Instant::now()) - } - pub fn is_finished(&self) -> bool { self.state == CountdownState::Finished } @@ -181,7 +167,7 @@ mod tests { #[test] fn start_from_idle_enters_running() { let now = Instant::now(); - let mut countdown = Countdown::new(1, "test", Duration::from_secs(10)); + let mut countdown = Countdown::new("test", Duration::from_secs(10)); countdown.start(now).expect("start should succeed"); assert_eq!(countdown.state(), CountdownState::Running); @@ -191,7 +177,7 @@ mod tests { #[test] fn pause_and_resume_preserve_remaining_time() { let now = Instant::now(); - let mut countdown = Countdown::new(1, "test", Duration::from_secs(10)); + let mut countdown = Countdown::new("test", Duration::from_secs(10)); countdown.start(now).expect("start should succeed"); let after_three_seconds = now.checked_add(Duration::from_secs(3)).unwrap(); @@ -217,7 +203,7 @@ mod tests { #[test] fn invalid_transition_returns_error() { let now = Instant::now(); - let mut countdown = Countdown::new(1, "test", Duration::from_secs(5)); + let mut countdown = Countdown::new("test", Duration::from_secs(5)); let err = countdown .pause(now) @@ -234,7 +220,7 @@ mod tests { #[test] fn running_countdown_reaches_finished() { let now = Instant::now(); - let mut countdown = Countdown::new(1, "test", Duration::from_secs(2)); + let mut countdown = Countdown::new("test", Duration::from_secs(2)); countdown.start(now).expect("start should succeed"); let after_two_seconds = now.checked_add(Duration::from_secs(2)).unwrap(); diff --git a/src-tauri/src/countdown/service.rs b/src-tauri/src/countdown/service.rs index ebdc2c2..3bd89f3 100644 --- a/src-tauri/src/countdown/service.rs +++ b/src-tauri/src/countdown/service.rs @@ -1,11 +1,15 @@ use crate::countdown::errors::CountdownError; use crate::countdown::model::{Countdown, CountdownState}; +use std::collections::HashMap; use tokio::sync::Mutex; use tokio::time::{Duration, Instant}; +const MAX_COUNTDOWNS: usize = 10; + #[derive(Debug)] pub struct CountdownService { - countdown: Mutex, + countdowns: Mutex>, + next_id: Mutex, } #[derive(Debug)] @@ -19,45 +23,123 @@ pub struct CountdownSnapshot { } impl CountdownService { - pub fn default() -> Self { - Self::new(0, "Countdown0", Duration::new(600, 0)) - } - - pub fn new(id: u64, label: &str, duration: Duration) -> Self { + pub fn new() -> Self { Self { - countdown: Mutex::new(Countdown::new(id, label, duration)), + countdowns: Mutex::new(HashMap::new()), + next_id: Mutex::new(0), } } - pub async fn snapshot(&self, now: Instant) -> CountdownSnapshot { - let countdown = self.countdown.lock().await; - CountdownSnapshot { - id: countdown.id(), - label: countdown.label().to_string(), - state: countdown.state(), - duration: countdown.remaining_at(now), - start_instant: countdown.start_timestamp(), - target_instant: countdown.target_timestamp(), + pub async fn create_countdown( + &self, + label: String, + duration: Duration, + ) -> Result { + let mut countdowns = self.countdowns.lock().await; + if countdowns.len() >= MAX_COUNTDOWNS { + Err(CountdownError::MaxCountdownsReached) + } else { + if label.is_empty() { + return Err(CountdownError::LabelNotFound); + } + if duration.as_millis() == 0 { + return Err(CountdownError::InvalidDuration); + } + let mut next_id = self.next_id.lock().await; + let id = *next_id; + *next_id += 1; + countdowns.insert(id, Countdown::new(label, duration)); + Ok(id) } } - pub async fn start(&self, now: Instant) -> Result<(), CountdownError> { - let mut countdown = self.countdown.lock().await; - countdown.start(now) + pub async fn list_countdown(&self) -> Result, CountdownError> { + let mut countdowns = self.countdowns.lock().await; + if countdowns.is_empty() { + return Ok(Vec::new()); + } + let mut snapshots = Vec::new(); + for (id, countdown) in countdowns.iter_mut() { + let now = Instant::now(); + countdown.sync_finished_at(now); + snapshots.push(CountdownSnapshot { + id: *id, + label: countdown.label().to_string(), + state: countdown.state(), + duration: countdown.remaining_at(now), + start_instant: countdown.start_timestamp(), + target_instant: countdown.target_timestamp(), + }) + } + Ok(snapshots) } - pub async fn reset(&self) { - let mut countdown = self.countdown.lock().await; - countdown.reset() + pub async fn delete_countdown(&self, id: u64) -> Result<(), CountdownError> { + let mut countdowns = self.countdowns.lock().await; + if let Some(countdown) = countdowns.get_mut(&id) { + countdown.reset(); + countdowns.remove(&id); + Ok(()) + } else { + Err(CountdownError::IdNotFound) + } } - pub async fn resume(&self, now: Instant) -> Result<(), CountdownError> { - let mut countdown = self.countdown.lock().await; - countdown.resume(now) + pub async fn snapshot( + &self, + id: u64, + now: Instant, + ) -> Result { + let mut countdowns = self.countdowns.lock().await; + if let Some(countdown) = countdowns.get_mut(&id) { + countdown.sync_finished_at(now); + Ok(CountdownSnapshot { + id, + label: countdown.label().to_string(), + state: countdown.state(), + duration: countdown.remaining_at(now), + start_instant: countdown.start_timestamp(), + target_instant: countdown.target_timestamp(), + }) + } else { + Err(CountdownError::IdNotFound) + } } - pub async fn pause(&self, now: Instant) -> Result<(), CountdownError> { - let mut countdown = self.countdown.lock().await; - countdown.pause(now) + pub async fn start(&self, id: u64, now: Instant) -> Result<(), CountdownError> { + let mut countdowns = self.countdowns.lock().await; + if let Some(countdown) = countdowns.get_mut(&id) { + countdown.start(now) + } else { + Err(CountdownError::IdNotFound) + } + } + + pub async fn reset(&self, id: u64) -> Result<(), CountdownError> { + let mut countdowns = self.countdowns.lock().await; + if let Some(countdown) = countdowns.get_mut(&id) { + countdown.reset(); + Ok(()) + } else { + Err(CountdownError::IdNotFound) + } + } + + pub async fn resume(&self, id: u64, now: Instant) -> Result<(), CountdownError> { + let mut countdowns = self.countdowns.lock().await; + if let Some(countdown) = countdowns.get_mut(&id) { + countdown.resume(now) + } else { + Err(CountdownError::IdNotFound) + } + } + + pub async fn pause(&self, id: u64, now: Instant) -> Result<(), CountdownError> { + let mut countdowns = self.countdowns.lock().await; + if let Some(countdown) = countdowns.get_mut(&id) { + countdown.pause(now) + } else { + Err(CountdownError::IdNotFound) + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3587f70..e927521 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,8 @@ mod app_state; mod countdown; use crate::countdown::commands::{ - countdown_pause, countdown_reset, countdown_resume, countdown_snapshot, countdown_start, + countdown_create, countdown_delete, countdown_list, countdown_pause, countdown_reset, + countdown_resume, countdown_snapshot, countdown_start, }; pub use app_state::AppState; @@ -15,6 +16,9 @@ pub fn run() { .manage(app_state) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ + countdown_create, + countdown_list, + countdown_delete, countdown_start, countdown_reset, countdown_pause, diff --git a/src/features/countdown/api.ts b/src/features/countdown/api.ts index 0f7be8f..96c3cb2 100644 --- a/src/features/countdown/api.ts +++ b/src/features/countdown/api.ts @@ -1,17 +1,26 @@ import {invokeCommand} from "../../shared/tauri/invoke"; -import type {CountdownCommand, CountdownSnapshot, CountdownSnapshotDto, Duration} from "./types"; +import {CountdownCommand, CountdownSnapshot, CountdownState, Duration} from "./types"; import {millisToDuration} from "./helper.ts"; +type CountdownSnapshotDto = { + id: number; + label: string; + duration: number; + state: CountdownState; + start_epoch_ms: number | null; + target_epoch_ms: number | null; +}; + export async function fetchCountdownSnapshot(): Promise { let temp = await invokeCommand("countdown_snapshot"); - let duration: Duration = millisToDuration(temp.duration); + const duration: Duration = millisToDuration(temp.duration); return { id: temp.id, label: temp.label, duration: duration, state: temp.state, - start_epoch_ms: temp.start_epoch_ms ? new Date(temp.start_epoch_ms) : null, - target_epoch_ms: temp.target_epoch_ms ? new Date(temp.target_epoch_ms) : null, + start_epoch: temp.start_epoch_ms !== null ? new Date(temp.start_epoch_ms) : null, + target_epoch: temp.target_epoch_ms !== null ? new Date(temp.target_epoch_ms) : null, }; } diff --git a/src/features/countdown/types.ts b/src/features/countdown/types.ts index 3e98bbb..9d92a47 100644 --- a/src/features/countdown/types.ts +++ b/src/features/countdown/types.ts @@ -7,22 +7,13 @@ export type Duration = { millis: number; } -export type CountdownSnapshotDto = { - id: number; - label: string; - duration: number; - state: CountdownState; - start_epoch_ms: number | null; - target_epoch_ms: number | null; -}; - export type CountdownSnapshot = { id: number; label: string; duration: Duration; state: CountdownState; - start_epoch_ms: Date | null; - target_epoch_ms: Date | null; + start_epoch: Date | null; + target_epoch: Date | null; } export type CountdownCommand = diff --git a/src/styles.css b/src/styles.css index a268815..e17ad14 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,2 +1,3 @@ @import "@picocss/pico/css/pico.classless.jade.min.css"; +@import "./styles/base.css"; @import "./styles/countdown.css"; \ No newline at end of file diff --git a/src/styles/base.css b/src/styles/base.css new file mode 100644 index 0000000..4fb733e --- /dev/null +++ b/src/styles/base.css @@ -0,0 +1,16 @@ +:root { + --pico-border-radius: 0.5rem; + --pico-typography-spacing-vertical: 1.5rem; + --pico-form-element-spacing-vertical: 1rem; + --pico-form-element-spacing-horizontal: 1.25rem; +} + +h1 { + --pico-font-family: Pacifico, cursive; + --pico-font-weight: 400; + --pico-typography-spacing-vertical: 0.5rem; +} + +button { + --pico-font-weight: 700; +} \ No newline at end of file