use chrono::Datelike;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
Frame,
};
use crate::models::{AccountType, BudgetPeriod, TargetCadence};
use crate::services::{AccountService, BudgetService, CategoryService};
use crate::tui::app::{App, BudgetHeaderDisplay, FocusedPanel};
use crate::tui::layout::BudgetLayout;
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
let layout = BudgetLayout::new(area);
render_atb_header(frame, app, layout.atb_header);
render_category_table(frame, app, layout.categories);
}
fn render_atb_header(frame: &mut Frame, app: &mut App, area: Rect) {
let budget_service = BudgetService::new(app.storage);
let (label, amount, color) = match app.budget_header_display {
BudgetHeaderDisplay::AvailableToBudget => {
let atb = budget_service
.get_available_to_budget(&app.current_period)
.unwrap_or_default();
let color = if atb.is_negative() {
Color::Red
} else if atb.is_zero() {
Color::Green
} else {
Color::Yellow
};
let label = if atb.is_negative() {
"Overspent"
} else if atb.is_zero() {
"All money assigned!"
} else {
"Available to Assign"
};
(label.to_string(), atb, color)
}
_ => {
let account_type = match app.budget_header_display {
BudgetHeaderDisplay::Checking => AccountType::Checking,
BudgetHeaderDisplay::Savings => AccountType::Savings,
BudgetHeaderDisplay::Credit => AccountType::Credit,
BudgetHeaderDisplay::Cash => AccountType::Cash,
BudgetHeaderDisplay::Investment => AccountType::Investment,
BudgetHeaderDisplay::LineOfCredit => AccountType::LineOfCredit,
BudgetHeaderDisplay::Other => AccountType::Other,
BudgetHeaderDisplay::AvailableToBudget => unreachable!(),
};
let account_service = AccountService::new(app.storage);
let balance = account_service
.total_balance_by_type(account_type)
.unwrap_or_default();
let count = account_service.count_by_type(account_type).unwrap_or(0);
let color = if balance.is_negative() {
Color::Red
} else if balance.is_zero() {
Color::Yellow
} else {
Color::Green
};
let label = if count == 0 {
format!("{} (no accounts)", app.budget_header_display.label())
} else if count == 1 {
format!("{} (1 account)", app.budget_header_display.label())
} else {
format!("{} ({} accounts)", app.budget_header_display.label(), count)
};
(label, balance, color)
}
};
let income_indicator = if let Some(overage) = budget_service
.is_over_expected_income(&app.current_period)
.ok()
.flatten()
{
Some((format!(" │ Over Income: {} ⚠", overage), Color::Red))
} else if let Some(remaining) = budget_service
.get_remaining_to_budget_from_income(&app.current_period)
.ok()
.flatten()
{
if remaining.is_positive() {
Some((
format!(" │ Remaining to Budget: {} ✓", remaining),
Color::Green,
))
} else if remaining.is_zero() {
Some((" │ Income: Fully Budgeted ✓".to_string(), Color::Green))
} else {
None
}
} else {
None
};
let block = Block::default()
.title(format!(" Budget - {} ", app.current_period))
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White));
let mut spans = vec![
Span::styled("◀ ", Style::default().fg(Color::DarkGray)),
Span::styled(label, Style::default().fg(Color::White)),
Span::styled(" ▶ ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", amount),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
];
if let Some((income_text, income_color)) = income_indicator {
spans.push(Span::styled(income_text, Style::default().fg(income_color)));
}
spans.extend(vec![
Span::raw(" │ "),
Span::styled("[< / >] Toggle ", Style::default().fg(Color::Yellow)),
Span::styled("[[ / ]] Period ", Style::default().fg(Color::Yellow)),
Span::styled("[m] Move", Style::default().fg(Color::Yellow)),
]);
let line = Line::from(spans);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
fn render_category_table(frame: &mut Frame, app: &mut App, area: Rect) {
let is_focused = app.focused_panel == FocusedPanel::Main;
let border_color = if is_focused { Color::Cyan } else { Color::Gray };
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let category_service = CategoryService::new(app.storage);
let budget_service = BudgetService::new(app.storage);
let groups = category_service.list_groups().unwrap_or_default();
let categories = category_service.list_categories().unwrap_or_default();
let mut rows: Vec<Row> = Vec::new();
let mut row_to_category_index: Vec<Option<usize>> = Vec::new();
let mut visual_index = 0usize;
for group in &groups {
rows.push(
Row::new(vec![Cell::from(format!("▼ {}", group.name))])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.height(1),
);
row_to_category_index.push(None);
let group_categories: Vec<_> = categories
.iter()
.filter(|c| c.group_id == group.id)
.collect();
for category in group_categories {
let cat_index = visual_index;
visual_index += 1;
let summary = budget_service
.get_category_summary(category.id, &app.current_period)
.unwrap_or_else(|_| crate::models::CategoryBudgetSummary::empty(category.id));
let target = budget_service.get_target(category.id).ok().flatten();
let target_indicator = if target.is_some() { "◉ " } else { " " };
let target_display = match &target {
Some(t) => {
match &t.cadence {
TargetCadence::ByDate { target_date } => {
let target_period =
BudgetPeriod::monthly(target_date.year(), target_date.month());
let cumulative_paid = budget_service
.calculate_cumulative_paid(category.id, &target_period)
.unwrap_or_default();
let cumulative_budgeted = budget_service
.calculate_cumulative_budgeted(category.id, &target_period)
.unwrap_or_default();
let progress_amount = if cumulative_paid.cents() > 0 {
cumulative_paid.cents()
} else {
cumulative_budgeted.cents().max(0)
};
let unpaid_budgeted =
(cumulative_budgeted.cents() - cumulative_paid.cents()).max(0);
let preview_amount = cumulative_paid.cents() + unpaid_budgeted;
let target_cents = t.amount.cents();
let (progress_pct, preview_pct) = if target_cents > 0 {
let progress = ((progress_amount as f64 / target_cents as f64)
* 100.0)
.min(100.0);
let preview =
((preview_amount.max(0) as f64 / target_cents as f64) * 100.0)
.min(100.0);
(progress, preview)
} else {
(0.0, 0.0)
};
let base_text = format!(
"{} by {} ({:.0}%",
t.amount,
target_date.format("%b %Y"),
progress_pct
);
if (preview_pct - progress_pct).abs() > 0.5 {
Line::from(vec![
Span::styled(base_text, Style::default().fg(Color::Magenta)),
Span::styled(
format!(" → {:.0}%", preview_pct),
Style::default().fg(Color::White),
),
Span::styled(")", Style::default().fg(Color::Magenta)),
])
} else {
Line::from(vec![Span::styled(
format!("{})", base_text),
Style::default().fg(Color::Magenta),
)])
}
}
_ => Line::from(Span::styled(
format!("{} {}", t.amount, t.cadence),
Style::default().fg(Color::Magenta),
)),
}
}
None => Line::from(Span::styled("—", Style::default().fg(Color::White))),
};
let available_style = if summary.is_overspent() {
Style::default().fg(Color::Red)
} else if summary.available.is_zero() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
let activity_style = if summary.activity.is_negative() {
Style::default().fg(Color::Red)
} else if summary.activity.is_positive() {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
};
rows.push(Row::new(vec![
Cell::from(format!("{}{}", target_indicator, category.name)),
Cell::from(format!("{}", summary.budgeted)),
Cell::from(format!("{}", summary.activity)).style(activity_style),
Cell::from(format!("{}", summary.available)).style(available_style),
Cell::from(target_display),
]));
row_to_category_index.push(Some(cat_index));
}
}
if rows.is_empty() {
let text = Paragraph::new("No categories. Run 'envelope category create' to add some.")
.block(block)
.style(Style::default().fg(Color::Yellow));
frame.render_widget(text, area);
return;
}
let widths = [
ratatui::layout::Constraint::Min(20), ratatui::layout::Constraint::Length(14), ratatui::layout::Constraint::Length(14), ratatui::layout::Constraint::Length(14), ratatui::layout::Constraint::Length(30), ];
let header = Row::new(vec![
Cell::from("Category").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Budgeted").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Activity").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Available").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Target").style(Style::default().add_modifier(Modifier::BOLD)),
])
.style(Style::default().fg(Color::Yellow))
.height(1);
let table = Table::new(rows, widths)
.header(header)
.block(block)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let selected_row = row_to_category_index
.iter()
.position(|&idx| idx == Some(app.selected_category_index))
.unwrap_or(0);
let mut state = TableState::default();
state.select(Some(selected_row));
frame.render_stateful_widget(table, area, &mut state);
}