envelope_cli/tui/views/
budget.rs

1//! Budget view
2//!
3//! Shows budget categories with budgeted, activity, available, and target amounts
4
5use 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
19/// Render the budget view
20pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
21    let layout = BudgetLayout::new(area);
22
23    // Render ATB header
24    render_atb_header(frame, app, layout.atb_header);
25
26    // Render category table
27    render_category_table(frame, app, layout.categories);
28}
29
30/// Render Available to Budget header (or account type balance based on toggle)
31fn 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            // Get account type from the display mode
60            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            // Color: green for positive, red for negative, yellow for zero
78            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    // Check for expected income comparison
99    let income_indicator = if let Some(overage) = budget_service
100        .is_over_expected_income(&app.current_period)
101        .ok()
102        .flatten()
103    {
104        // Over budget warning
105        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    // Add income indicator if present
146    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
164/// Render category budget table
165fn 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    // Get groups and categories
177    let groups = category_service.list_groups().unwrap_or_default();
178    let categories = category_service.list_categories().unwrap_or_default();
179
180    // Build rows with group headers
181    let mut rows: Vec<Row> = Vec::new();
182    let mut row_to_category_index: Vec<Option<usize>> = Vec::new();
183
184    // Track visual index (categories in display order)
185    let mut visual_index = 0usize;
186
187    for group in &groups {
188        // Group header row
189        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        // Categories in this group
201        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            // Get target for this category
214            let target = budget_service.get_target(category.id).ok().flatten();
215
216            // Target indicator for category name
217            let target_indicator = if target.is_some() { "◉ " } else { "  " };
218
219            // Build target display with progress for ByDate goals
220            let target_display = match &target {
221                Some(t) => {
222                    match &t.cadence {
223                        TargetCadence::ByDate { target_date } => {
224                            // For ByDate goals: paid is the source of truth, budgeted is fallback
225                            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                            // Use paid amount; fall back to budgeted only if no payments yet
235                            let progress_amount = if cumulative_paid.cents() > 0 {
236                                cumulative_paid.cents()
237                            } else {
238                                cumulative_budgeted.cents().max(0)
239                            };
240
241                            // Preview: what progress would be if all budgeted amount is paid
242                            // Only count unpaid budgeted (avoid double-counting already paid amounts)
243                            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                            // Show preview only if it differs from current progress
268                            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            // Available column styling
294            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            // Activity styling (negative = spending)
303            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    // Column widths
331    let widths = [
332        ratatui::layout::Constraint::Min(20), // Category name (with target indicator)
333        ratatui::layout::Constraint::Length(14), // Budgeted
334        ratatui::layout::Constraint::Length(14), // Activity
335        ratatui::layout::Constraint::Length(14), // Available
336        ratatui::layout::Constraint::Length(30), // Target (wider for ByDate progress)
337    ];
338
339    // Header row
340    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    // Find the row index that corresponds to the selected category index
361    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}