05/23 Phase 9

This commit is contained in:
2026-05-23 12:11:14 -04:00
parent 524cdfda9a
commit 3ac30787d5
6 changed files with 342 additions and 4 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"Bash(python -m pytest tests/test_settings.py -v)", "Bash(python -m pytest tests/test_settings.py -v)",
"Bash(python -m pytest tests/ -v --tb=short)", "Bash(python -m pytest tests/ -v --tb=short)",
"Bash(python -m pytest tests/ -q --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\")"
] ]
} }
} }
+12
View File
@@ -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 ### ✅ Phase 8 — Packaging
- [x] Write `core/paths.py``user_data_dir()` + `bundled_asset()` helpers (frozen-aware) - [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 - [x] Update `db/database.py` + `core/exporter.py` to use `user_data_dir()` so DB + exports land next to the .exe when frozen
Binary file not shown.
+5 -3
View File
@@ -19,6 +19,7 @@ from core.fetcher import fetch_all
from core.exporter import create_icon_png from core.exporter import create_icon_png
from core.paths import bundled_asset, user_data_dir from core.paths import bundled_asset, user_data_dir
from ui.statusbar import StatusBar from ui.statusbar import StatusBar
from ui.dashboard import DashboardScreen
from ui.history import HistoryScreen from ui.history import HistoryScreen
from ui.analysis import AnalysisScreen from ui.analysis import AnalysisScreen
from ui.predictor_ui import PredictorScreen from ui.predictor_ui import PredictorScreen
@@ -50,8 +51,8 @@ class LottoSightApp(tk.Tk):
self._build_ui() self._build_ui()
self._start_scheduler() self._start_scheduler()
self._navigate("History") # open History as default for now self._navigate("Dashboard")
self._launch_fetch() # auto-fetch on startup self._launch_fetch()
# ── UI construction ─────────────────────────────────────────────────────── # ── UI construction ───────────────────────────────────────────────────────
@@ -102,6 +103,8 @@ class LottoSightApp(tk.Tk):
self._current_screen.refresh() self._current_screen.refresh()
def _make_screen(self, name: str) -> tk.Widget: def _make_screen(self, name: str) -> tk.Widget:
if name == "Dashboard":
return DashboardScreen(self._content, on_fetch=self._manual_fetch)
if name == "History": if name == "History":
return HistoryScreen(self._content) return HistoryScreen(self._content)
if name == "Analysis": if name == "Analysis":
@@ -110,7 +113,6 @@ class LottoSightApp(tk.Tk):
return PredictorScreen(self._content) return PredictorScreen(self._content)
if name == "Settings": if name == "Settings":
return SettingsScreen(self._content, on_fetch=self._manual_fetch) return SettingsScreen(self._content, on_fetch=self._manual_fetch)
# Placeholder for screens added in later phases
placeholder = ttk.Label( placeholder = ttk.Label(
self._content, text=f"{name} — coming soon", self._content, text=f"{name} — coming soon",
font=("TkDefaultFont", 14), anchor="center", font=("TkDefaultFont", 14), anchor="center",
+148
View File
@@ -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()
+175
View File
@@ -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("<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
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")