#[allow(clippy::wildcard_imports)]
use super::*;
pub struct HealthRow {
pub scope: crate::health::HealthScope,
pub state: String,
pub layer: String,
pub derived: Option<String>,
}
pub(in crate::app) fn render_stats(app: &App, f: &mut ratatui::Frame, area: Rect) {
let block = Block::default().borders(Borders::ALL).title("Stats (F7)");
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let strip = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
f.render_widget(Paragraph::new(stats_tab_line(app.stats_tab)), strip);
if inner.height <= 1 {
return;
}
let hint = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: 1,
};
f.render_widget(
Paragraph::new(Span::styled(
app.stats_tab.hint(),
Style::default().fg(Color::DarkGray),
)),
hint,
);
if inner.height <= 2 {
return;
}
let body = Rect {
x: inner.x,
y: inner.y + 2,
width: inner.width,
height: inner.height - 2,
};
match app.stats_tab {
StatsTab::Health => render_stats_health(app, f, body),
StatsTab::Time => render_stats_time(app, f, body),
StatsTab::Burndown => render_stats_burndown(app, f, body),
}
}
pub(in crate::app) fn stats_tab_line(active: StatsTab) -> Line<'static> {
let mut spans = vec![Span::styled(
"F7 Stats ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
for (index, tab) in StatsTab::ALL.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let style = if *tab == active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(format!("[{}]", tab.label()), style));
}
Line::from(spans)
}
pub(in crate::app) fn stats_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.to_string()),
])
}
pub(in crate::app) fn render_stats_health(app: &App, f: &mut ratatui::Frame, area: Rect) {
let inner = area;
if inner.height == 0 || inner.width == 0 {
return;
}
let Some(document) = app.health.as_ref() else {
f.render_widget(
Paragraph::new(vec![Line::from(Span::styled(
"Health is unavailable: the repository could not be read.",
Style::default().fg(Color::Red),
))]),
inner,
);
return;
};
let rows = app.stats_rows();
let mut lines: Vec<Line<'static>> = Vec::new();
let summary = crate::commands::health::HealthSummary::of(document);
lines.push(Line::from(Span::styled(
summary.render().trim_end().to_string(),
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
let recorded_any = document.scopes.iter().any(|entry| entry.recorded.is_some());
if !recorded_any {
lines.push(Line::from(Span::styled(
"No health is recorded.",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(
"Record a state with `tix health set <issue> --state <state> --rationale <why>`,",
));
lines.push(Line::from(
"or open an issue and press `H` to fill the form in.",
));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
" SCOPE STATE LAYER DERIVED",
Style::default().fg(Color::DarkGray),
)));
for (index, row) in rows.iter().enumerate() {
let selected = index == app.stats_cursor;
let marker = if selected { "> " } else { " " };
let style = if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let derived = row.derived.clone().unwrap_or_else(|| "-".to_string());
lines.push(Line::from(Span::styled(
format!(
"{marker}{:<16} {:<16} {:<8} {derived}",
row.scope.as_str(),
row.state,
row.layer
),
style,
)));
}
if !document.diagnostics.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{} diagnostics", document.diagnostics.len()),
Style::default().fg(Color::DarkGray),
)));
for diagnostic in &document.diagnostics {
let shared = diagnostic.to_diagnostic();
lines.push(Line::from(format!(
"{} {} {}",
shared.kind.as_str(),
shared.scope,
shared.message
)));
if let Some(remedy) = &shared.remedy {
lines.push(Line::from(Span::styled(
format!(" remedy: {remedy}"),
Style::default().fg(Color::DarkGray),
)));
}
}
}
f.render_widget(Paragraph::new(lines), inner);
}
pub(in crate::app) fn feed_minutes(text: &str, hint: &str) -> std::result::Result<i64, String> {
let text = text.trim();
text.parse()
.map_err(|_| format!("minutes {text:?} is not a whole number of minutes; {hint}"))
}
pub(in crate::app) fn time_issue_cell(issue: u32) -> String {
format!("#{issue:<5}")
}
#[allow(clippy::too_many_lines)]
pub(in crate::app) fn render_stats_time(app: &App, f: &mut ratatui::Frame, area: Rect) {
let Some(state) = app.stats_time.as_ref() else {
f.render_widget(
Paragraph::new(vec![Line::from(Span::styled(
"Time is unavailable: the issues could not be read.",
Style::default().fg(Color::Red),
))]),
area,
);
return;
};
let total = state.total();
let rows = state.rows();
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(stats_line("scope", &total.filter.describe()));
lines.push(stats_line("state", total.state().as_str()));
lines.push(stats_line(
"total_minutes",
&crate::ui::detail::dash_number(total.total_minutes),
));
lines.push(stats_line(
"entries",
&format!(
"{} counted, {} malformed, {} undated",
total.entry_count, total.malformed_count, total.undated_count
),
));
lines.push(stats_line(
"deleted",
&crate::ui::detail::dash_number(total.deleted_count),
));
match total.state() {
crate::time_tracking::aggregate::TimeTotalState::Recorded => {}
crate::time_tracking::aggregate::TimeTotalState::Nothing => {
lines.push(Line::from(Span::styled(
"No time is recorded for this scope.",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
}
crate::time_tracking::aggregate::TimeTotalState::Unknown => {
let sentence = if total.malformed_count > 0 {
"This scope selected entries and none carried a usable duration — the findings \
below name each."
} else {
"This scope names a period and the entries it selected carry no date, so no period \
can place them — the total is unknown, not a zero."
};
lines.push(Line::from(Span::styled(
sentence,
Style::default().fg(Color::Yellow),
)));
}
crate::time_tracking::aggregate::TimeTotalState::InvalidFilter
| crate::time_tracking::aggregate::TimeTotalState::UnknownIssue => {
lines.push(Line::from(Span::styled(
"The scope was refused: no entry was counted, and the findings below say what to \
change (`f` edits the filter).",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
}
}
if total.is_partial() {
lines.push(Line::from(Span::styled(
"The total is partial: the counts above name what it left out.",
Style::default().fg(Color::Yellow),
)));
}
for finding in &total.diagnostics {
lines.push(stats_line(
"finding",
&format!("{}: {}", finding.kind.as_str(), finding.reason),
));
lines.push(stats_line(" remedy", &finding.remedy));
}
if total.undated_count > 0 {
lines.push(stats_line(
"undated",
&format!(
"{} — an entry that carries no date is in no period, so a period-bounded scope \
counts it as unplaceable (its value is unknown to the period, not zero)",
total.undated_count
),
));
let scope = state.scope();
for group in state.snapshot.by_issue(&scope) {
if group.undated_count == 0 {
continue;
}
lines.push(Line::from(format!(
" {} holds {}",
group.group.label(),
group.undated_count
)));
}
lines.push(stats_line(
" remedy",
"drop the period bounds with `f`, or give the entry a date",
));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("current entries ({})", rows.len()),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
if rows.is_empty() {
lines.push(Line::from(
" none — press `a` to add one, or `f` to widen the scope",
));
} else {
lines.push(Line::from(Span::styled(
format!(
" {:<7} {}",
"ISSUE",
crate::ui::detail::time_entry_heading()
),
Style::default().fg(Color::DarkGray),
)));
let selected = state.cursor.min(rows.len() - 1);
for (index, indexed) in rows.iter().enumerate() {
let marker = if index == selected { "> " } else { " " };
let style = if index == selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
format!(
"{marker}{} {}",
time_issue_cell(indexed.issue),
crate::ui::detail::time_entry_row(&indexed.entry)
),
style,
)));
}
}
if state.breakdown != TimeBreakdown::All {
let groups = state.groups();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"breakdown by {} ({} groups)",
state.breakdown.label(),
groups.len()
),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
for group in &groups {
lines.push(Line::from(format!(
" {:<14} {:<14} {:>9} {} counted, {} malformed, {} undated",
group.group.label(),
group.state().as_str(),
crate::ui::detail::dash_number(group.total_minutes),
group.entry_count,
group.malformed_count,
group.undated_count,
)));
}
}
if state.audit {
let audit = state.audit_view();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"history (audit): {} deleted, {} corrected",
audit.deleted_count, audit.corrected_count
),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
for issue in &audit.issues {
lines.push(Line::from(format!(
" #{} current {} deleted {} corrected {}",
issue.issue,
issue.current,
issue.deletions.len(),
issue.corrections,
)));
for deletion in &issue.deletions {
lines.push(Line::from(format!(
" {} by {} at {}",
crate::ui::detail::dash_cell(deletion.entry_id.as_deref()),
crate::ui::detail::dash_cell(Some(deletion.deleted_by.as_str())),
crate::ui::detail::dash_cell(Some(deletion.deleted_at.as_str())),
)));
}
}
for finding in &audit.diagnostics {
lines.push(stats_line(
" audit finding",
&format!("{}: {}", finding.kind.as_str(), finding.reason),
));
}
}
f.render_widget(Paragraph::new(lines), area);
}
pub(in crate::app) fn render_stats_burndown(app: &App, f: &mut ratatui::Frame, area: Rect) {
let Some(state) = app.stats_burndown.as_ref() else {
f.render_widget(
Paragraph::new(vec![Line::from(Span::styled(
"Burndown is unavailable: the tab has not been read yet.",
Style::default().fg(Color::Red),
))]),
area,
);
return;
};
let body = crate::ui::analytics::frame(state);
let lead = usize::from(area.height / 2);
let offset = body.cursor_line.saturating_sub(lead);
let offset = u16::try_from(offset).unwrap_or(u16::MAX);
f.render_widget(Paragraph::new(body.lines).scroll((offset, 0)), area);
}
pub(in crate::app) fn render_time_form(app: &App, f: &mut ratatui::Frame, area: Rect) {
let Some(form) = app.time_form.as_ref() else {
return;
};
let width = area.width.min(78);
let height = area.height.min(12);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
let block = Block::default().borders(Borders::ALL).title(form.title());
let inner = block.inner(rect);
f.render_widget(Clear, rect);
f.render_widget(block, rect);
if inner.height == 0 {
return;
}
let field = |index: usize, label: &str, value: &str| {
let focused = form.field == index;
Line::from(vec![
Span::styled(format!("{label:<10}"), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("[{value}]"),
if focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
),
])
};
let mut lines: Vec<Line<'static>> = Vec::new();
if form.is_add() {
lines.push(field(0, "issue", &form.issue));
lines.push(field(1, "minutes", &form.minutes));
lines.push(field(2, "date", &form.date));
lines.push(field(3, "author", &form.author));
lines.push(field(4, "note", &form.note));
} else if form.is_edit() {
lines.push(field(0, "minutes", &form.minutes));
lines.push(field(1, "date", &form.date));
lines.push(field(2, "author", &form.author));
lines.push(field(3, "note", &form.note));
lines.push(field(4, "reason", &form.reason));
lines.push(Line::from(Span::styled(
"a blank field keeps the recorded value; the reason is required",
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(field(0, "reason", &form.reason));
lines.push(field(
1,
"confirm",
if form.confirm {
"confirmed"
} else {
"press space to confirm"
},
));
lines.push(Line::from(Span::styled(
"a deletion removes the current entry; its value and its event stay in History",
Style::default().fg(Color::DarkGray),
)));
}
if let Some(error) = &form.error {
lines.push(Line::from(Span::styled(
error.clone(),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(Span::styled(
"Tab: next field space: confirm Enter: submit Esc: cancel",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
pub(in crate::app) fn render_time_filter_form(app: &App, f: &mut ratatui::Frame, area: Rect) {
let Some(form) = app.time_filter_form.as_ref() else {
return;
};
let width = area.width.min(72);
let height = area.height.min(12);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
let block = Block::default().borders(Borders::ALL).title("Time filter");
let inner = block.inner(rect);
f.render_widget(Clear, rect);
f.render_widget(block, rect);
if inner.height == 0 {
return;
}
let field = |index: usize, label: &str, value: &str| {
let focused = form.field == index;
Line::from(vec![
Span::styled(format!("{label:<10}"), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("[{value}]"),
if focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
),
])
};
let mut lines = vec![
field(0, "issue", &form.issue),
field(1, "assignee", &form.assignee),
field(2, "from", &form.from),
field(3, "to", &form.to),
Line::from(Span::styled(
"blank criteria are dropped; the period is inclusive (`YYYY-MM-DD`)",
Style::default().fg(Color::DarkGray),
)),
];
if let Some(error) = &form.error {
lines.push(Line::from(Span::styled(
error.clone(),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(Span::styled(
"Tab: next field Enter: apply Esc: cancel",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
pub(in crate::app) fn render_health_form(app: &App, f: &mut ratatui::Frame, area: Rect) {
let Some(form) = app.health_form.as_ref() else {
return;
};
let width = area.width.min(72);
let height = area.height.min(10);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
let block = Block::default().borders(Borders::ALL).title(format!(
"Health for #{} ({}, for {})",
form.issue,
form.layer_name(),
"an issue's own value"
));
let inner = block.inner(rect);
f.render_widget(Clear, rect);
f.render_widget(block, rect);
if inner.height == 0 {
return;
}
let field = |index: usize, label: &str, value: &str| {
let focused = form.field == index;
Line::from(vec![
Span::styled(format!("{label:<10}"), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("[{value}]"),
if focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
),
])
};
let lines = vec![
field(0, "state", &form.state),
field(1, "actor", &form.actor),
field(2, "rationale", &form.rationale),
field(3, "layer", form.layer_name()),
Line::from(Span::styled(
"Tab: next field space: toggle layer Enter: record Esc: cancel",
Style::default().fg(Color::DarkGray),
)),
];
f.render_widget(Paragraph::new(lines), inner);
}