Skip to main content

gitlab_time_report/
tables.rs

1//! Contains functions to generate the tables with time statistics.
2
3use crate::model::{Label, TimeLog, User};
4use crate::{TimeDeltaExt, filters};
5use chrono::{Duration, Local};
6use std::collections::{HashMap, HashSet};
7
8/// Sums up all time logs by user for the last N days and returns a `HashMap`.
9#[must_use]
10fn get_total_time_by_user_in_last_n_days(
11    time_logs: &[TimeLog],
12    days: Duration,
13) -> HashMap<&User, Duration> {
14    let filtered = filters::filter_by_last_n_days(time_logs, days);
15    filters::total_time_spent_by_user(filtered).collect()
16}
17
18/// Sums up all time logs by user for yesterday and returns a `HashMap`.
19#[must_use]
20fn get_total_time_by_user_yesterday(time_logs: &[TimeLog]) -> HashMap<&User, Duration> {
21    let yesterday = Local::now().date_naive() - Duration::days(1);
22    let filtered = filters::filter_by_date(time_logs, yesterday, yesterday);
23    filters::total_time_spent_by_user(filtered).collect()
24}
25
26/// Get all time logs with today's date.
27#[must_use]
28fn get_todays_time_logs(time_logs: &[TimeLog]) -> Vec<&TimeLog> {
29    let today = Local::now().date_naive();
30    filters::filter_by_date(time_logs, today, today).collect()
31}
32
33/// Helper function to get duration from a map, returning zero if not found
34#[must_use]
35fn get_duration_or_zero<'a>(map: &HashMap<&'a User, Duration>, user: &'a User) -> Duration {
36    map.get(user).copied().unwrap_or(Duration::zero())
37}
38
39/// Assemble data for the table with time statistics for the last N days.
40/// Returns a tuple of the table data and the table header.
41#[must_use]
42pub fn populate_table_timelogs_in_timeframes_by_user(
43    time_logs: &[TimeLog],
44) -> (Vec<Vec<String>>, &[&str]) {
45    let today_timelogs = get_total_time_by_user_in_last_n_days(time_logs, Duration::days(1));
46    let yesterday_timelogs = get_total_time_by_user_yesterday(time_logs);
47    let this_week_timelogs = get_total_time_by_user_in_last_n_days(time_logs, Duration::weeks(1));
48    let this_month_timelogs = get_total_time_by_user_in_last_n_days(time_logs, Duration::days(30));
49
50    // Calculate totals for each timeframe
51    let total_today = today_timelogs.values().copied().sum::<Duration>();
52    let total_yesterday = yesterday_timelogs.values().copied().sum::<Duration>();
53    let total_this_week = this_week_timelogs.values().copied().sum::<Duration>();
54    let total_this_month = this_month_timelogs.values().copied().sum::<Duration>();
55    let total_time = filters::total_time_spent(time_logs);
56
57    let mut table_data: Vec<_> = filters::total_time_spent_by_user(time_logs)
58        .map(|(user, total_time_of_user)| {
59            vec![
60                user.name.clone(),
61                get_duration_or_zero(&today_timelogs, user).to_hm_string(),
62                get_duration_or_zero(&yesterday_timelogs, user).to_hm_string(),
63                get_duration_or_zero(&this_week_timelogs, user).to_hm_string(),
64                get_duration_or_zero(&this_month_timelogs, user).to_hm_string(),
65                total_time_of_user.to_hm_string(),
66            ]
67        })
68        .collect();
69
70    table_data.push(vec![
71        "Total".to_string(),
72        total_today.to_hm_string(),
73        total_yesterday.to_hm_string(),
74        total_this_week.to_hm_string(),
75        total_this_month.to_hm_string(),
76        total_time.to_hm_string(),
77    ]);
78
79    let table_header = &[
80        "User",
81        "Today",
82        "Yesterday",
83        "Last seven days",
84        "Last 30 days",
85        "Total time spent",
86    ];
87
88    (table_data, table_header)
89}
90
91/// Assemble data for a table with time statistics for the given labels.
92/// Returns a tuple of the table data and the table header.
93#[must_use]
94pub fn populate_table_timelogs_by_label<'a>(
95    time_logs: &[TimeLog],
96    label_filter: Option<&HashSet<String>>,
97    label_others: Option<&Label>,
98) -> (Vec<Vec<String>>, &'a [&'a str]) {
99    let time_by_label = filters::group_by_label(time_logs, label_filter, label_others);
100
101    let table_data: Vec<_> = time_by_label
102        .map(|(label, timelogs)| {
103            let total_time = filters::total_time_spent(timelogs);
104            let label_name = match label {
105                Some(label) => label.title.clone(),
106                None => "No label".to_string(),
107            };
108            vec![label_name, total_time.to_hm_string()]
109        })
110        .collect();
111
112    let table_header = &["Label", "Time Spent"];
113
114    (table_data, table_header)
115}
116
117/// Assemble data for a table with time statistics for all milestones.
118/// Returns a tuple of the table data and the table header.
119#[must_use]
120pub fn populate_table_timelogs_by_milestone<'a>(
121    time_logs: &[TimeLog],
122) -> (Vec<Vec<String>>, &'a [&'a str]) {
123    let time_by_milestone = filters::group_by_milestone(time_logs);
124
125    let table_data: Vec<_> = time_by_milestone
126        .map(|(milestone, timelogs)| {
127            let total_time = filters::total_time_spent(timelogs);
128            let title = milestone.map_or_else(|| "No milestone".to_string(), |m| m.title.clone());
129            vec![title, total_time.to_hm_string()]
130        })
131        .collect();
132    let table_header = &["Milestone", "Time Spent"];
133    (table_data, table_header)
134}
135
136#[must_use]
137pub fn populate_table_todays_timelogs(time_logs: &[TimeLog]) -> (Vec<Vec<String>>, &[&str]) {
138    let todays_timelogs = get_todays_time_logs(time_logs);
139
140    let mut table_data: Vec<_> = todays_timelogs
141        .iter()
142        .map(|timelog| {
143            let date_time = timelog.spent_at.to_rfc2822();
144            let user = timelog.user.to_string();
145            let time_spent = timelog.time_spent.to_hm_string();
146            let summary = timelog.summary.clone().unwrap_or_default();
147            let trackable_item = timelog.trackable_item.common.title.clone();
148            vec![date_time, user, time_spent, summary, trackable_item]
149        })
150        .collect();
151
152    table_data.sort();
153
154    let table_header = &["Date", "User", "Time Spent", "Summary", "Trackable item"];
155    (table_data, table_header)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::model::{
162        Issue, Label, Labels, MergeRequest, Milestone, TimeLog, TrackableItem, TrackableItemFields,
163        TrackableItemKind, User,
164    };
165    use chrono::{Duration, Local, Timelike};
166
167    const NUMBER_OF_LOGS: usize = 5;
168
169    fn create_test_user(name: &str) -> User {
170        User {
171            username: name.to_string(),
172            name: name.to_string(),
173        }
174    }
175
176    fn get_timelogs() -> [TimeLog; NUMBER_OF_LOGS] {
177        let now = Local::now().with_second(0).unwrap();
178        let c4_issue = TrackableItem {
179            common: TrackableItemFields {
180                title: "Create C4 model".to_string(),
181                milestone: Some(Milestone {
182                    title: "End of Elaboration".to_string(),
183                    ..Default::default()
184                }),
185                labels: Labels {
186                    labels: vec![Label {
187                        title: "Documentation".into(),
188                    }],
189                },
190                ..Default::default()
191            },
192            kind: TrackableItemKind::Issue(Issue::default()),
193        };
194
195        [
196            TimeLog {
197                spent_at: now - Duration::days(2),
198                time_spent: Duration::seconds(3600),
199                summary: None,
200                user: create_test_user("user1"),
201                trackable_item: TrackableItem {
202                    common: TrackableItemFields {
203                        title: "Meeting notes".to_string(),
204                        ..Default::default()
205                    },
206                    kind: TrackableItemKind::Issue(Issue::default()),
207                },
208            },
209            TimeLog {
210                spent_at: now - Duration::days(1),
211                time_spent: Duration::seconds(3600),
212                summary: Some("test".to_string()),
213                user: create_test_user("user2"),
214                trackable_item: c4_issue.clone(),
215            },
216            TimeLog {
217                spent_at: now,
218                time_spent: Duration::seconds(1800),
219                summary: Some("test".to_string()),
220                user: create_test_user("user1"),
221                trackable_item: TrackableItem {
222                    common: TrackableItemFields {
223                        title: "Create CI/CD pipeline".to_string(),
224                        milestone: None,
225                        labels: Labels {
226                            labels: vec![
227                                Label {
228                                    title: "Documentation".into(),
229                                },
230                                Label {
231                                    title: "Development".into(),
232                                },
233                            ],
234                        },
235                        ..Default::default()
236                    },
237                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
238                },
239            },
240            TimeLog {
241                spent_at: now - Duration::hours(1),
242                time_spent: Duration::seconds(5400),
243                summary: Some("Fix a big bug".to_string()),
244                user: create_test_user("user3"),
245                trackable_item: TrackableItem {
246                    common: TrackableItemFields {
247                        title: "Coughing in my microphone causes segfault".to_string(),
248                        labels: Labels {
249                            labels: vec![
250                                Label {
251                                    title: "Bug".into(),
252                                },
253                                Label {
254                                    title: "Development".into(),
255                                },
256                            ],
257                        },
258                        ..Default::default()
259                    },
260                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
261                },
262            },
263            TimeLog {
264                spent_at: now - Duration::minutes(15),
265                time_spent: Duration::seconds(7200),
266                summary: None,
267                user: create_test_user("user1"),
268                trackable_item: c4_issue.clone(),
269            },
270        ]
271    }
272
273    /// Helper function: turn `&User` key map into name & Duration for easy assertions.
274    fn to_name_map(m: &HashMap<&User, Duration>) -> HashMap<String, Duration> {
275        m.iter()
276            .map(|(u, d)| (u.name.clone(), *d))
277            .collect::<HashMap<_, _>>()
278    }
279
280    #[test]
281    fn test_get_duration_or_zero_existing_user() {
282        const DURATION: Duration = Duration::seconds(1200);
283        let user = create_test_user("user1");
284        let mut map = HashMap::new();
285        map.insert(&user, DURATION);
286
287        let result = get_duration_or_zero(&map, &user);
288        assert_eq!(result, DURATION);
289    }
290
291    #[test]
292    fn test_get_duration_or_zero_missing_user() {
293        const DURATION: Duration = Duration::seconds(1200);
294        let user_in_map = create_test_user("user1");
295        let user_not_in_map = create_test_user("user4");
296        let mut map = HashMap::new();
297        map.insert(&user_in_map, DURATION);
298
299        let result = get_duration_or_zero(&map, &user_not_in_map);
300        assert_eq!(result, Duration::zero());
301    }
302
303    #[test]
304    fn test_total_time_by_user_yesterday() {
305        const NUMBER_OF_USERS: usize = 1;
306        const TIME_SPENT: Duration = Duration::seconds(3600);
307
308        let time_logs = get_timelogs();
309        let result = get_total_time_by_user_yesterday(&time_logs);
310        let name_map = to_name_map(&result);
311
312        assert_eq!(name_map.len(), NUMBER_OF_USERS);
313        assert_eq!(name_map.get("user2"), Some(&TIME_SPENT));
314    }
315
316    #[test]
317    fn test_total_time_by_user_in_last_n_days_empty() {
318        const N_DAYS: Duration = Duration::days(7);
319        let time_logs = vec![];
320        let result = get_total_time_by_user_in_last_n_days(&time_logs, N_DAYS);
321        assert!(result.is_empty());
322    }
323
324    #[test]
325    fn test_total_time_by_user_in_last_n_days_one_day() {
326        const NUMBER_OF_USERS: usize = 2;
327        const N_DAYS: Duration = Duration::days(1);
328        const TIME_SPENT_USER_1: Duration = Duration::seconds(9000);
329        const TIME_SPENT_USER_3: Duration = Duration::seconds(5400);
330
331        let time_logs = get_timelogs();
332        let result = get_total_time_by_user_in_last_n_days(&time_logs, N_DAYS);
333        let name_map = to_name_map(&result);
334
335        assert_eq!(name_map.len(), NUMBER_OF_USERS);
336        assert_eq!(name_map.get("user1"), Some(&TIME_SPENT_USER_1));
337        assert_eq!(name_map.get("user3"), Some(&TIME_SPENT_USER_3));
338        assert!(!name_map.contains_key("user2"));
339    }
340
341    #[test]
342    fn test_total_time_by_user_in_last_n_days_seven_days() {
343        const N_DAYS: Duration = Duration::days(7);
344        const TIME_SPENT_USER_1: Duration = Duration::seconds(12600);
345        const TIME_SPENT_USER_2: Duration = Duration::seconds(3600);
346        const TIME_SPENT_USER_3: Duration = Duration::seconds(5400);
347
348        let time_logs = get_timelogs();
349        let result = get_total_time_by_user_in_last_n_days(&time_logs, N_DAYS);
350        let name_map = to_name_map(&result);
351
352        assert_eq!(name_map.get("user1"), Some(&TIME_SPENT_USER_1));
353        assert_eq!(name_map.get("user2"), Some(&TIME_SPENT_USER_2));
354        assert_eq!(name_map.get("user3"), Some(&TIME_SPENT_USER_3));
355    }
356
357    #[test]
358    fn test_create_table_timelogs_in_timeframes_by_user_header_and_rows() {
359        const NUMBER_OF_STATS: usize = 5;
360        const NUMBER_OF_COLUMNS: usize = NUMBER_OF_STATS + 1;
361
362        let time_logs = get_timelogs();
363        let (table, _header) = populate_table_timelogs_in_timeframes_by_user(&time_logs);
364
365        // We should have one row per user
366        let name_column: HashSet<String> = table.iter().map(|row| row[0].clone()).collect();
367        assert!(name_column.contains("user1"));
368        assert!(name_column.contains("user2"));
369        assert!(name_column.contains("user3"));
370
371        // Each row should have 6 columns: user + 5 stats
372        for row in &table {
373            assert_eq!(row.len(), NUMBER_OF_COLUMNS);
374        }
375    }
376
377    #[test]
378    fn test_create_table_timelogs_by_label() {
379        let time_logs = get_timelogs();
380        let (table, _header) = populate_table_timelogs_by_label(&time_logs, None, None);
381
382        let label_time_spent_map: HashMap<String, String> = table
383            .into_iter()
384            .map(|row| (row[0].clone(), row[1].clone()))
385            .collect();
386
387        let expected_map = std::collections::HashMap::from([
388            ("Documentation".to_string(), "03h 30m".to_string()),
389            ("Development".to_string(), "02h 00m".to_string()),
390            ("Bug".to_string(), "01h 30m".to_string()),
391            ("No label".to_string(), "01h 00m".to_string()),
392        ]);
393
394        assert_eq!(label_time_spent_map, expected_map);
395    }
396
397    #[test]
398    fn test_create_table_timelogs_by_label_with_others() {
399        let time_logs = get_timelogs();
400        let label_documentation = Label {
401            title: "Documentation".into(),
402        };
403        let label_others = Label {
404            title: "Others".into(),
405        };
406
407        let selected = HashSet::from([label_documentation.title.clone()]);
408        let (table, _) =
409            populate_table_timelogs_by_label(&time_logs, Some(&selected), Some(&label_others));
410
411        let label_time_spent_map: HashMap<String, String> = table
412            .into_iter()
413            .map(|row| (row[0].clone(), row[1].clone()))
414            .collect();
415
416        let expected_map = HashMap::from([
417            ("Documentation".to_string(), "03h 30m".to_string()),
418            ("Others".to_string(), "02h 30m".to_string()),
419        ]);
420        assert_eq!(label_time_spent_map, expected_map);
421    }
422
423    #[test]
424    fn test_populate_table_todays_timelogs() {
425        let time_logs = get_timelogs();
426        let (table, _) = populate_table_todays_timelogs(&time_logs);
427
428        assert!(table.is_sorted());
429
430        let now = Local::now().with_second(0).unwrap();
431        let log3 = now.to_rfc2822();
432        let log2 = (now - Duration::minutes(15)).to_rfc2822();
433        let log1 = (now - Duration::hours(1)).to_rfc2822();
434
435        let expected_table = [
436            [
437                &log1,
438                "user3",
439                "01h 30m",
440                "Fix a big bug",
441                "Coughing in my microphone causes segfault",
442            ],
443            [&log2, "user1", "02h 00m", "", "Create C4 model"],
444            [&log3, "user1", "00h 30m", "test", "Create CI/CD pipeline"],
445        ];
446        assert_eq!(table, expected_table);
447    }
448}