1pub 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
20pub type SeriesData = Vec<(String, Vec<f32>)>;
22
23const ROUNDING_PRECISION: u8 = 2;
25
26pub 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
52pub 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
82pub 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
106fn 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
122fn 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
135fn 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 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 let outer_key_string = Bar::option_to_string(outer_key);
162 axis_labels.push(outer_key_string);
163
164 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 for key in &all_inner_keys {
172 data_points.entry(key.clone()).or_insert("0".into());
173 }
174
175 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
192pub 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
226pub 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
256fn 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 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
304fn 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 .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}