From 8eea1e976f651af501ce93d1e11c3566137f4f21 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 12:58:57 -0400 Subject: [PATCH] 05/23 Phase 14 --- .claude/settings.local.json | 3 +- CLAUDE.md | 35 +++++++ core/checker.py | 108 ++++++++++++++++++++ data/lottosight.db | Bin 557056 -> 557056 bytes db/models.py | 67 +++++++++++++ tests/test_checker.py | 161 ++++++++++++++++++++++++++++++ tests/test_custom_games.py | 192 ++++++++++++++++++++++++++++++++++++ ui/predictor_ui.py | 174 ++++++++++++++++++++++++++++++++ ui/settings.py | 113 ++++++++++++++++++++- 9 files changed, 851 insertions(+), 2 deletions(-) create mode 100644 core/checker.py create mode 100644 tests/test_checker.py create mode 100644 tests/test_custom_games.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 504eba1..c26e47e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,8 @@ "Bash(python -m PyInstaller lottosight.spec --clean)", "Bash(python -c \"from ui.dashboard import DashboardScreen\")", "Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)", - "Bash(python -m pytest tests/ -q)" + "Bash(python -m pytest tests/ -q)", + "Bash(python -c ' *)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 82a39a5..69ef410 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,41 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 13 — Ticket Checker +- [x] Write `core/checker.py` + - [x] `prize_tier(main_matches, bonus_match)` — maps match counts to Jackpot/Match 5/Match 4+Bonus/… tier labels + - [x] `check_ticket(game_id, numbers, bonus)` — compares ticket against all draws, returns matches sorted by quality desc + - [x] `parse_numbers(raw)` — parses space- or comma-separated user input, raises ValueError on bad input +- [x] Add "Check Ticket" tab to `ui/predictor_ui.py` (3rd tab in Notebook) + - [x] Game dropdown, Numbers entry, Bonus entry, Check / Clear buttons + - [x] Hint label with accepted input format + - [x] Results treeview: Draw Date, Draw Numbers, Bonus, Main Hits, Bonus Hit, Prize Tier + - [x] Row colours: purple=Jackpot, green=≥3 matches or bonus hit, grey=low match + - [x] Summary bar: "N draws matched • Best: " + - [x] Input validation: number count, range check, bonus range +- [x] 27 tests in `tests/test_checker.py` (254/254 total passing) + +--- + +### ✅ Phase 14 — Custom Game Management +- [x] Add `_BUILTIN_GAMES = {"Powerball", "Mega Millions"}` constant to `db/models.py` +- [x] Add `add_game(name, main_count, main_max, bonus_count, bonus_max) -> int` + - [x] Rejects blank names, duplicate names, and builtin game names (ValueError) + - [x] Inserts with `active=1`, returns new row id +- [x] Add `delete_game(game_id) -> bool` + - [x] Refuses builtin games → returns False + - [x] Refuses games with existing draw records → returns False + - [x] Cascades: deletes predictions for game first, then removes game → returns True +- [x] Update `ui/settings.py` + - [x] Delete button on each non-builtin game row; warns if draws exist, asks confirm + - [x] "+ Add Custom Game" button at bottom of Games section + - [x] `_AddGameDialog` modal (tk.Toplevel): name + main_count/max + bonus_count/max spinboxes + - [x] Error label in dialog for inline validation feedback + - [x] Refresh games list after add or delete +- [x] Write `tests/test_custom_games.py` — 20 tests (274/274 total passing) + +--- + ### ✅ Phase 12 — Number Search + Next Draw Schedule - [x] Number search in History screen - [x] Add `number` param to `get_draws_with_game()` — SQL: `',' || numbers || ',' LIKE ?` for exact boundary matching diff --git a/core/checker.py b/core/checker.py new file mode 100644 index 0000000..398681e --- /dev/null +++ b/core/checker.py @@ -0,0 +1,108 @@ +""" +core/checker.py +--------------- +Ticket checker — compare a user-supplied ticket against all historical draws. +Returns every draw where at least one number (main or bonus) matched, +sorted by match quality descending. + +Prize tiers follow Powerball / Mega Millions conventions: + Jackpot 5 main + bonus + Match 5 5 main, no bonus + Match 4 + Bonus 4 main + bonus + Match 4 4 main + Match 3 + Bonus 3 main + bonus + Match 3 3 main + Match 2 + Bonus 2 main + bonus + Match 1 + Bonus 1 main + bonus + Bonus Only 0 main + bonus +""" + +from db.models import get_all_draws_numbers, get_game_by_id + +# (required_main_matches, requires_bonus_match, tier_label) +_TIERS = [ + (5, True, "Jackpot"), + (5, False, "Match 5"), + (4, True, "Match 4 + Bonus"), + (4, False, "Match 4"), + (3, True, "Match 3 + Bonus"), + (3, False, "Match 3"), + (2, True, "Match 2 + Bonus"), + (1, True, "Match 1 + Bonus"), + (0, True, "Bonus Only"), +] + + +def prize_tier(main_matches: int, bonus_match: bool) -> str: + """Return the prize tier label for a given match result.""" + for req_main, req_bonus, label in _TIERS: + if main_matches == req_main: + if req_bonus and not bonus_match: + continue + return label + return "No Prize" + + +def check_ticket(game_id: int, numbers: list[int], + bonus: int | None = None) -> list[dict]: + """ + Compare *numbers* (and optional *bonus*) against all draws for *game_id*. + + Returns a list of dicts for draws with ≥ 1 matched number (main or bonus), + sorted by (main_matches DESC, bonus_match DESC, draw_date DESC). + + Each dict contains: + draw_date str + draw_numbers list[int] + draw_bonus int | None + main_matches int + bonus_match bool + prize_tier str + """ + draws = get_all_draws_numbers(game_id) + ticket_set = set(numbers) + results = [] + + for draw in draws: + draw_set = set(draw["numbers"]) + main_matches = len(ticket_set & draw_set) + bonus_match = ( + bonus is not None + and draw["bonus"] is not None + and bonus == draw["bonus"] + ) + + if main_matches == 0 and not bonus_match: + continue + + results.append({ + "draw_date": draw["draw_date"], + "draw_numbers": draw["numbers"], + "draw_bonus": draw["bonus"], + "main_matches": main_matches, + "bonus_match": bonus_match, + "prize_tier": prize_tier(main_matches, bonus_match), + }) + + results.sort( + key=lambda x: (x["main_matches"], x["bonus_match"], x["draw_date"]), + reverse=True, + ) + return results + + +def parse_numbers(raw: str) -> list[int]: + """ + Parse a user-typed string of lottery numbers into a sorted list of ints. + Accepts space- or comma-separated input: '7 14 32 56 68' or '7,14,32,56,68'. + Raises ValueError if any token is not a positive integer. + """ + tokens = raw.replace(",", " ").split() + if not tokens: + raise ValueError("No numbers entered.") + nums = [] + for t in tokens: + if not t.isdigit(): + raise ValueError(f"'{t}' is not a valid number.") + nums.append(int(t)) + return sorted(nums) diff --git a/data/lottosight.db b/data/lottosight.db index 968cb919a6507d29ffe0bb6c1eb225eda19f5597..1f247f9c5c95af915124f733f16a71841b3b892f 100644 GIT binary patch delta 168 zcmZo@P- int: + """ + Insert a new game row. Returns the new game id. + Raises ValueError if name is blank or already exists. + """ + name = name.strip() + if not name: + raise ValueError("Game name cannot be blank.") + conn = get_connection() + try: + cursor = conn.cursor() + cursor.execute( + "SELECT id FROM games WHERE name = ?", (name,) + ) + if cursor.fetchone(): + raise ValueError(f"A game named '{name}' already exists.") + cursor.execute( + """INSERT INTO games (name, main_count, main_max, bonus_count, bonus_max, active) + VALUES (?, ?, ?, ?, ?, 1)""", + (name, main_count, main_max, bonus_count, bonus_max), + ) + conn.commit() + new_id = cursor.lastrowid + logger.info("[DB] Added custom game id=%d name='%s'", new_id, name) + return new_id + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def delete_game(game_id: int) -> bool: + """ + Delete a game by id. Returns True on success, False if the game has draws + (to protect data integrity) or is a built-in game. + """ + conn = get_connection() + try: + cursor = conn.cursor() + cursor.execute("SELECT name FROM games WHERE id = ?", (game_id,)) + row = cursor.fetchone() + if row is None: + return False + if row["name"] in _BUILTIN_GAMES: + logger.warning("[DB] Refused to delete built-in game '%s'", row["name"]) + return False + cursor.execute("SELECT COUNT(*) as cnt FROM draws WHERE game_id = ?", (game_id,)) + if cursor.fetchone()["cnt"] > 0: + logger.warning("[DB] Refused to delete game id=%d — has draws", game_id) + return False + cursor.execute("DELETE FROM predictions WHERE game_id = ?", (game_id,)) + cursor.execute("DELETE FROM games WHERE id = ?", (game_id,)) + conn.commit() + logger.info("[DB] Deleted custom game id=%d", game_id) + return True + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def get_all_games(active_only=False): """ Return list of all games as sqlite3.Row objects. diff --git a/tests/test_checker.py b/tests/test_checker.py new file mode 100644 index 0000000..304179f --- /dev/null +++ b/tests/test_checker.py @@ -0,0 +1,161 @@ +""" +tests/test_checker.py +---------------------- +Tests for core/checker.py — ticket checking and prize tier logic. + +Draw data (Powerball): + Draw 1 2024-01-01: [1, 13, 36, 61, 69] bonus=7 + Draw 2 2024-01-03: [1, 2, 13, 45, 69] bonus=15 + Draw 3 2024-01-05: [2, 13, 22, 36, 55] bonus=3 +""" + +import pytest +from db.models import get_game_by_name, insert_draw +from core.checker import check_ticket, prize_tier, parse_numbers + +DRAWS = [ + ("2024-01-01", [1, 13, 36, 61, 69], 7), + ("2024-01-03", [1, 2, 13, 45, 69], 15), + ("2024-01-05", [2, 13, 22, 36, 55], 3), +] + + +@pytest.fixture +def pb(tmp_db): + game = get_game_by_name("Powerball") + for date, nums, bonus in DRAWS: + insert_draw(game["id"], date, nums, bonus=bonus) + return game + + +# ── prize_tier ──────────────────────────────────────────────────────────────── + +def test_jackpot(tmp_db): + assert prize_tier(5, True) == "Jackpot" + +def test_match_5(tmp_db): + assert prize_tier(5, False) == "Match 5" + +def test_match_4_bonus(tmp_db): + assert prize_tier(4, True) == "Match 4 + Bonus" + +def test_match_4(tmp_db): + assert prize_tier(4, False) == "Match 4" + +def test_match_3_bonus(tmp_db): + assert prize_tier(3, True) == "Match 3 + Bonus" + +def test_match_3(tmp_db): + assert prize_tier(3, False) == "Match 3" + +def test_match_2_bonus(tmp_db): + assert prize_tier(2, True) == "Match 2 + Bonus" + +def test_match_1_bonus(tmp_db): + assert prize_tier(1, True) == "Match 1 + Bonus" + +def test_bonus_only(tmp_db): + assert prize_tier(0, True) == "Bonus Only" + +def test_no_prize(tmp_db): + assert prize_tier(0, False) == "No Prize" + +def test_no_prize_2_no_bonus(tmp_db): + assert prize_tier(2, False) == "No Prize" + +def test_no_prize_1_no_bonus(tmp_db): + assert prize_tier(1, False) == "No Prize" + + +# ── check_ticket — exact match ──────────────────────────────────────────────── + +def test_jackpot_ticket_found(pb): + results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=7) + assert len(results) >= 1 + top = results[0] + assert top["main_matches"] == 5 + assert top["bonus_match"] is True + assert top["prize_tier"] == "Jackpot" + assert top["draw_date"] == "2024-01-01" + + +def test_match_5_no_bonus(pb): + results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=99) + top = results[0] + assert top["main_matches"] == 5 + assert top["bonus_match"] is False + assert top["prize_tier"] == "Match 5" + + +def test_partial_match_3(pb): + # Numbers 1, 13, 69 appear in draw 1 and draw 2 + results = check_ticket(pb["id"], [1, 13, 69, 4, 8]) + dates_matched = {r["draw_date"] for r in results} + assert "2024-01-01" in dates_matched + assert "2024-01-03" in dates_matched + + +def test_bonus_only_match(pb): + # No main number matches but bonus=7 matches draw 1 + results = check_ticket(pb["id"], [3, 4, 5, 6, 7], bonus=7) + bonus_only = [r for r in results if r["draw_date"] == "2024-01-01"] + assert len(bonus_only) == 1 + assert bonus_only[0]["main_matches"] == 0 + assert bonus_only[0]["bonus_match"] is True + assert bonus_only[0]["prize_tier"] == "Bonus Only" + + +def test_no_match_returns_empty(pb): + # Numbers that don't appear in any draw + results = check_ticket(pb["id"], [3, 4, 5, 6, 7]) + assert results == [] + + +def test_no_bonus_in_ticket(pb): + results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=None) + top = results[0] + assert top["bonus_match"] is False + assert top["prize_tier"] == "Match 5" + + +def test_results_sorted_by_matches_desc(pb): + # Mix of matches: use numbers that appear in multiple draws with varying counts + results = check_ticket(pb["id"], [1, 13, 36, 61, 69]) + if len(results) > 1: + for i in range(len(results) - 1): + assert results[i]["main_matches"] >= results[i + 1]["main_matches"] + + +def test_result_contains_required_keys(pb): + results = check_ticket(pb["id"], [1, 13, 36, 61, 69]) + for key in ("draw_date", "draw_numbers", "draw_bonus", + "main_matches", "bonus_match", "prize_tier"): + assert key in results[0] + + +def test_empty_db_returns_empty(tmp_db): + pb = get_game_by_name("Powerball") + assert check_ticket(pb["id"], [1, 2, 3, 4, 5]) == [] + + +# ── parse_numbers ───────────────────────────────────────────────────────────── + +def test_parse_space_separated(tmp_db): + assert parse_numbers("7 14 32 56 68") == [7, 14, 32, 56, 68] + +def test_parse_comma_separated(tmp_db): + assert parse_numbers("7,14,32,56,68") == [7, 14, 32, 56, 68] + +def test_parse_mixed_delimiters(tmp_db): + assert parse_numbers("7, 14, 32") == [7, 14, 32] + +def test_parse_returns_sorted(tmp_db): + assert parse_numbers("68 7 32") == [7, 32, 68] + +def test_parse_empty_raises(tmp_db): + with pytest.raises(ValueError, match="No numbers"): + parse_numbers(" ") + +def test_parse_non_digit_raises(tmp_db): + with pytest.raises(ValueError): + parse_numbers("7 14 abc") diff --git a/tests/test_custom_games.py b/tests/test_custom_games.py new file mode 100644 index 0000000..437bfb5 --- /dev/null +++ b/tests/test_custom_games.py @@ -0,0 +1,192 @@ +""" +tests/test_custom_games.py +--------------------------- +Tests for add_game() and delete_game() in db/models.py, +covering custom game management. +""" + +import pytest +from db.models import ( + add_game, delete_game, get_all_games, get_game_by_name, + get_game_by_id, insert_draw, _BUILTIN_GAMES, +) + + +# ── add_game ────────────────────────────────────────────────────────────────── + +def test_add_game_creates_row(tmp_db): + gid = add_game("EuroMillions", 5, 50, bonus_count=2, bonus_max=12) + game = get_game_by_id(gid) + assert game is not None + assert game["name"] == "EuroMillions" + + +def test_add_game_returns_id(tmp_db): + gid = add_game("UK Lotto", 6, 59) + assert isinstance(gid, int) + assert gid > 0 + + +def test_add_game_default_active(tmp_db): + gid = add_game("Test Game", 5, 45) + game = get_game_by_id(gid) + assert game["active"] == 1 + + +def test_add_game_appears_in_get_all_games(tmp_db): + add_game("EuroMillions", 5, 50, bonus_count=2, bonus_max=12) + names = [g["name"] for g in get_all_games()] + assert "EuroMillions" in names + + +def test_add_game_parameters_stored_correctly(tmp_db): + gid = add_game("Custom", 6, 45, bonus_count=1, bonus_max=20) + game = get_game_by_id(gid) + assert game["main_count"] == 6 + assert game["main_max"] == 45 + assert game["bonus_count"] == 1 + assert game["bonus_max"] == 20 + + +def test_add_game_duplicate_name_raises(tmp_db): + add_game("Test Game", 5, 45) + with pytest.raises(ValueError, match="already exists"): + add_game("Test Game", 6, 50) + + +def test_add_game_blank_name_raises(tmp_db): + with pytest.raises(ValueError, match="blank"): + add_game(" ", 5, 45) + + +def test_add_game_no_bonus(tmp_db): + gid = add_game("No Bonus Game", 6, 49, bonus_count=0, bonus_max=0) + game = get_game_by_id(gid) + assert game["bonus_count"] == 0 + assert game["bonus_max"] == 0 + + +def test_add_game_builtin_name_raises(tmp_db): + with pytest.raises(ValueError): + add_game("Powerball", 5, 69, 1, 26) + + +# ── delete_game ─────────────────────────────────────────────────────────────── + +def test_delete_custom_game_no_draws(tmp_db): + gid = add_game("Temp Game", 5, 45) + result = delete_game(gid) + assert result is True + assert get_game_by_id(gid) is None + + +def test_delete_custom_game_removed_from_list(tmp_db): + gid = add_game("Gone Game", 5, 45) + delete_game(gid) + names = [g["name"] for g in get_all_games()] + assert "Gone Game" not in names + + +def test_delete_game_with_draws_returns_false(tmp_db): + gid = add_game("Active Game", 5, 45) + insert_draw(gid, "2024-01-01", [1, 2, 3, 4, 5]) + result = delete_game(gid) + assert result is False + assert get_game_by_id(gid) is not None + + +def test_delete_builtin_powerball_returns_false(tmp_db): + pb = get_game_by_name("Powerball") + result = delete_game(pb["id"]) + assert result is False + assert get_game_by_name("Powerball") is not None + + +def test_delete_builtin_mega_millions_returns_false(tmp_db): + mm = get_game_by_name("Mega Millions") + result = delete_game(mm["id"]) + assert result is False + + +def test_delete_nonexistent_game_returns_false(tmp_db): + result = delete_game(99999) + assert result is False + + +def test_delete_game_clears_predictions(tmp_db): + from db.models import insert_prediction, get_predictions + gid = add_game("Prediction Game", 5, 45) + insert_prediction(gid, "Hot Numbers", [1, 2, 3, 4, 5]) + delete_game(gid) + preds = get_predictions(game_id=gid, limit=100) + assert preds == [] + + +# ── integration: custom game works with existing features ───────────────────── + +def test_custom_game_works_with_frequency_analysis(tmp_db): + from core.analyzer import frequency_analysis + gid = add_game("My Lottery", 6, 49, bonus_count=1, bonus_max=10) + insert_draw(gid, "2024-01-01", [1, 7, 14, 22, 35, 49]) + insert_draw(gid, "2024-01-03", [1, 7, 15, 23, 36, 48]) + freq = frequency_analysis(gid) + assert freq[1] == 2 + assert freq[7] == 2 + + +def test_custom_game_works_with_predictor(tmp_db): + from core.predictor import hot_numbers + gid = add_game("My Lottery", 6, 49, bonus_count=1, bonus_max=10) + insert_draw(gid, "2024-01-01", [1, 7, 14, 22, 35, 49], bonus=3) + insert_draw(gid, "2024-01-03", [1, 7, 15, 23, 36, 48], bonus=5) + result = hot_numbers(gid) + assert len(result["numbers"]) == 6 + assert all(1 <= n <= 49 for n in result["numbers"]) + + +# ── UI smoke tests ──────────────────────────────────────────────────────────── + +def _has_display(): + try: + import tkinter as tk + r = tk.Tk(); r.withdraw(); r.destroy() + return True + except Exception: + return False + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_settings_shows_add_button(tmp_db): + import tkinter as tk + from ui.settings import SettingsScreen + + try: + root = tk.Tk() + root.withdraw() + except Exception: + pytest.skip("Tkinter init failed") + try: + screen = SettingsScreen(root) + screen.refresh() + assert screen.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_settings_custom_game_shows_delete_button(tmp_db): + import tkinter as tk + from ui.settings import SettingsScreen + + add_game("My Custom Lottery", 5, 50) + try: + root = tk.Tk() + root.withdraw() + except Exception: + pytest.skip("Tkinter init failed") + try: + screen = SettingsScreen(root) + screen.refresh() + assert screen.winfo_exists() + finally: + root.destroy() diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py index c1db668..df1072f 100644 --- a/ui/predictor_ui.py +++ b/ui/predictor_ui.py @@ -21,6 +21,7 @@ from db.models import ( from core.predictor import ( hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, ) +from core.checker import check_ticket, parse_numbers from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir logger = logging.getLogger(__name__) @@ -73,11 +74,14 @@ class PredictorScreen(ttk.Frame): gen_frame = ttk.Frame(nb) saved_frame = ttk.Frame(nb) + check_frame = ttk.Frame(nb) nb.add(gen_frame, text="Generate") nb.add(saved_frame, text="Saved") + nb.add(check_frame, text="Check Ticket") self._build_generate_tab(gen_frame) self._build_saved_tab(saved_frame) + self._build_check_tab(check_frame) # ── Generate tab ────────────────────────────────────────────────────────── @@ -224,6 +228,7 @@ class PredictorScreen(ttk.Frame): def refresh(self): self._load_games() + self._load_check_games() self._refresh_saved() def _load_games(self): @@ -409,3 +414,172 @@ class PredictorScreen(ttk.Frame): return delete_all_predictions(game_id=game_id) self._refresh_saved() + + # ── Check Ticket tab ────────────────────────────────────────────────────── + + def _build_check_tab(self, parent): + # Input bar + bar = ttk.Frame(parent, padding=(6, 8, 6, 4)) + bar.pack(fill="x") + + ttk.Label(bar, text="Game:").pack(side="left") + self._chk_game_var = tk.StringVar() + self._chk_game_cb = ttk.Combobox( + bar, textvariable=self._chk_game_var, state="readonly", width=15 + ) + self._chk_game_cb.pack(side="left", padx=(4, 14)) + + ttk.Label(bar, text="Numbers:").pack(side="left") + self._chk_nums_var = tk.StringVar() + chk_nums_entry = ttk.Entry(bar, textvariable=self._chk_nums_var, width=22) + chk_nums_entry.pack(side="left", padx=(4, 4)) + chk_nums_entry.bind("", lambda e: self._run_check()) + + ttk.Label(bar, text="Bonus:").pack(side="left") + self._chk_bonus_var = tk.StringVar() + chk_bonus_entry = ttk.Entry(bar, textvariable=self._chk_bonus_var, width=5) + chk_bonus_entry.pack(side="left", padx=(4, 14)) + chk_bonus_entry.bind("", lambda e: self._run_check()) + + ttk.Button(bar, text="Check", command=self._run_check).pack(side="left", padx=(0, 4)) + ttk.Button(bar, text="Clear", command=self._clear_check).pack(side="left") + + self._chk_status_var = tk.StringVar() + ttk.Label(bar, textvariable=self._chk_status_var, + foreground="#c0392b").pack(side="left", padx=(12, 0)) + + # Hint text below bar + hint = ttk.Frame(parent, padding=(6, 0, 6, 4)) + hint.pack(fill="x") + ttk.Label(hint, + text="Enter space- or comma-separated main numbers, and optionally a bonus number.", + foreground="#888888", font=("TkDefaultFont", 8)).pack(anchor="w") + + # Results treeview + tree_frame = ttk.Frame(parent) + tree_frame.pack(fill="both", expand=True, padx=6, pady=(0, 4)) + + c_cols = ("date", "draw_numbers", "bonus", "main", "bonus_hit", "tier") + self._chk_tree = ttk.Treeview( + tree_frame, columns=c_cols, show="headings", selectmode="browse" + ) + self._chk_tree.heading("date", text="Draw Date") + self._chk_tree.heading("draw_numbers", text="Draw Numbers") + self._chk_tree.heading("bonus", text="Bonus") + self._chk_tree.heading("main", text="Main Hits") + self._chk_tree.heading("bonus_hit", text="Bonus Hit") + self._chk_tree.heading("tier", text="Prize Tier") + + self._chk_tree.column("date", width=100, anchor="center", stretch=False) + self._chk_tree.column("draw_numbers", width=190, anchor="w") + self._chk_tree.column("bonus", width=55, anchor="center", stretch=False) + self._chk_tree.column("main", width=70, anchor="center", stretch=False) + self._chk_tree.column("bonus_hit", width=70, anchor="center", stretch=False) + self._chk_tree.column("tier", width=160, anchor="w", stretch=False) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._chk_tree.yview) + hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._chk_tree.xview) + self._chk_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + self._chk_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 colour tags + self._chk_tree.tag_configure("jackpot", foreground="#8e44ad", font=("TkDefaultFont", 9, "bold")) + self._chk_tree.tag_configure("high", foreground="#1e8449") + self._chk_tree.tag_configure("low", foreground="#555555") + + # Summary label + self._chk_summary_var = tk.StringVar() + ttk.Label(parent, textvariable=self._chk_summary_var, + anchor="e", padding=(6, 2)).pack(fill="x") + + def _run_check(self): + self._chk_status_var.set("") + self._chk_tree.delete(*self._chk_tree.get_children()) + self._chk_summary_var.set("") + + # Resolve game + game = get_game_by_name(self._chk_game_var.get()) + if game is None: + self._chk_status_var.set("Select a game first.") + return + + # Parse main numbers + try: + numbers = parse_numbers(self._chk_nums_var.get()) + except ValueError as e: + self._chk_status_var.set(str(e)) + return + + if len(numbers) != game["main_count"]: + self._chk_status_var.set( + f"{game['name']} requires {game['main_count']} main numbers " + f"(got {len(numbers)})." + ) + return + + # Validate range + invalid = [n for n in numbers if not (1 <= n <= game["main_max"])] + if invalid: + self._chk_status_var.set( + f"Numbers out of range 1–{game['main_max']}: {invalid}" + ) + return + + # Parse optional bonus + bonus = None + raw_bonus = self._chk_bonus_var.get().strip() + if raw_bonus: + if not raw_bonus.isdigit(): + self._chk_status_var.set("Bonus must be a number.") + return + bonus = int(raw_bonus) + if game["bonus_max"] and not (1 <= bonus <= game["bonus_max"]): + self._chk_status_var.set( + f"Bonus out of range 1–{game['bonus_max']}." + ) + return + + results = check_ticket(game["id"], numbers, bonus=bonus) + + for r in results: + nums_fmt = " ".join(f"{n:02d}" for n in r["draw_numbers"]) + bonus_str = str(r["draw_bonus"]) if r["draw_bonus"] is not None else "—" + bonus_hit = "✓" if r["bonus_match"] else "—" + tier = r["prize_tier"] + + if tier == "Jackpot": + tag = "jackpot" + elif r["main_matches"] >= 3 or r["bonus_match"]: + tag = "high" + else: + tag = "low" + + self._chk_tree.insert("", "end", values=( + r["draw_date"], nums_fmt, bonus_str, + r["main_matches"], bonus_hit, tier, + ), tags=(tag,)) + + total = len(results) + best = results[0]["prize_tier"] if results else "—" + self._chk_summary_var.set( + f"{total} draw{'s' if total != 1 else ''} matched • Best: {best}" + ) + + def _clear_check(self): + self._chk_nums_var.set("") + self._chk_bonus_var.set("") + self._chk_status_var.set("") + self._chk_tree.delete(*self._chk_tree.get_children()) + self._chk_summary_var.set("") + + def _load_check_games(self): + games = get_all_games(active_only=True) + names = [g["name"] for g in games] + self._chk_game_cb["values"] = names + if not self._chk_game_var.get() or self._chk_game_var.get() not in names: + if names: + self._chk_game_var.set(names[0]) diff --git a/ui/settings.py b/ui/settings.py index 2e663d6..846868b 100644 --- a/ui/settings.py +++ b/ui/settings.py @@ -7,13 +7,14 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread. """ import tkinter as tk -from tkinter import ttk +from tkinter import ttk, messagebox import logging from db.database import get_db_stats from db.models import ( get_all_games, get_draw_count, set_game_active, get_last_fetch_per_source, get_predictions, + add_game, delete_game, _BUILTIN_GAMES, ) logger = logging.getLogger(__name__) @@ -131,6 +132,17 @@ class SettingsScreen(ttk.Frame): foreground="#777777", ).pack(side="left", padx=(16, 0)) + if game["name"] not in _BUILTIN_GAMES: + ttk.Button( + row, text="Delete", + command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname), + ).pack(side="left", padx=(12, 0)) + + ttk.Button( + self._games_body, text="+ Add Custom Game", + command=self._open_add_game_dialog, + ).pack(anchor="w", pady=(8, 0)) + def _refresh_sources(self): for w in self._sources_body.winfo_children(): w.destroy() @@ -190,3 +202,102 @@ class SettingsScreen(ttk.Frame): self._on_fetch() else: self._fetch_msg_var.set("Fetch not available.") + + def _open_add_game_dialog(self): + _AddGameDialog(self, on_save=self._refresh_games) + + def _delete_game(self, game_id: int, game_name: str): + count = get_draw_count(game_id) + if count > 0: + messagebox.showwarning( + "Cannot Delete", + f"'{game_name}' has {count:,} draw records.\n" + "Delete all draws for this game before removing it.", + ) + return + if not messagebox.askyesno("Delete Game", + f"Permanently delete '{game_name}'?"): + return + if delete_game(game_id): + self._refresh_games() + else: + messagebox.showerror("Error", f"Could not delete '{game_name}'.") + + +class _AddGameDialog(tk.Toplevel): + """Modal dialog for adding a custom lottery game.""" + + def __init__(self, parent, on_save): + super().__init__(parent) + self.title("Add Custom Game") + self.resizable(False, False) + self.grab_set() # modal + self._on_save = on_save + self._build() + self.transient(parent) + self.wait_visibility() + self.focus_set() + + def _build(self): + pad = {"padx": 10, "pady": 4} + + # Name + r = ttk.Frame(self, padding=(14, 14, 14, 4)) + r.pack(fill="x") + ttk.Label(r, text="Game name:", width=18, anchor="w").pack(side="left") + self._name_var = tk.StringVar() + ttk.Entry(r, textvariable=self._name_var, width=22).pack(side="left") + + # Main balls + r2 = ttk.Frame(self, padding=(14, 4, 14, 4)) + r2.pack(fill="x") + ttk.Label(r2, text="Main balls:", width=18, anchor="w").pack(side="left") + self._main_count = tk.IntVar(value=5) + ttk.Spinbox(r2, from_=1, to=10, textvariable=self._main_count, + width=5).pack(side="left") + ttk.Label(r2, text=" out of 1–", foreground="#555").pack(side="left") + self._main_max = tk.IntVar(value=69) + ttk.Spinbox(r2, from_=1, to=99, textvariable=self._main_max, + width=5).pack(side="left") + + # Bonus balls + r3 = ttk.Frame(self, padding=(14, 4, 14, 4)) + r3.pack(fill="x") + ttk.Label(r3, text="Bonus balls:", width=18, anchor="w").pack(side="left") + self._bonus_count = tk.IntVar(value=1) + ttk.Spinbox(r3, from_=0, to=5, textvariable=self._bonus_count, + width=5).pack(side="left") + ttk.Label(r3, text=" out of 1–", foreground="#555").pack(side="left") + self._bonus_max = tk.IntVar(value=26) + ttk.Spinbox(r3, from_=0, to=99, textvariable=self._bonus_max, + width=5).pack(side="left") + + # Error label + self._err_var = tk.StringVar() + ttk.Label(self, textvariable=self._err_var, + foreground="#c0392b", + padding=(14, 2)).pack(fill="x") + + # Buttons + btn_row = ttk.Frame(self, padding=(14, 4, 14, 14)) + btn_row.pack(fill="x") + ttk.Button(btn_row, text="Add Game", command=self._save).pack(side="right") + ttk.Button(btn_row, text="Cancel", command=self.destroy).pack(side="right", padx=(0, 6)) + + def _save(self): + name = self._name_var.get().strip() + if not name: + self._err_var.set("Game name is required.") + return + try: + add_game( + name=name, + main_count=int(self._main_count.get()), + main_max=int(self._main_max.get()), + bonus_count=int(self._bonus_count.get()), + bonus_max=int(self._bonus_max.get()), + ) + self._on_save() + self.destroy() + except ValueError as e: + self._err_var.set(str(e))