05/23 Phase 3

This commit is contained in:
2026-05-23 11:18:25 -04:00
parent f33962cb15
commit 5dcf93a85f
7 changed files with 467 additions and 19 deletions
+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(python -m pytest tests/test_fetcher.py -v)", "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)"
] ]
} }
} }
+10 -9
View File
@@ -244,15 +244,16 @@ All actions are logged to console and optionally to a log file:
--- ---
### 🔲 Phase 3 — History Browser ### Phase 3 — History Browser
- [ ] Write `ui/history.py` - [x] Write `ui/history.py`
- [ ] Treeview table with columns: Date, Numbers, Bonus, Multiplier, Source - [x] Treeview table with columns: Game, Date, Numbers, Bonus, Mult., Source
- [ ] Filter by game (dropdown) - [x] Filter by game (dropdown)
- [ ] Search by date range - [x] Search by date range (From / To entries)
- [ ] Sort by column headers - [x] Sort by column headers (▲/▼ indicators, numeric sort for bonus/multiplier)
- [ ] Row count display - [x] Row count display
- [ ] Connect history screen to DB reads - [x] Connect history screen to DB reads via `get_draws_with_game()`
- [ ] Test with real fetched data - [x] Auto-refresh after fetch completes
- [x] 13 tests (80/80 total passing)
--- ---
Binary file not shown.
+39
View File
@@ -224,6 +224,45 @@ def get_draw_count(game_id):
conn.close() 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): def get_all_draws_numbers(game_id):
""" """
Return all draws as list of dicts with parsed number lists. Return all draws as list of dicts with parsed number lists.
+32 -8
View File
@@ -16,6 +16,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
from db.database import init_db from db.database import init_db
from core.fetcher import fetch_all from core.fetcher import fetch_all
from ui.statusbar import StatusBar from ui.statusbar import StatusBar
from ui.history import HistoryScreen
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -38,9 +39,12 @@ class LottoSightApp(tk.Tk):
self._fetch_lock = threading.Lock() self._fetch_lock = threading.Lock()
self._scheduler: BackgroundScheduler | None = None self._scheduler: BackgroundScheduler | None = None
self._current_screen: tk.Widget | None = None
self._screens: dict[str, tk.Widget] = {}
self._build_ui() self._build_ui()
self._start_scheduler() self._start_scheduler()
self._navigate("History") # open History as default for now
self._launch_fetch() # auto-fetch on startup self._launch_fetch() # auto-fetch on startup
# ── UI construction ─────────────────────────────────────────────────────── # ── UI construction ───────────────────────────────────────────────────────
@@ -65,15 +69,10 @@ class LottoSightApp(tk.Tk):
) )
self._fetch_btn.pack(side="right", padx=4) self._fetch_btn.pack(side="right", padx=4)
# Content area — replaced by real screens in later phases # Content area — screens are packed/unpacked inside here
self._content = ttk.Frame(self, padding=16) self._content = ttk.Frame(self)
self._content.pack(side="top", fill="both", expand=True) 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 # Status bar
self._statusbar = StatusBar(self) self._statusbar = StatusBar(self)
self._statusbar.pack(side="bottom", fill="x") self._statusbar.pack(side="bottom", fill="x")
@@ -82,7 +81,29 @@ class LottoSightApp(tk.Tk):
def _navigate(self, screen_name: str): def _navigate(self, screen_name: str):
logger.info("Navigate → %s", screen_name) 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 ────────────────────────────────────────────────────────── # ── Fetch wiring ──────────────────────────────────────────────────────────
@@ -126,6 +147,9 @@ class LottoSightApp(tk.Tk):
def _on_fetch_done(self, results: list): def _on_fetch_done(self, results: list):
self._fetch_btn.config(state="normal", text="Fetch Now") self._fetch_btn.config(state="normal", text="Fetch Now")
self._statusbar.update_fetch_results(results) 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 ───────────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────
+168
View File
@@ -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()
+215
View File
@@ -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("<FocusIn>", lambda e: self._clear_hint(self._from_entry, "YYYY-MM-DD"))
self._from_entry.bind("<FocusOut>", 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("<FocusIn>", lambda e: self._clear_hint(self._to_entry, "YYYY-MM-DD"))
self._to_entry.bind("<FocusOut>", 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()