Detailed step-by-step plan covering schema migration, core modules (NLP, Sankey, PDF import), shared utilities, new views (Insights, Credit Cards), and modifications to existing views.
56 KiB
Feature Batch 2 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add 16 features to Outlay: custom budget cycles, bulk transaction operations, monthly/yearly recap, natural language entry, Sankey diagram, what-if sandbox, tags UI, splits UI, templates UI, auto-categorization rules UI, searchable category picker, PDF import, spending streaks/gamification, financial goal projections, credit card tracking, and spending anomaly alerts.
Architecture: Features are implemented bottom-up: schema migration and models first, then core library modules, then shared UI utilities, then new views, then modifications to existing views. Each task is one feature (or a foundational piece). Tasks are ordered so dependencies are satisfied.
Tech Stack: Rust, GTK4 (gtk4-rs 0.11), libadwaita 0.9, rusqlite (bundled SQLite), Cairo for charts, pdf-extract for PDF parsing, Tesseract OCR fallback.
Task 1: Schema Migration v9 + New Models
Files:
- Modify:
outlay-core/src/db.rs(add migration, bump CURRENT_SCHEMA_VERSION to 9) - Modify:
outlay-core/src/models.rs(add new structs)
Step 1: Add new model structs to models.rs
Add after the NewSubscription struct (line 269), before #[cfg(test)]:
#[derive(Debug, Clone)]
pub struct CreditCard {
pub id: i64,
pub name: String,
pub credit_limit: Option<f64>,
pub statement_close_day: i32,
pub due_day: i32,
pub min_payment_pct: f64,
pub current_balance: f64,
pub currency: String,
pub color: Option<String>,
pub active: bool,
}
#[derive(Debug, Clone)]
pub struct NewCreditCard {
pub name: String,
pub credit_limit: Option<f64>,
pub statement_close_day: i32,
pub due_day: i32,
pub min_payment_pct: f64,
pub currency: String,
pub color: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Achievement {
pub id: i64,
pub name: String,
pub description: String,
pub earned_at: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedTransaction {
pub amount: f64,
pub category_name: Option<String>,
pub category_id: Option<i64>,
pub note: Option<String>,
pub payee: Option<String>,
pub transaction_type: TransactionType,
}
#[derive(Debug, Clone)]
pub struct SankeyNode {
pub label: String,
pub value: f64,
pub color: (f64, f64, f64),
pub y: f64,
pub height: f64,
}
#[derive(Debug, Clone)]
pub struct SankeyFlow {
pub from_idx: usize,
pub to_idx: usize,
pub value: f64,
pub from_y: f64,
pub to_y: f64,
pub width: f64,
}
#[derive(Debug, Clone)]
pub struct SankeyLayout {
pub left_nodes: Vec<SankeyNode>,
pub right_nodes: Vec<SankeyNode>,
pub center_y: f64,
pub center_height: f64,
pub flows_in: Vec<SankeyFlow>,
pub flows_out: Vec<SankeyFlow>,
pub net: f64,
}
#[derive(Debug, Clone)]
pub struct RecapCategory {
pub category_name: String,
pub category_icon: Option<String>,
pub category_color: Option<String>,
pub amount: f64,
pub percentage: f64,
pub change_pct: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct MonthlyRecap {
pub total_income: f64,
pub total_expenses: f64,
pub net: f64,
pub transaction_count: i64,
pub categories: Vec<RecapCategory>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetCycleMode {
Calendar,
Payday,
Rolling,
}
impl BudgetCycleMode {
pub fn as_str(&self) -> &'static str {
match self {
BudgetCycleMode::Calendar => "calendar",
BudgetCycleMode::Payday => "payday",
BudgetCycleMode::Rolling => "rolling",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"payday" => BudgetCycleMode::Payday,
"rolling" => BudgetCycleMode::Rolling,
_ => BudgetCycleMode::Calendar,
}
}
}
#[derive(Debug, Clone)]
pub struct PdfParsedRow {
pub date: Option<NaiveDate>,
pub description: String,
pub amount: f64,
pub is_credit: bool,
}
Step 2: Add migration v9 to db.rs
In the open() method, after the v8 migration block, add:
if version < 9 {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS credit_cards (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
credit_limit REAL,
statement_close_day INTEGER NOT NULL,
due_day INTEGER NOT NULL,
min_payment_pct REAL DEFAULT 2.0,
current_balance REAL DEFAULT 0.0,
currency TEXT NOT NULL DEFAULT 'USD',
color TEXT,
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS streaks (
id INTEGER PRIMARY KEY,
streak_type TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT,
current_count INTEGER DEFAULT 0,
best_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
earned_at TEXT,
icon TEXT
);
INSERT OR IGNORE INTO achievements (name, description, icon) VALUES
('First Transaction', 'Log your first transaction', 'star-symbolic'),
('7-Day No-Spend', 'Go 7 days without spending', 'fire-symbolic'),
('30-Day No-Spend', 'Go 30 days without spending', 'fire-symbolic'),
('Month Under Budget', 'Stay under budget for a full month', 'shield-symbolic'),
('3 Months Under Budget', 'Stay under budget for 3 consecutive months', 'shield-symbolic'),
('First Goal Completed', 'Complete your first savings goal', 'trophy-symbolic'),
('100 Transactions', 'Log 100 transactions', 'medal-symbolic'),
('Budget Streak 6mo', 'Stay under budget for 6 consecutive months', 'crown-symbolic');
UPDATE schema_version SET version = 9;
")?;
}
Update CURRENT_SCHEMA_VERSION to 9.
Step 3: Add credit card CRUD methods to db.rs
// Credit Cards
pub fn insert_credit_card(&self, card: &NewCreditCard) -> SqlResult<i64> { ... }
pub fn list_credit_cards(&self) -> SqlResult<Vec<CreditCard>> { ... }
pub fn get_credit_card(&self, id: i64) -> SqlResult<CreditCard> { ... }
pub fn update_credit_card(&self, card: &CreditCard) -> SqlResult<()> { ... }
pub fn delete_credit_card(&self, id: i64) -> SqlResult<()> { ... }
pub fn record_card_payment(&self, card_id: i64, amount: f64) -> SqlResult<()> {
// Reduces current_balance by amount
}
pub fn add_card_charge(&self, card_id: i64, amount: f64) -> SqlResult<()> {
// Increases current_balance by amount
}
Step 4: Add achievement and streak DB methods
pub fn list_achievements(&self) -> SqlResult<Vec<Achievement>> { ... }
pub fn award_achievement(&self, name: &str) -> SqlResult<bool> {
// Sets earned_at = now if not already earned. Returns true if newly awarded.
}
pub fn get_no_spend_streak(&self, today: NaiveDate) -> SqlResult<i32> {
// Count consecutive days backwards from today with zero expense transactions
}
pub fn get_total_transaction_count(&self) -> SqlResult<i64> {
// SELECT COUNT(*) FROM transactions
}
pub fn check_and_award_achievements(&self, today: NaiveDate) -> SqlResult<Vec<String>> {
// Check conditions, award any newly earned, return list of newly earned names
}
Step 5: Add budget period computation
pub fn get_budget_cycle_mode(&self) -> BudgetCycleMode {
self.get_setting("budget_cycle_mode")
.ok().flatten()
.map(|s| BudgetCycleMode::from_str(&s))
.unwrap_or(BudgetCycleMode::Calendar)
}
pub fn get_budget_period(&self, ref_date: NaiveDate) -> (NaiveDate, NaiveDate) {
// Returns (start, end) of the budget period containing ref_date
// Calendar: 1st to last day of month
// Payday: start_day to start_day-1 of next period
// Rolling: computed from start_date + N*cycle_days
}
pub fn get_next_budget_period(&self, current_start: NaiveDate) -> (NaiveDate, NaiveDate) { ... }
pub fn get_prev_budget_period(&self, current_start: NaiveDate) -> (NaiveDate, NaiveDate) { ... }
Step 6: Add monthly recap query
pub fn get_monthly_recap(&self, year: i32, month: u32) -> SqlResult<MonthlyRecap> {
// Gets totals by category for the month, computes percentages,
// compares to previous month for change_pct per category
}
pub fn get_yearly_month_summaries(&self, year: i32) -> SqlResult<Vec<(String, f64, f64)>> {
// Returns (month_label, income, expenses) for each month of the year
}
Step 7: Build and verify migration
Run: cargo build 2>&1
Expected: Compiles with no errors (warnings OK)
Step 8: Commit
feat: Add schema v9 migration with credit cards, streaks, achievements tables
Adds new model structs, credit card CRUD, achievement tracking,
budget period computation, and monthly recap queries.
Task 2: Natural Language Parser (nlp.rs)
Files:
- Create:
outlay-core/src/nlp.rs - Modify:
outlay-core/src/lib.rs(addpub mod nlp;)
Step 1: Create nlp.rs with parser
use crate::models::{Category, ParsedTransaction, TransactionType};
/// Parse a free-form text string into a transaction.
///
/// Supported patterns:
/// "Coffee 4.50" -> amount=4.50, category=fuzzy("Coffee")
/// "4.50 groceries milk" -> amount=4.50, category=fuzzy("groceries"), note="milk"
/// "Lunch 12.50 at Subway" -> amount=12.50, category=fuzzy("Lunch"), payee="Subway"
/// "$25 gas" -> amount=25, category=fuzzy("gas")
/// "50 rent from employer" -> amount=50, payee="employer"
pub fn parse_transaction(input: &str, categories: &[Category]) -> Option<ParsedTransaction> {
let input = input.trim();
if input.is_empty() {
return None;
}
// Tokenize
let tokens: Vec<&str> = input.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
// Find the amount token (first token that parses as a number, with optional $ prefix)
let mut amount: Option<f64> = None;
let mut amount_idx: Option<usize> = None;
for (i, tok) in tokens.iter().enumerate() {
let cleaned = tok.trim_start_matches('$').replace(',', "");
if let Ok(val) = cleaned.parse::<f64>() {
if val > 0.0 {
amount = Some(val);
amount_idx = Some(i);
break;
}
}
}
let amount = amount?;
let amount_idx = amount_idx.unwrap();
// Collect non-amount tokens
let word_tokens: Vec<&str> = tokens.iter().enumerate()
.filter(|(i, _)| *i != amount_idx)
.map(|(_, t)| *t)
.collect();
// Find payee marker ("at", "from", "to")
let mut payee: Option<String> = None;
let mut pre_marker_words: Vec<&str> = Vec::new();
let mut found_marker = false;
let mut post_marker_words: Vec<&str> = Vec::new();
for word in &word_tokens {
let lower = word.to_lowercase();
if !found_marker && (lower == "at" || lower == "from" || lower == "to") {
found_marker = true;
continue;
}
if found_marker {
post_marker_words.push(word);
} else {
pre_marker_words.push(word);
}
}
if !post_marker_words.is_empty() {
payee = Some(post_marker_words.join(" "));
}
// Try to match first pre-marker word(s) to a category
let mut matched_category: Option<(String, i64)> = None;
let mut note_words: Vec<&str> = Vec::new();
if !pre_marker_words.is_empty() {
// Try matching progressively fewer words from the start
for len in (1..=pre_marker_words.len()).rev() {
let candidate = pre_marker_words[..len].join(" ");
if let Some(cat) = fuzzy_match_category(&candidate, categories) {
matched_category = Some((cat.name.clone(), cat.id));
note_words = pre_marker_words[len..].to_vec();
break;
}
}
// If no match, treat first word as potential category hint, rest as note
if matched_category.is_none() {
note_words = pre_marker_words;
}
}
let note = if note_words.is_empty() {
None
} else {
Some(note_words.join(" "))
};
Some(ParsedTransaction {
amount,
category_name: matched_category.as_ref().map(|(n, _)| n.clone()),
category_id: matched_category.map(|(_, id)| id),
note,
payee,
transaction_type: TransactionType::Expense,
})
}
fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&'a Category> {
let query_lower = query.to_lowercase();
// Exact match
if let Some(cat) = categories.iter().find(|c| c.name.to_lowercase() == query_lower) {
return Some(cat);
}
// Prefix match
if let Some(cat) = categories.iter().find(|c| c.name.to_lowercase().starts_with(&query_lower)) {
return Some(cat);
}
// Contains match
if let Some(cat) = categories.iter().find(|c| c.name.to_lowercase().contains(&query_lower)) {
return Some(cat);
}
// Reverse contains (query contains category name)
if let Some(cat) = categories.iter().find(|c| query_lower.contains(&c.name.to_lowercase())) {
return Some(cat);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn test_categories() -> Vec<Category> {
vec![
Category { id: 1, name: "Food & Drink".into(), icon: None, color: None, transaction_type: TransactionType::Expense, is_default: false, sort_order: 0, parent_id: None },
Category { id: 2, name: "Transport".into(), icon: None, color: None, transaction_type: TransactionType::Expense, is_default: false, sort_order: 0, parent_id: None },
Category { id: 3, name: "Groceries".into(), icon: None, color: None, transaction_type: TransactionType::Expense, is_default: false, sort_order: 0, parent_id: None },
Category { id: 4, name: "Gas".into(), icon: None, color: None, transaction_type: TransactionType::Expense, is_default: false, sort_order: 0, parent_id: None },
Category { id: 5, name: "Coffee".into(), icon: None, color: None, transaction_type: TransactionType::Expense, is_default: false, sort_order: 0, parent_id: None },
]
}
#[test]
fn test_simple_category_amount() {
let cats = test_categories();
let result = parse_transaction("Coffee 4.50", &cats).unwrap();
assert!((result.amount - 4.50).abs() < 0.001);
assert_eq!(result.category_id, Some(5));
assert!(result.payee.is_none());
}
#[test]
fn test_amount_first() {
let cats = test_categories();
let result = parse_transaction("4.50 groceries milk", &cats).unwrap();
assert!((result.amount - 4.50).abs() < 0.001);
assert_eq!(result.category_id, Some(3));
assert_eq!(result.note.as_deref(), Some("milk"));
}
#[test]
fn test_with_payee() {
let cats = test_categories();
let result = parse_transaction("Coffee 12.50 at Starbucks", &cats).unwrap();
assert!((result.amount - 12.50).abs() < 0.001);
assert_eq!(result.category_id, Some(5));
assert_eq!(result.payee.as_deref(), Some("Starbucks"));
}
#[test]
fn test_dollar_sign() {
let cats = test_categories();
let result = parse_transaction("$25 gas", &cats).unwrap();
assert!((result.amount - 25.0).abs() < 0.001);
assert_eq!(result.category_id, Some(4));
}
#[test]
fn test_no_category_match() {
let cats = test_categories();
let result = parse_transaction("15.00 mystery", &cats).unwrap();
assert!((result.amount - 15.0).abs() < 0.001);
assert!(result.category_id.is_none());
}
#[test]
fn test_empty_input() {
let cats = test_categories();
assert!(parse_transaction("", &cats).is_none());
}
#[test]
fn test_no_amount() {
let cats = test_categories();
assert!(parse_transaction("just some words", &cats).is_none());
}
}
Step 2: Register module in lib.rs
Add pub mod nlp; after the pub mod notifications; line.
Step 3: Run tests
Run: cargo test -p outlay-core nlp
Expected: All 7 tests pass.
Step 4: Commit
feat: Add natural language transaction parser
Supports patterns like "Coffee 4.50", "$25 gas",
"Lunch 12.50 at Subway" with fuzzy category matching.
Task 3: Sankey Layout Engine (sankey.rs)
Files:
- Create:
outlay-core/src/sankey.rs - Modify:
outlay-core/src/lib.rs(addpub mod sankey;)
Step 1: Create sankey.rs
use crate::models::{SankeyFlow, SankeyLayout, SankeyNode};
/// Compute a Sankey diagram layout.
///
/// `income_sources`: (label, amount, (r, g, b)) for each income category
/// `expense_categories`: (label, amount, (r, g, b)) for each expense category
/// `total_height`: pixel height of the diagram area
pub fn compute_sankey_layout(
income_sources: &[(String, f64, (f64, f64, f64))],
expense_categories: &[(String, f64, (f64, f64, f64))],
total_height: f64,
) -> SankeyLayout {
let total_income: f64 = income_sources.iter().map(|(_, v, _)| v).sum();
let total_expense: f64 = expense_categories.iter().map(|(_, v, _)| v).sum();
let net = total_income - total_expense;
let max_side = total_income.max(total_expense).max(1.0);
let padding = 4.0;
// Layout left (income) nodes
let mut left_nodes = Vec::new();
let mut y = 0.0;
let income_count = income_sources.len().max(1);
let total_padding_left = padding * (income_count.saturating_sub(1)) as f64;
let available_left = total_height - total_padding_left;
for (label, value, color) in income_sources {
let h = (value / max_side) * available_left;
left_nodes.push(SankeyNode {
label: label.clone(),
value: *value,
color: *color,
y,
height: h,
});
y += h + padding;
}
// Layout right (expense) nodes
let mut right_nodes = Vec::new();
y = 0.0;
let expense_count = expense_categories.len().max(1);
let total_padding_right = padding * (expense_count.saturating_sub(1)) as f64;
let available_right = total_height - total_padding_right;
for (label, value, color) in expense_categories {
let h = (value / max_side) * available_right;
right_nodes.push(SankeyNode {
label: label.clone(),
value: *value,
color: *color,
y,
height: h,
});
y += h + padding;
}
// Center node (net/available)
let center_height = (total_income / max_side) * available_left;
let center_y = 0.0;
// Flows from income -> center
let mut flows_in = Vec::new();
let mut from_y_cursor = 0.0;
let mut to_y_cursor = 0.0;
for (i, node) in left_nodes.iter().enumerate() {
let w = (node.value / max_side) * available_left;
flows_in.push(SankeyFlow {
from_idx: i,
to_idx: 0,
value: node.value,
from_y: from_y_cursor,
to_y: to_y_cursor,
width: w,
});
from_y_cursor += w + padding;
to_y_cursor += w;
}
// Flows from center -> expenses
let mut flows_out = Vec::new();
let mut from_y_cursor = 0.0;
let mut to_y_cursor = 0.0;
for (i, node) in right_nodes.iter().enumerate() {
let w = (node.value / max_side) * available_right;
flows_out.push(SankeyFlow {
from_idx: 0,
to_idx: i,
value: node.value,
from_y: from_y_cursor,
to_y: to_y_cursor,
width: w,
});
from_y_cursor += w;
to_y_cursor += w + padding;
}
SankeyLayout {
left_nodes,
right_nodes,
center_y,
center_height,
flows_in,
flows_out,
net,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_layout() {
let income = vec![("Salary".into(), 5000.0, (0.0, 0.8, 0.0))];
let expenses = vec![
("Rent".into(), 2000.0, (0.8, 0.0, 0.0)),
("Food".into(), 1000.0, (0.8, 0.4, 0.0)),
];
let layout = compute_sankey_layout(&income, &expenses, 400.0);
assert_eq!(layout.left_nodes.len(), 1);
assert_eq!(layout.right_nodes.len(), 2);
assert!((layout.net - 2000.0).abs() < 0.01);
assert_eq!(layout.flows_in.len(), 1);
assert_eq!(layout.flows_out.len(), 2);
}
#[test]
fn test_empty_inputs() {
let layout = compute_sankey_layout(&[], &[], 400.0);
assert!(layout.left_nodes.is_empty());
assert!(layout.right_nodes.is_empty());
assert!((layout.net - 0.0).abs() < 0.01);
}
}
Step 2: Register in lib.rs
Add pub mod sankey; after pub mod nlp;.
Step 3: Run tests
Run: cargo test -p outlay-core sankey
Expected: All tests pass.
Step 4: Commit
feat: Add Sankey diagram layout engine
Computes node positions and flow paths for income-to-expense
money flow visualization.
Task 4: PDF Import (import_pdf.rs)
Files:
- Create:
outlay-core/src/import_pdf.rs - Modify:
outlay-core/src/lib.rs(addpub mod import_pdf;) - Modify:
outlay-core/Cargo.toml(addpdf-extractdependency)
Step 1: Add dependency to outlay-core/Cargo.toml
Add after the thiserror line:
pdf-extract = "0.7"
Step 2: Create import_pdf.rs
use crate::models::PdfParsedRow;
use chrono::NaiveDate;
/// Extract transactions from a PDF bank statement.
/// Tries text extraction first, falls back to OCR if no text found.
pub fn extract_transactions_from_pdf(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
// Try text-based extraction first
match extract_text_based(bytes) {
Ok(rows) if !rows.is_empty() => return Ok(rows),
_ => {}
}
// Fall back to OCR
if crate::ocr::is_available() {
return extract_ocr_based(bytes);
}
Err("No text found in PDF and OCR is not available".to_string())
}
fn extract_text_based(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
let text = pdf_extract::extract_text_from_mem(bytes)
.map_err(|e| format!("PDF text extraction failed: {}", e))?;
if text.trim().is_empty() {
return Ok(Vec::new());
}
let mut rows = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(row) = parse_statement_line(line) {
rows.push(row);
}
}
Ok(rows)
}
fn extract_ocr_based(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
// Use existing OCR to extract amounts and descriptions
let amounts = crate::ocr::extract_amounts_from_image(bytes)
.map_err(|e| format!("OCR extraction failed: {}", e))?;
let rows: Vec<PdfParsedRow> = amounts.into_iter().map(|(amount, source_line)| {
PdfParsedRow {
date: None,
description: source_line,
amount: amount.abs(),
is_credit: amount > 0.0,
}
}).collect();
Ok(rows)
}
/// Try to parse a single line from a bank statement.
/// Common formats:
/// "01/15/2026 GROCERY STORE -45.67"
/// "2026-01-15 SALARY +2500.00"
/// "15 Jan Coffee Shop 12.50"
fn parse_statement_line(line: &str) -> Option<PdfParsedRow> {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 2 {
return None;
}
// Try to find a date at the start
let (date, desc_start) = try_parse_date_prefix(&tokens);
// Try to find an amount at the end
let (amount, is_credit, desc_end) = try_parse_amount_suffix(&tokens)?;
// Everything between date and amount is description
if desc_start >= desc_end {
return None;
}
let description = tokens[desc_start..desc_end].join(" ");
if description.is_empty() {
return None;
}
Some(PdfParsedRow {
date,
description,
amount,
is_credit,
})
}
fn try_parse_date_prefix(tokens: &[&str]) -> (Option<NaiveDate>, usize) {
if tokens.is_empty() {
return (None, 0);
}
// Try single token: "2026-01-15", "01/15/2026", "15/01/2026"
if let Some(d) = parse_date_flexible(tokens[0]) {
return (Some(d), 1);
}
// Try two tokens: "15 Jan", "Jan 15"
if tokens.len() >= 2 {
let combined = format!("{} {}", tokens[0], tokens[1]);
if let Some(d) = parse_date_flexible(&combined) {
return (Some(d), 2);
}
// Try three tokens: "15 Jan 2026"
if tokens.len() >= 3 {
let combined = format!("{} {} {}", tokens[0], tokens[1], tokens[2]);
if let Some(d) = parse_date_flexible(&combined) {
return (Some(d), 3);
}
}
}
(None, 0)
}
fn try_parse_amount_suffix(tokens: &[&str]) -> Option<(f64, bool, usize)> {
// Try last token as amount
for i in (0..tokens.len()).rev() {
let tok = tokens[i];
let cleaned = tok.replace(',', "").replace('$', "");
if let Ok(val) = cleaned.parse::<f64>() {
let is_credit = val > 0.0 || tok.starts_with('+');
return Some((val.abs(), is_credit, i));
}
}
None
}
fn parse_date_flexible(s: &str) -> Option<NaiveDate> {
let formats = [
"%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%m-%d-%Y",
"%d %b %Y", "%b %d %Y", "%d %b", "%b %d",
];
for fmt in &formats {
if let Ok(d) = NaiveDate::parse_from_str(s, fmt) {
return Some(d);
}
}
None
}
Step 3: Register in lib.rs
Add pub mod import_pdf; after pub mod import_qif;.
Step 4: Build
Run: cargo build -p outlay-core 2>&1
Expected: Compiles (pdf-extract may take a moment to download).
Step 5: Commit
feat: Add PDF bank statement import with OCR fallback
Extracts transactions from text-based PDFs by parsing date,
description, and amount patterns. Falls back to Tesseract OCR
for scanned PDFs.
Task 5: Searchable Category Picker Utility (#15)
Files:
- Create:
outlay-gtk/src/category_combo.rs - Modify:
outlay-gtk/src/main.rs(addmod category_combo;)
This is a shared utility used by many views, so it comes before UI features.
Step 1: Create category_combo.rs
use adw::prelude::*;
use gtk::glib;
use crate::icon_theme;
use outlay_core::models::{Category, TransactionType};
use outlay_core::db::Database;
use std::rc::Rc;
use std::cell::RefCell;
/// Build a searchable category combo row.
///
/// Returns (combo_row, category_ids) where category_ids maps combo indices to DB IDs.
pub fn make_searchable_category_combo(
db: &Rc<Database>,
txn_type: Option<TransactionType>,
title: &str,
) -> (adw::ComboRow, Rc<RefCell<Vec<i64>>>) {
let categories = db.list_categories(txn_type).unwrap_or_default();
let ids: Vec<i64> = categories.iter().map(|c| c.id).collect();
let entries: Vec<String> = categories.iter().map(|c| {
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
match icon {
Some(i) => format!("{}\t{}", i, c.name),
None => c.name.clone(),
}
}).collect();
let refs: Vec<&str> = entries.iter().map(|s| s.as_str()).collect();
let model = gtk::StringList::new(&refs);
// Wrap in a FilterListModel with a custom filter
let filter = gtk::CustomFilter::new(move |_| true);
let filter_model = gtk::FilterListModel::new(Some(model.clone()), Some(filter.clone()));
let combo = adw::ComboRow::builder()
.title(title)
.model(&filter_model)
.build();
// Category icon+name factory
let factory = make_category_factory();
combo.set_factory(Some(&factory));
combo.set_list_factory(Some(&make_category_factory()));
// Enable search expression
combo.set_enable_search(true);
let expression = gtk::PropertyExpression::new(
gtk::StringObject::static_type(),
gtk::Expression::NONE,
"string",
);
combo.set_expression(Some(&expression));
let category_ids = Rc::new(RefCell::new(ids));
(combo, category_ids)
}
/// Update the combo's model for a different transaction type.
pub fn update_category_combo(
combo: &adw::ComboRow,
ids: &Rc<RefCell<Vec<i64>>>,
db: &Rc<Database>,
txn_type: Option<TransactionType>,
) {
let categories = db.list_categories(txn_type).unwrap_or_default();
let new_ids: Vec<i64> = categories.iter().map(|c| c.id).collect();
let entries: Vec<String> = categories.iter().map(|c| {
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
match icon {
Some(i) => format!("{}\t{}", i, c.name),
None => c.name.clone(),
}
}).collect();
let refs: Vec<&str> = entries.iter().map(|s| s.as_str()).collect();
let model = gtk::StringList::new(&refs);
combo.set_model(Some(&model));
combo.set_selected(0);
*ids.borrow_mut() = new_ids;
}
fn make_category_factory() -> gtk::SignalListItemFactory {
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let icon = gtk::Image::new();
icon.set_pixel_size(20);
let label = gtk::Label::new(None);
hbox.append(&icon);
hbox.append(&label);
item.set_child(Some(&hbox));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
let text = string_obj.string();
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
let label = icon.next_sibling().and_downcast::<gtk::Label>().unwrap();
if let Some((icon_name, name)) = text.split_once('\t') {
icon.set_icon_name(Some(icon_name));
icon.set_visible(true);
label.set_label(name);
} else {
icon.set_visible(false);
label.set_label(&text);
}
});
factory
}
Step 2: Register module in main.rs
Add mod category_combo; after mod calendar_view;.
Step 3: Build
Run: cargo build 2>&1
Expected: Compiles.
Step 4: Commit
feat: Add searchable category combo utility
Shared widget with icon display and search filtering,
used across log view, edit dialog, quick add, and other forms.
Task 6: Tags UI (#11)
Files:
- Modify:
outlay-gtk/src/history_view.rs(add tag filter chips) - Modify:
outlay-gtk/src/edit_dialog.rs(add tag editing)
Step 1: Add tag filter chips to History view
In history_view.rs, after the category chips are built in the FlowBox:
- Query
db.list_tags_for_month(year, month)to get tags present in the current month - For each tag, add a chip styled with
outlinedCSS class (vsfilledfor categories) - On chip toggle, filter transaction rows to those where
db.get_transaction_tags(txn_id)includes the selected tag - Store active tag filter in a new
Rc<RefCell<HashSet<i64>>>field (active_tags)
Step 2: Add tag editing to edit_dialog.rs
- In the edit dialog, after the note field, add a tags entry row
- Load existing tags with
db.get_transaction_tags(txn_id) - Display as comma-separated text in an
adw::EntryRowtitled "Tags" - On save, parse comma-separated text, call
db.get_or_create_tag()for each, thendb.set_transaction_tags()
Step 3: Build and test manually
Run: cargo build 2>&1
Step 4: Commit
feat: Add tags UI to history view and edit dialog
Tag filter chips in history, tag editing when modifying
transactions. Uses existing tags DB tables.
Task 7: Transaction Splits UI (#12)
Files:
- Modify:
outlay-gtk/src/log_view.rs(add split toggle and split rows) - Modify:
outlay-gtk/src/edit_dialog.rs(show splits for existing transactions)
Step 1: Add split toggle to log_view.rs
After the category row in the log form:
- Add a "Split" toggle button
- When toggled on: hide the single category row, show a vertical box of split rows
- Each split row: a horizontal box with category combo (use
category_combo::make_searchable_category_combo) + amount entry + note entry + remove button - "Add Split" button at the bottom of the split list
- Validation label: "Remaining: X.XX" computed as (total amount - sum of split amounts)
Step 2: Wire splits into save logic
In the save button handler:
- If split mode is active, validate that split total equals transaction amount
- After
db.insert_transaction(), calldb.insert_splits(txn_id, splits) - Each split:
(category_id, amount, note)
Step 3: Show splits in edit dialog
In edit_dialog.rs:
- After loading the transaction, check
db.has_splits(txn_id) - If true, load splits with
db.get_splits(txn_id)and show split rows pre-filled - On save:
db.delete_splits(txn_id)thendb.insert_splits(txn_id, new_splits)
Step 4: Build
Run: cargo build 2>&1
Step 5: Commit
feat: Add transaction splits UI in log view and edit dialog
Split a transaction across multiple categories with validation
that split amounts sum to the total.
Task 8: Transaction Templates UI (#13)
Files:
- Modify:
outlay-gtk/src/log_view.rs(add templates button and save-as-template)
Step 1: Add templates popover
In the log view header area:
- Add a "Templates" icon button (document-symbolic)
- On click, open a
gtk::Popovercontaining a scrollable list of templates fromdb.list_templates() - Each template is an
adw::ActionRowwith: name as title, amount + category as subtitle, type badge prefix - On template row activation: fill the log form fields (type, amount, category, currency, payee, note)
- Close the popover after selection
Step 2: Add "Save as Template" button
After the save button in the log form:
- Add "Save as Template" button (visible only when amount and category are filled)
- On click: open a small
adw::AlertDialogasking for a template name - On confirm: call
db.insert_template(name, amount, type, category_id, currency, payee, note, tags) - Show a toast "Template saved"
Step 3: Build
Run: cargo build 2>&1
Step 4: Commit
feat: Add transaction templates UI
Templates popover in log view for quick form fill, plus
save-as-template to create presets from current form state.
Task 9: Auto-Categorization Rules UI (#14)
Files:
- Modify:
outlay-gtk/src/settings_view.rs(add rules section)
Step 1: Add rules section to settings
Between the Categories and Import/Export sections:
- Add
adw::PreferencesGrouptitled "Categorization Rules" - For each rule from
db.list_rules(), add anadw::ActionRow:- Title: pattern text
- Subtitle: "When [field] contains '[pattern]' -> [category_name]"
- Suffix: delete button
- "Add Rule" button at the bottom
Step 2: Add rule creation dialog
On "Add Rule" click, open a dialog with:
- Field dropdown: "Note" / "Payee" (adw::ComboRow with StringList)
- Pattern entry:
adw::EntryRowtitled "Pattern (text to match)" - Category: searchable category combo from
category_combo::make_searchable_category_combo - Priority:
adw::SpinRow(0-100, default 0) - Save button: calls
db.insert_rule(field, pattern, category_id, priority)
Step 3: Wire delete
Delete button calls db.delete_rule(id) and removes the row.
Step 4: Build
Run: cargo build 2>&1
Step 5: Commit
feat: Add auto-categorization rules UI in settings
Create and manage rules that auto-assign categories during
import based on note/payee pattern matching.
Task 10: Credit Cards View (#19)
Files:
- Create:
outlay-gtk/src/credit_cards_view.rs - Modify:
outlay-gtk/src/main.rs(addmod credit_cards_view;) - Modify:
outlay-gtk/src/window.rs(add to sidebar and content stack)
Step 1: Create credit_cards_view.rs
Structure:
pub struct CreditCardsView {
pub container: gtk::Box,
}
impl CreditCardsView {
pub fn new(db: Rc<Database>) -> Self { ... }
}
Layout:
- Toast overlay wrapping a scrolled clamp
- Summary card at top: total balance, total limit, utilization LevelBar, next due date label
- Card list:
adw::PreferencesGroupwith each card asadw::ExpanderRow- Collapsed: card name (title), balance + due countdown (subtitle), colored dot prefix, utilization mini LevelBar suffix
- Expanded content: statement close day, min payment amount, "Record Payment" button, Edit button, Delete button
- "Add Card" button at bottom
Step 2: Wire "Record Payment"
Opens a dialog asking for payment amount. On confirm:
db.record_card_payment(card_id, amount)to reduce balance- Creates an expense transaction with note "Credit card payment - [card name]"
- Refreshes the view
Step 3: Wire Add/Edit/Delete
- Add: dialog with name, limit, statement close day, due day, min payment %, currency, color
- Edit: same dialog pre-filled
- Delete: confirmation dialog, then
db.delete_credit_card(id)
Step 4: Register in window.rs
Add to SIDEBAR_ITEMS after forecast:
SidebarItem { id: "creditcards", label: "Credit Cards", icon: "outlay-creditcards" },
In MainWindow::new(), create the view and add to content_stack:
let credit_cards_view = CreditCardsView::new(db.clone());
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
Add mod credit_cards_view; to main.rs.
Step 5: Build
Run: cargo build 2>&1
Step 6: Commit
feat: Add credit cards view with billing cycle tracking
Track credit card balances, statement dates, due dates,
utilization, and record payments.
Task 11: Insights View - Streaks, Achievements, Recap, Anomalies (#7, #17, #24)
Files:
- Create:
outlay-gtk/src/insights_view.rs - Modify:
outlay-gtk/src/main.rs(addmod insights_view;) - Modify:
outlay-gtk/src/window.rs(add to sidebar and content stack)
Step 1: Create insights_view.rs
Structure:
pub struct InsightsView {
pub container: gtk::Box,
pub on_navigate_category: Rc<RefCell<Option<Rc<dyn Fn(i64)>>>>,
pub on_navigate_history: Rc<RefCell<Option<Rc<dyn Fn()>>>>,
}
impl InsightsView {
pub fn new(db: Rc<Database>) -> Self { ... }
pub fn refresh(&self) { ... }
pub fn set_on_navigate_category(&self, cb: Rc<dyn Fn(i64)>) { ... }
pub fn set_on_navigate_history(&self, cb: Rc<dyn Fn()>) { ... }
}
Step 2: Streaks section
adw::PreferencesGrouptitled "Streaks"- Three
adw::ActionRowitems:- "No-Spend Streak": fire-symbolic prefix, title="X days", subtitle="Best: Y days"
- "Under Budget": shield-symbolic prefix, title="X months"
- "Savings": piggy-bank-symbolic prefix, title="X months"
- Data from
db.get_no_spend_streak(today)and similar queries
Step 3: Achievements section
adw::PreferencesGrouptitled "Achievements"gtk::FlowBoxwith achievement cards- Each card: icon (colored if earned, dimmed if not) + name label + earned date
- Data from
db.list_achievements()
Step 4: Monthly/Yearly Recap section
- Toggle buttons: "This Month" / "This Year"
- Monthly recap content (default):
- Summary row: income | expenses | net | count
adw::PreferencesGroupwith per-categoryadw::ActionRowitems- Each row: category icon prefix, name title, amount subtitle, change badge suffix (green up-arrow or red down-arrow with percentage)
- Yearly recap content:
- Per-month rows showing income, expenses, net
- Year-over-year comparison at top
- "Share as PDF" button
Step 5: Anomaly Alerts section
adw::PreferencesGrouptitled "Spending Insights"- Each anomaly from
db.detect_anomalies(year, month)asadw::ActionRow:- warning-symbolic (amber) for overspending, info-symbolic (blue) for other
- Title: anomaly message
- Subtitle: deviation amount
- Clickable: invokes
on_navigate_categorycallback
Step 6: Register in window.rs
Add to SIDEBAR_ITEMS after forecast (before creditcards):
SidebarItem { id: "insights", label: "Insights", icon: "outlay-insights" },
Create view and add to stack. Wire on_navigate_category callback in MainWindow::new().
Step 7: Build
Run: cargo build 2>&1
Step 8: Commit
feat: Add insights view with streaks, achievements, recap, and anomaly alerts
Dedicated view showing spending streaks, earned achievements,
monthly/yearly recap with category breakdowns, and anomaly alerts.
Task 12: Anomaly Banner in History View (#24)
Files:
- Modify:
outlay-gtk/src/history_view.rs(add banner) - Modify:
outlay-gtk/src/window.rs(wire navigation callback)
Step 1: Add anomaly banner
At the top of the history list container (inside the toast_overlay):
- Add an
adw::Banner(initially hidden) - On each
refresh()call, rundb.detect_anomalies(year, month) - If anomalies exist, show banner: "N spending insights for this month"
- Banner has a button label "View" that invokes a navigation callback
Step 2: Wire navigation
Add a field on_navigate_insights: Rc<RefCell<Option<Rc<dyn Fn()>>>> to HistoryView.
In MainWindow::new(), set this callback to switch to the insights view.
Step 3: Build
Run: cargo build 2>&1
Step 4: Commit
feat: Show anomaly banner in history view
Displays a banner when spending insights exist for the
displayed month, linking to the Insights view.
Task 13: Bulk Transaction Operations (#6)
Files:
- Modify:
outlay-gtk/src/history_view.rs(selection mode, action bar)
Step 1: Add selection mode state
New fields in HistoryView:
in_selection_mode: Rc<Cell<bool>>,
selected_ids: Rc<RefCell<HashSet<i64>>>,
Step 2: Add "Select" button
In the history header area (next to search):
- "Select" button (edit-symbolic icon)
- On click: toggles
in_selection_mode, refreshes view - When in selection mode: button label changes to "Cancel"
Step 3: Modify transaction row rendering
When in_selection_mode is true:
- Prepend a
gtk::CheckButtonto each transaction row - Check button toggles the transaction ID in
selected_ids - Row click toggles the check instead of opening edit dialog
Step 4: Add action bar
When in selection mode and at least one item is selected, show an action bar at the bottom:
- "Delete (N)" button: confirmation dialog, then
db.delete_transaction()for each selected - "Recategorize" button: opens category picker dialog, then
db.update_transaction()for each - "Tag" button: opens tag picker dialog with checkboxes, then
db.set_transaction_tags()for each - "Select All" button: selects all visible transactions
- After any bulk action: exit selection mode, refresh view
Step 5: Build
Run: cargo build 2>&1
Step 6: Commit
feat: Add bulk transaction operations in history view
Selection mode with checkboxes for batch delete, recategorize,
and tag operations on multiple transactions.
Task 14: Natural Language Entry in Log View (#8)
Files:
- Modify:
outlay-gtk/src/log_view.rs(add NL entry bar) - Modify:
outlay-gtk/src/quick_add.rs(add NL entry bar to popup)
Step 1: Add NL entry bar to log_view.rs
At the top of the log form content (before the type toggle):
adw::PreferencesGroupcontaining anadw::EntryRowtitled "Quick entry"- Placeholder: "e.g. Coffee 4.50 at Starbucks"
- Below the entry: a preview box (initially hidden) showing parsed result
- Category icon + name, amount, payee, note - all in a compact row
- "Add" button to save
Step 2: Wire parser
On entry text change (debounced with glib::timeout_add_local_once 300ms):
- Call
outlay_core::nlp::parse_transaction(text, &categories) - If result is Some: show preview row with parsed fields
- If result is None: hide preview
On Enter key or "Add" button:
- Insert transaction from parsed result
- If no category matched, use the first expense category as default
- Show toast, clear entry
Step 3: Add to quick_add.rs
Add the same NL entry bar at the top of show_quick_add_popup(), before the type toggle.
Step 4: Build
Run: cargo build 2>&1
Step 5: Commit
feat: Add natural language entry bar to log view and quick add
Type "Coffee 4.50 at Starbucks" for instant parsed transaction
entry with fuzzy category matching.
Task 15: Sankey Diagram in Charts View (#9)
Files:
- Modify:
outlay-gtk/src/charts_view.rs(add Sankey section)
Step 1: Add Sankey section
After the existing chart sections (donut, bar, line):
adw::PreferencesGrouptitled "Money Flow"gtk::DrawingAreawith 600x400 size request- Cairo draw function:
- Query
db.get_monthly_totals_by_category(year, month, Income)and(Expense) - Build income_sources and expense_categories vectors with colors
- Call
outlay_core::sankey::compute_sankey_layout() - Draw left column (income nodes as rounded rectangles)
- Draw center column (single "Available" node)
- Draw right column (expense nodes)
- Draw flows as cubic bezier curves with alpha transparency
- Draw labels on each node
- Query
Step 2: Add hover tooltip
Use gtk::EventControllerMotion on the drawing area:
- Track mouse position
- On motion: check if cursor is over a flow or node
- If over a flow: set tooltip text to "Category: $X.XX"
- Redraw to highlight the hovered flow
Step 3: Theme support
- Check
adw::StyleManager::default().is_dark()for background/text colors - Income flows: green with 0.3 alpha
- Expense flows: category color with 0.3 alpha
- Net node: blue if positive, red if negative
Step 4: Build
Run: cargo build 2>&1
Step 5: Commit
feat: Add Sankey money flow diagram to charts view
Cairo-drawn flow visualization showing income sources flowing
through to expense categories with proportional widths.
Task 16: Custom Budget Cycle (#5)
Files:
- Modify:
outlay-gtk/src/budgets_view.rs(cycle config, period navigation, period-aware queries)
Step 1: Add cycle config popover
Next to the month navigation in budgets view:
- Gear icon button
- Opens a
gtk::Popoverwith:adw::ComboRowfor cycle mode: Calendar / Payday / Rollingadw::SpinRowfor start day (1-31, shown when mode is Payday)adw::SpinRowfor period days (7-90, shown when mode is Rolling)
- On change: save to
db.set_setting(), refresh view
Step 2: Update period display
- When mode is Calendar: show "March 2026" (existing behavior)
- When mode is Payday/Rolling: show "Mar 15 - Apr 14" format
- Use
db.get_budget_period(ref_date)for current period - Prev/Next buttons call
db.get_prev_budget_period()/db.get_next_budget_period()
Step 3: Update data queries
All budget data loading in the view must use db.list_transactions_in_range(start, end) and db.get_totals_in_range(start, end) instead of calendar month queries when not in Calendar mode.
Safe-to-spend recalculates based on period end date instead of month end.
Step 4: Build
Run: cargo build 2>&1
Step 5: Commit
feat: Add custom budget cycle support
Configure budget periods as calendar months, payday offsets,
or rolling N-day windows. Budgets view adapts navigation
and queries to the selected cycle.
Task 17: What-If / Sandbox Mode (#10)
Files:
- Modify:
outlay-gtk/src/budgets_view.rs(sandbox toggle and logic)
Step 1: Add sandbox state
New fields:
sandbox_active: Rc<Cell<bool>>,
sandbox_values: Rc<RefCell<HashMap<i64, f64>>>,
Step 2: Add sandbox toggle button
In the budgets header area:
- "What-If" toggle button
- When activated: set
sandbox_activeto true, addwarningCSS class to header, show "Sandbox Mode" label, show Apply/Discard buttons - When deactivated: clear sandbox values, refresh from DB
Step 3: Make budget amounts editable in sandbox
When sandbox is active:
- Each budget row's amount label becomes an inline entry
- On entry change: update
sandbox_valuesHashMap - Recalculate progress bars and safe-to-spend using sandbox values instead of DB values
- Show a dot indicator on modified rows
Step 4: Wire Apply/Discard
- Apply: iterate
sandbox_values, calldb.set_budget()for each, exit sandbox, refresh - Discard: clear
sandbox_values, exit sandbox, refresh
Step 5: Build
Run: cargo build 2>&1
Step 6: Commit
feat: Add what-if sandbox mode to budgets view
Experiment with budget changes without saving. Modified values
shown with indicators, apply or discard when done.
Task 18: Financial Goal Projections (#18)
Files:
- Modify:
outlay-gtk/src/goals_view.rs(add projection subtitles) - Modify:
outlay-core/src/db.rs(add contribution history query)
Step 1: Add contribution history query to db.rs
pub fn get_goal_contribution_history(&self, goal_id: i64, months: i32) -> SqlResult<Vec<f64>> {
// Returns monthly contribution amounts for the last N months
// Based on savings_goals.saved changes over time
// For simplicity: total_saved / months_since_creation as average
}
pub fn get_goal_avg_monthly_contribution(&self, goal_id: i64) -> SqlResult<f64> {
// Average monthly savings rate for this goal
}
Step 2: Add projection subtitle to goal rows
In goals_view.rs, for each active goal row:
- Call
db.get_goal_avg_monthly_contribution(goal_id) - If rate > 0: calculate months_remaining = (target - saved) / rate
- Compute projected_date = today + months_remaining months
- Subtitle text:
- If deadline exists and projected_date <= deadline: "On track - X months ahead"
- If deadline exists and projected_date > deadline: "Behind schedule - need X.XX/month to catch up"
- If no deadline: "At current rate, reachable by [date]"
- If rate == 0: "Start contributing to see projection"
Step 3: Build
Run: cargo build 2>&1
Step 4: Commit
feat: Add financial goal projections
Each savings goal shows projected completion date based
on average contribution rate.
Task 19: PDF Import UI (#16)
Files:
- Modify:
outlay-gtk/src/settings_view.rs(add PDF import button and preview dialog)
Step 1: Add PDF import button
In the Import section of settings, after the OFX import button:
- "PDF Statement" import button
- On click: open
gtk::FileDialogfiltered to*.pdf - Read file bytes, call
outlay_core::import_pdf::extract_transactions_from_pdf()
Step 2: Build preview dialog
After extraction, open an adw::Dialog (or adw::Window) showing:
- List of extracted rows, each as an
adw::ActionRow:- Title: description
- Subtitle: date (if parsed) + amount
- Suffix: category combo (auto-matched via
db.match_category(), fallback "Uncategorized") - Checkbox prefix for selection
- "Merge" / "Replace" toggle at top
- "Import All" and "Import Selected" buttons at bottom
Step 3: Wire import action
On import:
- For each selected row, create a
NewTransactionwith parsed date (or today), amount, matched category - Call
db.insert_transaction()for each - If merge mode, check
db.find_duplicate_transaction()first - Show toast with count of imported transactions
Step 4: Build
Run: cargo build 2>&1
Step 5: Commit
feat: Add PDF bank statement import
Extracts transactions from PDF text or OCR fallback,
shows preview for category assignment before importing.
Task 20: Startup Anomaly Toast + Achievement Check
Files:
- Modify:
outlay-gtk/src/main.rs(add startup checks)
Step 1: Add achievement check on startup
After the recurring transaction generation block in build_ui():
{
let newly_earned = db.check_and_award_achievements(chrono::Local::now().date_naive())
.unwrap_or_default();
for name in &newly_earned {
let toast = adw::Toast::new(&format!("Achievement unlocked: {}", name));
main_window.log_view.toast_overlay.add_toast(toast);
}
}
Step 2: Add anomaly toast on startup
{
let today = chrono::Local::now().date_naive();
let anomalies = db.detect_anomalies(today.year(), today.month());
if !anomalies.is_empty() {
let toast = adw::Toast::new(&format!("{} spending insights this month", anomalies.len()));
toast.set_timeout(5);
main_window.log_view.toast_overlay.add_toast(toast);
}
}
Step 3: Build
Run: cargo build 2>&1
Step 4: Commit
feat: Show achievement and anomaly toasts on startup
Checks for newly earned achievements and spending anomalies
when the app launches.
Task 21: Final Integration and Keyboard Shortcuts
Files:
- Modify:
outlay-gtk/src/window.rs(update keyboard shortcuts for new views)
Step 1: Update SIDEBAR_ITEMS order
The final sidebar order should be:
const SIDEBAR_ITEMS: &[SidebarItem] = &[
SidebarItem { id: "log", label: "Log", icon: "outlay-log" },
SidebarItem { id: "history", label: "History", icon: "outlay-history" },
SidebarItem { id: "charts", label: "Charts", icon: "outlay-charts" },
SidebarItem { id: "budgets", label: "Budgets", icon: "outlay-budgets" },
SidebarItem { id: "goals", label: "Goals", icon: "outlay-goals" },
SidebarItem { id: "recurring", label: "Recurring", icon: "outlay-recurring" },
SidebarItem { id: "subscriptions", label: "Subscriptions", icon: "outlay-subscriptions" },
SidebarItem { id: "wishlist", label: "Wishlist", icon: "outlay-wishlist" },
SidebarItem { id: "forecast", label: "Forecast", icon: "outlay-forecast" },
SidebarItem { id: "insights", label: "Insights", icon: "outlay-insights" },
SidebarItem { id: "creditcards", label: "Credit Cards", icon: "outlay-creditcards" },
];
Step 2: Add icons for new views
In outlay-gtk/src/icon_theme.rs or data/icons/, add icons for:
outlay-insights(lightbulb or chart-line)outlay-creditcards(credit-card)
If using fallback symbolic icons, map them in icon_theme.rs.
Step 3: Update keyboard shortcuts window
Add entries for the new views (Ctrl+0 for Insights, etc. if desired, or just add them to the shortcuts window documentation).
Step 4: Full build and smoke test
Run: cargo build 2>&1 && cargo run
Verify:
- All sidebar items appear and navigate correctly
- Insights view shows streaks, achievements, recap, anomalies
- Credit Cards view shows add/edit/delete/payment flow
- History bulk select works
- NL entry bar parses and saves
- Sankey chart renders
- Budget cycle switching works
- What-if sandbox works
- Tags, splits, templates, rules all functional
- PDF import processes a sample PDF
- Goal projections show on goal rows
- Anomaly banner appears in history
- Startup toasts fire
Step 5: Commit
feat: Final integration of 16 features
Wire up sidebar navigation, keyboard shortcuts, and icons
for Insights and Credit Cards views.
Summary
| Task | Feature(s) | New Files | Key Modified Files |
|---|---|---|---|
| 1 | Schema + Models | - | db.rs, models.rs |
| 2 | NL Parser | nlp.rs | lib.rs |
| 3 | Sankey Engine | sankey.rs | lib.rs |
| 4 | PDF Import Core | import_pdf.rs | lib.rs, Cargo.toml |
| 5 | Searchable Category | category_combo.rs | main.rs |
| 6 | Tags UI | - | history_view.rs, edit_dialog.rs |
| 7 | Splits UI | - | log_view.rs, edit_dialog.rs |
| 8 | Templates UI | - | log_view.rs |
| 9 | Rules UI | - | settings_view.rs |
| 10 | Credit Cards View | credit_cards_view.rs | main.rs, window.rs |
| 11 | Insights View | insights_view.rs | main.rs, window.rs |
| 12 | Anomaly Banner | - | history_view.rs, window.rs |
| 13 | Bulk Operations | - | history_view.rs |
| 14 | NL Entry UI | - | log_view.rs, quick_add.rs |
| 15 | Sankey Chart | - | charts_view.rs |
| 16 | Budget Cycle | - | budgets_view.rs |
| 17 | What-If Sandbox | - | budgets_view.rs |
| 18 | Goal Projections | - | goals_view.rs, db.rs |
| 19 | PDF Import UI | - | settings_view.rs |
| 20 | Startup Checks | - | main.rs |
| 21 | Final Integration | - | window.rs, icon_theme.rs |