diff --git a/expense_tracker/core/repositories.py b/expense_tracker/core/repositories.py index d7afedc..ae14f3a 100644 --- a/expense_tracker/core/repositories.py +++ b/expense_tracker/core/repositories.py @@ -170,6 +170,72 @@ 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]: + """ + 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, + SUM(ABS(amount)) as total + FROM transactions + WHERE date >= ? AND date < ? + AND amount < 0 + GROUP BY day + """, + (start_date.isoformat(), end_date.isoformat()), + ) + + result: dict[int, float] = {} + for row in rows.fetchall(): + result[row["day"]] = row["total"] + return result + + def get_transactions_for_date(self, target_date: date) -> list[Transaction]: + """ + Query transactions matching exact date. + Order by amount DESC (largest expenses first). + """ + rows = self.conn.execute( + "SELECT * FROM transactions WHERE date = ? ORDER BY amount ASC", + (target_date.isoformat(),), + ) + transactions: list[Transaction] = [] + for row in rows.fetchall(): + transaction = self._row_to_transaction(row) + if transaction: + transactions.append(transaction) + return transactions + + def get_months_with_expenses(self) -> list[tuple[int, int]]: + """ + Returns a list of (year, month) tuples for all months that have expenses. + Only includes months with negative amounts (expenses). + 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 + WHERE amount < 0 + ORDER BY year DESC, month DESC + """ + ) + result: list[tuple[int, int]] = [] + for row in rows.fetchall(): + result.append((row["year"], row["month"])) + return result + class MerchantCategoryRepository: """ diff --git a/expense_tracker/gui/main_window.py b/expense_tracker/gui/main_window.py index f9de788..f17eb0b 100644 --- a/expense_tracker/gui/main_window.py +++ b/expense_tracker/gui/main_window.py @@ -1,7 +1,8 @@ import tkinter as tk +from datetime import date from tkinter import ttk -from expense_tracker.gui.tabs import TransactionsTab +from expense_tracker.gui.tabs import TransactionsTab, HeatmapTab class MainWindow(tk.Frame): @@ -23,8 +24,15 @@ def __init__(self, master, transaction_repo, merchant_repo): self.notebook, transaction_repo, merchant_repo, self ) - # Add tab to notebook + # 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.heatmap_tab, text="Heatmap") + + # Bind tab change event for lazy loading + self.notebook.bind("<>", self._on_tab_changed) def _open_dialog(self, dialog_class, *args, **kwargs): if self._active_dialog is not None and self._active_dialog.winfo_exists(): @@ -51,3 +59,15 @@ def on_close(): self._active_dialog = None self.transactions_tab.refresh() + + 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 + self.heatmap_tab.refresh() + + def show_transactions_for_date(self, target_date: date): + """Switch to Transactions tab with date filter applied.""" + self.notebook.select(0) # Switch to Transactions tab (index 0) + self.transactions_tab.filter_by_date(target_date) diff --git a/expense_tracker/gui/tabs/__init__.py b/expense_tracker/gui/tabs/__init__.py index 3d6a4fb..8439276 100644 --- a/expense_tracker/gui/tabs/__init__.py +++ b/expense_tracker/gui/tabs/__init__.py @@ -1,3 +1,4 @@ from .transactions_tab import TransactionsTab +from .heatmap_tab import HeatmapTab -__all__ = ["TransactionsTab"] +__all__ = ["TransactionsTab", "HeatmapTab"] diff --git a/expense_tracker/gui/tabs/heatmap_tab.py b/expense_tracker/gui/tabs/heatmap_tab.py new file mode 100644 index 0000000..56f4533 --- /dev/null +++ b/expense_tracker/gui/tabs/heatmap_tab.py @@ -0,0 +1,292 @@ +import calendar +import tkinter as tk +from datetime import date +from tkinter import ttk + +from expense_tracker.core.repositories import TransactionRepository + + +class HeatmapTab(tk.Frame): + def __init__(self, master, transaction_repo: TransactionRepository, main_window): + super().__init__(master) + self.transaction_repo: TransactionRepository = transaction_repo + self.main_window = main_window + + # State + self._months_with_expenses: list[tuple[int, int]] = [] + self._current_index = 0 + self._spending_data: dict[int, float] = {} + + # Tooltip + self._tooltip: tk.Toplevel | None = None + + self.pack(fill=tk.BOTH, expand=True) + + # Build UI + self._build_header() + self._calendar_container = tk.Frame(self) + self._calendar_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + def _build_header(self): + """Build header with month navigation controls.""" + header = tk.Frame(self) + header.pack(fill=tk.X, padx=10, pady=10) + + # 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) + + def _update_header_label(self): + """Update the month/year label and button states.""" + if not self._months_with_expenses: + self.month_label.config(text="No expenses found") + self.prev_button.config(state=tk.DISABLED) + self.next_button.config(state=tk.DISABLED) + return + + year, month = self._months_with_expenses[self._current_index] + month_name = calendar.month_name[month] + + # Show current position + self.month_label.config( + text=f"{month_name} {year}" + ) + + # Update button states + self.prev_button.config( + state=tk.NORMAL if self._current_index < len(self._months_with_expenses) - 1 else tk.DISABLED + ) + self.next_button.config( + state=tk.NORMAL if self._current_index > 0 else tk.DISABLED + ) + + def _previous_month(self): + """Navigate to previous month with expenses.""" + if self._current_index < len(self._months_with_expenses) - 1: + self._current_index += 1 + self._update_header_label() + self._build_calendar_grid() + + def _next_month(self): + """Navigate to next month with expenses.""" + if self._current_index > 0: + self._current_index -= 1 + self._update_header_label() + self._build_calendar_grid() + + 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() + + # Reset to most recent month + self._current_index = 0 + + # Update header + self._update_header_label() + + # Build calendar + self._build_calendar_grid() + + def _build_calendar_grid(self): + """Build the calendar grid with spending data.""" + # Clear existing calendar + for widget in self._calendar_container.winfo_children(): + widget.destroy() + + if not self._months_with_expenses: + # Show message if no transactions + message = tk.Label( + self._calendar_container, + text="No expenses found", + font=("Arial", 16), + fg="gray", + ) + message.pack(pady=50) + return + + # Get current month + 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( + year, month + ) + + # Create grid frame + grid_frame = tk.Frame(self._calendar_container) + grid_frame.pack(expand=True) + + # Day of week headers + day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + for col, day_name in enumerate(day_names): + # Use Canvas for headers to match cell width exactly + header_canvas = tk.Canvas( + grid_frame, + width=90, + height=25, + highlightthickness=0, + ) + header_canvas.grid(row=0, column=col, padx=2, pady=2) + header_canvas.create_text( + 45, 12, + text=day_name, + font=("Arial", 15, "bold"), + ) + + # Get calendar information + first_weekday, num_days = calendar.monthrange(year, month) + + # Calculate color thresholds + spending_values = [v for v in self._spending_data.values() if v > 0] + if spending_values: + spending_values.sort() + p25_idx = len(spending_values) // 4 + p75_idx = (3 * len(spending_values)) // 4 + p25 = spending_values[p25_idx] if p25_idx < len(spending_values) else 0 + p75 = spending_values[p75_idx] if p75_idx < len(spending_values) else 0 + else: + p25 = p75 = 0 + + # Build calendar grid + current_day = 1 + for week in range(6): # Max 6 weeks + if current_day > num_days: + break + + for weekday in range(7): + row = week + 1 # +1 for header row + + # Check if we should place a day here + if week == 0 and weekday < first_weekday: + # Empty cell before first day of month + empty_canvas = tk.Canvas( + grid_frame, + width=90, + height=70, + highlightthickness=1, + highlightbackground="#CCCCCC", + ) + empty_canvas.grid(row=row, column=weekday, padx=0, pady=2) + # Draw gray background for empty cells + empty_canvas.create_rectangle( + 0, 0, 90, 70, + outline="", + ) + elif current_day <= num_days: + # Create day cell using Canvas for reliable color rendering + spending = self._spending_data.get(current_day, 0.0) + color = self._get_color_for_spending(spending, p25, p75) + + # Determine text color for readability + text_color = "white" if color == "#CC0000" else "black" + + # Create canvas with explicit background rectangle + cell_canvas = tk.Canvas( + grid_frame, + width=90, + height=70, + highlightthickness=1, + highlightbackground="#999999", + ) + cell_canvas.grid(row=row, column=weekday, padx=0, pady=2) + + # Draw colored rectangle as background (this bypasses theme) + cell_canvas.create_rectangle( + 0, 0, 90, 70, + fill=color, + outline="", + ) + + # Draw text on top of the colored background + cell_canvas.create_text( + 45, 20, + text=str(current_day), + fill=text_color, + font=("Arial", 14, "bold"), + ) + cell_canvas.create_text( + 45, 50, + text=f"${spending:.2f}", + fill=text_color, + font=("Arial", 12), + ) + + # Bind events + day_num = current_day # Capture in closure + cell_canvas.bind( + "", + lambda _e, y=year, m=month, d=day_num: self._on_day_click(y, m, d), + ) + cell_canvas.bind( + "", + lambda e, m=month, d=day_num, s=spending: self._show_tooltip( + e, m, d, s + ), + ) + cell_canvas.bind("", self._hide_tooltip) + # Change cursor on hover + cell_canvas.configure(cursor="hand2") + + current_day += 1 + + def _get_color_for_spending(self, spending: float, p25: float, p75: float) -> str: + """ + Calculate color based on spending amount using a heatmap gradient. + White (no spending) -> Light red -> Medium red -> Dark red (high spending) + """ + if spending == 0: + return "#FFFFFF" # White (no spending) + elif spending < p25: + return "#FFCCCC" # Light red (low spending) + elif spending < p75: + return "#FF6666" # Medium red (moderate spending) + else: + return "#CC0000" # Dark red (high spending) + + def _show_tooltip(self, event, month: int, day: int, amount: float): + """Show tooltip with spending details.""" + self._hide_tooltip(event) # Hide any existing tooltip + + month_name = calendar.month_name[month] + + self._tooltip = tk.Toplevel(self) + self._tooltip.wm_overrideredirect(True) + self._tooltip.wm_geometry(f"+{event.x_root + 10}+{event.y_root + 10}") + + label = tk.Label( + self._tooltip, + text=f"{month_name} {day}: ${amount:.2f}", + background="#FFFFE0", + relief=tk.SOLID, + borderwidth=1, + padx=5, + pady=3, + ) + label.pack() + + def _hide_tooltip(self, event): + """Hide the tooltip.""" + if self._tooltip: + self._tooltip.destroy() + self._tooltip = None + + def _on_day_click(self, year: int, month: int, day: int): + """Handle click on a day cell.""" + # Construct the full date + clicked_date = date(year, month, day) + + # Call main window to switch to Transactions tab with filter + self.main_window.show_transactions_for_date(clicked_date) diff --git a/expense_tracker/gui/tabs/transactions_tab.py b/expense_tracker/gui/tabs/transactions_tab.py index e096279..adae87c 100644 --- a/expense_tracker/gui/tabs/transactions_tab.py +++ b/expense_tracker/gui/tabs/transactions_tab.py @@ -1,5 +1,6 @@ import math import tkinter as tk +from datetime import date from tkinter import ttk, messagebox from expense_tracker.core.repositories import TransactionRepository @@ -18,6 +19,7 @@ def __init__(self, master, transaction_repo, merchant_repo, main_window): self._page_size = 100 self._total_transactions = 0 self._search_keyword: str | None = None + self._filter_date: date | None = None self.pack(fill=tk.BOTH, expand=True) self._build_toolbar() @@ -77,8 +79,16 @@ def refresh(self): offset = self._current_page * self._page_size - # Use search if keyword is active, otherwise get all transactions - if self._search_keyword: + # Use date filter if active, otherwise use search/all transactions + if self._filter_date: + transactions = self.transaction_repo.get_transactions_for_date( + self._filter_date + ) + self._total_transactions = len(transactions) + self.search_indicator.config( + text=f"Filtered by date: {self._filter_date.isoformat()}" + ) + elif self._search_keyword: self._total_transactions = self.transaction_repo.count_search_results( self._search_keyword ) @@ -227,6 +237,15 @@ def _search_transactions(self): def _clear_search(self): self._search_keyword = None + self._filter_date = None # Also clear date filter + self.qvar.set("") # Clear the search entry field + self._current_page = 0 # Reset to first page + self.refresh() + + def filter_by_date(self, target_date: date): + """Filter transactions by a specific date.""" + self._filter_date = target_date + self._search_keyword = None # Clear search when filtering by date self.qvar.set("") # Clear the search entry field self._current_page = 0 # Reset to first page self.refresh() diff --git a/openspec/changes/add-spending-heatmap/design.md b/openspec/changes/archive/2025-11-30-add-spending-heatmap/design.md similarity index 63% rename from openspec/changes/add-spending-heatmap/design.md rename to openspec/changes/archive/2025-11-30-add-spending-heatmap/design.md index 002a73d..dad7083 100644 --- a/openspec/changes/add-spending-heatmap/design.md +++ b/openspec/changes/archive/2025-11-30-add-spending-heatmap/design.md @@ -2,13 +2,13 @@ ## Architecture Overview -The spending heatmap feature introduces a tabbed interface to the main window, replacing the current single-view design: +The spending heatmap feature adds a new tab to the existing tabbed interface: - **Repository Layer**: Add aggregation method to `TransactionRepository` -- **GUI Layer Refactor**: Convert `MainWindow` to use `ttk.Notebook` for tabbed interface - - **Transactions Tab**: Current transaction table view (existing functionality) +- **GUI Layer**: Add new `HeatmapTab` to the existing `ttk.Notebook` - **Heatmap Tab**: New calendar heatmap visualization - - **Extensible**: Framework for future tabs (metrics, charts, etc.) -- **Integration**: Tab switching via notebook widget, no separate dialogs +- **Integration**: Tab switching via existing notebook widget, no separate dialogs + +**Note**: The tabbed UI refactor (MainWindow with ttk.Notebook and TransactionsTab extraction) has already been completed and is in production. ## Component Design @@ -35,49 +35,7 @@ def get_daily_spending_for_month(self, year: int, month: int) -> dict[int, float - No new indexes needed (date column already used in ORDER BY clauses) - Expected < 50ms for typical monthly data (< 200 transactions) -### 2. Main Window Tabbed Interface Refactor -**File**: `expense_tracker/gui/main_window.py` - -**Current Structure** (single view): -``` -MainWindow(tk.Frame) - → Toolbar - → Transaction Table (Treeview) - → Footer (pagination) -``` - -**New Structure** (tabbed): -``` -MainWindow(tk.Frame) - → ttk.Notebook (tab container) - → Tab 1: "Transactions" (TransactionTab) - → Toolbar - → Transaction Table (Treeview) - → Footer (pagination + search indicator) - → Tab 2: "Heatmap" (HeatmapTab) - → Month navigation header - → Calendar grid -``` - -### 3. Transactions Tab Component -**File**: `expense_tracker/gui/tabs/transactions_tab.py` (new file) - -New `TransactionsTab` class: -- Inherits from `tk.Frame` -- Extracts existing transaction table logic from `MainWindow` -- Encapsulates: - - Toolbar with buttons (Add, Edit, Delete, Refresh, Import, Search) - - Transaction Treeview table - - Pagination footer - - Search functionality -- Constructor takes `master`, `transaction_repo`, `merchant_repo` -- Public methods: - - `refresh()`: Reload transaction data - - `_add_transaction()`, `_edit_transaction()`, etc. (existing methods) - -**Migration Strategy**: Move existing code from `MainWindow` to `TransactionsTab` with minimal changes. - -### 4. Heatmap Tab Component +### 2. Heatmap Tab Component **File**: `expense_tracker/gui/tabs/heatmap_tab.py` (new file) New `HeatmapTab` class: @@ -122,54 +80,35 @@ New `HeatmapTab` class: - Week offsets for proper grid alignment - Empty cells for days outside the current month (grayed out) -### 5. Refactored Main Window +### 3. Main Window Integration **File**: `expense_tracker/gui/main_window.py` -Updated `MainWindow` class becomes a tab container: +Add the HeatmapTab to the existing MainWindow: ```python -class MainWindow(tk.Frame): - def __init__(self, master, transaction_repo, merchant_repo): - super().__init__(master) - self.transaction_repo = transaction_repo - self.merchant_repo = merchant_repo - self.master = master - self._active_dialog = None # Keep for other dialogs (add, edit, upload) - - # Create notebook (tab container) - self.notebook = ttk.Notebook(self) - self.notebook.pack(fill=tk.BOTH, expand=True) - - # Create tabs - self.transactions_tab = TransactionsTab( - self.notebook, transaction_repo, merchant_repo, self - ) - self.heatmap_tab = HeatmapTab( - self.notebook, transaction_repo - ) - - # Add tabs to notebook - self.notebook.add(self.transactions_tab, text="Transactions") - self.notebook.add(self.heatmap_tab, text="Heatmap") - - # Bind tab change event for lazy loading/refresh - self.notebook.bind("<>", self._on_tab_changed) - - 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 - self.heatmap_tab.refresh() +# In __init__ method, after creating TransactionsTab: +self.heatmap_tab = HeatmapTab( + self.notebook, transaction_repo +) +self.notebook.add(self.heatmap_tab, text="Heatmap") + +# Bind tab change event for lazy loading/refresh +self.notebook.bind("<>", self._on_tab_changed) + +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 + self.heatmap_tab.refresh() ``` -**Key Changes**: -- Remove `_build_toolbar()`, `_build_body()`, `_build_footer()` (moved to TransactionsTab) -- Keep `_active_dialog` management for Add/Edit/Upload dialogs (still modal) -- Notebook widget manages tab switching -- Each tab is self-contained with its own logic +**Changes Required**: +- Import `HeatmapTab` from `expense_tracker.gui.tabs` +- Instantiate HeatmapTab and add to notebook +- Add `_on_tab_changed` event handler for lazy loading -### 6. Drill-Down Interaction +### 4. Drill-Down Interaction When user clicks a cell in the heatmap: 1. Capture the selected date (year, month, day) 2. Switch to Transactions tab @@ -192,9 +131,9 @@ When user clicks a cell in the heatmap: ``` App Launch - → MainWindow creates Notebook with two tabs - → TransactionsTab loads (displays current page of transactions) - → HeatmapTab created but not populated (lazy load) + → MainWindow creates Notebook (already has Transactions tab) + → HeatmapTab created and added to notebook + → HeatmapTab not populated until first viewed (lazy load) User switches to "Heatmap" tab → Notebook <> event fires @@ -240,15 +179,15 @@ def calculate_color(spending: float, percentiles: dict) -> str: ## Alternative Designs Considered -### Alt 1: Separate Modal Dialog (Original Design) -**Pros**: Simple to implement, no need to refactor MainWindow +### Alt 1: Separate Modal Dialog +**Pros**: Simple to implement, no changes to existing MainWindow structure **Cons**: Disrupts workflow, requires opening/closing dialog, harder to navigate between views -**Decision**: Rejected - tabbed interface is more modern and user-friendly +**Decision**: Rejected - leveraging existing tab infrastructure is more user-friendly and consistent -### Alt 2: Embedded Heatmap in Main Window (Side-by-Side) -**Pros**: No need for tabs, always visible alongside transactions -**Cons**: Clutters main window, reduces transaction table space, harder to implement responsive layout -**Decision**: Rejected - tabs provide better space management +### Alt 2: Embedded Heatmap in Transactions Tab (Side-by-Side) +**Pros**: Always visible alongside transactions +**Cons**: Clutters transaction view, reduces table space, harder to implement responsive layout +**Decision**: Rejected - separate tab provides better space management ### Alt 3: Canvas-Based Rendering **Pros**: More flexible drawing, custom shapes, gradients @@ -278,12 +217,12 @@ def calculate_color(spending: float, percentiles: dict) -> str: - **Query Time**: < 50ms for typical month (< 200 transactions) - **Render Time**: < 150ms for full calendar grid (31 cells) -- **Total Load Time**: < 200ms from button click to visible heatmap -- **Memory**: Negligible impact (< 1MB for dialog and data) +- **Total Load Time**: < 200ms from tab switch to visible heatmap +- **Memory**: Negligible impact (< 1MB for tab and data) ## Testing Strategy 1. **Unit Tests**: Repository aggregation method with various date ranges -2. **Integration Tests**: Heatmap dialog creation and month navigation +2. **Integration Tests**: HeatmapTab creation, month navigation, and drill-down 3. **Manual Tests**: Visual verification of color gradients and calendar layout 4. **Edge Case Tests**: Empty months, leap years, boundary dates diff --git a/openspec/changes/add-spending-heatmap/proposal.md b/openspec/changes/archive/2025-11-30-add-spending-heatmap/proposal.md similarity index 100% rename from openspec/changes/add-spending-heatmap/proposal.md rename to openspec/changes/archive/2025-11-30-add-spending-heatmap/proposal.md diff --git a/openspec/changes/add-spending-heatmap/specs/heatmap-visualization/spec.md b/openspec/changes/archive/2025-11-30-add-spending-heatmap/specs/heatmap-visualization/spec.md similarity index 100% rename from openspec/changes/add-spending-heatmap/specs/heatmap-visualization/spec.md rename to openspec/changes/archive/2025-11-30-add-spending-heatmap/specs/heatmap-visualization/spec.md diff --git a/openspec/changes/add-spending-heatmap/specs/spending-aggregation/spec.md b/openspec/changes/archive/2025-11-30-add-spending-heatmap/specs/spending-aggregation/spec.md similarity index 100% rename from openspec/changes/add-spending-heatmap/specs/spending-aggregation/spec.md rename to openspec/changes/archive/2025-11-30-add-spending-heatmap/specs/spending-aggregation/spec.md diff --git a/openspec/changes/add-spending-heatmap/tasks.md b/openspec/changes/archive/2025-11-30-add-spending-heatmap/tasks.md similarity index 100% rename from openspec/changes/add-spending-heatmap/tasks.md rename to openspec/changes/archive/2025-11-30-add-spending-heatmap/tasks.md diff --git a/tests/core/test_repository.py b/tests/core/test_repository.py index 84b271f..e29bdb6 100644 --- a/tests/core/test_repository.py +++ b/tests/core/test_repository.py @@ -479,3 +479,301 @@ def test_count_search_results(in_memory_repo): # None keyword returns total count count = repo.count_search_results(None) assert count == 3 + + +def test_get_daily_spending_for_month_with_data(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add expenses and income for January 2023 + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, # Expense + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-30.0, # Another expense same day + category="Transport", + description="Taxi", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-100.0, # Expense + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=500.0, # Income (should be excluded) + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-10"), + amount=-25.0, # Different month (should be excluded) + category="Food", + description="Lunch", + ) + ) + + spending = repo.get_daily_spending_for_month(2023, 1) + + # Should only include expenses from January 2023 + assert len(spending) == 2 + assert spending[5] == 80.0 # Day 5: 50 + 30 + assert spending[15] == 100.0 # Day 15: 100 (income excluded) + assert 10 not in spending # No transactions on day 10 + + +def test_get_daily_spending_for_month_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transaction for different month + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + # Query for empty month + spending = repo.get_daily_spending_for_month(2023, 3) + assert len(spending) == 0 + assert spending == {} + + +def test_get_daily_spending_excludes_income(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add only income transactions + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=1000.0, # Positive = income + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-10"), + amount=500.0, # Positive = income + category="Income", + description="Bonus", + ) + ) + + spending = repo.get_daily_spending_for_month(2023, 1) + + # Should return empty since only income (no expenses) + assert len(spending) == 0 + assert spending == {} + + +def test_get_transactions_for_date_with_data(in_memory_repo): + repo: TransactionRepository = in_memory_repo + target = date.fromisoformat("2023-01-05") + + # Add transactions on target date + repo.add_transaction( + Transaction( + id=None, + date=target, + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=target, + amount=-30.0, + category="Transport", + description="Taxi", + ) + ) + # Add transaction on different date (should be excluded) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-06"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + transactions = repo.get_transactions_for_date(target) + + assert len(transactions) == 2 + # Verify all returned transactions have the target date + assert all(t.date == target for t in transactions) + + +def test_get_transactions_for_date_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transaction on different date + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + # Query for date with no transactions + transactions = repo.get_transactions_for_date(date.fromisoformat("2023-01-10")) + assert len(transactions) == 0 + assert transactions == [] + + +def test_get_transactions_for_date_ordering(in_memory_repo): + repo: TransactionRepository = in_memory_repo + target = date.fromisoformat("2023-01-05") + + # Add transactions with different amounts + repo.add_transaction( + Transaction( + id=None, + date=target, + amount=-10.0, + category="Food", + description="Snack", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=target, + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=target, + amount=-50.0, + category="Transport", + description="Taxi", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=target, + amount=500.0, # Income + category="Income", + description="Paycheck", + ) + ) + + transactions = repo.get_transactions_for_date(target) + + # Should be ordered by amount ASC (most negative first, then positive) + assert len(transactions) == 4 + assert transactions[0].amount == -100.0 # Largest expense first + assert transactions[1].amount == -50.0 + assert transactions[2].amount == -10.0 + assert transactions[3].amount == 500.0 # Income last + + +def test_get_months_with_expenses(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add expenses 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("2023-01-20"), + amount=-30.0, + category="Transport", + description="Taxi", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2024-02-05"), + amount=-75.0, + category="Food", + description="Restaurant", + ) + ) + # Add income (should be excluded) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-01"), + amount=1000.0, + category="Income", + description="Salary", + ) + ) + + months = repo.get_months_with_expenses() + + # Should return months with expenses only, most recent first + assert len(months) == 3 + assert months[0] == (2024, 2) # February 2024 + assert months[1] == (2023, 3) # March 2023 + assert months[2] == (2023, 1) # January 2023 (duplicate month, single entry) + # February 2023 should NOT be included (only income) + + +def test_get_months_with_expenses_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add only income + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=1000.0, + category="Income", + description="Salary", + ) + ) + + months = repo.get_months_with_expenses() + + # Should return empty list (no expenses) + assert len(months) == 0 + assert months == []