feat(countdown): add multi-countdown management with ID-based commands
- refactor backend service from single countdown to HashMap storage with generated IDs - add create/list/delete commands and make start/pause/resume/reset/snapshot ID-aware - introduce validation and new countdown errors (id/label/duration/state/action/max limit) - update frontend countdown snapshot typing/mapping to match backend DTO changes - add shared base Pico CSS overrides and import base styles
This commit is contained in:
@@ -36,7 +36,7 @@ impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
clock_anchor: ClockAnchor::new(),
|
||||
countdown_service: CountdownService::default(),
|
||||
countdown_service: CountdownService::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64, String> {
|
||||
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<Vec<CountdownSnapshotDto>, String> {
|
||||
let snapshots = state
|
||||
.countdown_service
|
||||
.list_countdown()
|
||||
.await
|
||||
.map_err(|e: CountdownError| e.to_string())?;
|
||||
let mut snapshot_dtos: Vec<CountdownSnapshotDto> = 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<CountdownSnapshotDto, String> {
|
||||
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,
|
||||
|
||||
@@ -9,23 +9,3 @@ pub struct CountdownSnapshotDto {
|
||||
pub start_epoch_ms: Option<u128>,
|
||||
pub target_epoch_ms: Option<u128>,
|
||||
}
|
||||
|
||||
impl CountdownSnapshotDto {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
label: String,
|
||||
duration: u128,
|
||||
state: CountdownState,
|
||||
start_epoch_ms: Option<u128>,
|
||||
target_epoch_ms: Option<u128>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
label,
|
||||
duration,
|
||||
state,
|
||||
start_epoch_ms,
|
||||
target_epoch_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Duration>,
|
||||
@@ -22,9 +21,8 @@ pub struct Countdown {
|
||||
}
|
||||
|
||||
impl Countdown {
|
||||
pub fn new(id: u64, label: impl Into<String>, duration: Duration) -> Self {
|
||||
pub fn new(label: impl Into<String>, 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<Instant> {
|
||||
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();
|
||||
|
||||
@@ -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<Countdown>,
|
||||
countdowns: Mutex<HashMap<u64, Countdown>>,
|
||||
next_id: Mutex<u64>,
|
||||
}
|
||||
|
||||
#[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<u64, CountdownError> {
|
||||
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<Vec<CountdownSnapshot>, 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<CountdownSnapshot, CountdownError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CountdownSnapshot> {
|
||||
let temp = await invokeCommand<CountdownSnapshotDto>("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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
@import "@picocss/pico/css/pico.classless.jade.min.css";
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/countdown.css";
|
||||
16
src/styles/base.css
Normal file
16
src/styles/base.css
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user