pub use super::{BurndownOptions, BurndownType, SeriesData};
use crate::TimeDeltaExt;
use crate::model::TimeLog;
use chrono::{Duration, NaiveDate};
use std::collections::{BTreeMap, HashSet};
struct AggregatedHours {
actual_hours: BTreeMap<String, Vec<f32>>,
total_required_hours: f32,
}
pub(super) fn calculate_burndown_data(
time_logs: &[TimeLog],
burndown_type: &BurndownType,
burndown_options: &BurndownOptions,
) -> (SeriesData, Vec<String>) {
let aggregated_hours = aggregate_hours_per_sprint(time_logs, burndown_type, burndown_options);
let mut burndown_series = create_actual_hours_line(&aggregated_hours, burndown_options.sprints);
let ideal = create_ideal_burndown_line(
burndown_options.sprints,
aggregated_hours.total_required_hours,
);
burndown_series.push(ideal);
let x_axis = create_burndown_x_axis_labels(burndown_options);
(burndown_series, x_axis)
}
fn aggregate_hours_per_sprint(
time_logs: &[TimeLog],
burndown_type: &BurndownType,
burndown_options: &BurndownOptions,
) -> AggregatedHours {
let project_end = project_end_date(burndown_options);
let mut actual_hours: BTreeMap<String, Vec<f32>> = BTreeMap::new();
let mut unique_users: HashSet<String> = HashSet::new();
for log in time_logs {
let log_date = log.spent_at.naive_local().date();
if log_date > project_end {
continue;
}
let sprint_number = sprint_index(
burndown_options.start_date,
burndown_options.weeks_per_sprint,
log_date,
);
let key = match burndown_type {
BurndownType::PerPerson => log.user.name.clone(),
BurndownType::Total => {
unique_users.insert(log.user.username.clone());
"Total".to_string()
}
};
let entry = actual_hours
.entry(key)
.or_insert_with(|| vec![0.0_f32; burndown_options.sprints as usize]);
if let Some(sprint) = entry.get_mut(sprint_number) {
*sprint += log.time_spent.total_hours();
}
}
#[expect(
clippy::cast_precision_loss,
reason = "unique_users.len() should always be < 23 bits"
)]
let total_required_hours = match burndown_type {
BurndownType::PerPerson => burndown_options.hours_per_person,
BurndownType::Total => burndown_options.hours_per_person * unique_users.len() as f32,
};
AggregatedHours {
actual_hours,
total_required_hours,
}
}
fn project_end_date(burndown_options: &BurndownOptions) -> NaiveDate {
let project_weeks = i64::from(burndown_options.weeks_per_sprint * burndown_options.sprints);
burndown_options.start_date + Duration::weeks(project_weeks)
}
fn create_actual_hours_line(
aggregated_hours: &AggregatedHours,
sprint_amount: u16,
) -> Vec<(String, Vec<f32>)> {
aggregated_hours
.actual_hours
.iter()
.map(|(user, sprints)| {
let mut remaining_hours_per_sprint = Vec::with_capacity(sprint_amount as usize + 1);
let mut remaining_hours: f32 = aggregated_hours.total_required_hours;
remaining_hours_per_sprint.push(remaining_hours);
for hours_this_sprint in sprints {
remaining_hours -= *hours_this_sprint;
remaining_hours_per_sprint.push(remaining_hours);
}
(user.clone(), remaining_hours_per_sprint)
})
.collect::<Vec<_>>()
}
fn create_ideal_burndown_line(sprints: u16, total_required_hours: f32) -> (String, Vec<f32>) {
let target_hours_per_sprint = total_required_hours / f32::from(sprints);
(
"Ideal".to_string(),
(0..=sprints)
.map(|i| (total_required_hours - f32::from(i) * target_hours_per_sprint).max(0.0))
.collect(),
)
}
fn create_burndown_x_axis_labels(burndown_options: &BurndownOptions) -> Vec<String> {
let x_axis_text = match burndown_options.weeks_per_sprint {
1 => "W",
_ => "S",
};
(0..=burndown_options.sprints)
.map(|sprint_num| match sprint_num {
0 => "Start".to_string(),
_ => format!("{x_axis_text}{sprint_num}"),
})
.collect::<Vec<_>>()
}
fn sprint_index(start: NaiveDate, sprint_length: u16, date: NaiveDate) -> usize {
let days = (date - start).num_days();
let sprint_length_in_days = i64::from(sprint_length * 7);
usize::try_from(days / sprint_length_in_days).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::charts::tests::*;
use chrono::NaiveDate;
#[test]
fn test_create_burndown_chart_per_user_no_sprints() {
const TOTAL_DATA_LENGTH: usize = (PROJECT_WEEKS + 1) as usize;
const USER_1_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 6.5, 5.0, 5.0, 5.0];
const USER_2_DATA: [f32; TOTAL_DATA_LENGTH] =
[TOTAL_HOURS_PER_PERSON, 5.75, 4.75, 4.75, 0.75];
const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 7.5, 5.0, 2.5, 0.0];
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let (burndown_series, x_axis) = calculate_burndown_data(
&time_logs,
&BurndownType::PerPerson,
&chart_options.unwrap(),
);
assert_eq!(x_axis, vec!["Start", "W1", "W2", "W3", "W4"]);
let user_1_data = burndown_series.first().unwrap();
assert_eq!(user_1_data.0, "User 1");
assert_eq!(user_1_data.1, USER_1_DATA);
let user_2_data = burndown_series.get(1).unwrap();
assert_eq!(user_2_data.0, "User 2");
assert_eq!(user_2_data.1, USER_2_DATA);
let ideal_data = burndown_series.last().unwrap();
assert_eq!(ideal_data.0, "Ideal");
assert_eq!(ideal_data.1, IDEAL_DATA);
}
#[test]
fn test_create_burndown_chart_per_user_with_sprints() {
const SPRINTS: u16 = 2;
const WEEKS_PER_SPRINT: u16 = 2;
const TOTAL_DATA_LENGTH: usize = (SPRINTS + 1) as usize;
const USER_1_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 5.0, 5.0];
const USER_2_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 4.75, 0.75];
const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS_PER_PERSON, 5.0, 0.0];
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let (burndown_series, x_axis) = calculate_burndown_data(
&time_logs,
&BurndownType::PerPerson,
&chart_options.unwrap(),
);
assert_eq!(x_axis, vec!["Start", "S1", "S2"]);
let data = &burndown_series;
let user_1_data = data.first().unwrap();
assert_eq!(user_1_data.0, "User 1");
assert_eq!(user_1_data.1, USER_1_DATA);
let user_2_data = data.get(1).unwrap();
assert_eq!(user_2_data.0, "User 2");
assert_eq!(user_2_data.1, USER_2_DATA);
let ideal_data = data.last().unwrap();
assert_eq!(ideal_data.0, "Ideal");
assert_eq!(ideal_data.1, IDEAL_DATA);
}
#[test]
fn test_create_burndown_chart_total_no_sprints() {
const TOTAL_DATA_LENGTH: usize = (PROJECT_WEEKS + 1) as usize;
const NUMBER_OF_USERS: f32 = 2.0;
const TOTAL_HOURS: f32 = NUMBER_OF_USERS * TOTAL_HOURS_PER_PERSON;
const TOTAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 12.25, 9.75, 9.75, 5.75];
const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 15.0, 10.0, 5.0, 0.0];
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let (burndown_series, x_axis) =
calculate_burndown_data(&time_logs, &BurndownType::Total, &chart_options.unwrap());
assert_eq!(x_axis, vec!["Start", "W1", "W2", "W3", "W4"]);
let total_data = burndown_series.first().unwrap();
assert_eq!(total_data.0, "Total");
assert_eq!(total_data.1, TOTAL_DATA);
let ideal_data = burndown_series.last().unwrap();
assert_eq!(ideal_data.0, "Ideal");
assert_eq!(ideal_data.1, IDEAL_DATA);
}
#[test]
fn test_create_burndown_chart_total_with_sprints() {
const SPRINTS: u16 = 2;
const WEEKS_PER_SPRINT: u16 = 2;
const NUMBER_OF_USERS: f32 = 2.0;
const TOTAL_HOURS: f32 = NUMBER_OF_USERS * TOTAL_HOURS_PER_PERSON;
const TOTAL_DATA_LENGTH: usize = (SPRINTS + 1) as usize;
const TOTAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 9.75, 5.75];
const IDEAL_DATA: [f32; TOTAL_DATA_LENGTH] = [TOTAL_HOURS, 10.0, 0.0];
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let (burndown_series, x_axis) =
calculate_burndown_data(&time_logs, &BurndownType::Total, &chart_options.unwrap());
assert_eq!(x_axis, vec!["Start", "S1", "S2"]);
let total_data = burndown_series.first().unwrap();
assert_eq!(total_data.0, "Total");
assert_eq!(total_data.1, TOTAL_DATA);
let ideal_data = burndown_series.last().unwrap();
assert_eq!(ideal_data.0, "Ideal");
assert_eq!(ideal_data.1, IDEAL_DATA);
}
#[test]
fn test_create_ideal_burndown_line() {
const SPRINTS: u16 = 4;
const IDEAL_DATA_LENGTH: usize = (SPRINTS + 1) as usize;
const REQUIRED_HOURS: f32 = 100.0;
const IDEAL_DATA: [f32; IDEAL_DATA_LENGTH] = [REQUIRED_HOURS, 75.0, 50.0, 25.0, 0.0];
let result = create_ideal_burndown_line(SPRINTS, REQUIRED_HOURS);
assert_eq!(result.0, "Ideal");
#[expect(clippy::float_cmp)]
{
assert_eq!(
*result.1.first().unwrap(),
REQUIRED_HOURS,
"Ideal burndown line should start with the full amount of hours"
);
assert_eq!(
*result.1.last().unwrap(),
0.0,
"Ideal burndown line should end with 0 hours"
);
}
assert_eq!(result.1, IDEAL_DATA);
}
#[test]
fn test_sprint_index() {
let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let week_0 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let week_4 = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
let week_pre_start = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
assert_eq!(sprint_index(start, WEEKS_PER_SPRINT_DEFAULT, week_0), 0);
assert_eq!(sprint_index(start, WEEKS_PER_SPRINT_DEFAULT, week_4), 4);
assert_eq!(
sprint_index(start, WEEKS_PER_SPRINT_DEFAULT, week_pre_start),
0
);
}
}