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:
2026-02-27 15:57:17 +01:00
parent bf42cf1799
commit 2a32025382
11 changed files with 243 additions and 98 deletions

View File

@@ -36,7 +36,7 @@ impl AppState {
pub fn new() -> Self {
Self {
clock_anchor: ClockAnchor::new(),
countdown_service: CountdownService::default(),
countdown_service: CountdownService::new(),
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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;
}