1use chrono::Datelike;
6use ratatui::{
7 layout::Rect,
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
11 Frame,
12};
13
14use crate::models::{AccountType, BudgetPeriod, TargetCadence};
15use crate::services::{AccountService, BudgetService, CategoryService};
16use crate::tui::app::{App, BudgetHeaderDisplay, FocusedPanel};
17use crate::tui::layout::BudgetLayout;
18
19pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
21 let layout = BudgetLayout::new(area);
22
23 render_atb_header(frame, app, layout.atb_header);
25
26 render_category_table(frame, app, layout.categories);
28}
29
30fn render_atb_header(frame: &mut Frame, app: &mut App, area: Rect) {
32 let budget_service = BudgetService::new(app.storage);
33
34 let (label, amount, color) = match app.budget_header_display {
35 BudgetHeaderDisplay::AvailableToBudget => {
36 let atb = budget_service
37 .get_available_to_budget(&app.current_period)
38 .unwrap_or_default();
39
40 let color = if atb.is_negative() {
41 Color::Red
42 } else if atb.is_zero() {
43 Color::Green
44 } else {
45 Color::Yellow
46 };
47
48 let label = if atb.is_negative() {
49 "Overspent"
50 } else if atb.is_zero() {
51 "All money assigned!"
52 } else {
53 "Available to Assign"
54 };
55
56 (label.to_string(), atb, color)
57 }
58 _ => {
59 let account_type = match app.budget_header_display {
61 BudgetHeaderDisplay::Checking => AccountType::Checking,
62 BudgetHeaderDisplay::Savings => AccountType::Savings,
63 BudgetHeaderDisplay::Credit => AccountType::Credit,
64 BudgetHeaderDisplay::Cash => AccountType::Cash,
65 BudgetHeaderDisplay::Investment => AccountType::Investment,
66 BudgetHeaderDisplay::LineOfCredit => AccountType::LineOfCredit,
67 BudgetHeaderDisplay::Other => AccountType::Other,
68 BudgetHeaderDisplay::AvailableToBudget => unreachable!(),
69 };
70
71 let account_service = AccountService::new(app.storage);
72 let balance = account_service
73 .total_balance_by_type(account_type)
74 .unwrap_or_default();
75 let count = account_service.count_by_type(account_type).unwrap_or(0);
76
77 let color = if balance.is_negative() {
79 Color::Red
80 } else if balance.is_zero() {
81 Color::Yellow
82 } else {
83 Color::Green
84 };
85
86 let label = if count == 0 {
87 format!("{} (no accounts)", app.budget_header_display.label())
88 } else if count == 1 {
89 format!("{} (1 account)", app.budget_header_display.label())
90 } else {
91 format!("{} ({} accounts)", app.budget_header_display.label(), count)
92 };
93
94 (label, balance, color)
95 }
96 };
97
98 let income_indicator = if let Some(overage) = budget_service
100 .is_over_expected_income(&app.current_period)
101 .ok()
102 .flatten()
103 {
104 Some((format!(" │ Over Income: {} ⚠", overage), Color::Red))
106 } else if let Some(remaining) = budget_service
107 .get_remaining_to_budget_from_income(&app.current_period)
108 .ok()
109 .flatten()
110 {
111 if remaining.is_positive() {
112 Some((
113 format!(" │ Remaining to Budget: {} ✓", remaining),
114 Color::Green,
115 ))
116 } else if remaining.is_zero() {
117 Some((" │ Income: Fully Budgeted ✓".to_string(), Color::Green))
118 } else {
119 None
120 }
121 } else {
122 None
123 };
124
125 let block = Block::default()
126 .title(format!(" Budget - {} ", app.current_period))
127 .title_style(
128 Style::default()
129 .fg(Color::Cyan)
130 .add_modifier(Modifier::BOLD),
131 )
132 .borders(Borders::ALL)
133 .border_style(Style::default().fg(Color::White));
134
135 let mut spans = vec![
136 Span::styled("◀ ", Style::default().fg(Color::DarkGray)),
137 Span::styled(label, Style::default().fg(Color::White)),
138 Span::styled(" ▶ ", Style::default().fg(Color::DarkGray)),
139 Span::styled(
140 format!("{}", amount),
141 Style::default().fg(color).add_modifier(Modifier::BOLD),
142 ),
143 ];
144
145 if let Some((income_text, income_color)) = income_indicator {
147 spans.push(Span::styled(income_text, Style::default().fg(income_color)));
148 }
149
150 spans.extend(vec![
151 Span::raw(" │ "),
152 Span::styled("[< / >] Toggle ", Style::default().fg(Color::Yellow)),
153 Span::styled("[[ / ]] Period ", Style::default().fg(Color::Yellow)),
154 Span::styled("[m] Move", Style::default().fg(Color::Yellow)),
155 ]);
156
157 let line = Line::from(spans);
158
159 let paragraph = Paragraph::new(line).block(block);
160
161 frame.render_widget(paragraph, area);
162}
163
164fn render_category_table(frame: &mut Frame, app: &mut App, area: Rect) {
166 let is_focused = app.focused_panel == FocusedPanel::Main;
167 let border_color = if is_focused { Color::Cyan } else { Color::Gray };
168
169 let block = Block::default()
170 .borders(Borders::ALL)
171 .border_style(Style::default().fg(border_color));
172
173 let category_service = CategoryService::new(app.storage);
174 let budget_service = BudgetService::new(app.storage);
175
176 let groups = category_service.list_groups().unwrap_or_default();
178 let categories = category_service.list_categories().unwrap_or_default();
179
180 let mut rows: Vec<Row> = Vec::new();
182 let mut row_to_category_index: Vec<Option<usize>> = Vec::new();
183
184 let mut visual_index = 0usize;
186
187 for group in &groups {
188 rows.push(
190 Row::new(vec![Cell::from(format!("▼ {}", group.name))])
191 .style(
192 Style::default()
193 .fg(Color::Cyan)
194 .add_modifier(Modifier::BOLD),
195 )
196 .height(1),
197 );
198 row_to_category_index.push(None);
199
200 let group_categories: Vec<_> = categories
202 .iter()
203 .filter(|c| c.group_id == group.id)
204 .collect();
205
206 for category in group_categories {
207 let cat_index = visual_index;
208 visual_index += 1;
209 let summary = budget_service
210 .get_category_summary(category.id, &app.current_period)
211 .unwrap_or_else(|_| crate::models::CategoryBudgetSummary::empty(category.id));
212
213 let target = budget_service.get_target(category.id).ok().flatten();
215
216 let target_indicator = if target.is_some() { "◉ " } else { " " };
218
219 let target_display = match &target {
221 Some(t) => {
222 match &t.cadence {
223 TargetCadence::ByDate { target_date } => {
224 let target_period =
226 BudgetPeriod::monthly(target_date.year(), target_date.month());
227 let cumulative_paid = budget_service
228 .calculate_cumulative_paid(category.id, &target_period)
229 .unwrap_or_default();
230 let cumulative_budgeted = budget_service
231 .calculate_cumulative_budgeted(category.id, &target_period)
232 .unwrap_or_default();
233
234 let progress_amount = if cumulative_paid.cents() > 0 {
236 cumulative_paid.cents()
237 } else {
238 cumulative_budgeted.cents().max(0)
239 };
240
241 let unpaid_budgeted =
244 (cumulative_budgeted.cents() - cumulative_paid.cents()).max(0);
245 let preview_amount = cumulative_paid.cents() + unpaid_budgeted;
246
247 let target_cents = t.amount.cents();
248 let (progress_pct, preview_pct) = if target_cents > 0 {
249 let progress = ((progress_amount as f64 / target_cents as f64)
250 * 100.0)
251 .min(100.0);
252 let preview =
253 ((preview_amount.max(0) as f64 / target_cents as f64) * 100.0)
254 .min(100.0);
255 (progress, preview)
256 } else {
257 (0.0, 0.0)
258 };
259
260 let base_text = format!(
261 "{} by {} ({:.0}%",
262 t.amount,
263 target_date.format("%b %Y"),
264 progress_pct
265 );
266
267 if (preview_pct - progress_pct).abs() > 0.5 {
269 Line::from(vec![
270 Span::styled(base_text, Style::default().fg(Color::Magenta)),
271 Span::styled(
272 format!(" → {:.0}%", preview_pct),
273 Style::default().fg(Color::White),
274 ),
275 Span::styled(")", Style::default().fg(Color::Magenta)),
276 ])
277 } else {
278 Line::from(vec![Span::styled(
279 format!("{})", base_text),
280 Style::default().fg(Color::Magenta),
281 )])
282 }
283 }
284 _ => Line::from(Span::styled(
285 format!("{} {}", t.amount, t.cadence),
286 Style::default().fg(Color::Magenta),
287 )),
288 }
289 }
290 None => Line::from(Span::styled("—", Style::default().fg(Color::White))),
291 };
292
293 let available_style = if summary.is_overspent() {
295 Style::default().fg(Color::Red)
296 } else if summary.available.is_zero() {
297 Style::default().fg(Color::Yellow)
298 } else {
299 Style::default().fg(Color::Green)
300 };
301
302 let activity_style = if summary.activity.is_negative() {
304 Style::default().fg(Color::Red)
305 } else if summary.activity.is_positive() {
306 Style::default().fg(Color::Green)
307 } else {
308 Style::default().fg(Color::Yellow)
309 };
310
311 rows.push(Row::new(vec![
312 Cell::from(format!("{}{}", target_indicator, category.name)),
313 Cell::from(format!("{}", summary.budgeted)),
314 Cell::from(format!("{}", summary.activity)).style(activity_style),
315 Cell::from(format!("{}", summary.available)).style(available_style),
316 Cell::from(target_display),
317 ]));
318 row_to_category_index.push(Some(cat_index));
319 }
320 }
321
322 if rows.is_empty() {
323 let text = Paragraph::new("No categories. Run 'envelope category create' to add some.")
324 .block(block)
325 .style(Style::default().fg(Color::Yellow));
326 frame.render_widget(text, area);
327 return;
328 }
329
330 let widths = [
332 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), ];
338
339 let header = Row::new(vec![
341 Cell::from("Category").style(Style::default().add_modifier(Modifier::BOLD)),
342 Cell::from("Budgeted").style(Style::default().add_modifier(Modifier::BOLD)),
343 Cell::from("Activity").style(Style::default().add_modifier(Modifier::BOLD)),
344 Cell::from("Available").style(Style::default().add_modifier(Modifier::BOLD)),
345 Cell::from("Target").style(Style::default().add_modifier(Modifier::BOLD)),
346 ])
347 .style(Style::default().fg(Color::Yellow))
348 .height(1);
349
350 let table = Table::new(rows, widths)
351 .header(header)
352 .block(block)
353 .highlight_style(
354 Style::default()
355 .bg(Color::DarkGray)
356 .add_modifier(Modifier::BOLD),
357 )
358 .highlight_symbol("▶ ");
359
360 let selected_row = row_to_category_index
362 .iter()
363 .position(|&idx| idx == Some(app.selected_category_index))
364 .unwrap_or(0);
365
366 let mut state = TableState::default();
367 state.select(Some(selected_row));
368
369 frame.render_stateful_widget(table, area, &mut state);
370}