diff --git a/CLAUDE.md b/CLAUDE.md index e2cebdc..fa429da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -341,6 +341,19 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 16 — Lottery Ball Display +- [x] Write `ui/widgets.py` + - [x] `ball_color(number, is_bonus) -> (bg, fg)` — range-based colour lookup (pure, no Tk) + - [x] `BallsBar(tk.Canvas)` — draws numbered balls; supports `highlights` dict for per-ball colour override; inherits parent background +- [x] Update `ui/dashboard.py` — last-draw cards use `BallsBar` for numbers; hot-numbers section uses small-radius (`r=11`) balls +- [x] Update `ui/history.py` — ball detail strip below treeview; shows `BallsBar` for selected row; clears on filter/populate +- [x] Update `ui/predictor_ui.py` + - [x] Generate tab: detail strip shows selected ticket as balls + - [x] Check Ticket tab: detail strip shows ticket (green=matched, grey=unmatched) and draw numbers (green=matched) side by side +- [x] Write `tests/test_widgets.py` — 12 tests (308/308 total passing) + +--- + ### ✅ Phase 15 — Odds Calculator - [x] Write `core/odds.py` - [x] `total_combinations(main_count, main_max, bonus_count, bonus_max) -> int` diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..54bf37f --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,124 @@ +""" +tests/test_widgets.py +--------------------- +Tests for ui/widgets.py — ball_color() is pure and testable without a display. +BallsBar creation tests require Tkinter and are skipped when unavailable. +""" + +import pytest +from ui.widgets import ball_color, BallsBar + + +def _has_display(): + try: + import tkinter as tk + r = tk.Tk(); r.withdraw(); r.destroy() + return True + except Exception: + return False + + +# ── ball_color — pure function, no display needed ───────────────────────────── + +def test_ball_color_returns_two_hex_strings(): + bg, fg = ball_color(7) + assert bg.startswith("#") and len(bg) == 7 + assert fg.startswith("#") and len(fg) == 7 + +def test_ball_color_bonus_is_red(): + bg, _ = ball_color(7, is_bonus=True) + assert bg == "#e74c3c" + +def test_ball_color_bonus_same_regardless_of_number(): + assert ball_color(1, is_bonus=True) == ball_color(26, is_bonus=True) + assert ball_color(99, is_bonus=True) == ball_color(5, is_bonus=True) + +def test_ball_color_different_ranges_have_different_colors(): + bgs = {ball_color(n)[0] for n in [1, 15, 25, 35, 45, 55, 65, 75]} + assert len(bgs) >= 5 # at least 5 distinct background colors + +def test_ball_color_boundary_values(): + # Each range boundary should resolve without error + for n in [1, 9, 10, 19, 20, 29, 30, 39, 40, 49, 50, 59, 60, 69, 70, 99]: + bg, fg = ball_color(n) + assert bg.startswith("#") + assert fg.startswith("#") + +def test_ball_color_out_of_defined_range(): + # Number > 99 falls back gracefully + bg, fg = ball_color(200) + assert bg.startswith("#") + assert fg.startswith("#") + +def test_ball_color_is_deterministic(): + for n in [1, 7, 14, 22, 35, 49, 69]: + assert ball_color(n) == ball_color(n) + +def test_ball_color_non_bonus_not_red(): + # Main balls in defined ranges should not be the bonus red + for n in range(1, 70): + bg, _ = ball_color(n, is_bonus=False) + assert bg != "#e74c3c" or n >= 60 # only 60-69 range is red + + +# ── BallsBar — needs display ────────────────────────────────────────────────── + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_ballsbar_creates_widget(): + import tkinter as tk + try: + root = tk.Tk(); root.withdraw() + except Exception: + pytest.skip("Tkinter init failed") + try: + bar = BallsBar(root, numbers=[1, 7, 14, 22, 35], bonus=3) + assert bar.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_ballsbar_no_bonus(): + import tkinter as tk + try: + root = tk.Tk(); root.withdraw() + except Exception: + pytest.skip("Tkinter init failed") + try: + bar = BallsBar(root, numbers=[5, 12, 30, 44, 66]) + assert bar.winfo_exists() + assert int(bar.cget("width")) > 0 + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_ballsbar_with_highlights(): + import tkinter as tk + try: + root = tk.Tk(); root.withdraw() + except Exception: + pytest.skip("Tkinter init failed") + try: + hi = {7: ("#27ae60", "#ffffff"), 14: ("#cccccc", "#888888")} + bar = BallsBar(root, numbers=[1, 7, 14, 22, 35], highlights=hi) + assert bar.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_ballsbar_larger_radius(): + import tkinter as tk + try: + root = tk.Tk(); root.withdraw() + except Exception: + pytest.skip("Tkinter init failed") + try: + bar_small = BallsBar(root, numbers=[1, 2, 3], radius=11) + bar_normal = BallsBar(root, numbers=[1, 2, 3], radius=14) + w_small = int(bar_small.cget("width")) + w_normal = int(bar_normal.cget("width")) + assert w_normal > w_small + finally: + root.destroy() diff --git a/ui/dashboard.py b/ui/dashboard.py index 98d50d2..50914d0 100644 --- a/ui/dashboard.py +++ b/ui/dashboard.py @@ -16,6 +16,7 @@ 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 +from ui.widgets import BallsBar # Weekday indices: Monday=0 … Sunday=6 _DRAW_DAYS: dict[str, list[int]] = { @@ -131,13 +132,10 @@ class DashboardScreen(ttk.Frame): else: ttk.Label(card, text=draw["draw_date"], foreground="#555555", font=_CARD_FONT).pack(anchor="w") - ttk.Label(card, - text=_fmt_numbers(draw["numbers"]), - font=_NUMBER_FONT, foreground="#1a1a2e").pack(anchor="w", pady=(6, 2)) - if game["bonus_count"] > 0 and draw["bonus"]: - bonus_label = "Bonus" if game["name"] != "Powerball" else "Powerball" - ttk.Label(card, text=f"{bonus_label}: {draw['bonus']}", - foreground="#2471a3", font=_CARD_FONT).pack(anchor="w") + nums = [int(n) for n in draw["numbers"].split(",") if n.strip().isdigit()] + bonus = (int(draw["bonus"]) if draw["bonus"] and str(draw["bonus"]).isdigit() + and game["bonus_count"] > 0 else None) + BallsBar(card, numbers=nums, bonus=bonus).pack(anchor="w", pady=(6, 2)) if draw["multiplier"]: ttk.Label(card, text=f"Multiplier: {draw['multiplier']}", foreground="#777777", font=_CARD_FONT).pack(anchor="w") @@ -191,10 +189,8 @@ class DashboardScreen(ttk.Frame): font=_CARD_FONT).pack(side="left") continue - top5 = sorted(freq, key=freq.get, reverse=True)[:5] - nums_str = " ".join(f"{n:02d}" for n in sorted(top5)) - ttk.Label(row, text=nums_str, font=_CARD_FONT, - foreground="#1a5276").pack(side="left") - counts_str = " ".join(f"({freq[n]}×)" for n in sorted(top5)) - ttk.Label(row, text=f" {counts_str}", foreground="#aaaaaa", + top5 = sorted(sorted(freq, key=freq.get, reverse=True)[:5]) + BallsBar(row, numbers=top5, radius=11).pack(side="left", padx=(0, 6)) + counts_str = " ".join(f"({freq[n]}×)" for n in top5) + ttk.Label(row, text=counts_str, foreground="#aaaaaa", font=_CARD_FONT).pack(side="left") diff --git a/ui/history.py b/ui/history.py index 74a8e25..60a92d8 100644 --- a/ui/history.py +++ b/ui/history.py @@ -13,6 +13,7 @@ from db.models import get_all_games, get_game_by_name, get_draws_with_game from core.exporter import ( export_draws_excel, export_draws_csv, default_path, ensure_exports_dir, ) +from ui.widgets import BallsBar _COLUMNS = ("game", "date", "numbers", "bonus", "multiplier", "source") _LABELS = { @@ -115,6 +116,15 @@ class HistoryScreen(ttk.Frame): tree_frame.rowconfigure(0, weight=1) tree_frame.columnconfigure(0, weight=1) + # ── Ball detail strip ───────────────────────────────────────────────── + ttk.Separator(self, orient="horizontal").pack(fill="x", padx=6) + self._detail_frame = ttk.Frame(self, padding=(8, 3, 8, 3)) + self._detail_frame.pack(fill="x") + ttk.Label(self._detail_frame, text="Select a row to preview.", + foreground="#aaaaaa", font=("TkDefaultFont", 8)).pack(anchor="w") + + self._tree.bind("<>", self._on_row_select) + # ── Row count ───────────────────────────────────────────────────────── self._count_var = tk.StringVar(value="0 rows") ttk.Label(self, textvariable=self._count_var, anchor="e", @@ -173,6 +183,7 @@ class HistoryScreen(ttk.Frame): def _populate(self, rows): self._tree.delete(*self._tree.get_children()) + self._clear_detail() for row in rows: self._tree.insert("", "end", values=( row["game_name"], @@ -185,6 +196,38 @@ class HistoryScreen(ttk.Frame): count = len(rows) self._count_var.set(f"{count} row{'s' if count != 1 else ''}") + def _clear_detail(self): + for w in self._detail_frame.winfo_children(): + w.destroy() + ttk.Label(self._detail_frame, text="Select a row to preview.", + foreground="#aaaaaa", font=("TkDefaultFont", 8)).pack(anchor="w") + + def _on_row_select(self, _event=None): + sel = self._tree.selection() + if not sel: + self._clear_detail() + return + vals = self._tree.item(sel[0], "values") + # vals: (game, date, numbers_fmt, bonus, multiplier, source) + nums_text = vals[2] # e.g. "1 13 36 61 69" + bonus_text = str(vals[3]) + try: + nums = [int(x) for x in nums_text.split() if x.isdigit()] + bonus = int(bonus_text) if bonus_text.isdigit() else None + except Exception: + self._clear_detail() + return + if not nums: + self._clear_detail() + return + for w in self._detail_frame.winfo_children(): + w.destroy() + row_frame = ttk.Frame(self._detail_frame) + row_frame.pack(anchor="w") + ttk.Label(row_frame, text=vals[1], foreground="#555555", + font=("TkDefaultFont", 8), width=11).pack(side="left") + BallsBar(row_frame, numbers=nums, bonus=bonus).pack(side="left") + # ── Sorting ─────────────────────────────────────────────────────────────── def _sort_by(self, col): diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py index df1072f..e35d764 100644 --- a/ui/predictor_ui.py +++ b/ui/predictor_ui.py @@ -23,6 +23,7 @@ from core.predictor import ( ) from core.checker import check_ticket, parse_numbers from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir +from ui.widgets import BallsBar logger = logging.getLogger(__name__) @@ -137,6 +138,13 @@ class PredictorScreen(ttk.Frame): self._tree.pack(side="left", fill="both", expand=True) vsb.pack(side="right", fill="y") + self._tree.bind("<>", self._on_gen_row_select) + + # ── Ball detail strip ───────────────────────────────────────────────── + ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6) + self._gen_detail = ttk.Frame(parent, padding=(8, 3)) + self._gen_detail.pack(fill="x") + # Bottom bar bottom = ttk.Frame(parent, padding=(6, 4)) bottom.pack(fill="x") @@ -352,9 +360,28 @@ class PredictorScreen(ttk.Frame): self._refresh_saved() logger.info("[PREDICT] Saved %d prediction(s) to DB", saved) + def _on_gen_row_select(self, _event=None): + for w in self._gen_detail.winfo_children(): + w.destroy() + sel = self._tree.selection() + if not sel: + return + vals = self._tree.item(sel[0], "values") + # vals: (#, numbers_fmt, bonus) + try: + nums = [int(x) for x in str(vals[1]).split() if x.isdigit()] + b_txt = str(vals[2]) + bonus = int(b_txt) if b_txt.isdigit() else None + except Exception: + return + if nums: + BallsBar(self._gen_detail, numbers=nums, bonus=bonus).pack(anchor="w") + def _clear(self): self._tickets = [] self._tree.delete(*self._tree.get_children()) + for w in self._gen_detail.winfo_children(): + w.destroy() self._save_btn.config(state="disabled") self._status_var.set("") @@ -491,6 +518,13 @@ class PredictorScreen(ttk.Frame): self._chk_tree.tag_configure("high", foreground="#1e8449") self._chk_tree.tag_configure("low", foreground="#555555") + self._chk_tree.bind("<>", self._on_chk_row_select) + + # ── Ball detail strip ───────────────────────────────────────────────── + ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6) + self._chk_detail = ttk.Frame(parent, padding=(8, 3)) + self._chk_detail.pack(fill="x") + # Summary label self._chk_summary_var = tk.StringVar() ttk.Label(parent, textvariable=self._chk_summary_var, @@ -569,11 +603,64 @@ class PredictorScreen(ttk.Frame): f"{total} draw{'s' if total != 1 else ''} matched • Best: {best}" ) + def _on_chk_row_select(self, _event=None): + for w in self._chk_detail.winfo_children(): + w.destroy() + sel = self._chk_tree.selection() + if not sel: + return + vals = self._chk_tree.item(sel[0], "values") + # vals: (date, draw_numbers_fmt, bonus, main_hits, bonus_hit, tier) + try: + draw_nums = [int(x) for x in str(vals[1]).split() if x.isdigit()] + db_txt = str(vals[2]) + draw_bonus = int(db_txt) if db_txt.isdigit() else None + except Exception: + return + if not draw_nums: + return + + # Ticket numbers from the input entries + try: + ticket_nums = parse_numbers(self._chk_nums_var.get()) + except Exception: + ticket_nums = [] + raw_b = self._chk_bonus_var.get().strip() + ticket_bonus = int(raw_b) if raw_b.isdigit() else None + + ticket_set = set(ticket_nums) + draw_set = set(draw_nums) + _GREEN = ("#27ae60", "#ffffff") + _GREY = ("#cccccc", "#888888") + + ticket_hi = {n: (_GREEN if n in draw_set else _GREY) for n in ticket_nums} + draw_hi = {n: _GREEN for n in draw_nums if n in ticket_set} + if ticket_bonus is not None: + ticket_hi[ticket_bonus] = ( + _GREEN if draw_bonus is not None and ticket_bonus == draw_bonus else _GREY + ) + if draw_bonus is not None and ticket_bonus is not None and draw_bonus == ticket_bonus: + draw_hi[draw_bonus] = _GREEN + + grid = ttk.Frame(self._chk_detail) + grid.pack(anchor="w") + if ticket_nums: + ttk.Label(grid, text="Ticket:", foreground="#555555", + font=("TkDefaultFont", 8), width=7).grid(row=0, column=0, sticky="w") + BallsBar(grid, numbers=ticket_nums, bonus=ticket_bonus, + highlights=ticket_hi).grid(row=0, column=1, sticky="w") + ttk.Label(grid, text="Draw:", foreground="#555555", + font=("TkDefaultFont", 8), width=7).grid(row=1, column=0, sticky="w") + BallsBar(grid, numbers=draw_nums, bonus=draw_bonus, + highlights=draw_hi).grid(row=1, column=1, sticky="w") + 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()) + for w in self._chk_detail.winfo_children(): + w.destroy() self._chk_summary_var.set("") def _load_check_games(self): diff --git a/ui/widgets.py b/ui/widgets.py new file mode 100644 index 0000000..8ec258d --- /dev/null +++ b/ui/widgets.py @@ -0,0 +1,109 @@ +""" +ui/widgets.py +------------- +Shared reusable widgets. + + ball_color(number, is_bonus) -> (bg_hex, fg_hex) + BallsBar — tk.Canvas that draws a horizontal row of numbered lottery balls +""" + +import tkinter as tk + +# (low, high, bg_hex, fg_hex) — range-based colour scheme +_RANGES = [ + ( 1, 9, "#f0f0f0", "#333333"), + (10, 19, "#ffd700", "#333333"), + (20, 29, "#ff7f50", "#ffffff"), + (30, 39, "#4a9edd", "#ffffff"), + (40, 49, "#3cb371", "#ffffff"), + (50, 59, "#9b59b6", "#ffffff"), + (60, 69, "#e74c3c", "#ffffff"), + (70, 99, "#555555", "#ffffff"), +] +_BONUS_BG = "#e74c3c" +_BONUS_FG = "#ffffff" +_FALLBACK = ("#aaaaaa", "#333333") + + +def ball_color(number: int, is_bonus: bool = False) -> tuple[str, str]: + """Return (bg_hex, fg_hex) for a lottery ball. Pure function, no Tk needed.""" + if is_bonus: + return _BONUS_BG, _BONUS_FG + for lo, hi, bg, fg in _RANGES: + if lo <= number <= hi: + return bg, fg + return _FALLBACK + + +class BallsBar(tk.Canvas): + """ + Horizontal row of numbered lottery balls drawn on a tk.Canvas. + + numbers — list of main ball integers + bonus — optional single bonus ball integer (drawn after a gap, in red) + radius — ball radius in pixels (default 14 → 28 px diameter) + highlights — {number: (bg_hex, fg_hex)} overrides for specific balls + (e.g. green for matched, grey for unmatched in ticket checker) + """ + + _GAP = 3 # px between adjacent balls + _SEP = 10 # extra px gap before the bonus ball + + def __init__( + self, + parent, + numbers: list[int], + bonus: int | None = None, + radius: int = 14, + highlights: dict | None = None, + **kwargs, + ): + self._radius = radius + self._highlights = highlights or {} + + diam = radius * 2 + n_main = len(numbers) + n_bonus = 1 if bonus is not None else 0 + + width = ( + n_main * (diam + self._GAP) + + (self._SEP + n_bonus * (diam + self._GAP) if n_bonus else 0) + + 4 + ) + height = diam + 4 + + # Inherit parent background so the canvas blends in seamlessly + if "bg" not in kwargs and "background" not in kwargs: + try: + kwargs["background"] = parent.cget("background") + except Exception: + pass + + kwargs.setdefault("highlightthickness", 0) + kwargs.setdefault("bd", 0) + super().__init__(parent, width=width, height=height, **kwargs) + + self._draw(numbers, bonus, diam) + + def _draw(self, numbers: list[int], bonus: int | None, diam: int): + r = self._radius + x = 2 + r + y = r + 2 + + for num in numbers: + bg, fg = self._highlights.get(num) or ball_color(num, False) + self._ball(x, y, num, bg, fg) + x += diam + self._GAP + + if bonus is not None: + x += self._SEP + bg, fg = self._highlights.get(bonus) or ball_color(bonus, True) + self._ball(x, y, bonus, bg, fg) + + def _ball(self, cx: int, cy: int, number: int, bg: str, fg: str): + r = self._radius - 1 + self.create_oval(cx - r, cy - r, cx + r, cy + r, + fill=bg, outline="#aaaaaa", width=1) + size = 7 if number >= 10 else 8 + self.create_text(cx, cy, text=str(number), + font=("TkDefaultFont", size, "bold"), fill=fg)