gitlab_time_report/charts/
mod.rs

1//! Methods to turn [`TimeLog`] into SVG and HTML charts.
2
3pub mod burndown;
4mod charming_extensions;
5mod chart_options;
6mod estimates;
7
8use crate::charts::charming_extensions::Series;
9use crate::model::TimeLog;
10use charming::component::Toolbox;
11use charming::{
12    Chart,
13    series::{Bar, Line},
14};
15use charming_extensions::{ChartExt, MultiSeries, SingleSeries};
16pub use chart_options::{BurndownOptions, BurndownType, ChartSettingError, RenderOptions};
17use std::collections::{BTreeMap, BTreeSet};
18use std::fs;
19
20/// Data to be converted into types from [`charming::series`].
21pub type SeriesData = Vec<(String, Vec<f32>)>;
22
23/// Number of digits after the decimal point when displaying values in charts.
24const ROUNDING_PRECISION: u8 = 2;
25
26/// Create a bar chart.
27/// # Parameters
28/// - `grouped_time_log`: A map of keys (e.g., User or Milestone) to a list of time logs
29/// - `title`: The title for the chart
30/// - `x_axis_label`: The text set for the X-axis
31/// - `render`: Options for the rendering of the chart.
32/// # Errors
33/// - Returns [`ChartSettingError::CharmingError`] if the creation of the chart failed.
34/// - Returns [`ChartSettingError::FileNotFound`] if the theme file in [`RenderOptions`] does not exist.
35/// - Returns [`ChartSettingError::IoError`] if there was an error reading or writing from/to the filesystem.
36pub fn create_bar_chart<'a, T>(
37    grouped_time_log: BTreeMap<impl Into<Option<&'a T>>, Vec<&'a TimeLog>>,
38    title: &str,
39    x_axis_label: &str,
40    render: &mut RenderOptions,
41) -> Result<(), ChartSettingError>
42where
43    T: std::fmt::Display + 'a,
44{
45    let hours_per_t = create_multi_series(grouped_time_log);
46    let chart = Chart::create_bar_chart(hours_per_t, &[x_axis_label.into()], 0.0, title);
47
48    let chart_name = format!("barchart-{title}");
49    render_chart_with_settings(chart, render, &chart_name)
50}
51
52/// Create a grouped bar chart, i.e., Hours per Label per User.
53/// # Parameters
54/// - `grouped_time_log`: A map of `Outer` keys (e.g., Label) to a map of `Inner` keys (e.g., User) to value (Duration)
55/// - `title`: The title of the chart
56/// - `x_axis_label_rotate`: The rotation of the X-axis labels.
57/// - `render`: Options for the rendering of the chart.
58/// # Errors
59/// - Returns [`ChartSettingError::CharmingError`] if the creation of the chart failed.
60/// - Returns [`ChartSettingError::FileNotFound`] if the theme file in [`RenderOptions`] does not exist.
61/// - Returns [`ChartSettingError::IoError`] if there was an error reading or writing from/to the filesystem.
62pub fn create_grouped_bar_chart<'a, Outer, Inner>(
63    grouped_time_log: BTreeMap<
64        impl Into<Option<&'a Outer>>,
65        BTreeMap<impl Into<Option<&'a Inner>> + Clone, Vec<&'a TimeLog>>,
66    >,
67    title: &str,
68    x_axis_label_rotate: f64,
69    render: &mut RenderOptions,
70) -> Result<(), ChartSettingError>
71where
72    Outer: std::fmt::Display + 'a,
73    Inner: std::fmt::Display + 'a,
74{
75    let (series, axis_labels) = create_grouped_series(grouped_time_log);
76    let chart = Chart::create_bar_chart(series, &axis_labels, x_axis_label_rotate, title);
77
78    let chart_name = format!("barchart-grouped-{title}");
79    render_chart_with_settings(chart, render, &chart_name)
80}
81
82/// Create a pie chart.
83/// # Parameters
84/// - `grouped_time_log`: A map of keys (e.g., User or Milestone) to a list of time logs
85/// - `title`: The title of the chart
86/// - `render`: Options for the rendering of the chart.
87/// # Errors
88/// - Returns [`ChartSettingError::CharmingError`] if the creation of the chart failed.
89/// - Returns [`ChartSettingError::FileNotFound`] if the theme file in [`RenderOptions`] does not exist.
90/// - Returns [`ChartSettingError::IoError`] if there was an error reading or writing from/to the filesystem.
91pub fn create_pie_chart<'a, T>(
92    grouped_time_log: BTreeMap<impl Into<Option<&'a T>>, Vec<&'a TimeLog>>,
93    title: &str,
94    render: &mut RenderOptions,
95) -> Result<(), ChartSettingError>
96where
97    T: std::fmt::Display + 'a,
98{
99    let hours_per_t = create_single_series(grouped_time_log);
100    let chart = Chart::create_pie_chart(hours_per_t, title);
101
102    let chart_name = format!("piechart-{title}");
103    render_chart_with_settings(chart, render, &chart_name)
104}
105
106/// Calculate the total hours per key (i.e., User or Milestone) and create a `Series`
107/// for each data point to display in a chart. The key of the `BTreeMap` is `Into<Option<T>` to
108/// allow for `Option<T>` and non-`Option<T>` keys. If it is `None`, the key is "None"
109fn create_multi_series<'a, T, Series>(
110    grouped_time_log: BTreeMap<impl Into<Option<&'a T>>, Vec<&'a TimeLog>>,
111) -> Vec<Series>
112where
113    T: std::fmt::Display + 'a,
114    Series: MultiSeries,
115{
116    let map = Series::create_data_point_mapping(grouped_time_log);
117    map.into_iter()
118        .map(|(hours, key)| Series::with_defaults(key.as_str(), vec![hours]))
119        .collect()
120}
121
122/// Calculate the total hours per key (i.e., User or Milestone) and create a singular `Series` for
123/// all data points display in a chart. The key of the `BTreeMap` is `Into<Option<T>` to allow for
124/// `Option<T>` and non-`Option<T>` keys. If it is `None`, the key is "None"
125fn create_single_series<'a, T, Series>(
126    grouped_time_log: BTreeMap<impl Into<Option<&'a T>>, Vec<&'a TimeLog>>,
127) -> Series
128where
129    T: std::fmt::Display + 'a,
130    Series: SingleSeries,
131{
132    Series::with_defaults(Series::create_data_point_mapping(grouped_time_log))
133}
134
135/// Create a `Series` for all data points of a key to display in a chart.
136/// The key of the `BTreeMap` is `Into<Option<T>` to allow for `Option<T>` and non-`Option<T>` keys.
137/// If it is `None`, the key is "None".
138/// Returns the `Series` and the axis labels for the grouped bar chart.
139fn create_grouped_series<'a, Outer, Inner, Series>(
140    grouped_time_log: BTreeMap<
141        impl Into<Option<&'a Outer>>,
142        BTreeMap<impl Into<Option<&'a Inner>> + Clone, Vec<&'a TimeLog>>,
143    >,
144) -> (Vec<Series>, Vec<String>)
145where
146    Outer: std::fmt::Display + 'a,
147    Inner: std::fmt::Display + 'a,
148    Series: MultiSeries,
149{
150    let mut duration_per_inner = BTreeMap::new();
151    let mut axis_labels = Vec::new();
152
153    // First, get all inner keys to validate later that they are present in all outer keys
154    let all_inner_keys = grouped_time_log
155        .values()
156        .flat_map(|inner_map| inner_map.keys().cloned().map(|k| Bar::option_to_string(k)))
157        .collect::<BTreeSet<_>>();
158
159    for (outer_key, inner_map) in grouped_time_log {
160        // Add the outer key to Vec of axis labels
161        let outer_key_string = Bar::option_to_string(outer_key);
162        axis_labels.push(outer_key_string);
163
164        // Create a map with the inner key as String and the total hours as f32
165        let mut data_points = Bar::create_data_point_mapping(inner_map)
166            .into_iter()
167            .map(|(v, k)| (k, v))
168            .collect::<BTreeMap<_, _>>();
169
170        // Ensure that all inner keys are present
171        for key in &all_inner_keys {
172            data_points.entry(key.clone()).or_insert("0".into());
173        }
174
175        // Insert all data points into the outer map
176        for (key, value) in data_points {
177            duration_per_inner
178                .entry(key)
179                .or_insert_with(Vec::new)
180                .push(value);
181        }
182    }
183
184    let series = duration_per_inner
185        .into_iter()
186        .map(|(key, hours)| Series::with_defaults(key.as_str(), hours))
187        .collect();
188
189    (series, axis_labels)
190}
191
192/// Create a burndown line chart.
193/// # Errors
194/// - Returns [`ChartSettingError::FileNotFound`] if the file in [`RenderOptions`] does not exist.
195/// - Returns [`ChartSettingError::CharmingError`] if the graph could not be created.
196pub fn create_burndown_chart(
197    time_logs: &[TimeLog],
198    burndown_type: &BurndownType,
199    burndown_options: &BurndownOptions,
200    render_options: &mut RenderOptions,
201) -> Result<(), ChartSettingError> {
202    let (burndown_data, x_axis) =
203        burndown::calculate_burndown_data(time_logs, burndown_type, burndown_options);
204
205    let burndown_series = burndown_data
206        .into_iter()
207        .map(|(name, data)| {
208            let data = data
209                .into_iter()
210                .map(|d| round_to_string(d, ROUNDING_PRECISION))
211                .collect();
212            Line::with_defaults(&name, data)
213        })
214        .collect::<Vec<_>>();
215
216    let title = match burndown_type {
217        BurndownType::Total => "Burndown Chart Total",
218        BurndownType::PerPerson => "Burndown Chart per Person",
219    };
220
221    let chart = Chart::create_line_chart(burndown_series, &x_axis, 0.0, title);
222    let chart_name = format!("burndown-{burndown_type}");
223    render_chart_with_settings(chart, render_options, &chart_name)
224}
225
226/// Creates a chart with estimates and actual time from grouped time logs (i.e., Estimates vs.
227/// actual time on all labels)
228/// # Errors
229/// - Returns [`ChartSettingError::FileNotFound`] if the file in [`RenderOptions`] does not exist.
230/// - Returns [`ChartSettingError::CharmingError`] if the graph could not be created.
231pub fn create_estimate_chart<'a, T>(
232    grouped_time_log: BTreeMap<impl Into<Option<&'a T>> + Clone, Vec<&'a TimeLog>>,
233    title: &str,
234    render_options: &mut RenderOptions,
235) -> Result<(), ChartSettingError>
236where
237    T: std::fmt::Display + 'a,
238{
239    let (estimate_data, x_axis) = estimates::calculate_estimate_data::<T, Bar>(grouped_time_log);
240    let estimate_series = estimate_data
241        .into_iter()
242        .map(|(name, data)| {
243            let data = data
244                .into_iter()
245                .map(|d| round_to_string(d, ROUNDING_PRECISION))
246                .collect();
247            Bar::with_defaults(&name, data)
248        })
249        .collect();
250
251    let chart = Chart::create_bar_chart(estimate_series, &x_axis, 50.0, title);
252    let chart_name = format!("barchart-{title}");
253    render_chart_with_settings(chart, render_options, &chart_name)
254}
255
256/// Renders a chart as an SVG and an HTML file.
257fn render_chart_with_settings(
258    mut chart: Chart,
259    render_options: &mut RenderOptions,
260    chart_name: &str,
261) -> Result<(), ChartSettingError> {
262    let chart_theme = render_options
263        .theme_file_path
264        .map(fs::read_to_string)
265        .transpose()?;
266
267    if !render_options.output_path.exists() {
268        fs::create_dir_all(render_options.output_path)?;
269    }
270
271    let chart_filename = format!(
272        "{prefix:02}-{name}",
273        prefix = render_options.file_name_prefix,
274        name = chart_name.replace(' ', "-").to_lowercase()
275    );
276
277    let html = chart.render_html(
278        u64::from(render_options.width),
279        u64::from(render_options.height),
280        chart_theme.as_deref(),
281    )?;
282    let html_path = render_options
283        .output_path
284        .join(format!("{chart_filename}.html"));
285    fs::write(html_path, html)?;
286
287    // Disable toolbox in SVG
288    chart = chart.toolbox(Toolbox::new().show(false));
289    let svg = chart.render_svg(
290        u32::from(render_options.width),
291        u32::from(render_options.height),
292        chart_theme.as_deref(),
293    )?;
294    let svg_path = render_options
295        .output_path
296        .join(format!("{chart_filename}.svg"));
297
298    fs::write(svg_path, svg)?;
299
300    render_options.file_name_prefix += 1;
301    Ok(())
302}
303
304/// Rounds the value to `precision` number of decimal places and returns a string
305/// to avoid floating point inaccuracies
306fn round_to_string(value: f32, max_precision: u8) -> String {
307    let p_i32 = i32::from(max_precision);
308    let rounded = (value * 10.0_f32.powi(p_i32)).round() / 10.0_f32.powi(p_i32);
309    format!("{rounded:.precision$}", precision = max_precision as usize)
310        // Remove zeros behind the point
311        .trim_end_matches('0')
312        .trim_end_matches('.')
313        .to_string()
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::filters;
320    use crate::model::{
321        Issue, MergeRequest, Milestone, TrackableItem, TrackableItemFields, TrackableItemKind, User,
322    };
323    use charming::series::{Bar, Pie};
324    use chrono::{DateTime, Duration, Local, NaiveDate};
325
326    const NUMBER_OF_LOGS: usize = 6;
327    pub(super) const PROJECT_WEEKS: u16 = 4;
328    pub(super) const WEEKS_PER_SPRINT_DEFAULT: u16 = 1;
329    pub(super) const SPRINTS: u16 = PROJECT_WEEKS;
330    pub(super) const TOTAL_HOURS_PER_PERSON: f32 = 10.0;
331    pub(super) const PROJECT_START: Option<NaiveDate> = NaiveDate::from_ymd_opt(2025, 1, 1);
332
333    #[expect(clippy::too_many_lines)]
334    pub(super) fn get_time_logs() -> [TimeLog; NUMBER_OF_LOGS] {
335        let user1 = User {
336            name: "User 1".into(),
337            username: "user1".to_string(),
338        };
339        let user2 = User {
340            name: "User 2".into(),
341            username: "user2".to_string(),
342        };
343
344        let m1 = Milestone {
345            title: "M1".into(),
346            ..Milestone::default()
347        };
348        let m2 = Milestone {
349            title: "M2".into(),
350            ..Milestone::default()
351        };
352
353        let issue_0 = TrackableItem {
354            kind: TrackableItemKind::Issue(Issue::default()),
355            common: TrackableItemFields {
356                id: 0,
357                title: "Issue 0".into(),
358                time_estimate: Duration::hours(2),
359                total_time_spent: Duration::hours(3) + Duration::minutes(30),
360                milestone: Some(m1.clone()),
361                ..Default::default()
362            },
363        };
364
365        [
366            TimeLog {
367                time_spent: Duration::hours(1),
368                spent_at: "2025-01-01T12:00:00+01:00"
369                    .parse::<DateTime<Local>>()
370                    .unwrap(),
371                user: user1.clone(),
372                trackable_item: issue_0.clone(),
373                ..Default::default()
374            },
375            TimeLog {
376                time_spent: Duration::hours(2) + Duration::minutes(30),
377                spent_at: "2025-01-02T09:10:23+01:00"
378                    .parse::<DateTime<Local>>()
379                    .unwrap(),
380                user: user1.clone(),
381                trackable_item: issue_0.clone(),
382                ..Default::default()
383            },
384            TimeLog {
385                time_spent: Duration::hours(1) + Duration::minutes(30),
386                spent_at: "2025-01-10T12:00:00+01:00"
387                    .parse::<DateTime<Local>>()
388                    .unwrap(),
389                user: user1.clone(),
390                trackable_item: TrackableItem {
391                    common: TrackableItemFields {
392                        id: 1,
393                        title: "Issue 1".into(),
394                        time_estimate: Duration::hours(2),
395                        total_time_spent: Duration::hours(1) + Duration::minutes(30),
396                        milestone: Some(m2.clone()),
397                        ..Default::default()
398                    },
399                    ..Default::default()
400                },
401                ..Default::default()
402            },
403            TimeLog {
404                time_spent: Duration::hours(4) + Duration::minutes(15),
405                spent_at: "2025-01-01T12:00:00+01:00"
406                    .parse::<DateTime<Local>>()
407                    .unwrap(),
408                user: user2.clone(),
409                trackable_item: TrackableItem {
410                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
411                    common: TrackableItemFields {
412                        id: 0,
413                        title: "MR 0".into(),
414                        time_estimate: Duration::hours(5),
415                        total_time_spent: Duration::hours(4) + Duration::minutes(15),
416                        milestone: Some(m1.clone()),
417                        ..Default::default()
418                    },
419                },
420                ..Default::default()
421            },
422            TimeLog {
423                time_spent: Duration::hours(1),
424                spent_at: "2025-01-08T12:00:00+01:00"
425                    .parse::<DateTime<Local>>()
426                    .unwrap(),
427                user: user2.clone(),
428                trackable_item: TrackableItem {
429                    common: TrackableItemFields {
430                        id: 2,
431                        title: "Issue 2".into(),
432                        time_estimate: Duration::hours(2),
433                        total_time_spent: Duration::hours(1),
434                        milestone: None,
435                        ..Default::default()
436                    },
437                    ..Default::default()
438                },
439                ..Default::default()
440            },
441            TimeLog {
442                time_spent: Duration::hours(4),
443                spent_at: "2025-01-28T12:00:00+01:00"
444                    .parse::<DateTime<Local>>()
445                    .unwrap(),
446                user: user2.clone(),
447                trackable_item: TrackableItem {
448                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
449                    common: TrackableItemFields {
450                        id: 1,
451                        title: "MR 1".to_string(),
452                        total_time_spent: Duration::hours(4),
453                        ..Default::default()
454                    },
455                },
456                ..Default::default()
457            },
458        ]
459    }
460
461    #[test]
462    fn validate_test_data() {
463        let time_logs = get_time_logs();
464        let by_item = filters::group_by_trackable_item(&time_logs);
465        by_item.into_iter().for_each(|(item, time_logs)| {
466            let total_time = filters::total_time_spent(time_logs.clone());
467            assert_eq!(
468                total_time, item.common.total_time_spent,
469                "{} {} has an incorrect total time spent",
470                item.kind, item.common.id
471            );
472        });
473    }
474
475    #[test]
476    fn test_create_multi_series() {
477        const USER_1_TIME: f32 = 5.0;
478        const USER_2_TIME: f32 = 9.25;
479
480        let time_logs = get_time_logs();
481        let time_logs_per_user = filters::group_by_user(&time_logs).collect();
482        let expected_result = [
483            Bar::with_defaults(
484                "User 1",
485                vec![round_to_string(USER_1_TIME, ROUNDING_PRECISION)],
486            ),
487            Bar::with_defaults(
488                "User 2",
489                vec![round_to_string(USER_2_TIME, ROUNDING_PRECISION)],
490            ),
491        ];
492
493        let result: Vec<Bar> = create_multi_series(time_logs_per_user);
494        assert_eq!(result, expected_result);
495    }
496
497    #[test]
498    fn test_create_multi_series_with_optional_key() {
499        const NONE_TIME: f32 = 5.0;
500        const M1_TIME: f32 = 7.75;
501        const M2_TIME: f32 = 1.5;
502
503        let time_logs = get_time_logs();
504        let time_logs_per_milestone = filters::group_by_milestone(&time_logs).collect();
505        let expected_result = [
506            Bar::with_defaults("None", vec![round_to_string(NONE_TIME, ROUNDING_PRECISION)]),
507            Bar::with_defaults("M1", vec![round_to_string(M1_TIME, ROUNDING_PRECISION)]),
508            Bar::with_defaults("M2", vec![round_to_string(M2_TIME, ROUNDING_PRECISION)]),
509        ];
510
511        let result: Vec<Bar> = create_multi_series(time_logs_per_milestone);
512        assert_eq!(result, expected_result);
513    }
514
515    #[test]
516    fn test_create_single_series() {
517        const USER_1_TIME: f32 = 5.0;
518        const USER_2_TIME: f32 = 9.25;
519
520        let time_logs = get_time_logs();
521        let time_logs_per_user = filters::group_by_user(&time_logs).collect();
522        let expected_result = Pie::with_defaults(vec![
523            (round_to_string(USER_1_TIME, ROUNDING_PRECISION), "User 1"),
524            (round_to_string(USER_2_TIME, ROUNDING_PRECISION), "User 2"),
525        ]);
526
527        let result: Pie = create_single_series(time_logs_per_user);
528        assert_eq!(result, expected_result);
529    }
530
531    #[test]
532    fn test_create_single_series_with_optional_key() {
533        const NONE_TIME: f32 = 5.0;
534        const M1_TIME: f32 = 7.75;
535        const M2_TIME: f32 = 1.5;
536
537        let time_logs = get_time_logs();
538        let time_logs_per_label = filters::group_by_milestone(&time_logs).collect();
539        let expected_result = Pie::with_defaults(vec![
540            (round_to_string(NONE_TIME, ROUNDING_PRECISION), "None"),
541            (round_to_string(M1_TIME, ROUNDING_PRECISION), "M1"),
542            (round_to_string(M2_TIME, ROUNDING_PRECISION), "M2"),
543        ]);
544
545        let result: Pie = create_single_series(time_logs_per_label);
546        assert_eq!(result, expected_result);
547    }
548
549    #[test]
550    fn test_create_grouped_series() {
551        const USER_1_NONE: f32 = 0.0;
552        const USER_1_M1: f32 = 3.5;
553        const USER_1_M2: f32 = 1.5;
554        const USER_2_NONE: f32 = 5.0;
555        const USER_2_M1: f32 = 4.25;
556        const USER_2_M2: f32 = 0.0;
557
558        let time_logs = get_time_logs();
559        let time_logs_per_milestone_per_user: BTreeMap<_, _> =
560            filters::group_by_milestone(&time_logs)
561                .map(|(m, t)| (m, filters::group_by_user(t).collect::<BTreeMap<_, _>>()))
562                .collect();
563
564        let user_1_expected_data = vec![
565            round_to_string(USER_1_NONE, 2),
566            round_to_string(USER_1_M1, 2),
567            round_to_string(USER_1_M2, 2),
568        ];
569        let user_2_expected_data = vec![
570            round_to_string(USER_2_NONE, 2),
571            round_to_string(USER_2_M1, 2),
572            round_to_string(USER_2_M2, 2),
573        ];
574
575        let expected_result = [
576            Bar::with_defaults("User 1", user_1_expected_data),
577            Bar::with_defaults("User 2", user_2_expected_data),
578        ];
579
580        let expected_labels = ["None", "M1", "M2"];
581
582        let (series, labels): (Vec<Bar>, _) =
583            create_grouped_series(time_logs_per_milestone_per_user);
584        assert_eq!(series, expected_result);
585        assert_eq!(labels, expected_labels);
586    }
587
588    #[test]
589    fn test_round_to_string() {
590        assert_eq!(round_to_string(1.23456, 2), "1.23");
591        assert_eq!(round_to_string(1.75, 2), "1.75");
592        assert_eq!(round_to_string(1.75, 3), "1.75");
593        assert_eq!(round_to_string(1.66666, 2), "1.67");
594        assert_eq!(round_to_string(1.66666, 1), "1.7");
595        assert_eq!(round_to_string(1.66666, 0), "2");
596        assert_eq!(round_to_string(1.99999, 2), "2");
597        assert_eq!(round_to_string(1.0, 2), "1");
598        assert_eq!(round_to_string(-1.286, 2), "-1.29");
599    }
600}