pace_core/service/
activity_store.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    sync::Arc,
4};
5
6use getset::{Getters, MutGetters, Setters};
7use pace_time::{date::PaceDate, duration::PaceDurationRange, time_range::TimeRangeOptions};
8use tracing::debug;
9use typed_builder::TypedBuilder;
10
11use wildmatch::WildMatch;
12
13use crate::{
14    commands::{
15        hold::HoldOptions, resume::ResumeOptions, DeleteOptions, EndOptions, KeywordOptions,
16        UpdateOptions,
17    },
18    domain::{
19        activity::{
20            Activity, ActivityGroup, ActivityGuid, ActivityItem, ActivityKind, ActivitySession,
21        },
22        category,
23        filter::{ActivityFilterKind, FilterOptions, FilteredActivities},
24        reflection::{SummaryActivityGroup, SummaryGroupByCategory},
25        status::ActivityStatusKind,
26    },
27    error::{ActivityStoreErrorKind, PaceOptResult, PaceResult},
28    storage::{
29        ActivityQuerying, ActivityReadOps, ActivityStateManagement, ActivityStorage,
30        ActivityWriteOps, StorageKind, SyncStorage,
31    },
32};
33
34/// The activity store entity
35#[derive(TypedBuilder, Getters, Setters, MutGetters)]
36#[getset(get = "pub", get_mut = "pub", set = "pub")]
37pub struct ActivityStore {
38    /// In-memory cache for activities
39    cache: ActivityStoreCache,
40
41    /// The storage backend
42    storage: Arc<StorageKind>,
43}
44
45#[derive(Debug, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq, Default)]
46#[getset(get = "pub", get_mut = "pub", set = "pub")]
47pub struct ActivityStoreCache {
48    by_start_date: BTreeMap<PaceDate, Vec<ActivityItem>>,
49}
50
51type Category = String;
52
53type Subcategory = Option<String>;
54
55type Description = String;
56
57impl ActivityStore {
58    /// Create a new `ActivityStore` with a given storage backend
59    ///
60    /// # Arguments
61    ///
62    /// * `storage` - The storage backend to use for the activity store
63    ///
64    /// # Errors
65    ///
66    /// This method will return an error if the storage backend cannot be used
67    ///
68    /// # Returns
69    ///
70    /// This method returns a new `ActivityStore` if the storage backend
71    /// was successfully created
72    pub fn with_storage(storage: Arc<StorageKind>) -> PaceResult<Self> {
73        debug!("Creating activity store with storage: {}", storage);
74
75        let mut store = Self {
76            cache: ActivityStoreCache::default(),
77            storage,
78        };
79
80        store.setup_storage()?;
81
82        store.populate_caches()?;
83
84        Ok(store)
85    }
86
87    /// Populate the in-memory cache with activities from the storage backend
88    ///
89    /// This method is called during the initialization of the activity store
90    ///
91    /// # Errors
92    ///
93    /// This method will return an error if the cache cannot be populated
94    ///
95    /// # Returns
96    ///
97    /// This method returns `Ok(())` if the cache was successfully populated
98    fn populate_caches(&mut self) -> PaceResult<()> {
99        self.cache.by_start_date = self
100            .storage
101            .group_activities_by_start_date()?
102            .ok_or(ActivityStoreErrorKind::PopulatingCache)?;
103
104        Ok(())
105    }
106
107    #[tracing::instrument(skip(self))]
108    pub fn summary_groups_by_category_for_time_range(
109        &self,
110        filter_opts: FilterOptions,
111        time_range_opts: TimeRangeOptions,
112    ) -> PaceOptResult<SummaryGroupByCategory> {
113        let Some(activity_guids) = self.list_activities_by_time_range(time_range_opts)? else {
114            debug!("No activities found for time range: {:?}", time_range_opts);
115
116            return Ok(None);
117        };
118
119        // merge the activities into summary groups
120        let mut summary_groups: SummaryGroupByCategory = BTreeMap::new();
121
122        let mut activity_sessions_lookup_by_category: HashMap<
123            (Category, Subcategory, Description),
124            Vec<ActivitySession>,
125        > = HashMap::new();
126
127        // Temporarily end all activities for duration calculation
128        let _ = self.end_all_active_intermissions(EndOptions::default())?;
129        let _ = self.end_all_activities(EndOptions::default())?;
130
131        for activity_guid in activity_guids {
132            let activity_item = self.read_activity(activity_guid)?;
133
134            let activity_category = activity_item
135                .activity()
136                .category()
137                .as_deref()
138                .unwrap_or("Uncategorized")
139                .to_string();
140
141            // Skip if category does not match user input
142            if let Some(category) = filter_opts.category() {
143                let (filter_category, activity_category) = if *filter_opts.case_sensitive() {
144                    (category.clone(), activity_category.clone())
145                } else {
146                    (category.to_lowercase(), activity_category.to_lowercase())
147                };
148
149                if !WildMatch::new(&filter_category).matches(&activity_category) {
150                    continue;
151                }
152            }
153
154            let mut activity_session = ActivitySession::new(activity_item.clone());
155
156            if let Some(intermissions) =
157                self.list_intermissions_for_activity_id(*activity_item.guid())?
158            {
159                activity_session.add_multiple_intermissions(intermissions);
160            };
161
162            // Handle splitting subcategories
163            let (category, subcategory) =
164                category::split_category_by_category_separator(&activity_category, None);
165
166            // Deduplicate activities by category and description first
167            _ = activity_sessions_lookup_by_category
168                .entry((
169                    category,
170                    subcategory,
171                    activity_item.activity().description().to_owned(),
172                ))
173                .and_modify(|e| e.push(activity_session.clone()))
174                .or_insert_with(|| vec![activity_session.clone()]);
175        }
176
177        debug!(
178            "Activity sessions lookup by category: {:#?}",
179            activity_sessions_lookup_by_category
180        );
181
182        // Deduplicate activities by description
183        for ((category, subcategory, description), activity_sessions) in
184            &activity_sessions_lookup_by_category
185        {
186            if activity_sessions.is_empty() {
187                // Skip if there are no activity sessions
188                continue;
189            }
190
191            // FIXME: This is a bit of a hack to handle the subcategory
192            // It will be an empty string if not present
193            let subcategory = subcategory.clone().unwrap_or_default();
194
195            // Now we have a list of activity sessions grouped by description and (sub)category
196            let activity_group = ActivityGroup::with_multiple_sessions(
197                description.clone(),
198                activity_sessions.clone(),
199            );
200
201            _ = summary_groups
202                .entry((category.clone(), subcategory))
203                .and_modify(|e| e.add_activity_group(activity_group.clone()))
204                .or_insert_with(|| SummaryActivityGroup::with_activity_group(activity_group));
205        }
206
207        Ok(Some(summary_groups))
208    }
209}
210
211impl ActivityStorage for ActivityStore {
212    #[tracing::instrument(skip(self))]
213    fn setup_storage(&self) -> PaceResult<()> {
214        self.storage.setup_storage()
215    }
216}
217
218impl SyncStorage for ActivityStore {
219    #[tracing::instrument(skip(self))]
220    fn sync(&self) -> PaceResult<()> {
221        self.storage.sync()
222    }
223}
224
225impl ActivityReadOps for ActivityStore {
226    #[tracing::instrument(skip(self))]
227    fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult<ActivityItem> {
228        self.storage.read_activity(activity_id)
229    }
230
231    #[tracing::instrument(skip(self))]
232    fn list_activities(&self, filter: ActivityFilterKind) -> PaceOptResult<FilteredActivities> {
233        self.storage.list_activities(filter)
234    }
235}
236
237impl ActivityWriteOps for ActivityStore {
238    #[tracing::instrument(skip(self))]
239    fn create_activity(&self, activity: Activity) -> PaceResult<ActivityItem> {
240        self.storage.create_activity(activity)
241    }
242
243    #[tracing::instrument(skip(self))]
244    fn update_activity(
245        &self,
246        activity_id: ActivityGuid,
247        updated_activity: Activity,
248        update_opts: UpdateOptions,
249    ) -> PaceResult<ActivityItem> {
250        self.storage
251            .update_activity(activity_id, updated_activity, update_opts)
252    }
253
254    #[tracing::instrument(skip(self))]
255    fn delete_activity(
256        &self,
257        activity_id: ActivityGuid,
258        delete_opts: DeleteOptions,
259    ) -> PaceResult<ActivityItem> {
260        self.storage.delete_activity(activity_id, delete_opts)
261    }
262}
263
264impl ActivityStateManagement for ActivityStore {
265    #[tracing::instrument(skip(self))]
266    fn begin_activity(&self, activity: Activity) -> PaceResult<ActivityItem> {
267        self.storage.begin_activity(activity)
268    }
269
270    #[tracing::instrument(skip(self))]
271    fn end_activity(
272        &self,
273        activity_id: ActivityGuid,
274        end_opts: EndOptions,
275    ) -> PaceResult<ActivityItem> {
276        self.storage.end_activity(activity_id, end_opts)
277    }
278
279    #[tracing::instrument(skip(self))]
280    fn end_all_activities(&self, end_opts: EndOptions) -> PaceOptResult<Vec<ActivityItem>> {
281        self.storage.end_all_activities(end_opts)
282    }
283
284    #[tracing::instrument(skip(self))]
285    fn end_last_unfinished_activity(&self, end_opts: EndOptions) -> PaceOptResult<ActivityItem> {
286        self.storage.end_last_unfinished_activity(end_opts)
287    }
288
289    #[tracing::instrument(skip(self))]
290    fn hold_most_recent_active_activity(
291        &self,
292        hold_opts: HoldOptions,
293    ) -> PaceOptResult<ActivityItem> {
294        self.storage.hold_most_recent_active_activity(hold_opts)
295    }
296
297    #[tracing::instrument(skip(self))]
298    fn end_all_active_intermissions(
299        &self,
300        end_opts: EndOptions,
301    ) -> PaceOptResult<Vec<ActivityGuid>> {
302        self.storage.end_all_active_intermissions(end_opts)
303    }
304
305    #[tracing::instrument(skip(self))]
306    fn resume_activity(
307        &self,
308        activity_id: ActivityGuid,
309        resume_opts: ResumeOptions,
310    ) -> PaceResult<ActivityItem> {
311        self.storage.resume_activity(activity_id, resume_opts)
312    }
313
314    #[tracing::instrument(skip(self))]
315    fn hold_activity(
316        &self,
317        activity_id: ActivityGuid,
318        hold_opts: HoldOptions,
319    ) -> PaceResult<ActivityItem> {
320        self.storage.hold_activity(activity_id, hold_opts)
321    }
322
323    #[tracing::instrument(skip(self))]
324    fn resume_most_recent_activity(
325        &self,
326        resume_opts: ResumeOptions,
327    ) -> PaceOptResult<ActivityItem> {
328        self.storage.resume_most_recent_activity(resume_opts)
329    }
330}
331
332impl ActivityQuerying for ActivityStore {
333    #[tracing::instrument(skip(self))]
334    fn list_activities_by_id(&self) -> PaceOptResult<BTreeMap<ActivityGuid, Activity>> {
335        self.storage.list_activities_by_id()
336    }
337
338    #[tracing::instrument(skip(self))]
339    fn group_activities_by_duration_range(
340        &self,
341    ) -> PaceOptResult<BTreeMap<PaceDurationRange, Vec<ActivityItem>>> {
342        self.storage.group_activities_by_duration_range()
343    }
344
345    #[tracing::instrument(skip(self))]
346    fn group_activities_by_start_date(
347        &self,
348    ) -> PaceOptResult<BTreeMap<PaceDate, Vec<ActivityItem>>> {
349        self.storage.group_activities_by_start_date()
350    }
351
352    #[tracing::instrument(skip(self))]
353    fn list_activities_with_intermissions(
354        &self,
355    ) -> PaceOptResult<BTreeMap<ActivityGuid, Vec<ActivityItem>>> {
356        self.storage.list_activities_with_intermissions()
357    }
358
359    #[tracing::instrument(skip(self))]
360    fn group_activities_by_keywords(
361        &self,
362        keyword_opts: KeywordOptions,
363    ) -> PaceOptResult<BTreeMap<String, Vec<ActivityItem>>> {
364        self.storage.group_activities_by_keywords(keyword_opts)
365    }
366
367    #[tracing::instrument(skip(self))]
368    fn group_activities_by_kind(&self) -> PaceOptResult<BTreeMap<ActivityKind, Vec<ActivityItem>>> {
369        self.storage.group_activities_by_kind()
370    }
371
372    #[tracing::instrument(skip(self))]
373    fn list_activities_by_time_range(
374        &self,
375        time_range_opts: TimeRangeOptions,
376    ) -> PaceOptResult<Vec<ActivityGuid>> {
377        self.storage.list_activities_by_time_range(time_range_opts)
378    }
379
380    #[tracing::instrument(skip(self))]
381    fn group_activities_by_status(
382        &self,
383    ) -> PaceOptResult<BTreeMap<ActivityStatusKind, Vec<ActivityItem>>> {
384        self.storage.group_activities_by_status()
385    }
386}