From b56ff0728eeecc320cc3958f297be25ad61e7690 Mon Sep 17 00:00:00 2001 From: simonsan <14062932+simonsan@users.noreply.github.com> Date: Fri, 8 Mar 2024 03:00:18 +0100 Subject: [PATCH] feat(review): split categories into (sub-)categories and deduplicated based on that --- crates/core/src/domain/category.rs | 36 +++++++++++++++++++++ crates/core/src/domain/review.rs | 8 ++--- crates/core/src/lib.rs | 3 +- crates/core/src/service/activity_store.rs | 38 +++++++++++++++++------ crates/core/tests/activity_tracker.rs | 2 +- 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/crates/core/src/domain/category.rs b/crates/core/src/domain/category.rs index 94ec977a..07ce3fe7 100644 --- a/crates/core/src/domain/category.rs +++ b/crates/core/src/domain/category.rs @@ -4,6 +4,8 @@ use serde_derive::{Deserialize, Serialize}; use typed_builder::TypedBuilder; use ulid::Ulid; +use crate::GeneralConfig; + /// The category entity #[derive(Debug, Serialize, Deserialize, TypedBuilder, Clone)] pub struct Category { @@ -56,6 +58,40 @@ pub fn extract_categories(category_string: &str, separator: &str) -> (Category, } } +/// Splits the category by the category separator or the default +/// separator from `GeneralConfig` +/// +/// # Arguments +/// +/// * `category_string` - The category string +/// * `separator` - The separator used to separate the category and subcategory +/// +/// # Returns +/// +/// A tuple containing the category and and optional subcategory +pub fn split_category_by_category_separator( + category_string: &str, + separator: Option<&str>, +) -> (String, Option) { + let default_separator = GeneralConfig::default() + .category_separator() + .clone() + .unwrap_or("::".to_string()); + + let separator = separator.unwrap_or(default_separator.as_str()); + + let parts: Vec<_> = category_string.split(separator).collect(); + + if parts.len() > 1 { + // if there are more than one part, the first part is the category + // and the rest is the subcategory + (parts[0].to_string(), Some(parts[1..].concat())) + } else { + // if there is only one part, it's the category + (parts[0].to_string(), None) + } +} + /// The category id #[derive(Debug, Serialize, Deserialize, Clone, Copy)] pub struct CategoryGuid(Ulid); diff --git a/crates/core/src/domain/review.rs b/crates/core/src/domain/review.rs index b5fcc014..33f4809a 100644 --- a/crates/core/src/domain/review.rs +++ b/crates/core/src/domain/review.rs @@ -38,9 +38,9 @@ pub enum ReviewFormatKind { /// Represents a category for summarizing activities. // We use a string to allow for user-defined categories for now, // but we may want to change this to an enum in the future. -pub type SummaryCategory = String; +pub type SummaryCategories = (String, String); -pub type SummaryGroupByCategory = BTreeMap; +pub type SummaryGroupByCategory = BTreeMap; /// Represents a summary of activities and insights for a specified review period. #[derive( @@ -106,7 +106,7 @@ impl std::fmt::Display for ReviewSummary { "Breaks (Amount)", ]); - for (category, summary_group) in self.summary_groups_by_category.iter() { + for ((category, subcategory), summary_group) in self.summary_groups_by_category.iter() { builder.push_record(vec![ category, "", @@ -116,7 +116,7 @@ impl std::fmt::Display for ReviewSummary { for (description, activity_group) in summary_group.activity_groups_by_description() { builder.push_record(vec![ - "", + subcategory, description, format!( "{} ({})", diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7efc1a8e..8fc7cc35 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -49,10 +49,11 @@ pub use crate::{ ActivityKindOptions, ActivitySession, }, activity_log::ActivityLog, + category::split_category_by_category_separator, filter::{ActivityFilterKind, FilteredActivities}, intermission::IntermissionAction, review::{ - Highlights, ReviewSummary, SummaryActivityGroup, SummaryCategory, + Highlights, ReviewSummary, SummaryActivityGroup, SummaryCategories, SummaryGroupByCategory, }, status::ActivityStatus, diff --git a/crates/core/src/service/activity_store.rs b/crates/core/src/service/activity_store.rs index 2bfcdaf4..efa609f8 100644 --- a/crates/core/src/service/activity_store.rs +++ b/crates/core/src/service/activity_store.rs @@ -11,6 +11,7 @@ use crate::{ commands::{resume::ResumeOptions, DeleteOptions, UpdateOptions}, domain::{ activity::{Activity, ActivityGuid, ActivityItem, ActivitySession}, + category, filter::{ActivityFilterKind, FilteredActivities}, review::SummaryGroupByCategory, }, @@ -40,6 +41,12 @@ pub struct ActivityStoreCache { by_start_date: BTreeMap>, } +type Category = String; + +type Subcategory = Option; + +type Description = String; + impl ActivityStore { /// Create a new `ActivityStore` with a given storage backend /// @@ -105,7 +112,7 @@ impl ActivityStore { let mut summary_groups: SummaryGroupByCategory = BTreeMap::new(); let mut activity_sessions_lookup_by_category: HashMap< - (String, String), + (Category, Subcategory, Description), Vec, > = HashMap::new(); @@ -124,14 +131,21 @@ impl ActivityStore { activity_session.add_multiple_intermissions(intermissions); }; + // Handle splitting subcategories + let (category, subcategory) = category::split_category_by_category_separator( + activity_item + .activity() + .category() + .as_deref() + .unwrap_or("Uncategorized"), + None, + ); + // Deduplicate activities by category and description first _ = activity_sessions_lookup_by_category .entry(( - activity_item - .activity() - .category() - .clone() - .unwrap_or("Uncategorized".to_string()), + category, + subcategory, activity_item.activity().description().to_owned(), )) .and_modify(|e| e.push(activity_session.clone())) @@ -144,20 +158,26 @@ impl ActivityStore { ); // Deduplicate activities by description - for ((category, description), activity_sessions) in &activity_sessions_lookup_by_category { + for ((category, subcategory, description), activity_sessions) in + &activity_sessions_lookup_by_category + { if activity_sessions.is_empty() { // Skip if there are no activity sessions continue; } - // Now we have a list of activity sessions grouped by description and category + // FIXME: This is a bit of a hack to handle the subcategory + // It will be an empty string if not present + let subcategory = subcategory.clone().unwrap_or_default(); + + // Now we have a list of activity sessions grouped by description and (sub)category let activity_group = ActivityGroup::with_multiple_sessions( description.clone(), activity_sessions.to_vec(), ); _ = summary_groups - .entry(category.clone()) + .entry((category.clone(), subcategory)) .and_modify(|e| e.add_activity_group(activity_group.clone())) .or_insert_with(|| SummaryActivityGroup::with_activity_group(activity_group)); } diff --git a/crates/core/tests/activity_tracker.rs b/crates/core/tests/activity_tracker.rs index 9db451ce..b64ecd05 100644 --- a/crates/core/tests/activity_tracker.rs +++ b/crates/core/tests/activity_tracker.rs @@ -33,7 +33,7 @@ fn test_activity_tracker( ); let group = summary_groups_by_category - .get("development::pace") + .get(&("development".to_string(), "pace".to_string())) .ok_or("Should have a category.")?; assert_eq!(group.len(), 1, "Should have 1 activity.");