# 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)]`:

```rust
#[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:

```rust
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`**

```rust
// 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**

```rust
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**

```rust
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**

```rust
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**

```rust
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`**

```rust
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:
```toml
pdf-extract = "0.7"
```

**Step 2: Create `import_pdf.rs`**

```rust
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`**

```rust
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:
```rust
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:
```rust
SidebarItem { id: "creditcards", label: "Credit Cards", icon: "outlay-creditcards" },
```

In `MainWindow::new()`, create the view and add to content_stack:
```rust
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:
```rust
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):
```rust
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`:
```rust
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:
```rust
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**

```rust
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()`:
```rust
{
    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**

```rust
{
    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:
```rust
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 |
