1pub use super::{BurndownOptions, BurndownType, SeriesData};
4use crate::TimeDeltaExt;
5use crate::model::TimeLog;
6use chrono::{Duration, NaiveDate};
7use std::collections::{BTreeMap, HashSet};
8
9struct AggregatedHours {
11 actual_hours: BTreeMap<String, Vec<f32>>,
13 total_required_hours: f32,
15}
16
17pub(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 let mut burndown_series = create_actual_hours_line(&aggregated_hours, burndown_options.sprints);
30
31 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
42fn 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 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
103fn 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
109fn 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 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
133fn create_ideal_burndown_line(sprints: u16, total_required_hours: f32) -> (String, Vec<f32>) {
135 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
145fn create_burndown_x_axis_labels(burndown_options: &BurndownOptions) -> Vec<String> {
148 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
161fn 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}