gitlab_time_report/charts/
burndown.rs

1//! Calculates the necessary data for burndown chart creation.
2
3pub use super::{BurndownOptions, BurndownType, SeriesData};
4use crate::TimeDeltaExt;
5use crate::model::TimeLog;
6use chrono::{Duration, NaiveDate};
7use std::collections::{BTreeMap, HashSet};
8
9/// Aggregated work hours per sprint and the total required hours for the project.
10struct AggregatedHours {
11    /// The total hours worked per sprint/week.
12    actual_hours: BTreeMap<String, Vec<f32>>,
13    /// The total required hours for the project.
14    total_required_hours: f32,
15}
16
17/// Prepares the data for the burndown chart.
18/// # Returns
19/// - `LineData`: To be converted into `Line`
20/// - `Vec<String>`: To be converted into the x-axis labels.
21pub(super) fn calculate_burndown_data(
22    time_logs: &[TimeLog],
23    burndown_type: &BurndownType,
24    burndown_options: &BurndownOptions,
25) -> (SeriesData, Vec<String>) {
26    let aggregated_hours = aggregate_hours_per_sprint(time_logs, burndown_type, burndown_options);
27
28    // Create the actual times line
29    let mut burndown_series = create_actual_hours_line(&aggregated_hours, burndown_options.sprints);
30
31    // Add the ideal burndown line
32    let ideal = create_ideal_burndown_line(
33        burndown_options.sprints,
34        aggregated_hours.total_required_hours,
35    );
36    burndown_series.push(ideal);
37
38    let x_axis = create_burndown_x_axis_labels(burndown_options);
39    (burndown_series, x_axis)
40}
41
42/// Aggregates the logged hours per sprint and calculates the required hours.
43/// For [`BurndownType::PerPerson`] this returns one series per user.
44/// For [`BurndownType::Total`] this returns a single `"Total"` series.
45///
46/// The returned [`AggregatedHours::total_required_hours`] contains the total required hours,
47/// either per person or for all users combined, depending on `burndown_type`.
48fn aggregate_hours_per_sprint(
49    time_logs: &[TimeLog],
50    burndown_type: &BurndownType,
51    burndown_options: &BurndownOptions,
52) -> AggregatedHours {
53    let project_end = project_end_date(burndown_options);
54
55    let mut actual_hours: BTreeMap<String, Vec<f32>> = BTreeMap::new();
56    let mut unique_users: HashSet<String> = HashSet::new();
57
58    for log in time_logs {
59        let log_date = log.spent_at.naive_local().date();
60        if log_date > project_end {
61            continue;
62        }
63
64        let sprint_number = sprint_index(
65            burndown_options.start_date,
66            burndown_options.weeks_per_sprint,
67            log_date,
68        );
69
70        let key = match burndown_type {
71            BurndownType::PerPerson => log.user.name.clone(),
72            BurndownType::Total => {
73                // Track seen users by username (guaranteed to be unique)
74                unique_users.insert(log.user.username.clone());
75                "Total".to_string()
76            }
77        };
78
79        let entry = actual_hours
80            .entry(key)
81            .or_insert_with(|| vec![0.0_f32; burndown_options.sprints as usize]);
82
83        if let Some(sprint) = entry.get_mut(sprint_number) {
84            *sprint += log.time_spent.total_hours();
85        }
86    }
87
88    #[expect(
89        clippy::cast_precision_loss,
90        reason = "unique_users.len() should always be < 23 bits"
91    )]
92    let total_required_hours = match burndown_type {
93        BurndownType::PerPerson => burndown_options.hours_per_person,
94        BurndownType::Total => burndown_options.hours_per_person * unique_users.len() as f32,
95    };
96
97    AggregatedHours {
98        actual_hours,
99        total_required_hours,
100    }
101}
102
103/// Calculates the project end date from the start date, number of sprints and weeks per sprint.
104fn project_end_date(burndown_options: &BurndownOptions) -> NaiveDate {
105    let project_weeks = i64::from(burndown_options.weeks_per_sprint * burndown_options.sprints);
106    burndown_options.start_date + Duration::weeks(project_weeks)
107}
108
109/// Creates the line from the actual hours worked per user for the burndown chart.
110fn create_actual_hours_line(
111    aggregated_hours: &AggregatedHours,
112    sprint_amount: u16,
113) -> Vec<(String, Vec<f32>)> {
114    aggregated_hours
115        .actual_hours
116        .iter()
117        .map(|(user, sprints)| {
118            let mut remaining_hours_per_sprint = Vec::with_capacity(sprint_amount as usize + 1);
119
120            // Start at full time remaining
121            let mut remaining_hours: f32 = aggregated_hours.total_required_hours;
122            remaining_hours_per_sprint.push(remaining_hours);
123
124            for hours_this_sprint in sprints {
125                remaining_hours -= *hours_this_sprint;
126                remaining_hours_per_sprint.push(remaining_hours);
127            }
128            (user.clone(), remaining_hours_per_sprint)
129        })
130        .collect::<Vec<_>>()
131}
132
133/// Calculates the ideal burndown line.
134fn create_ideal_burndown_line(sprints: u16, total_required_hours: f32) -> (String, Vec<f32>) {
135    // Calculate target hours per sprint per person
136    let target_hours_per_sprint = total_required_hours / f32::from(sprints);
137    (
138        "Ideal".to_string(),
139        (0..=sprints)
140            .map(|i| (total_required_hours - f32::from(i) * target_hours_per_sprint).max(0.0))
141            .collect(),
142    )
143}
144
145/// Creates the x-axis labels for the burndown chart.
146/// Start, W1..Wn if `weeks_per_sprint` == 1, S1..Sn otherwise.
147fn create_burndown_x_axis_labels(burndown_options: &BurndownOptions) -> Vec<String> {
148    // x-axis labels: Start, W1..Wn
149    let x_axis_text = match burndown_options.weeks_per_sprint {
150        1 => "W",
151        _ => "S",
152    };
153    (0..=burndown_options.sprints)
154        .map(|sprint_num| match sprint_num {
155            0 => "Start".to_string(),
156            _ => format!("{x_axis_text}{sprint_num}"),
157        })
158        .collect::<Vec<_>>()
159}
160
161/// Calculates the number of the week the date is in, based on the start date.
162fn sprint_index(start: NaiveDate, sprint_length: u16, date: NaiveDate) -> usize {
163    let days = (date - start).num_days();
164    let sprint_length_in_days = i64::from(sprint_length * 7);
165    usize::try_from(days / sprint_length_in_days).unwrap_or(0)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::charts::tests::*;
172    use chrono::NaiveDate;
173
174    #[test]
175    fn test_create_burndown_chart_per_user_no_sprints() {
176        const TOTAL_DATA_LENGTH: usize = (PROJECT_WEEKS + 1) as usize;
177        const USER_1_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 6.5, 5.0, 5.0, 5.0];
178        const USER_2_DATA: [f32; TOTAL_DATA_LENGTH] =
179            [TOTAL_HOURS_PER_PERSON, 5.75, 4.75, 4.75, 0.75];
180        const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 7.5, 5.0, 2.5, 0.0];
181
182        let time_logs = get_time_logs();
183        let chart_options = BurndownOptions::new(
184            &time_logs,
185            WEEKS_PER_SPRINT_DEFAULT,
186            SPRINTS,
187            TOTAL_HOURS_PER_PERSON,
188            PROJECT_START,
189        );
190
191        let (burndown_series, x_axis) = calculate_burndown_data(
192            &time_logs,
193            &BurndownType::PerPerson,
194            &chart_options.unwrap(),
195        );
196
197        assert_eq!(x_axis, vec!["Start", "W1", "W2", "W3", "W4"]);
198
199        let user_1_data = burndown_series.first().unwrap();
200        assert_eq!(user_1_data.0, "User 1");
201        assert_eq!(user_1_data.1, USER_1_DATA);
202
203        let user_2_data = burndown_series.get(1).unwrap();
204        assert_eq!(user_2_data.0, "User 2");
205        assert_eq!(user_2_data.1, USER_2_DATA);
206
207        let ideal_data = burndown_series.last().unwrap();
208        assert_eq!(ideal_data.0, "Ideal");
209        assert_eq!(ideal_data.1, IDEAL_DATA);
210    }
211
212    #[test]
213    fn test_create_burndown_chart_per_user_with_sprints() {
214        const SPRINTS: u16 = 2;
215        const WEEKS_PER_SPRINT: u16 = 2;
216        const TOTAL_DATA_LENGTH: usize = (SPRINTS + 1) as usize;
217        const USER_1_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 5.0, 5.0];
218        const USER_2_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 4.75, 0.75];
219        const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 5.0, 0.0];
220
221        let time_logs = get_time_logs();
222        let chart_options = BurndownOptions::new(
223            &time_logs,
224            WEEKS_PER_SPRINT,
225            SPRINTS,
226            TOTAL_HOURS_PER_PERSON,
227            PROJECT_START,
228        );
229        let (burndown_series, x_axis) = calculate_burndown_data(
230            &time_logs,
231            &BurndownType::PerPerson,
232            &chart_options.unwrap(),
233        );
234
235        assert_eq!(x_axis, vec!["Start", "S1", "S2"]);
236
237        let data = &burndown_series;
238        let user_1_data = data.first().unwrap();
239        assert_eq!(user_1_data.0, "User 1");
240        assert_eq!(user_1_data.1, USER_1_DATA);
241
242        let user_2_data = data.get(1).unwrap();
243        assert_eq!(user_2_data.0, "User 2");
244        assert_eq!(user_2_data.1, USER_2_DATA);
245
246        let ideal_data = data.last().unwrap();
247        assert_eq!(ideal_data.0, "Ideal");
248        assert_eq!(ideal_data.1, IDEAL_DATA);
249    }
250
251    #[test]
252    fn test_create_burndown_chart_total_no_sprints() {
253        const TOTAL_DATA_LENGTH: usize = (PROJECT_WEEKS + 1) as usize;
254        const NUMBER_OF_USERS: f32 = 2.0;
255        const TOTAL_HOURS: f32 = NUMBER_OF_USERS * TOTAL_HOURS_PER_PERSON;
256        const TOTAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 12.25, 9.75, 9.75, 5.75];
257        const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 15.0, 10.0, 5.0, 0.0];
258
259        let time_logs = get_time_logs();
260        let chart_options = BurndownOptions::new(
261            &time_logs,
262            WEEKS_PER_SPRINT_DEFAULT,
263            SPRINTS,
264            TOTAL_HOURS_PER_PERSON,
265            PROJECT_START,
266        );
267
268        let (burndown_series, x_axis) =
269            calculate_burndown_data(&time_logs, &BurndownType::Total, &chart_options.unwrap());
270
271        assert_eq!(x_axis, vec!["Start", "W1", "W2", "W3", "W4"]);
272
273        let total_data = burndown_series.first().unwrap();
274        assert_eq!(total_data.0, "Total");
275        assert_eq!(total_data.1, TOTAL_DATA);
276
277        let ideal_data = burndown_series.last().unwrap();
278        assert_eq!(ideal_data.0, "Ideal");
279        assert_eq!(ideal_data.1, IDEAL_DATA);
280    }
281
282    #[test]
283    fn test_create_burndown_chart_total_with_sprints() {
284        const SPRINTS: u16 = 2;
285        const WEEKS_PER_SPRINT: u16 = 2;
286        const NUMBER_OF_USERS: f32 = 2.0;
287        const TOTAL_HOURS: f32 = NUMBER_OF_USERS * TOTAL_HOURS_PER_PERSON;
288        const TOTAL_DATA_LENGTH: usize = (SPRINTS + 1) as usize;
289        const TOTAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 9.75, 5.75];
290        const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 10.0, 0.0];
291
292        let time_logs = get_time_logs();
293        let chart_options = BurndownOptions::new(
294            &time_logs,
295            WEEKS_PER_SPRINT,
296            SPRINTS,
297            TOTAL_HOURS_PER_PERSON,
298            PROJECT_START,
299        );
300        let (burndown_series, x_axis) =
301            calculate_burndown_data(&time_logs, &BurndownType::Total, &chart_options.unwrap());
302
303        assert_eq!(x_axis, vec!["Start", "S1", "S2"]);
304
305        let total_data = burndown_series.first().unwrap();
306        assert_eq!(total_data.0, "Total");
307        assert_eq!(total_data.1, TOTAL_DATA);
308
309        let ideal_data = burndown_series.last().unwrap();
310        assert_eq!(ideal_data.0, "Ideal");
311        assert_eq!(ideal_data.1, IDEAL_DATA);
312    }
313
314    #[test]
315    fn test_create_ideal_burndown_line() {
316        const SPRINTS: u16 = 4;
317        const IDEAL_DATA_LENGTH: usize = (SPRINTS + 1) as usize;
318        const REQUIRED_HOURS: f32 = 100.0;
319        const IDEAL_DATA: [f32; IDEAL_DATA_LENGTH] = [REQUIRED_HOURS, 75.0, 50.0, 25.0, 0.0];
320
321        let result = create_ideal_burndown_line(SPRINTS, REQUIRED_HOURS);
322        assert_eq!(result.0, "Ideal");
323        #[expect(clippy::float_cmp)]
324        {
325            assert_eq!(
326                *result.1.first().unwrap(),
327                REQUIRED_HOURS,
328                "Ideal burndown line should start with the full amount of hours"
329            );
330            assert_eq!(
331                *result.1.last().unwrap(),
332                0.0,
333                "Ideal burndown line should end with 0 hours"
334            );
335        }
336        assert_eq!(result.1, IDEAL_DATA);
337    }
338
339    #[test]
340    fn test_sprint_index() {
341        let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
342        let week_0 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
343        let week_4 = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
344        let week_pre_start = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
345        assert_eq!(sprint_index(start, WEEKS_PER_SPRINT_DEFAULT, week_0), 0);
346        assert_eq!(sprint_index(start, WEEKS_PER_SPRINT_DEFAULT, week_4), 4);
347        assert_eq!(
348            sprint_index(start, WEEKS_PER_SPRINT_DEFAULT, week_pre_start),
349            0
350        );
351    }
352}