From 3ac30787d54532e5150779fd3962638d48d8b44d Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 12:11:14 -0400 Subject: [PATCH] 05/23 Phase 9 --- .claude/settings.local.json | 3 +- CLAUDE.md | 12 +++ data/lottosight.db | Bin 557056 -> 557056 bytes main.py | 8 +- tests/test_dashboard.py | 148 ++++++++++++++++++++++++++++++ ui/dashboard.py | 175 ++++++++++++++++++++++++++++++++++++ 6 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 tests/test_dashboard.py create mode 100644 ui/dashboard.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9780c4e..987da7c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(python -m pytest tests/test_settings.py -v)", "Bash(python -m pytest tests/ -v --tb=short)", "Bash(python -m pytest tests/ -q --tb=short)", - "Bash(python -m PyInstaller lottosight.spec --clean)" + "Bash(python -m PyInstaller lottosight.spec --clean)", + "Bash(python -c \"from ui.dashboard import DashboardScreen\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index d92ce39..7fd2478 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,18 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 9 — Dashboard Screen +- [x] Write `ui/dashboard.py` + - [x] Last Draw Results — card per active game (date, numbers, bonus, multiplier) + - [x] Database Summary — draw counts per game + prediction total + - [x] Hot Numbers — top-5 most-frequent per game (last 100 draws) with counts + - [x] Scrollable canvas layout (same pattern as Settings) + - [x] `on_fetch` callback injection for future toolbar wiring +- [x] Wire Dashboard into `main.py` — replaces "coming soon" placeholder; opens on launch +- [x] 12 tests (177/177 total passing) + +--- + ### ✅ Phase 8 — Packaging - [x] Write `core/paths.py` — `user_data_dir()` + `bundled_asset()` helpers (frozen-aware) - [x] Update `db/database.py` + `core/exporter.py` to use `user_data_dir()` so DB + exports land next to the .exe when frozen diff --git a/data/lottosight.db b/data/lottosight.db index 1c30682311a9f93b7a89c86cdcc8082354c444ce..275a2dbfe3de6cf62c3b2235478bc0d5ef434db1 100644 GIT binary patch delta 173 zcmZo@P-CI8nx#v2kNUhrNU-7ZbBMQ%X@{d9h$TCnK{sV|rq4YBAp?2Nnlr zQSMWd*&9^Zc#OFJa-Z5PC@_PY$xw7sud;xdm7#%^u~~6xa&l^Mv9ZWx#(rfXh@dIw z38=7v@Z@tn3Q%Da_UTYzL!n8%s?FU!?cF_$K+FWh%s|Wn#H>Kfw!OQDz4S8x2=zBn delta 100 zcmZo@P- tk.Widget: + if name == "Dashboard": + return DashboardScreen(self._content, on_fetch=self._manual_fetch) if name == "History": return HistoryScreen(self._content) if name == "Analysis": @@ -110,7 +113,6 @@ class LottoSightApp(tk.Tk): return PredictorScreen(self._content) if name == "Settings": return SettingsScreen(self._content, on_fetch=self._manual_fetch) - # Placeholder for screens added in later phases placeholder = ttk.Label( self._content, text=f"{name} — coming soon", font=("TkDefaultFont", 14), anchor="center", diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..b9d63aa --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,148 @@ +""" +tests/test_dashboard.py +------------------------ +Tests for the Dashboard screen data layer and UI smoke tests. +""" + +import pytest +from db.models import ( + get_all_games, get_game_by_name, get_last_draw, + insert_draw, insert_prediction, +) +from db.database import get_db_stats +from core.analyzer import frequency_analysis + + +# ── Data-layer checks used by the Dashboard ─────────────────────────────────── + +def test_last_draw_none_on_empty_db(tmp_db): + pb = get_game_by_name("Powerball") + assert get_last_draw(pb["id"]) is None + + +def test_last_draw_returns_most_recent(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-05", [5, 10, 20, 30, 40], bonus=15) + last = get_last_draw(pb["id"]) + assert last["draw_date"] == "2024-01-05" + + +def test_last_draw_numbers_parseable(tmp_db): + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7) + last = get_last_draw(pb["id"]) + nums = [int(n) for n in last["numbers"].split(",")] + assert sorted(nums) == [1, 13, 36, 61, 69] + + +def test_db_stats_all_games_present(tmp_db): + stats = get_db_stats() + assert "Powerball" in stats + assert "Mega Millions" in stats + + +def test_db_stats_counts_correct_after_inserts(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", [5, 10, 20, 30, 40], bonus=15) + stats = get_db_stats() + assert stats["Powerball"] == 2 + assert stats["Mega Millions"] == 0 + + +def test_hot_numbers_empty_returns_empty(tmp_db): + pb = get_game_by_name("Powerball") + assert frequency_analysis(pb["id"], last_n=100) == {} + + +def test_hot_numbers_top5_correct(tmp_db): + pb = get_game_by_name("Powerball") + # Insert 3 draws; numbers 1 and 13 appear most (3 times each) + insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7) + insert_draw(pb["id"], "2024-01-03", [1, 2, 13, 45, 69], bonus=15) + insert_draw(pb["id"], "2024-01-05", [1, 13, 22, 36, 55], bonus=3) + + freq = frequency_analysis(pb["id"], last_n=100) + top5 = sorted(freq, key=freq.get, reverse=True)[:5] + assert 1 in top5 + assert 13 in top5 + + +def test_predictions_count_correct(tmp_db): + from db.models import get_predictions + pb = get_game_by_name("Powerball") + insert_prediction(pb["id"], "Hot Numbers", [1, 2, 3, 4, 5], bonus=7) + insert_prediction(pb["id"], "Due Numbers", [5, 10, 20, 30, 40], bonus=15) + assert len(get_predictions(limit=100_000)) == 2 + + +# ── UI smoke tests ──────────────────────────────────────────────────────────── + +def _has_display(): + try: + import tkinter as tk + r = tk.Tk(); r.withdraw(); r.destroy() + return True + except Exception: + return False + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_dashboard_instantiates(tmp_db): + import tkinter as tk + from ui.dashboard import DashboardScreen + + root = tk.Tk(); root.withdraw() + try: + screen = DashboardScreen(root) + screen.refresh() + assert screen.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_dashboard_refresh_with_data(tmp_db): + import tkinter as tk + from ui.dashboard import DashboardScreen + + pb = get_game_by_name("Powerball") + insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7, multiplier="2x") + + root = tk.Tk(); root.withdraw() + try: + screen = DashboardScreen(root) + screen.refresh() + assert screen.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_dashboard_refresh_empty_db(tmp_db): + import tkinter as tk + from ui.dashboard import DashboardScreen + + root = tk.Tk(); root.withdraw() + try: + screen = DashboardScreen(root) + screen.refresh() + assert screen.winfo_exists() + finally: + root.destroy() + + +@pytest.mark.skipif(not _has_display(), reason="no display available") +def test_dashboard_on_fetch_callback(tmp_db): + import tkinter as tk + from ui.dashboard import DashboardScreen + + called = [] + root = tk.Tk(); root.withdraw() + try: + screen = DashboardScreen(root, on_fetch=lambda: called.append(1)) + screen.refresh() + assert screen.winfo_exists() + finally: + root.destroy() diff --git a/ui/dashboard.py b/ui/dashboard.py new file mode 100644 index 0000000..cc7f4b5 --- /dev/null +++ b/ui/dashboard.py @@ -0,0 +1,175 @@ +""" +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 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 + +_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._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, 20, 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 + 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)") + + # ── Refresh ─────────────────────────────────────────────────────────────── + + def refresh(self): + self._refresh_last_draws() + self._refresh_db_summary() + self._refresh_hot_numbers() + + def _refresh_last_draws(self): + for w in self._last_draw_body.winfo_children(): + w.destroy() + + games = get_all_games(active_only=True) + if not games: + ttk.Label(self._last_draw_body, text="No active games.", + foreground="#aaaaaa").pack(anchor="w") + return + + # Lay cards out in a horizontal row that wraps via grid + 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") + + 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") + 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") + if draw["multiplier"]: + ttk.Label(card, text=f"Multiplier: {draw['multiplier']}", + foreground="#777777", font=_CARD_FONT).pack(anchor="w") + + 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 = get_all_games(active_only=True) + 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(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", + font=_CARD_FONT).pack(side="left")