From 25534062543ab7bd6f7508405f086de5d070db39 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sat, 23 May 2026 16:31:55 -0400 Subject: [PATCH] 05/23 Phase 18,19 --- .claude/settings.local.json | 4 +- CLAUDE.md | 32 +++++ core/importer.py | 183 +++++++++++++++++++++++++++++ data/lottosight.db | Bin 557056 -> 557056 bytes db/database.py | 23 ++++ tests/test_backup.py | 104 ++++++++++++++++ tests/test_importer.py | 228 ++++++++++++++++++++++++++++++++++++ ui/dashboard.py | 37 +++++- ui/settings.py | 68 ++++++++++- 9 files changed, 674 insertions(+), 5 deletions(-) create mode 100644 core/importer.py create mode 100644 tests/test_backup.py create mode 100644 tests/test_importer.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c26e47e..4a34f60 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,9 @@ "Bash(python -c \"from ui.dashboard import DashboardScreen\")", "Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)", "Bash(python -m pytest tests/ -q)", - "Bash(python -c ' *)" + "Bash(python -c ' *)", + "Bash(python -m pytest tests/test_backup.py -v)", + "Bash(python -m pytest --tb=short -q)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 224b781..f7c24da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -341,6 +341,38 @@ All actions are logged to console and optionally to a log file: --- +### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup +- [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed +- [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing +- [x] Update `ui/dashboard.py` + - [x] Import `gap_analysis` from `core.analyzer` + - [x] Add "Most Overdue Numbers" section below Hot Numbers + - [x] `_refresh_overdue()` — top-5 highest-gap numbers per active game, shown as orange-highlighted `BallsBar` with gap counts + - [x] Called from `refresh()` +- [x] Update `ui/settings.py` + - [x] Import `backup_db, restore_db` from `db.database` + - [x] Add "Database Actions" section with Backup DB + Restore DB buttons + - [x] `_backup_db()` — runs backup, shows path in success dialog + - [x] `_restore_db()` — file dialog, confirm prompt, restores, advises restart +- [x] Write `tests/test_backup.py` — 10 tests (361/361 total passing) + +--- + +### ✅ Phase 18 — Draw CSV Import +- [x] Write `core/importer.py` + - [x] `_parse_date(text)` — accepts ISO (YYYY-MM-DD), US slash (MM/DD/YYYY), US dash, day-first formats + - [x] `_is_header(row)` — heuristic: first cell is not a valid date + - [x] `_parse_row_lottosight` — handles LottoSight's own export format (Game, Date, Numbers, Bonus, …) + - [x] `_parse_row_generic` — handles wide format (Date, N1, N2, …) and packed format (Date, "N1,N2,…") + - [x] `import_draws_csv(game_id, filepath) -> {added, skipped, errors}` — validates count + range, calls `insert_draw`, deduplicates via existing DB logic +- [x] Update `ui/settings.py` + - [x] Add `filedialog` import + - [x] "Import CSV" button on every game row (builtin and custom) + - [x] `_import_csv(game_id, game_name)` — file dialog → import → show summary with error preview → refresh games +- [x] Write `tests/test_importer.py` — 23 tests (351/351 total passing) + +--- + ### ✅ Phase 17 — Predictor Power Features - [x] Add `quick_pick(game_id, exclude=None)` to `core/predictor.py` — pure random, no draw history required - [x] Add `exclude: set | None = None` parameter to all 5 existing strategies + `_random_ticket` + `_fill_to_count` diff --git a/core/importer.py b/core/importer.py new file mode 100644 index 0000000..e0acf0e --- /dev/null +++ b/core/importer.py @@ -0,0 +1,183 @@ +""" +core/importer.py +---------------- +CSV draw importer for LottoSight. + +Supported formats (auto-detected): + 1. LottoSight export — Game, Date, "N1,N2,...", Bonus, Multiplier, Source + 2. Wide format — Date, N1, N2, ..., Nn [, Bonus] + 3. Packed format — Date, "N1,N2,...", [Bonus] + +Header rows are auto-detected and skipped. +Dates accepted: YYYY-MM-DD, MM/DD/YYYY, M/D/YYYY, MM-DD-YYYY. +Duplicate draws (same game + date already in DB) are skipped, not errored. +""" + +import csv +from datetime import datetime + +from db.models import get_game_by_id, insert_draw + + +# ── Date parsing ────────────────────────────────────────────────────────────── + +_DATE_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y", "%d/%m/%Y", "%-m/%-d/%Y") + + +def _parse_date(text: str) -> str | None: + """Return ISO date string (YYYY-MM-DD) or None.""" + text = text.strip() + for fmt in _DATE_FORMATS: + try: + return datetime.strptime(text, fmt).strftime("%Y-%m-%d") + except ValueError: + pass + return None + + +def _is_header(row: list[str]) -> bool: + """Heuristic: first cell is not a recognisable date → row is a header.""" + return bool(row) and _parse_date(row[0]) is None + + +# ── Row parsing ─────────────────────────────────────────────────────────────── + +def _parse_row_lottosight(cells: list[str]) -> dict | None: + """Parse LottoSight export row: Game, Date, Numbers, Bonus, ...""" + if len(cells) < 3: + return None + date_str = _parse_date(cells[1]) + if date_str is None: + return None + try: + numbers = [int(n.strip()) for n in cells[2].split(",") if n.strip()] + bonus = int(cells[3]) if len(cells) > 3 and cells[3].strip().isdigit() else None + except (ValueError, IndexError): + return None + return {"date": date_str, "numbers": numbers, "bonus": bonus} + + +def _parse_row_generic(cells: list[str], game: dict) -> dict | None: + """Parse wide or packed format: Date, [nums...]""" + date_str = _parse_date(cells[0]) + if date_str is None: + return None + + rest = cells[1:] + if not rest: + return None + + # Packed: second cell contains comma-separated numbers + if "," in rest[0]: + try: + numbers = [int(n.strip()) for n in rest[0].split(",") if n.strip()] + bonus = int(rest[1]) if len(rest) > 1 and rest[1].strip().isdigit() else None + return {"date": date_str, "numbers": numbers, "bonus": bonus} + except ValueError: + pass + + # Wide: each number in its own column + nums: list[int] = [] + for col in rest: + col = col.strip() + if col.isdigit(): + nums.append(int(col)) + else: + break # stop at first non-numeric cell (e.g. multiplier string) + + if len(nums) < game["main_count"]: + return None + + numbers = nums[: game["main_count"]] + bonus = (nums[game["main_count"]] + if game["bonus_count"] > 0 and len(nums) > game["main_count"] + else None) + return {"date": date_str, "numbers": numbers, "bonus": bonus} + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def import_draws_csv(game_id: int, filepath: str) -> dict: + """ + Import draw records from *filepath* into the database for *game_id*. + + Returns: + {"added": int, "skipped": int, "errors": list[str]} + """ + game = get_game_by_id(game_id) + if game is None: + return {"added": 0, "skipped": 0, "errors": [f"Unknown game_id {game_id}"]} + + added = skipped = 0 + errors: list[str] = [] + + # Read file + try: + with open(filepath, newline="", encoding="utf-8-sig") as f: + sample = f.read(4096) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",\t;") + except csv.Error: + dialect = csv.excel + rows = list(csv.reader(f, dialect)) + except FileNotFoundError: + return {"added": 0, "skipped": 0, "errors": [f"File not found: {filepath}"]} + except Exception as e: + return {"added": 0, "skipped": 0, "errors": [f"Could not read file: {e}"]} + + if not rows: + return {"added": 0, "skipped": 0, "errors": ["File is empty"]} + + # Detect LottoSight export format by header + lottosight_fmt = ( + len(rows[0]) >= 3 + and rows[0][0].strip().lower() == "game" + and rows[0][1].strip().lower() == "date" + ) + + # Determine start row (skip header if present) + start = 1 if (lottosight_fmt or _is_header(rows[0])) else 0 + + for line_num, row in enumerate(rows[start:], start=start + 1): + cells = [c.strip() for c in row] + if not any(cells): + continue # blank line + + if lottosight_fmt: + parsed = _parse_row_lottosight(cells) + else: + parsed = _parse_row_generic(cells, game) + + if parsed is None: + errors.append(f"Line {line_num}: could not parse — {row}") + continue + + numbers = parsed["numbers"] + + # Validate count + if len(numbers) != game["main_count"]: + errors.append( + f"Line {line_num}: expected {game['main_count']} numbers, " + f"got {len(numbers)}" + ) + continue + + # Validate range + bad = [n for n in numbers if not (1 <= n <= game["main_max"])] + if bad: + errors.append( + f"Line {line_num}: numbers out of range 1–{game['main_max']}: {bad}" + ) + continue + + result = insert_draw( + game_id, parsed["date"], numbers, + bonus=parsed["bonus"], source="csv_import", + ) + if result == "inserted": + added += 1 + else: + skipped += 1 + + return {"added": added, "skipped": skipped, "errors": errors} diff --git a/data/lottosight.db b/data/lottosight.db index 300aa5a79e2bf69fa0303cd80178933279d14851..9a7da8730982e9aae072eccbb9c5cc61a6a08d82 100644 GIT binary patch delta 177 zcmZo@P-*eKpgpNu9wuzSJ{>A-s0A{lc}IKuj&?>MW&&bnAZ7t#Rv>2EzN4Mp;|Bl;A~|FL delta 101 zcmZo@P-MW&&bnAZ7t#Rv>2EzN4Mp;|Bm2g(5=$ diff --git a/db/database.py b/db/database.py index a35d30b..28ea0e1 100644 --- a/db/database.py +++ b/db/database.py @@ -8,6 +8,8 @@ All table definitions live here. Call init_db() once on app startup. import sqlite3 import logging import os +import shutil +from datetime import datetime from core.paths import user_data_dir @@ -143,6 +145,27 @@ def _seed_games(cursor, conn): logger.info("[DB] Default games already seeded — skipped") +def backup_db(dest_dir: str | None = None) -> str: + """Copy the live DB to dest_dir and return the backup file path.""" + if dest_dir is None: + dest_dir = os.path.join(user_data_dir(), "exports") + os.makedirs(dest_dir, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + dest = os.path.join(dest_dir, f"lottosight_backup_{ts}.db") + shutil.copy2(DB_PATH, dest) + logger.info("[DB] Backup created: %s", dest) + return dest + + +def restore_db(source_path: str) -> None: + """Overwrite the live DB with a backup file.""" + if not os.path.isfile(source_path): + raise FileNotFoundError(f"Backup file not found: {source_path}") + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + shutil.copy2(source_path, DB_PATH) + logger.info("[DB] Database restored from: %s", source_path) + + def get_db_stats(): """ Return a dict of basic DB stats for display in Settings screen. diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..ab986ae --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,104 @@ +""" +tests/test_backup.py +--------------------- +Tests for db.database backup_db() and restore_db(). +""" + +import os +import pytest +from db.database import backup_db, restore_db, DB_PATH, get_connection +from db.models import insert_draw, get_draws_with_game, get_game_by_name + + +# ── backup_db ───────────────────────────────────────────────────────────────── + +def test_backup_creates_file(tmp_db, tmp_path): + dest = backup_db(dest_dir=str(tmp_path)) + assert os.path.isfile(dest) + + +def test_backup_filename_contains_timestamp(tmp_db, tmp_path): + dest = backup_db(dest_dir=str(tmp_path)) + basename = os.path.basename(dest) + assert basename.startswith("lottosight_backup_") + assert basename.endswith(".db") + + +def test_backup_returns_path_string(tmp_db, tmp_path): + result = backup_db(dest_dir=str(tmp_path)) + assert isinstance(result, str) + assert len(result) > 0 + + +def test_backup_file_is_valid_sqlite(tmp_db, tmp_path): + dest = backup_db(dest_dir=str(tmp_path)) + import sqlite3 + conn = sqlite3.connect(dest) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {r[0] for r in cursor.fetchall()} + conn.close() + assert "games" in tables + assert "draws" in tables + + +def test_backup_preserves_draw_data(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + insert_draw(game["id"], "2024-06-01", [5, 14, 22, 36, 69], bonus=7) + dest = backup_db(dest_dir=str(tmp_path)) + import sqlite3 + conn = sqlite3.connect(dest) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT numbers FROM draws WHERE draw_date='2024-06-01'") + row = cursor.fetchone() + conn.close() + assert row is not None + assert "5" in row["numbers"] + + +def test_backup_creates_dest_dir_if_missing(tmp_db, tmp_path): + nested = str(tmp_path / "a" / "b" / "c") + dest = backup_db(dest_dir=nested) + assert os.path.isfile(dest) + + +def test_backup_default_dest_dir(tmp_db, monkeypatch, tmp_path): + from core.paths import user_data_dir as _udd + monkeypatch.setattr("db.database.user_data_dir", lambda: str(tmp_path)) + dest = backup_db() # no dest_dir — should use exports/ sub-dir + assert os.path.isfile(dest) + assert "exports" in dest + + +# ── restore_db ──────────────────────────────────────────────────────────────── + +def test_restore_missing_file_raises(tmp_db): + with pytest.raises(FileNotFoundError): + restore_db("/nonexistent/backup.db") + + +def test_restore_replaces_live_db(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + insert_draw(game["id"], "2024-07-04", [3, 17, 28, 45, 62], bonus=12) + backup_path = backup_db(dest_dir=str(tmp_path)) + + # Delete the draw from the live DB + conn = get_connection() + conn.execute("DELETE FROM draws WHERE draw_date='2024-07-04'") + conn.commit() + conn.close() + + draws_before = get_draws_with_game(game_id=game["id"]) + assert not any(d["draw_date"] == "2024-07-04" for d in draws_before) + + restore_db(backup_path) + + draws_after = get_draws_with_game(game_id=game["id"]) + assert any(d["draw_date"] == "2024-07-04" for d in draws_after) + + +def test_restore_returns_none(tmp_db, tmp_path): + backup_path = backup_db(dest_dir=str(tmp_path)) + result = restore_db(backup_path) + assert result is None diff --git a/tests/test_importer.py b/tests/test_importer.py new file mode 100644 index 0000000..9178751 --- /dev/null +++ b/tests/test_importer.py @@ -0,0 +1,228 @@ +""" +tests/test_importer.py +---------------------- +Tests for core/importer.py — CSV draw import. + +Powerball config: 5 main from 1–69, 1 bonus from 1–26. +""" + +import os +import pytest +from db.models import get_game_by_name, add_game, get_draws_with_game +from core.importer import import_draws_csv, _parse_date + + +# ── _parse_date ─────────────────────────────────────────────────────────────── + +def test_parse_date_iso(): + assert _parse_date("2024-01-15") == "2024-01-15" + +def test_parse_date_us_slash(): + assert _parse_date("01/15/2024") == "2024-01-15" + +def test_parse_date_us_dash(): + assert _parse_date("01-15-2024") == "2024-01-15" + +def test_parse_date_invalid(): + assert _parse_date("not-a-date") is None + +def test_parse_date_header_text(): + assert _parse_date("Date") is None + assert _parse_date("draw_date") is None + + +# ── Wide format (Date, N1, N2, ...) ────────────────────────────────────────── + +def test_wide_no_header(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + "2024-01-01,1,13,36,61,69,7\n" + "2024-01-03,2,7,22,45,61,15\n" + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 2 + assert result["skipped"] == 0 + assert result["errors"] == [] + +def test_wide_with_header(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + "Date,Ball1,Ball2,Ball3,Ball4,Ball5,Bonus\n" + "2024-01-01,1,13,36,61,69,7\n" + "2024-01-03,2,7,22,45,61,15\n" + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 2 + assert result["errors"] == [] + +def test_wide_no_bonus_column(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text("2024-01-01,1,13,36,61,69\n") + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 1 + draws = get_draws_with_game(game_id=game["id"]) + assert draws[0]["bonus"] is None + +def test_wide_us_date_format(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text("01/15/2024,5,14,22,36,69,7\n") + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 1 + draws = get_draws_with_game(game_id=game["id"]) + assert draws[0]["draw_date"] == "2024-01-15" + + +# ── Packed format (Date, "N1,N2,...", Bonus) ────────────────────────────────── + +def test_packed_format(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text('2024-01-01,"1,13,36,61,69",7\n') + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 1 + assert result["errors"] == [] + +def test_packed_with_header(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + 'Date,Numbers,Bonus\n' + '2024-01-01,"1,13,36,61,69",7\n' + '2024-01-03,"2,7,22,45,61",15\n' + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 2 + + +# ── LottoSight export format ────────────────────────────────────────────────── + +def test_lottosight_export_format(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "export.csv" + csv_file.write_text( + "Game,Date,Numbers,Bonus,Multiplier,Source\n" + 'Powerball,2024-01-01,"1,13,36,61,69",7,2x,powerball_ny\n' + 'Powerball,2024-01-03,"2,7,22,45,61",15,3x,powerball_ny\n' + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 2 + assert result["errors"] == [] + + +# ── Duplicate detection ─────────────────────────────────────────────────────── + +def test_duplicate_skipped(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + "2024-01-01,1,13,36,61,69,7\n" + "2024-01-01,1,13,36,61,69,7\n" # same date → duplicate + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 1 + assert result["skipped"] == 1 + +def test_reimport_all_skipped(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text("2024-01-01,1,13,36,61,69,7\n") + import_draws_csv(game["id"], str(csv_file)) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 0 + assert result["skipped"] == 1 + + +# ── Validation errors ───────────────────────────────────────────────────────── + +def test_wrong_number_count(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text("2024-01-01,1,13,36,61\n") # only 4 numbers + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 0 + assert len(result["errors"]) == 1 # rejected — too few numbers to parse + +def test_number_out_of_range(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text("2024-01-01,1,13,36,61,99,7\n") # 99 > 69 + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 0 + assert len(result["errors"]) == 1 + +def test_mixed_valid_invalid(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + "2024-01-01,1,13,36,61,69,7\n" + "bad_date,1,2,3,4,5,6\n" # unparseable + "2024-01-03,2,7,22,45,61,15\n" + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 2 + assert len(result["errors"]) == 1 + + +# ── Edge cases ──────────────────────────────────────────────────────────────── + +def test_file_not_found(tmp_db): + game = get_game_by_name("Powerball") + result = import_draws_csv(game["id"], "/nonexistent/path/file.csv") + assert result["added"] == 0 + assert len(result["errors"]) == 1 + assert "not found" in result["errors"][0].lower() + +def test_empty_file(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "empty.csv" + csv_file.write_text("") + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 0 + assert result["errors"] == ["File is empty"] + +def test_blank_lines_ignored(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + "2024-01-01,1,13,36,61,69,7\n" + "\n" + "2024-01-03,2,7,22,45,61,15\n" + "\n" + ) + result = import_draws_csv(game["id"], str(csv_file)) + assert result["added"] == 2 + assert result["errors"] == [] + +def test_unknown_game_id(tmp_db, tmp_path): + csv_file = tmp_path / "draws.csv" + csv_file.write_text("2024-01-01,1,2,3,4,5\n") + result = import_draws_csv(99999, str(csv_file)) + assert result["added"] == 0 + assert len(result["errors"]) == 1 + +def test_custom_game_import(tmp_db, tmp_path): + gid = add_game("My Lotto", 6, 49, bonus_count=0, bonus_max=0) + csv_file = tmp_path / "draws.csv" + csv_file.write_text( + "2024-01-01,5,14,22,33,41,48\n" + "2024-01-08,3,17,28,35,44,49\n" + ) + result = import_draws_csv(gid, str(csv_file)) + assert result["added"] == 2 + assert result["errors"] == [] + +def test_data_persisted_correctly(tmp_db, tmp_path): + game = get_game_by_name("Powerball") + csv_file = tmp_path / "draws.csv" + csv_file.write_text("2024-03-15,5,14,22,36,69,7\n") + import_draws_csv(game["id"], str(csv_file)) + draws = get_draws_with_game(game_id=game["id"]) + assert len(draws) == 1 + assert draws[0]["draw_date"] == "2024-03-15" + nums = [int(n) for n in draws[0]["numbers"].split(",")] + assert sorted(nums) == [5, 14, 22, 36, 69] + assert str(draws[0]["bonus"]) == "7" diff --git a/ui/dashboard.py b/ui/dashboard.py index 50914d0..b72e70c 100644 --- a/ui/dashboard.py +++ b/ui/dashboard.py @@ -15,7 +15,7 @@ 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 +from core.analyzer import frequency_analysis, gap_analysis from ui.widgets import BallsBar # Weekday indices: Monday=0 … Sunday=6 @@ -97,6 +97,7 @@ class DashboardScreen(ttk.Frame): 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 ─────────────────────────────────────────────────────────────── @@ -104,6 +105,7 @@ class DashboardScreen(ttk.Frame): 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(): @@ -194,3 +196,36 @@ class DashboardScreen(ttk.Frame): 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 = get_all_games(active_only=True) + 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") diff --git a/ui/settings.py b/ui/settings.py index 846868b..c5e20c4 100644 --- a/ui/settings.py +++ b/ui/settings.py @@ -7,15 +7,16 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread. """ import tkinter as tk -from tkinter import ttk, messagebox +from tkinter import ttk, messagebox, filedialog import logging -from db.database import get_db_stats +from db.database import get_db_stats, backup_db, restore_db from db.models import ( get_all_games, get_draw_count, set_game_active, get_last_fetch_per_source, get_predictions, add_game, delete_game, _BUILTIN_GAMES, ) +from core.importer import import_draws_csv logger = logging.getLogger(__name__) @@ -80,6 +81,7 @@ class SettingsScreen(ttk.Frame): self._sources_body = self._section("Data Sources") self._fetch_body = self._build_fetch_section() self._db_body = self._section("Database") + self._build_db_actions() def _build_fetch_section(self): body = self._section("Fetch Schedule") @@ -132,11 +134,16 @@ class SettingsScreen(ttk.Frame): foreground="#777777", ).pack(side="left", padx=(16, 0)) + ttk.Button( + row, text="Import CSV", + command=lambda gid=game["id"], gname=game["name"]: self._import_csv(gid, gname), + ).pack(side="left", padx=(12, 0)) + if game["name"] not in _BUILTIN_GAMES: ttk.Button( row, text="Delete", command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname), - ).pack(side="left", padx=(12, 0)) + ).pack(side="left", padx=(6, 0)) ttk.Button( self._games_body, text="+ Add Custom Game", @@ -187,6 +194,13 @@ class SettingsScreen(ttk.Frame): ttk.Label(row, text="Predictions:", width=18, anchor="w").pack(side="left") ttk.Label(row, text=str(pred_count), foreground="#555555").pack(side="left") + def _build_db_actions(self): + body = self._section("Database Actions") + row = ttk.Frame(body) + row.pack(fill="x") + ttk.Button(row, text="Backup DB", command=self._backup_db ).pack(side="left", padx=(0, 8)) + ttk.Button(row, text="Restore DB", command=self._restore_db).pack(side="left") + # ── Actions ─────────────────────────────────────────────────────────────── def _toggle_game(self, game_id: int, var: tk.BooleanVar): @@ -203,6 +217,54 @@ class SettingsScreen(ttk.Frame): else: self._fetch_msg_var.set("Fetch not available.") + def _import_csv(self, game_id: int, game_name: str): + fp = filedialog.askopenfilename( + title=f"Import draws for {game_name}", + filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], + ) + if not fp: + return + result = import_draws_csv(game_id, fp) + added = result["added"] + skipped = result["skipped"] + errors = result["errors"] + msg = f"Added {added:,} draw{'s' if added != 1 else ''}." + if skipped: + msg += f"\nSkipped {skipped:,} duplicate{'s' if skipped != 1 else ''}." + if errors: + preview = "\n".join(errors[:5]) + suffix = f"\n… and {len(errors) - 5} more." if len(errors) > 5 else "" + msg += f"\n\n{len(errors)} row error(s):\n{preview}{suffix}" + messagebox.showinfo("Import Complete", msg) + self._refresh_games() + + def _backup_db(self): + try: + dest = backup_db() + messagebox.showinfo("Backup Complete", f"Database backed up to:\n{dest}") + except Exception as e: + messagebox.showerror("Backup Failed", str(e)) + + def _restore_db(self): + fp = filedialog.askopenfilename( + title="Select backup file to restore", + filetypes=[("SQLite DB", "*.db"), ("All files", "*.*")], + ) + if not fp: + return + if not messagebox.askyesno( + "Confirm Restore", + "Restoring will overwrite the current database.\n" + "This cannot be undone. Continue?", + ): + return + try: + restore_db(fp) + messagebox.showinfo("Restore Complete", + "Database restored. Please restart the app.") + except Exception as e: + messagebox.showerror("Restore Failed", str(e)) + def _open_add_game_dialog(self): _AddGameDialog(self, on_save=self._refresh_games)