From 5dcf93a85f72360e0cf4e79acce1e4a83d60549d Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 11:18:25 -0400 Subject: [PATCH] 05/23 Phase 3 --- .claude/settings.local.json | 3 +- CLAUDE.md | 19 ++-- data/lottosight.db | Bin 557056 -> 557056 bytes db/models.py | 39 +++++++ main.py | 42 +++++-- tests/test_history.py | 168 ++++++++++++++++++++++++++++ ui/history.py | 215 ++++++++++++++++++++++++++++++++++++ 7 files changed, 467 insertions(+), 19 deletions(-) create mode 100644 tests/test_history.py create mode 100644 ui/history.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 65440b1..765c2c4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(python -m pytest tests/test_fetcher.py -v)", - "Bash(python -m pytest -v)" + "Bash(python -m pytest -v)", + "Bash(python -m pytest tests/test_history.py -v)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index a9b5d94..f16e9cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,15 +244,16 @@ All actions are logged to console and optionally to a log file: --- -### 🔲 Phase 3 — History Browser -- [ ] Write `ui/history.py` - - [ ] Treeview table with columns: Date, Numbers, Bonus, Multiplier, Source - - [ ] Filter by game (dropdown) - - [ ] Search by date range - - [ ] Sort by column headers - - [ ] Row count display -- [ ] Connect history screen to DB reads -- [ ] Test with real fetched data +### ✅ Phase 3 — History Browser +- [x] Write `ui/history.py` + - [x] Treeview table with columns: Game, Date, Numbers, Bonus, Mult., Source + - [x] Filter by game (dropdown) + - [x] Search by date range (From / To entries) + - [x] Sort by column headers (▲/▼ indicators, numeric sort for bonus/multiplier) + - [x] Row count display +- [x] Connect history screen to DB reads via `get_draws_with_game()` +- [x] Auto-refresh after fetch completes +- [x] 13 tests (80/80 total passing) --- diff --git a/data/lottosight.db b/data/lottosight.db index 2bd2c3f0e9ad2517031924c70465a2d0e8cc5aaf..2775ee369b498eb53f7a14510c82e32200c8f07c 100644 GIT binary patch delta 179 zcmZo@P-0%B$$W&vVWAZFX1 J*3Ulc0|0UrHCO-u delta 104 zcmZo@P-_-=hrI+d7ZbBMQ%X@{d9h$TCnK{sV|rq4YBAF$2Nnk| zX8tq={&V~@`O`KF3PkWT88S~k*QeUt+uz>X&j`d!K+FupEI`Z(#BAGp``L>>000>Y BA&vk5 diff --git a/db/models.py b/db/models.py index 46f04f3..03a3ef3 100644 --- a/db/models.py +++ b/db/models.py @@ -224,6 +224,45 @@ def get_draw_count(game_id): conn.close() +def get_draws_with_game(game_id=None, limit=None, date_from=None, date_to=None, order="DESC"): + """ + Like get_draws() but JOINs games so each row includes game_name. + game_id=None returns draws for all games. + Used by the History screen. + """ + conn = get_connection() + try: + cursor = conn.cursor() + query = """ + SELECT d.*, g.name AS game_name + FROM draws d + JOIN games g ON g.id = d.game_id + WHERE 1=1 + """ + params = [] + if game_id is not None: + query += " AND d.game_id = ?" + params.append(game_id) + if date_from: + query += " AND d.draw_date >= ?" + params.append(date_from) + if date_to: + query += " AND d.draw_date <= ?" + params.append(date_to) + + order_sql = "DESC" if order.upper() == "DESC" else "ASC" + query += f" ORDER BY d.draw_date {order_sql}, g.name ASC" + + if limit: + query += " LIMIT ?" + params.append(limit) + + cursor.execute(query, params) + return cursor.fetchall() + finally: + conn.close() + + def get_all_draws_numbers(game_id): """ Return all draws as list of dicts with parsed number lists. diff --git a/main.py b/main.py index e26862a..cf4bcc3 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from db.database import init_db from core.fetcher import fetch_all from ui.statusbar import StatusBar +from ui.history import HistoryScreen logging.basicConfig( level=logging.INFO, @@ -38,10 +39,13 @@ class LottoSightApp(tk.Tk): self._fetch_lock = threading.Lock() self._scheduler: BackgroundScheduler | None = None + self._current_screen: tk.Widget | None = None + self._screens: dict[str, tk.Widget] = {} self._build_ui() self._start_scheduler() - self._launch_fetch() # auto-fetch on startup + self._navigate("History") # open History as default for now + self._launch_fetch() # auto-fetch on startup # ── UI construction ─────────────────────────────────────────────────────── @@ -65,15 +69,10 @@ class LottoSightApp(tk.Tk): ) self._fetch_btn.pack(side="right", padx=4) - # Content area — replaced by real screens in later phases - self._content = ttk.Frame(self, padding=16) + # Content area — screens are packed/unpacked inside here + self._content = ttk.Frame(self) self._content.pack(side="top", fill="both", expand=True) - self._content_label = ttk.Label( - self._content, text="Dashboard", font=("TkDefaultFont", 14) - ) - self._content_label.pack(expand=True) - # Status bar self._statusbar = StatusBar(self) self._statusbar.pack(side="bottom", fill="x") @@ -82,7 +81,29 @@ class LottoSightApp(tk.Tk): def _navigate(self, screen_name: str): logger.info("Navigate → %s", screen_name) - self._content_label.config(text=screen_name) + + if self._current_screen is not None: + self._current_screen.pack_forget() + + if screen_name not in self._screens: + self._screens[screen_name] = self._make_screen(screen_name) + + self._current_screen = self._screens[screen_name] + self._current_screen.pack(fill="both", expand=True) + + # Refresh data-bearing screens each time they're shown + if hasattr(self._current_screen, "refresh"): + self._current_screen.refresh() + + def _make_screen(self, name: str) -> tk.Widget: + if name == "History": + return HistoryScreen(self._content) + # Placeholder for screens added in later phases + placeholder = ttk.Label( + self._content, text=f"{name} — coming soon", + font=("TkDefaultFont", 14), anchor="center", + ) + return placeholder # ── Fetch wiring ────────────────────────────────────────────────────────── @@ -126,6 +147,9 @@ class LottoSightApp(tk.Tk): def _on_fetch_done(self, results: list): self._fetch_btn.config(state="normal", text="Fetch Now") self._statusbar.update_fetch_results(results) + # Refresh the current screen if it can show new data + if self._current_screen and hasattr(self._current_screen, "refresh"): + self._current_screen.refresh() # ── Lifecycle ───────────────────────────────────────────────────────────── diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..dd44619 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,168 @@ +""" +tests/test_history.py +--------------------- +Tests for the History screen data layer — get_draws_with_game(). +UI widget instantiation is tested with a hidden Tk root (skipped on headless). +""" + +import pytest + +from db.models import ( + get_draws_with_game, + get_game_by_name, + insert_draw, +) + + +# ── get_draws_with_game ─────────────────────────────────────────────────────── + +def test_returns_empty_for_fresh_db(tmp_db): + rows = get_draws_with_game() + assert rows == [] + + +def test_returns_all_games_when_no_filter(tmp_db): + pb = get_game_by_name("Powerball") + mm = get_game_by_name("Mega Millions") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7, source="powerball_ny") + insert_draw(mm["id"], "2024-01-02", [7, 11, 22, 29, 38], bonus=4, source="megamillions_ny") + + rows = get_draws_with_game() + assert len(rows) == 2 + + +def test_game_name_is_included(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7, source="powerball_ny") + + rows = get_draws_with_game() + assert rows[0]["game_name"] == "Powerball" + + +def test_filters_by_game_id(tmp_db): + pb = get_game_by_name("Powerball") + mm = get_game_by_name("Mega Millions") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7, source="powerball_ny") + insert_draw(mm["id"], "2024-01-02", [7, 11, 22, 29, 38], bonus=4, source="megamillions_ny") + + rows = get_draws_with_game(game_id=pb["id"]) + assert len(rows) == 1 + assert rows[0]["game_name"] == "Powerball" + + +def test_filters_by_date_from(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + insert_draw(pb["id"], "2024-06-01", [5, 10, 20, 30, 40], bonus=3) + + rows = get_draws_with_game(date_from="2024-06-01") + assert len(rows) == 1 + assert rows[0]["draw_date"] == "2024-06-01" + + +def test_filters_by_date_to(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + insert_draw(pb["id"], "2024-06-01", [5, 10, 20, 30, 40], bonus=3) + + rows = get_draws_with_game(date_to="2024-01-31") + assert len(rows) == 1 + assert rows[0]["draw_date"] == "2024-01-01" + + +def test_filters_by_date_range(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + insert_draw(pb["id"], "2024-03-15", [5, 10, 20, 30, 40], bonus=3) + insert_draw(pb["id"], "2024-12-01", [9, 18, 27, 36, 45], bonus=15) + + rows = get_draws_with_game(date_from="2024-02-01", date_to="2024-06-30") + assert len(rows) == 1 + assert rows[0]["draw_date"] == "2024-03-15" + + +def test_default_order_is_desc(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + insert_draw(pb["id"], "2024-06-01", [5, 10, 20, 30, 40], bonus=3) + + rows = get_draws_with_game() + assert rows[0]["draw_date"] == "2024-06-01" + assert rows[1]["draw_date"] == "2024-01-01" + + +def test_order_asc(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + insert_draw(pb["id"], "2024-06-01", [5, 10, 20, 30, 40], bonus=3) + + rows = get_draws_with_game(order="ASC") + assert rows[0]["draw_date"] == "2024-01-01" + + +def test_limit(tmp_db): + pb = get_game_by_name("Powerball") + for day in range(1, 6): + insert_draw(pb["id"], f"2024-01-{day:02d}", [day, 2, 3, 4, 5], bonus=day) + + rows = get_draws_with_game(limit=3) + assert len(rows) == 3 + + +def test_combined_filters(tmp_db): + pb = get_game_by_name("Powerball") + mm = get_game_by_name("Mega Millions") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + insert_draw(pb["id"], "2024-03-01", [5, 10, 20, 30, 40], bonus=3) + insert_draw(mm["id"], "2024-03-01", [7, 11, 22, 29, 38], bonus=4) + + rows = get_draws_with_game(game_id=pb["id"], date_from="2024-02-01") + assert len(rows) == 1 + assert rows[0]["game_name"] == "Powerball" + assert rows[0]["draw_date"] == "2024-03-01" + + +# ── UI smoke test (skipped on headless) ─────────────────────────────────────── + +def _has_display(): + try: + import tkinter as tk + root = tk.Tk() + root.withdraw() + root.destroy() + return True + except Exception: + return False + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_history_screen_instantiates(tmp_db): + import tkinter as tk + from ui.history import HistoryScreen + + root = tk.Tk() + root.withdraw() + try: + screen = HistoryScreen(root) + assert screen.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_history_screen_shows_inserted_rows(tmp_db): + import tkinter as tk + from ui.history import HistoryScreen + + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7, source="powerball_ny") + insert_draw(pb["id"], "2024-03-01", [5, 20, 35, 50, 65], bonus=15, source="powerball_ny") + + root = tk.Tk() + root.withdraw() + try: + screen = HistoryScreen(root) + children = screen._tree.get_children() + assert len(children) == 2 + finally: + root.destroy() diff --git a/ui/history.py b/ui/history.py new file mode 100644 index 0000000..d5b5b86 --- /dev/null +++ b/ui/history.py @@ -0,0 +1,215 @@ +""" +ui/history.py +------------- +Draw History browser screen. +Shows a sortable, filterable Treeview of all draw records. +""" + +import tkinter as tk +from tkinter import ttk + +from db.models import get_all_games, get_game_by_name, get_draws_with_game + +_COLUMNS = ("game", "date", "numbers", "bonus", "multiplier", "source") +_LABELS = { + "game": "Game", + "date": "Date", + "numbers": "Numbers", + "bonus": "Bonus", + "multiplier": "Mult.", + "source": "Source", +} +_WIDTHS = { + "game": 120, + "date": 100, + "numbers": 190, + "bonus": 55, + "multiplier": 55, + "source": 130, +} +_ANCHORS = {c: "center" for c in _COLUMNS} +_ANCHORS["numbers"] = "w" +_ANCHORS["source"] = "w" + + +def _fmt_numbers(raw: str) -> str: + """'1,13,36,61,69' → '1 13 36 61 69'""" + return " ".join(raw.split(",")) if raw else "" + + +class HistoryScreen(ttk.Frame): + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + self._sort_col = "date" + self._sort_asc = False # newest first + + self._build_ui() + self.refresh() + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + # ── Filter bar ──────────────────────────────────────────────────────── + bar = ttk.Frame(self, padding=(6, 6, 6, 4)) + bar.pack(fill="x") + + ttk.Label(bar, text="Game:").pack(side="left") + self._game_var = tk.StringVar(value="All Games") + self._game_cb = ttk.Combobox( + bar, textvariable=self._game_var, state="readonly", width=15 + ) + self._game_cb.pack(side="left", padx=(4, 14)) + + ttk.Label(bar, text="From:").pack(side="left") + self._from_entry = ttk.Entry(bar, width=11) + self._from_entry.pack(side="left", padx=(4, 0)) + self._from_entry.insert(0, "YYYY-MM-DD") + self._from_entry.bind("", lambda e: self._clear_hint(self._from_entry, "YYYY-MM-DD")) + self._from_entry.bind("", lambda e: self._restore_hint(self._from_entry, "YYYY-MM-DD")) + + ttk.Label(bar, text="To:", padding=(8, 0, 0, 0)).pack(side="left") + self._to_entry = ttk.Entry(bar, width=11) + self._to_entry.pack(side="left", padx=(4, 14)) + self._to_entry.insert(0, "YYYY-MM-DD") + self._to_entry.bind("", lambda e: self._clear_hint(self._to_entry, "YYYY-MM-DD")) + self._to_entry.bind("", lambda e: self._restore_hint(self._to_entry, "YYYY-MM-DD")) + + ttk.Button(bar, text="Search", command=self._apply_filter).pack(side="left", padx=(0, 4)) + ttk.Button(bar, text="Clear", command=self._clear_filter).pack(side="left") + + ttk.Button(bar, text="↻ Refresh", command=self.refresh).pack(side="right") + + # ── Treeview + scrollbars ───────────────────────────────────────────── + tree_frame = ttk.Frame(self) + tree_frame.pack(fill="both", expand=True, padx=6, pady=(2, 0)) + + self._tree = ttk.Treeview( + tree_frame, columns=_COLUMNS, show="headings", selectmode="browse" + ) + for col in _COLUMNS: + self._tree.heading( + col, text=_LABELS[col], + command=lambda c=col: self._sort_by(c), + ) + self._tree.column(col, width=_WIDTHS[col], anchor=_ANCHORS[col], stretch=(col == "numbers")) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._tree.xview) + self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + self._tree.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + tree_frame.rowconfigure(0, weight=1) + tree_frame.columnconfigure(0, weight=1) + + # ── Row count ───────────────────────────────────────────────────────── + self._count_var = tk.StringVar(value="0 rows") + ttk.Label(self, textvariable=self._count_var, anchor="e", + padding=(6, 2)).pack(fill="x") + + # ── Hint helpers ────────────────────────────────────────────────────────── + + @staticmethod + def _clear_hint(entry: ttk.Entry, hint: str): + if entry.get() == hint: + entry.delete(0, "end") + + @staticmethod + def _restore_hint(entry: ttk.Entry, hint: str): + if entry.get().strip() == "": + entry.insert(0, hint) + + # ── Data loading ────────────────────────────────────────────────────────── + + def refresh(self): + """Reload games dropdown and re-run current filter.""" + self._load_games() + self._apply_filter() + + def _load_games(self): + games = get_all_games() + names = ["All Games"] + [g["name"] for g in games] + self._game_cb["values"] = names + if self._game_var.get() not in names: + self._game_var.set("All Games") + + def _apply_filter(self): + game_name = self._game_var.get() + raw_from = self._from_entry.get().strip() + raw_to = self._to_entry.get().strip() + date_from = raw_from if raw_from and raw_from != "YYYY-MM-DD" else None + date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None + + if game_name == "All Games": + game_id = None + else: + game = get_game_by_name(game_name) + game_id = game["id"] if game else None + + rows = get_draws_with_game( + game_id=game_id, + date_from=date_from, + date_to=date_to, + order="DESC" if not self._sort_asc else "ASC", + ) + self._populate(rows) + + def _populate(self, rows): + self._tree.delete(*self._tree.get_children()) + for row in rows: + self._tree.insert("", "end", values=( + row["game_name"], + row["draw_date"], + _fmt_numbers(row["numbers"]), + row["bonus"] or "", + row["multiplier"] or "", + row["source"] or "", + )) + count = len(rows) + self._count_var.set(f"{count} row{'s' if count != 1 else ''}") + + # ── Sorting ─────────────────────────────────────────────────────────────── + + def _sort_by(self, col): + if self._sort_col == col: + self._sort_asc = not self._sort_asc + else: + # Remove indicator from previous column + self._tree.heading(self._sort_col, text=_LABELS[self._sort_col]) + self._sort_col = col + self._sort_asc = col != "date" # date defaults descending, others ascending + + arrow = "▲" if self._sort_asc else "▼" + self._tree.heading(col, text=f"{_LABELS[col]} {arrow}") + + col_idx = _COLUMNS.index(col) + items = [(self._tree.set(iid, col), iid) for iid in self._tree.get_children()] + + # Numeric sort for bonus/multiplier columns + if col in ("bonus", "multiplier"): + def key(x): + try: + return (0, int(x[0])) + except (ValueError, TypeError): + return (1, x[0]) + items.sort(key=key, reverse=not self._sort_asc) + else: + items.sort(key=lambda x: x[0], reverse=not self._sort_asc) + + for i, (_, iid) in enumerate(items): + self._tree.move(iid, "", i) + + # ── Clear filter ────────────────────────────────────────────────────────── + + def _clear_filter(self): + self._game_var.set("All Games") + self._from_entry.delete(0, "end") + self._from_entry.insert(0, "YYYY-MM-DD") + self._to_entry.delete(0, "end") + self._to_entry.insert(0, "YYYY-MM-DD") + self._sort_col = "date" + self._sort_asc = False + for c in _COLUMNS: + self._tree.heading(c, text=_LABELS[c]) + self._apply_filter()