05/23 Phase 9
This commit is contained in:
@@ -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\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
Reference in New Issue
Block a user