Files
2026-05-23 17:36:33 -04:00

227 lines
8.0 KiB
Python

"""
main.py
-------
LottoSight entry point.
Creates the main window with toolbar, content area, and status bar.
Wires auto-fetch on launch (background thread) and 24hr APScheduler job.
"""
import logging
import os
import threading
import tkinter as tk
from tkinter import ttk
try:
import truststore
truststore.inject_into_ssl()
except Exception:
pass # not installed or not needed on this platform
from apscheduler.schedulers.background import BackgroundScheduler
from db.database import init_db
from core.fetcher import fetch_all
from core.exporter import create_icon_png
from core.paths import bundled_asset, user_data_dir
from ui.statusbar import StatusBar
from ui.dashboard import DashboardScreen
from ui.history import HistoryScreen
from ui.analysis import AnalysisScreen
from ui.predictor_ui import PredictorScreen
from ui.settings import SettingsScreen
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
class LottoSightApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("LottoSight")
self.minsize(900, 600)
try:
self.iconphoto(True, tk.PhotoImage(file=bundled_asset("assets/icon.png")))
except Exception:
pass
self._fetch_lock = threading.Lock()
self._scheduler: BackgroundScheduler | None = None
self._current_screen: tk.Widget | None = None
self._screens: dict[str, tk.Widget] = {}
self._build_ui()
self._start_scheduler()
self._navigate("Dashboard")
self._launch_fetch()
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
# Toolbar
toolbar = ttk.Frame(self, relief="raised", padding=(8, 4))
toolbar.pack(side="top", fill="x")
ttk.Label(
toolbar, text="LottoSight", font=("TkDefaultFont", 13, "bold")
).pack(side="left", padx=(0, 16))
for name in ("Dashboard", "History", "Analysis", "Predictor", "Settings"):
ttk.Button(
toolbar, text=name, width=10,
command=lambda n=name: self._navigate(n),
).pack(side="left", padx=2)
self._fetch_btn = ttk.Button(
toolbar, text="Fetch Now", command=self._manual_fetch
)
self._fetch_btn.pack(side="right", padx=4)
# Content area — screens are packed/unpacked inside here
self._content = ttk.Frame(self)
self._content.pack(side="top", fill="both", expand=True)
# Status bar
self._statusbar = StatusBar(self)
self._statusbar.pack(side="bottom", fill="x")
# ── Navigation ────────────────────────────────────────────────────────────
def _navigate(self, screen_name: str):
logger.info("Navigate → %s", screen_name)
if self._current_screen is not None:
self._current_screen.pack_forget()
if screen_name not in self._screens:
self._screens[screen_name] = self._make_screen(screen_name)
self._current_screen = self._screens[screen_name]
self._current_screen.pack(fill="both", expand=True)
# Refresh data-bearing screens each time they're shown
if hasattr(self._current_screen, "refresh"):
self._current_screen.refresh()
def _make_screen(self, name: str) -> tk.Widget:
if name == "Dashboard":
return DashboardScreen(self._content, on_fetch=self._manual_fetch)
if name == "History":
return HistoryScreen(self._content)
if name == "Analysis":
return AnalysisScreen(self._content)
if name == "Predictor":
return PredictorScreen(self._content)
if name == "Settings":
return SettingsScreen(self._content, on_fetch=self._manual_fetch)
placeholder = ttk.Label(
self._content, text=f"{name} — coming soon",
font=("TkDefaultFont", 14), anchor="center",
)
return placeholder
# ── Fetch wiring ──────────────────────────────────────────────────────────
def _start_scheduler(self):
self._scheduler = BackgroundScheduler()
self._scheduler.add_job(
self._launch_fetch, "interval", hours=24, id="auto_fetch_24h"
)
self._scheduler.start()
logger.info("[FETCH] 24hr scheduler started")
def _launch_fetch(self):
"""Start a background fetch thread (skips if one is already running)."""
thread = threading.Thread(
target=self._run_fetch, daemon=True, name="lottosight-fetch"
)
thread.start()
def _manual_fetch(self):
self._launch_fetch()
def _run_fetch(self):
"""Worker: fetch all sources, then update UI on the main thread."""
if not self._fetch_lock.acquire(blocking=False):
logger.info("[FETCH] Fetch already in progress — skipping")
return
try:
self.after(0, self._on_fetch_start)
results = fetch_all()
self.after(0, lambda r=results: self._on_fetch_done(r))
except Exception as e:
logger.error("[ERROR] fetch_all raised unexpectedly: %s", e, exc_info=True)
self.after(0, self._statusbar.set_ready)
finally:
self._fetch_lock.release()
def _on_fetch_start(self):
self._fetch_btn.config(state="disabled", text="Fetching…")
self._statusbar.set_fetching()
def _on_fetch_done(self, results: list):
self._fetch_btn.config(state="normal", text="Fetch Now")
self._statusbar.update_fetch_results(results)
total_added = sum(r.get("added", 0) for r in results)
if total_added > 0:
self._check_predictions_vs_latest()
# Refresh the current screen if it can show new data
if self._current_screen and hasattr(self._current_screen, "refresh"):
self._current_screen.refresh()
def _check_predictions_vs_latest(self):
"""Compare all saved predictions against the most recent draw per game."""
from db.models import get_predictions, get_last_draw
preds = get_predictions(limit=100_000)
if not preds:
return
best = 0
hit_count = 0
for pred in preds:
draw = get_last_draw(pred["game_id"])
if not draw:
continue
pred_nums = {int(n) for n in pred["numbers"].split(",") if n.strip().isdigit()}
draw_nums = {int(n) for n in draw["numbers"].split(",") if n.strip().isdigit()}
matches = len(pred_nums & draw_nums)
bonus_hit = (
pred.get("bonus") and draw.get("bonus")
and str(pred["bonus"]) == str(draw["bonus"])
)
if matches >= 2 or bonus_hit:
hit_count += 1
best = max(best, matches)
if hit_count > 0:
self._statusbar.append_match_alert(
f"{hit_count} saved prediction(s) matched ≥2 numbers (best: {best}/main)"
)
# ── Lifecycle ─────────────────────────────────────────────────────────────
def on_close(self):
if self._scheduler:
self._scheduler.shutdown(wait=False)
self.destroy()
def main():
init_db()
icon_path = bundled_asset("assets/icon.png")
if not os.path.exists(icon_path):
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
create_icon_png(icon_path)
app = LottoSightApp()
app.protocol("WM_DELETE_WINDOW", app.on_close)
app.mainloop()
if __name__ == "__main__":
main()