use super::item::format_ids;
use chrono::Duration;
use pinto::service::{Burndown, CycleTimeReport, DurationSummary};
pub(crate) fn format_burndown(chart: &Burndown, max_width: usize) -> String {
const MIN_TRACK: usize = 10;
const FIXED: usize = 10 + 1 + 1 + 1 + 1;
let value_width = chart.total.to_string().chars().count().max(1);
let mut out = String::new();
out.push_str(&format!(
"{} {} — burndown ({})\n",
chart.sprint_id,
chart.sprint_title,
chart.metric.as_str(),
));
if let (Some(first), Some(last)) = (chart.days.first(), chart.days.last()) {
out.push_str(&format!(
"Period {} → {} · total {}\n",
first.date, last.date, chart.total,
));
}
let track = max_width.saturating_sub(FIXED + value_width);
if track < MIN_TRACK {
let rem_w = value_width.max(3);
let ideal_w = value_width.max(5);
out.push_str(&format!(
"{:<10} {:>rem_w$} {:>ideal_w$}\n",
"date", "rem", "ideal",
));
for d in &chart.days {
out.push_str(&format!(
"{} {:>rem_w$} {:>ideal_w$}\n",
d.date,
d.remaining,
d.ideal.round() as i64,
));
}
return out;
}
out.push_str("█ remaining ┆ ideal\n");
for d in &chart.days {
let cells = scale(d.remaining, chart.total, track);
let ideal_idx = scale_round(d.ideal, chart.total, track).min(track.saturating_sub(1));
let mut bar: Vec<char> = (0..track)
.map(|i| if i < cells { '█' } else { '░' })
.collect();
bar[ideal_idx] = '┆';
let bar: String = bar.into_iter().collect();
out.push_str(&format!(
"{} │{}│ {:>vw$}\n",
d.date,
bar,
d.remaining,
vw = value_width,
));
}
out
}
pub(crate) fn format_cycletime(report: &CycleTimeReport) -> String {
let mut out = String::new();
out.push_str(&format!(
"Cycle/Lead time — {} completed\n",
report.completed
));
if report.completed == 0 {
return out;
}
if let Some(cycle) = &report.cycle {
out.push_str(&format!(
" cycle (start → done) {}\n",
format_summary(cycle)
));
}
if let Some(lead) = &report.lead {
out.push_str(&format!(
" lead (created → done) {}\n",
format_summary(lead)
));
}
if !report.missing_start.is_empty() {
out.push_str(&format!(
"\n⚠ {} completed item(s) without start time (excluded from cycle time): {}\n",
report.missing_start.len(),
format_ids(&report.missing_start),
));
}
out
}
fn format_summary(s: &DurationSummary) -> String {
format!(
"n={} mean {} median {} min {} max {}",
s.count,
format_duration(s.mean),
format_duration(s.median),
format_duration(s.min),
format_duration(s.max),
)
}
pub(super) fn format_duration(d: Duration) -> String {
let secs = d.num_seconds();
if secs == 0 {
return "0s".to_string();
}
let sign = if secs < 0 { "-" } else { "" };
let secs = secs.unsigned_abs();
let days = secs / 86_400;
let hours = (secs % 86_400) / 3_600;
let mins = (secs % 3_600) / 60;
let s = secs % 60;
let body = if days > 0 {
if hours > 0 {
format!("{days}d {hours}h")
} else {
format!("{days}d")
}
} else if hours > 0 {
if mins > 0 {
format!("{hours}h {mins}m")
} else {
format!("{hours}h")
}
} else if mins > 0 {
if s > 0 {
format!("{mins}m {s}s")
} else {
format!("{mins}m")
}
} else {
format!("{s}s")
};
format!("{sign}{body}")
}
fn scale(value: u32, total: u32, width: usize) -> usize {
if total == 0 {
0
} else {
(u64::from(value) * width as u64 / u64::from(total)) as usize
}
}
fn scale_round(value: f64, total: u32, width: usize) -> usize {
if total == 0 {
0
} else {
(value / f64::from(total) * width as f64).round() as usize
}
}