From 4baa5066e9da84fc5e3fc52673b20b4c6486634c Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Sat, 14 Feb 2026 12:32:28 -0800 Subject: [PATCH 1/2] add bar chart for category spending --- .../core/transaction_repository.py | 18 ++ expense_tracker/gui/tabs/statistics_tab.py | 166 +++++++++++++++++- expense_tracker/services/statistics.py | 10 ++ tests/core/test_repository.py | 110 ++++++++++++ tests/services/test_statistics.py | 71 ++++++++ 5 files changed, 371 insertions(+), 4 deletions(-) diff --git a/expense_tracker/core/transaction_repository.py b/expense_tracker/core/transaction_repository.py index f3cc75f..a04a22d 100644 --- a/expense_tracker/core/transaction_repository.py +++ b/expense_tracker/core/transaction_repository.py @@ -381,6 +381,24 @@ def transaction_exists(self, transaction: Transaction) -> bool: ) return row.fetchone() is not None + def get_spending_by_category(self, start_date: date, end_date: date) -> list[tuple[str, float]]: + """ + Returns all categories with their total spending, sorted descending by amount. + Only includes expenses (negative amounts). + """ + 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 + """, + (start_date.isoformat(), end_date.isoformat()), + ) + return [(row["category"], row["total"]) for row in rows.fetchall()] + def get_total_expense(self, start_date: date, end_date: date) -> float: """ Get total expenses for a given month. diff --git a/expense_tracker/gui/tabs/statistics_tab.py b/expense_tracker/gui/tabs/statistics_tab.py index ddc0402..8e9d907 100644 --- a/expense_tracker/gui/tabs/statistics_tab.py +++ b/expense_tracker/gui/tabs/statistics_tab.py @@ -20,13 +20,73 @@ def __init__(self, master, statistics_service: StatisticsService): self.pack(fill=tk.BOTH, expand=True) - # Build UI + # Color palette for bar chart + self._bar_colors = [ + "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", + "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac", + ] + + # Build scrollable container + self._build_scroll_container() + + # Build UI inside scrollable content self._build_header() self._build_metrics_cards() + self._build_category_chart() + + def _build_scroll_container(self): + """Build a scrollable container for all page content.""" + self._scroll_canvas = tk.Canvas(self, highlightthickness=0) + self._scrollbar = ttk.Scrollbar( + self, orient=tk.VERTICAL, command=self._scroll_canvas.yview + ) + self._scroll_canvas.configure(yscrollcommand=self._scrollbar.set) + + self._scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self._scroll_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Inner frame that holds all content + self._content_frame = tk.Frame(self._scroll_canvas) + self._canvas_window = self._scroll_canvas.create_window( + (0, 0), window=self._content_frame, anchor="nw" + ) + + # Update scroll region when content changes size + self._content_frame.bind( + "", + lambda e: self._scroll_canvas.configure( + scrollregion=self._scroll_canvas.bbox("all") + ), + ) + + # Make content frame match canvas width + self._scroll_canvas.bind( + "", + self._on_canvas_configure, + ) + + # Bind mousewheel scrolling + self._scroll_canvas.bind("", self._bind_mousewheel) + self._scroll_canvas.bind("", self._unbind_mousewheel) + + def _on_canvas_configure(self, event): + """Keep content frame width in sync with canvas width.""" + self._scroll_canvas.itemconfig(self._canvas_window, width=event.width) + # Redraw chart when width changes + self._draw_category_chart() + + def _bind_mousewheel(self, event): + self._scroll_canvas.bind_all("", self._on_mousewheel) + + def _unbind_mousewheel(self, event): + self._scroll_canvas.unbind_all("") + + def _on_mousewheel(self, event): + self._scroll_canvas.yview_scroll(-1 * (event.delta // 120), "units") def _build_header(self): """Build header with month navigation controls.""" - header = tk.Frame(self) + header = tk.Frame(self._content_frame) header.pack(fill=tk.X, padx=20, pady=15) # Previous month button @@ -50,8 +110,8 @@ def _build_header(self): def _build_metrics_cards(self): """Build card-based layout for displaying metrics (2x3 grid).""" # Container for metrics cards - cards_container = tk.Frame(self) - cards_container.pack(fill=tk.BOTH, expand=True, padx=20, pady=10) + cards_container = tk.Frame(self._content_frame) + cards_container.pack(fill=tk.X, padx=20, pady=10) # Configure 2x3 grid for col in range(3): @@ -206,6 +266,102 @@ def _build_metrics_cards(self): ) self.mom_label.pack(pady=(10, 20)) + def _build_category_chart(self): + """Build the category spending bar chart section below cards.""" + # Section title + tk.Label( + self._content_frame, + text="Spending by Category", + font=("Arial", 16, "bold"), + fg="#ffffff", + anchor="w", + ).pack(fill=tk.X, padx=20, pady=(10, 5)) + + # Chart container + chart_frame = tk.Frame( + self._content_frame, bg="#2b2b2b", relief=tk.RIDGE, borderwidth=2 + ) + chart_frame.pack(fill=tk.X, padx=20, pady=(0, 15)) + + self.chart_canvas = tk.Canvas(chart_frame, bg="#2b2b2b", highlightthickness=0) + self.chart_canvas.pack(fill=tk.X) + + def _draw_category_chart(self): + """Draw horizontal bar chart of spending by category.""" + self.chart_canvas.delete("all") + + canvas_width = self._scroll_canvas.winfo_width() - 44 # account for padding + scrollbar + + if canvas_width <= 1: + return + + breakdown = self.statistics_service.get_monthly_category_breakdown( + self._current_year, self._current_month + ) + + label_margin = 150 + right_margin = 80 + top_margin = 15 + bottom_margin = 15 + bar_height = 28 + bar_gap = 8 + + if not breakdown: + empty_height = 80 + self.chart_canvas.config(width=canvas_width, height=empty_height) + self.chart_canvas.create_text( + canvas_width / 2, + empty_height / 2, + text="No expenses this month", + fill="#aaaaaa", + font=("Arial", 14), + ) + return + + total_chart_height = ( + len(breakdown) * (bar_height + bar_gap) - bar_gap + top_margin + bottom_margin + ) + self.chart_canvas.config(width=canvas_width, height=total_chart_height) + + max_amount = breakdown[0][1] + available_width = canvas_width - label_margin - right_margin + + for i, (category, amount) in enumerate(breakdown): + y = top_margin + i * (bar_height + bar_gap) + color = self._bar_colors[i % len(self._bar_colors)] + + # Category label (right-aligned) + self.chart_canvas.create_text( + label_margin - 10, + y + bar_height / 2, + text=category, + fill="#ffffff", + font=("Arial", 11), + anchor="e", + ) + + # Bar + bar_width = (amount / max_amount) * available_width if max_amount > 0 else 0 + bar_width = max(bar_width, 2) # Minimum visible bar + self.chart_canvas.create_rectangle( + label_margin, + y, + label_margin + bar_width, + y + bar_height, + fill=color, + outline="", + ) + + # Amount label + self.chart_canvas.create_text( + label_margin + bar_width + 8, + y + bar_height / 2, + text=f"${amount:,.2f}", + fill="#ffffff", + font=("Arial", 11), + anchor="w", + ) + def _update_header_label(self): """Update the month/year label and button states.""" month_name = calendar.month_name[self._current_month] @@ -312,6 +468,8 @@ def _update_metrics(self): else: self.mom_label.config(text="0.0%", fg="#ffffff") + self._draw_category_chart() + def refresh(self): """Refresh statistics when tab becomes active.""" self._update_metrics() diff --git a/expense_tracker/services/statistics.py b/expense_tracker/services/statistics.py index d60ed65..afb9b71 100644 --- a/expense_tracker/services/statistics.py +++ b/expense_tracker/services/statistics.py @@ -149,6 +149,16 @@ def get_available_years(self) -> list[int]: """ return self.transaction_repo.get_years_with_expenses() + def get_monthly_category_breakdown(self, year: int, month: int) -> list[tuple[str, float]]: + """ + Get spending by category for a given month, sorted descending by amount. + + Returns: + List of (category, total_spending) tuples + """ + start_date, end_date = self._get_month_date_range(year, month) + return self.transaction_repo.get_spending_by_category(start_date, end_date) + def get_monthly_total_expense(self, year: int, month: int) -> float: """ Get total expenses for a given month. diff --git a/tests/core/test_repository.py b/tests/core/test_repository.py index 83bfe73..09b22a1 100644 --- a/tests/core/test_repository.py +++ b/tests/core/test_repository.py @@ -1370,6 +1370,116 @@ def test_transaction_exists_different_description(in_memory_repo): ) +def test_get_spending_by_category(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", + ) + ) + # Income should be excluded + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + + result = repo.get_spending_by_category(date(2023, 1, 1), date(2023, 2, 1)) + + # Sorted descending by spending, income excluded + assert len(result) == 3 + assert result[0] == ("Groceries", 150.0) + assert result[1] == ("Restaurants", 75.0) + assert result[2] == ("Transportation", 25.0) + + +def test_get_spending_by_category_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + result = repo.get_spending_by_category(date(2023, 1, 1), date(2023, 2, 1)) + assert result == [] + + +def test_get_spending_by_category_only_income(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + + result = repo.get_spending_by_category(date(2023, 1, 1), date(2023, 2, 1)) + assert result == [] + + +def test_get_spending_by_category_different_month_excluded(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add expense in January + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + # Add expense in February (should be excluded) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-10"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + result = repo.get_spending_by_category(date(2023, 1, 1), date(2023, 2, 1)) + + assert len(result) == 1 + assert result[0] == ("Food", 50.0) + + def test_get_total_expense(in_memory_repo): repo: TransactionRepository = in_memory_repo # Add expenses and income for January 2023 diff --git a/tests/services/test_statistics.py b/tests/services/test_statistics.py index 1ef6f06..9148213 100644 --- a/tests/services/test_statistics.py +++ b/tests/services/test_statistics.py @@ -364,6 +364,77 @@ def test_get_cashflow_trend(in_memory_repo, statistics_service): assert trend[1] == (2023, 2, 1200.0) # 2000 - 800 +def test_get_monthly_category_breakdown_with_data(in_memory_repo, statistics_service): + """Test get_monthly_category_breakdown returns correct spending by category.""" + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-200.0, + category="Food", + description="Groceries", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 15), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 20), + amount=3000.0, # Income excluded + category="Income", + description="Salary", + ) + ) + + result = statistics_service.get_monthly_category_breakdown(2023, 1) + + assert len(result) == 2 + assert result[0] == ("Food", 200.0) + assert result[1] == ("Shopping", 100.0) + + +def test_get_monthly_category_breakdown_empty(statistics_service): + """Test get_monthly_category_breakdown returns empty list when no expenses.""" + result = statistics_service.get_monthly_category_breakdown(2023, 1) + assert result == [] + + +def test_get_monthly_category_breakdown_december(in_memory_repo, statistics_service): + """Test get_monthly_category_breakdown handles December (year boundary).""" + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 12, 15), + amount=-150.0, + category="Gifts", + description="Holiday shopping", + ) + ) + # January next year should be excluded + in_memory_repo.add_transaction( + Transaction( + id=None, + date=date(2024, 1, 5), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + result = statistics_service.get_monthly_category_breakdown(2023, 12) + + assert len(result) == 1 + assert result[0] == ("Gifts", 150.0) + + def test_get_monthly_total_expense(in_memory_repo, statistics_service): """Test get_monthly_total_expense delegates correctly to the repository.""" in_memory_repo.add_transaction( From e0c3876e3f5c3e83e46baa36c67ee300fb0bccae Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Sat, 14 Feb 2026 12:34:26 -0800 Subject: [PATCH 2/2] handles scrolling for mac and windows --- expense_tracker/gui/tabs/statistics_tab.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/expense_tracker/gui/tabs/statistics_tab.py b/expense_tracker/gui/tabs/statistics_tab.py index 8e9d907..9b72060 100644 --- a/expense_tracker/gui/tabs/statistics_tab.py +++ b/expense_tracker/gui/tabs/statistics_tab.py @@ -1,4 +1,5 @@ import calendar +import platform import tkinter as tk from tkinter import ttk @@ -82,7 +83,11 @@ def _unbind_mousewheel(self, event): self._scroll_canvas.unbind_all("") def _on_mousewheel(self, event): - self._scroll_canvas.yview_scroll(-1 * (event.delta // 120), "units") + if platform.system() == "Darwin": + delta = -event.delta + else: + delta = -1 * (event.delta // 120) + self._scroll_canvas.yview_scroll(delta, "units") def _build_header(self): """Build header with month navigation controls."""