use clap::Parser;
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::{prelude::*, widgets::*};
use std::collections::HashMap;
use std::io;
mod expense;
use expense::*;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long)]
add: bool,
#[arg(short, long)]
edit: bool,
#[arg(short, long)]
search: Option<String>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
if args.add {
Expense::add_expense()?;
}
if args.edit {
Expense::edit_expenses("expenses.csv")?;
}
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut expenses = match Expense::read_csv("expenses.csv") {
Ok(expenses) => expenses,
Err(err) => {
eprintln!("Error reading CSV: {}", err);
match Expense::create_expenses_csv() {
Ok(_) => Vec::new(),
Err(err) => {
eprintln!("Error creating CSV: {}", err);
return Err(err);
}
}
}
};
if let Some(query) = &args.search {
let matcher = SkimMatcherV2::default();
expenses = expenses
.iter()
.filter(|expense| {
matcher.fuzzy_match(&expense.description, query).is_some()
|| matcher
.fuzzy_match(&expense.expense_type.to_string(), query)
.is_some()
})
.cloned()
.collect();
}
expenses.sort_by(|a, b| b.date.cmp(&a.date));
let mut should_quit = false;
let mut table_state = TableState::default().with_selected(Some(0));
let table_size = expenses.len();
while !should_quit {
terminal.draw(|f| ui(f, &expenses, &mut table_state))?;
should_quit = handle_events(&mut table_state, table_size)?;
}
disable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(LeaveAlternateScreen)?;
Ok(())
}
fn handle_events(table_state: &mut TableState, table_size: usize) -> io::Result<bool> {
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(KeyEvent {
kind: KeyEventKind::Press,
code,
..
}) = event::read()?
{
match code {
KeyCode::Char('q') => return Ok(true),
KeyCode::Down | KeyCode::Char('s') => {
if let Some(selected) = table_state.selected() {
let next_index = if selected >= table_size - 1 {
0
} else {
selected + 1
};
table_state.select(Some(next_index));
}
}
KeyCode::Up | KeyCode::Char('w') => {
if let Some(selected) = table_state.selected() {
let next_index = if selected == 0 {
table_size - 1
} else {
selected - 1
};
table_state.select(Some(next_index));
}
}
_ => {}
}
}
}
Ok(false)
}
fn ui(frame: &mut Frame, expenses: &[Expense], table_state: &mut TableState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.margin(2)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref())
.split(frame.size());
let charts_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(chunks[1]);
let positive_chunk = charts_chunks[0];
let negative_chunk = charts_chunks[1];
let total_amount: f64 = expenses.iter().map(|expense| expense.amount).sum();
let total_spent: f64 = expenses
.iter()
.filter(|expense| expense.amount < 0.0)
.map(|expense| expense.amount)
.sum();
let total_earned: f64 = expenses
.iter()
.filter(|expense| expense.amount >= 0.0)
.map(|expense| expense.amount)
.sum();
let rows = expenses
.iter()
.map(|expense| {
Row::new(vec![
expense.date.clone(),
expense.description.clone(),
capitalize(expense.expense_type.to_string()),
expense.amount.to_string(),
])
})
.collect::<Vec<Row>>();
let widths = [
Constraint::Length(15),
Constraint::Length(65),
Constraint::Length(20),
Constraint::Length(10),
];
let expense_table = Table::new(rows, widths)
.block(Block::default().borders(Borders::ALL))
.header(
Row::new(vec!["Date", "Description", "Type", "Amount"]).style(Style::default().bold()),
)
.highlight_style(Style::new().add_modifier(Modifier::REVERSED))
.highlight_symbol(">>");
let table_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref())
.split(chunks[0]);
frame.render_stateful_widget(expense_table, table_chunks[0], table_state);
let rows = vec![
Row::new(vec![
"".to_string(),
"".to_string(),
"Net Total Spent".to_string(),
total_amount.to_string(),
])
.style(Style::default().bold())
.top_margin(1),
Row::new(vec![
"".to_string(),
"".to_string(),
"Total Spent".to_string(),
total_spent.to_string(),
])
.style(Style::default().bold()),
Row::new(vec![
"".to_string(),
"".to_string(),
"Total Earned".to_string(),
total_earned.to_string(),
])
.style(Style::default().bold()),
];
let data_table = Table::new(rows, widths);
frame.render_widget(data_table, table_chunks[1]);
let mut aggregated_expenses: HashMap<String, f64> = HashMap::new();
for expense in expenses {
let entry = aggregated_expenses
.entry(expense.expense_type.to_string())
.or_insert(0.0);
*entry += expense.amount;
}
let total_earned_data: Vec<(String, f64)> = aggregated_expenses
.clone()
.into_iter()
.filter(|(_, amount)| *amount >= 0.0)
.collect();
let total_spent_data: Vec<(String, f64)> = aggregated_expenses
.clone()
.into_iter()
.filter(|(_, amount)| *amount < 0.0)
.map(|(expense_type, amount)| (capitalize(expense_type), -amount))
.collect();
for (mut expense_data, chunk, title, color) in [
(
total_spent_data.clone(),
positive_chunk,
"Expenditure",
Style::default().cyan(),
),
(
total_earned_data,
negative_chunk,
"Income",
Style::default().red(),
),
] {
expense_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let max_expense_amount = expense_data
.iter()
.map(|(_, amount)| *amount)
.fold(f64::NEG_INFINITY, f64::max);
let type_data: Vec<(&str, u64)> = expense_data
.iter()
.map(|(date, amount)| (date.as_str(), *amount as u64))
.collect();
let available_width = chunk.width as usize;
let num_types = expense_data.len() + 5;
let min_bar_width = 1;
let bar_width = if num_types > 0 {
(available_width / num_types).max(min_bar_width) as u16
} else {
min_bar_width as u16
};
let type_barchart = BarChart::default()
.block(Block::default().title(title).borders(Borders::ALL))
.bar_width(bar_width)
.bar_style(color)
.value_style(Style::default().white().bold())
.label_style(Style::default().white())
.data(&type_data)
.max(max_expense_amount.ceil() as u64);
frame.render_widget(type_barchart, chunk); }
}