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}_{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 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
305fn 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 .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}