diff --git a/docs/plans/2026-03-03-feature-batch-2-plan.md b/docs/plans/2026-03-03-feature-batch-2-plan.md new file mode 100644 index 0000000..f6e70d1 --- /dev/null +++ b/docs/plans/2026-03-03-feature-batch-2-plan.md @@ -0,0 +1,1832 @@ +# 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, + pub statement_close_day: i32, + pub due_day: i32, + pub min_payment_pct: f64, + pub current_balance: f64, + pub currency: String, + pub color: Option, + pub active: bool, +} + +#[derive(Debug, Clone)] +pub struct NewCreditCard { + pub name: String, + pub credit_limit: Option, + pub statement_close_day: i32, + pub due_day: i32, + pub min_payment_pct: f64, + pub currency: String, + pub color: Option, +} + +#[derive(Debug, Clone)] +pub struct Achievement { + pub id: i64, + pub name: String, + pub description: String, + pub earned_at: Option, + pub icon: Option, +} + +#[derive(Debug, Clone)] +pub struct ParsedTransaction { + pub amount: f64, + pub category_name: Option, + pub category_id: Option, + pub note: Option, + pub payee: Option, + 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, + pub right_nodes: Vec, + pub center_y: f64, + pub center_height: f64, + pub flows_in: Vec, + pub flows_out: Vec, + pub net: f64, +} + +#[derive(Debug, Clone)] +pub struct RecapCategory { + pub category_name: String, + pub category_icon: Option, + pub category_color: Option, + pub amount: f64, + pub percentage: f64, + pub change_pct: Option, +} + +#[derive(Debug, Clone)] +pub struct MonthlyRecap { + pub total_income: f64, + pub total_expenses: f64, + pub net: f64, + pub transaction_count: i64, + pub categories: Vec, +} + +#[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, + 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 { ... } +pub fn list_credit_cards(&self) -> SqlResult> { ... } +pub fn get_credit_card(&self, id: i64) -> SqlResult { ... } +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> { ... } +pub fn award_achievement(&self, name: &str) -> SqlResult { + // Sets earned_at = now if not already earned. Returns true if newly awarded. +} +pub fn get_no_spend_streak(&self, today: NaiveDate) -> SqlResult { + // Count consecutive days backwards from today with zero expense transactions +} +pub fn get_total_transaction_count(&self) -> SqlResult { + // SELECT COUNT(*) FROM transactions +} +pub fn check_and_award_achievements(&self, today: NaiveDate) -> SqlResult> { + // 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 { + // 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> { + // 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 { + 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 = None; + let mut amount_idx: Option = None; + for (i, tok) in tokens.iter().enumerate() { + let cleaned = tok.trim_start_matches('$').replace(',', ""); + if let Ok(val) = cleaned.parse::() { + 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 = 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 { + 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, 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, 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, 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 = 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 { + 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, 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::() { + let is_credit = val > 0.0 || tok.starts_with('+'); + return Some((val.abs(), is_credit, i)); + } + } + None +} + +fn parse_date_flexible(s: &str) -> Option { + 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, + txn_type: Option, + title: &str, +) -> (adw::ComboRow, Rc>>) { + let categories = db.list_categories(txn_type).unwrap_or_default(); + let ids: Vec = categories.iter().map(|c| c.id).collect(); + let entries: Vec = 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>>, + db: &Rc, + txn_type: Option, +) { + let categories = db.list_categories(txn_type).unwrap_or_default(); + let new_ids: Vec = categories.iter().map(|c| c.id).collect(); + let entries: Vec = 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::().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::().unwrap(); + let string_obj = item.item().and_downcast::().unwrap(); + let text = string_obj.string(); + let hbox = item.child().and_downcast::().unwrap(); + let icon = hbox.first_child().and_downcast::().unwrap(); + let label = icon.next_sibling().and_downcast::().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>>` 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) -> 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>>>, + pub on_navigate_history: Rc>>>, +} + +impl InsightsView { + pub fn new(db: Rc) -> Self { ... } + pub fn refresh(&self) { ... } + pub fn set_on_navigate_category(&self, cb: Rc) { ... } + pub fn set_on_navigate_history(&self, cb: Rc) { ... } +} +``` + +**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>>>` 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>, +selected_ids: Rc>>, +``` + +**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>, +sandbox_values: Rc>>, +``` + +**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> { + // 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 { + // 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 |