""" ui/dashboard.py --------------- Dashboard — the home screen shown on app launch. Sections: • Last Draw Results — most-recent draw card per active game • Database Summary — draw counts + prediction total • Hot Numbers — top-5 most frequent numbers per game (last 100 draws) """ 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, gap_analysis from ui.widgets import BallsBar # 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") _SECTION_PAD = (0, 20) # top, bottom def _fmt_numbers(raw: str) -> str: """'1,13,36,61,69' → '01 13 36 61 69'""" if not raw: return "" return " ".join(f"{int(n):02d}" for n in raw.split(",")) 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 ──────────────────────────────────────────────────────────────── def _build_ui(self): # Scrollable canvas so the screen stays clean at small sizes canvas = tk.Canvas(self, highlightthickness=0) vsb = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") canvas.pack(side="left", fill="both", expand=True) inner = ttk.Frame(canvas, padding=(28, 16, 28, 20)) win = canvas.create_window((0, 0), window=inner, anchor="nw") def _resize(event=None): canvas.configure(scrollregion=canvas.bbox("all")) canvas.itemconfig(win, width=canvas.winfo_width()) inner.bind("", lambda e: _resize()) canvas.bind("", lambda e: _resize()) canvas.bind("", lambda e: canvas.bind_all("", lambda ev: canvas.yview_scroll(-1 * (ev.delta // 120), "units"))) canvas.bind("", 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): f = ttk.Frame(self._inner) f.pack(fill="x", pady=(0, 8)) ttk.Label(f, text=title, font=_HEADER_FONT).pack(anchor="w") ttk.Separator(f, orient="horizontal").pack(fill="x", pady=(3, 0)) body = ttk.Frame(self._inner) body.pack(fill="x", pady=_SECTION_PAD) return body def _draw_sections(self): self._last_draw_body = self._section_header("Last Draw Results") self._db_body = self._section_header("Database Summary") self._hot_body = self._section_header("Hot Numbers (last 100 draws)") self._overdue_body = self._section_header("Most Overdue Numbers") # ── 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() self._refresh_overdue() def _refresh_last_draws(self): for w in self._last_draw_body.winfo_children(): w.destroy() games = self._filtered_games() if not games: ttk.Label(self._last_draw_body, text="No active games.", foreground="#aaaaaa").pack(anchor="w") return row_frame = ttk.Frame(self._last_draw_body) row_frame.pack(fill="x") for col_idx, game in enumerate(games): draw = get_last_draw(game["id"]) card = ttk.LabelFrame( row_frame, text=game["name"], padding=(14, 10, 14, 12) ) 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") else: ttk.Label(card, text=draw["draw_date"], foreground="#555555", 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") 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() stats = get_db_stats() # {game_name: count} for name, count in sorted(stats.items()): row = ttk.Frame(self._db_body) row.pack(fill="x", pady=1) ttk.Label(row, text=f"{name}:", width=18, anchor="w", font=_CARD_FONT).pack(side="left") ttk.Label(row, text=f"{count:,} draws", foreground="#555555", font=_CARD_FONT).pack(side="left") pred_total = len(get_predictions(limit=100_000)) row = ttk.Frame(self._db_body) row.pack(fill="x", pady=(4, 0)) ttk.Label(row, text="Saved predictions:", width=18, anchor="w", font=_CARD_FONT).pack(side="left") ttk.Label(row, text=str(pred_total), foreground="#555555", font=_CARD_FONT).pack(side="left") def _refresh_hot_numbers(self): for w in self._hot_body.winfo_children(): w.destroy() games = self._filtered_games() if not games: ttk.Label(self._hot_body, text="No active games.", foreground="#aaaaaa").pack(anchor="w") return for game in games: freq = frequency_analysis(game["id"], last_n=100) row = ttk.Frame(self._hot_body) row.pack(fill="x", pady=3) ttk.Label(row, text=f"{game['name']}:", width=18, anchor="w", font=_CARD_FONT).pack(side="left") if not freq: ttk.Label(row, text="No data yet.", foreground="#aaaaaa", font=_CARD_FONT).pack(side="left") continue 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") def _refresh_overdue(self): for w in self._overdue_body.winfo_children(): w.destroy() games = self._filtered_games() if not games: ttk.Label(self._overdue_body, text="No active games.", foreground="#aaaaaa").pack(anchor="w") return _ORANGE = ("#e67e22", "#ffffff") for game in games: gaps = gap_analysis(game["id"]) # {number: gap} row = ttk.Frame(self._overdue_body) row.pack(fill="x", pady=3) ttk.Label(row, text=f"{game['name']}:", width=18, anchor="w", font=_CARD_FONT).pack(side="left") if not gaps: ttk.Label(row, text="No data yet.", foreground="#aaaaaa", font=_CARD_FONT).pack(side="left") continue top5 = sorted(sorted(gaps, key=gaps.get, reverse=True)[:5]) highlights = {n: _ORANGE for n in top5} BallsBar(row, numbers=top5, radius=11, highlights=highlights).pack(side="left", padx=(0, 6)) gaps_str = " ".join(f"({gaps[n]} ago)" for n in top5) ttk.Label(row, text=gaps_str, foreground="#aaaaaa", font=_CARD_FONT).pack(side="left")