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}_{repository}_{name}",
273        prefix = render_options.file_name_prefix,
274        repository = render_options.repository_name,
275        name = chart_name.replace(' ', "-").to_lowercase()
276    );
277
278    let html = chart.render_html(
279        u64::from(render_options.width),
280        u64::from(render_options.height),
281        chart_theme.as_deref(),
282    )?;
283    let html_path = render_options
284        .output_path
285        .join(format!("{chart_filename}.html"));
286    fs::write(html_path, html)?;
287
288    // Disable toolbox in SVG
289    chart = chart.toolbox(Toolbox::new().show(false));
290    let svg = chart.render_svg(
291        u32::from(render_options.width),
292        u32::from(render_options.height),
293        chart_theme.as_deref(),
294    )?;
295    let svg_path = render_options
296        .output_path
297        .join(format!("{chart_filename}.svg"));
298
299    fs::write(svg_path, svg)?;
300
301    render_options.file_name_prefix += 1;
302    Ok(())
303}
304
305/// Rounds the value to `precision` number of decimal places and returns a string
306/// to avoid floating point inaccuracies
307fn round_to_string(value: f32, max_precision: u8) -> String {
308    let p_i32 = i32::from(max_precision);
309    let rounded = (value * 10.0_f32.powi(p_i32)).round() / 10.0_f32.powi(p_i32);
310    format!("{rounded:.precision$}", precision = max_precision as usize)
311        // Remove zeros behind the point
312        .trim_end_matches('0')
313        .trim_end_matches('.')
314        .to_string()
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::filters;
321    use crate::model::{
322        Issue, MergeRequest, Milestone, TrackableItem, TrackableItemFields, TrackableItemKind, User,
323    };
324    use charming::series::{Bar, Pie};
325    use chrono::{DateTime, Duration, Local, NaiveDate};
326
327    const NUMBER_OF_LOGS: usize = 6;
328    pub(super) const PROJECT_WEEKS: u16 = 4;
329    pub(super) const WEEKS_PER_SPRINT_DEFAULT: u16 = 1;
330    pub(super) const SPRINTS: u16 = PROJECT_WEEKS;
331    pub(super) const TOTAL_HOURS_PER_PERSON: f32 = 10.0;
332    pub(super) const PROJECT_START: Option<NaiveDate> = NaiveDate::from_ymd_opt(2025, 1, 1);
333
334    #[expect(clippy::too_many_lines)]
335    pub(super) fn get_time_logs() -> [TimeLog; NUMBER_OF_LOGS] {
336        let user1 = User {
337            name: "User 1".into(),
338            username: "user1".to_string(),
339        };
340        let user2 = User {
341            name: "User 2".into(),
342            username: "user2".to_string(),
343        };
344
345        let m1 = Milestone {
346            title: "M1".into(),
347            ..Milestone::default()
348        };
349        let m2 = Milestone {
350            title: "M2".into(),
351            ..Milestone::default()
352        };
353
354        let issue_0 = TrackableItem {
355            kind: TrackableItemKind::Issue(Issue::default()),
356            common: TrackableItemFields {
357                id: 0,
358                title: "Issue 0".into(),
359                time_estimate: Duration::hours(2),
360                total_time_spent: Duration::hours(3) + Duration::minutes(30),
361                milestone: Some(m1.clone()),
362                ..Default::default()
363            },
364        };
365
366        [
367            TimeLog {
368                time_spent: Duration::hours(1),
369                spent_at: "2025-01-01T12:00:00+01:00"
370                    .parse::<DateTime<Local>>()
371                    .unwrap(),
372                user: user1.clone(),
373                trackable_item: issue_0.clone(),
374                ..Default::default()
375            },
376            TimeLog {
377                time_spent: Duration::hours(2) + Duration::minutes(30),
378                spent_at: "2025-01-02T09:10:23+01:00"
379                    .parse::<DateTime<Local>>()
380                    .unwrap(),
381                user: user1.clone(),
382                trackable_item: issue_0.clone(),
383                ..Default::default()
384            },
385            TimeLog {
386                time_spent: Duration::hours(1) + Duration::minutes(30),
387                spent_at: "2025-01-10T12:00:00+01:00"
388                    .parse::<DateTime<Local>>()
389                    .unwrap(),
390                user: user1.clone(),
391                trackable_item: TrackableItem {
392                    common: TrackableItemFields {
393                        id: 1,
394                        title: "Issue 1".into(),
395                        time_estimate: Duration::hours(2),
396                        total_time_spent: Duration::hours(1) + Duration::minutes(30),
397                        milestone: Some(m2.clone()),
398                        ..Default::default()
399                    },
400                    ..Default::default()
401                },
402                ..Default::default()
403            },
404            TimeLog {
405                time_spent: Duration::hours(4) + Duration::minutes(15),
406                spent_at: "2025-01-01T12:00:00+01:00"
407                    .parse::<DateTime<Local>>()
408                    .unwrap(),
409                user: user2.clone(),
410                trackable_item: TrackableItem {
411                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
412                    common: TrackableItemFields {
413                        id: 0,
414                        title: "MR 0".into(),
415                        time_estimate: Duration::hours(5),
416                        total_time_spent: Duration::hours(4) + Duration::minutes(15),
417                        milestone: Some(m1.clone()),
418                        ..Default::default()
419                    },
420                },
421                ..Default::default()
422            },
423            TimeLog {
424                time_spent: Duration::hours(1),
425                spent_at: "2025-01-08T12:00:00+01:00"
426                    .parse::<DateTime<Local>>()
427                    .unwrap(),
428                user: user2.clone(),
429                trackable_item: TrackableItem {
430                    common: TrackableItemFields {
431                        id: 2,
432                        title: "Issue 2".into(),
433                        time_estimate: Duration::hours(2),
434                        total_time_spent: Duration::hours(1),
435                        milestone: None,
436                        ..Default::default()
437                    },
438                    ..Default::default()
439                },
440                ..Default::default()
441            },
442            TimeLog {
443                time_spent: Duration::hours(4),
444                spent_at: "2025-01-28T12:00:00+01:00"
445                    .parse::<DateTime<Local>>()
446                    .unwrap(),
447                user: user2.clone(),
448                trackable_item: TrackableItem {
449                    kind: TrackableItemKind::MergeRequest(MergeRequest::default()),
450                    common: TrackableItemFields {
451                        id: 1,
452                        title: "MR 1".to_string(),
453                        total_time_spent: Duration::hours(4),
454                        ..Default::default()
455                    },
456                },
457                ..Default::default()
458            },
459        ]
460    }
461
462    #[test]
463    fn validate_test_data() {
464        let time_logs = get_time_logs();
465        let by_item = filters::group_by_trackable_item(&time_logs);
466        by_item.into_iter().for_each(|(item, time_logs)| {
467            let total_time = filters::total_time_spent(time_logs.clone());
468            assert_eq!(
469                total_time, item.common.total_time_spent,
470                "{} {} has an incorrect total time spent",
471                item.kind, item.common.id
472            );
473        });
474    }
475
476    #[test]
477    fn test_create_multi_series() {
478        const USER_1_TIME: f32 = 5.0;
479        const USER_2_TIME: f32 = 9.25;
480
481        let time_logs = get_time_logs();
482        let time_logs_per_user = filters::group_by_user(&time_logs).collect();
483        let expected_result = [
484            Bar::with_defaults(
485                "User 1",
486                vec![round_to_string(USER_1_TIME, ROUNDING_PRECISION)],
487            ),
488            Bar::with_defaults(
489                "User 2",
490                vec![round_to_string(USER_2_TIME, ROUNDING_PRECISION)],
491            ),
492        ];
493
494        let result: Vec<Bar> = create_multi_series(time_logs_per_user);
495        assert_eq!(result, expected_result);
496    }
497
498    #[test]
499    fn test_create_multi_series_with_optional_key() {
500        const NONE_TIME: f32 = 5.0;
501        const M1_TIME: f32 = 7.75;
502        const M2_TIME: f32 = 1.5;
503
504        let time_logs = get_time_logs();
505        let time_logs_per_milestone = filters::group_by_milestone(&time_logs).collect();
506        let expected_result = [
507            Bar::with_defaults("None", vec![round_to_string(NONE_TIME, ROUNDING_PRECISION)]),
508            Bar::with_defaults("M1", vec![round_to_string(M1_TIME, ROUNDING_PRECISION)]),
509            Bar::with_defaults("M2", vec![round_to_string(M2_TIME, ROUNDING_PRECISION)]),
510        ];
511
512        let result: Vec<Bar> = create_multi_series(time_logs_per_milestone);
513        assert_eq!(result, expected_result);
514    }
515
516    #[test]
517    fn test_create_single_series() {
518        const USER_1_TIME: f32 = 5.0;
519        const USER_2_TIME: f32 = 9.25;
520
521        let time_logs = get_time_logs();
522        let time_logs_per_user = filters::group_by_user(&time_logs).collect();
523        let expected_result = Pie::with_defaults(vec![
524            (round_to_string(USER_1_TIME, ROUNDING_PRECISION), "User 1"),
525            (round_to_string(USER_2_TIME, ROUNDING_PRECISION), "User 2"),
526        ]);
527
528        let result: Pie = create_single_series(time_logs_per_user);
529        assert_eq!(result, expected_result);
530    }
531
532    #[test]
533    fn test_create_single_series_with_optional_key() {
534        const NONE_TIME: f32 = 5.0;
535        const M1_TIME: f32 = 7.75;
536        const M2_TIME: f32 = 1.5;
537
538        let time_logs = get_time_logs();
539        let time_logs_per_label = filters::group_by_milestone(&time_logs).collect();
540        let expected_result = Pie::with_defaults(vec![
541            (round_to_string(NONE_TIME, ROUNDING_PRECISION), "None"),
542            (round_to_string(M1_TIME, ROUNDING_PRECISION), "M1"),
543            (round_to_string(M2_TIME, ROUNDING_PRECISION), "M2"),
544        ]);
545
546        let result: Pie = create_single_series(time_logs_per_label);
547        assert_eq!(result, expected_result);
548    }
549
550    #[test]
551    fn test_create_grouped_series() {
552        const USER_1_NONE: f32 = 0.0;
553        const USER_1_M1: f32 = 3.5;
554        const USER_1_M2: f32 = 1.5;
555        const USER_2_NONE: f32 = 5.0;
556        const USER_2_M1: f32 = 4.25;
557        const USER_2_M2: f32 = 0.0;
558
559        let time_logs = get_time_logs();
560        let time_logs_per_milestone_per_user: BTreeMap<_, _> =
561            filters::group_by_milestone(&time_logs)
562                .map(|(m, t)| (m, filters::group_by_user(t).collect::<BTreeMap<_, _>>()))
563                .collect();
564
565        let user_1_expected_data = vec![
566            round_to_string(USER_1_NONE, 2),
567            round_to_string(USER_1_M1, 2),
568            round_to_string(USER_1_M2, 2),
569        ];
570        let user_2_expected_data = vec![
571            round_to_string(USER_2_NONE, 2),
572            round_to_string(USER_2_M1, 2),
573            round_to_string(USER_2_M2, 2),
574        ];
575
576        let expected_result = [
577            Bar::with_defaults("User 1", user_1_expected_data),
578            Bar::with_defaults("User 2", user_2_expected_data),
579        ];
580
581        let expected_labels = ["None", "M1", "M2"];
582
583        let (series, labels): (Vec<Bar>, _) =
584            create_grouped_series(time_logs_per_milestone_per_user);
585        assert_eq!(series, expected_result);
586        assert_eq!(labels, expected_labels);
587    }
588
589    #[test]
590    fn test_round_to_string() {
591        assert_eq!(round_to_string(1.23456, 2), "1.23");
592        assert_eq!(round_to_string(1.75, 2), "1.75");
593        assert_eq!(round_to_string(1.75, 3), "1.75");
594        assert_eq!(round_to_string(1.66666, 2), "1.67");
595        assert_eq!(round_to_string(1.66666, 1), "1.7");
596        assert_eq!(round_to_string(1.66666, 0), "2");
597        assert_eq!(round_to_string(1.99999, 2), "2");
598        assert_eq!(round_to_string(1.0, 2), "1");
599        assert_eq!(round_to_string(-1.286, 2), "-1.29");
600    }
601}