264 lines
10 KiB
Python
264 lines
10 KiB
Python
"""
|
||
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("<Configure>", lambda e: _resize())
|
||
canvas.bind("<Configure>", lambda e: _resize())
|
||
canvas.bind("<Enter>",
|
||
lambda e: canvas.bind_all("<MouseWheel>",
|
||
lambda ev: canvas.yview_scroll(-1 * (ev.delta // 120), "units")))
|
||
canvas.bind("<Leave>",
|
||
lambda e: canvas.unbind_all("<MouseWheel>"))
|
||
|
||
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("<<ComboboxSelected>>", 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")
|