05/23 Phase 12

This commit is contained in:
2026-05-23 12:37:01 -04:00
parent 78e88ae8ab
commit ae0c64f731
7 changed files with 172 additions and 1 deletions
+14
View File
@@ -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 `<Return>` 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 ### ✅ Phase 11 — Saved Predictions Viewer
- [x] Add `delete_prediction(pred_id)` and `delete_all_predictions(game_id=None)` to `db/models.py` - [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 - [x] Refactor `ui/predictor_ui.py` to ttk.Notebook with two tabs
Binary file not shown.
+7 -1
View File
@@ -224,10 +224,12 @@ 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"): 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. Like get_draws() but JOINs games so each row includes game_name.
game_id=None returns draws for all games. 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. Used by the History screen.
""" """
conn = get_connection() 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: if date_to:
query += " AND d.draw_date <= ?" query += " AND d.draw_date <= ?"
params.append(date_to) 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" order_sql = "DESC" if order.upper() == "DESC" else "ASC"
query += f" ORDER BY d.draw_date {order_sql}, g.name ASC" query += f" ORDER BY d.draw_date {order_sql}, g.name ASC"
+45
View File
@@ -4,6 +4,8 @@ tests/test_dashboard.py
Tests for the Dashboard screen data layer and UI smoke tests. Tests for the Dashboard screen data layer and UI smoke tests.
""" """
from datetime import date
import pytest import pytest
from db.models import ( from db.models import (
get_all_games, get_game_by_name, get_last_draw, 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 db.database import get_db_stats
from core.analyzer import frequency_analysis from core.analyzer import frequency_analysis
from ui.dashboard import _next_draw_date
# ── Data-layer checks used by the Dashboard ─────────────────────────────────── # ── 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 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 ──────────────────────────────────────────────────────────── # ── UI smoke tests ────────────────────────────────────────────────────────────
def _has_display(): def _has_display():
+66
View File
@@ -122,6 +122,72 @@ def test_combined_filters(tmp_db):
assert rows[0]["draw_date"] == "2024-03-01" 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) ─────────────────────────────────────── # ── UI smoke test (skipped on headless) ───────────────────────────────────────
def _has_display(): def _has_display():
+25
View File
@@ -11,11 +11,31 @@ Sections:
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from datetime import date, timedelta
from db.database import get_db_stats from db.database import get_db_stats
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
from core.analyzer import frequency_analysis 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") _HEADER_FONT = ("TkDefaultFont", 10, "bold")
_CARD_FONT = ("TkDefaultFont", 9) _CARD_FONT = ("TkDefaultFont", 9)
_NUMBER_FONT = ("TkDefaultFont", 12, "bold") _NUMBER_FONT = ("TkDefaultFont", 12, "bold")
@@ -122,6 +142,11 @@ class DashboardScreen(ttk.Frame):
ttk.Label(card, text=f"Multiplier: {draw['multiplier']}", ttk.Label(card, text=f"Multiplier: {draw['multiplier']}",
foreground="#777777", font=_CARD_FONT).pack(anchor="w") 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): def _refresh_db_summary(self):
for w in self._db_body.winfo_children(): for w in self._db_body.winfo_children():
w.destroy() w.destroy()
+15
View File
@@ -78,6 +78,12 @@ class HistoryScreen(ttk.Frame):
self._to_entry.bind("<FocusIn>", lambda e: self._clear_hint(self._to_entry, "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")) self._to_entry.bind("<FocusOut>", 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("<Return>", lambda e: self._apply_filter())
ttk.Button(bar, text="Search", command=self._apply_filter).pack(side="left", padx=(0, 4)) 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="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_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 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": if game_name == "All Games":
game_id = None game_id = None
else: else:
@@ -158,6 +167,7 @@ class HistoryScreen(ttk.Frame):
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
order="DESC" if not self._sort_asc else "ASC", order="DESC" if not self._sort_asc else "ASC",
number=number,
) )
self._populate(rows) self._populate(rows)
@@ -214,6 +224,7 @@ class HistoryScreen(ttk.Frame):
self._from_entry.insert(0, "YYYY-MM-DD") self._from_entry.insert(0, "YYYY-MM-DD")
self._to_entry.delete(0, "end") self._to_entry.delete(0, "end")
self._to_entry.insert(0, "YYYY-MM-DD") self._to_entry.insert(0, "YYYY-MM-DD")
self._number_var.set("")
self._sort_col = "date" self._sort_col = "date"
self._sort_asc = False self._sort_asc = False
for c in _COLUMNS: 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 date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
return game_id, date_from, date_to 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): def _export_excel(self):
ensure_exports_dir() ensure_exports_dir()
fp = filedialog.asksaveasfilename( fp = filedialog.asksaveasfilename(