From 59789f3cfe8f057aebe2280c0764077e500a0697 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 17:36:33 -0400 Subject: [PATCH] 05/23 Phase 22 --- .claude/settings.local.json | 4 +- CLAUDE.md | 27 +++++ core/fetcher.py | 10 +- data/lottosight.db | Bin 2002944 -> 2002944 bytes db/database.py | 34 ++++-- main.py | 32 ++++++ tests/test_fetcher.py | 6 +- tests/test_phase22.py | 208 ++++++++++++++++++++++++++++++++++++ ui/dashboard.py | 42 +++++++- ui/statusbar.py | 18 +++- 10 files changed, 358 insertions(+), 23 deletions(-) create mode 100644 tests/test_phase22.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b88b128..68d2596 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,9 @@ "Bash(python -m pytest --tb=short -q)", "Bash(python -m pytest tests/test_wheeling.py -v)", "WebFetch(domain:www.valottery.com)", - "Bash(pip install *)" + "Bash(pip install *)", + "Bash(python -m pytest tests/ -x -q)", + "Bash(python -m pytest tests/test_phase22.py -v)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 45a9744..cfde8d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -376,6 +376,33 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 22 — Incremental VA Fetch + Top Prize + Dashboard Filter + Auto-Check +- [x] Incremental VA fetch in `core/fetcher.py` + - [x] `_fetch_va_lottery()` calls `get_last_draw()` for last known date + - [x] Breaks out of parsing loop when `draw_date <= last_date` (data is newest-first) + - [x] Result: subsequent fetches only download new records, not all 10K+ rows +- [x] `top_prize` column in `db/database.py` + - [x] Added `top_prize TEXT NOT NULL DEFAULT ''` to `CREATE TABLE games` + - [x] ALTER TABLE migration for existing databases (checks via `PRAGMA table_info`) + - [x] Updated `_seed_games()` with prize strings for all 5 builtin games + - [x] Back-fills `top_prize` for existing rows where empty (safe to re-run) +- [x] Dashboard game filter in `ui/dashboard.py` + - [x] `_filter_var` combobox at top ("All Games" + active game names) + - [x] `_update_filter_options()` — keeps combobox in sync with active games + - [x] `_filtered_games()` — returns subset based on selection + - [x] Last Draws, Hot Numbers, Overdue sections all respect filter +- [x] Top prize display in last-draw cards (`ui/dashboard.py`) + - [x] Shows "Top prize: $X" in purple above draw date if `top_prize` is set +- [x] VA source names added to `ui/statusbar.py` `_SOURCE_NAMES` +- [x] `append_match_alert(msg)` method added to `StatusBar` +- [x] Auto-check predictions after fetch in `main.py` + - [x] `_check_predictions_vs_latest()` — compares all saved predictions against newest draw per game + - [x] Called from `_on_fetch_done()` when `total_added > 0` + - [x] Status bar shows alert when ≥1 prediction matches ≥2 main numbers or bonus +- [x] `tests/test_phase22.py` — 14 tests (403/403 total passing) + +--- + ### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup - [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed - [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing diff --git a/core/fetcher.py b/core/fetcher.py index 81ddec1..4e03dc1 100644 --- a/core/fetcher.py +++ b/core/fetcher.py @@ -14,7 +14,7 @@ import threading import requests from datetime import datetime -from db.models import get_game_by_name, insert_draw, insert_fetch_log +from db.models import get_game_by_name, get_last_draw, insert_draw, insert_fetch_log logger = logging.getLogger(__name__) @@ -262,6 +262,10 @@ def _fetch_va_lottery(game_name: str, source: str, url: str) -> dict: game_id = game["id"] added = skipped = 0 + # Incremental fetch: VA data is newest-first; stop once we reach known dates + last_draw = get_last_draw(game_id) + last_date = last_draw["draw_date"] if last_draw else None + try: resp = requests.get(url, timeout=30) resp.raise_for_status() @@ -279,6 +283,10 @@ def _fetch_va_lottery(game_name: str, source: str, url: str) -> dict: if draw_date is None: continue + # Data comes newest-first; stop when we reach already-stored dates + if last_date and draw_date <= last_date: + break + try: p1 = parts[1].strip() p1_lower = p1.lower() diff --git a/data/lottosight.db b/data/lottosight.db index 37ad4ae1a2a6c2fb76db5f82c2b29f02659904ab..a1bff4f2f143e8ca790d3cd1765c9e7f4d805652 100644 GIT binary patch delta 354 zcmZozP}Q)YYJxPQqM9GKf2SlS#|+Z@>19N60&INBUI+Z?#s9Jt#Yc-kCzw>j{!6i9OO%w^zP$XCo~ z$;ZNbg?ABeF0U0Y56>l@>4h73bT%s#gz#_~yKyjR8*?y8GfZbp&ZDX>ao3-;+yVLbcSY m0=rH0x?LBG!V;d|FKt=;}QU;`gU~y delta 192 zcmWm5y$-=p7zW^;)0VdMP=7`B77R=#Z{kKYAtp(qgQSxfK1L=hmmm@*i`gU+*J11E zGd$z7I;N{*x(h^a-x-PCdAuDq;Yi(=lSQ;lh96t#zL-VxY0_VVAPAD66AXe$$Pw}c zi(nHRLV-{uxK}76R;K&b)JY|3pbYnY@#2T;bYW>>U&=r`e4k-^lid|7u`=^mg;iON V)mel2tjSvJe*)IIW%tl}{Qw0DNXh^J diff --git a/db/database.py b/db/database.py index e97bec7..82b0c59 100644 --- a/db/database.py +++ b/db/database.py @@ -50,7 +50,8 @@ def init_db(): main_max INTEGER NOT NULL, bonus_count INTEGER NOT NULL DEFAULT 0, bonus_max INTEGER NOT NULL DEFAULT 0, - active INTEGER NOT NULL DEFAULT 1 + active INTEGER NOT NULL DEFAULT 1, + top_prize TEXT NOT NULL DEFAULT '' ) """) @@ -105,6 +106,15 @@ def init_db(): ) """) + # Migration: add top_prize column to existing databases + cursor.execute("PRAGMA table_info(games)") + cols = {r["name"] for r in cursor.fetchall()} + if "top_prize" not in cols: + cursor.execute( + "ALTER TABLE games ADD COLUMN top_prize TEXT NOT NULL DEFAULT ''" + ) + logger.info("[DB] Migrated: added top_prize column to games") + conn.commit() logger.info("[DB] Tables created/verified OK") @@ -122,22 +132,28 @@ def _seed_games(cursor, conn): """ Insert default game configs if they don't already exist. Uses INSERT OR IGNORE to be idempotent on every launch. + Also back-fills top_prize for any existing rows where it is empty. """ defaults = [ - # name main_count main_max bonus_count bonus_max active - ("Powerball", 5, 69, 1, 26, 1), - ("Mega Millions", 5, 70, 1, 25, 1), - ("Cash 5", 5, 45, 0, 0, 1), - ("Millionaire for Life", 5, 58, 1, 5, 1), - ("Bank a Million", 6, 40, 1, 40, 1), + # name mc mm bc bm active top_prize + ("Powerball", 5, 69, 1, 26, 1, "Jackpot (variable)"), + ("Mega Millions", 5, 70, 1, 25, 1, "Jackpot (variable)"), + ("Cash 5", 5, 45, 0, 0, 1, "Jackpot from $200K"), + ("Millionaire for Life", 5, 58, 1, 5, 1, "$1M/yr for life"), + ("Bank a Million", 6, 40, 1, 40, 1, "$1M after taxes"), ] for row in defaults: cursor.execute(""" INSERT OR IGNORE INTO games - (name, main_count, main_max, bonus_count, bonus_max, active) - VALUES (?, ?, ?, ?, ?, ?) + (name, main_count, main_max, bonus_count, bonus_max, active, top_prize) + VALUES (?, ?, ?, ?, ?, ?, ?) """, row) + # Back-fill top_prize for rows inserted before this column existed + cursor.execute( + "UPDATE games SET top_prize = ? WHERE name = ? AND top_prize = ''", + (row[6], row[0]), + ) inserted = conn.total_changes conn.commit() diff --git a/main.py b/main.py index e28ff99..e804c84 100644 --- a/main.py +++ b/main.py @@ -167,10 +167,42 @@ 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) + total_added = sum(r.get("added", 0) for r in results) + if total_added > 0: + self._check_predictions_vs_latest() # Refresh the current screen if it can show new data if self._current_screen and hasattr(self._current_screen, "refresh"): self._current_screen.refresh() + def _check_predictions_vs_latest(self): + """Compare all saved predictions against the most recent draw per game.""" + from db.models import get_predictions, get_last_draw + preds = get_predictions(limit=100_000) + if not preds: + return + + best = 0 + hit_count = 0 + for pred in preds: + draw = get_last_draw(pred["game_id"]) + if not draw: + continue + pred_nums = {int(n) for n in pred["numbers"].split(",") if n.strip().isdigit()} + draw_nums = {int(n) for n in draw["numbers"].split(",") if n.strip().isdigit()} + matches = len(pred_nums & draw_nums) + bonus_hit = ( + pred.get("bonus") and draw.get("bonus") + and str(pred["bonus"]) == str(draw["bonus"]) + ) + if matches >= 2 or bonus_hit: + hit_count += 1 + best = max(best, matches) + + if hit_count > 0: + self._statusbar.append_match_alert( + f"{hit_count} saved prediction(s) matched ≥2 numbers (best: {best}/main)" + ) + # ── Lifecycle ───────────────────────────────────────────────────────────── def on_close(self): diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index d3fc9dd..e473bc0 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -254,7 +254,8 @@ def test_cash5_va_skips_duplicates(tmp_db): with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT)): result = fetch_cash5_va() assert result["added"] == 0 - assert result["skipped"] == 2 + # Incremental fetch breaks early once last known date is reached — skipped stays 0 + assert result["skipped"] == 0 def test_cash5_va_parses_date_and_numbers(tmp_db): @@ -331,7 +332,8 @@ def test_bankamil_va_skips_duplicates(tmp_db): with patch("core.fetcher.requests.get", return_value=_text_resp(BANKAMIL_VA_TEXT)): result = fetch_bankamillion_va() assert result["added"] == 0 - assert result["skipped"] == 2 + # Incremental fetch breaks early once last known date is reached — skipped stays 0 + assert result["skipped"] == 0 def test_bankamil_va_network_error(tmp_db): diff --git a/tests/test_phase22.py b/tests/test_phase22.py new file mode 100644 index 0000000..a6451cb --- /dev/null +++ b/tests/test_phase22.py @@ -0,0 +1,208 @@ +""" +tests/test_phase22.py +---------------------- +Tests for Phase 22 features: + - Incremental VA fetch (break on already-stored dates) + - top_prize column in games table + - Dashboard game filter + - Auto-check predictions after fetch (match detection logic) +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from db.database import get_db_stats, init_db +from db.models import ( + get_all_games, + get_game_by_name, + get_last_draw, + insert_draw, + insert_prediction, + get_predictions, +) +from core.fetcher import fetch_cash5_va, fetch_bankamillion_va + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def _text_resp(text, status=200): + m = MagicMock() + m.status_code = status + m.raise_for_status = MagicMock() + m.text = text + return m + + +CASH5_VA_TEXT_OLD = ( + "5/22/2026; 15,29,30,34,36\n" + "5/21/2026; 1,2,5,38,44\n" +) + +CASH5_VA_TEXT_NEWER = ( + "5/23/2026; 10,11,12,13,14\n" # new record + "5/22/2026; 15,29,30,34,36\n" # already in DB + "5/21/2026; 1,2,5,38,44\n" # already in DB +) + + +# ── top_prize column ─────────────────────────────────────────────────────────── + +def test_top_prize_seeded_powerball(tmp_db): + row = get_game_by_name("Powerball") + assert row["top_prize"] == "Jackpot (variable)" + + +def test_top_prize_seeded_mega_millions(tmp_db): + row = get_game_by_name("Mega Millions") + assert row["top_prize"] == "Jackpot (variable)" + + +def test_top_prize_seeded_cash5(tmp_db): + row = get_game_by_name("Cash 5") + assert row["top_prize"] == "Jackpot from $200K" + + +def test_top_prize_seeded_millionaire_for_life(tmp_db): + row = get_game_by_name("Millionaire for Life") + assert row["top_prize"] == "$1M/yr for life" + + +def test_top_prize_seeded_bank_a_million(tmp_db): + row = get_game_by_name("Bank a Million") + assert row["top_prize"] == "$1M after taxes" + + +def test_top_prize_all_games_non_empty(tmp_db): + games = get_all_games() + for g in games: + # Builtin games must have a top_prize; custom games may be empty + assert "top_prize" in g.keys() + + +# ── Incremental VA fetch ─────────────────────────────────────────────────────── + +def test_incremental_fetch_only_new_records_added(tmp_db): + """Second fetch with a new leading record only inserts that one record.""" + with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)): + r1 = fetch_cash5_va() + assert r1["added"] == 2 + + with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_NEWER)): + r2 = fetch_cash5_va() + assert r2["added"] == 1 + assert r2["skipped"] == 0 # stopped before re-attempting stored dates + + +def test_incremental_fetch_no_new_data(tmp_db): + """Second fetch of identical data adds nothing and doesn't error.""" + with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)): + fetch_cash5_va() + with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)): + r = fetch_cash5_va() + assert r["status"] == "success" + assert r["added"] == 0 + + +def test_incremental_fetch_db_count_correct(tmp_db): + """Total draws in DB after incremental fetch equals unique dates only.""" + with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)): + fetch_cash5_va() + with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_NEWER)): + fetch_cash5_va() + + game = get_game_by_name("Cash 5") + stats = get_db_stats() + assert stats["Cash 5"] == 3 # 2026-05-21, 05-22, 05-23 + + +def test_incremental_fetch_bank_a_million(tmp_db): + """Bank a Million incremental fetch behaves the same as Cash 5.""" + bam_old = ( + "Results for Bank a Million\n" + "5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n" + "5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n" + ) + bam_newer = ( + "Results for Bank a Million\n" + "5/23/2026; 1,5,10,20,35,38; Bonus Ball: 7\n" + "5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n" + "5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n" + ) + with patch("core.fetcher.requests.get", return_value=_text_resp(bam_old)): + r1 = fetch_bankamillion_va() + assert r1["added"] == 2 + + with patch("core.fetcher.requests.get", return_value=_text_resp(bam_newer)): + r2 = fetch_bankamillion_va() + assert r2["added"] == 1 + assert r2["skipped"] == 0 + + +# ── Match detection logic (unit-level) ──────────────────────────────────────── + +def _count_matches(pred_numbers: str, draw_numbers: str) -> int: + pred = {int(n) for n in pred_numbers.split(",") if n.strip().isdigit()} + draw = {int(n) for n in draw_numbers.split(",") if n.strip().isdigit()} + return len(pred & draw) + + +def test_match_detection_exact(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7) + last = get_last_draw(pb["id"]) + + pred_id = insert_prediction(pb["id"], "Hot Numbers", [1, 13, 36, 61, 69], bonus=7) + preds = get_predictions(game_id=pb["id"]) + pred = preds[0] + + matches = _count_matches(pred["numbers"], last["numbers"]) + assert matches == 5 + + +def test_match_detection_partial(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7) + last = get_last_draw(pb["id"]) + + insert_prediction(pb["id"], "Hot Numbers", [1, 13, 5, 6, 7], bonus=99) + preds = get_predictions(game_id=pb["id"]) + + matches = _count_matches(preds[0]["numbers"], last["numbers"]) + assert matches == 2 + + +def test_match_detection_no_match(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7) + last = get_last_draw(pb["id"]) + + insert_prediction(pb["id"], "Due Numbers", [2, 4, 6, 8, 10], bonus=99) + preds = get_predictions(game_id=pb["id"]) + + matches = _count_matches(preds[0]["numbers"], last["numbers"]) + assert matches == 0 + + +def test_match_alert_threshold_is_two(tmp_db): + """Only predictions with ≥2 main matches (or bonus hit) count toward alert.""" + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7) + last = get_last_draw(pb["id"]) + + preds_data = [ + ([1, 2, 3, 4, 5], 99), # 1 match — below threshold + ([1, 13, 2, 3, 4], 99), # 2 matches — at threshold + ([1, 13, 36, 2, 3], 99), # 3 matches — above threshold + ] + for nums, bonus in preds_data: + insert_prediction(pb["id"], "Test", nums, bonus=bonus) + + preds = get_predictions(game_id=pb["id"]) + draw_nums = {int(n) for n in last["numbers"].split(",") if n.strip().isdigit()} + + qualifying = [ + p for p in preds + if len({int(n) for n in p["numbers"].split(",") if n.strip().isdigit()} & draw_nums) >= 2 + ] + assert len(qualifying) == 2 diff --git a/ui/dashboard.py b/ui/dashboard.py index b72e70c..824fc30 100644 --- a/ui/dashboard.py +++ b/ui/dashboard.py @@ -54,6 +54,7 @@ class DashboardScreen(ttk.Frame): def __init__(self, parent, on_fetch=None, **kwargs): super().__init__(parent, **kwargs) self._on_fetch = on_fetch + self._filter_var = tk.StringVar(value="All Games") self._build_ui() # ── Layout ──────────────────────────────────────────────────────────────── @@ -66,7 +67,7 @@ class DashboardScreen(ttk.Frame): vsb.pack(side="right", fill="y") canvas.pack(side="left", fill="both", expand=True) - inner = ttk.Frame(canvas, padding=(28, 20, 28, 20)) + inner = ttk.Frame(canvas, padding=(28, 16, 28, 20)) win = canvas.create_window((0, 0), window=inner, anchor="nw") def _resize(event=None): @@ -82,6 +83,18 @@ class DashboardScreen(ttk.Frame): lambda e: canvas.unbind_all("")) self._inner = inner + + # Game filter bar + filter_bar = ttk.Frame(inner) + filter_bar.pack(fill="x", pady=(0, 12)) + ttk.Label(filter_bar, text="Show game:").pack(side="left", padx=(0, 6)) + self._game_filter = ttk.Combobox( + filter_bar, textvariable=self._filter_var, + state="readonly", width=22, + ) + self._game_filter.pack(side="left") + self._game_filter.bind("<>", lambda _e: self.refresh()) + self._draw_sections() def _section_header(self, title: str): @@ -101,7 +114,22 @@ class DashboardScreen(ttk.Frame): # ── Refresh ─────────────────────────────────────────────────────────────── + def _update_filter_options(self): + games = get_all_games(active_only=True) + names = ["All Games"] + [g["name"] for g in games] + self._game_filter["values"] = names + if self._filter_var.get() not in names: + self._filter_var.set("All Games") + + def _filtered_games(self): + games = get_all_games(active_only=True) + sel = self._filter_var.get() + if not sel or sel == "All Games": + return games + return [g for g in games if g["name"] == sel] + def refresh(self): + self._update_filter_options() self._refresh_last_draws() self._refresh_db_summary() self._refresh_hot_numbers() @@ -111,13 +139,12 @@ class DashboardScreen(ttk.Frame): for w in self._last_draw_body.winfo_children(): w.destroy() - games = get_all_games(active_only=True) + games = self._filtered_games() if not games: ttk.Label(self._last_draw_body, text="No active games.", foreground="#aaaaaa").pack(anchor="w") return - # Lay cards out in a horizontal row that wraps via grid row_frame = ttk.Frame(self._last_draw_body) row_frame.pack(fill="x") @@ -128,6 +155,11 @@ class DashboardScreen(ttk.Frame): ) card.grid(row=0, column=col_idx, padx=(0, 16), sticky="nw") + top_prize = game["top_prize"] if "top_prize" in game.keys() else "" + if top_prize: + ttk.Label(card, text=f"Top prize: {top_prize}", + foreground="#8e44ad", font=_CARD_FONT).pack(anchor="w") + if draw is None: ttk.Label(card, text="No draws yet.", foreground="#aaaaaa", font=_CARD_FONT).pack(anchor="w") @@ -172,7 +204,7 @@ class DashboardScreen(ttk.Frame): for w in self._hot_body.winfo_children(): w.destroy() - games = get_all_games(active_only=True) + games = self._filtered_games() if not games: ttk.Label(self._hot_body, text="No active games.", foreground="#aaaaaa").pack(anchor="w") @@ -201,7 +233,7 @@ class DashboardScreen(ttk.Frame): for w in self._overdue_body.winfo_children(): w.destroy() - games = get_all_games(active_only=True) + games = self._filtered_games() if not games: ttk.Label(self._overdue_body, text="No active games.", foreground="#aaaaaa").pack(anchor="w") diff --git a/ui/statusbar.py b/ui/statusbar.py index 58850ff..76c6837 100644 --- a/ui/statusbar.py +++ b/ui/statusbar.py @@ -10,9 +10,12 @@ from tkinter import ttk from datetime import datetime _SOURCE_NAMES = { - "powerball_ny": "Powerball", - "megamillions_ny": "Mega Millions (NY)", - "megamillions_tx": "Mega Millions (TX)", + "powerball_ny": "Powerball", + "megamillions_ny": "Mega Millions (NY)", + "megamillions_tx": "Mega Millions (TX)", + "cash5_va": "Cash 5 (VA)", + "millionaireforlife_va": "Millionaire for Life (VA)", + "bankamillion_va": "Bank a Million (VA)", } @@ -50,5 +53,10 @@ class StatusBar(ttk.Frame): parts.append(f"{name} — ERROR: {r['message']}") else: parts.append(f"{name} — {r['added']} added, {r['skipped']} skipped") - text = "Last fetch: " + " | ".join(parts) + f" | {now}" - self.set_text(text) + self._last_fetch_text = "Last fetch: " + " | ".join(parts) + f" | {now}" + self.set_text(self._last_fetch_text) + + def append_match_alert(self, msg: str): + """Append a prediction-match alert to the current status text.""" + base = getattr(self, "_last_fetch_text", self._label.cget("text")) + self.set_text(f"{base} • {msg}")