gitlab_time_report/
tables.rs

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