1use crate::model::{Label, Milestone, TimeLog, TrackableItem, User};
4use chrono::{Duration, Local, NaiveDate};
5use std::collections::{BTreeMap, HashSet};
6
7pub fn group_by_filter<'a, T, I, F>(
16 nodes: I,
17 filter: F,
18) -> impl Iterator<Item = (&'a T, Vec<&'a TimeLog>)>
19where
20 T: Ord + 'a,
21 I: IntoIterator<Item = &'a TimeLog>,
22 F: Fn(&'a TimeLog) -> &'a T,
23{
24 let mut grouped = BTreeMap::<&'a T, Vec<&'a TimeLog>>::new();
25 for node in nodes {
26 let key = filter(node);
27 grouped.entry(key).or_default().push(node);
28 }
29 grouped.into_iter()
30}
31
32pub fn group_by_user<'a>(
49 nodes: impl IntoIterator<Item = &'a TimeLog>,
50) -> impl Iterator<Item = (&'a User, Vec<&'a TimeLog>)> {
51 group_by_filter(nodes, |node| &node.user)
52}
53
54pub fn group_by_milestone<'a>(
56 nodes: impl IntoIterator<Item = &'a TimeLog>,
57) -> impl Iterator<Item = (Option<&'a Milestone>, Vec<&'a TimeLog>)> {
58 group_by_filter(nodes, |node| &node.trackable_item.common.milestone)
59 .map(|(milestone, nodes)| (milestone.as_ref(), nodes))
60}
61
62pub fn group_by_type<'a, 'b>(
66 nodes: impl IntoIterator<Item = &'a TimeLog>,
67) -> impl Iterator<Item = (String, Vec<&'a TimeLog>)> {
68 let mut grouped = BTreeMap::<String, Vec<&'a TimeLog>>::new();
69 for node in nodes {
70 let key = node.trackable_item.kind.to_string();
71 grouped.entry(key).or_default().push(node);
72 }
73 grouped.into_iter()
74}
75
76pub fn group_by_trackable_item<'a>(
78 time_logs: impl IntoIterator<Item = &'a TimeLog>,
79) -> impl Iterator<Item = (&'a TrackableItem, Vec<&'a TimeLog>)> {
80 group_by_filter(time_logs, |node| &node.trackable_item)
81}
82
83pub fn group_by_label<'a>(
94 nodes: impl IntoIterator<Item = &'a TimeLog>,
95 selected_labels: Option<&HashSet<String>>,
96 other_label: Option<&'a Label>,
97) -> impl Iterator<Item = (Option<&'a Label>, Vec<&'a TimeLog>)> {
98 let mut label_map = BTreeMap::<Option<&Label>, Vec<&TimeLog>>::new();
99
100 for time_log in nodes {
101 let labels = &time_log.trackable_item.common.labels.labels;
102
103 if labels.is_empty() {
104 if selected_labels.is_none() {
107 label_map.entry(None).or_default().push(time_log);
108 continue;
109 }
110
111 if other_label.is_some() {
113 label_map.entry(other_label).or_default().push(time_log);
114 }
115 continue;
116 }
117
118 let mut matched_any_selected_label = false;
119
120 for label in labels {
121 let should_include_label =
123 selected_labels.is_none_or(|sel_labels| sel_labels.contains(&label.title));
124
125 if should_include_label {
126 label_map.entry(Some(label)).or_default().push(time_log);
127 matched_any_selected_label = true;
128 }
129 }
130
131 if other_label.is_some() && !matched_any_selected_label {
133 label_map.entry(other_label).or_default().push(time_log);
134 }
135 }
136 label_map.into_iter()
137}
138
139pub fn filter_by_date<'a>(
141 nodes: impl IntoIterator<Item = &'a TimeLog>,
142 start: NaiveDate,
143 end: NaiveDate,
144) -> impl Iterator<Item = &'a TimeLog> {
145 nodes.into_iter().filter(move |node| {
146 let spent_day = node.spent_at.with_timezone(&Local).date_naive();
147 spent_day >= start && spent_day <= end
148 })
149}
150
151pub fn filter_by_last_n_days<'a>(
154 time_logs: impl IntoIterator<Item = &'a TimeLog>,
155 days: Duration,
156) -> impl Iterator<Item = &'a TimeLog> {
157 let end: NaiveDate = Local::now().date_naive();
158 let start: NaiveDate = end - days + Duration::days(1);
159 filter_by_date(time_logs, start, end)
160}
161
162#[must_use]
164pub fn total_time_spent<'a>(time_logs: impl IntoIterator<Item = &'a TimeLog>) -> Duration {
165 time_logs
166 .into_iter()
167 .map(|node| node.time_spent)
168 .sum::<Duration>()
169}
170
171pub fn total_time_spent_by_user<'a>(
173 time_logs: impl IntoIterator<Item = &'a TimeLog>,
174) -> impl Iterator<Item = (&'a User, Duration)> {
175 group_by_user(time_logs).map(|(user, timelogs)| (user, total_time_spent(timelogs)))
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::model::{
182 Issue, Labels, MergeRequest, TrackableItem, TrackableItemFields, TrackableItemKind,
183 };
184
185 const NUMBER_OF_LOGS: usize = 5;
186
187 #[expect(clippy::too_many_lines)]
188 fn get_timelogs() -> [TimeLog; NUMBER_OF_LOGS] {
189 let user1 = User {
190 name: "user1".to_string(),
191 username: "user1".to_string(),
192 };
193 let user2 = User {
194 name: "user2".to_string(),
195 username: "user2".to_string(),
196 };
197
198 [
199 TimeLog {
200 spent_at: Local::now() - Duration::days(2),
201 time_spent: Duration::seconds(3600),
202 summary: None,
203 user: user1.clone(),
204 trackable_item: TrackableItem::default(),
205 },
206 TimeLog {
207 spent_at: Local::now() - Duration::days(5),
208 time_spent: Duration::seconds(7200),
209 summary: Some("First entry".to_string()),
210 user: user2.clone(),
211 trackable_item: TrackableItem::default(),
212 },
213 TimeLog {
214 spent_at: Local::now() - Duration::days(1),
215 time_spent: Duration::seconds(3600),
216 summary: Some("test".to_string()),
217 user: user2.clone(),
218 trackable_item: TrackableItem {
219 common: TrackableItemFields {
220 id: 1,
221 title: "Second Issue".to_string(),
222 milestone: Some(Milestone {
223 title: "End of Elaboration".to_string(),
224 ..Default::default()
225 }),
226 labels: Labels {
227 labels: vec![Label {
228 title: "Documentation".into(),
229 }],
230 },
231 time_estimate: Duration::seconds(7200),
232 ..Default::default()
233 },
234 kind: TrackableItemKind::Issue(Issue::default()),
235 },
236 },
237 TimeLog {
238 spent_at: Local::now(),
239 time_spent: Duration::seconds(1800),
240 summary: Some("test".to_string()),
241 user: user1.clone(),
242 trackable_item: TrackableItem {
243 common: TrackableItemFields {
244 title: "First MR".into(),
245 milestone: None,
246 labels: Labels {
247 labels: vec![
248 Label {
249 title: "Documentation".into(),
250 },
251 Label {
252 title: "Development".into(),
253 },
254 ],
255 },
256 time_estimate: Duration::seconds(1800),
257 ..Default::default()
258 },
259 kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
260 },
261 },
262 TimeLog {
263 spent_at: Local::now(),
264 time_spent: Duration::seconds(5400),
265 summary: Some("Fix a big bug".to_string()),
266 user: User {
267 name: "user3".to_string(),
268 username: "user3".to_string(),
269 },
270 trackable_item: TrackableItem {
271 common: TrackableItemFields {
272 id: 1,
273 title: "Second MR".into(),
274 labels: Labels {
275 labels: vec![
276 Label {
277 title: "Bug".into(),
278 },
279 Label {
280 title: "Development".into(),
281 },
282 ],
283 },
284 time_estimate: Duration::seconds(3600),
285 ..Default::default()
286 },
287 kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
288 },
289 },
290 ]
291 }
292
293 #[test]
294 fn test_group_by_user() {
295 const NUMBER_OF_USERS: usize = 3;
296 const NUMBER_OF_USER1_LOGS: usize = 2;
297 const NUMBER_OF_USER2_LOGS: usize = 2;
298 const NUMBER_OF_USER3_LOGS: usize = 1;
299
300 let input = get_timelogs();
301 assert_eq!(input.len(), NUMBER_OF_LOGS);
302
303 let output = group_by_user(&input).collect::<BTreeMap<_, _>>();
304 let user1 = User {
305 name: "user1".to_string(),
306 username: "user1".to_string(),
307 };
308 let user2 = User {
309 name: "user2".to_string(),
310 username: "user2".to_string(),
311 };
312 let user3 = User {
313 name: "user3".to_string(),
314 username: "user3".to_string(),
315 };
316
317 assert_eq!(output.len(), NUMBER_OF_USERS);
318 assert_eq!(output.get(&user1).unwrap().len(), NUMBER_OF_USER1_LOGS);
319 assert_eq!(output.get(&user2).unwrap().len(), NUMBER_OF_USER2_LOGS);
320 assert_eq!(output.get(&user3).unwrap().len(), NUMBER_OF_USER3_LOGS);
321 }
322
323 #[test]
324 fn test_group_by_milestone() {
325 const NUMBER_OF_MILESTONES: usize = 2;
326 const NUMBER_OF_NONE: usize = 4;
327 const NUMBER_OF_SOME: usize = 1;
328
329 let input = get_timelogs();
330 assert_eq!(input.len(), NUMBER_OF_LOGS);
331
332 let milestone_none = None;
333 let milestone_some = Some(&Milestone {
334 title: "End of Elaboration".to_string(),
335 ..Default::default()
336 });
337
338 let output = group_by_milestone(&input).collect::<BTreeMap<_, _>>();
339
340 assert_eq!(output.len(), NUMBER_OF_MILESTONES);
341
342 let output_none = output.get(&milestone_none).unwrap();
343 let output_some = output.get(&milestone_some).unwrap();
344 assert_eq!(output_none.len(), NUMBER_OF_NONE);
345 assert_eq!(output_some.len(), NUMBER_OF_SOME);
346
347 assert!(output_none.contains(&&input[0]));
349 assert!(output_none.contains(&&input[1]));
350 assert!(output_some.contains(&&input[2]));
351 assert!(output_none.contains(&&input[3]));
352 assert!(output_none.contains(&&input[4]));
353 }
354
355 #[test]
356 fn test_group_by_type() {
357 const NUMBER_OF_TYPES: usize = 2;
358 const NUMBER_OF_ISSUES: usize = 3;
359 const NUMBER_OF_MERGE_REQUESTS: usize = 2;
360
361 let input = get_timelogs();
362 assert_eq!(input.len(), NUMBER_OF_LOGS);
363
364 let output = group_by_type(&input).collect::<BTreeMap<_, _>>();
365
366 assert_eq!(output.len(), NUMBER_OF_TYPES);
367 assert_eq!(output.get("Issue").unwrap().len(), NUMBER_OF_ISSUES);
368 assert_eq!(
369 output.get("Merge Request").unwrap().len(),
370 NUMBER_OF_MERGE_REQUESTS
371 );
372 }
373
374 #[test]
375 fn test_group_by_trackable_item() {
376 const NUMBER_OF_ITEMS: usize = 4;
377 const NUMBER_OF_ISSUE_0: usize = 2;
378 const NUMBER_OF_ISSUE_1: usize = 1;
379 const NUMBER_OF_MR_0: usize = 1;
380 const NUMBER_OF_MR_1: usize = 1;
381
382 let input = get_timelogs();
383 let mut result = group_by_trackable_item(&input).collect::<BTreeMap<_, _>>();
384 assert_eq!(result.len(), NUMBER_OF_ITEMS);
385
386 let item_1 = result.pop_first().unwrap();
387 assert_eq!(
388 std::mem::discriminant(&item_1.0.kind),
389 std::mem::discriminant(&TrackableItemKind::Issue(Issue::default()))
390 );
391 assert_eq!(item_1.0.common.id, 0);
392 assert_eq!(item_1.1.len(), NUMBER_OF_ISSUE_0);
393
394 let item_2 = result.pop_first().unwrap();
395 assert_eq!(
396 std::mem::discriminant(&item_2.0.kind),
397 std::mem::discriminant(&TrackableItemKind::MergeRequest(MergeRequest::default()))
398 );
399 assert_eq!(item_2.0.common.id, 0);
400 assert_eq!(item_2.1.len(), NUMBER_OF_MR_0);
401
402 let item_3 = result.pop_first().unwrap();
403 assert_eq!(
404 std::mem::discriminant(&item_3.0.kind),
405 std::mem::discriminant(&TrackableItemKind::Issue(Issue::default()))
406 );
407 assert_eq!(item_3.0.common.id, 1);
408 assert_eq!(item_3.1.len(), NUMBER_OF_ISSUE_1);
409
410 let item_4 = result.pop_first().unwrap();
411 assert_eq!(
412 std::mem::discriminant(&item_4.0.kind),
413 std::mem::discriminant(&TrackableItemKind::MergeRequest(MergeRequest::default()))
414 );
415 assert_eq!(item_4.0.common.id, 1);
416 assert_eq!(item_4.1.len(), NUMBER_OF_MR_1);
417 }
418
419 #[test]
420 fn test_group_by_label_contains_selected_labels() {
421 const NUMBER_OF_SELECTED_LABELS: usize = 2;
422
423 let input = get_timelogs();
424
425 let label_documentation = Some(&Label {
426 title: "Documentation".to_string(),
427 });
428 let label_development = Some(&Label {
429 title: "Development".to_string(),
430 });
431 let label_bug = Some(&Label {
432 title: "Bug".to_string(),
433 });
434 let label_others = Some(&Label {
435 title: "Others".to_string(),
436 });
437
438 #[expect(clippy::unnecessary_literal_unwrap)]
439 let label_filter = HashSet::from([
440 label_development.unwrap().title.clone(),
441 label_documentation.unwrap().title.clone(),
442 ]);
443 assert_eq!(label_filter.len(), NUMBER_OF_SELECTED_LABELS);
444
445 let result = group_by_label(&input, Some(&label_filter), None).collect::<BTreeMap<_, _>>();
446
447 assert_eq!(result.len(), NUMBER_OF_SELECTED_LABELS);
448 assert!(result.contains_key(&label_documentation));
449 assert!(result.contains_key(&label_development));
450 assert!(!result.contains_key(&label_bug));
451 assert!(!result.contains_key(&label_others));
452 assert!(!result.contains_key(&None));
453 }
454
455 #[test]
456 fn test_group_by_label_contains_items_without_labels() {
457 const NUMBER_OF_LABELS_INCLUDING_NO_LABEL: usize = 4;
458 const NUMBER_OF_NO_LABEL: usize = 2;
459 const TIME_SPENT_BY_NO_LABEL: Duration = Duration::seconds(10800);
460
461 let time_logs = get_timelogs();
462 let result = group_by_label(&time_logs, None, None).collect::<BTreeMap<_, _>>();
463
464 assert_eq!(result.len(), NUMBER_OF_LABELS_INCLUDING_NO_LABEL);
465 assert!(result.contains_key(&None));
466 let no_label = result.get(&None).unwrap();
467 assert_eq!(no_label.len(), NUMBER_OF_NO_LABEL);
468 assert_eq!(
469 no_label.iter().map(|t| t.time_spent).sum::<Duration>(),
470 TIME_SPENT_BY_NO_LABEL
471 );
472 }
473
474 #[test]
475 fn test_group_by_label_none_selected_labels_contains_all_labels() {
476 const NUMBER_OF_ALL_LABELS: usize = 3;
477
478 let input = get_timelogs();
479
480 let label_documentation = Some(&Label {
481 title: "Documentation".to_string(),
482 });
483 let label_development = Some(&Label {
484 title: "Development".to_string(),
485 });
486 let label_bug = Some(&Label {
487 title: "Bug".to_string(),
488 });
489 let label_others = Some(&Label {
490 title: "Others".to_string(),
491 });
492
493 let result = group_by_label(&input, None, None).collect::<BTreeMap<_, _>>();
494 assert_eq!(result.len(), NUMBER_OF_ALL_LABELS + 1);
496 assert!(result.contains_key(&label_documentation));
497 assert!(result.contains_key(&label_development));
498 assert!(result.contains_key(&label_bug));
499 assert!(result.contains_key(&None));
500 assert!(!result.contains_key(&label_others));
501 }
502
503 #[test]
504 fn test_group_by_label_with_other_label() {
505 const NUMBER_OF_SELECTED_LABELS: usize = 2;
506 const NUMBER_OF_DOCUMENTATION: usize = 2;
507 const NUMBER_OF_DEVELOPMENT: usize = 2;
508 const NUMBER_OF_OTHERS: usize = 2;
509
510 const TIME_SPENT_BY_DOCUMENTATION: Duration = Duration::seconds(5400);
511 const TIME_SPENT_BY_DEVELOPMENT: Duration = Duration::seconds(7200);
512 const TIME_SPENT_BY_OTHERS: Duration = Duration::seconds(10800);
513
514 let input = get_timelogs();
515 assert_eq!(input.len(), NUMBER_OF_LOGS);
516
517 let label_documentation = Some(&Label {
518 title: "Documentation".to_string(),
519 });
520 let label_development = Some(&Label {
521 title: "Development".to_string(),
522 });
523 let label_bug = Some(&Label {
524 title: "Bug".to_string(),
525 });
526 let label_others = Label {
527 title: "Others".to_string(),
528 };
529
530 #[expect(clippy::unnecessary_literal_unwrap)]
531 let label_filter = HashSet::from([
532 label_development.unwrap().title.clone(),
533 label_documentation.unwrap().title.clone(),
534 ]);
535 assert_eq!(label_filter.len(), NUMBER_OF_SELECTED_LABELS);
536
537 let result = group_by_label(&input, Some(&label_filter), Some(&label_others))
538 .collect::<BTreeMap<_, _>>();
539
540 assert_eq!(result.len(), NUMBER_OF_SELECTED_LABELS + 1);
542
543 let result_documentation = result.get(&label_documentation).unwrap();
545 assert_eq!(result_documentation.len(), NUMBER_OF_DOCUMENTATION);
546 assert_eq!(
547 result_documentation
548 .iter()
549 .map(|t| t.time_spent)
550 .sum::<Duration>(),
551 TIME_SPENT_BY_DOCUMENTATION
552 );
553
554 let result_development = result.get(&label_development).unwrap();
555 assert_eq!(result_development.len(), NUMBER_OF_DEVELOPMENT);
556 assert_eq!(
557 result_development
558 .iter()
559 .map(|t| t.time_spent)
560 .sum::<Duration>(),
561 TIME_SPENT_BY_DEVELOPMENT
562 );
563
564 let result_others = result.get(&Some(&label_others)).unwrap();
565 assert_eq!(result_others.len(), NUMBER_OF_OTHERS);
566 assert_eq!(
567 result_others.iter().map(|t| t.time_spent).sum::<Duration>(),
568 TIME_SPENT_BY_OTHERS
569 );
570 assert!(!result.contains_key(&label_bug));
571 }
572
573 #[test]
574 fn test_group_by_label_without_other_label() {
575 const NUMBER_OF_LABELS: usize = 3;
576 const NUMBER_OF_DOCUMENTATION: usize = 2;
577 const NUMBER_OF_DEVELOPMENT: usize = 2;
578 const NUMBER_OF_BUGS: usize = 1;
579
580 const TIME_SPENT_BY_DOCUMENTATION: Duration = Duration::seconds(5400);
581 const TIME_SPENT_BY_DEVELOPMENT: Duration = Duration::seconds(7200);
582 const TIME_SPENT_BY_BUGS: Duration = Duration::seconds(5400);
583
584 let input = get_timelogs();
585 assert_eq!(input.len(), NUMBER_OF_LOGS);
586
587 let label_documentation = Some(&Label {
588 title: "Documentation".to_string(),
589 });
590 let label_development = Some(&Label {
591 title: "Development".to_string(),
592 });
593 let label_bug = Some(&Label {
594 title: "Bug".to_string(),
595 });
596
597 #[expect(clippy::unnecessary_literal_unwrap)]
598 let label_filter = HashSet::from([
599 label_development.unwrap().title.clone(),
600 label_documentation.unwrap().title.clone(),
601 label_bug.unwrap().title.clone(),
602 ]);
603
604 let result = group_by_label(&input, Some(&label_filter), None).collect::<BTreeMap<_, _>>();
605 assert_eq!(result.len(), NUMBER_OF_LABELS);
606
607 let result_documentation = result.get(&label_documentation).unwrap();
609 assert_eq!(result_documentation.len(), NUMBER_OF_DOCUMENTATION);
610 assert_eq!(
611 result_documentation
612 .iter()
613 .map(|t| t.time_spent)
614 .sum::<Duration>(),
615 TIME_SPENT_BY_DOCUMENTATION
616 );
617
618 let result_development = result.get(&label_development).unwrap();
619 assert_eq!(result_development.len(), NUMBER_OF_DEVELOPMENT);
620 assert_eq!(
621 result_development
622 .iter()
623 .map(|t| t.time_spent)
624 .sum::<Duration>(),
625 TIME_SPENT_BY_DEVELOPMENT
626 );
627
628 let result_bug = result.get(&label_bug).unwrap();
629 assert_eq!(result_bug.len(), NUMBER_OF_BUGS);
630 assert_eq!(
631 result_bug.iter().map(|t| t.time_spent).sum::<Duration>(),
632 TIME_SPENT_BY_BUGS
633 );
634
635 assert!(!result.contains_key(&None));
636 }
637
638 #[test]
639 fn test_total_time_spent_by_user() {
640 const NUMBER_OF_USERS: usize = 3;
641 const TIME_SPENT_BY_USER_1: Duration = Duration::seconds(5400);
642 const TIME_SPENT_BY_USER_2: Duration = Duration::seconds(10800);
643 const TIME_SPENT_BY_USER_3: Duration = Duration::seconds(5400);
644
645 let input = get_timelogs();
646 assert_eq!(input.len(), NUMBER_OF_LOGS);
647
648 let totals = total_time_spent_by_user(&input).collect::<BTreeMap<_, _>>();
649
650 let user1 = User {
651 name: "user1".to_string(),
652 username: "user1".to_string(),
653 };
654 let user2 = User {
655 name: "user2".to_string(),
656 username: "user2".to_string(),
657 };
658 let user3 = User {
659 name: "user3".to_string(),
660 username: "user3".to_string(),
661 };
662
663 assert_eq!(totals.len(), NUMBER_OF_USERS);
664 assert_eq!(totals.get(&user1), Some(&TIME_SPENT_BY_USER_1));
665 assert_eq!(totals.get(&user2), Some(&TIME_SPENT_BY_USER_2));
666 assert_eq!(totals.get(&user3), Some(&TIME_SPENT_BY_USER_3));
667 }
668
669 #[test]
670 fn test_filter_by_dates() {
671 const NUMBER_OF_FILTERED_LOGS: usize = 3;
672
673 let input = get_timelogs();
674 assert_eq!(input.len(), NUMBER_OF_LOGS);
675
676 let end = Local::now().date_naive();
678 let start = end - Duration::days(1);
679 let output = filter_by_date(&input, start, end).collect::<Vec<_>>();
680
681 assert_eq!(output.len(), NUMBER_OF_FILTERED_LOGS);
682 for node in output {
683 let spent_day = node.spent_at.with_timezone(&Local).date_naive();
684 assert!(spent_day >= start && spent_day <= end);
685 }
686 }
687
688 #[test]
689 fn test_filter_by_last_n_days() {
690 const NUMBER_OF_FILTERED_LOGS: usize = 3;
691 const NUMBER_OF_DAYS: i64 = 2;
692
693 let input = get_timelogs();
694 let output =
695 filter_by_last_n_days(&input, Duration::days(NUMBER_OF_DAYS)).collect::<Vec<_>>();
696
697 let end = Local::now();
698 let start = end - Duration::days(NUMBER_OF_DAYS);
699
700 assert_eq!(output.len(), NUMBER_OF_FILTERED_LOGS);
701 for log in output {
702 assert!(log.spent_at >= start && log.spent_at <= end);
703 }
704 }
705}