diff --git a/Cargo.lock b/Cargo.lock index 65b193fd..83eeec53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1444,6 +1444,9 @@ name = "strum" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" diff --git a/config/projects.pace.toml b/config/projects.pace.toml index 3226c848..977d55a9 100644 --- a/config/projects.pace.toml +++ b/config/projects.pace.toml @@ -6,54 +6,39 @@ # TODO: IDs are optional and can be generated with the `pace id`(??? is that sensible, or something else?) command. # TODO: Add a `pace projects init` command to generate a new project configuration file. -[project] -id = "01HPY7F03JBVKSWDNTM2RSBXSJ" +[01HPY7F03JBVKSWDNTM2RSBXSJ] name = "Pace Project" description = "An example project managed with Pace." -root_tasks_file = "tasks.toml" # Path to the root tasks file +tasks-file = "./tasks.pace.toml" # Path to the root tasks file filters = ["*pace*"] # Optional: Define default filters for your project [defaults] # Optional: Define a default category for your project -category = { id = "01HPY7F03K4AZMA0DVW3A1M0TG", name = "Uncategorized", description = "Uncategorized Content" } - -[[categories]] -# Optional: Define categories for your project -id = "01HPY7F03K3JCWK5ZJJ02TT12G" -name = "Development" -description = "Development related tasks" -# Optional: Define subcategories for your category -# TODO: Add support for subcategories -subcategories = [ +categories = [ + { id = "01HPY7F03K4AZMA0DVW3A1M0TG", name = "Uncategorized", description = "Uncategorized Content" }, { id = "01HPY7F03K1H1A8A7S0K1ZCFX3", name = "Frontend", description = "Frontend Development" }, { id = "01HPY7F03KSF8TXQQWZDF63DFD", name = "Backend", description = "Backend Development" }, { id = "01HPY7F03KK3FGAJTHP2MBZA37", name = "Fullstack", description = "Fullstack Development" }, ] -[[categories]] -# Optional: Define categories for your project -id = "01HPY7F03KS1YHKT86BXSMMEMX" -name = "Design" -description = "Design related tasks" - -[[subprojects]] +[01HPY7F03K6TT2KKFEYVJT79ZB] # Optional: Define subprojects or directories with their own tasks -id = "01HPY7F03K6TT2KKFEYVJT79ZB" name = "Pace Subproject A" description = "" -tasks_file = "subproject-a/tasks.toml" +tasks-file = "subproject-a/tasks.toml" # Optional: Define default filters for your project filters = [ "*pace*, *subproject-a", ] +parent-id = "01HPY7F03JBVKSWDNTM2RSBXSJ" -[[subprojects]] +[01HPY7F03KF7VE3K9E51P0H1TB] # Optional: Define subprojects or directories with their own tasks -id = "01HPY7F03KF7VE3K9E51P0H1TB" name = "Pace Subproject B" description = "" -tasks_file = "subproject-b/tasks.toml" +tasks-file = "subproject-b/tasks.toml" # Optional: Define default filters for your project filters = [ "*pace*, *subproject-b", ] +parent-id = "01HPY7F03JBVKSWDNTM2RSBXSJ" diff --git a/config/tasks.pace.toml b/config/tasks.pace.toml index 23779ec6..794a9906 100644 --- a/config/tasks.pace.toml +++ b/config/tasks.pace.toml @@ -5,8 +5,7 @@ # TODO: IDs are optional and can be generated with the `pace id`(??? is that sensible, or something else?) command. # TODO: Add a `pace tasks init` command to generate a new project configuration file. -[[tasks]] -id = "01HPY7H596FT2R880SEKH7KN25" +[01HPY7H596FT2R880SEKH7KN25] title = "Implement feature X" created_at = "2024-02-04T12:34:56" finished_at = "2024-02-05T13:34:56" @@ -15,8 +14,7 @@ priority = "high" status = "pending" tags = ["feature", "X"] -[[tasks]] -id = "01HPY7F03JQ6SJF5C97H7G7E0E" +[01HPY7F03JQ6SJF5C97H7G7E0E] title = "Fix bug Y" created_at = "2024-02-06T12:34:56" description = "Detailed description of bug Y to be fixed." diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6dd93ede..60f0e1fc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -36,7 +36,7 @@ rayon = "1.8.1" rusqlite = { version = "0.31.0", features = ["bundled", "chrono", "uuid"], optional = true } serde = "1.0.197" serde_derive = "1.0.197" -strum = "0.26.1" +strum = { version = "0.26.1", features = ["derive"] } strum_macros = "0.26.1" thiserror = "1.0.57" toml = { version = "0.8.10", features = ["indexmap", "preserve_order"] } diff --git a/crates/core/src/commands.rs b/crates/core/src/commands.rs new file mode 100644 index 00000000..67a56b4d --- /dev/null +++ b/crates/core/src/commands.rs @@ -0,0 +1,24 @@ +pub mod hold; +pub mod resume; + +use getset::Getters; +use typed_builder::TypedBuilder; + +use crate::{HoldOptions, PaceDateTime}; + +/// Options for ending an activity +#[derive(Debug, Clone, PartialEq, TypedBuilder, Eq, Hash, Default, Getters)] +#[getset(get = "pub")] +pub struct EndOptions { + /// The end time + #[builder(default, setter(into))] + end_time: PaceDateTime, +} + +impl From for EndOptions { + fn from(hold_opts: HoldOptions) -> Self { + Self { + end_time: *hold_opts.begin_time(), + } + } +} diff --git a/crates/core/src/commands/hold.rs b/crates/core/src/commands/hold.rs new file mode 100644 index 00000000..7def9697 --- /dev/null +++ b/crates/core/src/commands/hold.rs @@ -0,0 +1,21 @@ +use getset::Getters; +use typed_builder::TypedBuilder; + +use crate::{IntermissionAction, PaceDateTime}; + +/// Options for holding an activity +#[derive(Debug, Clone, PartialEq, TypedBuilder, Eq, Hash, Default, Getters)] +#[getset(get = "pub")] +pub struct HoldOptions { + /// The action to take on the intermission + #[builder(default)] + action: IntermissionAction, + + /// The start time of the intermission + #[builder(default, setter(into))] + begin_time: PaceDateTime, + + /// The reason for holding the activity + #[builder(default, setter(into))] + reason: Option, +} diff --git a/crates/core/src/commands/resume.rs b/crates/core/src/commands/resume.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/core/src/commands/resume.rs @@ -0,0 +1 @@ + diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 43b2dfc2..572f88c1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -480,7 +480,7 @@ fn get_global_config_path() -> Option { #[cfg(test)] mod tests { - use crate::{domain::project::ProjectConfig, domain::task::TaskList, error::TestResult}; + use crate::error::TestResult; use super::*; use rstest::*; @@ -495,24 +495,4 @@ mod tests { Ok(()) } - - #[rstest] - fn test_parse_project_file_passes( - #[files("../../config/projects.pace.toml")] config_path: PathBuf, - ) -> TestResult<()> { - let toml_string = fs::read_to_string(config_path)?; - let _ = toml::from_str::(&toml_string)?; - - Ok(()) - } - - #[rstest] - fn test_parse_tasks_file_passes( - #[files("../../config/tasks.pace.toml")] config_path: PathBuf, - ) -> TestResult<()> { - let toml_string = fs::read_to_string(config_path)?; - let _ = toml::from_str::(&toml_string)?; - - Ok(()) - } } diff --git a/crates/core/src/domain/activity.rs b/crates/core/src/domain/activity.rs index 5263ee90..733ff082 100644 --- a/crates/core/src/domain/activity.rs +++ b/crates/core/src/domain/activity.rs @@ -1,6 +1,6 @@ //! Activity entity and business logic -use chrono::{Local, NaiveDateTime}; +use chrono::Local; use core::fmt::Formatter; use getset::{Getters, MutGetters, Setters}; use merge::Merge; @@ -11,10 +11,32 @@ use ulid::Ulid; use crate::{ calculate_duration, - domain::time::{duration_to_str, BeginDateTime, PaceDuration}, + domain::time::{duration_to_str, PaceDateTime, PaceDuration}, PaceResult, }; +#[derive(Debug, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq, Default)] +#[getset(get = "pub", get_mut = "pub")] +pub struct ActivityItem { + guid: ActivityGuid, + activity: Activity, +} + +impl From for ActivityItem { + fn from(activity: Activity) -> Self { + Self { + guid: ActivityGuid::default(), + activity, + } + } +} + +impl From<(ActivityGuid, Activity)> for ActivityItem { + fn from((guid, activity): (ActivityGuid, Activity)) -> Self { + Self { guid, activity } + } +} + /// The kind of activity a user can track #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash, Copy)] #[serde(rename_all = "kebab-case")] @@ -77,6 +99,16 @@ impl ActivityKind { pub fn is_pomodoro_intermission(&self) -> bool { matches!(self, Self::PomodoroIntermission) } + + pub fn to_symbol(&self) -> &'static str { + match self { + Self::Activity => "πŸ“†", + Self::Task => "πŸ“‹", + Self::Intermission => "⏸️", + Self::PomodoroWork => "πŸ…β²οΈ", + Self::PomodoroIntermission => "πŸ…βΈοΈ", + } + } } /// The cycle of pomodoro activity a user can track @@ -95,18 +127,21 @@ enum PomodoroCycle { /// /// The activity entity is used to store and manage an activity #[derive( - Debug, Serialize, Deserialize, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq, + Debug, + Serialize, + Deserialize, + TypedBuilder, + Getters, + Setters, + MutGetters, + Clone, + Eq, + PartialEq, + Default, )] #[getset(get = "pub")] #[derive(Merge)] pub struct Activity { - /// The activity's unique identifier - #[builder(default = Some(ActivityGuid::default()), setter(strip_option, into))] - #[getset(get_copy, get_mut = "pub")] - #[serde(rename = "id", skip_serializing_if = "Option::is_none")] - #[merge(skip)] - guid: Option, - /// The category of the activity // TODO: We had it as a struct before with an ID, but it's questionable if we should go for this // TODO: Reconsider when we implement the project management part @@ -128,12 +163,14 @@ pub struct Activity { /// The start date and time of the activity #[builder(default, setter(into))] #[getset(get = "pub")] + // TODO: Should the begin time be updatable? #[merge(skip)] - begin: BeginDateTime, + begin: PaceDateTime, #[builder(default)] #[serde(flatten, skip_serializing_if = "Option::is_none")] #[getset(get = "pub", get_mut = "pub")] + #[merge(strategy = crate::util::overwrite_left_with_right)] activity_end_options: Option, /// The kind of activity @@ -144,6 +181,7 @@ pub struct Activity { /// Optional attributes for the activity kind #[builder(default, setter(strip_option))] #[serde(flatten, skip_serializing_if = "Option::is_none")] + #[merge(strategy = crate::util::overwrite_left_with_right)] activity_kind_options: Option, // TODO: How to better support subcategories @@ -159,7 +197,13 @@ pub struct Activity { /// The pomodoro cycle of the activity #[builder(default, setter(strip_option))] #[serde(skip_serializing_if = "Option::is_none")] + #[merge(strategy = crate::util::overwrite_left_with_right)] pomodoro_cycle_options: Option, + + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + #[builder(default = true)] + #[merge(strategy = merge::bool::overwrite_false)] + active: bool, } #[derive( @@ -170,7 +214,7 @@ pub struct ActivityEndOptions { /// The end date and time of the activity #[builder(default)] #[getset(get = "pub")] - end: NaiveDateTime, + end: PaceDateTime, /// The duration of the activity #[builder(default)] @@ -179,7 +223,7 @@ pub struct ActivityEndOptions { } impl ActivityEndOptions { - pub fn new(end: NaiveDateTime, duration: PaceDuration) -> Self { + pub fn new(end: PaceDateTime, duration: PaceDuration) -> Self { Self { end, duration } } } @@ -197,7 +241,7 @@ impl ActivityEndOptions { PartialEq, Default, )] -#[getset(get = "pub")] +#[getset(get = "pub", set = "pub", get_mut = "pub")] #[derive(Merge)] #[serde(rename_all = "kebab-case")] pub struct ActivityKindOptions { @@ -207,32 +251,24 @@ pub struct ActivityKindOptions { #[serde(default, skip_serializing_if = "std::ops::Not::not")] #[builder(default = false)] - #[getset(get = "pub", get_mut = "pub")] #[merge(strategy = merge::bool::overwrite_false)] - is_archived: bool, + archived: bool, } impl ActivityKindOptions { - pub fn new(parent_id: impl Into>) -> Self { + pub fn with_parent_id(parent_id: ActivityGuid) -> Self { Self { parent_id: parent_id.into(), - is_archived: false, + ..Self::default() } } -} -impl Default for Activity { - fn default() -> Self { - Self { - guid: Some(ActivityGuid::default()), - category: Some("Uncategorized".to_string()), - description: Some("This is an example activity".to_string()), - begin: BeginDateTime::default(), - kind: ActivityKind::Activity, - pomodoro_cycle_options: None, - activity_kind_options: None, - activity_end_options: None, - } + pub fn is_archived(&self) -> bool { + *self.archived() + } + + pub fn archive(&mut self) { + self.archived = true; } } @@ -266,7 +302,8 @@ impl Display for Activity { write!( f, - "Activity: \"{}\" ({}) started {}", + "{} Activity: \"{}\" ({}) started {}", + self.kind.to_symbol(), self.description().as_ref().unwrap_or(&nop_desc), self.category().as_ref().unwrap_or(&nop_cat), rel_time, @@ -297,6 +334,37 @@ impl Activity { pub fn is_active(&self) -> bool { self.activity_end_options().is_none() && (!self.kind.is_intermission() || !self.kind.is_pomodoro_intermission()) + && *self.active() + } + + /// Make the activity active + pub fn make_active(&mut self) { + self.active = true; + } + + /// Make the activity inactive + pub fn make_inactive(&mut self) { + self.active = false; + } + + /// Archive the activity + /// This is only possible if the activity is not active and has ended + pub fn archive(&mut self) { + if !self.is_active() && self.has_ended() { + self.activity_kind_options + .get_or_insert_with(ActivityKindOptions::default) + .archive(); + } + } + + /// Unarchive the activity + /// This is only possible if the activity is archived + pub fn unarchive(&mut self) { + if self.is_archived() { + self.activity_kind_options + .get_or_insert_with(ActivityKindOptions::default) + .archived = false; + } } /// If the activity is an active intermission @@ -304,13 +372,24 @@ impl Activity { pub fn is_active_intermission(&self) -> bool { self.activity_end_options().is_none() && (self.kind.is_intermission() || self.kind.is_pomodoro_intermission()) + && *self.active() + } + + /// If the activity is archived + #[must_use] + pub fn is_archived(&self) -> bool { + self.activity_kind_options + .as_ref() + .map(|opts| opts.is_archived()) + .unwrap_or(false) } - /// If the activity has ended + /// If the activity has ended and is not archived #[must_use] pub fn has_ended(&self) -> bool { self.activity_end_options().is_some() && (!self.kind.is_intermission() || !self.kind.is_pomodoro_intermission()) + && !self.is_archived() } /// End the activity @@ -321,6 +400,7 @@ impl Activity { /// * `duration` - The [`PaceDuration`] of the activity pub fn end_activity(&mut self, end_opts: ActivityEndOptions) { self.activity_end_options = Some(end_opts); + self.make_inactive(); } /// End the activity with a given end date and time @@ -339,8 +419,8 @@ impl Activity { /// Returns `Ok(())` if the activity is ended successfully pub fn end_activity_with_duration_calc( &mut self, - begin: BeginDateTime, - end: NaiveDateTime, + begin: PaceDateTime, + end: PaceDateTime, ) -> PaceResult<()> { let end_opts = ActivityEndOptions::new(end, calculate_duration(&begin, end)?); self.end_activity(end_opts); @@ -366,12 +446,13 @@ mod tests { use std::str::FromStr; + use chrono::NaiveDateTime; + use super::*; #[test] fn test_parse_single_toml_activity_passes() { let toml = r#" - id = "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" category = "Work" description = "This is an example activity" end = "2021-08-01T12:00:00" @@ -382,11 +463,6 @@ mod tests { let activity: Activity = toml::from_str(toml).unwrap(); - assert_eq!( - activity.guid.unwrap().to_string(), - "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" - ); - assert_eq!(activity.category.as_ref().unwrap(), "Work"); assert_eq!( @@ -398,12 +474,14 @@ mod tests { assert_eq!( end, - NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + PaceDateTime::from( + NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + ) ); assert_eq!( activity.begin, - BeginDateTime::from( + PaceDateTime::from( NaiveDateTime::parse_from_str("2021-08-01T10:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() ) ); @@ -416,7 +494,6 @@ mod tests { #[test] fn test_parse_single_toml_intermission_passes() { let toml = r#" - id = "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" end = "2021-08-01T12:00:00" begin = "2021-08-01T10:00:00" duration = 50 @@ -426,23 +503,20 @@ mod tests { let activity: Activity = toml::from_str(toml).unwrap(); - assert_eq!( - activity.guid.unwrap().to_string(), - "01F9Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3Z3" - ); - let ActivityEndOptions { end, duration } = activity.activity_end_options().clone().unwrap(); assert_eq!( end, - NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + PaceDateTime::from( + NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() + ) ); assert_eq!(duration, PaceDuration::from_str("50").unwrap()); assert_eq!( activity.begin, - BeginDateTime::from( + PaceDateTime::from( NaiveDateTime::parse_from_str("2021-08-01T10:00:00", "%Y-%m-%dT%H:%M:%S").unwrap() ) ); diff --git a/crates/core/src/domain/activity_log.rs b/crates/core/src/domain/activity_log.rs index db371145..6f64d25e 100644 --- a/crates/core/src/domain/activity_log.rs +++ b/crates/core/src/domain/activity_log.rs @@ -1,31 +1,61 @@ use getset::{Getters, MutGetters}; +use rayon::iter::{FromParallelIterator, IntoParallelIterator}; use serde_derive::{Deserialize, Serialize}; -use std::collections::VecDeque; +use std::collections::BTreeMap; -use crate::domain::activity::Activity; +use crate::{domain::activity::Activity, ActivityGuid, ActivityItem}; /// The activity log entity /// /// The activity log entity is used to store and manage activities -#[derive(Debug, Clone, Serialize, Deserialize, Getters, MutGetters, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Getters, MutGetters, Default, PartialEq, Eq)] +#[getset(get = "pub", get_mut = "pub")] pub struct ActivityLog { - #[getset(get = "pub", get_mut = "pub")] - activities: VecDeque, + /// The activities in the log + #[serde(flatten)] + activities: BTreeMap, +} + +impl std::ops::DerefMut for ActivityLog { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.activities + } } impl std::ops::Deref for ActivityLog { - type Target = VecDeque; + type Target = BTreeMap; fn deref(&self) -> &Self::Target { &self.activities } } -impl FromIterator for ActivityLog { - fn from_iter>(iter: T) -> Self { - Self { - activities: iter.into_iter().collect::>(), - } +impl FromIterator for ActivityLog { + fn from_iter>(iter: T) -> Self { + let iter = iter + .into_iter() + .map(|item| (*item.guid(), item.activity().clone())); + let map = BTreeMap::from_iter(iter); + + Self { activities: map } + } +} + +impl FromIterator<(ActivityGuid, Activity)> for ActivityLog { + fn from_iter>(iter: T) -> Self { + let map = BTreeMap::from_iter(iter); + + Self { activities: map } + } +} + +impl FromParallelIterator<(ActivityGuid, Activity)> for ActivityLog { + fn from_par_iter>( + par_iter: T, + ) -> Self { + let map = BTreeMap::from_par_iter(par_iter); + + Self { activities: map } } } @@ -47,4 +77,92 @@ mod tests { Ok(()) } + + #[rstest] + fn test_parse_activity_log_fails() { + let toml_string = r#" + [01HPY70577H375FDKT9XXAT7VB] + name = "Test Activity" + guid = "test-activity" + start_time = "2021-01-01T00:00:00Z" + end_time = "2021-01-01T00:00:00Z" + duration = -5 + tags = ["test"] + "#; + + let result = toml::from_str::(toml_string); + assert!(result.is_err()); + } + + #[rstest] + fn test_parse_activity_log_empty() { + let toml_string = r#""#; + + let result = toml::from_str::(toml_string); + assert!(result.is_ok()); + } + + #[rstest] + fn test_parse_activity_log_single_activity() { + let toml_string = r#" + [01HPY70577H375FDKT9XXAT7VB] + category = "development::pace" + description = "Implemented the login feature." + end = "2024-02-04T10:30:00" + begin = "2024-02-04T09:00:00" + duration = 5400 + kind = "activity" + "#; + + let result = toml::from_str::(toml_string); + assert!(result.is_ok()); + } + + #[rstest] + fn test_parse_activity_log_multiple_activities() { + let toml_string = r#" + [01HQH12254TEXQ16WCR95YZ1SN] + category = "development::pace" + description = "Implemented the login feature." + end = "2024-02-04T10:30:00" + begin = "2024-02-04T09:00:00" + duration = 5400 + kind = "activity" + + [01HQH129DHAWRKQG6NM13NH6MH] + category = "development::pace" + description = "Implemented the login feature." + end = "2024-02-04T10:30:00" + begin = "2024-02-04T09:00:00" + duration = 5400 + kind = "activity" + "#; + + let result = toml::from_str::(toml_string); + assert!(result.is_ok()); + } + + #[rstest] + fn test_parse_activity_log_multiple_activities_with_same_id_fails() { + let toml_string = r#" + [01HPY70577H375FDKT9XXAT7VB] + category = "development::pace" + description = "Implemented the login feature." + end = "2024-02-04T10:30:00" + begin = "2024-02-04T09:00:00" + duration = 5400 + kind = "activity" + + [01HPY70577H375FDKT9XXAT7VB] + category = "development::pace" + description = "Implemented the login feature." + end = "2024-02-04T10:30:00" + begin = "2024-02-04T09:00:00" + duration = 5400 + kind = "activity" + "#; + + let result = toml::from_str::(toml_string); + assert!(result.is_err()); + } } diff --git a/crates/core/src/domain/filter.rs b/crates/core/src/domain/filter.rs index ab26abfc..7a8a116d 100644 --- a/crates/core/src/domain/filter.rs +++ b/crates/core/src/domain/filter.rs @@ -1,11 +1,15 @@ -use crate::domain::activity_log::ActivityLog; +use crate::ActivityGuid; +use strum::EnumIter; /// Filter for activities -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, EnumIter)] pub enum ActivityFilter { - /// All activities + /// Everything, activities, intermissions, archived, and ended #[default] - All, + Everything, + + /// Only activities, no intermissions + OnlyActivities, /// Active, currently running activities Active, @@ -21,30 +25,34 @@ pub enum ActivityFilter { } /// Filtered activities -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum FilteredActivities { - /// All activities - All(ActivityLog), + /// Everything, activities, intermissions, archived, and ended + Everything(Vec), + + /// Only activities, no intermissions + OnlyActivities(Vec), /// Active, currently running activities - Active(ActivityLog), + Active(Vec), /// Active, currently running activities - ActiveIntermission(ActivityLog), + ActiveIntermission(Vec), /// Archived activities - Archived(ActivityLog), + Archived(Vec), /// Activities that have ended - Ended(ActivityLog), + Ended(Vec), } impl FilteredActivities { - /// Convert the filtered activities into an activity log + /// Convert the filtered activities into a vector of activity GUIDs #[must_use] - pub fn into_log(self) -> ActivityLog { + pub fn into_vec(self) -> Vec { match self { - Self::All(activities) + Self::Everything(activities) + | Self::OnlyActivities(activities) | Self::Active(activities) | Self::Archived(activities) | Self::Ended(activities) diff --git a/crates/core/src/domain/intermission.rs b/crates/core/src/domain/intermission.rs index c9a2b7de..674f5545 100644 --- a/crates/core/src/domain/intermission.rs +++ b/crates/core/src/domain/intermission.rs @@ -1,5 +1,31 @@ //! Intermission entity and business logic -use crate::Activity; +use serde_derive::{Deserialize, Serialize}; -impl Activity {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "snake_case")] +pub enum IntermissionAction { + /// Extends the ongoing intermission + #[default] + Extend, + /// Starts a new intermission + New, +} + +impl IntermissionAction { + /// Returns `true` if the intermission action is [`Extend`]. + /// + /// [`Extend`]: IntermissionAction::Extend + #[must_use] + pub fn is_extend(&self) -> bool { + matches!(self, Self::Extend) + } + + /// Returns `true` if the intermission action is [`New`]. + /// + /// [`New`]: IntermissionAction::New + #[must_use] + pub fn is_new(&self) -> bool { + matches!(self, Self::New) + } +} diff --git a/crates/core/src/domain/project.rs b/crates/core/src/domain/project.rs index 36413f98..83fd5445 100644 --- a/crates/core/src/domain/project.rs +++ b/crates/core/src/domain/project.rs @@ -1,18 +1,65 @@ +use std::{ + collections::BTreeMap, + fmt::{Display, Formatter}, + path::PathBuf, +}; + use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; use ulid::Ulid; -use crate::domain::task::Task; +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ProjectList { + /// The tasks in the list + #[serde(flatten)] + projects: BTreeMap, + defaults: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DefaultOptions { + categories: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Category { + id: Ulid, + name: String, + description: Option, +} + +#[derive(Debug, TypedBuilder, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub struct Project { + name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + + tasks_file: PathBuf, + + filters: Option>, -#[derive(Serialize, Deserialize, Debug)] -pub struct ProjectConfig { - project: Project, - subprojects: Vec, + #[serde(flatten)] + subproject_options: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, TypedBuilder, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub struct SubprojectOptions { + parent_id: Option, +} + +/// The unique identifier of an activity +#[derive(Debug, Clone, Serialize, Deserialize, Ord, PartialEq, PartialOrd, Eq, Copy, Hash)] pub struct ProjectGuid(Ulid); +impl Display for ProjectGuid { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + impl Default for ProjectGuid { fn default() -> Self { Self(Ulid::new()) @@ -34,34 +81,22 @@ impl rusqlite::types::ToSql for ProjectGuid { } } -#[derive(Debug, TypedBuilder, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Project { - #[builder(default, setter(strip_option))] - #[serde(rename = "id", skip_serializing_if = "Option::is_none")] - guid: Option, - - name: String, - - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, +#[cfg(test)] +mod tests { - // TODO: Broken Eq impl - // #[serde(skip)] - // next_actions: BinaryHeap, - #[serde(skip_serializing_if = "Option::is_none")] - finished: Option>, + use crate::error::TestResult; - #[serde(skip_serializing_if = "Option::is_none")] - archived: Option>, + use super::*; + use rstest::*; + use std::{fs, path::PathBuf}; - root_tasks_file: String, -} + #[rstest] + fn test_parse_project_file_passes( + #[files("../../config/projects.pace.toml")] config_path: PathBuf, + ) -> TestResult<()> { + let toml_string = fs::read_to_string(config_path)?; + let _ = toml::from_str::(&toml_string)?; -#[derive(Serialize, Deserialize, Debug, TypedBuilder)] -struct Subproject { - #[builder(default, setter(strip_option))] - id: Option, - name: String, - description: String, - tasks_file: String, + Ok(()) + } } diff --git a/crates/core/src/domain/task.rs b/crates/core/src/domain/task.rs index 32551063..83e1200e 100644 --- a/crates/core/src/domain/task.rs +++ b/crates/core/src/domain/task.rs @@ -1,5 +1,10 @@ //! Task entity and business logic +use std::{ + collections::BTreeMap, + fmt::{Display, Formatter}, +}; + use chrono::NaiveDateTime; use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; @@ -7,15 +12,6 @@ use ulid::Ulid; use crate::domain::{priority::ItemPriorityKind, status::ItemStatus}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, Clone)] -pub struct TaskId(Ulid); - -impl Default for TaskId { - fn default() -> Self { - Self(Ulid::new()) - } -} - #[derive(Debug, TypedBuilder, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct Task { created_at: NaiveDateTime, @@ -26,10 +22,6 @@ pub struct Task { #[serde(skip_serializing_if = "Option::is_none")] finished_at: Option, - #[builder(default, setter(strip_option))] - #[serde(rename = "id", skip_serializing_if = "Option::is_none")] - guid: Option, - priority: ItemPriorityKind, status: ItemStatus, @@ -42,11 +34,13 @@ pub struct Task { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TaskList { - tasks: Vec, + /// The tasks in the list + #[serde(flatten)] + tasks: BTreeMap, } #[cfg(feature = "sqlite")] -impl rusqlite::types::FromSql for TaskId { +impl rusqlite::types::FromSql for TaskGuid { fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { let bytes = <[u8; 16]>::column_result(value)?; Ok(Self(Ulid::from(u128::from_be_bytes(bytes)))) @@ -54,8 +48,44 @@ impl rusqlite::types::FromSql for TaskId { } #[cfg(feature = "sqlite")] -impl rusqlite::types::ToSql for TaskId { +impl rusqlite::types::ToSql for TaskGuid { fn to_sql(&self) -> rusqlite::Result> { Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string())) } } + +/// The unique identifier of an activity +#[derive(Debug, Clone, Serialize, Deserialize, Ord, PartialEq, PartialOrd, Eq, Copy, Hash)] +pub struct TaskGuid(Ulid); + +impl Display for TaskGuid { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Default for TaskGuid { + fn default() -> Self { + Self(Ulid::new()) + } +} + +#[cfg(test)] +mod tests { + + use crate::error::TestResult; + + use super::*; + use rstest::*; + use std::{fs, path::PathBuf}; + + #[rstest] + fn test_parse_tasks_file_passes( + #[files("../../config/tasks.pace.toml")] config_path: PathBuf, + ) -> TestResult<()> { + let toml_string = fs::read_to_string(config_path)?; + let _ = toml::from_str::(&toml_string)?; + + Ok(()) + } +} diff --git a/crates/core/src/domain/time.rs b/crates/core/src/domain/time.rs index 10d0ad00..681c5cef 100644 --- a/crates/core/src/domain/time.rs +++ b/crates/core/src/domain/time.rs @@ -141,11 +141,11 @@ impl From for PaceDuration { } } -/// Wrapper for the start time of an activity to implement default -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Eq, PartialEq)] -pub struct BeginDateTime(NaiveDateTime); +/// Wrapper for the start and end time of an activity to implement default +#[derive(Debug, Serialize, Deserialize, Hash, Clone, Copy, Eq, PartialEq)] +pub struct PaceDateTime(NaiveDateTime); -impl BeginDateTime { +impl PaceDateTime { pub fn new(time: NaiveDateTime) -> Self { Self(time) } @@ -158,28 +158,33 @@ impl BeginDateTime { pub fn and_local_timezone(&self, tz: Tz) -> chrono::LocalResult> { self.0.and_local_timezone(tz) } + + /// Alias for `Local::now()` and used by `Self::default()` + pub fn now() -> Self { + Self(Local::now().naive_local().round_subsecs(0)) + } } -impl Display for BeginDateTime { +impl Display for PaceDateTime { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { ::fmt(&self.0, f) } } // Default BeginTime to now -impl Default for BeginDateTime { +impl Default for PaceDateTime { fn default() -> Self { - Self(Local::now().naive_local().round_subsecs(0)) + Self::now() } } -impl From for BeginDateTime { +impl From for PaceDateTime { fn from(time: NaiveDateTime) -> Self { Self(time) } } -impl From> for BeginDateTime { +impl From> for PaceDateTime { fn from(time: Option) -> Self { match time { Some(time) => Self(time), @@ -201,8 +206,9 @@ impl From> for BeginDateTime { /// # Returns /// /// Returns the duration of the activity -pub fn calculate_duration(begin: &BeginDateTime, end: NaiveDateTime) -> PaceResult { +pub fn calculate_duration(begin: &PaceDateTime, end: PaceDateTime) -> PaceResult { let duration = end + .0 .signed_duration_since(begin.naive_date_time()) .to_std()?; @@ -251,7 +257,7 @@ mod tests { #[test] fn test_calculate_duration_passes() { - let begin = BeginDateTime::new(NaiveDateTime::new( + let begin = PaceDateTime::new(NaiveDateTime::new( NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), )); @@ -260,13 +266,13 @@ mod tests { NaiveTime::from_hms_opt(0, 0, 1).expect("Invalid date"), ); - let duration = calculate_duration(&begin, end).expect("Duration calculation failed"); + let duration = calculate_duration(&begin, end.into()).expect("Duration calculation failed"); assert_eq!(duration, Duration::from_secs(1).into()); } #[test] fn test_calculate_duration_fails() { - let begin = BeginDateTime::new(NaiveDateTime::new( + let begin = PaceDateTime::new(NaiveDateTime::new( NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), NaiveTime::from_hms_opt(0, 0, 1).expect("Invalid date"), )); @@ -275,7 +281,7 @@ mod tests { NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), ); - let duration = calculate_duration(&begin, end); + let duration = calculate_duration(&begin, end.into()); assert!(duration.is_err()); } @@ -299,8 +305,8 @@ mod tests { NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), ); - let result = BeginDateTime::new(time); - assert_eq!(result, BeginDateTime(time)); + let result = PaceDateTime::new(time); + assert_eq!(result, PaceDateTime(time)); } #[test] @@ -309,17 +315,17 @@ mod tests { NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), ); - let begin_date_time = BeginDateTime::new(time); + let begin_date_time = PaceDateTime::new(time); let result = begin_date_time.naive_date_time(); assert_eq!(result, time); } #[test] fn test_begin_date_time_default_passes() { - let result = BeginDateTime::default(); + let result = PaceDateTime::default(); assert_eq!( result, - BeginDateTime(Local::now().naive_local().round_subsecs(0)) + PaceDateTime(Local::now().naive_local().round_subsecs(0)) ); } @@ -329,8 +335,8 @@ mod tests { NaiveDate::from_ymd_opt(2021, 1, 1).expect("Invalid date"), NaiveTime::from_hms_opt(0, 0, 0).expect("Invalid date"), ); - let result = BeginDateTime::from(time); - assert_eq!(result, BeginDateTime(time)); + let result = PaceDateTime::from(time); + assert_eq!(result, PaceDateTime(time)); } #[test] diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index d04ed7a8..09938c2b 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -4,7 +4,7 @@ use displaydoc::Display; use std::{error::Error, path::PathBuf}; use thiserror::Error; -use crate::domain::activity::ActivityGuid; +use crate::{domain::activity::ActivityGuid, Activity}; /// Result type that is being returned from test functions and methods that can fail and thus have errors. pub type TestResult = Result>; @@ -102,8 +102,8 @@ pub enum ActivityLogErrorKind { NoActivityToHold, /// Failed to unwrap Arc ArcUnwrapFailed, - /// Mutex lock failed, it has been poisoned - MutexHasBeenPoisoned, + /// RwLock locking failed, it has been poisoned + RwLockHasBeenPoisoned, /// There are no unfinished activities to end NoUnfinishedActivities, /// There is no cache to sync @@ -120,8 +120,14 @@ pub enum ActivityLogErrorKind { ActivityIdAlreadyInUse(ActivityGuid), /// Failed to parse duration '{0}' from activity log, please use only numbers >= 0 ParsingDurationFailed(String), - /// Activity in the ActivityLog has a different id than the one provided + /// Activity in the ActivityLog has a different id than the one provided: {0} != {1} ActivityIdMismatch(ActivityGuid, ActivityGuid), + /// Activity already has an intermission: {0} + ActivityAlreadyHasIntermission(Box), + /// There have been some activities that have not been ended + ActivityNotEnded, + /// No active activity found + NoActiveActivityFound, } trait PaceErrorMarker: Error {} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 74ded18c..77d079ec 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,5 +1,6 @@ //! # Pace Core +pub(crate) mod commands; pub(crate) mod config; pub(crate) mod domain; pub(crate) mod error; @@ -12,6 +13,7 @@ pub use toml; // Public API pub use crate::{ + commands::{hold::HoldOptions, EndOptions}, config::{ find_root_config_file_path, find_root_project_file, get_activity_log_paths, get_config_paths, get_home_activity_log_path, get_home_config_path, AutoArchivalConfig, @@ -19,12 +21,16 @@ pub use crate::{ ReviewConfig, }, domain::{ - activity::{Activity, ActivityEndOptions, ActivityGuid, ActivityKind, ActivityKindOptions}, + activity::{ + Activity, ActivityEndOptions, ActivityGuid, ActivityItem, ActivityKind, + ActivityKindOptions, + }, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, + intermission::IntermissionAction, time::{ calculate_duration, duration_to_str, extract_time_or_now, parse_time_from_user_input, - BeginDateTime, PaceDuration, + PaceDateTime, PaceDuration, }, }, error::{PaceError, PaceErrorKind, PaceOptResult, PaceResult, TestResult}, diff --git a/crates/core/src/service/activity_store.rs b/crates/core/src/service/activity_store.rs index 08b55739..80b9de24 100644 --- a/crates/core/src/service/activity_store.rs +++ b/crates/core/src/service/activity_store.rs @@ -1,11 +1,14 @@ -use std::collections::{BTreeMap, HashSet, VecDeque}; +use std::{ + collections::{BTreeMap, HashSet, VecDeque}, + sync::Arc, +}; use chrono::{prelude::NaiveDate, NaiveDateTime}; use serde_derive::{Deserialize, Serialize}; use crate::{ domain::{ - activity::{Activity, ActivityGuid}, + activity::{Activity, ActivityGuid, ActivityItem}, activity_log::ActivityLog, filter::FilteredActivities, }, @@ -14,6 +17,7 @@ use crate::{ ActivityQuerying, ActivityReadOps, ActivityStateManagement, ActivityStorage, ActivityWriteOps, SyncStorage, }, + EndOptions, HoldOptions, }; /// The activity store entity @@ -22,7 +26,7 @@ pub struct ActivityStore { cache: ActivityStoreCache, /// The storage backend - storage: Box, + storage: Arc, } /// TODO: Optimization for later to make lookup faster @@ -36,7 +40,7 @@ struct ActivityStoreCache { impl ActivityStore { /// Create a new `ActivityStore` #[must_use] - pub fn new(storage: Box) -> Self { + pub fn new(storage: Arc) -> Self { let store = Self { cache: ActivityStoreCache::default(), storage, @@ -63,7 +67,7 @@ impl SyncStorage for ActivityStore { } impl ActivityReadOps for ActivityStore { - fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.storage.read_activity(activity_id) } @@ -76,7 +80,7 @@ impl ActivityReadOps for ActivityStore { } impl ActivityWriteOps for ActivityStore { - fn create_activity(&self, activity: Activity) -> PaceResult { + fn create_activity(&self, activity: Activity) -> PaceResult { self.storage.create_activity(activity) } @@ -84,51 +88,56 @@ impl ActivityWriteOps for ActivityStore { &self, activity_id: ActivityGuid, activity: Activity, - ) -> PaceResult { + ) -> PaceResult { self.storage.update_activity(activity_id, activity) } - fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.storage.delete_activity(activity_id) } } impl ActivityStateManagement for ActivityStore { - fn begin_activity(&self, activity: Activity) -> PaceResult { + fn begin_activity(&self, activity: Activity) -> PaceResult { self.storage.begin_activity(activity) } fn end_single_activity( &self, activity_id: ActivityGuid, - end_time: Option, - ) -> PaceResult { - self.storage.end_single_activity(activity_id, end_time) + end_opts: EndOptions, + ) -> PaceResult { + self.storage.end_single_activity(activity_id, end_opts) } fn end_all_unfinished_activities( &self, - time: Option, - ) -> PaceOptResult> { - self.storage.end_all_unfinished_activities(time) + end_opts: EndOptions, + ) -> PaceOptResult> { + self.storage.end_all_unfinished_activities(end_opts) } - fn end_last_unfinished_activity(&self, time: Option) -> PaceOptResult { - self.storage.end_last_unfinished_activity(time) + fn end_last_unfinished_activity(&self, end_opts: EndOptions) -> PaceOptResult { + self.storage.end_last_unfinished_activity(end_opts) } - fn hold_last_unfinished_activity( - &self, - hold_time: Option, - ) -> PaceOptResult { - self.storage.hold_last_unfinished_activity(hold_time) + fn hold_last_unfinished_activity(&self, hold_opts: HoldOptions) -> PaceOptResult { + self.storage.hold_last_unfinished_activity(hold_opts) } fn end_all_active_intermissions( &self, - end_time: Option, - ) -> PaceOptResult> { - self.storage.end_all_active_intermissions(end_time) + end_opts: EndOptions, + ) -> PaceOptResult> { + self.storage.end_all_active_intermissions(end_opts) + } + + fn resume_activity( + &self, + activity_id: Option, + resume_time: Option, + ) -> PaceOptResult { + self.storage.resume_activity(activity_id, resume_time) } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index e0e447a5..ed9eab0a 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -1,12 +1,11 @@ -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Arc}; use chrono::{NaiveDate, NaiveDateTime}; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use crate::{ config::{ActivityLogStorageKind, PaceConfig}, domain::{ - activity::{Activity, ActivityGuid}, + activity::{Activity, ActivityGuid, ActivityItem}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, review::ActivityStats, @@ -14,6 +13,7 @@ use crate::{ }, error::{PaceErrorKind, PaceOptResult, PaceResult}, storage::file::TomlActivityStorage, + EndOptions, HoldOptions, }; /// A type of storage that can be synced to a persistent medium - a file @@ -38,20 +38,20 @@ pub mod in_memory; /// # Returns /// /// The storage backend. -pub fn get_storage_from_config(config: &PaceConfig) -> PaceResult> { - let storage: Box = match config +pub fn get_storage_from_config(config: &PaceConfig) -> PaceResult> { + let storage: Arc = match config .general() .activity_log_options() .activity_log_storage() { - ActivityLogStorageKind::File => Box::new(TomlActivityStorage::new( + ActivityLogStorageKind::File => Arc::new(TomlActivityStorage::new( config.general().activity_log_options().activity_log_path(), )?), ActivityLogStorageKind::Database => { return Err(PaceErrorKind::DatabaseStorageNotImplemented.into()) } #[cfg(test)] - ActivityLogStorageKind::InMemory => Box::new(in_memory::InMemoryActivityStorage::new()), + ActivityLogStorageKind::InMemory => Arc::new(in_memory::InMemoryActivityStorage::new()), }; Ok(storage) @@ -118,7 +118,7 @@ pub trait ActivityReadOps { /// # Returns /// /// The activity that was read from the storage backend. If no activity is found, it should return `Ok(None)`. - fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult; + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult; /// List activities from the storage backend. /// @@ -154,7 +154,7 @@ pub trait ActivityWriteOps: ActivityReadOps { /// # Returns /// /// If the activity was created successfully it should return the ID of the created activity. - fn create_activity(&self, activity: Activity) -> PaceResult; + fn create_activity(&self, activity: Activity) -> PaceResult; /// Update an existing activity in the storage backend. /// @@ -178,12 +178,13 @@ pub trait ActivityWriteOps: ActivityReadOps { /// /// # Returns /// - /// If the activity was updated successfully it should return the activity before it was updated. + /// If the activity was updated successfully it should return the original activity before it was updated. fn update_activity( &self, + // TODO!: Refactor to UpdateOptions and ActivityItem activity_id: ActivityGuid, - activity: Activity, - ) -> PaceResult; + updated_activity: Activity, + ) -> PaceResult; /// Delete an activity from the storage backend. /// @@ -198,7 +199,7 @@ pub trait ActivityWriteOps: ActivityReadOps { /// # Returns /// /// If the activity was deleted successfully it should return the activity that was deleted. - fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult; + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult; } /// Managing Activity State @@ -221,7 +222,7 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity /// # Returns /// /// If the activity was started successfully it should return the ID of the started activity. - fn begin_activity(&self, activity: Activity) -> PaceResult { + fn begin_activity(&self, activity: Activity) -> PaceResult { self.create_activity(activity) } @@ -230,7 +231,7 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity /// # Arguments /// /// * `activity_id` - The ID of the activity to end. - /// * `end_time` - The time (HH:MM) to end the activity at. If `None`, the current time is used. + /// * `end_opts` - The options to end the activity. /// /// # Errors /// @@ -242,14 +243,14 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity fn end_single_activity( &self, activity_id: ActivityGuid, - end_time: Option, - ) -> PaceResult; + end_opts: EndOptions, + ) -> PaceResult; /// End all unfinished activities in the storage backend. /// /// # Arguments /// - /// * `time` - The time (HH:MM) to end the activities at. If `None`, the current time is used. + /// * `end_opts` - The options to end the activities. /// /// # Errors /// @@ -260,14 +261,14 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity /// A collection of the activities that were ended. Returns Ok(None) if no activities were ended. fn end_all_unfinished_activities( &self, - end_time: Option, - ) -> PaceOptResult>; + end_opts: EndOptions, + ) -> PaceOptResult>; /// End all active intermissions in the storage backend. /// /// # Arguments /// - /// * `time` - The time (HH:MM) to end the intermissions at. If `None`, the current time is used. + /// * `end_opts` - The options to end the intermissions. /// /// # Errors /// @@ -278,14 +279,14 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity /// A collection of the intermissions that were ended. Returns Ok(None) if no intermissions were ended. fn end_all_active_intermissions( &self, - end_time: Option, - ) -> PaceOptResult>; + end_opts: EndOptions, + ) -> PaceOptResult>; /// End the last unfinished activity in the storage backend. /// /// # Arguments /// - /// * `time` - The time (HH:MM) to end the activity at. If `None`, the current time is used. + /// * `end_opts` - The options to end the activity. /// /// # Errors /// @@ -294,16 +295,13 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity /// # Returns /// /// The activity that was ended. Returns Ok(None) if no activity was ended. - fn end_last_unfinished_activity( - &self, - end_time: Option, - ) -> PaceOptResult; + fn end_last_unfinished_activity(&self, end_opts: EndOptions) -> PaceOptResult; /// Hold an activity in the storage backend. /// /// # Arguments /// - /// * `hold_time` - The time (HH:MM) to hold the activity at. If `None`, the current time is used. + /// * `hold_opts` - The options to hold the activity. /// /// # Errors /// @@ -317,10 +315,27 @@ pub trait ActivityStateManagement: ActivityReadOps + ActivityWriteOps + Activity /// # Note /// /// This function should not be used to hold an activity that is already held. It should only be used to hold the last unfinished activity. - fn hold_last_unfinished_activity( + fn hold_last_unfinished_activity(&self, hold_opts: HoldOptions) -> PaceOptResult; + + /// Resume an activity in the storage backend. + /// + /// # Arguments + /// + /// * `activity_id` - The ID of the activity to resume. If `None`, the last unfinished activity is resumed. + /// * `resume_time` - The time (HH:MM) to resume the activity at. If `None`, the current time is used. + /// + /// # Errors + /// + /// This function should return an error if the activity cannot be resumed. + /// + /// # Returns + /// + /// The activity that was resumed. Returns Ok(None) if no activity was resumed. + fn resume_activity( &self, - hold_time: Option, - ) -> PaceOptResult; + activity_id: Option, + resume_time: Option, + ) -> PaceOptResult; } /// Querying Activities @@ -342,37 +357,10 @@ pub trait ActivityQuerying: ActivityReadOps { /// # Returns /// /// A collection of the activities that are currently active. - fn list_current_activities(&self) -> PaceOptResult { + fn list_current_activities(&self) -> PaceOptResult> { Ok(self .list_activities(ActivityFilter::Active)? - .map(FilteredActivities::into_log)) - } - - /// 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 most_recent_active_activity(&self) -> PaceOptResult { - let Some(current) = self.list_current_activities()? else { - // There are no active activities at all - return Ok(None); - }; - - let activity = current - .activities() - .par_iter() - .find_first(|activity| activity.is_active() && !activity.is_active_intermission()) - .cloned(); - - drop(current); - - Ok(activity) + .map(FilteredActivities::into_vec)) } /// Find activities within a specific date range. @@ -419,10 +407,10 @@ pub trait ActivityQuerying: ActivityReadOps { /// /// A collection of the activities that are currently active intermissions. /// If no activities are found, it should return `Ok(None)`. - fn list_active_intermissions(&self) -> PaceOptResult { + fn list_active_intermissions(&self) -> PaceOptResult> { Ok(self .list_activities(ActivityFilter::ActiveIntermission)? - .map(FilteredActivities::into_log)) + .map(FilteredActivities::into_vec)) } /// List the most recent activities from the storage backend. @@ -439,21 +427,116 @@ pub trait ActivityQuerying: ActivityReadOps { /// /// A collection of the most recent activities. /// If no activities are found, it should return `Ok(None)`. - fn list_most_recent_activities(&self, count: usize) -> PaceOptResult { + fn list_most_recent_activities(&self, count: usize) -> PaceOptResult> { let filtered = self - .list_activities(ActivityFilter::All)? - .map(FilteredActivities::into_log); + .list_activities(ActivityFilter::Everything)? + .map(FilteredActivities::into_vec); - let Some(filtered) = filtered else { + let Some(mut filtered) = filtered else { return Ok(None); }; + // TODO!: Actually check if we are sorted right way + filtered.sort(); + if filtered.len() > count { - Ok(Some((*filtered).clone().into_iter().take(count).collect())) + Ok(Some((*filtered).iter().take(count).cloned().collect())) } else { Ok(Some(filtered)) } } + + /// Check if an activity is currently active. + /// + /// # Arguments + /// + /// * `activity_id` - The ID of the activity to check. + /// + /// # Errors + /// + /// This function should return an error if the activity cannot be checked. + /// + /// # Returns + /// + /// If the activity is active, it should return `Ok(true)`. If it is not active, it should return `Ok(false)`. + fn is_activity_active(&self, activity_id: ActivityGuid) -> PaceResult { + let activity = self.read_activity(activity_id)?; + + Ok(activity.activity().is_active()) + } + + /// Check if an activity currently has one or more active intermissions. + /// + /// # Arguments + /// + /// * `activity_id` - The ID of the activity to check. + /// + /// # Errors + /// + /// This function should return an error if the activity cannot be checked. + /// + /// # Returns + /// + /// If the activity has active intermissions, it should return `Ok(Option>)` with the IDs of the active intermissions. + /// If it has no active intermissions, it should return `Ok(None)`. + fn list_active_intermissions_for_activity_id( + &self, + activity_id: ActivityGuid, + ) -> PaceOptResult> { + let guids = self.list_active_intermissions()?.map(|log| { + log.iter() + .filter_map(|active_intermission_id| { + if self + .read_activity(*active_intermission_id) + .ok()? + .activity() + .parent_id() + == Some(activity_id) + { + Some(*active_intermission_id) + } else { + None + } + }) + .collect::>() + }); + + Ok(guids) + } + + /// 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 most_recent_active_activity(&self) -> PaceOptResult { + let Some(mut current) = self.list_current_activities()? else { + // There are no active activities at all + return Ok(None); + }; + + // ULIDs are lexicographically sortable, so we can just sort them + // TODO!: Check if it's right like this + current.sort(); + + current + .into_iter() + .find(|activity_id| { + self.read_activity(*activity_id) + .map(|activity| { + activity.activity().is_active() + && !activity.activity().is_active_intermission() + }) + .unwrap_or(false) + }) + .map(|activity_id| self.read_activity(activity_id)) + .transpose() + } } /// Tagging Activities diff --git a/crates/core/src/storage/file.rs b/crates/core/src/storage/file.rs index 7bd01ef0..0308627a 100644 --- a/crates/core/src/storage/file.rs +++ b/crates/core/src/storage/file.rs @@ -9,7 +9,7 @@ use chrono::{NaiveDate, NaiveDateTime}; use crate::{ domain::{ - activity::{Activity, ActivityGuid}, + activity::{Activity, ActivityGuid, ActivityItem}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, }, @@ -18,6 +18,7 @@ use crate::{ in_memory::InMemoryActivityStorage, ActivityQuerying, ActivityReadOps, ActivityStateManagement, ActivityStorage, ActivityWriteOps, SyncStorage, }, + EndOptions, HoldOptions, }; /// In-memory backed TOML activity storage @@ -120,7 +121,7 @@ impl ActivityStorage for TomlActivityStorage { } impl ActivityReadOps for TomlActivityStorage { - fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.cache.read_activity(activity_id) } @@ -132,40 +133,45 @@ impl ActivityReadOps for TomlActivityStorage { impl ActivityStateManagement for TomlActivityStorage { fn end_all_unfinished_activities( &self, - time: Option, - ) -> PaceOptResult> { - self.cache.end_all_unfinished_activities(time) + end_opts: EndOptions, + ) -> PaceOptResult> { + self.cache.end_all_unfinished_activities(end_opts) } - fn end_last_unfinished_activity(&self, time: Option) -> PaceOptResult { - self.cache.end_last_unfinished_activity(time) + fn end_last_unfinished_activity(&self, end_opts: EndOptions) -> PaceOptResult { + self.cache.end_last_unfinished_activity(end_opts) } fn end_single_activity( &self, activity_id: ActivityGuid, - end_time: Option, - ) -> PaceResult { - self.cache.end_single_activity(activity_id, end_time) + end_opts: EndOptions, + ) -> PaceResult { + self.cache.end_single_activity(activity_id, end_opts) } - fn hold_last_unfinished_activity( - &self, - hold_time: Option, - ) -> PaceOptResult { - self.cache.hold_last_unfinished_activity(hold_time) + fn hold_last_unfinished_activity(&self, hold_opts: HoldOptions) -> PaceOptResult { + self.cache.hold_last_unfinished_activity(hold_opts) } fn end_all_active_intermissions( &self, - end_time: Option, - ) -> PaceOptResult> { - self.cache.end_all_active_intermissions(end_time) + end_opts: EndOptions, + ) -> PaceOptResult> { + self.cache.end_all_active_intermissions(end_opts) + } + + fn resume_activity( + &self, + activity_id: Option, + resume_time: Option, + ) -> PaceOptResult { + self.cache.resume_activity(activity_id, resume_time) } } impl ActivityWriteOps for TomlActivityStorage { - fn create_activity(&self, activity: Activity) -> PaceResult { + fn create_activity(&self, activity: Activity) -> PaceResult { self.cache.create_activity(activity) } @@ -173,11 +179,11 @@ impl ActivityWriteOps for TomlActivityStorage { &self, activity_id: ActivityGuid, activity: Activity, - ) -> PaceResult { + ) -> PaceResult { self.cache.update_activity(activity_id, activity) } - fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { self.cache.delete_activity(activity_id) } } @@ -195,4 +201,8 @@ impl ActivityQuerying for TomlActivityStorage { self.cache .find_activities_in_date_range(start_date, end_date) } + + fn most_recent_active_activity(&self) -> PaceOptResult { + self.cache.most_recent_active_activity() + } } diff --git a/crates/core/src/storage/in_memory.rs b/crates/core/src/storage/in_memory.rs index 74507f67..1393f96e 100644 --- a/crates/core/src/storage/in_memory.rs +++ b/crates/core/src/storage/in_memory.rs @@ -1,13 +1,15 @@ -use std::sync::{Arc, Mutex}; - -use chrono::{Local, NaiveDateTime, SubsecRound}; -use rayon::prelude::{ - IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock}, }; +use chrono::NaiveDateTime; +use merge::Merge; +use rayon::prelude::{IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator}; + use crate::{ domain::{ - activity::{Activity, ActivityEndOptions, ActivityGuid}, + activity::{Activity, ActivityEndOptions, ActivityGuid, ActivityItem}, activity_log::ActivityLog, filter::{ActivityFilter, FilteredActivities}, time::calculate_duration, @@ -17,16 +19,16 @@ use crate::{ ActivityQuerying, ActivityReadOps, ActivityStateManagement, ActivityStorage, ActivityWriteOps, SyncStorage, }, - ActivityKind, ActivityKindOptions, + ActivityKind, ActivityKindOptions, EndOptions, HoldOptions, }; /// Type for shared `ActivityLog` -type SharedActivityLog = Arc>; +type SharedActivityLog = Arc>; /// In-memory storage for activities #[derive(Debug, Clone)] pub struct InMemoryActivityStorage { - activities: SharedActivityLog, + log: SharedActivityLog, } impl From for InMemoryActivityStorage { @@ -40,7 +42,7 @@ impl InMemoryActivityStorage { #[must_use] pub fn new() -> Self { Self { - activities: Arc::new(Mutex::new(ActivityLog::default())), + log: Arc::new(RwLock::new(ActivityLog::default())), } } @@ -55,7 +57,7 @@ impl InMemoryActivityStorage { /// A new `InMemoryActivityStorage` with the given `ActivityLog` pub fn new_with_activity_log(activity_log: ActivityLog) -> Self { Self { - activities: Arc::new(Mutex::new(activity_log)), + log: Arc::new(RwLock::new(activity_log)), } } @@ -65,8 +67,8 @@ impl InMemoryActivityStorage { /// /// Returns an error if the mutex has been poisoned pub fn get_activity_log(&self) -> PaceResult { - let Ok(activity_log) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + let Ok(activity_log) = self.log.read() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; Ok(activity_log.clone()) @@ -92,148 +94,138 @@ impl SyncStorage for InMemoryActivityStorage { } impl ActivityReadOps for InMemoryActivityStorage { - fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { - let Ok(activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { + let Ok(activities) = self.log.read() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; let activity = activities - .activities() - .par_iter() - .find_first(|activity| { - activity - .guid() - .as_ref() - .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) - }) + .get(&activity_id) .cloned() .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; drop(activities); - Ok(activity) + Ok((activity_id, activity).into()) } fn list_activities(&self, filter: ActivityFilter) -> PaceOptResult { - let Ok(activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + let Ok(activity_log) = self.log.read() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; - let filtered = activities - .activities() - .iter() - .filter(|activity| match filter { + let filtered = activity_log + .par_iter() + .filter(|(_, activity)| match filter { + ActivityFilter::Everything => true, + ActivityFilter::OnlyActivities => activity.kind().is_activity(), ActivityFilter::Active => activity.is_active(), ActivityFilter::ActiveIntermission => activity.is_active_intermission(), ActivityFilter::Ended => activity.has_ended(), - ActivityFilter::All => true, - ActivityFilter::Archived => false, // TODO: Implement archived filter + ActivityFilter::Archived => activity.is_archived(), }) + .map(|(activity_id, _)| activity_id) .cloned() - .collect::(); + .collect::>(); + + drop(activity_log); - if filtered.activities().is_empty() { + if filtered.is_empty() { return Ok(None); } - drop(activities); - match filter { - ActivityFilter::All => Ok(Some(FilteredActivities::All(filtered.clone()))), - ActivityFilter::Active => Ok(Some(FilteredActivities::Active(filtered.clone()))), - ActivityFilter::ActiveIntermission => Ok(Some(FilteredActivities::ActiveIntermission( - filtered.clone(), - ))), - ActivityFilter::Archived => Ok(Some(FilteredActivities::Archived(filtered.clone()))), - ActivityFilter::Ended => Ok(Some(FilteredActivities::Ended(filtered.clone()))), + ActivityFilter::Everything => Ok(Some(FilteredActivities::Everything(filtered))), + ActivityFilter::OnlyActivities => { + Ok(Some(FilteredActivities::OnlyActivities(filtered))) + } + ActivityFilter::Active => Ok(Some(FilteredActivities::Active(filtered))), + ActivityFilter::ActiveIntermission => { + Ok(Some(FilteredActivities::ActiveIntermission(filtered))) + } + ActivityFilter::Archived => Ok(Some(FilteredActivities::Archived(filtered))), + ActivityFilter::Ended => Ok(Some(FilteredActivities::Ended(filtered))), } } } impl ActivityWriteOps for InMemoryActivityStorage { - fn create_activity(&self, activity: Activity) -> PaceResult { - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + fn create_activity(&self, activity: Activity) -> PaceResult { + let Ok(activities) = self.log.read() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; - let Some(activity_id) = activity.guid() else { - return Err(ActivityLogErrorKind::ActivityIdNotSet.into()); - }; + let activity_item = ActivityItem::from(activity.clone()); // Search for the activity in the list of activities to see if the ID is already in use. - if activities.activities().par_iter().any(|activity| { - activity - .guid() - .as_ref() - .map_or(false, |id| id == activity_id) - }) { - return Err(ActivityLogErrorKind::ActivityIdAlreadyInUse(*activity_id).into()); + // We use a ULID as the ID for the activity, so it should be unique and not collide with + // other activities. But still, let's check if the ID is already in use. If so, let's return + // an error. + // FIXME: We could essentially handle the case where the ID is already in use by creating a + // new ID and trying to insert the activity again. But for now, let's just return an error as + // it's not expected to happen. + if activities.contains_key(activity_item.guid()) { + return Err(ActivityLogErrorKind::ActivityIdAlreadyInUse(*activity_item.guid()).into()); } - activities.activities_mut().push_front(activity.clone()); + drop(activities); + + let Ok(mut activities) = self.log.write() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); + }; + + // We don't check for None here, because we know that the ID was not existing in the list of + // activities. + _ = activities + .activities_mut() + .insert(*activity_item.guid(), activity_item.activity().clone()); drop(activities); - Ok(*activity_id) + Ok(activity_item) } fn update_activity( &self, activity_id: ActivityGuid, - mut activity: Activity, - ) -> PaceResult { - // First things, first. Replace new activity's id with the original ID we are looking for. - // To make sure we are not accidentally changing the ID. - let _ = activity.guid_mut().replace(activity_id); - - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + updated_activity: Activity, + ) -> PaceResult { + let Ok(activities) = self.log.read() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; - let og_activity = activities - .activities_mut() - .par_iter_mut() - .find_first(|activity| { - activity - .guid() - .as_ref() - .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) - }) + let original_activity = activities + .get(&activity_id) + .cloned() .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; - let original_activity = og_activity.clone(); + drop(activities); + + let Ok(mut activities) = self.log.write() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); + }; - *og_activity = activity; + let _ = activities.entry(activity_id).and_modify(|activity| { + activity.merge(updated_activity); + }); drop(activities); - Ok(original_activity) + Ok((activity_id, original_activity).into()) } - fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + fn delete_activity(&self, activity_id: ActivityGuid) -> PaceResult { + let Ok(mut activities) = self.log.write() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; - let activity_index = activities - .activities_mut() - .par_iter() - .position_first(|activity| { - activity - .guid() - .as_ref() - .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) - }) - .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; - let activity = activities - .activities_mut() - .remove(activity_index) - .ok_or(ActivityLogErrorKind::ActivityCantBeRemoved(activity_index))?; + .remove(&activity_id) + .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; drop(activities); - Ok(activity) + Ok((activity_id, activity).into()) } } @@ -241,222 +233,177 @@ impl ActivityStateManagement for InMemoryActivityStorage { fn end_single_activity( &self, activity_id: ActivityGuid, - end_time: Option, - ) -> PaceResult { - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + end_opts: EndOptions, + ) -> PaceResult { + let Ok(mut activities) = self.log.write() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; - let end_time = end_time.unwrap_or_else(|| Local::now().naive_local()); - - let activity = activities - .activities_mut() - .par_iter_mut() - .find_first(|activity| { - activity - .guid() - .as_ref() - .map_or(false, |orig_activity_id| *orig_activity_id == activity_id) - }) - .ok_or(ActivityLogErrorKind::ActivityNotFound(activity_id))?; - - let duration = calculate_duration(activity.begin(), end_time)?; - - let end_opts = ActivityEndOptions::new(end_time, duration); - - activity.end_activity(end_opts); + let _ = activities.entry(activity_id).and_modify(|activity| { + match calculate_duration(activity.begin(), *end_opts.end_time()) { + Ok(duration) => { + let end_opts = ActivityEndOptions::new(*end_opts.end_time(), duration); + activity.end_activity(end_opts); + } + Err(_) => { + log::warn!( + "Activity {} ends before it began. That's impossible. Skipping \ + activity. Please fix manually and run the command again.", + activity + ); + } + } + }); drop(activities); - Ok(activity_id) + self.read_activity(activity_id) } - fn end_last_unfinished_activity( - &self, - end_time: Option, - ) -> PaceOptResult { - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); - }; - - let end_time = end_time.unwrap_or_else(|| Local::now().naive_local().round_subsecs(0)); - - let Some(last_unfinished_activity) = activities - .activities_mut() - .par_iter_mut() - .find_first(|activity| activity.is_active()) - else { + fn end_last_unfinished_activity(&self, end_opts: EndOptions) -> PaceOptResult { + let Some(most_recent) = self.most_recent_active_activity()? else { return Ok(None); }; - let duration = calculate_duration(last_unfinished_activity.begin(), end_time)?; - - let end_opts = ActivityEndOptions::new(end_time, duration); - last_unfinished_activity.end_activity(end_opts); + let activity = self.end_single_activity(*most_recent.guid(), end_opts)?; - Ok(Some(last_unfinished_activity.clone())) + Ok(Some(activity)) } fn end_all_unfinished_activities( &self, - end_time: Option, - ) -> PaceOptResult> { - let mut ended_activities = vec![]; - - let end_time = end_time.unwrap_or_else(|| Local::now().naive_local().round_subsecs(0)); - - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + end_opts: EndOptions, + ) -> PaceOptResult> { + let Ok(mut activities) = self.log.write() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); }; - activities - .activities_mut() - .iter_mut() - .filter(|activity| activity.is_active()) - .for_each(|activity| { - match calculate_duration(activity.begin(), end_time) { - Ok(duration) => { - let end_opts = ActivityEndOptions::new(end_time, duration); - activity.end_activity(end_opts); - - ended_activities.push(activity.clone()); - } - Err(_) => { - log::warn!( - "Activity {} ends before it began. That's impossible. Skipping \ - activity.", - activity - ); - } - }; - }); + let active_activities = activities + .par_iter_mut() + .filter_map(|(activity_id, activity)| { + if activity.is_active() { + Some(*activity_id) + } else { + None + } + }) + .collect::>(); drop(activities); - if ended_activities.is_empty() { + // There are no active activities + if active_activities.is_empty() { return Ok(None); } + let ended_activities = active_activities + .par_iter() + .map(|activity_id| -> PaceResult { + self.end_single_activity(*activity_id, end_opts.clone()) + }) + .collect::>>()?; + + if ended_activities.len() != active_activities.len() { + // This is weird, we should return an error about it + return Err(ActivityLogErrorKind::ActivityNotEnded.into()); + } + Ok(Some(ended_activities)) } - fn hold_last_unfinished_activity( - &self, - hold_time: Option, - ) -> PaceOptResult { - let time = hold_time.unwrap_or_else(|| Local::now().naive_local().round_subsecs(0)); - + fn hold_last_unfinished_activity(&self, hold_opts: HoldOptions) -> PaceOptResult { // Get id from last activity that is not ended let Some(active_activity) = self.most_recent_active_activity()? else { // There are no active activities return Ok(None); }; + // TODO!: What if there are any other intermissions ongoing for other activities? + // TODO!: Should we end them as well? Or should we just end the intermission for the active activity? + // Check if the latest active activity is already having an intermission - // TODO: Refactor, that should be way easier to do - if let Some(intermissions) = self.list_active_intermissions()? { - if intermissions.iter().any(|intermission| { - intermission - .activity_kind_options() - .as_ref() - .map(|kind| { - let Some(parent_id) = kind.parent_id() else { - return false; - }; - - let Some(activity_id) = active_activity.guid() else { - return false; - }; - - parent_id == activity_id - }) - .unwrap_or(false) - }) { - // There is already an intermission for an active activity - return Ok(None); + if let Some(intermissions) = + self.list_active_intermissions_for_activity_id(*active_activity.guid())? + { + // If there are active intermissions and we want to extend return early with the active activity + // + // Handles the case, if someone wants to create an intermission for an + // activity that already has an intermission, but hasn't set that we should + // create a new intermission. In this case we don't want to create + // another intermission, but return with the active activity. + if !intermissions.is_empty() && hold_opts.action().is_extend() { + return Ok(Some(active_activity)); } }; - let Some(parent_id) = active_activity.guid() else { - return Err(ActivityLogErrorKind::ActivityIdNotSet.into()); - }; + // If there are active intermissions for any activity, end the intermissions + // because the user wants to create a new intermission + let _ = self.end_all_active_intermissions(hold_opts.clone().into())?; - let activity_kind_opts = ActivityKindOptions::new(*parent_id); + // Create a new intermission for the active activity + let activity_kind_opts = ActivityKindOptions::with_parent_id(*active_activity.guid()); + + let description = hold_opts.reason().clone().unwrap_or_else(|| { + active_activity + .activity() + .description() + .clone() + .unwrap_or_else(|| format!("Holding {}", active_activity.activity())) + }); let intermission = Activity::builder() - .begin(time) + .begin(*hold_opts.begin_time()) .kind(ActivityKind::Intermission) - .description( - active_activity - .description() - .clone() - .unwrap_or_else(|| format!("Holding {active_activity}")), - ) - .category(active_activity.category().clone()) + .description(description) + .category(active_activity.activity().category().clone()) .activity_kind_options(activity_kind_opts) .build(); - let id = self.create_activity(intermission.clone())?; - - if id - != intermission - .guid() - .ok_or_else(|| ActivityLogErrorKind::ActivityIdNotSet)? - { - return Err(ActivityLogErrorKind::ActivityIdMismatch( - id, - intermission - .guid() - .expect("ID for activity should be existing at this point."), - ) - .into()); - } + let _created_intermission_item = self.create_activity(intermission.clone())?; Ok(Some(active_activity)) } fn end_all_active_intermissions( &self, - end_time: Option, - ) -> PaceOptResult> { - let mut ended_intermissions = vec![]; - - let end_time = end_time.unwrap_or_else(|| Local::now().naive_local().round_subsecs(0)); - - let Ok(mut activities) = self.activities.lock() else { - return Err(ActivityLogErrorKind::MutexHasBeenPoisoned.into()); + end_opts: EndOptions, + ) -> PaceOptResult> { + let Some(active_intermissions) = self.list_active_intermissions()? else { + // There are no active intermissions + return Ok(None); }; - activities - .activities_mut() - .iter_mut() - .filter(|activity| activity.is_active_intermission()) - .for_each(|activity| { - match calculate_duration(activity.begin(), end_time) { - Ok(duration) => { - let end_opts = ActivityEndOptions::new(end_time, duration); - activity.end_activity(end_opts); - - ended_intermissions.push(activity.clone()); - } - Err(_) => { - log::warn!( - "Activity {} ends before it began. That's impossible. Skipping \ - activity.", - activity - ); - } - }; - }); - - drop(activities); + let ended_intermissions = active_intermissions + .par_iter() + .map(|activity_id| -> PaceResult { + let _ = self.end_single_activity(*activity_id, end_opts.clone())?; + Ok(*activity_id) + }) + .collect::>>()?; - if ended_intermissions.is_empty() { - return Ok(None); + if ended_intermissions.len() != active_intermissions.len() { + // This is weird, we should return an error about it + return Err(ActivityLogErrorKind::ActivityNotEnded.into()); } Ok(Some(ended_intermissions)) } + + fn resume_activity( + &self, + _activity_id: Option, + _resume_time: Option, + ) -> PaceOptResult { + // What do we need to do here? + // - Find the activity by id, if it's not given, find the last active activity + // - If there are active intermissions for any activity, end the intermissions + // and resume the activity with the same id as the most recent intermission's parent_id + // - If there are no active intermissions, but there are active activities, return the last active activity + // - If there are no active intermissions, resume the activity with the given id or the last active activity + // - If there are no active activities, return an error + + todo!("Implement resume_activity for InMemoryActivityStorage") + } } impl ActivityQuerying for InMemoryActivityStorage { @@ -468,9 +415,676 @@ impl ActivityQuerying for InMemoryActivityStorage { todo!("Implement find_activities_in_date_range for InMemoryActivityStorage") } - fn list_activities_by_id( - &self, - ) -> PaceOptResult> { - todo!("Implement list_activities_by_id for InMemoryActivityStorage") + fn list_activities_by_id(&self) -> PaceOptResult> { + let Ok(activities) = self.log.read() else { + return Err(ActivityLogErrorKind::RwLockHasBeenPoisoned.into()); + }; + + let activities_by_id = activities.activities().clone(); + + drop(activities); + + if activities_by_id.is_empty() { + return Ok(None); + } + + Ok(Some(activities_by_id)) + } +} + +#[cfg(test)] +mod tests { + + use chrono::Local; + + use crate::PaceDateTime; + + use super::*; + + #[test] + fn test_in_memory_activity_storage_passes() { + let storage = InMemoryActivityStorage::new(); + + assert_eq!( + storage.get_activity_log().unwrap().activities().len(), + 0, + "Activity log is not empty." + ); + } + + #[test] + fn test_in_memory_activity_storage_from_activity_log_passes() { + let activity_log = ActivityLog::default(); + let storage = InMemoryActivityStorage::from(activity_log); + + assert_eq!( + storage.get_activity_log().unwrap().activities().len(), + 0, + "Activity log is not empty." + ); + } + + // FIXME: Cleanup, this should be impossible now that we use a BTreeMap + // #[test] + // fn test_create_same_activity_twice_fails() { + // let storage = InMemoryActivityStorage::new(); + + // let begin = Local::now().naive_local(); + // let kind = ActivityKind::Activity; + // let description = "Test activity"; + + // let activity = Activity::builder() + // .begin(begin) + // .kind(kind) + // .description(description) + // .build(); + + // let item = storage.create_activity(activity.clone()).unwrap(); + + // assert_eq!( + // storage.get_activity_log().unwrap().activities().len(), + // 1, + // "Activity was not created." + // ); + + // let create_same_activity_result = storage.create_activity(activity); + + // assert!( + // create_same_activity_result.is_err(), + // "Activity was created twice." + // ); + // } + + #[test] + fn test_create_read_activity_passes() { + let storage = InMemoryActivityStorage::new(); + + let begin = Local::now().naive_local(); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin) + .kind(kind) + .description(description) + .build(); + + let item = storage.create_activity(activity.clone()).unwrap(); + + assert_eq!( + storage.get_activity_log().unwrap().activities().len(), + 1, + "Activity was not created." + ); + + let stored_activity = storage.read_activity(*item.guid()).unwrap(); + + assert_eq!( + activity, + *stored_activity.activity(), + "Stored activity is not the same as the original activity." + ); + } + + #[test] + fn test_list_activities_passes() { + let storage = InMemoryActivityStorage::new(); + + let begin = Local::now().naive_local(); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin) + .kind(kind) + .description(description) + .build(); + + let _activity_item = storage.create_activity(activity.clone()).unwrap(); + + let filtered_activities = storage + .list_activities(ActivityFilter::Everything) + .unwrap() + .unwrap() + .into_vec(); + + assert_eq!( + filtered_activities.len(), + 1, + "Amount of activities is not the same as the amount of created activities." + ); + + let stored_activity = storage.read_activity(filtered_activities[0]).unwrap(); + + assert_eq!( + activity, + *stored_activity.activity(), + "Filtered activities are not the same as the original activity." + ); + } + + #[test] + fn test_update_activity_passes() { + let storage = InMemoryActivityStorage::new(); + + let begin = Local::now().naive_local(); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let og_activity = Activity::builder() + .begin(begin) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(og_activity.clone()).unwrap(); + + let read_activity = storage.read_activity(*activity_item.guid()).unwrap(); + + assert_eq!( + og_activity, + *read_activity.activity(), + "Stored activity is not the same as the original activity." + ); + + let new_description = "Updated description"; + + let updated_activity = Activity::builder() + .begin(begin + chrono::Duration::seconds(30)) + .kind(ActivityKind::PomodoroWork) + .description(new_description) + .build(); + + let old_activity = storage + .update_activity(*activity_item.guid(), updated_activity.clone()) + .unwrap(); + + assert_eq!( + og_activity, + *old_activity.activity(), + "Stored activity is not the same as the original activity." + ); + + let new_stored_activity = storage.read_activity(*activity_item.guid()).unwrap(); + + assert_eq!( + old_activity.guid(), + new_stored_activity.guid(), + "ID was updated, but shouldn't." + ); + + assert_eq!( + new_stored_activity.activity().description().as_deref(), + Some(new_description), + "Description was not updated." + ); + + assert_eq!( + old_activity.activity().kind(), + new_stored_activity.activity().kind(), + "Kind was updated, but shouldn't." + ); + + assert_eq!( + og_activity.begin(), + new_stored_activity.activity().begin(), + "Begin time was updated, but shouldn't." + ); + + assert!( + new_stored_activity.activity().active(), + "Activity should be active now, but was not updated." + ); + } + + #[test] + fn test_crud_activity_passes() { + let storage = InMemoryActivityStorage::new(); + + // Create activity + let begin = Local::now().naive_local(); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin) + .kind(kind) + .description(description) + .build(); + + assert_eq!( + storage.get_activity_log().unwrap().activities().len(), + 0, + "Activity log is not empty." + ); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + assert_eq!( + storage.get_activity_log().unwrap().activities().len(), + 1, + "Activity was not created." + ); + + // Read activity + let stored_activity = storage.read_activity(*activity_item.guid()).unwrap(); + + assert_eq!( + activity, + *stored_activity.activity(), + "Stored activity is not the same as the original activity." + ); + + // Update activity + let new_description = "Updated description"; + + let updated_activity = Activity::builder() + .begin(begin + chrono::Duration::seconds(30)) + .kind(ActivityKind::PomodoroWork) + .description(new_description) + .build(); + + let _ = storage + .update_activity(*activity_item.guid(), updated_activity.clone()) + .unwrap(); + + let new_stored_activity = storage.read_activity(*activity_item.guid()).unwrap(); + + assert_eq!( + new_stored_activity.activity().description().as_deref(), + Some(new_description), + "Description was not updated." + ); + + assert_eq!( + stored_activity.activity().kind(), + new_stored_activity.activity().kind(), + "Kind was updated, but shouldn't." + ); + + assert_eq!( + stored_activity.activity().begin(), + new_stored_activity.activity().begin(), + "Begin time was updated, but shouldn't." + ); + + assert!( + new_stored_activity.activity().active(), + "Activity should be active now, but was not updated." + ); + + // Delete activity + let deleted_activity = storage.delete_activity(*activity_item.guid()).unwrap(); + + assert_eq!( + storage.get_activity_log().unwrap().activities().len(), + 0, + "Activity was not deleted." + ); + + assert_eq!( + deleted_activity, new_stored_activity, + "Deleted activity is not the same as the updated activity." + ); + + // Try to read the deleted activity + + let read_deleted_activity_result = storage.read_activity(*activity_item.guid()); + + assert!( + read_deleted_activity_result.is_err(), + "Deleted activity was read." + ); + } + + #[test] + fn test_end_single_activity_passes() { + let storage = InMemoryActivityStorage::new(); + let now = Local::now().naive_local(); + let begin_time = now - chrono::Duration::seconds(30); + let end_time = now + chrono::Duration::seconds(30); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + let end_opts = EndOptions::builder().end_time(end_time).build(); + + let ended_activity = storage + .end_single_activity(*activity_item.guid(), end_opts) + .unwrap(); + + assert_ne!( + activity_item, ended_activity, + "Activities do match, although they should be different." + ); + + assert!(ended_activity.activity().activity_end_options().is_some()); + + let ended_activity = storage.read_activity(*activity_item.guid()).unwrap(); + + assert!( + ended_activity.activity().has_ended(), + "Activity has not ended, but should have." + ); + + assert_eq!( + ended_activity + .activity() + .activity_end_options() + .as_ref() + .unwrap() + .end(), + &PaceDateTime::new(end_time), + "End time was not set." + ); + } + + #[test] + fn test_end_last_unfinished_activity_passes() { + let storage = InMemoryActivityStorage::new(); + let now = Local::now().naive_local(); + let begin_time = now - chrono::Duration::seconds(30); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + let ended_activity = storage + .end_last_unfinished_activity(EndOptions::builder().end_time(now).build()) + .unwrap() + .unwrap(); + + assert_eq!( + ended_activity.guid(), + activity_item.guid(), + "Activity IDs do not match." + ); + + assert!( + ended_activity.activity().has_ended(), + "Activity has not ended, but should have." + ); + + assert_eq!( + ended_activity + .activity() + .activity_end_options() + .as_ref() + .unwrap() + .end(), + &PaceDateTime::new(now), + "End time was not set." + ); + } + + #[test] + fn test_end_all_unfinished_activities_for_multiple_activities_passes() { + let storage = InMemoryActivityStorage::new(); + let now = Local::now().naive_local(); + let begin_time = now - chrono::Duration::seconds(30); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + let begin_time = now - chrono::Duration::seconds(60); + let kind = ActivityKind::Activity; + let description = "Test activity 2"; + + let activity2 = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item2 = storage.create_activity(activity2.clone()).unwrap(); + + let ended_activities = storage + .end_all_unfinished_activities(EndOptions::builder().end_time(now).build()) + .unwrap() + .unwrap(); + + assert_eq!(ended_activities.len(), 2, "Not all activities were ended."); + + assert!( + ended_activities + .iter() + .all(|activity| activity.activity().has_ended()), + "Not all activities have ended." + ); + + let ended_activity = storage.read_activity(*activity_item.guid()).unwrap(); + + assert!( + ended_activity.activity().has_ended(), + "Activity has not ended, but should have." + ); + + assert_eq!( + ended_activity + .activity() + .activity_end_options() + .as_ref() + .unwrap() + .end(), + &PaceDateTime::new(now), + "End time was not set." + ); + + let ended_activity2 = storage.read_activity(*activity_item2.guid()).unwrap(); + + assert!( + ended_activity2.activity().has_ended(), + "Activity has not ended, but should have." + ); + + assert_eq!( + ended_activity2 + .activity() + .activity_end_options() + .as_ref() + .unwrap() + .end(), + &PaceDateTime::new(now), + "End time was not set." + ); + } + + #[test] + fn test_hold_last_unfinished_activity_passes() { + let storage = InMemoryActivityStorage::new(); + let now = Local::now().naive_local(); + let begin_time = now - chrono::Duration::seconds(30); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + let hold_time = now + chrono::Duration::seconds(30); + + let hold_opts = HoldOptions::builder().begin_time(hold_time).build(); + + let held_activity = storage + .hold_last_unfinished_activity(hold_opts) + .unwrap() + .unwrap(); + + assert_eq!( + held_activity.guid(), + activity_item.guid(), + "Activity IDs do not match." + ); + + let intermission_guids = storage + .list_active_intermissions_for_activity_id(*activity_item.guid()) + .unwrap() + .unwrap(); + + assert_eq!(intermission_guids.len(), 1, "Intermission was not created."); + + let intermission_item = storage.read_activity(intermission_guids[0]).unwrap(); + + assert_eq!( + *intermission_item.activity().kind(), + ActivityKind::Intermission, + "Intermission was not created." + ); + + assert_eq!( + intermission_item + .activity() + .activity_kind_options() + .as_ref() + .unwrap() + .parent_id() + .unwrap(), + *activity_item.guid(), + "Parent ID is not set." + ); + } + + #[test] + fn test_hold_last_unfinished_activity_with_existing_intermission_does_nothing_passes() { + let storage = InMemoryActivityStorage::new(); + let now = Local::now().naive_local(); + let begin_time = now - chrono::Duration::seconds(30); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + let hold_opts = HoldOptions::builder() + .begin_time(now + chrono::Duration::seconds(30)) + .build(); + + let _held_item = storage + .hold_last_unfinished_activity(hold_opts) + .unwrap() + .unwrap(); + + let intermission_guids = storage + .list_active_intermissions_for_activity_id(*activity_item.guid()) + .unwrap(); + + assert_eq!( + intermission_guids.as_ref().unwrap().len(), + 1, + "Intermission was not created." + ); + + let hold_opts = HoldOptions::builder() + .begin_time(now + chrono::Duration::seconds(60)) + .build(); + + let _held_activity = storage + .hold_last_unfinished_activity(hold_opts) + .unwrap() + .unwrap(); + + let intermission_guids = storage + .list_active_intermissions_for_activity_id(*activity_item.guid()) + .unwrap() + .unwrap(); + + assert_eq!( + intermission_guids.len(), + 1, + "Intermission was created again." + ); + } + + #[test] + fn test_end_all_active_intermissions_passes() { + let storage = InMemoryActivityStorage::new(); + let now = Local::now().naive_local(); + let begin_time = now - chrono::Duration::seconds(30); + let end_time = now + chrono::Duration::seconds(60); + let kind = ActivityKind::Activity; + let description = "Test activity"; + + let activity = Activity::builder() + .begin(begin_time) + .kind(kind) + .description(description) + .build(); + + let activity_item = storage.create_activity(activity.clone()).unwrap(); + + let hold_opts = HoldOptions::builder() + .begin_time(now + chrono::Duration::seconds(30)) + .build(); + + let _ = storage.hold_last_unfinished_activity(hold_opts).unwrap(); + + let intermission_guids = storage + .list_active_intermissions_for_activity_id(*activity_item.guid()) + .unwrap(); + + assert_eq!( + intermission_guids.as_ref().unwrap().len(), + 1, + "Intermission was not created." + ); + + let end_opts = EndOptions::builder().end_time(end_time).build(); + + let ended_intermissions = storage.end_all_active_intermissions(end_opts).unwrap(); + + assert_eq!( + ended_intermissions.as_ref().unwrap().len(), + 1, + "Not all intermissions were ended." + ); + + let ended_intermission = storage + .read_activity(intermission_guids.as_ref().unwrap()[0]) + .unwrap(); + + assert!( + ended_intermission.activity().has_ended(), + "Intermission has not ended, but should have." + ); + + assert_eq!( + ended_intermission + .activity() + .activity_end_options() + .as_ref() + .unwrap() + .end(), + &PaceDateTime::new(end_time), + "End time was not set." + ); } } diff --git a/crates/core/tests/activity_store.rs b/crates/core/tests/activity_store.rs index 0b76b4d2..d6d52610 100644 --- a/crates/core/tests/activity_store.rs +++ b/crates/core/tests/activity_store.rs @@ -1,99 +1,142 @@ // Test the ActivityStore implementation with a InMemoryStorage backend. +use std::sync::Arc; + use chrono::{Local, NaiveDateTime}; + use pace_core::{ - Activity, ActivityFilter, ActivityGuid, ActivityLog, ActivityReadOps, ActivityStateManagement, - ActivityStore, ActivityWriteOps, BeginDateTime, InMemoryActivityStorage, PaceResult, - TestResult, + Activity, ActivityFilter, ActivityGuid, ActivityItem, ActivityKind, ActivityKindOptions, + ActivityLog, ActivityReadOps, ActivityStateManagement, ActivityStore, ActivityWriteOps, + EndOptions, HoldOptions, InMemoryActivityStorage, PaceDateTime, PaceResult, TestResult, }; use rstest::{fixture, rstest}; use similar_asserts::assert_eq; +struct TestData { + activities: Vec, + store: ActivityStore, +} + +enum ActivityStoreTestKind { + Empty, + WithActivitiesAndOpenIntermission, + WithoutIntermissions, +} + #[fixture] -fn activity_log_empty() -> ActivityLog { - let activities = vec![]; +fn activity_store_empty() -> TestData { + setup_activity_store(ActivityStoreTestKind::Empty) +} - ActivityLog::from_iter(activities) +#[fixture] +fn activity_store() -> TestData { + setup_activity_store(ActivityStoreTestKind::WithActivitiesAndOpenIntermission) } #[fixture] -fn activity_log_with_variety_content() -> (Vec, ActivityLog) { - let begin_time = BeginDateTime::new(NaiveDateTime::new( +fn activity_store_no_intermissions() -> TestData { + setup_activity_store(ActivityStoreTestKind::WithoutIntermissions) +} + +fn setup_activity_store(kind: ActivityStoreTestKind) -> TestData { + let begin_time = PaceDateTime::new(NaiveDateTime::new( NaiveDateTime::from_timestamp_opt(0, 0).unwrap().date(), NaiveDateTime::from_timestamp_opt(0, 0).unwrap().time(), )); let mut ended_activity = Activity::builder() - .description("Test Description".to_string()) + .description("Activity with end".to_string()) .begin(begin_time) + .active(false) .build(); ended_activity - .end_activity_with_duration_calc(begin_time, Local::now().naive_local()) + .end_activity_with_duration_calc(begin_time, PaceDateTime::now()) .expect("Creating ended activity should not fail."); - let activities = vec![ - Activity::default(), - Activity::default(), - ended_activity, - Activity::default(), - Activity::default(), - Activity::default(), - ]; - - (activities.clone(), ActivityLog::from_iter(activities)) -} - -#[fixture] -fn activity_log_with_content() -> (Vec, ActivityLog) { - let activities = vec![ - Activity::default(), - Activity::default(), - Activity::default(), - Activity::default(), - Activity::default(), - Activity::default(), - ]; - - (activities.clone(), ActivityLog::from_iter(activities)) -} - -#[fixture] -fn activity_log_for_intermissions() -> (Vec, ActivityLog) { - let time_30_min_ago = Local::now().naive_local() - chrono::Duration::minutes(30); - let begin_time = BeginDateTime::new(time_30_min_ago); + let ended_activity = ActivityItem::from((ActivityGuid::default(), ended_activity)); - let activities = vec![Activity::builder() + let mut archived_activity = Activity::builder() + .description("Activity with end".to_string()) .begin(begin_time) - .description("Test Description".to_string()) - .category("Test::Intermission".to_string()) - .build()]; + .active(false) + .build(); + archived_activity + .end_activity_with_duration_calc(begin_time, PaceDateTime::now()) + .expect("Creating ended activity should not fail."); + archived_activity.archive(); - (activities.clone(), ActivityLog::from_iter(activities)) -} + let archived_activity = ActivityItem::from((ActivityGuid::default(), archived_activity)); -#[fixture] -fn activity_store_with_item( - activity_log_empty: ActivityLog, -) -> TestResult<(ActivityGuid, Activity, ActivityStore)> { - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log_empty, - ))); + let time_30_min_ago = Local::now().naive_local() - chrono::Duration::minutes(30); + let begin_time = PaceDateTime::new(time_30_min_ago); + let intermission_begin_time = + PaceDateTime::new(time_30_min_ago + chrono::Duration::minutes(15)); + let desc = "Activity with Intermission".to_string(); + let cat = "Test::Intermission".to_string(); + + let active = ActivityItem::from(( + ActivityGuid::default(), + Activity::builder() + .begin(begin_time) + .description(desc.clone()) + .category(cat.clone()) + .build(), + )); - let activity = Activity::builder() - .description("Test Description".to_string()) - .category("Test::Category".to_string()) - .build(); + let guid = active.guid(); + + let intermission = ActivityItem::from(( + ActivityGuid::default(), + Activity::builder() + .begin(intermission_begin_time) + .kind(ActivityKind::Intermission) + .description(desc) + .category(cat) + .activity_kind_options(ActivityKindOptions::with_parent_id(*guid)) + .build(), + )); - let activity_id = store.create_activity(activity.clone())?; + let default_no_end = ActivityItem::from(( + ActivityGuid::default(), + Activity::builder() + .description("Default activity, but no end and not active.") + .active(false) + .build(), + )); - Ok((activity_id, activity, store)) + let mut activities = vec![]; + + match kind { + ActivityStoreTestKind::Empty => (), + ActivityStoreTestKind::WithActivitiesAndOpenIntermission => { + activities.push(default_no_end); + activities.push(archived_activity); + activities.push(ended_activity); + activities.push(active); + activities.push(intermission); + } + ActivityStoreTestKind::WithoutIntermissions => { + activities.push(default_no_end); + activities.push(archived_activity); + activities.push(ended_activity); + activities.push(active); + } + } + + TestData { + activities: activities.clone(), + store: ActivityStore::new(Arc::new(InMemoryActivityStorage::new_with_activity_log( + ActivityLog::from_iter(activities), + ))), + } } #[rstest] -fn test_activity_store_create_activity_passes(activity_log_empty: ActivityLog) -> TestResult<()> { - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log_empty, - ))); +fn test_activity_store_create_activity_passes(activity_store_empty: TestData) -> TestResult<()> { + let TestData { + activities: _, + store, + } = activity_store_empty; let activity = Activity::builder() .description("Test Description".to_string()) @@ -101,44 +144,39 @@ fn test_activity_store_create_activity_passes(activity_log_empty: ActivityLog) - .build(); let og_activity = activity.clone(); - let og_activity_id = activity.guid().expect("Activity ID should be set."); - let activity_id = store.create_activity(activity)?; + let og_activity_item = store.create_activity(activity)?; - assert_eq!(activity_id, og_activity_id); + let stored_activity = store.read_activity(*og_activity_item.guid())?; - let stored_activity = store.read_activity(og_activity_id)?; - - assert_eq!(stored_activity, og_activity); + assert_eq!(*stored_activity.activity(), og_activity); Ok(()) } -#[rstest] -fn test_activity_store_create_activity_fails( - activity_log_with_content: (Vec, ActivityLog), -) { - let (activities, activity_log) = activity_log_with_content; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); +// Creating the same activity twice should fail, as the GUID is the same. +// But this is not possible to test, as the GUID is generated by the store. +// +// #[rstest] +// fn test_activity_store_create_activity_fails(activity_store: TestData) { +// let TestData { activities, store } = activity_store; - let id = activities[0].guid().expect("Activity ID should be set."); +// let id = *activities[0].guid(); - let activity = Activity::builder() - .guid(id) - .description("Test Description".to_string()) - .category("Test::Category".to_string()) - .build(); +// let activity = Activity::builder() +// .description("Test Description".to_string()) +// .category("Test::Category".to_string()) +// .build(); - assert!(store.create_activity(activity).is_err()); -} +// assert!(store.create_activity(activity).is_err()); +// } #[rstest] -fn test_activity_store_read_activity_passes( - activity_store_with_item: TestResult<(ActivityGuid, Activity, ActivityStore)>, -) -> TestResult<()> { - let (og_activity_id, og_activity, store) = activity_store_with_item?; +fn test_activity_store_read_activity_passes(activity_store: TestData) -> TestResult<()> { + let TestData { activities, store } = activity_store; + + let og_activity = activities[0].clone(); + let og_activity_id = *og_activity.guid(); let stored_activity = store.read_activity(og_activity_id)?; @@ -148,103 +186,163 @@ fn test_activity_store_read_activity_passes( } #[rstest] -fn test_activity_store_read_activity_fails(activity_log_empty: ActivityLog) { - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log_empty, - ))); +fn test_activity_store_read_activity_fails(activity_store: TestData) { + let TestData { + activities: _, + store, + } = activity_store; let activity_id = ActivityGuid::default(); assert!(store.read_activity(activity_id).is_err()); } -// TODO!: Test the list_activities method with all the other filters. -// List activities can hardly fail, as it returns an empty list if no activities are found. -// Therefore, we only test the success case. It would fail if the mutex is poisoned. #[rstest] -fn test_activity_store_list_active_activities_passes( - activity_log_with_content: (Vec, ActivityLog), +fn test_activity_store_list_activities_returns_none_on_empty_passes( + activity_store_empty: TestData, ) -> TestResult<()> { - let (activities, activity_log) = activity_log_with_content; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); + let TestData { + activities: _, + store, + } = activity_store_empty; - let loaded_activities = store - .list_activities(ActivityFilter::Active)? - .expect("Should have activities."); + assert!(store.list_activities(ActivityFilter::Everything)?.is_none()); - assert_eq!( - activities.len(), - loaded_activities.into_log().activities().len() - ); + Ok(()) +} + +// List activities can hardly fail, as it returns an empty list if no activities are found. +// Therefore, we only test the success case. It would fail if the mutex is poisoned. +#[rstest] +fn test_activity_store_list_activities_passes(activity_store: TestData) -> TestResult<()> { + use strum::IntoEnumIterator; + + let TestData { activities, store } = activity_store; + + for filter in ActivityFilter::iter() { + match filter { + ActivityFilter::OnlyActivities => { + let loaded_activities = store + .list_activities(ActivityFilter::OnlyActivities)? + .expect("Should have only activities."); + + assert_eq!( + 4, + loaded_activities.into_vec().len(), + "Should have only 4 activities." + ); + } + ActivityFilter::Archived => { + let loaded_activities = store + .list_activities(ActivityFilter::Archived)? + .expect("Should have archived activities."); + + assert_eq!(1, loaded_activities.into_vec().len()); + } + ActivityFilter::ActiveIntermission => { + let loaded_activities = store + .list_activities(ActivityFilter::ActiveIntermission)? + .expect("Should have activities."); + + assert_eq!( + 1, + loaded_activities.into_vec().len(), + "Should have one active intermission." + ); + } + ActivityFilter::Active => { + let loaded_activities = store + .list_activities(ActivityFilter::Active)? + .expect("Should have active activities."); + + assert_eq!( + 2, + loaded_activities.into_vec().len(), + "Should have two active activities." + ); + } + ActivityFilter::Ended => { + let loaded_activities = store + .list_activities(ActivityFilter::Ended)? + .expect("Should have ended activities."); + + assert_eq!( + 1, + loaded_activities.into_vec().len(), + "Should have one ended activity." + ); + } + ActivityFilter::Everything => { + let loaded_activities = store + .list_activities(ActivityFilter::Everything)? + .expect("Should have everything (activities, intermissions, etc.)."); + + assert_eq!( + activities.len(), + loaded_activities.into_vec().len(), + "Should be the same length as initial activities." + ); + } + } + } Ok(()) } #[rstest] -fn test_activity_store_list_ended_activities_passes( - activity_log_with_variety_content: (Vec, ActivityLog), -) -> TestResult<()> { - let (_activities, activity_log) = activity_log_with_variety_content; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); +fn test_activity_store_list_ended_activities_passes(activity_store: TestData) -> TestResult<()> { + let TestData { + activities: _, + store, + } = activity_store; let loaded_activities = store .list_activities(ActivityFilter::Ended)? .expect("Should have activities."); - assert_eq!(1, loaded_activities.into_log().activities().len()); + assert_eq!(1, loaded_activities.into_vec().len()); Ok(()) } #[rstest] -fn test_activity_store_list_all_activities_passes( - activity_log_with_variety_content: (Vec, ActivityLog), -) -> TestResult<()> { - let (activities, activity_log) = activity_log_with_variety_content; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); +fn test_activity_store_list_all_activities_passes(activity_store: TestData) -> TestResult<()> { + let TestData { activities, store } = activity_store; let loaded_activities = store - .list_activities(ActivityFilter::All)? + .list_activities(ActivityFilter::Everything)? .expect("Should have activities."); - assert_eq!( - activities.len(), - loaded_activities.into_log().activities().len() - ); + assert_eq!(activities.len(), loaded_activities.into_vec().len()); Ok(()) } #[rstest] fn test_activity_store_list_all_activities_empty_result_passes( - activity_log_empty: ActivityLog, + activity_store_empty: TestData, ) -> TestResult<()> { - let activity_log = activity_log_empty; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); + let TestData { + activities: _, + store, + } = activity_store_empty; - assert!(store.list_activities(ActivityFilter::All)?.is_none()); + assert!(store.list_activities(ActivityFilter::Everything)?.is_none()); Ok(()) } #[rstest] -fn test_activity_store_update_activity_passes( - activity_store_with_item: TestResult<(ActivityGuid, Activity, ActivityStore)>, -) -> TestResult<()> { - let (og_activity_id, og_activity, store) = activity_store_with_item?; +fn test_activity_store_update_activity_passes(activity_store: TestData) -> TestResult<()> { + let TestData { activities, store } = activity_store; + + let og_activity = activities[0].clone(); + let og_activity_id = *og_activity.guid(); let updated_test_desc = "Updated Test Description".to_string(); let updated_test_cat = "Test::UpdatedCategory".to_string(); - let mut new_activity = Activity::builder() + let new_activity = Activity::builder() .description(updated_test_desc.to_string()) .category(updated_test_cat) .build(); @@ -255,18 +353,25 @@ fn test_activity_store_update_activity_passes( let stored_activity = store.read_activity(og_activity_id)?; - _ = new_activity.guid_mut().replace(og_activity_id); + assert_eq!( + stored_activity.activity().begin(), + og_activity.activity().begin() + ); - assert_eq!(stored_activity, new_activity); + assert_eq!( + stored_activity.activity().begin(), + og_activity.activity().begin() + ); Ok(()) } #[rstest] -fn test_activity_store_delete_activity_passes( - activity_store_with_item: TestResult<(ActivityGuid, Activity, ActivityStore)>, -) -> TestResult<()> { - let (og_activity_id, og_activity, store) = activity_store_with_item?; +fn test_activity_store_delete_activity_passes(activity_store: TestData) -> TestResult<()> { + let TestData { activities, store } = activity_store; + + let og_activity = activities[0].clone(); + let og_activity_id = *og_activity.guid(); let activity = store.delete_activity(og_activity_id)?; @@ -278,13 +383,11 @@ fn test_activity_store_delete_activity_passes( } #[rstest] -fn test_activity_store_delete_activity_fails( - activity_log_with_content: (Vec, ActivityLog), -) { - let (_, activity_log) = activity_log_with_content; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); +fn test_activity_store_delete_activity_fails(activity_store: TestData) { + let TestData { + activities: _, + store, + } = activity_store; let activity_id = ActivityGuid::default(); @@ -292,13 +395,11 @@ fn test_activity_store_delete_activity_fails( } #[rstest] -fn test_activity_store_update_activity_fails( - activity_log_with_content: (Vec, ActivityLog), -) { - let (_, activity_log) = activity_log_with_content; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); +fn test_activity_store_update_activity_fails(activity_store: TestData) { + let TestData { + activities: _, + store, + } = activity_store; let new_activity = Activity::builder() .description("test".to_string()) @@ -312,108 +413,208 @@ fn test_activity_store_update_activity_fails( #[rstest] fn test_activity_store_begin_intermission_passes( - activity_log_for_intermissions: (Vec, ActivityLog), + activity_store_no_intermissions: TestData, ) -> PaceResult<()> { - let (og_activities, activity_log) = activity_log_for_intermissions; + let TestData { activities, store } = activity_store_no_intermissions; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); + let og_activity = activities + .into_iter() + .find(|a| a.activity().is_active()) + .expect("Should have an active activity."); - let _held_activity = store.hold_last_unfinished_activity(None)?; + let og_activity_id = og_activity.guid(); - let activities = store - .list_activities(ActivityFilter::All)? + let held_activity = store.hold_last_unfinished_activity(HoldOptions::default())?; + + assert!(held_activity.is_some()); + + assert_eq!(og_activity, held_activity.unwrap()); + + let active_intermissions = store + .list_activities(ActivityFilter::ActiveIntermission)? .expect("Should have activities.") - .into_log(); + .into_vec(); + + assert_eq!( + active_intermissions.len(), + 1, + "Should have one intermission." + ); - assert_eq!(activities.activities().len(), 2); + let intermission = active_intermissions + .first() + .expect("Should have intermission."); - let intermission = activities - .activities() - .iter() - .find(|a| a.is_active_intermission()) + let intermission = store + .read_activity(*intermission) .expect("Should have intermission."); assert_eq!( - intermission.category(), + intermission.activity().category(), &Some("Test::Intermission".to_string()) ); assert_eq!( - intermission.description(), - &Some("Test Description".to_string()) + intermission.activity().description(), + &Some("Activity with Intermission".to_string()) ); - assert_eq!(intermission.is_active_intermission(), true); + assert_eq!( + intermission.activity().is_active_intermission(), + true, + "Intermission should be considered active." + ); - assert!(intermission.activity_end_options().is_none()); + assert!( + intermission.activity().activity_end_options().is_none(), + "Intermission should not contain end options." + ); assert_eq!( - intermission.parent_id().unwrap(), - og_activities.first().unwrap().guid().unwrap() + intermission.activity().parent_id().unwrap(), + *og_activity_id, + "Parent ID should be the same as original activity." ); - // dbg!(&intermission); - // dbg!(&activities); - Ok(()) } #[rstest] -fn test_activity_store_end_intermission_passes( - activity_log_for_intermissions: (Vec, ActivityLog), -) -> PaceResult<()> { - let (_og_activities, activity_log) = activity_log_for_intermissions; +fn test_activity_store_begin_intermission_with_existing_does_nothing_passes( + activity_store: TestData, +) -> TestResult<()> { + let TestData { activities, store } = activity_store; - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log, - ))); + assert!( + store + .hold_last_unfinished_activity(HoldOptions::default())? + .is_some_and(|a| a.activity().is_active()), + "Should contain and active activity." + ); - let _held_activity = store.hold_last_unfinished_activity(None)?; + assert_eq!( + activities.len(), + store + .list_activities(ActivityFilter::Everything)? + .expect("Should have activities.") + .into_vec() + .len(), + "Should have no new activities." + ); + // check that the intermission is still active let activities = store - .list_activities(ActivityFilter::All)? + .list_activities(ActivityFilter::ActiveIntermission)? .expect("Should have activities.") - .into_log(); + .into_vec(); - assert_eq!(activities.activities().len(), 2); + let intermission = activities.first().expect("Should have intermission."); - let ended_intermissions = store.end_all_active_intermissions(None)?.unwrap(); + let intermission = store + .read_activity(*intermission) + .expect("Should have intermission."); + + assert_eq!( + intermission.activity().category(), + &Some("Test::Intermission".to_string()) + ); + + assert_eq!( + intermission.activity().description(), + &Some("Activity with Intermission".to_string()) + ); + + assert_eq!( + intermission.activity().is_active_intermission(), + true, + "Intermission should be considered active." + ); + + Ok(()) +} + +#[rstest] +fn test_activity_store_end_intermission_passes(activity_store: TestData) -> TestResult<()> { + let TestData { + activities: og_activities, + store, + } = activity_store; + + let ended_intermissions = store + .end_all_active_intermissions(EndOptions::default())? + .unwrap(); // There should be one ended intermission - assert_eq!(ended_intermissions.len(), 1); + assert_eq!( + ended_intermissions.len(), + 1, + "Should have one intermission." + ); let intermission = ended_intermissions.first().unwrap(); + let intermission = store + .read_activity(*intermission) + .expect("Should have intermission."); + let activities = store - .list_activities(ActivityFilter::All)? + .list_activities(ActivityFilter::Everything)? .expect("Should have activities.") - .into_log(); - - // No new intermissions should be created - assert_eq!(activities.activities().len(), 2); + .into_vec(); - assert!(intermission.activity_end_options().is_some()); + assert_eq!( + activities.len(), + og_activities.len(), + "No new intermissions should be created." + ); - assert_eq!(intermission.is_active_intermission(), false); + assert!( + intermission.activity().activity_end_options().is_some(), + "Intermission should have end options." + ); - dbg!(&activities.activities()); + assert_eq!( + intermission.activity().is_active_intermission(), + false, + "Intermission shouldn't be considered active anymore." + ); Ok(()) } #[rstest] fn test_activity_store_end_intermission_with_empty_log_passes( - activity_log_empty: ActivityLog, + activity_store_empty: TestData, ) -> TestResult<()> { - let store = ActivityStore::new(Box::new(InMemoryActivityStorage::new_with_activity_log( - activity_log_empty, - ))); + let TestData { + activities: _, + store, + } = activity_store_empty; - let result = store.end_all_active_intermissions(None)?; + let result = store.end_all_active_intermissions(EndOptions::default())?; assert!(result.is_none()); Ok(()) } + +#[rstest] +fn test_activity_store_resume_activity_passes(activity_store: TestData) -> PaceResult<()> { + let TestData { + activities: test_activities, + store, + } = activity_store; + + let activities = store + .list_activities(ActivityFilter::Everything)? + .expect("Should have activities.") + .into_vec(); + + dbg!(&activities); + + assert_eq!(activities.len(), test_activities.len()); + + // let resumed_activity = store.resume_last_unfinished_activity(None)?; + + Ok(()) +} diff --git a/data/activity_2024-02.pace.toml b/data/activity_2024-02.pace.toml index b27e7220..a2512c4a 100644 --- a/data/activity_2024-02.pace.toml +++ b/data/activity_2024-02.pace.toml @@ -1,41 +1,35 @@ -[[activities]] -id = "01HPY705770QKPC8D7AA8W4FNT" +[01HPY705770QKPC8D7AA8W4FNT] category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:58:54" kind = "activity" -[[activities]] -id = "01HPY70577HNJPA7MC2NKW5JT1" +[01HPY70577HNJPA7MC2NKW5JT1] category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:58:51" kind = "activity" -[[activities]] -id = "01HPY7057730PYEPN7R3Q9TWC7" +[01HPY7057730PYEPN7R3Q9TWC7] category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:58:50" kind = "activity" -[[activities]] -id = "01HPY7057761DVHMFW64P1YXQ8" +[01HPY7057761DVHMFW64P1YXQ8] category = "MyCategory::SubCategory" description = "This is my task description" begin = "2024-02-10T01:23:03" kind = "activity" -[[activities]] -id = "01HPY70577MQYQXTR4YFJ6NB1Y" +[01HPY70577MQYQXTR4YFJ6NB1Y] end = "2024-02-04T00:00:00" begin = "2024-02-03T23:30:00" duration = 1800 kind = "intermission" parent-id = "01HPY70577HJBZ20NQR15AR9G0" -[[activities]] -id = "01HPY70577HJBZ20NQR15AR9G0" +[01HPY70577HJBZ20NQR15AR9G0] category = "design::pace" description = "Initial design process and requirements analysis." end = "2024-02-04T00:15:00" @@ -43,8 +37,7 @@ duration = 6300 begin = "2024-02-03T22:30:00" kind = "task" -[[activities]] -id = "01HPY70577H375FDKT9XXAT7VB" +[01HPY70577H375FDKT9XXAT7VB] category = "development::pace" description = "Implemented the login feature." end = "2024-02-04T10:30:00" @@ -52,8 +45,7 @@ begin = "2024-02-04T09:00:00" duration = 5400 kind = "task" -[[activities]] -id = "01HPY70577PMEY35A8V8FV30VC" +[01HPY70577PMEY35A8V8FV30VC] category = "research::pace" description = "Researched secure authentication methods." end = "2024-02-04T12:00:00" diff --git a/src/commands.rs b/src/commands.rs index ff619625..2ded1fc7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -40,7 +40,7 @@ pub enum PaceCmd { /// Starts tracking time for the specified activity. Begin(begin::BeginCmd), - /// Pauses the time tracking for the specified activity. + /// Pauses the time tracking for the most recently active activity. Hold(hold::HoldCmd), /// Shows you at a glance what you're currently tracking. diff --git a/src/commands/begin.rs b/src/commands/begin.rs index 8d4fb199..02a7408c 100644 --- a/src/commands/begin.rs +++ b/src/commands/begin.rs @@ -90,16 +90,12 @@ impl BeginCmd { let activity_store = ActivityStore::new(get_storage_from_config(config)?); - let activity_id = activity_store.begin_activity(activity.clone())?; + let activity_item = activity_store.begin_activity(activity.clone())?; - if let Some(og_activity_id) = activity.guid() { - if activity_id == *og_activity_id { - activity_store.sync()?; - println!("{activity}"); - return Ok(()); - } - } + activity_store.sync()?; - eyre::bail!("Failed to start {activity}"); + println!("{}", activity_item.activity()); + + Ok(()) } } diff --git a/src/commands/end.rs b/src/commands/end.rs index def7e8af..41463346 100644 --- a/src/commands/end.rs +++ b/src/commands/end.rs @@ -8,7 +8,7 @@ use crate::prelude::PACE_APP; use pace_core::{ get_storage_from_config, parse_time_from_user_input, ActivityStateManagement, ActivityStore, - SyncStorage, + EndOptions, SyncStorage, }; /// `end` subcommand #[derive(Command, Debug, Parser)] @@ -44,15 +44,17 @@ impl EndCmd { let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + let end_opts = EndOptions::builder().end_time(time).build(); + if *only_last { - if let Some(last_activity) = activity_store.end_last_unfinished_activity(time)? { - println!("Ended {last_activity}"); + if let Some(last_activity) = activity_store.end_last_unfinished_activity(end_opts)? { + println!("Ended {}", last_activity.activity()); } } else if let Some(unfinished_activities) = - activity_store.end_all_unfinished_activities(time)? + activity_store.end_all_unfinished_activities(end_opts)? { for activity in &unfinished_activities { - println!("Ended {activity}"); + println!("Ended {}", activity.activity()); } } else { println!("No unfinished activities to end."); diff --git a/src/commands/hold.rs b/src/commands/hold.rs index a6005fba..5a6fc9c9 100644 --- a/src/commands/hold.rs +++ b/src/commands/hold.rs @@ -6,7 +6,7 @@ use clap::Parser; use eyre::Result; use pace_core::{ get_storage_from_config, parse_time_from_user_input, ActivityStateManagement, ActivityStore, - SyncStorage, + HoldOptions, IntermissionAction, SyncStorage, }; use crate::prelude::PACE_APP; @@ -17,6 +17,16 @@ 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 reason for the intermission, if this is not set, the description of the activity to be held will be used + #[clap(long)] + reason: Option, + + /// If there are existing intermissions, they will be finished and a new one is being created + /// + /// This is useful, if you want to also track the purpose of an interruption to an activity. + #[clap(long)] + new_if_exists: bool, } impl Runnable for HoldCmd { @@ -32,15 +42,31 @@ impl Runnable for HoldCmd { impl HoldCmd { /// Inner run implementation for the hold command pub fn inner_run(&self) -> Result<()> { - let HoldCmd { time } = self; + let HoldCmd { + time, + new_if_exists, + reason, + } = self; + + let action = if *new_if_exists { + IntermissionAction::New + } else { + IntermissionAction::Extend + }; let time = parse_time_from_user_input(time)?; + let hold_opts = HoldOptions::builder() + .action(action) + .reason(reason.clone()) + .begin_time(time) + .build(); + let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); - if let Some(activity) = activity_store.hold_last_unfinished_activity(time)? { + if let Some(activity) = activity_store.hold_last_unfinished_activity(hold_opts)? { activity_store.sync()?; - println!("Held {activity}"); + println!("Held {}", activity.activity()); } else { println!("No unfinished activities to hold."); }; diff --git a/src/commands/now.rs b/src/commands/now.rs index 25d25735..080b73f4 100644 --- a/src/commands/now.rs +++ b/src/commands/now.rs @@ -6,7 +6,10 @@ use eyre::Result; use crate::prelude::PACE_APP; -use pace_core::{get_storage_from_config, ActivityQuerying, ActivityStorage, ActivityStore}; +use pace_core::{ + get_storage_from_config, ActivityItem, ActivityQuerying, ActivityReadOps, ActivityStorage, + ActivityStore, +}; /// `now` subcommand #[derive(Command, Debug, Parser)] @@ -31,10 +34,14 @@ impl NowCmd { match activity_store.list_current_activities()? { Some(activities) => { - activities - .activities() + let activity_items = activities .iter() - .for_each(|activity| println!("{activity}")); + .flat_map(|activity_id| activity_store.read_activity(*activity_id)) + .collect::>(); + + activity_items.iter().for_each(|activity| { + println!("{}", activity.activity()); + }); } None => { println!("No activities are currently running."); diff --git a/src/commands/resume.rs b/src/commands/resume.rs index 25f888fe..ed19b51a 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -6,7 +6,7 @@ use clap::Parser; use dialoguer::{theme::ColorfulTheme, FuzzySelect}; use eyre::Result; -use pace_core::{get_storage_from_config, ActivityQuerying, ActivityStore}; +use pace_core::{get_storage_from_config, ActivityQuerying, ActivityReadOps, ActivityStore}; use crate::prelude::PACE_APP; @@ -45,32 +45,39 @@ impl ResumeCmd { let ResumeCmd { list } = self; let activity_store = ActivityStore::new(get_storage_from_config(&PACE_APP.config())?); + if *list { // List activities to resume with fuzzy search and select // TODO: add symbols for intermissions, ended or archived activities - if let Some(activity_log) = activity_store.list_most_recent_activities(usize::from( + if let Some(activity_ids) = activity_store.list_most_recent_activities(usize::from( PACE_APP .config() .general() .most_recent_count() .unwrap_or_else(|| 9u8), ))? { - let items: Vec = activity_log + let activity_items = activity_ids + .iter() + // TODO: With pomodoro, we might want to filter for activities that are not intermissions + .flat_map(|activity_id| activity_store.read_activity(*activity_id)) + .collect::>(); + + let string_repr = activity_items .iter() - .map(|activity| activity.to_string()) - .collect(); + .map(|activity| activity.activity().to_string()) + .collect::>(); let selection = FuzzySelect::with_theme(&ColorfulTheme::default()) .with_prompt("Which activity do you want to continue?") - .items(&items) + .items(&string_repr) .interact() .unwrap(); - let activity = activity_log.get(selection).unwrap(); + let activity = activity_items.get(selection).map(|f| f.activity()); // TODO: check what other things are needed to resume this activity // TODO: Anything to sync to storage? - println!("Resumed {activity}"); + println!("Resumed {activity:?}"); } else { println!("No recent activities to continue."); }; @@ -78,7 +85,7 @@ impl ResumeCmd { if let Some(activity_log) = active_intermissions { // TODO: check for open intermissions // TODO: if there is no open intermission, resume the last unfinished activity - if activity_log.len() == 0 { + if activity_log.is_empty() { // TODO: Resume last unfinished activity that has no active/open intermissions (Default without options) println!("Resume last unfinished activity"); } else {