Files
outlay/docs/plans/2026-03-03-feature-batch-2-plan.md
lashman 773dae4684 Add feature batch 2 implementation plan (21 tasks)
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.
2026-03-03 16:30:49 +02:00

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 (add pub 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 (add pub 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 (add pub mod import_pdf;)
  • Modify: outlay-core/Cargo.toml (add pdf-extract dependency)

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 (add mod 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 outlined CSS class (vs filled for 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::EntryRow titled "Tags"
  • On save, parse comma-separated text, call db.get_or_create_tag() for each, then db.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(), call db.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) then db.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::Popover containing a scrollable list of templates from db.list_templates()
  • Each template is an adw::ActionRow with: 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::AlertDialog asking 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::PreferencesGroup titled "Categorization Rules"
  • For each rule from db.list_rules(), add an adw::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::EntryRow titled "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 (add mod 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::PreferencesGroup with each card as adw::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 (add mod 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::PreferencesGroup titled "Streaks"
  • Three adw::ActionRow items:
    1. "No-Spend Streak": fire-symbolic prefix, title="X days", subtitle="Best: Y days"
    2. "Under Budget": shield-symbolic prefix, title="X months"
    3. "Savings": piggy-bank-symbolic prefix, title="X months"
  • Data from db.get_no_spend_streak(today) and similar queries

Step 3: Achievements section

  • adw::PreferencesGroup titled "Achievements"
  • gtk::FlowBox with 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::PreferencesGroup with per-category adw::ActionRow items
    • 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::PreferencesGroup titled "Spending Insights"
  • Each anomaly from db.detect_anomalies(year, month) as adw::ActionRow:
    • warning-symbolic (amber) for overspending, info-symbolic (blue) for other
    • Title: anomaly message
    • Subtitle: deviation amount
    • Clickable: invokes on_navigate_category callback

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, run db.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::CheckButton to 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::PreferencesGroup containing an adw::EntryRow titled "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::PreferencesGroup titled "Money Flow"
  • gtk::DrawingArea with 600x400 size request
  • Cairo draw function:
    1. Query db.get_monthly_totals_by_category(year, month, Income) and (Expense)
    2. Build income_sources and expense_categories vectors with colors
    3. Call outlay_core::sankey::compute_sankey_layout()
    4. Draw left column (income nodes as rounded rectangles)
    5. Draw center column (single "Available" node)
    6. Draw right column (expense nodes)
    7. Draw flows as cubic bezier curves with alpha transparency
    8. Draw labels on each node

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::Popover with:
    • adw::ComboRow for cycle mode: Calendar / Payday / Rolling
    • adw::SpinRow for start day (1-31, shown when mode is Payday)
    • adw::SpinRow for 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_active to true, add warning CSS 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_values HashMap
  • 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, call db.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::FileDialog filtered 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 NewTransaction with 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