Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions expense_tracker/core/transaction_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
171 changes: 167 additions & 4 deletions expense_tracker/gui/tabs/statistics_tab.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import calendar
import platform
import tkinter as tk
from tkinter import ttk

Expand All @@ -20,13 +21,77 @@ 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(
"<Configure>",
lambda e: self._scroll_canvas.configure(
scrollregion=self._scroll_canvas.bbox("all")
),
)

# Make content frame match canvas width
self._scroll_canvas.bind(
"<Configure>",
self._on_canvas_configure,
)

# Bind mousewheel scrolling
self._scroll_canvas.bind("<Enter>", self._bind_mousewheel)
self._scroll_canvas.bind("<Leave>", 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("<MouseWheel>", self._on_mousewheel)

def _unbind_mousewheel(self, event):
self._scroll_canvas.unbind_all("<MouseWheel>")

def _on_mousewheel(self, event):
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."""
header = tk.Frame(self)
header = tk.Frame(self._content_frame)
header.pack(fill=tk.X, padx=20, pady=15)

# Previous month button
Expand All @@ -50,8 +115,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):
Expand Down Expand Up @@ -206,6 +271,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]
Expand Down Expand Up @@ -312,6 +473,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()
10 changes: 10 additions & 0 deletions expense_tracker/services/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
110 changes: 110 additions & 0 deletions tests/core/test_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading