From 2f670a4dc323b973046a66ab4087e0328b35a3c6 Mon Sep 17 00:00:00 2001 From: simonsan <14062932+simonsan@users.noreply.github.com> Date: Thu, 22 Feb 2024 13:27:05 +0100 Subject: [PATCH] start implementing hold and continue for intermissions Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> --- crates/core/src/domain/activity.rs | 68 +++++++++++++++++------ crates/core/src/domain/activity_log.rs | 8 +++ crates/core/src/domain/intermission.rs | 41 +------------- crates/core/src/domain/time.rs | 9 +++ crates/core/src/error.rs | 2 + crates/core/src/service/activity_store.rs | 18 ++++-- crates/core/src/storage.rs | 12 ++++ crates/core/src/storage/file.rs | 13 +++-- crates/core/src/storage/in_memory.rs | 24 +++++++- crates/core/tests/activity_store.rs | 31 ++++++++++- src/commands/hold.rs | 65 +++++++++++++++++----- 11 files changed, 206 insertions(+), 85 deletions(-) diff --git a/crates/core/src/domain/activity.rs b/crates/core/src/domain/activity.rs index 5d85368c..2bc9e2dd 100644 --- a/crates/core/src/domain/activity.rs +++ b/crates/core/src/domain/activity.rs @@ -37,6 +37,48 @@ pub enum ActivityKind { PomodoroIntermission, } +impl ActivityKind { + /// Returns `true` if the activity kind is [`Activity`]. + /// + /// [`Activity`]: ActivityKind::Activity + #[must_use] + pub fn is_activity(&self) -> bool { + matches!(self, Self::Activity) + } + + /// Returns `true` if the activity kind is [`Task`]. + /// + /// [`Task`]: ActivityKind::Task + #[must_use] + pub fn is_task(&self) -> bool { + matches!(self, Self::Task) + } + + /// Returns `true` if the activity kind is [`Intermission`]. + /// + /// [`Intermission`]: ActivityKind::Intermission + #[must_use] + pub fn is_intermission(&self) -> bool { + matches!(self, Self::Intermission) + } + + /// Returns `true` if the activity kind is [`PomodoroWork`]. + /// + /// [`PomodoroWork`]: ActivityKind::PomodoroWork + #[must_use] + pub fn is_pomodoro_work(&self) -> bool { + matches!(self, Self::PomodoroWork) + } + + /// Returns `true` if the activity kind is [`PomodoroIntermission`]. + /// + /// [`PomodoroIntermission`]: ActivityKind::PomodoroIntermission + #[must_use] + pub fn is_pomodoro_intermission(&self) -> bool { + matches!(self, Self::PomodoroIntermission) + } +} + /// The cycle of pomodoro activity a user can track // TODO!: Optional: Track Pomodoro work/break cycles #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -150,6 +192,14 @@ pub struct ActivityKindOptions { parent_id: Option, } +impl ActivityKindOptions { + pub fn new(parent_id: impl Into>) -> Self { + Self { + parent_id: parent_id.into(), + } + } +} + impl Default for Activity { fn default() -> Self { Self { @@ -246,24 +296,6 @@ impl Activity { self.end_activity(end_opts); Ok(()) } - - // pub fn start_intermission(&mut self, date: NaiveDate, time: NaiveTime) { - // let new_intermission = IntermissionPeriod::new(date, time); - // if let Some(ref mut periods) = self.intermission_periods { - // periods.push(new_intermission); - // } else { - // self.intermission_periods = Some(vec![new_intermission]); - // } - // } - - // pub fn end_intermission(&mut self, date: NaiveDate, time: NaiveTime) { - // if let Some(intermission_periods) = &mut self.intermission_periods { - // if let Some(last_period) = intermission_periods.last_mut() { - // // Assuming intermissions can't overlap, the last one is the one to end - // last_period.end(date, time); - // } - // } - // } } #[cfg(test)] diff --git a/crates/core/src/domain/activity_log.rs b/crates/core/src/domain/activity_log.rs index f18e8e77..db371145 100644 --- a/crates/core/src/domain/activity_log.rs +++ b/crates/core/src/domain/activity_log.rs @@ -13,6 +13,14 @@ pub struct ActivityLog { activities: VecDeque, } +impl std::ops::Deref for ActivityLog { + type Target = VecDeque; + + fn deref(&self) -> &Self::Target { + &self.activities + } +} + impl FromIterator for ActivityLog { fn from_iter>(iter: T) -> Self { Self { diff --git a/crates/core/src/domain/intermission.rs b/crates/core/src/domain/intermission.rs index 8470455e..c9a2b7de 100644 --- a/crates/core/src/domain/intermission.rs +++ b/crates/core/src/domain/intermission.rs @@ -1,42 +1,5 @@ //! Intermission entity and business logic -use chrono::{Local, NaiveDateTime}; -use serde_derive::{Deserialize, Serialize}; +use crate::Activity; -use crate::domain::time::PaceDuration; - -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] -pub struct IntermissionPeriod { - begin: NaiveDateTime, - end: Option, - duration: Option, -} - -impl Default for IntermissionPeriod { - fn default() -> Self { - Self { - begin: Local::now().naive_local(), - end: None, - duration: None, - } - } -} - -impl IntermissionPeriod { - pub fn new( - begin: NaiveDateTime, - end: Option, - duration: Option, - ) -> Self { - Self { - begin, - end, - duration, - } - } - - pub fn end(&mut self, end: NaiveDateTime) { - // TODO!: Calculate duration - self.end = Some(end); - } -} +impl Activity {} diff --git a/crates/core/src/domain/time.rs b/crates/core/src/domain/time.rs index 349bae9a..10d0ad00 100644 --- a/crates/core/src/domain/time.rs +++ b/crates/core/src/domain/time.rs @@ -179,6 +179,15 @@ impl From for BeginDateTime { } } +impl From> for BeginDateTime { + fn from(time: Option) -> Self { + match time { + Some(time) => Self(time), + None => Self::default(), + } + } +} + /// Calculate the duration of the activity /// /// # Arguments diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 8738a7c4..31727323 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -84,6 +84,8 @@ pub enum PaceErrorKind { DatabaseStorageNotImplemented, /// Failed to parse time '{0}' from user input, please use the format HH:MM ParsingTimeFromUserInputFailed(String), + /// There is no path available to store the activity log + NoPathAvailable, } /// [`ActivityLogErrorKind`] describes the errors that can happen while dealing with the activity log. diff --git a/crates/core/src/service/activity_store.rs b/crates/core/src/service/activity_store.rs index cb7a6886..01d536e2 100644 --- a/crates/core/src/service/activity_store.rs +++ b/crates/core/src/service/activity_store.rs @@ -1,11 +1,12 @@ use std::collections::{BTreeMap, HashSet, VecDeque}; -use chrono::NaiveDateTime; +use chrono::{prelude::NaiveDate, NaiveDateTime}; use serde_derive::{Deserialize, Serialize}; use crate::{ domain::{ activity::{Activity, ActivityGuid}, + activity_log::ActivityLog, filter::FilteredActivities, }, error::{PaceOptResult, PaceResult}, @@ -121,13 +122,18 @@ impl ActivityStateManagement for ActivityStore { impl ActivityQuerying for ActivityStore { fn find_activities_in_date_range( &self, - _start_date: chrono::prelude::NaiveDate, - _end_date: chrono::prelude::NaiveDate, - ) -> PaceResult { - todo!("Implement find_activities_in_date_range for ActivityStore") + start_date: NaiveDate, + end_date: NaiveDate, + ) -> PaceResult { + self.storage + .find_activities_in_date_range(start_date, end_date) } fn list_activities_by_id(&self) -> PaceOptResult> { - todo!("Implement list_activities_by_id for ActivityStore") + self.storage.list_activities_by_id() + } + + fn latest_active_activity(&self) -> PaceOptResult { + self.storage.latest_active_activity() } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index dabc3c2b..6099ff66 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -358,6 +358,18 @@ pub trait ActivityQuerying: ActivityReadOps { /// A collection of the activities that were loaded from the storage backend by their ID in a `BTreeMap`. /// If no activities are found, it should return `Ok(None)`. fn list_activities_by_id(&self) -> PaceOptResult>; + + /// Get the latest active activity. + /// + /// # Errors + /// + /// This function should return an error if the activity cannot be loaded. + /// + /// # Returns + /// + /// The latest active activity. + /// If no activity is found, it should return `Ok(None)`. + fn latest_active_activity(&self) -> PaceOptResult; } /// Tagging Activities diff --git a/crates/core/src/storage/file.rs b/crates/core/src/storage/file.rs index d5b5d461..d0db7005 100644 --- a/crates/core/src/storage/file.rs +++ b/crates/core/src/storage/file.rs @@ -177,14 +177,19 @@ impl ActivityWriteOps for TomlActivityStorage { impl ActivityQuerying for TomlActivityStorage { fn list_activities_by_id(&self) -> PaceOptResult> { - todo!("Implement `activities_by_id` for `TomlActivityStorage`") + self.cache.list_activities_by_id() } fn find_activities_in_date_range( &self, - _start_date: NaiveDate, - _end_date: NaiveDate, + start_date: NaiveDate, + end_date: NaiveDate, ) -> PaceResult { - todo!("Implement `find_activities_in_date_range` for `TomlActivityStorage`") + self.cache + .find_activities_in_date_range(start_date, end_date) + } + + fn latest_active_activity(&self) -> PaceOptResult { + self.cache.latest_active_activity() } } diff --git a/crates/core/src/storage/in_memory.rs b/crates/core/src/storage/in_memory.rs index e7162de1..7a708783 100644 --- a/crates/core/src/storage/in_memory.rs +++ b/crates/core/src/storage/in_memory.rs @@ -30,9 +30,7 @@ pub struct InMemoryActivityStorage { impl From for InMemoryActivityStorage { fn from(activities: ActivityLog) -> Self { - Self { - activities: Arc::new(Mutex::new(activities)), - } + Self::new_with_activity_log(activities) } } @@ -359,4 +357,24 @@ impl ActivityQuerying for InMemoryActivityStorage { ) -> PaceOptResult> { todo!("Implement list_activities_by_id for InMemoryActivityStorage") } + + fn latest_active_activity(&self) -> PaceOptResult { + let Ok(activities) = self.activities.lock() else { + return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + }; + + let activity = activities + .activities() + .par_iter() + .find_first(|activity| { + activity.is_active() + && !activity.kind().is_intermission() + && !activity.kind().is_pomodoro_intermission() + }) + .cloned(); + + drop(activities); + + Ok(activity) + } } diff --git a/crates/core/tests/activity_store.rs b/crates/core/tests/activity_store.rs index cb611cd4..9aa3420c 100644 --- a/crates/core/tests/activity_store.rs +++ b/crates/core/tests/activity_store.rs @@ -3,7 +3,7 @@ use chrono::{Local, NaiveDateTime}; use pace_core::{ Activity, ActivityFilter, ActivityGuid, ActivityLog, ActivityReadOps, ActivityStore, - ActivityWriteOps, BeginDateTime, InMemoryActivityStorage, TestResult, + ActivityWriteOps, BeginDateTime, InMemoryActivityStorage, PaceResult, TestResult, }; use rstest::{fixture, rstest}; use similar_asserts::assert_eq; @@ -301,3 +301,32 @@ fn test_activity_store_update_activity_fails( assert!(store.update_activity(activity_id, new_activity).is_err()); } + +#[rstest] +fn test_activity_store_begin_intermission_passes() -> PaceResult<()> { + let toml_string = r#" +[[activities]] +id = "01HQ8B27751H7QPBD2V7CZD1B7" +description = "Intermission Test" +begin = "2024-02-22T13:01:25" +kind = "intermission" +parent-id = "01HQ8B1WS5X0GZ660738FNED91" + +[[activities]] +id = "01HQ8B1WS5X0GZ660738FNED91" +category = "MyCategory::SubCategory" +description = "Intermission Test" +begin = "2024-02-22T13:01:14" +kind = "activity" +"#; + + let activity_log = toml::from_str::(toml_string)?; + + let _store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( + activity_log, + ))); + + // TODO!: Implement intermission handling. + + Ok(()) +} diff --git a/src/commands/hold.rs b/src/commands/hold.rs index f85e9556..9475c2b2 100644 --- a/src/commands/hold.rs +++ b/src/commands/hold.rs @@ -4,6 +4,11 @@ use abscissa_core::{status_err, Application, Command, Runnable, Shutdown}; use clap::Parser; use eyre::Result; +use pace_core::{ + get_storage_from_config, parse_time_from_user_input, Activity, ActivityKind, + ActivityKindOptions, ActivityQuerying, ActivityStateManagement, ActivityStorage, ActivityStore, + SyncStorage, +}; use crate::prelude::PACE_APP; @@ -13,6 +18,13 @@ pub struct HoldCmd { /// The time the activity has been holded (defaults to the current time if not provided). Format: HH:MM #[clap(long)] time: Option, + + /// The Category of the activity you want to start + /// + /// You can use the separator you setup in the configuration file + /// to specify a subcategory. + #[clap(short, long)] + category: Option, } impl Runnable for HoldCmd { @@ -28,24 +40,49 @@ impl Runnable for HoldCmd { impl HoldCmd { /// Inner run implementation for the hold command pub fn inner_run(&self) -> Result<()> { - // TODO!: Implement hold command - // - // let HoldCmd { time } = self; + let HoldCmd { time, category } = self; + + let time = parse_time_from_user_input(time)?; + + let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + + // Get id from last activity that is not ended + let Some(active_activity) = activity_store.latest_active_activity()? else { + eyre::bail!("No activity to hold."); + }; + + let Some(parent_id) = active_activity.guid() else { + eyre::bail!( + "Activity {active_activity} has no valid ID, can't identify uniquely. Stopping." + ); + }; + + let activity_kind_opts = ActivityKindOptions::new(*parent_id); - // let time = parse_time_from_user_input(time)?; + let activity = Activity::builder() + .begin(time.into()) + .kind(ActivityKind::Intermission) + .description( + active_activity + .description() + .clone() + .unwrap_or_else(|| format!("Holding {active_activity}")), + ) + .category(category.clone()) + .activity_kind_options(activity_kind_opts) + .build(); - // let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + activity_store.setup_storage()?; - // activity_store.setup_storage()?; + let activity_id = activity_store.begin_activity(activity.clone())?; - // if let Some(held_activity) = activity_store - // .end_or_hold_activities(ActivityEndKind::Hold, time)? - // .try_into_hold()? - // { - // println!("Held {held_activity}"); - // } else { - // println!("No unfinished activities to hold."); - // } + if let Some(og_activity_id) = activity.guid() { + if activity_id == *og_activity_id { + activity_store.sync()?; + println!("Held {activity}"); + return Ok(()); + } + } Ok(()) }