From dde14bfd1f3a5cb9296eef51057dd3a73c780e05 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 21:26:18 -0800 Subject: [PATCH 1/8] feat: new functions for statistics each month --- expense_tracker/core/repositories.py | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/expense_tracker/core/repositories.py b/expense_tracker/core/repositories.py index ae14f3a..338e60d 100644 --- a/expense_tracker/core/repositories.py +++ b/expense_tracker/core/repositories.py @@ -198,6 +198,83 @@ def get_daily_spending_for_month(self, year: int, month: int) -> dict[int, float for row in rows.fetchall(): result[row["day"]] = row["total"] return result + + def get_monthly_cashflow_trend(self, num_months: int) -> list[tuple[int, int, float]]: + """ + Returns a list of (year, month, net_amount) tuples for the past num_months. + Net amount is total income minus total expenses for each month. + Ordered by year and month ascending. + """ + rows = self.conn.execute( + """ + SELECT + CAST(strftime('%Y', date) AS INTEGER) as year, + CAST(strftime('%m', date) AS INTEGER) as month, + SUM(amount) as net_amount + FROM transactions + GROUP BY year, month + ORDER BY year DESC, month DESC + LIMIT ? + """, + (num_months,), + ) + + result: list[tuple[int, int, float]] = [] + for row in reversed(rows.fetchall()): + result.append((row["year"], row["month"], row["net_amount"])) + return result + + def get_monthly_net_income(self, year: int, month: int) -> float: + """ + Returns the net income (total income minus total expenses) for a specific month. + Positive amount means more income than expenses, negative means more expenses than income. + """ + # Create date range for the month + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + + row = self.conn.execute( + """ + SELECT SUM(amount) as net_income + FROM transactions + WHERE date >= ? AND date < ? + """, + (start_date.isoformat(), end_date.isoformat()), + ) + result = row.fetchone() + return result["net_income"] if result["net_income"] is not None else 0.0 + + def get_top_spending_category(self, year: int, month: int) -> tuple[str, float] | None: + """ + Returns the category with the highest spending (sum of negative amounts) for a specific month. + Returns tuple of (category_name, total_spending) or None if no expenses exist. + """ + # Create date range for the month + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + + rows = self.conn.execute( + """ + SELECT category, SUM(ABS(amount)) as total + FROM transactions + WHERE date >= ? AND date < ? + AND amount < 0 + GROUP BY category + ORDER BY total DESC + LIMIT 1 + """, + (start_date.isoformat(), end_date.isoformat()), + ) + result = rows.fetchone() + if result is None: + return None + return (result["category"], result["total"]) def get_transactions_for_date(self, target_date: date) -> list[Transaction]: """ @@ -214,6 +291,48 @@ def get_transactions_for_date(self, target_date: date) -> list[Transaction]: if transaction: transactions.append(transaction) return transactions + + def get_latest_month_with_data(self) -> tuple[int, int]: + """ + Get the most recent month that has transaction data. + Falls back to current month if no transactions exist. + """ + # Query for all months with transactions (ordered by most recent first) + rows = self.conn.execute( + """ + SELECT DISTINCT + CAST(strftime('%Y', date) AS INTEGER) as year, + CAST(strftime('%m', date) AS INTEGER) as month + FROM transactions + ORDER BY year DESC, month DESC + LIMIT 1 + """ + ) + result = rows.fetchone() + + if result is None: + # No transactions exist, default to current month + today = date.today() + return (today.year, today.month) + + return (result["year"], result["month"]) + + + def get_all_months_with_data(self) -> list[tuple[int, int]]: + """ + Returns a list of (year, month) tuples for all months that have transaction data. + Ordered by year and month descending (most recent first). + """ + rows = self.conn.execute( + """ + SELECT DISTINCT + CAST(strftime('%Y', date) AS INTEGER) as year, + CAST(strftime('%m', date) AS INTEGER) as month + FROM transactions + ORDER BY year DESC, month DESC + """ + ) + return {(row["year"], row["month"]) for row in rows.fetchall()} def get_months_with_expenses(self) -> list[tuple[int, int]]: """ From b08a58085f14648ab40fae684164021e1be5a355 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 21:26:27 -0800 Subject: [PATCH 2/8] gui: added a new tab to view stats --- expense_tracker/gui/tabs/statistics_tab.py | 208 +++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 expense_tracker/gui/tabs/statistics_tab.py diff --git a/expense_tracker/gui/tabs/statistics_tab.py b/expense_tracker/gui/tabs/statistics_tab.py new file mode 100644 index 0000000..eb80a87 --- /dev/null +++ b/expense_tracker/gui/tabs/statistics_tab.py @@ -0,0 +1,208 @@ +import calendar +import tkinter as tk +from tkinter import ttk + +from expense_tracker.core.repositories import TransactionRepository + + +class StatisticsTab(tk.Frame): + def __init__(self, master, transaction_repo: TransactionRepository): + super().__init__(master) + self.transaction_repo: TransactionRepository = transaction_repo + + # State: the latest month and year where the record is available + latest_year, latest_month = self.transaction_repo.get_latest_month_with_data() + self._current_year = latest_year + self._current_month = latest_month + + # Cache all months with data for navigation button state management + self._months_with_data = self.transaction_repo.get_all_months_with_data() + + self.pack(fill=tk.BOTH, expand=True) + + # Build UI + self._build_header() + self._build_metrics_cards() + + def _build_header(self): + """Build header with month navigation controls.""" + header = tk.Frame(self) + header.pack(fill=tk.X, padx=20, pady=15) + + # Previous month button + self.prev_button = ttk.Button( + header, text="<", command=self._previous_month, width=3 + ) + self.prev_button.pack(side=tk.LEFT, padx=5) + + # Month/Year label + self.month_label = ttk.Label(header, text="", font=("Arial", 20, "bold")) + self.month_label.pack(side=tk.LEFT, expand=True) + + # Next month button + self.next_button = ttk.Button( + header, text=">", command=self._next_month, width=3 + ) + self.next_button.pack(side=tk.RIGHT, padx=5) + + self._update_header_label() + + def _build_metrics_cards(self): + """Build card-based layout for displaying metrics.""" + # Container for metrics cards + cards_container = tk.Frame(self) + cards_container.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) + + # Configure grid to center cards + cards_container.grid_columnconfigure(0, weight=1) + cards_container.grid_columnconfigure(1, weight=1) + cards_container.grid_rowconfigure(0, weight=1) + + # Net Income Card + net_income_card = tk.Frame( + cards_container, relief=tk.RIDGE, borderwidth=2, bg="#2b2b2b" + ) + net_income_card.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") + + net_income_title = tk.Label( + net_income_card, + text="Monthly Net Income", + font=("Arial", 14, "bold"), + bg="#2b2b2b", + fg="#ffffff", + ) + net_income_title.pack(pady=(20, 10)) + + self.net_income_label = tk.Label( + net_income_card, + text="$0.00", + font=("Arial", 32, "bold"), + bg="#2b2b2b", + fg="#ffffff", + ) + self.net_income_label.pack(pady=(10, 20)) + + # Top Spending Category Card + top_category_card = tk.Frame( + cards_container, relief=tk.RIDGE, borderwidth=2, bg="#2b2b2b" + ) + top_category_card.grid(row=0, column=1, padx=10, pady=10, sticky="nsew") + + top_category_title = tk.Label( + top_category_card, + text="Top Spending Category", + font=("Arial", 14, "bold"), + bg="#2b2b2b", + fg="#ffffff", + ) + top_category_title.pack(pady=(20, 10)) + + self.top_category_name_label = tk.Label( + top_category_card, + text="N/A", + font=("Arial", 24, "bold"), + bg="#2b2b2b", + fg="#ffffff", + ) + self.top_category_name_label.pack(pady=(5, 5)) + + self.top_category_amount_label = tk.Label( + top_category_card, + text="$0.00", + font=("Arial", 18), + bg="#2b2b2b", + fg="#aaaaaa", + ) + self.top_category_amount_label.pack(pady=(5, 20)) + + def _update_header_label(self): + """Update the month/year label and button states.""" + month_name = calendar.month_name[self._current_month] + self.month_label.config(text=f"{month_name} {self._current_year}") + self._update_button_states() + + def _has_previous_month(self) -> bool: + """Check if there's data in the previous month.""" + if self._current_month == 1: + prev_year, prev_month = self._current_year - 1, 12 + else: + prev_year, prev_month = self._current_year, self._current_month - 1 + return (prev_year, prev_month) in self._months_with_data + + def _has_next_month(self) -> bool: + """Check if there's data in the next month.""" + if self._current_month == 12: + next_year, next_month = self._current_year + 1, 1 + else: + next_year, next_month = self._current_year, self._current_month + 1 + return (next_year, next_month) in self._months_with_data + + def _update_button_states(self): + """Enable/disable navigation buttons based on data availability.""" + # Enable previous button only if previous month has data + if self._has_previous_month(): + self.prev_button.config(state=tk.NORMAL) + else: + self.prev_button.config(state=tk.DISABLED) + + # Enable next button only if next month has data + if self._has_next_month(): + self.next_button.config(state=tk.NORMAL) + else: + self.next_button.config(state=tk.DISABLED) + + def _previous_month(self): + """Navigate to previous month.""" + if self._current_month == 1: + self._current_month = 12 + self._current_year -= 1 + else: + self._current_month -= 1 + self._update_header_label() + self._update_metrics() + + def _next_month(self): + """Navigate to next month.""" + if self._current_month == 12: + self._current_month = 1 + self._current_year += 1 + else: + self._current_month += 1 + self._update_header_label() + self._update_metrics() + + def _update_metrics(self): + """Update metric displays with current month data.""" + # Get monthly net income + net_income = self.transaction_repo.get_monthly_net_income( + self._current_year, self._current_month + ) + + # Format and color code net income + formatted_income = f"${abs(net_income):,.2f}" + if net_income < 0: + formatted_income = f"-{formatted_income}" + color = "#ff4444" # Red for negative + elif net_income > 0: + color = "#44ff44" # Green for positive + else: + color = "#ffffff" # White for zero + + self.net_income_label.config(text=formatted_income, fg=color) + + # Get top spending category + top_category = self.transaction_repo.get_top_spending_category( + self._current_year, self._current_month + ) + + if top_category is None: + self.top_category_name_label.config(text="N/A") + self.top_category_amount_label.config(text="$0.00") + else: + category_name, amount = top_category + self.top_category_name_label.config(text=category_name) + self.top_category_amount_label.config(text=f"${amount:,.2f}") + + def refresh(self): + """Refresh statistics when tab becomes active.""" + self._update_metrics() From 279facb66ec0d34016f2bf715c9d4f5668213b77 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 21:26:36 -0800 Subject: [PATCH 3/8] connecf to main app workflow --- expense_tracker/gui/main_window.py | 10 ++++++++-- expense_tracker/gui/tabs/__init__.py | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/expense_tracker/gui/main_window.py b/expense_tracker/gui/main_window.py index f17eb0b..77d374e 100644 --- a/expense_tracker/gui/main_window.py +++ b/expense_tracker/gui/main_window.py @@ -2,7 +2,7 @@ from datetime import date from tkinter import ttk -from expense_tracker.gui.tabs import TransactionsTab, HeatmapTab +from expense_tracker.gui.tabs import TransactionsTab, HeatmapTab, StatisticsTab class MainWindow(tk.Frame): @@ -24,11 +24,15 @@ def __init__(self, master, transaction_repo, merchant_repo): self.notebook, transaction_repo, merchant_repo, self ) + # Create Statistics tab + self.statistics_tab = StatisticsTab(self.notebook, transaction_repo) + # Create Heatmap tab self.heatmap_tab = HeatmapTab(self.notebook, transaction_repo, self) # Add tabs to notebook self.notebook.add(self.transactions_tab, text="Transactions") + self.notebook.add(self.statistics_tab, text="Statistics") self.notebook.add(self.heatmap_tab, text="Heatmap") # Bind tab change event for lazy loading @@ -64,7 +68,9 @@ def _on_tab_changed(self, event): """Refresh tab content when user switches tabs.""" current_tab = self.notebook.select() tab_index = self.notebook.index(current_tab) - if tab_index == 1: # Heatmap tab + if tab_index == 1: # Statistics tab + self.statistics_tab.refresh() + elif tab_index == 2: # Heatmap tab self.heatmap_tab.refresh() def show_transactions_for_date(self, target_date: date): diff --git a/expense_tracker/gui/tabs/__init__.py b/expense_tracker/gui/tabs/__init__.py index 8439276..a2e107c 100644 --- a/expense_tracker/gui/tabs/__init__.py +++ b/expense_tracker/gui/tabs/__init__.py @@ -1,4 +1,5 @@ from .transactions_tab import TransactionsTab from .heatmap_tab import HeatmapTab +from .statistics_tab import StatisticsTab -__all__ = ["TransactionsTab", "HeatmapTab"] +__all__ = ["TransactionsTab", "HeatmapTab", "StatisticsTab"] From 4d29ce31ad8bed5bd7ec8d956c6cb3233ff5140f Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 21:26:39 -0800 Subject: [PATCH 4/8] add tests --- tests/core/test_repository.py | 379 ++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) diff --git a/tests/core/test_repository.py b/tests/core/test_repository.py index e29bdb6..8b05e8a 100644 --- a/tests/core/test_repository.py +++ b/tests/core/test_repository.py @@ -777,3 +777,382 @@ def test_get_months_with_expenses_empty(in_memory_repo): # Should return empty list (no expenses) assert len(months) == 0 assert months == [] + + +def test_get_monthly_net_income_with_income_and_expenses(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add income and expenses for January 2023 + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=1000.0, # Income + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-10"), + amount=-50.0, # Expense + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-25.0, # Expense + category="Transport", + description="Taxi", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-20"), + amount=200.0, # Income + category="Income", + description="Bonus", + ) + ) + + net_income = repo.get_monthly_net_income(2023, 1) + + # Net income = 1000 + 200 - 50 - 25 = 1125 + assert net_income == 1125.0 + + +def test_get_monthly_net_income_only_expenses(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add only expenses + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-10"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-50.0, + category="Food", + description="Restaurant", + ) + ) + + net_income = repo.get_monthly_net_income(2023, 1) + + # Net income = -100 - 50 = -150 + assert net_income == -150.0 + + +def test_get_monthly_net_income_no_transactions(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transaction for different month + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-10"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + # Query for month with no transactions + net_income = repo.get_monthly_net_income(2023, 1) + + # Should return 0.0 for empty month + assert net_income == 0.0 + + +def test_get_monthly_net_income_only_income(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add only income + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + + net_income = repo.get_monthly_net_income(2023, 1) + + assert net_income == 2000.0 + + +def test_get_top_spending_category_with_multiple_categories(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add expenses in different categories + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-100.0, + category="Groceries", + description="Whole Foods", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-10"), + amount=-50.0, + category="Groceries", + description="Trader Joes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-75.0, + category="Restaurants", + description="Dinner", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-20"), + amount=-25.0, + category="Transportation", + description="Uber", + ) + ) + + top_category = repo.get_top_spending_category(2023, 1) + + # Groceries has highest spending: 100 + 50 = 150 + assert top_category is not None + assert top_category[0] == "Groceries" + assert top_category[1] == 150.0 + + +def test_get_top_spending_category_exclude_income(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add income and expenses + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=2000.0, # Income (should be excluded) + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-10"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + top_category = repo.get_top_spending_category(2023, 1) + + # Should return Food, not Income + assert top_category is not None + assert top_category[0] == "Food" + assert top_category[1] == 50.0 + + +def test_get_top_spending_category_no_expenses(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add only income + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=1000.0, + category="Income", + description="Salary", + ) + ) + + top_category = repo.get_top_spending_category(2023, 1) + + # Should return None when no expenses exist + assert top_category is None + + +def test_get_top_spending_category_empty_month(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transaction for different month + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-10"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + # Query for month with no transactions + top_category = repo.get_top_spending_category(2023, 1) + + # Should return None + assert top_category is None + + +def test_get_latest_month_with_data(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transactions across different months + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-03-10"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2024-02-05"), + amount=-75.0, + category="Food", + description="Restaurant", + ) + ) + + latest_year, latest_month = repo.get_latest_month_with_data() + + # Should return the most recent month (February 2024) + assert latest_year == 2024 + assert latest_month == 2 + + +def test_get_latest_month_with_data_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # No transactions + + latest_year, latest_month = repo.get_latest_month_with_data() + + # Should return current month when no transactions exist + today = date.today() + assert latest_year == today.year + assert latest_month == today.month + + +def test_get_all_months_with_data(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transactions across different months + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-20"), + amount=-30.0, + category="Transport", + description="Taxi", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-03-10"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2024-02-05"), + amount=-75.0, + category="Food", + description="Restaurant", + ) + ) + + months = repo.get_all_months_with_data() + + # Should return a set of all (year, month) tuples with data + assert isinstance(months, set) + assert len(months) == 3 + assert (2023, 1) in months # January 2023 (two transactions) + assert (2023, 3) in months # March 2023 + assert (2024, 2) in months # February 2024 + + +def test_get_all_months_with_data_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # No transactions + + months = repo.get_all_months_with_data() + + # Should return empty set + assert isinstance(months, set) + assert len(months) == 0 + + +def test_get_all_months_with_data_single_month(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add multiple transactions in the same month + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-05-10"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-05-15"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-05-25"), + amount=-30.0, + category="Transport", + description="Taxi", + ) + ) + + months = repo.get_all_months_with_data() + + # Should return set with single month (no duplicates) + assert isinstance(months, set) + assert len(months) == 1 + assert (2023, 5) in months From 08505121f3f6e5b7928dd571c2a8510dfb07e35d Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 21:40:34 -0800 Subject: [PATCH 5/8] refactor into two repositories --- expense_tracker/app.py | 8 +-- expense_tracker/core/merchant_repository.py | 64 +++++++++++++++++++ ...ositories.py => transaction_repository.py} | 59 +---------------- expense_tracker/gui/dialogs/add_expense.py | 2 +- expense_tracker/gui/dialogs/edit_expense.py | 7 +- expense_tracker/gui/dialogs/upload.py | 6 +- expense_tracker/gui/tabs/heatmap_tab.py | 2 +- expense_tracker/gui/tabs/statistics_tab.py | 2 +- expense_tracker/gui/tabs/transactions_tab.py | 2 +- expense_tracker/services/merchant.py | 6 +- tests/core/test_repository.py | 6 +- 11 files changed, 81 insertions(+), 83 deletions(-) create mode 100644 expense_tracker/core/merchant_repository.py rename expense_tracker/core/{repositories.py => transaction_repository.py} (85%) diff --git a/expense_tracker/app.py b/expense_tracker/app.py index d27166d..b83cf98 100644 --- a/expense_tracker/app.py +++ b/expense_tracker/app.py @@ -1,13 +1,11 @@ -from expense_tracker.core.repositories import ( - TransactionRepository, - MerchantCategoryRepository, -) from tkinter import Tk, ttk from expense_tracker.gui.main_window import MainWindow + from expense_tracker.version import versions from expense_tracker.utils.path import get_database_path from expense_tracker.utils.migration import migrate_legacy_databases - +from expense_tracker.core.merchant_repository import MerchantCategoryRepository +from expense_tracker.core.transaction_repository import TransactionRepository def main(): """Start the Expense Tracker application.""" diff --git a/expense_tracker/core/merchant_repository.py b/expense_tracker/core/merchant_repository.py new file mode 100644 index 0000000..2b182c9 --- /dev/null +++ b/expense_tracker/core/merchant_repository.py @@ -0,0 +1,64 @@ +import logging +import sqlite3 + +from expense_tracker.core.models import MerchantCategory + +logger = logging.getLogger(__name__) + +class MerchantCategoryRepository: + """ + A repository for managing merchant categories. + """ + + def __init__(self, db_path: str): + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self._init_schema() + logger.info("Initialized merchant category database schema") + + def _init_schema(self) -> None: + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS merchant_categories ( + merchant_key TEXT PRIMARY KEY, + category TEXT NOT NULL + ); + """) + + def _row_to_merchant_category( + self, row: sqlite3.Row | None + ) -> MerchantCategory | None: + if row is None: + return None + return MerchantCategory( + merchant_key=row["merchant_key"], + category=row["category"], + ) + + def set_category(self, merchant_category: MerchantCategory) -> None: + """Sets or updates the category for a given merchant key.""" + self.conn.execute( + """ + INSERT INTO merchant_categories (merchant_key, category) + VALUES (?, ?) + ON CONFLICT(merchant_key) DO UPDATE SET category=excluded.category + """, + (merchant_category.merchant_key, merchant_category.category), + ) + self.conn.commit() + + def get_category(self, merchant_key: str) -> MerchantCategory | None: + """Retrieves the category for a given merchant key.""" + row = self.conn.execute( + "SELECT * FROM merchant_categories WHERE merchant_key = ?", (merchant_key,) + ) + return self._row_to_merchant_category(row.fetchone()) + + def get_all_merchants(self) -> list[MerchantCategory]: + """Retrieves all merchant categories.""" + rows = self.conn.execute("SELECT * FROM merchant_categories") + merchants = [] + for row in rows.fetchall(): + merchant = self._row_to_merchant_category(row) + if merchant: + merchants.append(merchant) + return merchants diff --git a/expense_tracker/core/repositories.py b/expense_tracker/core/transaction_repository.py similarity index 85% rename from expense_tracker/core/repositories.py rename to expense_tracker/core/transaction_repository.py index 338e60d..5c1cd2c 100644 --- a/expense_tracker/core/repositories.py +++ b/expense_tracker/core/transaction_repository.py @@ -3,7 +3,7 @@ from dataclasses import replace from datetime import date -from expense_tracker.core.models import Transaction, MerchantCategory +from expense_tracker.core.models import Transaction logger = logging.getLogger(__name__) @@ -356,60 +356,3 @@ def get_months_with_expenses(self) -> list[tuple[int, int]]: return result -class MerchantCategoryRepository: - """ - A repository for managing merchant categories. - """ - - def __init__(self, db_path: str): - self.conn = sqlite3.connect(db_path, check_same_thread=False) - self.conn.row_factory = sqlite3.Row - self._init_schema() - logger.info("Initialized merchant category database schema") - - def _init_schema(self) -> None: - self.conn.executescript(""" - CREATE TABLE IF NOT EXISTS merchant_categories ( - merchant_key TEXT PRIMARY KEY, - category TEXT NOT NULL - ); - """) - - def _row_to_merchant_category( - self, row: sqlite3.Row | None - ) -> MerchantCategory | None: - if row is None: - return None - return MerchantCategory( - merchant_key=row["merchant_key"], - category=row["category"], - ) - - def set_category(self, merchant_category: MerchantCategory) -> None: - """Sets or updates the category for a given merchant key.""" - self.conn.execute( - """ - INSERT INTO merchant_categories (merchant_key, category) - VALUES (?, ?) - ON CONFLICT(merchant_key) DO UPDATE SET category=excluded.category - """, - (merchant_category.merchant_key, merchant_category.category), - ) - self.conn.commit() - - def get_category(self, merchant_key: str) -> MerchantCategory | None: - """Retrieves the category for a given merchant key.""" - row = self.conn.execute( - "SELECT * FROM merchant_categories WHERE merchant_key = ?", (merchant_key,) - ) - return self._row_to_merchant_category(row.fetchone()) - - def get_all_merchants(self) -> list[MerchantCategory]: - """Retrieves all merchant categories.""" - rows = self.conn.execute("SELECT * FROM merchant_categories") - merchants = [] - for row in rows.fetchall(): - merchant = self._row_to_merchant_category(row) - if merchant: - merchants.append(merchant) - return merchants diff --git a/expense_tracker/gui/dialogs/add_expense.py b/expense_tracker/gui/dialogs/add_expense.py index 6297b85..f8e6e51 100644 --- a/expense_tracker/gui/dialogs/add_expense.py +++ b/expense_tracker/gui/dialogs/add_expense.py @@ -3,7 +3,7 @@ from tkinter import ttk, messagebox from expense_tracker.core.models import Transaction -from expense_tracker.core.repositories import TransactionRepository +from expense_tracker.core.transaction_repository import TransactionRepository class AddExpenseDialog(tk.Toplevel): diff --git a/expense_tracker/gui/dialogs/edit_expense.py b/expense_tracker/gui/dialogs/edit_expense.py index 1d209d4..6a8c193 100644 --- a/expense_tracker/gui/dialogs/edit_expense.py +++ b/expense_tracker/gui/dialogs/edit_expense.py @@ -1,10 +1,9 @@ import logging import tkinter as tk from tkinter import ttk, messagebox -from expense_tracker.core.repositories import ( - TransactionRepository, - MerchantCategoryRepository, -) + +from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.core.merchant_repository import MerchantCategoryRepository from expense_tracker.services.merchant import MerchantCategoryService from expense_tracker.utils.merchant_normalizer import normalize_merchant diff --git a/expense_tracker/gui/dialogs/upload.py b/expense_tracker/gui/dialogs/upload.py index 3671382..22a388d 100644 --- a/expense_tracker/gui/dialogs/upload.py +++ b/expense_tracker/gui/dialogs/upload.py @@ -3,10 +3,8 @@ from datetime import datetime, date from expense_tracker.core.models import Transaction -from expense_tracker.core.repositories import ( - TransactionRepository, - MerchantCategoryRepository, -) +from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.core.merchant_repository import MerchantCategoryRepository from expense_tracker.utils.extract import parse_bofa_statement_pdf from expense_tracker.services.merchant import MerchantCategoryService from expense_tracker.utils.merchant_normalizer import normalize_merchant diff --git a/expense_tracker/gui/tabs/heatmap_tab.py b/expense_tracker/gui/tabs/heatmap_tab.py index 56f4533..fb35516 100644 --- a/expense_tracker/gui/tabs/heatmap_tab.py +++ b/expense_tracker/gui/tabs/heatmap_tab.py @@ -3,7 +3,7 @@ from datetime import date from tkinter import ttk -from expense_tracker.core.repositories import TransactionRepository +from expense_tracker.core.transaction_repository import TransactionRepository class HeatmapTab(tk.Frame): diff --git a/expense_tracker/gui/tabs/statistics_tab.py b/expense_tracker/gui/tabs/statistics_tab.py index eb80a87..4d808fc 100644 --- a/expense_tracker/gui/tabs/statistics_tab.py +++ b/expense_tracker/gui/tabs/statistics_tab.py @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk -from expense_tracker.core.repositories import TransactionRepository +from expense_tracker.core.transaction_repository import TransactionRepository class StatisticsTab(tk.Frame): diff --git a/expense_tracker/gui/tabs/transactions_tab.py b/expense_tracker/gui/tabs/transactions_tab.py index adae87c..2f36633 100644 --- a/expense_tracker/gui/tabs/transactions_tab.py +++ b/expense_tracker/gui/tabs/transactions_tab.py @@ -3,7 +3,7 @@ from datetime import date from tkinter import ttk, messagebox -from expense_tracker.core.repositories import TransactionRepository +from expense_tracker.core.transaction_repository import TransactionRepository from expense_tracker.gui.dialogs.add_expense import AddExpenseDialog from expense_tracker.gui.dialogs.edit_expense import EditExpenseDialog from expense_tracker.gui.dialogs.upload import UploadDialog diff --git a/expense_tracker/services/merchant.py b/expense_tracker/services/merchant.py index e01c460..73173fb 100644 --- a/expense_tracker/services/merchant.py +++ b/expense_tracker/services/merchant.py @@ -1,10 +1,8 @@ from typing import Callable import logging -from expense_tracker.core.repositories import ( - TransactionRepository, - MerchantCategoryRepository, -) +from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.core.merchant_repository import MerchantCategoryRepository from expense_tracker.core.models import MerchantCategory logger = logging.getLogger(__name__) diff --git a/tests/core/test_repository.py b/tests/core/test_repository.py index 8b05e8a..4b8c642 100644 --- a/tests/core/test_repository.py +++ b/tests/core/test_repository.py @@ -3,10 +3,8 @@ import pytest from expense_tracker.core.models import MerchantCategory, Transaction -from expense_tracker.core.repositories import ( - MerchantCategoryRepository, - TransactionRepository, -) +from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.core.merchant_repository import MerchantCategoryRepository @pytest.fixture From 562ac3d4ff44ee0c7d609401732de6eff9dd8520 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 21:54:47 -0800 Subject: [PATCH 6/8] refactor: add a new service layer --- expense_tracker/app.py | 5 +- .../core/transaction_repository.py | 26 +- expense_tracker/gui/main_window.py | 7 +- expense_tracker/gui/tabs/heatmap_tab.py | 10 +- expense_tracker/gui/tabs/statistics_tab.py | 26 +- expense_tracker/services/statistics.py | 104 ++++++ tests/core/test_repository.py | 22 +- tests/services/__init__.py | 0 tests/services/test_statistics.py | 304 ++++++++++++++++++ 9 files changed, 447 insertions(+), 57 deletions(-) create mode 100644 expense_tracker/services/statistics.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/test_statistics.py diff --git a/expense_tracker/app.py b/expense_tracker/app.py index b83cf98..4f1c1ce 100644 --- a/expense_tracker/app.py +++ b/expense_tracker/app.py @@ -6,6 +6,7 @@ from expense_tracker.utils.migration import migrate_legacy_databases from expense_tracker.core.merchant_repository import MerchantCategoryRepository from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.services.statistics import StatisticsService def main(): """Start the Expense Tracker application.""" @@ -23,6 +24,8 @@ def main(): merchant_repo = MerchantCategoryRepository( str(get_database_path("merchant_categories.db")) ) + statistics_service = StatisticsService(transaction_repo) + root = Tk() root.title("Expense Tracker") root.geometry("1200x700") @@ -32,6 +35,6 @@ def main(): tb.Style("darkly") except Exception: ttk.Style() - MainWindow(root, transaction_repo, merchant_repo) + MainWindow(root, transaction_repo, merchant_repo, statistics_service) root.focus_force() root.mainloop() diff --git a/expense_tracker/core/transaction_repository.py b/expense_tracker/core/transaction_repository.py index 5c1cd2c..1663010 100644 --- a/expense_tracker/core/transaction_repository.py +++ b/expense_tracker/core/transaction_repository.py @@ -170,18 +170,11 @@ def update_transaction(self, transaction_id: int, data: dict) -> None: self.conn.execute(query, values) self.conn.commit() - def get_daily_spending_for_month(self, year: int, month: int) -> dict[int, float]: + def get_daily_spending_range(self, start_date: date, end_date: date) -> dict[int, float]: """ Returns a dictionary mapping day-of-month (1-31) to total spending. Only includes expenses (negative amounts). """ - # Create date range for the month - start_date = date(year, month, 1) - if month == 12: - end_date = date(year + 1, 1, 1) - else: - end_date = date(year, month + 1, 1) - rows = self.conn.execute( """ SELECT CAST(strftime('%d', date) AS INTEGER) as day, @@ -224,18 +217,12 @@ def get_monthly_cashflow_trend(self, num_months: int) -> list[tuple[int, int, fl result.append((row["year"], row["month"], row["net_amount"])) return result - def get_monthly_net_income(self, year: int, month: int) -> float: + def get_monthly_net_income(self, start_date: date, end_date: date) -> float: """ Returns the net income (total income minus total expenses) for a specific month. Positive amount means more income than expenses, negative means more expenses than income. """ # Create date range for the month - start_date = date(year, month, 1) - if month == 12: - end_date = date(year + 1, 1, 1) - else: - end_date = date(year, month + 1, 1) - row = self.conn.execute( """ SELECT SUM(amount) as net_income @@ -247,18 +234,11 @@ def get_monthly_net_income(self, year: int, month: int) -> float: result = row.fetchone() return result["net_income"] if result["net_income"] is not None else 0.0 - def get_top_spending_category(self, year: int, month: int) -> tuple[str, float] | None: + def get_top_spending_category(self, start_date: date, end_date: date) -> tuple[str, float] | None: """ Returns the category with the highest spending (sum of negative amounts) for a specific month. Returns tuple of (category_name, total_spending) or None if no expenses exist. """ - # Create date range for the month - start_date = date(year, month, 1) - if month == 12: - end_date = date(year + 1, 1, 1) - else: - end_date = date(year, month + 1, 1) - rows = self.conn.execute( """ SELECT category, SUM(ABS(amount)) as total diff --git a/expense_tracker/gui/main_window.py b/expense_tracker/gui/main_window.py index 77d374e..94e385f 100644 --- a/expense_tracker/gui/main_window.py +++ b/expense_tracker/gui/main_window.py @@ -6,10 +6,11 @@ class MainWindow(tk.Frame): - def __init__(self, master, transaction_repo, merchant_repo): + def __init__(self, master, transaction_repo, merchant_repo, statistics_service): super().__init__(master) self.transaction_repo = transaction_repo self.merchant_repo = merchant_repo + self.statistics_service = statistics_service self.master = master self._active_dialog: tk.Toplevel | None = None @@ -25,10 +26,10 @@ def __init__(self, master, transaction_repo, merchant_repo): ) # Create Statistics tab - self.statistics_tab = StatisticsTab(self.notebook, transaction_repo) + self.statistics_tab = StatisticsTab(self.notebook, statistics_service) # Create Heatmap tab - self.heatmap_tab = HeatmapTab(self.notebook, transaction_repo, self) + self.heatmap_tab = HeatmapTab(self.notebook, statistics_service, self) # Add tabs to notebook self.notebook.add(self.transactions_tab, text="Transactions") diff --git a/expense_tracker/gui/tabs/heatmap_tab.py b/expense_tracker/gui/tabs/heatmap_tab.py index fb35516..f3e7565 100644 --- a/expense_tracker/gui/tabs/heatmap_tab.py +++ b/expense_tracker/gui/tabs/heatmap_tab.py @@ -3,13 +3,13 @@ from datetime import date from tkinter import ttk -from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.services.statistics import StatisticsService class HeatmapTab(tk.Frame): - def __init__(self, master, transaction_repo: TransactionRepository, main_window): + def __init__(self, master, statistics_service: StatisticsService, main_window): super().__init__(master) - self.transaction_repo: TransactionRepository = transaction_repo + self.statistics_service = statistics_service self.main_window = main_window # State @@ -89,7 +89,7 @@ def _next_month(self): def refresh(self): """Fetch data and rebuild calendar grid.""" # Get all months with expenses - self._months_with_expenses = self.transaction_repo.get_months_with_expenses() + self._months_with_expenses = self.statistics_service.get_available_months(expenses_only=True) # Reset to most recent month self._current_index = 0 @@ -121,7 +121,7 @@ def _build_calendar_grid(self): year, month = self._months_with_expenses[self._current_index] # Fetch spending data for current month - self._spending_data = self.transaction_repo.get_daily_spending_for_month( + self._spending_data = self.statistics_service.get_spending_heatmap_data( year, month ) diff --git a/expense_tracker/gui/tabs/statistics_tab.py b/expense_tracker/gui/tabs/statistics_tab.py index 4d808fc..39dec67 100644 --- a/expense_tracker/gui/tabs/statistics_tab.py +++ b/expense_tracker/gui/tabs/statistics_tab.py @@ -2,21 +2,21 @@ import tkinter as tk from tkinter import ttk -from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.services.statistics import StatisticsService class StatisticsTab(tk.Frame): - def __init__(self, master, transaction_repo: TransactionRepository): + def __init__(self, master, statistics_service: StatisticsService): super().__init__(master) - self.transaction_repo: TransactionRepository = transaction_repo + self.statistics_service = statistics_service # State: the latest month and year where the record is available - latest_year, latest_month = self.transaction_repo.get_latest_month_with_data() + latest_year, latest_month = self.statistics_service.get_latest_available_month() self._current_year = latest_year self._current_month = latest_month # Cache all months with data for navigation button state management - self._months_with_data = self.transaction_repo.get_all_months_with_data() + self._months_with_data = self.statistics_service.get_available_months() self.pack(fill=tk.BOTH, expand=True) @@ -173,27 +173,25 @@ def _next_month(self): def _update_metrics(self): """Update metric displays with current month data.""" - # Get monthly net income - net_income = self.transaction_repo.get_monthly_net_income( + # Get monthly metrics from statistics service + metrics = self.statistics_service.get_monthly_metrics( self._current_year, self._current_month ) # Format and color code net income - formatted_income = f"${abs(net_income):,.2f}" - if net_income < 0: + formatted_income = f"${abs(metrics.net_income):,.2f}" + if metrics.net_income < 0: formatted_income = f"-{formatted_income}" color = "#ff4444" # Red for negative - elif net_income > 0: + elif metrics.net_income > 0: color = "#44ff44" # Green for positive else: color = "#ffffff" # White for zero self.net_income_label.config(text=formatted_income, fg=color) - # Get top spending category - top_category = self.transaction_repo.get_top_spending_category( - self._current_year, self._current_month - ) + # Display top spending category + top_category = (metrics.top_category, metrics.top_category_spending) if metrics.top_category else None if top_category is None: self.top_category_name_label.config(text="N/A") diff --git a/expense_tracker/services/statistics.py b/expense_tracker/services/statistics.py new file mode 100644 index 0000000..cc52681 --- /dev/null +++ b/expense_tracker/services/statistics.py @@ -0,0 +1,104 @@ +import logging +from typing import NamedTuple +from datetime import date + +from expense_tracker.core.transaction_repository import TransactionRepository + +logger = logging.getLogger(__name__) + + +class MonthlyMetrics(NamedTuple): + """Monthly statistics metrics""" + year: int + month: int + net_income: float + top_category: str | None + top_category_spending: float | None + + +class StatisticsService: + """ + Service layer for transaction statistics and analytics. + Combines repository calls to provide higher-level business logic. + """ + + def __init__(self, transaction_repo: TransactionRepository): + self.transaction_repo = transaction_repo + + def get_monthly_metrics(self, year: int, month: int) -> MonthlyMetrics: + """ + Get comprehensive monthly metrics by combining multiple repository queries. + + Returns: + MonthlyMetrics with net income and top spending category + """ + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + net_income = self.transaction_repo.get_monthly_net_income(start_date, end_date) + top_category_data = self.transaction_repo.get_top_spending_category(start_date, end_date) + + if top_category_data: + top_category, top_spending = top_category_data + else: + top_category, top_spending = None, None + + return MonthlyMetrics( + year=year, + month=month, + net_income=net_income, + top_category=top_category, + top_category_spending=top_spending + ) + + def get_spending_heatmap_data(self, year: int, month: int) -> dict[int, float]: + """ + Get daily spending data formatted for heatmap visualization. + + Returns: + Dictionary mapping day (1-31) to spending amount + """ + # Create date range for the month + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + return self.transaction_repo.get_daily_spending_range(start_date, end_date) + + def get_available_months(self, expenses_only: bool = False) -> list[tuple[int, int]]: + """ + Get list of months with transaction data. + + Args: + expenses_only: If True, only return months with expenses + + Returns: + List of (year, month) tuples + """ + if expenses_only: + return self.transaction_repo.get_months_with_expenses() + return list(self.transaction_repo.get_all_months_with_data()) + + def get_latest_available_month(self) -> tuple[int, int]: + """ + Get the most recent month with transaction data. + + Returns: + Tuple of (year, month) + """ + return self.transaction_repo.get_latest_month_with_data() + + def get_cashflow_trend(self, num_months: int = 6) -> list[tuple[int, int, float]]: + """ + Get cashflow trend for visualization. + + Args: + num_months: Number of months to retrieve (default: 6) + + Returns: + List of (year, month, net_amount) tuples + """ + return self.transaction_repo.get_monthly_cashflow_trend(num_months) diff --git a/tests/core/test_repository.py b/tests/core/test_repository.py index 4b8c642..75d5045 100644 --- a/tests/core/test_repository.py +++ b/tests/core/test_repository.py @@ -528,7 +528,7 @@ def test_get_daily_spending_for_month_with_data(in_memory_repo): ) ) - spending = repo.get_daily_spending_for_month(2023, 1) + spending = repo.get_daily_spending_range(date(2023, 1, 1), date(2023, 2, 1)) # Should only include expenses from January 2023 assert len(spending) == 2 @@ -551,7 +551,7 @@ def test_get_daily_spending_for_month_empty(in_memory_repo): ) # Query for empty month - spending = repo.get_daily_spending_for_month(2023, 3) + spending = repo.get_daily_spending_range(date(2023, 3, 1), date(2023, 4, 1)) assert len(spending) == 0 assert spending == {} @@ -578,7 +578,7 @@ def test_get_daily_spending_excludes_income(in_memory_repo): ) ) - spending = repo.get_daily_spending_for_month(2023, 1) + spending = repo.get_daily_spending_range(date(2023, 1, 1), date(2023, 2, 1)) # Should return empty since only income (no expenses) assert len(spending) == 0 @@ -817,7 +817,7 @@ def test_get_monthly_net_income_with_income_and_expenses(in_memory_repo): ) ) - net_income = repo.get_monthly_net_income(2023, 1) + net_income = repo.get_monthly_net_income(date(2023, 1, 1), date(2023, 2, 1)) # Net income = 1000 + 200 - 50 - 25 = 1125 assert net_income == 1125.0 @@ -845,7 +845,7 @@ def test_get_monthly_net_income_only_expenses(in_memory_repo): ) ) - net_income = repo.get_monthly_net_income(2023, 1) + net_income = repo.get_monthly_net_income(date(2023, 1, 1), date(2023, 2, 1)) # Net income = -100 - 50 = -150 assert net_income == -150.0 @@ -865,7 +865,7 @@ def test_get_monthly_net_income_no_transactions(in_memory_repo): ) # Query for month with no transactions - net_income = repo.get_monthly_net_income(2023, 1) + net_income = repo.get_monthly_net_income(date(2023, 1, 1), date(2023, 2, 1)) # Should return 0.0 for empty month assert net_income == 0.0 @@ -884,7 +884,7 @@ def test_get_monthly_net_income_only_income(in_memory_repo): ) ) - net_income = repo.get_monthly_net_income(2023, 1) + net_income = repo.get_monthly_net_income(date(2023, 1, 1), date(2023, 2, 1)) assert net_income == 2000.0 @@ -929,7 +929,7 @@ def test_get_top_spending_category_with_multiple_categories(in_memory_repo): ) ) - top_category = repo.get_top_spending_category(2023, 1) + top_category = repo.get_top_spending_category(date(2023, 1, 1), date(2023, 2, 1)) # Groceries has highest spending: 100 + 50 = 150 assert top_category is not None @@ -959,7 +959,7 @@ def test_get_top_spending_category_exclude_income(in_memory_repo): ) ) - top_category = repo.get_top_spending_category(2023, 1) + top_category = repo.get_top_spending_category(date(2023, 1, 1), date(2023, 2, 1)) # Should return Food, not Income assert top_category is not None @@ -980,7 +980,7 @@ def test_get_top_spending_category_no_expenses(in_memory_repo): ) ) - top_category = repo.get_top_spending_category(2023, 1) + top_category = repo.get_top_spending_category(date(2023, 1, 1), date(2023, 2, 1)) # Should return None when no expenses exist assert top_category is None @@ -1000,7 +1000,7 @@ def test_get_top_spending_category_empty_month(in_memory_repo): ) # Query for month with no transactions - top_category = repo.get_top_spending_category(2023, 1) + top_category = repo.get_top_spending_category(date(2023, 1, 1), date(2023, 2, 1)) # Should return None assert top_category is None diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/test_statistics.py b/tests/services/test_statistics.py new file mode 100644 index 0000000..82e12c1 --- /dev/null +++ b/tests/services/test_statistics.py @@ -0,0 +1,304 @@ +from datetime import date + +import pytest + +from expense_tracker.core.models import Transaction +from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.services.statistics import StatisticsService, MonthlyMetrics + + +@pytest.fixture +def in_memory_repo(): + """Provides an in-memory TransactionRepository for testing.""" + repo = TransactionRepository(":memory:") + yield repo + repo.conn.close() + + +@pytest.fixture +def statistics_service(in_memory_repo): + """Provides a StatisticsService with an in-memory repository.""" + return StatisticsService(in_memory_repo) + + +def test_get_monthly_metrics_with_data(in_memory_repo, statistics_service): + """Test get_monthly_metrics returns correct combined data.""" + # Add income and expenses for January 2023 + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=2000.0, # Income + category="Income", + description="Salary", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 10), + amount=-500.0, # Expense - Groceries + category="Groceries", + description="Whole Foods", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 15), + amount=-300.0, # Expense - Groceries + category="Groceries", + description="Trader Joes", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 20), + amount=-100.0, # Expense - Restaurants + category="Restaurants", + description="Dinner", + ) + ) + + metrics = statistics_service.get_monthly_metrics(2023, 1) + + assert isinstance(metrics, MonthlyMetrics) + assert metrics.year == 2023 + assert metrics.month == 1 + assert metrics.net_income == 1100.0 # 2000 - 500 - 300 - 100 + assert metrics.top_category == "Groceries" + assert metrics.top_category_spending == 800.0 # 500 + 300 + + +def test_get_monthly_metrics_no_data(statistics_service): + """Test get_monthly_metrics with no transactions.""" + metrics = statistics_service.get_monthly_metrics(2023, 1) + + assert metrics.year == 2023 + assert metrics.month == 1 + assert metrics.net_income == 0.0 + assert metrics.top_category is None + assert metrics.top_category_spending is None + + +def test_get_monthly_metrics_only_income(in_memory_repo, statistics_service): + """Test get_monthly_metrics with only income (no expenses).""" + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=1500.0, + category="Income", + description="Salary", + ) + ) + + metrics = statistics_service.get_monthly_metrics(2023, 1) + + assert metrics.net_income == 1500.0 + assert metrics.top_category is None # No expenses + assert metrics.top_category_spending is None + + +def test_get_spending_heatmap_data(in_memory_repo, statistics_service): + """Test get_spending_heatmap_data returns daily spending.""" + # Add expenses for January 2023 + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Food", + description="Lunch", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-30.0, + category="Transport", + description="Taxi", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 15), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + heatmap_data = statistics_service.get_spending_heatmap_data(2023, 1) + + assert isinstance(heatmap_data, dict) + assert heatmap_data[5] == 80.0 # 50 + 30 + assert heatmap_data[15] == 100.0 + assert 10 not in heatmap_data + + +def test_get_available_months_all(in_memory_repo, statistics_service): + """Test get_available_months returns all months with transactions.""" + # Add transactions for different months + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 2, 10), + amount=1000.0, # Income only + category="Income", + description="Salary", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 3, 15), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + months = statistics_service.get_available_months(expenses_only=False) + + assert len(months) == 3 + assert (2023, 1) in months + assert (2023, 2) in months + assert (2023, 3) in months + + +def test_get_available_months_expenses_only(in_memory_repo, statistics_service): + """Test get_available_months with expenses_only filter.""" + # Add transactions for different months + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 2, 10), + amount=1000.0, # Income only - should be excluded + category="Income", + description="Salary", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 3, 15), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + months = statistics_service.get_available_months(expenses_only=True) + + assert len(months) == 2 + assert (2023, 1) in months + assert (2023, 2) not in months # Only income + assert (2023, 3) in months + + +def test_get_latest_available_month(in_memory_repo, statistics_service): + """Test get_latest_available_month returns most recent month.""" + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 3, 15), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + year, month = statistics_service.get_latest_available_month() + + assert year == 2023 + assert month == 3 + + +def test_get_latest_available_month_empty(statistics_service): + """Test get_latest_available_month with no transactions defaults to current month.""" + year, month = statistics_service.get_latest_available_month() + + # Should default to current month + today = date.today() + assert year == today.year + assert month == today.month + + +def test_get_cashflow_trend(in_memory_repo, statistics_service): + """Test get_cashflow_trend returns monthly net amounts.""" + # Add transactions for multiple months + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 10), + amount=-500.0, + category="Food", + description="Groceries", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 2, 5), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 2, 10), + amount=-800.0, + category="Shopping", + description="Clothes", + ) + ) + + trend = statistics_service.get_cashflow_trend(num_months=2) + + assert len(trend) == 2 + # Check January + assert trend[0] == (2023, 1, 1500.0) # 2000 - 500 + # Check February + assert trend[1] == (2023, 2, 1200.0) # 2000 - 800 From 6f1c03be92d2ebd767ac15ec49d36b46c58f964c Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 22:04:10 -0800 Subject: [PATCH 7/8] refactor: extract helper function --- expense_tracker/services/statistics.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/expense_tracker/services/statistics.py b/expense_tracker/services/statistics.py index cc52681..7daac5d 100644 --- a/expense_tracker/services/statistics.py +++ b/expense_tracker/services/statistics.py @@ -25,6 +25,16 @@ class StatisticsService: def __init__(self, transaction_repo: TransactionRepository): self.transaction_repo = transaction_repo + @staticmethod + def _get_month_date_range(year: int, month: int) -> tuple[date, date]: + """Helper to get start and end date for a given month.""" + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + return start_date, end_date + def get_monthly_metrics(self, year: int, month: int) -> MonthlyMetrics: """ Get comprehensive monthly metrics by combining multiple repository queries. @@ -32,11 +42,7 @@ def get_monthly_metrics(self, year: int, month: int) -> MonthlyMetrics: Returns: MonthlyMetrics with net income and top spending category """ - start_date = date(year, month, 1) - if month == 12: - end_date = date(year + 1, 1, 1) - else: - end_date = date(year, month + 1, 1) + start_date, end_date = self._get_month_date_range(year, month) net_income = self.transaction_repo.get_monthly_net_income(start_date, end_date) top_category_data = self.transaction_repo.get_top_spending_category(start_date, end_date) @@ -61,11 +67,7 @@ def get_spending_heatmap_data(self, year: int, month: int) -> dict[int, float]: Dictionary mapping day (1-31) to spending amount """ # Create date range for the month - start_date = date(year, month, 1) - if month == 12: - end_date = date(year + 1, 1, 1) - else: - end_date = date(year, month + 1, 1) + start_date, end_date = self._get_month_date_range(year, month) return self.transaction_repo.get_daily_spending_range(start_date, end_date) def get_available_months(self, expenses_only: bool = False) -> list[tuple[int, int]]: From b2fcb6b1472c5114ce74bc282d6f949a43fd12f4 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Thu, 4 Dec 2025 22:08:42 -0800 Subject: [PATCH 8/8] add openspec --- .../add-category-spending-chart/proposal.md | 25 +++++ .../category-breakdown-visualization/spec.md | 97 +++++++++++++++++++ .../add-category-spending-chart/tasks.md | 28 ++++++ .../add-monthly-cashflow-chart/proposal.md | 26 +++++ .../specs/cashflow-visualization/spec.md | 88 +++++++++++++++++ .../add-monthly-cashflow-chart/tasks.md | 25 +++++ .../proposal.md | 26 +++++ .../specs/statistics-metrics/spec.md | 88 +++++++++++++++++ .../tasks.md | 25 +++++ openspec/specs/statistics-metrics/spec.md | 90 +++++++++++++++++ 10 files changed, 518 insertions(+) create mode 100644 openspec/changes/add-category-spending-chart/proposal.md create mode 100644 openspec/changes/add-category-spending-chart/specs/category-breakdown-visualization/spec.md create mode 100644 openspec/changes/add-category-spending-chart/tasks.md create mode 100644 openspec/changes/add-monthly-cashflow-chart/proposal.md create mode 100644 openspec/changes/add-monthly-cashflow-chart/specs/cashflow-visualization/spec.md create mode 100644 openspec/changes/add-monthly-cashflow-chart/tasks.md create mode 100644 openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/proposal.md create mode 100644 openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/specs/statistics-metrics/spec.md create mode 100644 openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/tasks.md create mode 100644 openspec/specs/statistics-metrics/spec.md diff --git a/openspec/changes/add-category-spending-chart/proposal.md b/openspec/changes/add-category-spending-chart/proposal.md new file mode 100644 index 0000000..96c2b30 --- /dev/null +++ b/openspec/changes/add-category-spending-chart/proposal.md @@ -0,0 +1,25 @@ +# Proposal: Add Monthly Spending by Category Chart + +## Why +Users need to understand how their spending is distributed across different categories to identify areas for potential savings. A bar chart showing spending breakdown by category provides clear visual insight into spending priorities and helps users make informed budget decisions. + +## What Changes +- Add a horizontal bar chart to the Statistics tab showing spending breakdown by category for the selected month +- Display all expense categories with their total spending amounts +- Bars sorted by spending amount (highest to lowest) +- Each bar labeled with category name and dollar amount +- Use color-coded bars for visual distinction between categories +- Add repository method to fetch category spending aggregation for a specific month +- Chart updates when Statistics tab is refreshed or month navigation is used + +## Impact +- **Affected specs**: New capability `category-breakdown-visualization` +- **Affected code**: + - Modified: `expense_tracker/gui/tabs/statistics_tab.py` (add bar chart rendering) + - Modified: `expense_tracker/core/repositories.py` (add `get_category_spending_breakdown()` method) +- **Dependencies**: + - **REQUIRES**: `add-statistics-tab-with-metrics` must be implemented first + - Uses standard Tkinter Canvas (no new external dependencies) +- **User Impact**: Enhanced Statistics tab with category spending breakdown +- **No Breaking Changes**: Extends existing Statistics tab without affecting other functionality +- **Performance**: Minimal impact - single SQL query with GROUP BY on category field diff --git a/openspec/changes/add-category-spending-chart/specs/category-breakdown-visualization/spec.md b/openspec/changes/add-category-spending-chart/specs/category-breakdown-visualization/spec.md new file mode 100644 index 0000000..652f78b --- /dev/null +++ b/openspec/changes/add-category-spending-chart/specs/category-breakdown-visualization/spec.md @@ -0,0 +1,97 @@ +# Category Spending Breakdown Visualization Specification + +## ADDED Requirements + +### Requirement: Category Spending Aggregation +The system SHALL calculate total spending for each expense category in a specified month, returning results sorted by spending amount in descending order. + +#### Scenario: Calculate spending breakdown for month with multiple categories +- **GIVEN** a month has expenses: Groceries [-150.00, -50.00], Restaurants [-75.00], Transportation [-25.00] +- **WHEN** category spending breakdown is requested +- **THEN** the system returns [("Groceries", 200.00), ("Restaurants", 75.00), ("Transportation", 25.00)] + +#### Scenario: Exclude income transactions +- **GIVEN** a month has transactions: Groceries [-100.00], Salary [2000.00], Restaurants [-50.00] +- **WHEN** category spending breakdown is requested +- **THEN** the system returns [("Groceries", 100.00), ("Restaurants", 50.00)] and excludes Salary + +#### Scenario: Handle month with no expenses +- **GIVEN** a month has no expense transactions +- **WHEN** category spending breakdown is requested +- **THEN** the system returns an empty list + +#### Scenario: Handle uncategorized expenses +- **GIVEN** a month has expenses with category "Uncategorized" [-100.00] +- **WHEN** category spending breakdown is requested +- **THEN** the system includes "Uncategorized" in the results + +### Requirement: Horizontal Bar Chart Rendering +The system SHALL render a horizontal bar chart displaying category spending breakdown with labels and amounts. + +#### Scenario: Render bar chart with multiple categories +- **GIVEN** category spending data contains multiple categories with varying amounts +- **WHEN** bar chart is rendered +- **THEN** the system displays horizontal bars: + - Category name label on the left + - Bar width proportional to spending amount + - Dollar amount displayed at the end of each bar + - Bars sorted from highest to lowest spending + +#### Scenario: Color code category bars +- **GIVEN** bar chart is rendered with multiple categories +- **WHEN** displaying bars +- **THEN** each category uses a distinct color for visual differentiation + +#### Scenario: Handle edge case with no expenses +- **GIVEN** selected month has no expenses +- **WHEN** bar chart rendering is requested +- **THEN** the system displays a message "No expenses for this month" + +### Requirement: Bar Scaling and Layout +The system SHALL scale bar widths proportionally based on spending amounts to ensure proper visual representation. + +#### Scenario: Scale bars relative to maximum spending +- **GIVEN** category spending values are [200.00, 100.00, 50.00] +- **WHEN** bars are rendered +- **THEN** the largest bar (200.00) uses full available width and others scale proportionally + +#### Scenario: Format amounts as currency +- **GIVEN** category spending amounts are displayed on bars +- **WHEN** rendering amount labels +- **THEN** values are formatted with dollar signs and two decimal places (e.g., "$150.00") + +#### Scenario: Maintain minimum bar width for small amounts +- **GIVEN** a category has very small spending compared to others +- **WHEN** bar is rendered +- **THEN** bar maintains a minimum visible width to show the category exists + +### Requirement: Chart Integration in Statistics Tab +The system SHALL display the category spending bar chart in the Statistics tab, updating based on the selected month. + +#### Scenario: Display chart on tab load +- **GIVEN** user switches to Statistics tab +- **WHEN** the tab refreshes +- **THEN** the category chart displays spending breakdown for the current selected month + +#### Scenario: Update chart with month navigation +- **GIVEN** user is viewing Statistics tab for November 2025 +- **WHEN** user navigates to December 2025 +- **THEN** the category chart updates to show December's spending breakdown + +#### Scenario: Chart coexists with other visualizations +- **GIVEN** Statistics tab contains multiple charts and metrics +- **WHEN** tab is displayed +- **THEN** category chart renders in its designated area without overlapping other content + +### Requirement: Chart Scrolling for Many Categories +The system SHALL provide scrolling capability when the number of categories exceeds the visible area. + +#### Scenario: Display scrollbar for many categories +- **GIVEN** selected month has more than 10 expense categories +- **WHEN** bar chart is rendered +- **THEN** a vertical scrollbar appears allowing user to view all categories + +#### Scenario: Maintain chart readability with scrolling +- **GIVEN** category chart has scrolling enabled +- **WHEN** user scrolls through categories +- **THEN** category labels, bars, and amounts remain aligned and readable diff --git a/openspec/changes/add-category-spending-chart/tasks.md b/openspec/changes/add-category-spending-chart/tasks.md new file mode 100644 index 0000000..63843cd --- /dev/null +++ b/openspec/changes/add-category-spending-chart/tasks.md @@ -0,0 +1,28 @@ +# Implementation Tasks + +## 1. Repository Layer +- [ ] 1.1 Add `get_category_spending_breakdown(year: int, month: int) -> list[tuple[str, float]]` method to TransactionRepository +- [ ] 1.2 Method returns list of (category, total_spending) tuples sorted by amount descending +- [ ] 1.3 Only include expenses (negative amounts), exclude income transactions +- [ ] 1.4 Write unit tests for category breakdown calculation + +## 2. Chart Rendering +- [ ] 2.1 Create `_render_category_chart()` method in StatisticsTab +- [ ] 2.2 Implement horizontal bar chart layout with category labels and bars +- [ ] 2.3 Add proper scaling for bar widths based on max spending value +- [ ] 2.4 Display spending amount at the end of each bar (formatted as currency) +- [ ] 2.5 Use distinct colors for bars to improve visual differentiation +- [ ] 2.6 Handle edge case: display message when no expenses exist for the month + +## 3. Integration with Statistics Tab +- [ ] 3.1 Add Canvas widget to StatisticsTab layout for category chart +- [ ] 3.2 Update `refresh()` method to redraw category chart based on selected month +- [ ] 3.3 Ensure chart layout works well with other visualizations on the tab +- [ ] 3.4 Add scrolling if many categories exist (more than can fit in visible area) + +## 4. Testing and Validation +- [ ] 4.1 Run pytest to ensure all tests pass +- [ ] 4.2 Manual testing: verify chart displays correct category breakdown +- [ ] 4.3 Manual testing: verify chart handles edge cases (no expenses, single category, many categories) +- [ ] 4.4 Manual testing: verify chart updates correctly with month navigation +- [ ] 4.5 Run ruff linter and fix any issues diff --git a/openspec/changes/add-monthly-cashflow-chart/proposal.md b/openspec/changes/add-monthly-cashflow-chart/proposal.md new file mode 100644 index 0000000..9035816 --- /dev/null +++ b/openspec/changes/add-monthly-cashflow-chart/proposal.md @@ -0,0 +1,26 @@ +# Proposal: Add Monthly Net Cash Flow Trend Chart + +## Why +Users need to visualize their financial trends over time to identify patterns and make informed decisions. A line chart showing monthly net cash flow (income minus expenses) provides a clear view of whether finances are improving or declining over multiple months. + +## What Changes +- Add a line chart to the Statistics tab showing monthly net cash flow trend +- Display net income (income - expenses) for the last 12 months +- X-axis shows month labels (e.g., "Jan 2025", "Feb 2025") +- Y-axis shows dollar amounts with proper scaling +- Use Tkinter Canvas for drawing the chart (no external charting library required) +- Include horizontal gridlines and a zero-line indicator +- Add repository method to fetch monthly net income data for multiple months +- Chart updates when Statistics tab is refreshed + +## Impact +- **Affected specs**: New capability `cashflow-visualization` +- **Affected code**: + - Modified: `expense_tracker/gui/tabs/statistics_tab.py` (add chart rendering) + - Modified: `expense_tracker/core/repositories.py` (add `get_monthly_cashflow_trend()` method) +- **Dependencies**: + - **REQUIRES**: `add-statistics-tab-with-metrics` must be implemented first + - Uses standard Tkinter Canvas (no new external dependencies) +- **User Impact**: Enhanced Statistics tab with visual trend analysis +- **No Breaking Changes**: Extends existing Statistics tab without affecting other functionality +- **Performance**: Minimal impact - single SQL query aggregating 12 months of data diff --git a/openspec/changes/add-monthly-cashflow-chart/specs/cashflow-visualization/spec.md b/openspec/changes/add-monthly-cashflow-chart/specs/cashflow-visualization/spec.md new file mode 100644 index 0000000..7504a75 --- /dev/null +++ b/openspec/changes/add-monthly-cashflow-chart/specs/cashflow-visualization/spec.md @@ -0,0 +1,88 @@ +# Monthly Cash Flow Visualization Specification + +## ADDED Requirements + +### Requirement: Monthly Cash Flow Trend Calculation +The system SHALL calculate monthly net cash flow (income minus expenses) for a specified number of months, returning results in chronological order. + +#### Scenario: Calculate trend for 12 months +- **GIVEN** the current month is December 2025 +- **WHEN** monthly cashflow trend for 12 months is requested +- **THEN** the system returns net income values for Jan 2025 through Dec 2025 as list of (year, month, amount) tuples + +#### Scenario: Handle months with no transactions +- **GIVEN** some months in the requested range have no transactions +- **WHEN** monthly cashflow trend is requested +- **THEN** the system returns 0.00 for months with no transactions + +#### Scenario: Calculate trend across year boundary +- **GIVEN** the current month is February 2026 and trend for 12 months is requested +- **WHEN** monthly cashflow trend is calculated +- **THEN** the system returns data from March 2025 through February 2026 + +### Requirement: Line Chart Rendering +The system SHALL render a line chart displaying monthly net cash flow trend with proper axes, labels, and gridlines. + +#### Scenario: Render chart with positive and negative values +- **GIVEN** monthly cashflow data contains both positive and negative values +- **WHEN** chart is rendered +- **THEN** the system displays a line chart with: + - X-axis showing month labels (e.g., "Jan 2025") + - Y-axis showing dollar amounts + - Data points connected by lines + - Horizontal gridlines for readability + - Zero-line highlighted for reference + +#### Scenario: Color code data points +- **GIVEN** monthly cashflow data is displayed +- **WHEN** rendering data points and lines +- **THEN** positive values are displayed in green and negative values in red + +#### Scenario: Handle edge case with all zero values +- **GIVEN** all months have zero net income +- **WHEN** chart is rendered +- **THEN** the system displays a flat line at zero with appropriate axes + +### Requirement: Axis Scaling +The system SHALL automatically scale chart axes based on the data range to ensure all values are visible and readable. + +#### Scenario: Scale Y-axis for large values +- **GIVEN** monthly cashflow values range from -5000 to 10000 +- **WHEN** chart is rendered +- **THEN** Y-axis scales to include both min and max values with appropriate padding + +#### Scenario: Format Y-axis labels as currency +- **GIVEN** chart is rendering Y-axis labels +- **WHEN** axis labels are displayed +- **THEN** values are formatted with dollar signs (e.g., "$1000", "-$500") + +#### Scenario: Format X-axis as month/year +- **GIVEN** chart is rendering X-axis labels +- **WHEN** axis labels are displayed +- **THEN** months are formatted as "MMM YYYY" (e.g., "Jan 2025", "Feb 2025") + +### Requirement: Chart Integration in Statistics Tab +The system SHALL display the monthly cashflow chart in the Statistics tab below the metrics cards. + +#### Scenario: Display chart on tab load +- **GIVEN** user switches to Statistics tab +- **WHEN** the tab refreshes +- **THEN** the cashflow chart is rendered with current data + +#### Scenario: Chart updates with month navigation +- **GIVEN** user navigates to a different month using navigation controls +- **WHEN** the refresh occurs +- **THEN** the chart recalculates the 12-month window relative to the selected month + +### Requirement: Chart Responsiveness +The system SHALL ensure the chart renders properly within the available space and maintains readability. + +#### Scenario: Fit chart in available space +- **GIVEN** Statistics tab is displayed +- **WHEN** chart is rendered +- **THEN** chart fits within the tab's content area without horizontal scrolling + +#### Scenario: Maintain minimum readability +- **GIVEN** chart is rendered in limited space +- **WHEN** window is resized +- **THEN** chart maintains minimum readable size for labels and data points diff --git a/openspec/changes/add-monthly-cashflow-chart/tasks.md b/openspec/changes/add-monthly-cashflow-chart/tasks.md new file mode 100644 index 0000000..030c9ab --- /dev/null +++ b/openspec/changes/add-monthly-cashflow-chart/tasks.md @@ -0,0 +1,25 @@ +# Implementation Tasks + +## 1. Repository Layer +- [ ] 1.1 Add `get_monthly_cashflow_trend(num_months: int = 12) -> list[tuple[int, int, float]]` method to TransactionRepository +- [ ] 1.2 Method returns list of (year, month, net_income) tuples for the last N months +- [ ] 1.3 Write unit tests for cashflow trend calculation + +## 2. Chart Rendering +- [ ] 2.1 Create `_render_cashflow_chart()` method in StatisticsTab +- [ ] 2.2 Implement chart layout: title, axes, gridlines, data points, and connecting lines +- [ ] 2.3 Add proper scaling for Y-axis based on min/max values +- [ ] 2.4 Format X-axis labels as "MMM YYYY" (e.g., "Jan 2025") +- [ ] 2.5 Add horizontal zero-line indicator for visual reference +- [ ] 2.6 Use color coding: positive values in green, negative values in red + +## 3. Integration with Statistics Tab +- [ ] 3.1 Add Canvas widget to StatisticsTab layout below metrics cards +- [ ] 3.2 Update `refresh()` method to redraw chart when tab is activated +- [ ] 3.3 Ensure chart resizes properly with window + +## 4. Testing and Validation +- [ ] 4.1 Run pytest to ensure all tests pass +- [ ] 4.2 Manual testing: verify chart displays correct trend over 12 months +- [ ] 4.3 Manual testing: verify chart handles edge cases (no data, all negative, all positive) +- [ ] 4.4 Run ruff linter and fix any issues diff --git a/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/proposal.md b/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/proposal.md new file mode 100644 index 0000000..35003fb --- /dev/null +++ b/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/proposal.md @@ -0,0 +1,26 @@ +# Proposal: Add Statistics Tab with Key Metrics + +## Why +Users need a quick overview of their financial health without diving into detailed transactions. A statistics dashboard showing key metrics like monthly net income and top spending category provides immediate insights into spending patterns and financial trends. + +## What Changes +- Add new "Statistics" tab to the main window's tabbed interface +- Display key metrics in a clean, card-based layout: + - **Monthly Net Income**: Shows total income minus expenses for the current month + - **Top Spending Category**: Identifies which category has the highest spending this month +- Add repository methods to calculate monthly aggregated statistics +- Implement month navigation controls (previous/next month) to view historical metrics +- Use lazy loading pattern: statistics refresh when tab becomes active +- Create `StatisticsTab` component in `expense_tracker/gui/tabs/statistics_tab.py` + +## Impact +- **Affected specs**: New capability `statistics-metrics` +- **Affected code**: + - New: `expense_tracker/gui/tabs/statistics_tab.py` (Statistics tab UI component) + - Modified: `expense_tracker/gui/main_window.py` (add Statistics tab to notebook) + - Modified: `expense_tracker/gui/tabs/__init__.py` (export StatisticsTab) + - Modified: `expense_tracker/core/repositories.py` (add aggregation methods) +- **User Impact**: New Statistics tab provides quick financial overview +- **No Breaking Changes**: All existing functionality remains unchanged +- **Dependencies**: Builds on existing tabbed interface (ttk.Notebook) +- **Performance**: Minimal impact - simple SQL aggregation queries with lazy loading diff --git a/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/specs/statistics-metrics/spec.md b/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/specs/statistics-metrics/spec.md new file mode 100644 index 0000000..a69ec0a --- /dev/null +++ b/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/specs/statistics-metrics/spec.md @@ -0,0 +1,88 @@ +# Statistics Metrics Specification + +## ADDED Requirements + +### Requirement: Monthly Net Income Calculation +The system SHALL calculate and display monthly net income by summing all income (positive amounts) and subtracting all expenses (negative amounts) for a specified month. + +#### Scenario: Calculate net income for month with both income and expenses +- **GIVEN** a month has transactions with amounts [100.00, -50.00, -25.00, 200.00] +- **WHEN** monthly net income is calculated +- **THEN** the system returns 225.00 (100 + 200 - 50 - 25) + +#### Scenario: Calculate net income for month with only expenses +- **GIVEN** a month has only expense transactions [-100.00, -50.00] +- **WHEN** monthly net income is calculated +- **THEN** the system returns -150.00 + +#### Scenario: Calculate net income for month with no transactions +- **GIVEN** a month has no transactions +- **WHEN** monthly net income is calculated +- **THEN** the system returns 0.00 + +### Requirement: Top Spending Category Identification +The system SHALL identify and display the category with the highest total spending (sum of negative amounts) for a specified month. + +#### Scenario: Identify top spending category +- **GIVEN** a month has expenses: Groceries [-100.00, -50.00], Restaurants [-75.00], Transportation [-25.00] +- **WHEN** top spending category is requested +- **THEN** the system returns ("Groceries", 150.00) + +#### Scenario: Handle month with no expenses +- **GIVEN** a month has no expense transactions or only income transactions +- **WHEN** top spending category is requested +- **THEN** the system returns None + +#### Scenario: Handle tie between categories +- **GIVEN** a month has expenses: Groceries [-100.00], Restaurants [-100.00] +- **WHEN** top spending category is requested +- **THEN** the system returns one of the tied categories (deterministic ordering by category name) + +### Requirement: Statistics Tab Display +The system SHALL provide a Statistics tab in the main window that displays monthly financial metrics in a card-based layout. + +#### Scenario: Display statistics for current month +- **GIVEN** user opens the Statistics tab +- **WHEN** the tab loads +- **THEN** the system displays net income and top spending category for the current month + +#### Scenario: Navigate to previous month +- **GIVEN** user is viewing statistics for November 2025 +- **WHEN** user clicks the "Previous" button +- **THEN** the system displays statistics for October 2025 + +#### Scenario: Navigate to next month +- **GIVEN** user is viewing statistics for November 2025 +- **WHEN** user clicks the "Next" button +- **THEN** the system displays statistics for December 2025 + +### Requirement: Lazy Loading for Statistics Tab +The system SHALL refresh statistics data only when the Statistics tab becomes active, not continuously. + +#### Scenario: Statistics refresh on tab activation +- **GIVEN** user is viewing the Transactions tab +- **WHEN** user switches to the Statistics tab +- **THEN** the system queries the database and refreshes displayed metrics + +#### Scenario: No refresh when tab is inactive +- **GIVEN** user is viewing the Transactions tab +- **WHEN** transactions are added or modified +- **THEN** the Statistics tab does not refresh until user switches to it + +### Requirement: Currency Formatting +The system SHALL display monetary values with proper currency formatting and color coding based on positive/negative values. + +#### Scenario: Display positive net income +- **GIVEN** monthly net income is 500.00 +- **WHEN** displayed in the Statistics tab +- **THEN** the value is shown as "$500.00" in green color + +#### Scenario: Display negative net income +- **GIVEN** monthly net income is -150.00 +- **WHEN** displayed in the Statistics tab +- **THEN** the value is shown as "-$150.00" in red color + +#### Scenario: Display zero net income +- **GIVEN** monthly net income is 0.00 +- **WHEN** displayed in the Statistics tab +- **THEN** the value is shown as "$0.00" in default color diff --git a/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/tasks.md b/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/tasks.md new file mode 100644 index 0000000..4a82199 --- /dev/null +++ b/openspec/changes/archive/2025-12-05-add-statistics-tab-with-metrics/tasks.md @@ -0,0 +1,25 @@ +# Implementation Tasks + +## 1. Repository Layer +- [x] 1.1 Add `get_monthly_net_income(year: int, month: int) -> float` method to TransactionRepository +- [x] 1.2 Add `get_top_spending_category(year: int, month: int) -> tuple[str, float] | None` method to TransactionRepository +- [x] 1.3 Write unit tests for new repository methods + +## 2. Statistics Tab UI +- [x] 2.1 Create `StatisticsTab` class in `expense_tracker/gui/tabs/statistics_tab.py` +- [x] 2.2 Implement card-based layout for displaying metrics +- [x] 2.3 Add month navigation controls (previous/next buttons, current month label) +- [x] 2.4 Implement `refresh()` method to reload statistics when tab is activated +- [x] 2.5 Add proper formatting for currency values (negative in red, positive in green) + +## 3. Integration +- [x] 3.1 Export `StatisticsTab` from `expense_tracker/gui/tabs/__init__.py` +- [x] 3.2 Update `MainWindow` to create and add Statistics tab to notebook +- [x] 3.3 Update `_on_tab_changed()` in MainWindow to refresh Statistics tab when selected +- [x] 3.4 Test tab switching and lazy loading behavior + +## 4. Testing and Validation +- [x] 4.1 Run pytest to ensure all tests pass +- [x] 4.2 Manual testing: verify metrics calculations are correct +- [x] 4.3 Manual testing: verify month navigation works properly +- [x] 4.4 Run ruff linter and fix any issues diff --git a/openspec/specs/statistics-metrics/spec.md b/openspec/specs/statistics-metrics/spec.md new file mode 100644 index 0000000..42d0cda --- /dev/null +++ b/openspec/specs/statistics-metrics/spec.md @@ -0,0 +1,90 @@ +# statistics-metrics Specification + +## Purpose +TBD - created by archiving change add-statistics-tab-with-metrics. Update Purpose after archive. +## Requirements +### Requirement: Monthly Net Income Calculation +The system SHALL calculate and display monthly net income by summing all income (positive amounts) and subtracting all expenses (negative amounts) for a specified month. + +#### Scenario: Calculate net income for month with both income and expenses +- **GIVEN** a month has transactions with amounts [100.00, -50.00, -25.00, 200.00] +- **WHEN** monthly net income is calculated +- **THEN** the system returns 225.00 (100 + 200 - 50 - 25) + +#### Scenario: Calculate net income for month with only expenses +- **GIVEN** a month has only expense transactions [-100.00, -50.00] +- **WHEN** monthly net income is calculated +- **THEN** the system returns -150.00 + +#### Scenario: Calculate net income for month with no transactions +- **GIVEN** a month has no transactions +- **WHEN** monthly net income is calculated +- **THEN** the system returns 0.00 + +### Requirement: Top Spending Category Identification +The system SHALL identify and display the category with the highest total spending (sum of negative amounts) for a specified month. + +#### Scenario: Identify top spending category +- **GIVEN** a month has expenses: Groceries [-100.00, -50.00], Restaurants [-75.00], Transportation [-25.00] +- **WHEN** top spending category is requested +- **THEN** the system returns ("Groceries", 150.00) + +#### Scenario: Handle month with no expenses +- **GIVEN** a month has no expense transactions or only income transactions +- **WHEN** top spending category is requested +- **THEN** the system returns None + +#### Scenario: Handle tie between categories +- **GIVEN** a month has expenses: Groceries [-100.00], Restaurants [-100.00] +- **WHEN** top spending category is requested +- **THEN** the system returns one of the tied categories (deterministic ordering by category name) + +### Requirement: Statistics Tab Display +The system SHALL provide a Statistics tab in the main window that displays monthly financial metrics in a card-based layout. + +#### Scenario: Display statistics for current month +- **GIVEN** user opens the Statistics tab +- **WHEN** the tab loads +- **THEN** the system displays net income and top spending category for the current month + +#### Scenario: Navigate to previous month +- **GIVEN** user is viewing statistics for November 2025 +- **WHEN** user clicks the "Previous" button +- **THEN** the system displays statistics for October 2025 + +#### Scenario: Navigate to next month +- **GIVEN** user is viewing statistics for November 2025 +- **WHEN** user clicks the "Next" button +- **THEN** the system displays statistics for December 2025 + +### Requirement: Lazy Loading for Statistics Tab +The system SHALL refresh statistics data only when the Statistics tab becomes active, not continuously. + +#### Scenario: Statistics refresh on tab activation +- **GIVEN** user is viewing the Transactions tab +- **WHEN** user switches to the Statistics tab +- **THEN** the system queries the database and refreshes displayed metrics + +#### Scenario: No refresh when tab is inactive +- **GIVEN** user is viewing the Transactions tab +- **WHEN** transactions are added or modified +- **THEN** the Statistics tab does not refresh until user switches to it + +### Requirement: Currency Formatting +The system SHALL display monetary values with proper currency formatting and color coding based on positive/negative values. + +#### Scenario: Display positive net income +- **GIVEN** monthly net income is 500.00 +- **WHEN** displayed in the Statistics tab +- **THEN** the value is shown as "$500.00" in green color + +#### Scenario: Display negative net income +- **GIVEN** monthly net income is -150.00 +- **WHEN** displayed in the Statistics tab +- **THEN** the value is shown as "-$150.00" in red color + +#### Scenario: Display zero net income +- **GIVEN** monthly net income is 0.00 +- **WHEN** displayed in the Statistics tab +- **THEN** the value is shown as "$0.00" in default color +