use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::analytics::chart::{ChartExport, ChartProjection};
use crate::analytics::events::{AnalyticsScope, Period};
use crate::analytics::series::{Coverage, CoverageSummary, DailyPoint, Mode};
use crate::keybindings::TuiAction;
use crate::ui::detail::dash_number;
const BAR_CELLS: usize = 20;
const BAR: &str = "█";
const NO_BAR: &str = "-";
const ZERO_BAR: &str = "·";
const DAY_COLUMNS: [&str; 7] = [
"DATE",
"COVERAGE",
"REMAINING",
"SCOPE",
"DONE",
"+ADD",
"-DONE",
];
const DAY_WIDTHS: [usize; 7] = [10, 16, 9, 9, 9, 9, 9];
const fn day_text_column(index: usize) -> bool {
index < 2
}
pub const HELP_ACTIONS: &[TuiAction] = &[
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Next,
TuiAction::Previous,
TuiAction::Help,
TuiAction::Close,
];
pub const HELP_RAW: &[(&str, &str)] = &[
("[ / ]", "previous / next scope"),
("p", "period source (planning / observed)"),
("t", "mode (count / estimate)"),
("u", "next estimate unit"),
("v", "ask for the forecast"),
("d / D", "forecast origin (earlier / later)"),
];
pub const FOOTER_ACTIONS: &[TuiAction] = HELP_ACTIONS;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum BurndownPeriod {
#[default]
Planning,
Observed,
}
impl BurndownPeriod {
pub const ALL: [Self; 2] = [Self::Planning, Self::Observed];
pub const fn label(self) -> &'static str {
match self {
Self::Planning => "planning",
Self::Observed => "observed",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::Planning => "the scope's own planning period",
Self::Observed => "the days the record's own events span",
}
}
pub fn next(self) -> Self {
match self {
Self::Planning => Self::Observed,
Self::Observed => Self::Planning,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BurndownState {
pub choices: Vec<AnalyticsScope>,
pub choice: usize,
pub period: BurndownPeriod,
pub mode: Mode,
pub units: Vec<String>,
pub unit: usize,
pub forecast: bool,
pub origin_back: usize,
pub cursor: usize,
pub document: Option<ChartExport>,
pub problem: Option<String>,
}
impl BurndownState {
pub fn new(choices: Vec<AnalyticsScope>, units: Vec<String>) -> Self {
Self {
choices,
choice: 0,
period: BurndownPeriod::Planning,
mode: Mode::Count,
units,
unit: 0,
forecast: false,
origin_back: 0,
cursor: 0,
document: None,
problem: None,
}
}
pub fn selected(&self) -> Option<&AnalyticsScope> {
self.choices.get(self.choice)
}
pub fn scope(&self) -> Option<AnalyticsScope> {
let mut scope = self.selected()?.clone();
if self.period == BurndownPeriod::Observed {
scope.period = Period::unbounded();
}
Some(scope)
}
pub fn unit(&self) -> Option<&str> {
if self.mode != Mode::Estimate {
return None;
}
self.units.get(self.unit).map(String::as_str)
}
pub fn days(&self) -> usize {
self.document
.as_ref()
.map_or(0, |document| document.series.points.len())
}
pub fn origin(&self) -> Option<&DailyPoint> {
let points = &self.document.as_ref()?.series.points;
points.get(points.len().checked_sub(self.origin_back + 1)?)
}
pub fn projection(&self) -> Option<(String, String)> {
if !self.forecast {
return None;
}
let origin = self.origin()?;
let end = self.scope()?.period_end().map(str::to_string)?;
Some((origin.date.clone(), end))
}
pub fn shift_scope(&mut self, delta: isize) {
if self.choices.is_empty() {
self.choice = 0;
return;
}
let len = self.choices.len() as isize;
self.choice = ((self.choice as isize + delta).rem_euclid(len)) as usize;
self.origin_back = 0;
self.cursor = 0;
}
pub fn cycle_period(&mut self) {
self.period = self.period.next();
}
pub fn cycle_mode(&mut self) {
self.mode = match self.mode {
Mode::Count => Mode::Estimate,
Mode::Estimate => Mode::Count,
};
}
pub fn cycle_unit(&mut self) {
if !self.units.is_empty() {
self.unit = (self.unit + 1) % self.units.len();
}
}
pub fn toggle_forecast(&mut self) {
self.forecast = !self.forecast;
if !self.forecast {
self.origin_back = 0;
}
}
pub fn origin_earlier(&mut self) {
let last = self.days().saturating_sub(1);
self.origin_back = (self.origin_back + 1).min(last);
}
pub fn origin_later(&mut self) {
self.origin_back = self.origin_back.saturating_sub(1);
}
pub fn move_cursor(&mut self, delta: isize) {
let last = self.days().saturating_sub(1);
let next = if delta.is_negative() {
self.cursor.saturating_sub(delta.unsigned_abs())
} else {
self.cursor.saturating_add(delta as usize)
};
self.cursor = next.min(last);
}
pub fn clamp(&mut self) {
if self.choice >= self.choices.len() {
self.choice = 0;
}
if self.unit >= self.units.len() {
self.unit = 0;
}
let last = self.days().saturating_sub(1);
self.origin_back = self.origin_back.min(last);
self.cursor = self.cursor.min(last);
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BurndownFrame {
pub lines: Vec<Line<'static>>,
pub cursor_line: usize,
}
pub fn frame(state: &BurndownState) -> BurndownFrame {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut cursor_line = 0usize;
if state.choices.is_empty() {
lines.extend(no_scope_lines());
return BurndownFrame { lines, cursor_line };
}
lines.extend(scope_lines(state, state.document.as_ref()));
lines.push(Line::from(""));
let Some(document) = state.document.as_ref() else {
lines.extend(problem_lines(state));
return BurndownFrame { lines, cursor_line };
};
lines.extend(fold_lines(document));
lines.push(Line::from(""));
if document.is_empty() {
lines.extend(no_day_lines(document));
} else {
lines.push(head_line());
for (index, point) in document.series.points.iter().enumerate() {
let selected = index == state.cursor;
if selected {
cursor_line = lines.len();
}
lines.push(day_line(point, selected));
}
}
lines.push(Line::from(""));
lines.extend(projection_lines(state, document));
lines.push(Line::from(""));
lines.extend(diagnostic_lines(document));
BurndownFrame { lines, cursor_line }
}
fn dim() -> Style {
Style::default().fg(Color::DarkGray)
}
fn warning() -> Style {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
fn field(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label}: "), dim()),
Span::raw(value.to_string()),
])
}
fn scope_lines(state: &BurndownState, document: Option<&ChartExport>) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let Some(scope) = state.scope() else {
return lines;
};
let period = format!(
"{}..{}",
bound_text(scope.period_start()),
bound_text(scope.period_end())
);
lines.push(field("scope", scope.kind.as_str()));
lines.push(field("id", &scope.id));
lines.push(field("period", &period));
lines.push(field(
"period source",
&format!("{} ({})", state.period.label(), state.period.description()),
));
lines.push(field(
"snapshot",
scope.snapshot_id.as_deref().unwrap_or("-"),
));
lines.push(field("mode", state.mode.as_str()));
let unit = document
.and_then(|document| document.unit.as_deref())
.or_else(|| state.unit())
.unwrap_or("-");
lines.push(field("unit", unit));
lines.push(field(
"selected",
&format!("{} of {}", state.choice + 1, state.choices.len()),
));
lines
}
fn bound_text(bound: Option<&str>) -> &str {
bound.unwrap_or("open")
}
fn fold_lines(document: &ChartExport) -> Vec<Line<'static>> {
let coverage: CoverageSummary = document.coverage;
let covered = document
.series
.points
.first()
.zip(document.series.points.last())
.map_or_else(
|| "-".to_string(),
|(first, last)| format!("{}..{}", first.date, last.date),
);
vec![
field("formula", &document.formula_version),
Line::from(vec![
Span::styled("days: ", dim()),
Span::raw(document.series.points.len().to_string()),
Span::styled(" covered: ", dim()),
Span::raw(covered),
]),
Line::from(vec![
Span::styled("coverage: ", dim()),
Span::raw(format!(
"known={} unknown={} coverage_limited={}",
coverage.known, coverage.unknown, coverage.coverage_limited
)),
]),
]
}
fn finding_count(document: &ChartExport) -> usize {
document.series.diagnostics.len()
+ document
.projection
.as_ref()
.map_or(0, |projection| projection.diagnostics.len())
+ document.diagnostics.len()
}
fn head_line() -> Line<'static> {
let mut text = String::from(" ");
for (index, column) in DAY_COLUMNS.iter().enumerate() {
if index > 0 {
text.push_str(" ");
}
let width = DAY_WIDTHS[index];
if day_text_column(index) {
text.push_str(&format!("{column:<width$}"));
} else {
text.push_str(&format!("{column:>width$}"));
}
}
text.push_str(" CHART");
Line::from(Span::styled(text, dim()))
}
fn day_line(point: &DailyPoint, selected: bool) -> Line<'static> {
let cells = [
point.date.clone(),
point.coverage.as_str().to_string(),
dash_number(point.remaining),
dash_number(point.scope_total),
dash_number(point.completed),
dash_number(point.scope_added),
dash_number(point.completions),
];
let mut text = if selected {
String::from("> ")
} else {
String::from(" ")
};
for (index, cell) in cells.iter().enumerate() {
if index > 0 {
text.push_str(" ");
}
let width = DAY_WIDTHS[index];
if day_text_column(index) {
text.push_str(&format!("{cell:<width$}"));
} else {
text.push_str(&format!("{cell:>width$}"));
}
}
text.push_str(" ");
text.push_str(&bar_text(point.remaining));
Line::from(Span::styled(text, day_style(point.coverage, selected)))
}
fn day_style(coverage: Coverage, selected: bool) -> Style {
let base = match coverage {
Coverage::Known => Style::default(),
Coverage::CoverageLimited => Style::default().fg(Color::Yellow),
Coverage::Unknown => dim(),
};
if selected {
base.add_modifier(Modifier::BOLD)
} else {
base
}
}
fn bar_text(remaining: Option<i64>) -> String {
let Some(remaining) = remaining else {
return NO_BAR.to_string();
};
let cells = usize::try_from(remaining).unwrap_or(0);
if cells == 0 {
return ZERO_BAR.to_string();
}
let mut bar = BAR.repeat(cells.min(BAR_CELLS));
if cells > BAR_CELLS {
bar.push('…');
}
bar
}
fn projection_lines(state: &BurndownState, document: &ChartExport) -> Vec<Line<'static>> {
let mut lines = Vec::new();
match document.projection.as_ref() {
Some(projection) => lines.extend(forecast_lines(projection)),
None if !state.forecast => {
lines.push(field("projection", "off (press `v` to ask for it)"));
}
None => {
lines.push(field("projection", "unavailable"));
match state.scope().filter(|scope| scope.period_end().is_none()) {
Some(scope) => {
lines.push(Line::from(format!(
" the {} scope `{}` declares no planning end date, so a forecast has \
no boundary to be drawn against",
scope.kind, scope.id
)));
lines.push(Line::from(
" remedy: give the scope an end — the cycle's `due_date` or the \
milestone's target date",
));
}
None => {
lines.push(Line::from(
" the record holds no covered day to stand the forecast on",
));
lines.push(Line::from(
" remedy: widen the series' period (press `p`) so it covers a \
recorded day",
));
}
}
}
}
lines
}
fn forecast_lines(projection: &ChartProjection) -> Vec<Line<'static>> {
vec![
field("projection", projection.status.as_str()),
field(" as_of", &projection.as_of),
field(" end_date", &projection.end_date),
field(" remaining", &dash_number(projection.remaining)),
field(" observed_rate", &dash_number(projection.observed_rate)),
field(
" projected_completion",
projection.projected_completion.as_deref().unwrap_or("-"),
),
field(" meets_end_date", &dash_number(projection.meets_end_date)),
field(" formula", &projection.formula_version),
]
}
fn diagnostic_lines(document: &ChartExport) -> Vec<Line<'static>> {
let mut lines = vec![field("diagnostics", &finding_count(document).to_string())];
for diagnostic in &document.series.diagnostics {
lines.extend(finding_lines(diagnostic));
}
if let Some(projection) = document.projection.as_ref() {
for diagnostic in &projection.diagnostics {
lines.extend(finding_lines(diagnostic));
}
}
for diagnostic in &document.diagnostics {
lines.push(Line::from(format!(
" {} {} {}",
diagnostic.kind,
diagnostic
.scope
.as_deref()
.or(diagnostic.target.as_deref())
.unwrap_or("-"),
diagnostic.reason
)));
if let Some(remedy) = diagnostic.remedy.as_deref() {
lines.push(Line::from(Span::styled(
format!(" remedy: {remedy}"),
dim(),
)));
}
}
if document.series.diagnostics.is_empty()
&& document.diagnostics.is_empty()
&& document
.projection
.as_ref()
.is_none_or(|projection| projection.diagnostics.is_empty())
{
lines.push(Line::from(Span::styled(
" none: the fold answered every day it covers",
dim(),
)));
}
lines
}
fn finding_lines(
diagnostic: &crate::analytics::diagnostics::AnalyticsDiagnostic,
) -> Vec<Line<'static>> {
let mut place = diagnostic.scope.clone();
if let Some(date) = diagnostic.date.as_deref() {
place.push_str(&format!(" {date}"));
}
if let Some(event_id) = diagnostic.event_id.as_deref() {
place.push_str(&format!(" {event_id}"));
}
let mut lines = vec![Line::from(format!(
" {} {place} {}",
diagnostic.kind.as_str(),
diagnostic.message
))];
if let Some(remedy) = diagnostic.remedy.as_deref() {
lines.push(Line::from(Span::styled(
format!(" remedy: {remedy}"),
dim(),
)));
}
lines
}
fn no_scope_lines() -> Vec<Line<'static>> {
vec![
Line::from(Span::styled(
"No cycle or milestone is recorded.",
warning(),
)),
Line::from("The fold reports one scope at a time, and the record names none to report."),
Line::from("Define one with `tix cycle add <name>` or `tix milestone create <name>`,"),
Line::from("then open this tab again."),
]
}
fn no_day_lines(document: &ChartExport) -> Vec<Line<'static>> {
let findings = document.series.diagnostics.len();
let mut lines = vec![
Line::from(Span::styled(
"The fold answered no day for this scope.",
warning(),
)),
Line::from(format!(
"It reported {findings} finding(s) — below — rather than an empty series."
)),
];
if document.series.diagnostics.is_empty() {
lines.push(Line::from(
"The period could not be enumerated: press `p` for the period the record's own",
));
lines.push(Line::from(
"events span, or choose a scope whose record declares its dates.",
));
}
lines
}
fn problem_lines(state: &BurndownState) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
"The fold produced no document.",
warning(),
))];
match state.problem.as_deref() {
Some(problem) => lines.push(Line::from(problem.to_string())),
None => lines.push(Line::from(
"The scope, period, mode or unit selects no series this build can fold.",
)),
}
lines.push(Line::from(""));
lines.push(Line::from(
"press `t` for count mode, `p` for the period the record's own events span,",
));
lines.push(Line::from("or `[`/`]` for another scope"));
lines
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn render(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn unknown_day() -> DailyPoint {
DailyPoint {
date: "2026-01-05".to_string(),
scope_total: None,
remaining: None,
completed: None,
scope_added: None,
completions: None,
coverage: Coverage::Unknown,
event_ids: Vec::new(),
}
}
fn quiet_day() -> DailyPoint {
DailyPoint {
date: "2026-01-06".to_string(),
scope_total: Some(0),
remaining: Some(0),
completed: Some(0),
scope_added: Some(0),
completions: Some(0),
coverage: Coverage::Known,
event_ids: Vec::new(),
}
}
fn limited_day() -> DailyPoint {
DailyPoint {
date: "2026-01-07".to_string(),
scope_total: Some(2),
remaining: Some(1),
completed: None,
scope_added: None,
completions: None,
coverage: Coverage::CoverageLimited,
event_ids: Vec::new(),
}
}
fn text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn an_unanswerable_day_is_a_dash_and_a_read_quiet_day_is_a_zero() {
let unobserved = text(&day_line(&unknown_day(), false));
assert!(unobserved.contains("unknown"), "{unobserved}");
assert!(
unobserved.contains("2026-01-05 unknown"),
"the coverage marker travels beside the values it qualifies: {unobserved}"
);
let cells: Vec<&str> = unobserved.split_whitespace().collect();
assert_eq!(cells[0], "2026-01-05", "{cells:?}");
assert_eq!(cells[1], "unknown", "{cells:?}");
assert_eq!(
cells[2..7].to_vec(),
vec!["-"; 5],
"no numeric cell of an unanswerable day reads as a recorded zero: {cells:?}"
);
assert_eq!(
cells[7], "-",
"and the chart column draws the absent marker, not a zero-length bar: {cells:?}"
);
let observed = text(&day_line(&quiet_day(), false));
assert!(observed.contains("known"), "{observed}");
assert!(
observed.contains(" 0"),
"an observed quiet day's zero is a value: {observed}"
);
assert!(
observed.trim_end().ends_with(ZERO_BAR),
"and its chart column says a known zero, not an unknown one: {observed}"
);
}
#[test]
fn an_unbounded_scope_under_a_forecast_names_the_missing_planning_end() {
let unbounded = AnalyticsScope {
kind: crate::analytics::events::ScopeKind::Cycle,
id: "Open Cycle".to_string(),
period: crate::analytics::events::Period {
start: Some("2026-01-05".to_string()),
end: None,
},
snapshot_id: None,
};
let series = crate::analytics::series::BurndownSeries {
scope: unbounded.clone(),
mode: Mode::Count,
unit: None,
points: vec![quiet_day()],
diagnostics: Vec::new(),
formula_version: "burndown.count.v1".to_string(),
};
let document = ChartExport::build(&series, None, &[]);
let mut state = BurndownState::new(vec![unbounded.clone()], Vec::new());
state.forecast = true;
let rendered = render(&projection_lines(&state, &document));
assert!(
rendered.contains("declares no planning end date"),
"the missing planning end is named: {rendered}"
);
assert!(
!rendered.contains("no covered day"),
"the scope has a covered day, so that sentence is false here: {rendered}"
);
assert!(
rendered.contains("due_date"),
"and the remedy names the record's own field: {rendered}"
);
let bounded = AnalyticsScope {
period: crate::analytics::events::Period {
start: Some("2026-01-05".to_string()),
end: Some("2026-01-09".to_string()),
},
..unbounded.clone()
};
let mut state = BurndownState::new(vec![bounded], Vec::new());
state.forecast = true;
let empty = ChartExport::build(
&crate::analytics::series::BurndownSeries {
points: Vec::new(),
..series
},
None,
&[],
);
let rendered = render(&projection_lines(&state, &empty));
assert!(rendered.contains("no covered day"), "{rendered}");
assert!(!rendered.contains("planning end date"), "{rendered}");
}
#[test]
fn a_coverage_limited_day_draws_the_level_it_holds() {
let row = text(&day_line(&limited_day(), true));
assert!(row.starts_with("> "), "the cursor marks the row: {row}");
assert!(row.contains("coverage_limited"), "{row}");
assert!(row.contains('█'), "{row}");
}
#[test]
fn the_chart_bar_draws_the_value_it_stands_for() {
assert_eq!(bar_text(None), "-", "an unanswerable value draws no bar");
assert_eq!(
bar_text(Some(0)),
"·",
"a known zero is drawn as a zero, not as the absent marker"
);
assert_eq!(bar_text(Some(3)), "███");
assert_eq!(bar_text(Some(20)), "█".repeat(20));
assert_eq!(bar_text(Some(21)), format!("{}…", "█".repeat(20)));
}
#[test]
fn the_burndown_rows_name_only_this_tabs_keys() {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("the built-in bindings resolve");
let context = crate::keybindings::InputContext::Stats;
for action in HELP_ACTIONS {
assert!(
bindings.bound_actions(context).contains(action),
"`{}` is not bound in the Stats context",
action.as_str()
);
}
for absent in [
TuiAction::TimeAdd,
TuiAction::TimeEdit,
TuiAction::TimeDelete,
TuiAction::TimeFilter,
TuiAction::TimeBreakdown,
TuiAction::TimeHistory,
TuiAction::Submit,
] {
assert!(
!HELP_ACTIONS.contains(&absent),
"`{}` acts on nothing on the Burndown tab",
absent.as_str()
);
assert!(
!FOOTER_ACTIONS.contains(&absent),
"`{}` acts on nothing on the Burndown tab",
absent.as_str()
);
}
let advertised: Vec<&str> = HELP_RAW.iter().map(|(key, _)| *key).collect();
for key in ["[ / ]", "p", "t", "u", "v", "d / D"] {
assert!(
advertised.contains(&key),
"the tab names `{key}`: {advertised:?}"
);
}
}
#[test]
fn a_tab_with_no_scope_says_so() {
let state = BurndownState::new(Vec::new(), Vec::new());
let frame = frame(&state);
let text: Vec<String> = frame.lines.iter().map(text).collect();
assert!(
text.iter()
.any(|line| line.contains("No cycle or milestone is recorded")),
"{text:?}"
);
assert_eq!(frame.cursor_line, 0);
}
#[test]
fn the_controls_move_between_the_records_own_choices() {
let mut state = BurndownState::new(
vec![
AnalyticsScope::cycle("Sprint 7", Period::unbounded()).unwrap(),
AnalyticsScope::milestone("v1", Period::unbounded()).unwrap(),
],
vec!["points".to_string(), "hours".to_string()],
);
assert_eq!(state.mode, Mode::Count);
assert_eq!(state.unit(), None, "a count is unitless");
state.cycle_mode();
assert_eq!(state.mode, Mode::Estimate);
assert_eq!(state.unit(), Some("points"));
state.cycle_unit();
assert_eq!(state.unit(), Some("hours"));
state.cycle_unit();
assert_eq!(state.unit(), Some("points"), "the unit list wraps");
state.cycle_period();
assert_eq!(state.period, BurndownPeriod::Observed);
assert_eq!(
state.scope().unwrap().period,
Period::unbounded(),
"the observed source releases both bounds"
);
state.shift_scope(1);
assert_eq!(state.selected().unwrap().id, "v1");
state.shift_scope(1);
assert_eq!(state.selected().unwrap().id, "Sprint 7", "the ring wraps");
state.shift_scope(-1);
assert_eq!(state.selected().unwrap().id, "v1");
}
}