diff --git a/CLAUDE.md b/CLAUDE.md index 478122a..82a39a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,20 @@ All actions are logged to console and optionally to a log file: --- +### ✅ 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 + - [x] Add "Number:" entry field to History filter bar; bound to `` for quick search + - [x] Wire through `_apply_filter()` and `_clear_filter()` + - [x] 7 new tests — exact match, boundary false-positive prevention, combined with game/date filter +- [x] Next draw schedule on Dashboard + - [x] `_next_draw_date(game_name, from_date)` — computes next Powerball (Mon/Wed/Sat) or Mega Millions (Tue/Fri) draw date + - [x] Shown on each Last Draw card as "Next draw: Sat, May 24" in green + - [x] 6 new tests — day-of-week transitions, unknown game, always-in-future guarantee +- [x] 227/227 tests passing + +--- + ### ✅ Phase 11 — Saved Predictions Viewer - [x] Add `delete_prediction(pred_id)` and `delete_all_predictions(game_id=None)` to `db/models.py` - [x] Refactor `ui/predictor_ui.py` to ttk.Notebook with two tabs diff --git a/data/lottosight.db b/data/lottosight.db index 24f6e9d..968cb91 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/db/models.py b/db/models.py index 3007d72..84174aa 100644 --- a/db/models.py +++ b/db/models.py @@ -224,10 +224,12 @@ 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"): +def get_draws_with_game(game_id=None, limit=None, date_from=None, date_to=None, + order="DESC", number=None): """ Like get_draws() but JOINs games so each row includes game_name. game_id=None returns draws for all games. + number (int|str): if given, restrict to draws containing that ball number. Used by the History screen. """ conn = get_connection() @@ -249,6 +251,10 @@ def get_draws_with_game(game_id=None, limit=None, date_from=None, date_to=None, if date_to: query += " AND d.draw_date <= ?" params.append(date_to) + if number is not None: + # Wrap stored CSV in commas so boundary matches are exact + query += " AND ',' || d.numbers || ',' LIKE ?" + params.append(f"%,{int(number)},%") order_sql = "DESC" if order.upper() == "DESC" else "ASC" query += f" ORDER BY d.draw_date {order_sql}, g.name ASC" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b9d63aa..ba90244 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -4,6 +4,8 @@ tests/test_dashboard.py Tests for the Dashboard screen data layer and UI smoke tests. """ +from datetime import date + import pytest from db.models import ( get_all_games, get_game_by_name, get_last_draw, @@ -11,6 +13,7 @@ from db.models import ( ) from db.database import get_db_stats from core.analyzer import frequency_analysis +from ui.dashboard import _next_draw_date # ── Data-layer checks used by the Dashboard ─────────────────────────────────── @@ -77,6 +80,48 @@ def test_predictions_count_correct(tmp_db): assert len(get_predictions(limit=100_000)) == 2 + +# ── _next_draw_date ─────────────────────────────────────────────────────────── + +def test_next_draw_powerball_from_friday(): + # Friday → next Powerball is Saturday + fri = date(2024, 5, 24) # Friday, weekday=4 + result = _next_draw_date("Powerball", from_date=fri) + assert "Sat" in result + + +def test_next_draw_powerball_from_saturday(): + # Saturday → next Powerball is Monday + sat = date(2024, 5, 25) # Saturday, weekday=5 + result = _next_draw_date("Powerball", from_date=sat) + assert "Mon" in result + + +def test_next_draw_mega_millions_from_friday(): + # Friday → next MM is Tuesday (4 days away) + fri = date(2024, 5, 24) + result = _next_draw_date("Mega Millions", from_date=fri) + assert "Tue" in result + + +def test_next_draw_mega_millions_from_monday(): + # Monday → next MM is Tuesday (1 day away) + mon = date(2024, 5, 20) # Monday, weekday=0 + result = _next_draw_date("Mega Millions", from_date=mon) + assert "Tue" in result + + +def test_next_draw_unknown_game_returns_empty(): + assert _next_draw_date("Unknown Game") == "" + + +def test_next_draw_always_in_future(): + today = date.today() + for name in ("Powerball", "Mega Millions"): + result = _next_draw_date(name, from_date=today) + assert result != "" # always finds a future date within 7 days + + # ── UI smoke tests ──────────────────────────────────────────────────────────── def _has_display(): diff --git a/tests/test_history.py b/tests/test_history.py index dd44619..1988650 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -122,6 +122,72 @@ def test_combined_filters(tmp_db): assert rows[0]["draw_date"] == "2024-03-01" + +# ── number filter ───────────────────────────────────────────────────────────── + +def test_number_filter_returns_matching_draws(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7) + insert_draw(pb["id"], "2024-01-03", [2, 14, 37, 55, 68], bonus=10) + + rows = get_draws_with_game(number=13) + assert len(rows) == 1 + assert rows[0]["draw_date"] == "2024-01-01" + + +def test_number_filter_no_match_returns_empty(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + + rows = get_draws_with_game(number=99) + assert rows == [] + + +def test_number_filter_does_not_false_match_substring(tmp_db): + pb = get_game_by_name("Powerball") + # numbers 1 and 13 — searching for 1 must NOT return the draw containing 13 + insert_draw(pb["id"], "2024-01-01", [13, 23, 33, 43, 53], bonus=7) + + rows = get_draws_with_game(number=1) + assert rows == [] + + +def test_number_filter_matches_first_position(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7) + + rows = get_draws_with_game(number=1) + assert len(rows) == 1 + + +def test_number_filter_matches_last_position(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 69], bonus=7) + + rows = get_draws_with_game(number=69) + assert len(rows) == 1 + + +def test_number_filter_combined_with_game(tmp_db): + pb = get_game_by_name("Powerball") + mm = get_game_by_name("Mega Millions") + insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7) + insert_draw(mm["id"], "2024-01-02", [1, 14, 37, 55, 68], bonus=4) + + rows = get_draws_with_game(game_id=pb["id"], number=1) + assert len(rows) == 1 + assert rows[0]["game_name"] == "Powerball" + + +def test_number_filter_none_returns_all(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-01-03", [6, 7, 8, 9, 10], bonus=2) + + rows = get_draws_with_game(number=None) + assert len(rows) == 2 + + # ── UI smoke test (skipped on headless) ─────────────────────────────────────── def _has_display(): diff --git a/ui/dashboard.py b/ui/dashboard.py index cc7f4b5..98d50d2 100644 --- a/ui/dashboard.py +++ b/ui/dashboard.py @@ -11,11 +11,31 @@ Sections: import tkinter as tk from tkinter import ttk +from datetime import date, timedelta from db.database import get_db_stats from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions from core.analyzer import frequency_analysis +# Weekday indices: Monday=0 … Sunday=6 +_DRAW_DAYS: dict[str, list[int]] = { + "Powerball": [0, 2, 5], # Mon, Wed, Sat + "Mega Millions": [1, 4], # Tue, Fri +} + + +def _next_draw_date(game_name: str, from_date: date | None = None) -> str: + """Return the next scheduled draw date as 'Day, Mon DD' (e.g. 'Sat, May 24').""" + draw_days = _DRAW_DAYS.get(game_name) + if not draw_days: + return "" + today = from_date or date.today() + for delta in range(1, 8): + candidate = today + timedelta(days=delta) + if candidate.weekday() in draw_days: + return f"{candidate.strftime('%a, %b')} {candidate.day}" + return "" + _HEADER_FONT = ("TkDefaultFont", 10, "bold") _CARD_FONT = ("TkDefaultFont", 9) _NUMBER_FONT = ("TkDefaultFont", 12, "bold") @@ -122,6 +142,11 @@ class DashboardScreen(ttk.Frame): ttk.Label(card, text=f"Multiplier: {draw['multiplier']}", foreground="#777777", font=_CARD_FONT).pack(anchor="w") + next_draw = _next_draw_date(game["name"]) + if next_draw: + ttk.Label(card, text=f"Next draw: {next_draw}", + foreground="#117a65", font=_CARD_FONT).pack(anchor="w", pady=(6, 0)) + def _refresh_db_summary(self): for w in self._db_body.winfo_children(): w.destroy() diff --git a/ui/history.py b/ui/history.py index f65922a..74a8e25 100644 --- a/ui/history.py +++ b/ui/history.py @@ -78,6 +78,12 @@ class HistoryScreen(ttk.Frame): 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.Label(bar, text="Number:", padding=(8, 0, 0, 0)).pack(side="left") + self._number_var = tk.StringVar() + self._number_entry = ttk.Entry(bar, textvariable=self._number_var, width=5) + self._number_entry.pack(side="left", padx=(4, 14)) + self._number_entry.bind("", lambda e: self._apply_filter()) + 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") @@ -147,6 +153,9 @@ class HistoryScreen(ttk.Frame): 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 + raw_num = self._number_var.get().strip() + number = int(raw_num) if raw_num.isdigit() else None + if game_name == "All Games": game_id = None else: @@ -158,6 +167,7 @@ class HistoryScreen(ttk.Frame): date_from=date_from, date_to=date_to, order="DESC" if not self._sort_asc else "ASC", + number=number, ) self._populate(rows) @@ -214,6 +224,7 @@ class HistoryScreen(ttk.Frame): self._from_entry.insert(0, "YYYY-MM-DD") self._to_entry.delete(0, "end") self._to_entry.insert(0, "YYYY-MM-DD") + self._number_var.set("") self._sort_col = "date" self._sort_asc = False for c in _COLUMNS: @@ -237,6 +248,10 @@ class HistoryScreen(ttk.Frame): date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None return game_id, date_from, date_to + def _active_number(self): + raw = self._number_var.get().strip() + return int(raw) if raw.isdigit() else None + def _export_excel(self): ensure_exports_dir() fp = filedialog.asksaveasfilename(