diff --git a/src/auto_save.py b/src/auto_save.py index d3c2ad3..8091235 100644 --- a/src/auto_save.py +++ b/src/auto_save.py @@ -1,369 +1,13 @@ -"""Auto-save and backup utilities for TheChart. +"""Compatibility shim re-exporting auto-save utilities. -Provides two APIs: - -New application API (used by main app): - AutoSaveManager(save_callback=callable, interval_minutes=5, logger=None) - .enable_auto_save() / .disable_auto_save() - .mark_data_modified() / .force_save() - -Legacy test API (expected by tests/test_auto_save.py): - AutoSaveManager(data_file_path=..., backup_dir=..., status_callback=..., - error_callback=..., interval_minutes=0.1, max_backups=3) - .start() / .stop() - .create_backup(suffix) / .get_backup_files() / .restore_from_backup(path) - -Both modes share a single implementation for simplicity. Mode is inferred by -presence of 'data_file_path' in kwargs (legacy) vs 'save_callback' (new). +Canonical implementation lives in `thechart.core.auto_save`. """ from __future__ import annotations -import contextlib -import glob -import os -import re -import shutil -import threading -from collections.abc import Callable -from datetime import datetime +from thechart.core.auto_save import ( # noqa: F401 + AutoSaveManager, + BackupManager, +) -from constants import BACKUP_PATH - - -class AutoSaveManager: - """Unified auto-save & backup manager supporting legacy and new APIs.""" - - # ------------------------------------------------------------------ - # Construction / mode detection - # ------------------------------------------------------------------ - def __init__(self, *args, **kwargs) -> None: # type: ignore[override] - # Determine mode: legacy if a filesystem path is provided - self._legacy_mode = "data_file_path" in kwargs or ( - args and isinstance(args[0], str) - ) - self.logger = kwargs.get("logger") - - if self._legacy_mode: - # Legacy parameters (tests expect these attributes) - self.data_file_path: str = kwargs.get( - "data_file_path", args[0] if args else "" - ) - self.backup_dir: str = kwargs.get("backup_dir", BACKUP_PATH) - self.status_callback: Callable[[str], None] | None = kwargs.get( - "status_callback" - ) - self.error_callback: Callable[[str], None] | None = kwargs.get( - "error_callback" - ) - self.interval_minutes: float = float(kwargs.get("interval_minutes", 5)) - self.max_backups: int = int(kwargs.get("max_backups", 10)) - self.interval_seconds: float = self.interval_minutes * 60 - self.save_callback: Callable[[], None] | None = None # Not used in tests - self._thread: threading.Thread | None = None - self._stop_event = threading.Event() - self.is_running: bool = False - self._last_save_time: datetime | None = None - self._data_modified = False # Unused in legacy tests but kept - self._ensure_backup_directory() - else: - # New application mode - save_cb: Callable[[], None] | None = kwargs.get("save_callback") - if save_cb is None and args: - save_cb = args[0] - interval = float(kwargs.get("interval_minutes", 5)) - self.save_callback = save_cb - self.interval_minutes = interval - self.interval_seconds = interval * 60 - self._auto_save_enabled = False - self._save_thread: threading.Thread | None = None - self._stop_event = threading.Event() - self._last_save_time: datetime | None = None - self._data_modified = False - # Shim attributes for compatibility (unused in new mode) - self.data_file_path = "" - self.backup_dir = BACKUP_PATH - self.status_callback = None - self.error_callback = None - self.max_backups = 10 - self.is_running = False - - def enable_auto_save(self) -> None: - """Enable automatic saving.""" - if self._legacy_mode: - # Map to legacy start() - self.start() - return - if getattr(self, "_auto_save_enabled", False): - return - self._auto_save_enabled = True - self._stop_event.clear() - self._save_thread = threading.Thread(target=self._auto_save_loop, daemon=True) - self._save_thread.start() - if self.logger: - self.logger.info( - f"Auto-save enabled with {self.interval_minutes:.1f} minute intervals" - ) - - def disable_auto_save(self) -> None: - """Disable automatic saving.""" - if self._legacy_mode: - self.stop() - return - if not getattr(self, "_auto_save_enabled", False): - return - self._auto_save_enabled = False - self._stop_event.set() - if self._save_thread and self._save_thread.is_alive(): - self._save_thread.join(timeout=2.0) - if self.logger: - self.logger.info("Auto-save disabled") - - def mark_data_modified(self) -> None: - """Mark that data has been modified and needs saving.""" - self._data_modified = True - - def force_save(self) -> None: - """Force an immediate save if data has been modified.""" - if self._data_modified and self.save_callback: - try: - self.save_callback() - self._last_save_time = datetime.now() - self._data_modified = False - if self.logger: - self.logger.debug("Force save completed successfully") - except Exception as e: # pragma: no cover - defensive - if self.logger: - self.logger.error(f"Force save failed: {e}") - - def get_last_save_time(self) -> datetime | None: - """Get the timestamp of the last successful save.""" - return self._last_save_time - - def is_enabled(self) -> bool: - """Check if auto-save is currently enabled.""" - return ( - self.is_running - if self._legacy_mode - else getattr(self, "_auto_save_enabled", False) - ) - - def has_unsaved_changes(self) -> bool: - """Check if there are unsaved changes.""" - return self._data_modified - - def _auto_save_loop(self) -> None: - """Main auto-save loop running in background thread.""" - while not self._stop_event.wait(self.interval_seconds): - if self._data_modified and self.save_callback: - try: - self.save_callback() - self._last_save_time = datetime.now() - self._data_modified = False - if self.logger: - self.logger.debug("Auto-save completed successfully") - except Exception as e: # pragma: no cover - defensive - if self.logger: - self.logger.error(f"Auto-save failed: {e}") - - def set_interval(self, minutes: int) -> None: - """ - Change the auto-save interval. - - Args: - minutes: New interval in minutes (minimum 1, maximum 60) - """ - if not 1 <= minutes <= 60: - raise ValueError("Auto-save interval must be between 1 and 60 minutes") - old = self.interval_minutes - self.interval_minutes = float(minutes) - self.interval_seconds = self.interval_minutes * 60 - if self.logger: - self.logger.info( - "Auto-save interval changed from %.1f to %.1f minutes", - old, - self.interval_minutes, - ) - if not self._legacy_mode and getattr(self, "_auto_save_enabled", False): - self.disable_auto_save() - self.enable_auto_save() - - def cleanup(self) -> None: - if self._legacy_mode: - self.stop() - else: - self.disable_auto_save() - if self._data_modified: - if self.logger: - self.logger.info("Performing final save on cleanup") - self.force_save() - - # ------------------------------------------------------------------ - # Legacy mode API (periodic file backups) - # ------------------------------------------------------------------ - def start(self) -> None: - if not self._legacy_mode or self.is_running: - return - self.is_running = True - self._stop_event.clear() - with contextlib.suppress(Exception): - self.create_backup("startup") - - def _loop() -> None: - while not self._stop_event.wait(self.interval_seconds): - with contextlib.suppress(Exception): - self.create_backup("auto") - - self._thread = threading.Thread(target=_loop, daemon=True) - self._thread.start() - - def stop(self) -> None: - if not self._legacy_mode or not self.is_running: - return - self.is_running = False - self._stop_event.set() - if self._thread and self._thread.is_alive(): - self._thread.join(timeout=2.0) - - # --------------------- Backup helpers (legacy) --------------------- - def _ensure_backup_directory(self) -> None: - os.makedirs(self.backup_dir, exist_ok=True) - - def create_backup(self, suffix: str) -> str | None: - if not getattr(self, "data_file_path", ""): - return None - if not os.path.exists(self.data_file_path): - if self.error_callback: - self.error_callback("Source file does not exist") - return None - safe_suffix = re.sub(r"[^A-Za-z0-9_\-]+", "_", suffix.strip()) or "backup" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base = os.path.splitext(os.path.basename(self.data_file_path))[0] - filename = f"{base}_{safe_suffix}_{timestamp}.csv" - dest = os.path.join(self.backup_dir, filename) - try: - shutil.copy2(self.data_file_path, dest) - if self.status_callback: - self.status_callback(f"Backup created: {dest}") - self._cleanup_old_backups() - return dest - except Exception as e: # pragma: no cover - defensive - if self.error_callback: - self.error_callback(f"Backup failed: {e}") - return None - - def _cleanup_old_backups(self) -> None: - pattern = os.path.join(self.backup_dir, "*.csv") - files = glob.glob(pattern) - if len(files) <= self.max_backups: - return - files.sort(key=os.path.getmtime, reverse=True) - for f in files[self.max_backups :]: - with contextlib.suppress(Exception): - os.remove(f) - - def get_backup_files(self) -> list[str]: - pattern = os.path.join(self.backup_dir, "*.csv") - files = glob.glob(pattern) - files.sort(key=os.path.getmtime, reverse=True) - return files - - def restore_from_backup(self, backup_path: str) -> bool: - if not os.path.exists(backup_path): - if self.error_callback: - self.error_callback("Backup file does not exist") - return False - try: - shutil.copy2(backup_path, self.data_file_path) - if self.status_callback: - self.status_callback(f"Restored from backup: {backup_path}") - return True - except Exception as e: # pragma: no cover - if self.error_callback: - self.error_callback(f"Restore failed: {e}") - return False - - -class BackupManager: - """Standalone backup manager used by application code.""" - - def __init__( - self, - data_file_path: str, - backup_directory: str = BACKUP_PATH, - logger=None, - status_callback: Callable[[str], None] | None = None, - ) -> None: - self.data_file_path = data_file_path - self.backup_directory = backup_directory - self.logger = logger - self.status_callback = status_callback - self._ensure_backup_directory() - - def _ensure_backup_directory(self) -> None: - os.makedirs(self.backup_directory, exist_ok=True) - - def create_backup(self, backup_type: str = "manual") -> str | None: - if not os.path.exists(self.data_file_path): - if self.logger: - self.logger.warning("Cannot create backup: data file doesn't exist") - return None - try: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base_name = os.path.splitext(os.path.basename(self.data_file_path))[0] - backup_filename = f"{base_name}_backup_{backup_type}_{timestamp}.csv" - backup_path = os.path.join(self.backup_directory, backup_filename) - shutil.copy2(self.data_file_path, backup_path) - msg = f"Backup created: {backup_path}" - if self.logger: - self.logger.info(msg) - if self.status_callback: - self.status_callback(msg) - return backup_path - except Exception as e: # pragma: no cover - defensive - if self.logger: - self.logger.error(f"Backup creation failed: {e}") - return None - - def cleanup_old_backups(self, keep_count: int = 10) -> None: - try: - backup_pattern = os.path.join(self.backup_directory, "*_backup_*.csv") - backup_files = glob.glob(backup_pattern) - if len(backup_files) <= keep_count: - return - backup_files.sort(key=os.path.getmtime, reverse=True) - removed = 0 - for file_path in backup_files[keep_count:]: - with contextlib.suppress(Exception): - os.remove(file_path) - removed += 1 - msg = f"Cleaned up {removed} old backup files" - if self.logger: - self.logger.info(msg) - if self.status_callback and removed: - self.status_callback(msg) - except Exception as e: # pragma: no cover - defensive - if self.logger: - self.logger.error(f"Backup cleanup failed: {e}") - - def restore_from_backup(self, backup_path: str) -> bool: - if not os.path.exists(backup_path): - if self.logger: - self.logger.error(f"Backup file doesn't exist: {backup_path}") - return False - try: - # Create a backup of current data before restoring - current_backup = self.create_backup("pre_restore") - shutil.copy2(backup_path, self.data_file_path) - msg = f"Successfully restored from backup: {backup_path}" - if self.logger: - self.logger.info(msg) - if current_backup: - self.logger.info(f"Previous data backed up to: {current_backup}") - if self.status_callback: - self.status_callback(msg) - return True - except Exception as e: # pragma: no cover - defensive - if self.logger: - self.logger.error(f"Restore from backup failed: {e}") - return False +__all__ = ["AutoSaveManager", "BackupManager"] diff --git a/src/constants.py b/src/constants.py index 9622e13..ba59506 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,49 +1,15 @@ -import os -import sys +"""Compatibility shim for environment-driven constants. -import dotenv as _dotenv +Canonical definitions live in `thechart.core.constants`. +""" -# Determine external data directory (supports PyInstaller) -extDataDir = os.getcwd() -if getattr(sys, "frozen", False): # pragma: no cover - runtime packaging path - extDataDir = sys._MEIPASS # type: ignore[attr-defined] +from __future__ import annotations -_already_initialized = globals().get("_already_initialized", False) +from thechart.core.constants import ( # noqa: F401 + BACKUP_PATH, + LOG_CLEAR, + LOG_LEVEL, + LOG_PATH, +) -# Snapshot environment before potential .env load so we can honor values -# that were present prior to loading .env and ignore values introduced by it. -_pre_env = dict(os.environ) - -# Preserve patched load_dotenv if present (tests patch this symbol) -if "load_dotenv" not in globals(): # first import or not patched yet - load_dotenv = _dotenv.load_dotenv # type: ignore[assignment] - -# Always call (tests expect call with override=True) -load_dotenv(override=True) -_already_initialized = True - - -def _pre_or_default(key: str, default: str) -> str: - """Return the value from the pre-dotenv environment or the default. - - Values that only exist due to .env load are ignored so tests (and env) - take precedence, while still allowing us to call load_dotenv(override=True). - """ - if key in _pre_env: - return _pre_env[key] - # Ignore values introduced only via .env - return default - - -# Environment driven constants (tests expect specific defaults / formats) -LOG_LEVEL = (_pre_or_default("LOG_LEVEL", "INFO") or "INFO").upper() -LOG_PATH = _pre_or_default("LOG_PATH", "/tmp/logs/thechart") -LOG_CLEAR = (_pre_or_default("LOG_CLEAR", "False") or "False").capitalize() -BACKUP_PATH = _pre_or_default("BACKUP_PATH", "/tmp/thechart/backups") - -__all__ = [ - "LOG_LEVEL", - "LOG_PATH", - "LOG_CLEAR", - "BACKUP_PATH", -] +__all__ = ["LOG_LEVEL", "LOG_PATH", "LOG_CLEAR", "BACKUP_PATH"] diff --git a/src/data_manager.py b/src/data_manager.py index 788acc2..9b6b27b 100644 --- a/src/data_manager.py +++ b/src/data_manager.py @@ -1,524 +1,11 @@ -import csv -import logging -import os -import tempfile -from datetime import datetime -from typing import Any +"""Legacy shim for DataManager. -import pandas as pd +This preserves backward compatibility for imports like: + from data_manager import DataManager -from medicine_manager import MedicineManager -from pathology_manager import PathologyManager +Canonical implementation lives in: thechart.data.data_manager +""" +from thechart.data.data_manager import DataManager # noqa: F401 -class DataManager: - """Handle all data operations for the application with performance optimizations.""" - - def __init__( - self, - filename: str, - logger: logging.Logger, - medicine_manager: MedicineManager, - pathology_manager: PathologyManager, - ) -> None: - self._init_internal( - filename, - logger, - medicine_manager, - pathology_manager, - ) - - def _init_internal( - self, - filename: str, - logger: logging.Logger, - medicine_manager: MedicineManager, - pathology_manager: PathologyManager, - ) -> None: - self.filename = filename - self.logger = logger - self.medicine_manager = medicine_manager - self.pathology_manager = pathology_manager - - self._data_cache = None - self._cache_timestamp = 0 - self._headers_cache = None - self._dtype_cache = None - self._graph_cache = None - self._config_version = 0 - self._initialize_csv_file() - - def _get_csv_headers(self) -> tuple[str, ...]: - """Get CSV headers based on current pathology and medicine configuration. - Cached to avoid repeated computation.""" - if self._headers_cache is not None: - return self._headers_cache - - # Start with date - headers = ["date"] - - # Add pathology headers - for pathology_key in self.pathology_manager.get_pathology_keys(): - headers.append(pathology_key) - - # Add medicine headers - for medicine_key in self.medicine_manager.get_medicine_keys(): - headers.extend([medicine_key, f"{medicine_key}_doses"]) - - result = tuple(headers + ["note"]) - self._headers_cache = result - return result - - def _initialize_csv_file(self) -> None: - """Create CSV file with headers if it doesn't exist or is empty.""" - try: - creating = not os.path.exists(self.filename) - if creating or os.path.getsize(self.filename) == 0: - with open(self.filename, mode="w", newline="") as file: - writer = csv.writer(file) - writer.writerow(self._get_csv_headers()) - if creating: - # Emit warning so tests detect creation of missing file - self.logger.warning( - "CSV file did not exist and was created with headers." - ) - except Exception as e: - self.logger.error(f"Failed to initialize CSV file: {e}") - - def _invalidate_cache(self) -> None: - """Invalidate the data cache when data changes.""" - self._data_cache = None - self._cache_timestamp = 0 - self._graph_cache = None - - def invalidate_structure(self) -> None: - """Invalidate caches due to structural changes (e.g., medicines/pathologies). - - Public method for other managers / UI to call instead of reaching into - private attributes. This bumps a config version ensuring future loads - rebuild dependent caches. - """ - self._headers_cache = None - self._dtype_cache = None - self._graph_cache = None - self._config_version += 1 - # Data remains valid but columns may differ; safest is full invalidation - self._invalidate_cache() - - def _should_reload_data(self) -> bool: - """Check if data should be reloaded based on file modification time.""" - if self._data_cache is None: - return True - - try: - file_mtime = os.path.getmtime(self.filename) - return file_mtime > self._cache_timestamp - except OSError: - return True - - def _get_dtype_dict(self) -> dict[str, type]: - """Get pandas dtype dictionary for efficient reading. - Cached to avoid recreation.""" - if self._dtype_cache is not None: - return self._dtype_cache - - dtype_dict = {"date": str, "note": str} - - # Add pathology types - for pathology_key in self.pathology_manager.get_pathology_keys(): - dtype_dict[pathology_key] = int - - # Add medicine types - for medicine_key in self.medicine_manager.get_medicine_keys(): - dtype_dict[medicine_key] = int - dtype_dict[f"{medicine_key}_doses"] = str - - self._dtype_cache = dtype_dict - return dtype_dict - - def load_data(self) -> pd.DataFrame: - """Load data from CSV file with caching for better performance.""" - if not os.path.exists(self.filename): - self.logger.warning("CSV file does not exist. No data to load.") - return pd.DataFrame() - if os.path.getsize(self.filename) == 0: - self.logger.warning("CSV file is empty. No data to load.") - return pd.DataFrame() - - # Use cached data if available and file hasn't changed - if not self._should_reload_data(): - return self._data_cache.copy() - - try: - # Use pre-built dtype dictionary for faster parsing - dtype_dict = self._get_dtype_dict() - - # Read with optimized settings - df: pd.DataFrame = pd.read_csv( - self.filename, - dtype=dtype_dict, - na_filter=False, # Don't convert to NaN, keep as empty strings - engine="c", # Use faster C engine - ) - - # If file has only headers (no rows), treat as empty with warning - if df.empty: - self.logger.warning("CSV file contains only headers. No data to load.") - return pd.DataFrame() - - # Sort only if needed (check if already sorted) - if len(df) > 1 and not df["date"].is_monotonic_increasing: - df = df.sort_values(by="date").reset_index(drop=True) - - # Cache the data and timestamp - self._data_cache = df.copy() - self._cache_timestamp = os.path.getmtime(self.filename) - # Invalidate graph cache because underlying data changed - self._graph_cache = None - - return df.copy() - - except pd.errors.EmptyDataError: - self.logger.warning("CSV file is empty. No data to load.") - return pd.DataFrame() - except Exception as e: - self.logger.error(f"Error loading data: {str(e)}") - return pd.DataFrame() - - def add_entry(self, entry_data: list[str | int]) -> bool: - """Add a new entry to the CSV file with optimized duplicate checking.""" - try: - # Quick duplicate check using cached data if available - date_to_add: str = str(entry_data[0]) - - if self._data_cache is not None: - # Use cached data for duplicate check - if date_to_add in self._data_cache["date"].values: - self.logger.warning( - f"Entry with date {date_to_add} already exists." - ) - return False - else: - # Fallback to loading data if no cache - df: pd.DataFrame = self.load_data() - if not df.empty and date_to_add in df["date"].values: - self.logger.warning( - f"Entry with date {date_to_add} already exists." - ) - return False - - # Write to file - with open(self.filename, mode="a", newline="") as file: - writer = csv.writer(file) - writer.writerow(entry_data) - - # Invalidate cache since data changed - self._invalidate_cache() - return True - - except Exception as e: - self.logger.error(f"Error adding entry: {str(e)}") - return False - - def update_entry(self, original_date: str, values: list[str | int]) -> bool: - """Update an existing entry identified by original_date - with optimized processing.""" - try: - df: pd.DataFrame = self.load_data() - new_date: str = str(values[0]) - - # Optimized duplicate check - if original_date != new_date: - date_exists = (df["date"] == new_date).any() - if date_exists: - self.logger.warning( - f"Cannot update: entry with date {new_date} already exists." - ) - return False - - # Get current CSV headers to match with values - headers = list(self._get_csv_headers()) - - # Ensure we have the right number of values with optimized padding - if len(values) < len(headers): - # Pad with defaults efficiently - padding_needed = len(headers) - len(values) - for i in range(padding_needed): - header_idx = len(values) + i - if header_idx < len(headers): - header = headers[header_idx] - if header == "note" or header.endswith("_doses"): - values.append("") - else: - values.append(0) - - # Use vectorized update for better performance - mask = df["date"] == original_date - if mask.any(): - df.loc[mask, headers] = values - # Atomic write back to CSV to avoid partial writes - self._atomic_write_csv(df) - self._invalidate_cache() - return True - else: - self.logger.warning( - f"Entry with date {original_date} not found for update." - ) - return False - - except Exception as e: - self.logger.error(f"Error updating entry: {str(e)}") - return False - - def delete_entry(self, date: str) -> bool: - """Delete an entry identified by date with optimized processing.""" - try: - df: pd.DataFrame = self.load_data() - original_len = len(df) - - # Use vectorized filtering for better performance - df = df[df["date"] != date] - - # Only write if something was actually deleted - if len(df) < original_len: - self._atomic_write_csv(df) - self._invalidate_cache() - - return True - except Exception as e: - self.logger.error(f"Error deleting entry: {str(e)}") - return False - - # ------------------------------------------------------------------ - # File write helpers - # ------------------------------------------------------------------ - def _atomic_write_csv(self, df: pd.DataFrame) -> None: - """Write a DataFrame to CSV atomically by writing to a temp file then replacing. - - This prevents corrupted files if the app crashes mid-write. - """ - directory = os.path.dirname(os.path.abspath(self.filename)) or "." - os.makedirs(directory, exist_ok=True) - fd, tmp_path = tempfile.mkstemp( - prefix="thechart_", suffix=".csv", dir=directory - ) - try: - with os.fdopen(fd, "w") as tmp_file: - df.to_csv(tmp_file, index=False) - os.replace(tmp_path, self.filename) - finally: - # If replace succeeded tmp_path no longer exists; suppress errors - try: - if os.path.exists(tmp_path): - os.remove(tmp_path) - except Exception: - pass - - # ------------------------------------------------------------------ - # Archiving / Rotation - # ------------------------------------------------------------------ - def _get_archive_dir(self) -> str: - """Return path to the archives directory next to the main CSV.""" - base_dir = os.path.dirname(os.path.abspath(self.filename)) or "." - archive_dir = os.path.join(base_dir, "archives") - os.makedirs(archive_dir, exist_ok=True) - return archive_dir - - def _ensure_headers(self, df: pd.DataFrame) -> pd.DataFrame: - """Ensure dataframe has all expected headers in correct order. - - Missing numeric fields default to 0; dose/note string fields to ''. - Columns are ordered per _get_csv_headers(). - """ - headers = list(self._get_csv_headers()) - out = df.copy() - for col in headers: - if col not in out.columns: - if col == "note" or col.endswith("_doses"): - out[col] = "" - else: - out[col] = 0 - # Drop unknown columns to keep files tidy - out = out[headers] - return out - - def _write_archive_file(self, year: int, df: pd.DataFrame) -> str: - """Append archived rows to a per-year CSV with full headers. - - Returns the archive file path. - """ - archive_dir = self._get_archive_dir() - base = os.path.splitext(os.path.basename(self.filename))[0] - archive_path = os.path.join(archive_dir, f"{base}_{year}.csv") - df_to_write = self._ensure_headers(df) - # If file doesn't exist, write with header; else append without header - write_header = ( - not os.path.exists(archive_path) or os.path.getsize(archive_path) == 0 - ) - try: - df_to_write.to_csv(archive_path, mode="a", index=False, header=write_header) - except Exception as e: - self.logger.error(f"Failed to write archive file {archive_path}: {e}") - raise - return archive_path - - def archive_old_data(self, keep_years: int = 1) -> dict[str, Any]: - """Archive rows older than the most recent N years into per-year files. - - Args: - keep_years: Number of most recent full calendar years to keep in the - main CSV (minimum 1). Rows with a date older than the earliest - kept year are moved to archives/BASE_YYYY.csv. - - Returns: - Summary dict: { 'archived_rows': int, 'archive_files': set[str], - 'kept_rows': int } - """ - try: - keep_years = max(1, int(keep_years)) - except Exception: - keep_years = 1 - - df = self.load_data() - if df.empty or "date" not in df.columns: - return {"archived_rows": 0, "archive_files": set(), "kept_rows": 0} - - # Parse dates (stored as mm/dd/YYYY normally) - dates = pd.to_datetime(df["date"], format="%m/%d/%Y", errors="coerce") - df = df.copy() - df["__dt"] = dates - # If we couldn't parse dates, nothing to archive safely - if df["__dt"].isna().all(): - df.drop(columns=["__dt"], inplace=True) - return { - "archived_rows": 0, - "archive_files": set(), - "kept_rows": int(len(df)), - } - - current_year = datetime.now().year - earliest_kept_year = current_year - keep_years + 1 - - to_archive = df[df["__dt"].dt.year < earliest_kept_year] - to_keep = df[df["__dt"].dt.year >= earliest_kept_year] - - if to_archive.empty: - df.drop(columns=["__dt"], inplace=True) - return { - "archived_rows": 0, - "archive_files": set(), - "kept_rows": int(len(df)), - } - - archive_files: set[str] = set() - try: - # Group by year and append to each year's archive file - for year, group in to_archive.groupby(to_archive["__dt"].dt.year): - group = group.drop(columns=["__dt"]) # remove helper - path = self._write_archive_file(int(year), group) - archive_files.add(path) - - # Write the kept rows back to main CSV atomically - kept_df = to_keep.drop(columns=["__dt"]).copy() - # Ensure columns and order - kept_df = self._ensure_headers(kept_df) - self._atomic_write_csv(kept_df) - self._invalidate_cache() - except Exception as e: - # If archiving failed mid-way, log and propagate minimal info - self.logger.error(f"Archiving failed: {e}") - raise - - return { - "archived_rows": int(len(to_archive)), - "archive_files": archive_files, - "kept_rows": int(len(to_keep)), - } - - def get_today_medicine_doses( - self, date: str, medicine_name: str - ) -> list[tuple[str, str]]: - """Get list of (timestamp, dose) tuples for a medicine on a given date - with caching.""" - try: - df: pd.DataFrame = self.load_data() - if df.empty: - return [] - - # Use vectorized filtering for better performance - date_mask = df["date"] == date - if not date_mask.any(): - return [] - - dose_column = f"{medicine_name}_doses" - if dose_column not in df.columns: - return [] - - doses_str = df.loc[date_mask, dose_column].iloc[0] - - if not doses_str: - return [] - - # Optimized dose parsing - doses = [] - for dose_entry in doses_str.split("|"): - if ":" in dose_entry: - parts = dose_entry.split(":", 1) - if len(parts) == 2: - doses.append((parts[0], parts[1])) - - return doses - except Exception as e: - self.logger.error(f"Error getting medicine doses: {str(e)}") - return [] - - # ------------------------------------------------------------------ - # Retrieval helpers - # ------------------------------------------------------------------ - def get_row(self, date: str) -> list[str | int] | None: - """Return a row (as list aligned with current headers) for a date. - - Args: - date: Date string identifying the row - Returns: - List of values aligned with current CSV headers or None if not found. - """ - try: - df = self.load_data() - if df.empty or "date" not in df.columns: - return None - mask = df["date"] == date - if not mask.any(): - return None - headers = list(self._get_csv_headers()) - row_series = df.loc[mask, headers].iloc[0] - return [row_series[h] for h in headers] - except Exception: - return None - - # ------------------------------------------------------------------ - # Graph Data Handling - # ------------------------------------------------------------------ - def get_graph_ready_data(self) -> pd.DataFrame: - """Return a dataframe ready for graphing (datetime index cached). - - This avoids repeatedly parsing dates & re-sorting in the graph layer. - """ - base_df = self.load_data() - if base_df.empty: - return base_df - if self._graph_cache is not None: - return self._graph_cache.copy() - try: - graph_df = base_df.copy() - # Expect date stored in mm/dd/YYYY format - graph_df["date"] = pd.to_datetime( - graph_df["date"], format="%m/%d/%Y", errors="coerce" - ) - graph_df = graph_df.dropna(subset=["date"]).sort_values("date") - graph_df.set_index("date", inplace=True) - self._graph_cache = graph_df.copy() - return graph_df - except Exception: - # Fallback: return original (unindexed) data - return base_df +__all__ = ["DataManager"] diff --git a/src/error_handler.py b/src/error_handler.py index 8275e7b..da6ebc1 100644 --- a/src/error_handler.py +++ b/src/error_handler.py @@ -1,391 +1,17 @@ -"""Enhanced error handling and user feedback system for TheChart.""" - -import logging -from datetime import datetime -from typing import Any - - -class ErrorHandler: - """Centralized error handling with user-friendly feedback.""" - - def __init__(self, logger: logging.Logger, ui_manager=None): - """ - Initialize error handler. - - Args: - logger: Logger instance for error logging - ui_manager: UI manager for user feedback (optional) - """ - self.logger = logger - self.ui_manager = ui_manager - self.error_counts = {} - self.last_error_time = {} - - def handle_error( - self, - error: Exception, - context: str = "Unknown", - user_message: str | None = None, - show_dialog: bool = True, - log_level: int = logging.ERROR, - ) -> None: - """ - Handle an error with logging and user feedback. - - Args: - error: Exception that occurred - context: Context where error occurred - user_message: User-friendly message (auto-generated if None) - show_dialog: Whether to show error dialog to user - log_level: Logging level for the error - """ - error_key = f"{type(error).__name__}:{context}" - current_time = datetime.now() - - # Track error frequency - self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 - self.last_error_time[error_key] = current_time - - # Log the error with full traceback - error_msg = f"Error in {context}: {str(error)}" - if log_level >= logging.ERROR: - self.logger.error(error_msg, exc_info=True) - elif log_level >= logging.WARNING: - self.logger.warning(error_msg) - else: - self.logger.debug(error_msg) - - # Generate user-friendly message if not provided - if user_message is None: - user_message = self._generate_user_message(error, context) - - # Update UI status if available - if self.ui_manager: - self.ui_manager.update_status(f"Error: {user_message}", "error") - - # Show dialog if requested (tests expect a direct UI call method) - if show_dialog and self.ui_manager: - # Prefer a UI method when provided by UI manager in tests - show_fn = getattr(self.ui_manager, "show_error_dialog", None) - if callable(show_fn): - show_fn(user_message) - else: - self._show_error_dialog(user_message, error, context) - - def handle_validation_error( - self, field_name: str, error_message: str, suggested_fix: str = "" - ) -> None: - """ - Handle validation errors with specific guidance. - - Args: - field_name: Name of the field with validation error - error_message: Specific error message - suggested_fix: Suggested fix for the user - """ - full_message = f"Validation error in {field_name}: {error_message}" - if suggested_fix: - full_message += f"\n\nSuggested fix: {suggested_fix}" - - self.logger.warning(f"Validation error: {field_name} - {error_message}") - - if self.ui_manager: - self.ui_manager.update_status( - f"Invalid {field_name}: {error_message}", "warning" - ) - - def handle_file_error( - self, - operation: str, - file_path: str, - error: Exception, - recovery_action: str = "", - ) -> None: - """ - Handle file operation errors with recovery suggestions. - - Args: - operation: Type of file operation (read, write, delete, etc.) - file_path: Path to the file - error: Exception that occurred - recovery_action: Suggested recovery action - """ - context = f"File {operation}: {file_path}" - user_message = f"Failed to {operation} file: {file_path}" - - if recovery_action: - user_message += f"\n\nSuggested action: {recovery_action}" - - self.handle_error(error, context, user_message) - - def handle_data_error( - self, - operation: str, - data_type: str, - error: Exception, - recovery_suggestions: list[str] | None = None, - ) -> None: - """ - Handle data-related errors with specific guidance. - - Args: - operation: Data operation being performed - data_type: Type of data involved - error: Exception that occurred - recovery_suggestions: List of recovery suggestions - """ - context = f"Data {operation}: {data_type}" - user_message = f"Data error during {operation} of {data_type}" - - if recovery_suggestions: - user_message += "\n\nTry these solutions:\n" - user_message += "\n".join( - f"• {suggestion}" for suggestion in recovery_suggestions - ) - - self.handle_error(error, context, user_message) - - def log_performance_warning( - self, operation: str, duration_seconds: float, threshold_seconds: float = 1.0 - ) -> None: - """ - Log performance warnings for slow operations. - - Args: - operation: Operation that was slow - duration_seconds: How long it took - threshold_seconds: Threshold for considering it slow - """ - if duration_seconds > threshold_seconds: - self.logger.warning( - f"Performance warning: {operation} took {duration_seconds:.2f}s " - f"(threshold: {threshold_seconds:.2f}s)" - ) - - if self.ui_manager: - self.ui_manager.update_status( - f"Operation completed but was slow: {operation}", "warning" - ) - - def get_error_summary(self) -> dict[str, Any]: - """ - Get summary of errors that have occurred. - - Returns: - Dictionary with error statistics - """ - return { - "total_errors": sum(self.error_counts.values()), - "unique_errors": len(self.error_counts), - "error_counts": self.error_counts.copy(), - "last_error_times": self.last_error_time.copy(), - } - - def _generate_user_message(self, error: Exception, context: str) -> str: - """Generate user-friendly error message based on error type.""" - error_type = type(error).__name__ - - # Common error type mappings - user_messages = { - "FileNotFoundError": "The requested file could not be found.", - "PermissionError": "Permission denied. Check file permissions.", - "ValueError": "Invalid data format or value.", - "TypeError": "Incorrect data type provided.", - "KeyError": "Required data field is missing.", - "ConnectionError": "Network connection failed.", - "MemoryError": "Insufficient memory to complete operation.", - "OSError": "System operation failed.", - } - - base_message = user_messages.get( - error_type, f"An unexpected error occurred: {str(error)}" - ) - return f"{base_message} (Context: {context})" - - def _show_error_dialog( - self, user_message: str, error: Exception, context: str - ) -> None: - """Show error dialog to user with details.""" - from tkinter import messagebox - - # For now, show a simple error dialog - # In a more advanced implementation, we could show a custom dialog - # with error details, reporting options, etc. - - title = f"Error in {context}" - messagebox.showerror(title, user_message) - - -class OperationTimer: - """Context manager for timing operations and detecting performance issues.""" - - def __init__( - self, - error_handler: ErrorHandler | None, - operation_name: str, - warning_threshold: float = 1.0, - ): - """ - Initialize operation timer. - - Args: - operation_name: Name of the operation being timed - error_handler: Error handler for performance warnings - warning_threshold: Threshold in seconds for performance warnings - """ - self.error_handler = error_handler - self.operation_name = operation_name - self.warning_threshold = warning_threshold - self.start_time: float | None = None - - def __enter__(self): - """Start timing the operation.""" - import time - - self.start_time = time.time() - return self - - def __exit__(self, _exc_type, _exc_val, _exc_tb): - """End timing and check for performance issues.""" - import time - - if self.start_time is not None: - duration = time.time() - self.start_time - - if duration > self.warning_threshold and self.error_handler: - self.error_handler.log_performance_warning( - self.operation_name, duration, self.warning_threshold - ) - - # Don't suppress any exceptions - return False - - -def handle_exceptions(error_handler: ErrorHandler, context: str = "Operation"): - """ - Decorator for automatic exception handling. - - Args: - error_handler: ErrorHandler instance - context: Context description for error logging - """ - - def decorator(func): - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception as e: - error_handler.handle_error(e, f"{context}:{func.__name__}") - # Re-raise the exception if it's critical - if isinstance(e, MemoryError | KeyboardInterrupt | SystemExit): - raise - return None - - return wrapper - - return decorator - - -class UserFeedback: - """Enhanced user feedback system with progress tracking.""" - - def __init__(self, ui_manager=None, logger: logging.Logger | None = None): - """ - Initialize user feedback system. - - Args: - ui_manager: UI manager for status updates - logger: Logger for debugging feedback operations - """ - self.ui_manager = ui_manager - self.logger = logger - self.current_operation: str | None = None - self.operation_start_time: float | None = None - - def start_operation( - self, operation_name: str, estimated_duration: float | None = None - ) -> None: - """ - Start a long-running operation with user feedback. - - Args: - operation_name: Name of the operation - estimated_duration: Estimated duration in seconds (optional) - """ - import time - - self.current_operation = operation_name - self.operation_start_time = time.time() - - if self.ui_manager: - message = f"Starting: {operation_name}" - if estimated_duration: - message += f" (estimated: {estimated_duration:.1f}s)" - self.ui_manager.update_status(message, "info") - - if self.logger: - self.logger.info(f"Started operation: {operation_name}") - - def update_progress( - self, progress_text: str, percentage: float | None = None - ) -> None: - """ - Update progress of current operation. - - Args: - progress_text: Progress description - percentage: Progress percentage (0-100, optional) - """ - if not self.current_operation: - return - - if self.ui_manager: - message = f"{self.current_operation}: {progress_text}" - if percentage is not None: - message += f" ({percentage:.1f}%)" - self.ui_manager.update_status(message, "info") - - def complete_operation(self, success: bool = True, final_message: str = "") -> None: - """ - Complete the current operation with final status. - - Args: - success: Whether operation completed successfully - final_message: Final status message - """ - if not self.current_operation: - return - - import time - - duration = None - if self.operation_start_time: - duration = time.time() - self.operation_start_time - - if self.ui_manager: - if final_message: - message = final_message - else: - status_word = "completed" if success else "failed" - message = f"{self.current_operation} {status_word}" - - if duration: - message += f" ({duration:.1f}s)" - - status_type = "success" if success else "error" - self.ui_manager.update_status(message, status_type) - - if self.logger: - status_word = "completed" if success else "failed" - log_message = f"Operation {status_word}: {self.current_operation}" - if duration: - log_message += f" (duration: {duration:.1f}s)" - - if success: - self.logger.info(log_message) - else: - self.logger.error(log_message) - - # Reset operation tracking - self.current_operation = None - self.operation_start_time = None +"""Compatibility shim for error handling utilities.""" + +from __future__ import annotations + +from thechart.core.error_handler import ( # noqa: F401 + ErrorHandler, + OperationTimer, + UserFeedback, + handle_exceptions, +) + +__all__ = [ + "ErrorHandler", + "OperationTimer", + "handle_exceptions", + "UserFeedback", +] diff --git a/src/export_manager.py b/src/export_manager.py index d91e372..fa49ac3 100644 --- a/src/export_manager.py +++ b/src/export_manager.py @@ -1,443 +1,11 @@ -""" -Export Manager for TheChart Application +"""Compatibility shim for ExportManager. -Handles exporting data and graphs to various formats: -- CSV data to JSON, XML -- Graphs to PDF (with data tables) +Canonical implementation lives in `thechart.export.export_manager`. +This keeps `from export_manager import ExportManager` working. """ -import contextlib -import json -import logging -import os -from datetime import datetime -from pathlib import Path -from typing import Any -from xml.dom import minidom -from xml.etree.ElementTree import Element, SubElement, tostring +from __future__ import annotations -import pandas as pd -from reportlab.lib import colors -from reportlab.lib.pagesizes import A4, landscape -from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.platypus import ( - Image, - PageBreak, - Paragraph, - SimpleDocTemplate, - Spacer, - Table, - TableStyle, -) +from thechart.export import ExportManager # noqa: F401 -from data_manager import DataManager -from graph_manager import GraphManager -from medicine_manager import MedicineManager -from pathology_manager import PathologyManager - - -class ExportManager: - """Handle data and graph export operations.""" - - def __init__( - self, - data_manager: DataManager, - graph_manager: GraphManager, - medicine_manager: MedicineManager, - pathology_manager: PathologyManager, - logger: logging.Logger, - ) -> None: - self.data_manager = data_manager - self.graph_manager = graph_manager - self.medicine_manager = medicine_manager - self.pathology_manager = pathology_manager - self.logger = logger - - def export_data_to_json( - self, export_path: str, df: pd.DataFrame | None = None - ) -> bool: - """Export CSV data to JSON format.""" - try: - df = df if df is not None else self.data_manager.load_data() - if df.empty: - self.logger.warning("No data to export") - return False - - # Convert DataFrame to dictionary with better structure - export_data = { - "metadata": { - "export_date": datetime.now().isoformat(), - "total_entries": len(df), - "date_range": { - "start": df["date"].min() if not df.empty else None, - "end": df["date"].max() if not df.empty else None, - }, - "pathologies": list(self.pathology_manager.get_pathology_keys()), - "medicines": list(self.medicine_manager.get_medicine_keys()), - }, - "entries": df.to_dict(orient="records"), - } - - with open(export_path, "w", encoding="utf-8") as f: - json.dump(export_data, f, indent=2, ensure_ascii=False) - - self.logger.info(f"Data exported to JSON: {export_path}") - return True - - except Exception as e: - self.logger.error(f"Error exporting to JSON: {str(e)}") - return False - - def export_data_to_xml( - self, export_path: str, df: pd.DataFrame | None = None - ) -> bool: - """Export CSV data to XML format.""" - try: - df = df if df is not None else self.data_manager.load_data() - if df.empty: - self.logger.warning("No data to export") - return False - - # Create root element - root = Element("thechart_data") - - # Add metadata - metadata = SubElement(root, "metadata") - SubElement(metadata, "export_date").text = datetime.now().isoformat() - SubElement(metadata, "total_entries").text = str(len(df)) - - # Date range - date_range = SubElement(metadata, "date_range") - SubElement(date_range, "start").text = ( - df["date"].min() if not df.empty else "" - ) - SubElement(date_range, "end").text = ( - df["date"].max() if not df.empty else "" - ) - - # Pathologies - pathologies = SubElement(metadata, "pathologies") - for pathology in self.pathology_manager.get_pathology_keys(): - SubElement(pathologies, "pathology").text = pathology - - # Medicines - medicines = SubElement(metadata, "medicines") - for medicine in self.medicine_manager.get_medicine_keys(): - SubElement(medicines, "medicine").text = medicine - - # Add entries - entries = SubElement(root, "entries") - for _, row in df.iterrows(): - entry = SubElement(entries, "entry") - for column, value in row.items(): - elem = SubElement(entry, column.replace(" ", "_")) - elem.text = str(value) if pd.notna(value) else "" - - # Pretty print XML - rough_string = tostring(root, "utf-8") - reparsed = minidom.parseString(rough_string) - pretty_xml = reparsed.toprettyxml(indent=" ") - - with open(export_path, "w", encoding="utf-8") as f: - f.write(pretty_xml) - - self.logger.info(f"Data exported to XML: {export_path}") - return True - - except Exception as e: - self.logger.error(f"Error exporting to XML: {str(e)}") - return False - - def _save_graph_as_image(self, temp_dir: Path) -> str | None: - """Save current graph as temporary image for PDF inclusion.""" - try: - # Check if graph manager exists - if self.graph_manager is None: - self.logger.warning("No graph manager available for export") - return None - - # Check if graph manager and figure exist - if not hasattr(self.graph_manager, "fig") or self.graph_manager.fig is None: - self.logger.warning("No graph figure available for export") - return None - - # Ensure graph is up to date with current data - df = self.data_manager.load_data() - if not df.empty: - self.graph_manager.update_graph(df) - else: - self.logger.warning("No data available to update graph for export") - return None - - # Ensure temp directory exists - temp_dir.mkdir(parents=True, exist_ok=True) - temp_image_path = temp_dir / "graph.png" - - # Save the current figure - self.graph_manager.fig.savefig( - str(temp_image_path), - dpi=150, - bbox_inches="tight", - facecolor="white", - edgecolor="none", - ) - - # Ensure the figure data is properly flushed to disk - import matplotlib.pyplot as plt - - plt.draw() - plt.pause(0.01) # Small pause to ensure file is written - - # Verify the file was actually created and has content - if not temp_image_path.exists(): - self.logger.error( - f"Graph image file was not created: {temp_image_path}" - ) - return None - - if temp_image_path.stat().st_size == 0: - self.logger.error(f"Graph image file is empty: {temp_image_path}") - return None - - self.logger.info(f"Graph image saved successfully: {temp_image_path}") - return str(temp_image_path) - - except Exception as e: - self.logger.error(f"Error saving graph image: {str(e)}") - return None - - def export_to_pdf( - self, - export_path: str, - include_graph: bool = True, - df: pd.DataFrame | None = None, - ) -> bool: - """Export data and optionally graph to PDF format.""" - try: - df = df if df is not None else self.data_manager.load_data() - - # Create PDF document in landscape format for better table/graph display - doc = SimpleDocTemplate( - export_path, - pagesize=landscape(A4), - rightMargin=72, - leftMargin=72, - topMargin=72, - bottomMargin=18, - ) - - # Get styles - styles = getSampleStyleSheet() - title_style = ParagraphStyle( - "CustomTitle", - parent=styles["Heading1"], - fontSize=18, - spaceAfter=30, - textColor=colors.darkblue, - ) - - story = [] - - # Title - story.append(Paragraph("TheChart - Medication Tracker Export", title_style)) - story.append(Spacer(1, 20)) - - # Export metadata - export_info = [ - f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", - f"Total Entries: {len(df) if not df.empty else 0}", - ] - - if not df.empty: - export_info.extend( - [ - f"Date Range: {df['date'].min()} to {df['date'].max()}", - ( - "Pathologies: " - + ", ".join(self.pathology_manager.get_pathology_keys()) - ), - ( - "Medicines: " - + ", ".join(self.medicine_manager.get_medicine_keys()) - ), - ] - ) - - for info in export_info: - story.append(Paragraph(info, styles["Normal"])) - - story.append(Spacer(1, 20)) - - # Include graph if requested and available - if include_graph: - temp_dir = Path(export_path).parent / "temp_export" - graph_path = None - - try: - graph_path = self._save_graph_as_image(temp_dir) - if graph_path and os.path.exists(graph_path): - # Add page break before graph for full page display - story.append(PageBreak()) - - story.append( - Paragraph("Data Visualization", styles["Heading2"]) - ) - story.append(Spacer(1, 20)) - - # Full page graph - maintain proportions while maximizing size - # Let ReportLab scale proportionally to fit landscape page - img = Image(graph_path, width=9 * inch, height=5.4 * inch) - story.append(img) - else: - # Graph not available, add a note instead - story.append(PageBreak()) - story.append( - Paragraph("Data Visualization", styles["Heading2"]) - ) - story.append(Spacer(1, 10)) - story.append( - Paragraph( - "Graph not available - no data to visualize or graph " - "not generated yet.", - styles["Normal"], - ) - ) - - except Exception as e: - self.logger.error(f"Error including graph in PDF: {str(e)}") - # Add error note instead of failing completely - story.append(PageBreak()) - story.append(Paragraph("Data Visualization", styles["Heading2"])) - story.append(Spacer(1, 10)) - story.append( - Paragraph( - f"Graph could not be included: {str(e)}", styles["Normal"] - ) - ) - - # Add data table if we have data - if not df.empty: - # Start table on new page - story.append(PageBreak()) - story.append(Paragraph("Data Table", styles["Heading2"])) - story.append(Spacer(1, 20)) - - # Prepare table data - include all columns for full display - display_columns = ["date"] - for pathology_key in self.pathology_manager.get_pathology_keys(): - display_columns.append(pathology_key) - for medicine_key in self.medicine_manager.get_medicine_keys(): - display_columns.append(medicine_key) - display_columns.append("note") - - # Filter dataframe to display columns that exist - available_columns = [ - col for col in display_columns if col in df.columns - ] - display_df = df[available_columns].copy() - - # Don't truncate notes - landscape format has full width - # Keep notes as-is for complete data visibility - - # Convert to table data - table_data = [available_columns] # Headers - for _, row in display_df.iterrows(): - table_data.append( - [str(val) if pd.notna(val) else "" for val in row] - ) - - # Calculate optimal column widths for landscape format - col_widths = [] - for col in available_columns: - if col == "date": - col_widths.append(1.0 * inch) # Fixed width for dates - elif col == "note": - col_widths.append(3.5 * inch) # Wider for notes - elif col in self.pathology_manager.get_pathology_keys(): - col_widths.append(0.8 * inch) # Narrow for pathology scores - elif col in self.medicine_manager.get_medicine_keys(): - col_widths.append(0.8 * inch) # Narrow for medicine status - else: - col_widths.append(1.0 * inch) # Default width - - # Create table with specified column widths and better styling - table = Table(table_data, colWidths=col_widths, repeatRows=1) - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), colors.grey), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), - # Left align for better readability - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, 0), 10), - # Add more padding for better readability - ("LEFTPADDING", (0, 0), (-1, -1), 8), - ("RIGHTPADDING", (0, 0), (-1, -1), 8), - ("TOPPADDING", (0, 0), (-1, -1), 6), - ("BOTTOMPADDING", (0, 0), (-1, -1), 6), - ("BACKGROUND", (0, 1), (-1, -1), colors.beige), - ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), - # Slightly larger font for better readability - ("FONTSIZE", (0, 1), (-1, -1), 9), - ("GRID", (0, 0), (-1, -1), 1, colors.black), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("WORDWRAP", (0, 0), (-1, -1), True), - # Alternating row colors for better visual separation - ( - "ROWBACKGROUNDS", - (0, 1), - (-1, -1), - [colors.beige, colors.lightgrey], - ), - ] - ) - ) - - story.append(table) - else: - story.append(PageBreak()) - story.append( - Paragraph("No data available to export.", styles["Normal"]) - ) - - # Build PDF - doc.build(story) - - # Clean up temporary image file after PDF is built - if include_graph: - temp_dir = Path(export_path).parent / "temp_export" - if graph_path and os.path.exists(graph_path): - try: - os.remove(graph_path) - self.logger.debug(f"Cleaned up temporary image: {graph_path}") - except OSError as e: - self.logger.warning(f"Could not remove temp image: {e}") - - # Clean up temp directory if empty - if temp_dir.exists(): - with contextlib.suppress(OSError): - temp_dir.rmdir() - - self.logger.info(f"Data exported to PDF: {export_path}") - return True - - except Exception as e: - self.logger.error(f"Error exporting to PDF: {str(e)}") - return False - - def get_export_info(self) -> dict[str, Any]: - """Get information about available data for export.""" - df = self.data_manager.load_data() - - return { - "total_entries": len(df) if not df.empty else 0, - "date_range": { - "start": df["date"].min() if not df.empty else None, - "end": df["date"].max() if not df.empty else None, - }, - "pathologies": list(self.pathology_manager.get_pathology_keys()), - "medicines": list(self.medicine_manager.get_medicine_keys()), - "has_data": not df.empty, - } +__all__ = ["ExportManager"] diff --git a/src/export_window.py b/src/export_window.py index 684ec15..2582718 100644 --- a/src/export_window.py +++ b/src/export_window.py @@ -1,279 +1,11 @@ -""" -Export Window for TheChart Application +"""Compatibility shim for ExportWindow. -Provides a GUI interface for exporting data and graphs to various formats. +Canonical implementation now lives in `thechart.ui.export_window`. +This keeps `from export_window import ExportWindow` working. """ -import tkinter as tk -from collections.abc import Callable -from pathlib import Path -from tkinter import filedialog, messagebox, ttk +from __future__ import annotations -from export_manager import ExportManager +from thechart.ui.export_window import ExportWindow # noqa: F401 - -class ExportWindow: - """Export window for data and graph export functionality.""" - - def __init__( - self, - parent: tk.Tk, - export_manager: ExportManager, - get_current_filtered_df: Callable[[], object] | None = None, - ) -> None: - self.parent = parent - self.export_manager = export_manager - self._get_current_filtered_df = get_current_filtered_df - - # Create the export window - self.window = tk.Toplevel(parent) - self.window.title("Export Data") - self.window.geometry("500x450") # Made taller to ensure buttons are visible - self.window.resizable(False, False) - - # Center the window - self._center_window() - - # Make window modal - self.window.transient(parent) - self.window.grab_set() - - # Setup the UI - self._setup_ui() - - def _center_window(self) -> None: - """Center the export window on the parent window.""" - self.window.update_idletasks() - - # Get window dimensions - width = self.window.winfo_width() - height = self.window.winfo_height() - - # Get parent window position and size - parent_x = self.parent.winfo_rootx() - parent_y = self.parent.winfo_rooty() - parent_width = self.parent.winfo_width() - parent_height = self.parent.winfo_height() - - # Calculate position to center on parent - x = parent_x + (parent_width // 2) - (width // 2) - y = parent_y + (parent_height // 2) - (height // 2) - - self.window.geometry(f"{width}x{height}+{x}+{y}") - - def _setup_ui(self) -> None: - """Setup the export window UI.""" - # Main frame - main_frame = ttk.Frame(self.window, padding="15") - main_frame.pack(fill=tk.BOTH, expand=True) - - # Title - title_label = ttk.Label( - main_frame, text="Export Data & Graphs", font=("Arial", 14, "bold") - ) - title_label.pack(pady=(0, 15)) - - # Create scrollable content area for the main content - content_frame = ttk.Frame(main_frame) - content_frame.pack(fill=tk.BOTH, expand=True) - - # Export info section - self._create_info_section(content_frame) - - # Export options section - self._create_options_section(content_frame) - - # Buttons section - always at the bottom - self._create_buttons_section(main_frame) - - def _create_info_section(self, parent: ttk.Frame) -> None: - """Create the data information section.""" - info_frame = ttk.LabelFrame(parent, text="Data Summary", padding="10") - info_frame.pack(fill=tk.X, pady=(0, 20)) - - # Get export info - export_info = self.export_manager.get_export_info() - - # Display information - if export_info["has_data"]: - info_text = f"""Total Entries: {export_info["total_entries"]} -Date Range: {export_info["date_range"]["start"]} to {export_info["date_range"]["end"]} -Pathologies: {", ".join(export_info["pathologies"])} -Medicines: {", ".join(export_info["medicines"])}""" - else: - info_text = "No data available for export." - - info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT) - info_label.pack(anchor=tk.W) - - def _create_options_section(self, parent: ttk.Frame) -> None: - """Create the export options section.""" - options_frame = ttk.LabelFrame(parent, text="Export Options", padding="10") - options_frame.pack(fill=tk.X, pady=(0, 20)) - - # Include graph option (for PDF export) - self.include_graph_var = tk.BooleanVar(value=True) - graph_check = ttk.Checkbutton( - options_frame, - text="Include graph in PDF export", - variable=self.include_graph_var, - ) - graph_check.pack(anchor=tk.W, pady=(0, 10)) - - # Export scope option - self.scope_var = tk.StringVar(value="all") - scope_frame = ttk.Frame(options_frame) - scope_frame.pack(fill=tk.X, pady=(0, 10)) - ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT) - ttk.Radiobutton( - scope_frame, text="All data", variable=self.scope_var, value="all" - ).pack(side=tk.LEFT, padx=10) - ttk.Radiobutton( - scope_frame, - text="Current (filtered) view", - variable=self.scope_var, - value="filtered", - ).pack(side=tk.LEFT) - - # Format selection - format_label = ttk.Label(options_frame, text="Export Format:") - format_label.pack(anchor=tk.W) - - self.format_var = tk.StringVar(value="JSON") - formats = ["JSON", "XML", "PDF"] - - for fmt in formats: - radio = ttk.Radiobutton( - options_frame, text=fmt, variable=self.format_var, value=fmt - ) - radio.pack(anchor=tk.W, padx=(20, 0)) - - def _create_buttons_section(self, parent: ttk.Frame) -> None: - """Create the buttons section.""" - # Add a separator for visual clarity - separator = ttk.Separator(parent, orient="horizontal") - separator.pack(fill=tk.X, pady=(10, 10)) - - button_frame = ttk.Frame(parent) - button_frame.pack(fill=tk.X, pady=(0, 10)) - - # Export button with more prominent styling - export_btn = ttk.Button( - button_frame, text="Export...", command=self._handle_export - ) - export_btn.pack(side=tk.LEFT, padx=(10, 10), pady=5) - - # Cancel button - cancel_btn = ttk.Button( - button_frame, text="Cancel", command=self.window.destroy - ) - cancel_btn.pack(side=tk.RIGHT, padx=(10, 10), pady=5) - - def _handle_export(self) -> None: - """Handle the export button click.""" - # Check if we have data to export - export_info = self.export_manager.get_export_info() - if not export_info["has_data"]: - messagebox.showwarning( - "No Data", "There is no data available to export.", parent=self.window - ) - return - - # Get selected format - selected_format = self.format_var.get() - - # Define file types for dialog - file_types = { - "JSON": [("JSON files", "*.json"), ("All files", "*.*")], - "XML": [("XML files", "*.xml"), ("All files", "*.*")], - "PDF": [("PDF files", "*.pdf"), ("All files", "*.*")], - } - - # Default filename - default_name = f"thechart_export.{selected_format.lower()}" - - # Show save dialog - filename = filedialog.asksaveasfilename( - parent=self.window, - title=f"Export as {selected_format}", - defaultextension=f".{selected_format.lower()}", - filetypes=file_types[selected_format], - initialfile=default_name, - ) - - if not filename: - return - - # Determine scope DataFrame (if requested and available) - scoped_df = None - if self.scope_var.get() == "filtered" and self._get_current_filtered_df: - try: - scoped_df = self._get_current_filtered_df() - except Exception: - scoped_df = None - - # Perform export based on selected format - success = False - try: - if selected_format == "JSON": - success = self.export_manager.export_data_to_json( - filename, df=scoped_df - ) - elif selected_format == "XML": - success = self.export_manager.export_data_to_xml(filename, df=scoped_df) - elif selected_format == "PDF": - include_graph = self.include_graph_var.get() - success = self.export_manager.export_to_pdf( - filename, include_graph=include_graph, df=scoped_df - ) - - if success: - messagebox.showinfo( - "Export Successful", - f"Data exported successfully to:\n{filename}", - parent=self.window, - ) - # Ask if user wants to open the file location - if messagebox.askyesno( - "Open Location", - "Would you like to open the file location?", - parent=self.window, - ): - self._open_file_location(filename) - - self.window.destroy() - else: - messagebox.showerror( - "Export Failed", - f"Failed to export data as {selected_format}. " - "Please check the logs for more details.", - parent=self.window, - ) - - except Exception as e: - messagebox.showerror( - "Export Error", - f"An error occurred during export:\n{str(e)}", - parent=self.window, - ) - - def _open_file_location(self, filepath: str) -> None: - """Open the file location in the system file manager.""" - try: - file_path = Path(filepath) - directory = file_path.parent - - # Use system-specific command to open file manager - import subprocess - import sys - - if sys.platform == "win32": - subprocess.run(["explorer", str(directory)], check=False) - elif sys.platform == "darwin": - subprocess.run(["open", str(directory)], check=False) - else: # Linux and other Unix-like systems - subprocess.run(["xdg-open", str(directory)], check=False) - - except Exception: - # If opening file location fails, just ignore silently - pass +__all__ = ["ExportWindow"] diff --git a/src/input_validator.py b/src/input_validator.py index ca6d891..d807a45 100644 --- a/src/input_validator.py +++ b/src/input_validator.py @@ -1,291 +1,13 @@ -"""Input validation utilities for TheChart application.""" +"""Compatibility shim for InputValidator. -import re -from datetime import datetime -from typing import Any +This module preserves the legacy import path +`from input_validator import InputValidator` while the canonical +implementation now lives under `thechart.validation.input_validator`. +New code should import from `thechart.validation`. +""" +from __future__ import annotations -class InputValidator: - """Handles input validation for various data types in the application.""" +from thechart.validation import InputValidator - @staticmethod - def validate_date(date_str: str) -> tuple[bool, str, datetime | None]: - """ - Validate date string and return parsed datetime if valid. - - Args: - date_str: Date string to validate - - Returns: - Tuple of (is_valid, error_message, parsed_date) - """ - if not date_str or not date_str.strip(): - return False, "Date cannot be empty", None - - date_str = date_str.strip() - - # Common date formats to try - date_formats = [ - "%m/%d/%Y", # 01/15/2025 - "%m-%d-%Y", # 01-15-2025 - "%Y-%m-%d", # 2025-01-15 - "%m/%d/%y", # 01/15/25 - "%m-%d-%y", # 01-15-25 - ] - - for date_format in date_formats: - try: - parsed_date = datetime.strptime(date_str, date_format) - # Check for reasonable date range (not too far in past/future) - current_year = datetime.now().year - if not (1900 <= parsed_date.year <= current_year + 10): - continue - return True, "", parsed_date - except ValueError: - continue - - return False, "Invalid date format. Use MM/DD/YYYY format.", None - - @staticmethod - def validate_pathology_score(score: Any) -> tuple[bool, str, int]: - """ - Validate pathology score (0-10 scale). - - Args: - score: Score value to validate - - Returns: - Tuple of (is_valid, error_message, validated_score) - """ - try: - score_int = int(score) - if 0 <= score_int <= 10: - return True, "", score_int - else: - return False, "Pathology score must be between 0 and 10", 0 - except (ValueError, TypeError): - return False, "Pathology score must be a valid number", 0 - - @staticmethod - def validate_medicine_taken(taken: Any) -> tuple[bool, str, int]: - """ - Validate medicine taken boolean (0 or 1). - - Args: - taken: Boolean-like value to validate - - Returns: - Tuple of (is_valid, error_message, validated_value) - """ - try: - taken_int = int(taken) - if taken_int in (0, 1): - return True, "", taken_int - else: - return False, "Medicine taken must be 0 (not taken) or 1 (taken)", 0 - except (ValueError, TypeError): - return False, "Medicine taken must be a valid boolean value", 0 - - @staticmethod - def validate_dose_amount(dose_str: str) -> tuple[bool, str, str]: - """ - Validate dose amount string. - - Args: - dose_str: Dose string to validate - - Returns: - Tuple of (is_valid, error_message, cleaned_dose) - """ - if not dose_str: - return True, "", "" # Empty dose is valid - - dose_str = dose_str.strip() - - # Allow alphanumeric characters, spaces, periods, and common dose units - if re.match(r"^[\w\s\.\/\-\+]+$", dose_str): - # Limit length to prevent extremely long entries - if len(dose_str) <= 50: - return True, "", dose_str - else: - return ( - False, - "Dose description too long (max 50 characters)", - dose_str[:50], - ) - else: - return False, "Dose contains invalid characters", "" - - @staticmethod - def validate_note(note_str: str) -> tuple[bool, str, str]: - """ - Validate and sanitize note text. - - Args: - note_str: Note string to validate - - Returns: - Tuple of (is_valid, error_message, cleaned_note) - """ - if not note_str: - return True, "", "" # Empty note is valid - - note_str = note_str.strip() - - # Remove any potential harmful characters while preserving readability - cleaned_note = re.sub(r"[^\w\s\.\,\!\?\:\;\-\(\)\[\]\'\"]+", "", note_str) - - # Limit length - if len(cleaned_note) <= 500: - return True, "", cleaned_note - else: - return False, "Note too long (max 500 characters)", cleaned_note[:500] - - @staticmethod - def validate_filename(filename: str) -> tuple[bool, str, str]: - """ - Validate filename for export operations. - - Args: - filename: Filename to validate - - Returns: - Tuple of (is_valid, error_message, cleaned_filename) - """ - if not filename or not filename.strip(): - return False, "Filename cannot be empty", "" - - filename = filename.strip() - - # Remove/replace invalid filename characters - invalid_chars = r'[<>:"/\\|?*]' - cleaned_filename = re.sub(invalid_chars, "_", filename) - - # Ensure reasonable length - if len(cleaned_filename) <= 100: - return True, "", cleaned_filename - else: - return ( - False, - "Filename too long (max 100 characters)", - cleaned_filename[:100], - ) - - @staticmethod - def validate_time_format(time_str: str) -> tuple[bool, str, datetime | None]: - """ - Validate time string for dose tracking. - - Args: - time_str: Time string to validate - - Returns: - Tuple of (is_valid, error_message, parsed_time) - """ - if not time_str or not time_str.strip(): - return False, "Time cannot be empty", None - - time_str = time_str.strip() - - # Common time formats - time_formats = [ - "%I:%M %p", # 02:30 PM - "%H:%M", # 14:30 - "%I:%M%p", # 2:30PM (no space) - "%I%p", # 2PM - ] - - for time_format in time_formats: - try: - parsed_time = datetime.strptime(time_str, time_format) - return True, "", parsed_time - except ValueError: - continue - - return False, "Invalid time format. Use HH:MM AM/PM or HH:MM (24-hour)", None - - @staticmethod - def sanitize_csv_field(field_str: str) -> str: - """ - Sanitize field for CSV output to prevent injection attacks. - - Args: - field_str: Field string to sanitize - - Returns: - Sanitized string safe for CSV - """ - if not isinstance(field_str, str): - field_str = str(field_str) - - # Remove potential CSV injection characters - dangerous_prefixes = ["=", "+", "-", "@"] - cleaned = field_str.strip() - - # If field starts with dangerous character, prepend space - if cleaned and cleaned[0] in dangerous_prefixes: - cleaned = " " + cleaned - - return cleaned - - @staticmethod - def validate_entry_completeness( - entry_data: dict[str, Any], - ) -> tuple[bool, list[str]]: - """ - Backward-compat entry completeness check. - - Delegates to validate_entry_completeness_with_keys when possible. - """ - # Heuristic split: treat keys ending with _doses and note/date as - # non-core and assume the rest are a mix of pathologies and medicines; - # callers should prefer the explicit API below. - keys = [ - k - for k in entry_data - if k not in {"date", "note"} and not str(k).endswith("_doses") - ] - # Even split guess is unreliable; use value patterns instead: - path_keys = [k for k in keys if isinstance(entry_data.get(k), int | float)] - med_keys = [k for k in keys if k not in path_keys] - return InputValidator.validate_entry_completeness_with_keys( - entry_data, path_keys, med_keys - ) - - @staticmethod - def validate_entry_completeness_with_keys( - entry_data: dict[str, Any], - pathology_keys: list[str], - medicine_keys: list[str], - ) -> tuple[bool, list[str]]: - """ - Validate that an entry has the minimum required data using explicit keys. - - Args: - entry_data: Dictionary containing entry data - pathology_keys: Keys representing pathology scores (numeric, >0 meaningful) - medicine_keys: Keys representing medicine taken flags (0/1 boolean) - - Returns: - Tuple of (is_complete, list_of_missing_fields) - """ - missing_fields: list[str] = [] - if not entry_data.get("date"): - missing_fields.append("Date") - - def _as_int(v: Any) -> int: - try: - return int(v) - except Exception: - try: - return int(float(v)) - except Exception: - return 0 - - has_pathology = any(_as_int(entry_data.get(k, 0)) > 0 for k in pathology_keys) - has_medicine = any(_as_int(entry_data.get(k, 0)) == 1 for k in medicine_keys) - - if not (has_pathology or has_medicine): - missing_fields.append("At least one pathology score or medicine entry") - - return len(missing_fields) == 0, missing_fields +__all__ = ["InputValidator"] diff --git a/src/logger.py b/src/logger.py index f2d77b2..634da00 100644 --- a/src/logger.py +++ b/src/logger.py @@ -1,117 +1,11 @@ -"""Application logging utilities. +"""Compatibility shim for logger utilities. -This module centralizes logger initialization and honors environment-driven -settings from `constants` (LOG_LEVEL, LOG_PATH, LOG_CLEAR). +The canonical implementation resides in `thechart.core.logger`. +This module keeps `from logger import init_logger` working for legacy code/tests. """ from __future__ import annotations -import contextlib -import logging -import sys as _sys +from thechart.core.logger import init_logger # noqa: F401 -try: # Optional dependency; fall back to plain logging if missing - import colorlog # type: ignore -except Exception: # pragma: no cover - defensive in case of runtime packaging - colorlog = None - -from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH - -# Allow tests that patch 'logger.*' to affect this module imported as 'src.logger' -_sys.modules.setdefault("logger", _sys.modules.get(__name__)) - - -def _bool_from_str(value: str) -> bool: - """Parse a truthy string into a boolean. - - Accepts: '1', 'true', 'yes', 'y', 'on' (case-insensitive) as True. - Everything else is False. - """ - - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - - -def _level_from_str(level: str) -> int: - """Map a string like 'INFO' to a logging level, defaulting to INFO.""" - - try: - return getattr(logging, level.upper()) - except AttributeError: - return logging.INFO - - -def init_logger(dunder_name: str, testing_mode: bool) -> logging.Logger: - """Initialize and return a configured logger. - - - Ensures the log directory exists (LOG_PATH). - - Respects LOG_CLEAR: writes files in overwrite mode when true. - - Respects LOG_LEVEL for non-testing runs; testing forces DEBUG. - - Prevents duplicate handlers on repeated initialization. - """ - - log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s" - - # Do not create directories here to honor init tests mocking mkdir/existence. - - # Configure logger instance - logger = logging.getLogger(dunder_name) - logger.propagate = False - - # Clear existing handlers to avoid duplicates in re-inits (e.g., tests) - if logger.handlers: - for h in list(logger.handlers): - logger.removeHandler(h) - with contextlib.suppress(Exception): - h.close() - - # Level selection - if testing_mode: - logger.setLevel(logging.DEBUG) - else: - logger.setLevel(_level_from_str(LOG_LEVEL)) - - # Console handler (colored if colorlog available) - if colorlog is not None: - bold_seq = "\033[1m" - colorlog_format = f"{bold_seq} %(log_color)s {log_format}" - colorlog.basicConfig(format=colorlog_format) - sh = colorlog.StreamHandler() - sh.setLevel(logger.level) - sh.setFormatter(colorlog.ColoredFormatter(colorlog_format)) - else: - sh = logging.StreamHandler() - sh.setLevel(logger.level) - sh.setFormatter(logging.Formatter(log_format)) - logger.addHandler(sh) - - # File handlers (overwrite if LOG_CLEAR truthy) - write_mode = "w" if _bool_from_str(LOG_CLEAR) else "a" - formatter = logging.Formatter(log_format) - - try: - fh_all = logging.FileHandler( - f"{LOG_PATH}/app.log", mode=write_mode, encoding="utf-8" - ) - fh_all.setLevel(logging.DEBUG) - fh_all.setFormatter(formatter) - logger.addHandler(fh_all) - - fh_warn = logging.FileHandler( - f"{LOG_PATH}/app.warning.log", mode=write_mode, encoding="utf-8" - ) - fh_warn.setLevel(logging.WARNING) - fh_warn.setFormatter(formatter) - logger.addHandler(fh_warn) - - fh_err = logging.FileHandler( - f"{LOG_PATH}/app.error.log", mode=write_mode, encoding="utf-8" - ) - fh_err.setLevel(logging.ERROR) - fh_err.setFormatter(formatter) - logger.addHandler(fh_err) - except (PermissionError, FileNotFoundError): - # In restricted environments, fall back to console-only logging - # Tests expect graceful handling (no exception propagated) - pass - - return logger +__all__ = ["init_logger"] diff --git a/src/main.py b/src/main.py index 040ad8e..e5557bf 100644 --- a/src/main.py +++ b/src/main.py @@ -9,29 +9,50 @@ from typing import Any import pandas as pd -from auto_save import AutoSaveManager, BackupManager -from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH -from data_manager import DataManager -from error_handler import ErrorHandler -from export_manager import ExportManager -from export_window import ExportWindow -from graph_manager import GraphManager -from init import logger -from input_validator import InputValidator -from medicine_management_window import MedicineManagementWindow -from medicine_manager import MedicineManager -from pathology_management_window import PathologyManagementWindow -from pathology_manager import PathologyManager -from preferences import get_config_dir, get_pref, save_preferences, set_pref -from search_filter import DataFilter -from settings_window import SettingsWindow -from theme_manager import ThemeManager -from ui_manager import UIManager -from undo_manager import UndoAction, UndoManager +from thechart.analytics import GraphManager +from thechart.core.auto_save import AutoSaveManager, BackupManager +from thechart.core.constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH +from thechart.core.error_handler import ErrorHandler +from thechart.core.logger import init_logger +from thechart.core.preferences import ( + get_config_dir, + get_pref, + save_preferences, + set_pref, +) +from thechart.core.undo_manager import UndoAction, UndoManager +from thechart.data import DataManager +from thechart.export.export_manager import ExportManager +from thechart.managers import MedicineManager, PathologyManager +from thechart.search.search_filter import DataFilter +from thechart.ui import ThemeManager, UIManager +from thechart.ui.export_window import ExportWindow +from thechart.ui.medicine_management_window import MedicineManagementWindow +from thechart.ui.pathology_management_window import PathologyManagementWindow +from thechart.ui.settings_window import SettingsWindow +from thechart.validation import InputValidator # Provide alias module name expected by tests (they patch 'main.*') sys.modules.setdefault("main", sys.modules[__name__]) +# Initialize module-level logger via canonical util +testing_mode = bool(LOG_LEVEL == "DEBUG") +logger = init_logger("thechart.app", testing_mode=testing_mode) + +# Optional log clearing aligned with legacy behavior +if LOG_CLEAR == "True": + for _fp in ( + f"{LOG_PATH}/thechart.log", + f"{LOG_PATH}/thechart.warning.log", + f"{LOG_PATH}/thechart.error.log", + ): + try: + with open(_fp, "w", encoding="utf-8"): + pass + except Exception: + # Non-fatal in app context + pass + class MedTrackerApp: def __init__(self, root: tk.Tk) -> None: @@ -1230,7 +1251,7 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" return try: # Local import to defer module load cost until first use - from search_filter_ui import SearchFilterWidget # type: ignore + from thechart.ui import SearchFilterWidget # type: ignore self.search_filter_widget = SearchFilterWidget( self.main_frame, diff --git a/src/medicine_management_window.py b/src/medicine_management_window.py index 054b830..43c32e1 100644 --- a/src/medicine_management_window.py +++ b/src/medicine_management_window.py @@ -1,401 +1,12 @@ -""" -Medicine management window for adding, editing, and removing medicines. +"""Shim for backward compatibility. + +Re-exports canonical implementation from thechart.ui.medicine_management_window. """ -import tkinter as tk -from tkinter import messagebox, ttk +from __future__ import annotations -from medicine_manager import Medicine, MedicineManager - - -class MedicineManagementWindow: - """Window for managing medicine configurations.""" - - def __init__( - self, parent: tk.Tk, medicine_manager: MedicineManager, refresh_callback - ): - self.parent = parent - self.medicine_manager = medicine_manager - self.refresh_callback = refresh_callback - - # Create the window - self.window = tk.Toplevel(parent) - self.window.title("Manage Medicines") - self.window.geometry("600x500") - self.window.resizable(True, True) - - # Make window modal - self.window.transient(parent) - self.window.grab_set() - - self._setup_ui() - self._populate_medicine_list() - - # Center window - self.window.update_idletasks() - x = (self.window.winfo_screenwidth() // 2) - (600 // 2) - y = (self.window.winfo_screenheight() // 2) - (500 // 2) - self.window.geometry(f"600x500+{x}+{y}") - - def _setup_ui(self): - """Set up the user interface.""" - main_frame = ttk.Frame(self.window, padding="10") - main_frame.grid(row=0, column=0, sticky="nsew") - - self.window.grid_rowconfigure(0, weight=1) - self.window.grid_columnconfigure(0, weight=1) - main_frame.grid_rowconfigure(1, weight=1) - main_frame.grid_columnconfigure(0, weight=1) - - # Title - title_label = ttk.Label( - main_frame, text="Medicine Management", font=("Arial", 14, "bold") - ) - title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10)) - - # Medicine list - list_frame = ttk.LabelFrame(main_frame, text="Current Medicines") - list_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(0, 10)) - list_frame.grid_rowconfigure(0, weight=1) - list_frame.grid_columnconfigure(0, weight=1) - - # Treeview for medicines - columns = ("key", "name", "dosage", "quick_doses", "color", "default") - self.tree = ttk.Treeview(list_frame, columns=columns, show="headings") - - # Column headings - self.tree.heading("key", text="Key") - self.tree.heading("name", text="Name") - self.tree.heading("dosage", text="Dosage Info") - self.tree.heading("quick_doses", text="Quick Doses") - self.tree.heading("color", text="Color") - self.tree.heading("default", text="Default Enabled") - - # Column widths - self.tree.column("key", width=80) - self.tree.column("name", width=100) - self.tree.column("dosage", width=100) - self.tree.column("quick_doses", width=120) - self.tree.column("color", width=70) - self.tree.column("default", width=100) - - self.tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - - # Scrollbar for treeview - scrollbar = ttk.Scrollbar( - list_frame, orient="vertical", command=self.tree.yview - ) - scrollbar.grid(row=0, column=1, sticky="ns") - self.tree.configure(yscrollcommand=scrollbar.set) - - # Buttons - button_frame = ttk.Frame(main_frame) - button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0)) - - ttk.Button(button_frame, text="Add Medicine", command=self._add_medicine).grid( - row=0, column=0, padx=(0, 5) - ) - - ttk.Button( - button_frame, text="Edit Medicine", command=self._edit_medicine - ).grid(row=0, column=1, padx=5) - - ttk.Button( - button_frame, text="Remove Medicine", command=self._remove_medicine - ).grid(row=0, column=2, padx=5) - - ttk.Button(button_frame, text="Close", command=self._close_window).grid( - row=0, column=3, padx=(5, 0) - ) - - def _populate_medicine_list(self): - """Populate the medicine list.""" - # Clear existing items - for item in self.tree.get_children(): - self.tree.delete(item) - - # Add medicines - for medicine in self.medicine_manager.get_all_medicines().values(): - self.tree.insert( - "", - "end", - values=( - medicine.key, - medicine.display_name, - medicine.dosage_info, - ", ".join(medicine.quick_doses), - medicine.color, - "Yes" if medicine.default_enabled else "No", - ), - ) - - def _add_medicine(self): - """Add a new medicine.""" - MedicineEditDialog( - self.window, self.medicine_manager, None, self._on_medicine_changed - ) - - def _edit_medicine(self): - """Edit selected medicine.""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("No Selection", "Please select a medicine to edit.") - return - - item = self.tree.item(selection[0]) - medicine_key = item["values"][0] - medicine = self.medicine_manager.get_medicine(medicine_key) - - if medicine: - MedicineEditDialog( - self.window, self.medicine_manager, medicine, self._on_medicine_changed - ) - - def _remove_medicine(self): - """Remove selected medicine.""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning( - "No Selection", "Please select a medicine to remove." - ) - return - - item = self.tree.item(selection[0]) - medicine_key = item["values"][0] - medicine_name = item["values"][1] - - if messagebox.askyesno( - "Confirm Removal", - f"Are you sure you want to remove '{medicine_name}'?\n\n" - "This will also remove all associated data from your records!", - ): - if self.medicine_manager.remove_medicine(medicine_key): - messagebox.showinfo( - "Success", f"'{medicine_name}' removed successfully!" - ) - self._populate_medicine_list() - self._refresh_main_app() - else: - messagebox.showerror("Error", f"Failed to remove '{medicine_name}'.") - - def _on_medicine_changed(self): - """Called when a medicine is added or edited.""" - self._populate_medicine_list() - self._refresh_main_app() - - def _refresh_main_app(self): - """Refresh the main application after medicine changes.""" - if self.refresh_callback: - self.refresh_callback() - - def _close_window(self): - """Close the window.""" - self.window.destroy() - - -class MedicineEditDialog: - """Dialog for adding/editing a medicine.""" - - def __init__( - self, - parent: tk.Toplevel, - medicine_manager: MedicineManager, - medicine: Medicine | None, - callback, - ): - self.parent = parent - self.medicine_manager = medicine_manager - self.medicine = medicine - self.callback = callback - self.is_edit = medicine is not None - - # Create dialog - self.dialog = tk.Toplevel(parent) - self.dialog.title("Edit Medicine" if self.is_edit else "Add Medicine") - self.dialog.geometry("400x350") - self.dialog.resizable(False, False) - - # Make modal - self.dialog.transient(parent) - self.dialog.grab_set() - - self._setup_dialog() - self._populate_fields() - - # Center dialog - self.dialog.update_idletasks() - x = parent.winfo_x() + (parent.winfo_width() // 2) - (400 // 2) - y = parent.winfo_y() + (parent.winfo_height() // 2) - (350 // 2) - self.dialog.geometry(f"400x350+{x}+{y}") - - def _setup_dialog(self): - """Set up the dialog UI.""" - main_frame = ttk.Frame(self.dialog, padding="15") - main_frame.grid(row=0, column=0, sticky="nsew") - - self.dialog.grid_rowconfigure(0, weight=1) - self.dialog.grid_columnconfigure(0, weight=1) - - # Fields - fields_frame = ttk.Frame(main_frame) - fields_frame.grid(row=0, column=0, sticky="ew", pady=(0, 15)) - fields_frame.grid_columnconfigure(1, weight=1) - - row = 0 - - # Key - ttk.Label(fields_frame, text="Key:").grid(row=row, column=0, sticky="w", pady=5) - self.key_var = tk.StringVar() - key_entry = ttk.Entry(fields_frame, textvariable=self.key_var) - key_entry.grid(row=row, column=1, sticky="ew", padx=(10, 0), pady=5) - if self.is_edit: - key_entry.configure(state="readonly") - row += 1 - - # Display Name - ttk.Label(fields_frame, text="Display Name:").grid( - row=row, column=0, sticky="w", pady=5 - ) - self.name_var = tk.StringVar() - ttk.Entry(fields_frame, textvariable=self.name_var).grid( - row=row, column=1, sticky="ew", padx=(10, 0), pady=5 - ) - row += 1 - - # Dosage Info - ttk.Label(fields_frame, text="Dosage Info:").grid( - row=row, column=0, sticky="w", pady=5 - ) - self.dosage_var = tk.StringVar() - ttk.Entry(fields_frame, textvariable=self.dosage_var).grid( - row=row, column=1, sticky="ew", padx=(10, 0), pady=5 - ) - row += 1 - - # Quick Doses - ttk.Label(fields_frame, text="Quick Doses:").grid( - row=row, column=0, sticky="w", pady=5 - ) - self.doses_var = tk.StringVar() - ttk.Entry(fields_frame, textvariable=self.doses_var).grid( - row=row, column=1, sticky="ew", padx=(10, 0), pady=5 - ) - ttk.Label( - fields_frame, text="(comma-separated, e.g. 25,50,100)", font=("Arial", 8) - ).grid(row=row + 1, column=1, sticky="w", padx=(10, 0)) - row += 2 - - # Color - ttk.Label(fields_frame, text="Graph Color:").grid( - row=row, column=0, sticky="w", pady=5 - ) - self.color_var = tk.StringVar() - ttk.Entry(fields_frame, textvariable=self.color_var).grid( - row=row, column=1, sticky="ew", padx=(10, 0), pady=5 - ) - ttk.Label( - fields_frame, text="(hex color, e.g. #FF6B6B)", font=("Arial", 8) - ).grid(row=row + 1, column=1, sticky="w", padx=(10, 0)) - row += 2 - - # Default Enabled - self.default_var = tk.BooleanVar() - ttk.Checkbutton( - fields_frame, - text="Show in graph by default", - variable=self.default_var, - ).grid(row=row, column=0, columnspan=2, sticky="w", pady=5) - - # Buttons - button_frame = ttk.Frame(main_frame) - button_frame.grid(row=1, column=0) - - ttk.Button(button_frame, text="Save", command=self._save_medicine).grid( - row=0, column=0, padx=(0, 10) - ) - - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).grid( - row=0, column=1 - ) - - def _populate_fields(self): - """Populate fields if editing.""" - if self.medicine: - self.key_var.set(self.medicine.key) - self.name_var.set(self.medicine.display_name) - self.dosage_var.set(self.medicine.dosage_info) - self.doses_var.set(",".join(self.medicine.quick_doses)) - self.color_var.set(self.medicine.color) - self.default_var.set(self.medicine.default_enabled) - - def _save_medicine(self): - """Save the medicine.""" - # Validate fields - key = self.key_var.get().strip() - name = self.name_var.get().strip() - dosage = self.dosage_var.get().strip() - doses_str = self.doses_var.get().strip() - color = self.color_var.get().strip() - - if not all([key, name, dosage, doses_str, color]): - messagebox.showerror("Error", "All fields are required.") - return - - # Validate key format (alphanumeric and underscores only) - if not key.replace("_", "").replace("-", "").isalnum(): - messagebox.showerror( - "Error", - "Key must contain only letters, numbers, underscores, and hyphens.", - ) - return - - # Parse quick doses - try: - quick_doses = [dose.strip() for dose in doses_str.split(",")] - quick_doses = [dose for dose in quick_doses if dose] # Remove empty strings - if not quick_doses: - raise ValueError("At least one quick dose is required.") - except Exception: - messagebox.showerror("Error", "Quick doses must be comma-separated values.") - return - - # Validate color format - if not color.startswith("#") or len(color) != 7: - messagebox.showerror( - "Error", "Color must be in hex format (e.g., #FF6B6B)." - ) - return - - try: - int(color[1:], 16) # Validate hex color - except ValueError: - messagebox.showerror("Error", "Invalid hex color format.") - return - - # Create medicine object - new_medicine = Medicine( - key=key, - display_name=name, - dosage_info=dosage, - quick_doses=quick_doses, - color=color, - default_enabled=self.default_var.get(), - ) - - # Save medicine - success = False - if self.is_edit: - success = self.medicine_manager.update_medicine( - self.medicine.key, new_medicine - ) - else: - success = self.medicine_manager.add_medicine(new_medicine) - - if success: - action = "updated" if self.is_edit else "added" - messagebox.showinfo("Success", f"Medicine {action} successfully!") - self.callback() - self.dialog.destroy() - else: - action = "update" if self.is_edit else "add" - messagebox.showerror("Error", f"Failed to {action} medicine.") +try: # noqa: SIM105 + from thechart.ui.medicine_management_window import * # type: ignore # noqa: F401,F403 +except ModuleNotFoundError: # pragma: no cover + # Fallback for dev environments not using package layout + from src.thechart.ui.medicine_management_window import * # type: ignore # noqa: F401,F403 diff --git a/src/medicine_manager.py b/src/medicine_manager.py index e3dbb33..2c37564 100644 --- a/src/medicine_manager.py +++ b/src/medicine_manager.py @@ -1,195 +1,11 @@ -""" -Medicine configuration manager for the MedTracker application. -Handles dynamic loading and saving of medicine configurations. +"""Legacy shim: import canonical manager from thechart.managers. + +This module persists for backward compatibility with older imports +(`from medicine_manager import MedicineManager`). """ -import json -import logging -import os -from dataclasses import asdict, dataclass -from typing import Any +from __future__ import annotations +from thechart.managers import Medicine, MedicineManager # noqa: F401 -@dataclass -class Medicine: - """Data class representing a medicine.""" - - key: str # Internal key (e.g., "bupropion") - display_name: str # Display name (e.g., "Bupropion") - dosage_info: str # Dosage information (e.g., "150/300 mg") - quick_doses: list[str] # Common dose amounts for quick selection - color: str # Color for graph display - default_enabled: bool = False # Whether to show in graph by default - - -class MedicineManager: - """Manages medicine configurations and provides access to medicine data.""" - - def __init__( - self, config_file: str = "medicines.json", logger: logging.Logger = None - ): - self.config_file = config_file - self.logger = logger or logging.getLogger(__name__) - self.medicines: dict[str, Medicine] = {} - self._load_medicines() - - def _get_default_medicines(self) -> list[Medicine]: - """Get the default medicine configuration.""" - return [ - Medicine( - key="bupropion", - display_name="Bupropion", - dosage_info="150/300 mg", - quick_doses=["150", "300"], - color="#FF6B6B", - default_enabled=True, - ), - Medicine( - key="hydroxyzine", - display_name="Hydroxyzine", - dosage_info="25 mg", - quick_doses=["25", "50"], - color="#4ECDC4", - default_enabled=False, - ), - Medicine( - key="gabapentin", - display_name="Gabapentin", - dosage_info="100 mg", - quick_doses=["100", "300", "600"], - color="#45B7D1", - default_enabled=False, - ), - Medicine( - key="propranolol", - display_name="Propranolol", - dosage_info="10 mg", - quick_doses=["10", "20", "40"], - color="#96CEB4", - default_enabled=True, - ), - Medicine( - key="quetiapine", - display_name="Quetiapine", - dosage_info="25 mg", - quick_doses=["25", "50", "100"], - color="#FFEAA7", - default_enabled=False, - ), - ] - - def _load_medicines(self) -> None: - """Load medicines from configuration file.""" - if os.path.exists(self.config_file): - try: - with open(self.config_file) as f: - data = json.load(f) - - self.medicines = {} - for medicine_data in data.get("medicines", []): - medicine = Medicine(**medicine_data) - self.medicines[medicine.key] = medicine - - self.logger.info( - f"Loaded {len(self.medicines)} medicines from {self.config_file}" - ) - except Exception as e: - self.logger.error(f"Error loading medicines config: {e}") - self._create_default_config() - else: - self._create_default_config() - - def _create_default_config(self) -> None: - """Create default medicine configuration.""" - default_medicines = self._get_default_medicines() - self.medicines = {med.key: med for med in default_medicines} - self.save_medicines() - self.logger.info("Created default medicine configuration") - - def save_medicines(self) -> bool: - """Save current medicines to configuration file.""" - try: - data = { - "medicines": [asdict(medicine) for medicine in self.medicines.values()] - } - - with open(self.config_file, "w") as f: - json.dump(data, f, indent=2) - - self.logger.info( - f"Saved {len(self.medicines)} medicines to {self.config_file}" - ) - return True - except Exception as e: - self.logger.error(f"Error saving medicines config: {e}") - return False - - def get_all_medicines(self) -> dict[str, Medicine]: - """Get all medicines.""" - return self.medicines.copy() - - def get_medicine(self, key: str) -> Medicine | None: - """Get a specific medicine by key.""" - return self.medicines.get(key) - - def add_medicine(self, medicine: Medicine) -> bool: - """Add a new medicine.""" - if medicine.key in self.medicines: - self.logger.warning(f"Medicine with key '{medicine.key}' already exists") - return False - - self.medicines[medicine.key] = medicine - return self.save_medicines() - - def update_medicine(self, key: str, medicine: Medicine) -> bool: - """Update an existing medicine.""" - if key not in self.medicines: - self.logger.warning(f"Medicine with key '{key}' does not exist") - return False - - # If key is changing, remove old entry - if key != medicine.key: - del self.medicines[key] - - self.medicines[medicine.key] = medicine - return self.save_medicines() - - def remove_medicine(self, key: str) -> bool: - """Remove a medicine.""" - if key not in self.medicines: - self.logger.warning(f"Medicine with key '{key}' does not exist") - return False - - del self.medicines[key] - return self.save_medicines() - - def get_medicine_keys(self) -> list[str]: - """Get list of all medicine keys.""" - return list(self.medicines.keys()) - - def get_display_names(self) -> dict[str, str]: - """Get mapping of keys to display names.""" - return {key: med.display_name for key, med in self.medicines.items()} - - def get_quick_doses(self, key: str) -> list[str]: - """Get quick dose options for a medicine.""" - medicine = self.medicines.get(key) - return medicine.quick_doses if medicine else ["25", "50"] - - def get_graph_colors(self) -> dict[str, str]: - """Get mapping of medicine keys to graph colors.""" - return {key: med.color for key, med in self.medicines.items()} - - def get_default_enabled_medicines(self) -> list[str]: - """Get list of medicines that should be enabled by default in graphs.""" - return [key for key, med in self.medicines.items() if med.default_enabled] - - def get_medicine_vars_dict(self) -> dict[str, tuple[Any, str]]: - """Get medicine variables dictionary for UI compatibility.""" - # This maintains compatibility with existing UI code - import tkinter as tk - - return { - key: (tk.IntVar(value=0), f"{med.display_name} {med.dosage_info}") - for key, med in self.medicines.items() - } +__all__ = ["Medicine", "MedicineManager"] diff --git a/src/pathology_management_window.py b/src/pathology_management_window.py index aefa22e..bc41d2b 100644 --- a/src/pathology_management_window.py +++ b/src/pathology_management_window.py @@ -1,425 +1,12 @@ -""" -Pathology management window for adding, editing, and removing pathologies. +"""Shim for backward compatibility. + +Re-exports canonical implementation from thechart.ui.pathology_management_window. """ -import tkinter as tk -from tkinter import messagebox, ttk +from __future__ import annotations -from pathology_manager import Pathology, PathologyManager - - -class PathologyManagementWindow: - """Window for managing pathology configurations.""" - - def __init__( - self, parent: tk.Tk, pathology_manager: PathologyManager, refresh_callback - ): - self.parent = parent - self.pathology_manager = pathology_manager - self.refresh_callback = refresh_callback - - # Create the window - self.window = tk.Toplevel(parent) - self.window.title("Manage Pathologies") - self.window.geometry("800x500") - self.window.resizable(True, True) - - # Make window modal - self.window.transient(parent) - self.window.grab_set() - - self._setup_ui() - self._populate_pathology_list() - - # Center window - self.window.update_idletasks() - x = (self.window.winfo_screenwidth() // 2) - (800 // 2) - y = (self.window.winfo_screenheight() // 2) - (500 // 2) - self.window.geometry(f"800x500+{x}+{y}") - - def _setup_ui(self): - """Set up the UI components.""" - # Main frame - main_frame = ttk.Frame(self.window, padding="10") - main_frame.grid(row=0, column=0, sticky="nsew") - self.window.grid_rowconfigure(0, weight=1) - self.window.grid_columnconfigure(0, weight=1) - - # Pathology list - list_frame = ttk.LabelFrame(main_frame, text="Pathologies", padding="5") - list_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) - main_frame.grid_rowconfigure(0, weight=1) - main_frame.grid_columnconfigure(0, weight=1) - - # Treeview for pathology list - columns = ( - "Key", - "Display Name", - "Scale Info", - "Color", - "Default Enabled", - "Scale Range", - ) - self.tree = ttk.Treeview(list_frame, columns=columns, show="headings") - - # Configure columns - self.tree.heading("Key", text="Key") - self.tree.heading("Display Name", text="Display Name") - self.tree.heading("Scale Info", text="Scale Info") - self.tree.heading("Color", text="Color") - self.tree.heading("Default Enabled", text="Default Enabled") - self.tree.heading("Scale Range", text="Scale Range") - - self.tree.column("Key", width=120) - self.tree.column("Display Name", width=150) - self.tree.column("Scale Info", width=150) - self.tree.column("Color", width=80) - self.tree.column("Default Enabled", width=100) - self.tree.column("Scale Range", width=100) - - # Scrollbar for treeview - scrollbar = ttk.Scrollbar( - list_frame, orient="vertical", command=self.tree.yview - ) - self.tree.configure(yscrollcommand=scrollbar.set) - - self.tree.grid(row=0, column=0, sticky="nsew") - scrollbar.grid(row=0, column=1, sticky="ns") - - list_frame.grid_rowconfigure(0, weight=1) - list_frame.grid_columnconfigure(0, weight=1) - - # Buttons frame - button_frame = ttk.Frame(main_frame) - button_frame.grid(row=1, column=0, sticky="ew") - - ttk.Button( - button_frame, text="Add Pathology", command=self._add_pathology - ).pack(side="left", padx=(0, 5)) - ttk.Button( - button_frame, text="Edit Pathology", command=self._edit_pathology - ).pack(side="left", padx=(0, 5)) - ttk.Button( - button_frame, text="Remove Pathology", command=self._remove_pathology - ).pack(side="left", padx=(0, 5)) - ttk.Button(button_frame, text="Close", command=self.window.destroy).pack( - side="right" - ) - - def _populate_pathology_list(self): - """Populate the pathology list.""" - # Clear existing items - for item in self.tree.get_children(): - self.tree.delete(item) - - # Add pathologies - for pathology in self.pathology_manager.get_all_pathologies().values(): - scale_range = f"{pathology.scale_min}-{pathology.scale_max}" - self.tree.insert( - "", - "end", - values=( - pathology.key, - pathology.display_name, - pathology.scale_info, - pathology.color, - "Yes" if pathology.default_enabled else "No", - scale_range, - ), - ) - - def _add_pathology(self): - """Add a new pathology.""" - PathologyEditDialog( - self.window, self.pathology_manager, None, self._on_pathology_changed - ) - - def _edit_pathology(self): - """Edit selected pathology.""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning("No Selection", "Please select a pathology to edit.") - return - - item = self.tree.item(selection[0]) - pathology_key = item["values"][0] - pathology = self.pathology_manager.get_pathology(pathology_key) - - if pathology: - PathologyEditDialog( - self.window, - self.pathology_manager, - pathology, - self._on_pathology_changed, - ) - - def _remove_pathology(self): - """Remove selected pathology.""" - selection = self.tree.selection() - if not selection: - messagebox.showwarning( - "No Selection", "Please select a pathology to remove." - ) - return - - item = self.tree.item(selection[0]) - pathology_key = item["values"][0] - pathology_name = item["values"][1] - - if messagebox.askyesno( - "Confirm Removal", - f"Are you sure you want to remove '{pathology_name}'?\n\n" - "This will also remove all associated data from your records!", - ): - if self.pathology_manager.remove_pathology(pathology_key): - messagebox.showinfo( - "Success", f"'{pathology_name}' removed successfully!" - ) - self._populate_pathology_list() - self._refresh_main_app() - else: - messagebox.showerror("Error", f"Failed to remove '{pathology_name}'.") - - def _on_pathology_changed(self): - """Handle pathology changes.""" - self._populate_pathology_list() - self._refresh_main_app() - - def _refresh_main_app(self): - """Refresh the main application.""" - if self.refresh_callback: - self.refresh_callback() - - -class PathologyEditDialog: - """Dialog for adding/editing a pathology.""" - - def __init__( - self, - parent: tk.Toplevel, - pathology_manager: PathologyManager, - pathology: Pathology | None, - callback, - ): - self.parent = parent - self.pathology_manager = pathology_manager - self.pathology = pathology - self.callback = callback - self.is_edit = pathology is not None - - # Create dialog - self.dialog = tk.Toplevel(parent) - self.dialog.title("Edit Pathology" if self.is_edit else "Add Pathology") - self.dialog.geometry("450x400") - self.dialog.resizable(False, False) - - # Make modal - self.dialog.transient(parent) - self.dialog.grab_set() - - self._setup_dialog() - self._populate_fields() - - # Center dialog - self.dialog.update_idletasks() - x = parent.winfo_x() + (parent.winfo_width() // 2) - (450 // 2) - y = parent.winfo_y() + (parent.winfo_height() // 2) - (400 // 2) - self.dialog.geometry(f"450x400+{x}+{y}") - - def _setup_dialog(self): - """Set up the dialog UI.""" - # Main frame - main_frame = ttk.Frame(self.dialog, padding="15") - main_frame.grid(row=0, column=0, sticky="nsew") - self.dialog.grid_rowconfigure(0, weight=1) - self.dialog.grid_columnconfigure(0, weight=1) - - # Form fields - self.key_var = tk.StringVar() - self.name_var = tk.StringVar() - self.scale_info_var = tk.StringVar() - self.color_var = tk.StringVar() - self.default_var = tk.BooleanVar() - self.scale_min_var = tk.IntVar(value=0) - self.scale_max_var = tk.IntVar(value=10) - self.orientation_var = tk.StringVar(value="normal") - - # Key field - ttk.Label(main_frame, text="Key:").grid( - row=0, column=0, sticky="w", pady=(0, 5) - ) - key_entry = ttk.Entry(main_frame, textvariable=self.key_var, width=40) - key_entry.grid(row=0, column=1, sticky="ew", pady=(0, 5)) - ttk.Label(main_frame, text="(alphanumeric, underscores, hyphens only)").grid( - row=0, column=2, sticky="w", padx=(5, 0), pady=(0, 5) - ) - - # Display name field - ttk.Label(main_frame, text="Display Name:").grid( - row=1, column=0, sticky="w", pady=(0, 5) - ) - ttk.Entry(main_frame, textvariable=self.name_var, width=40).grid( - row=1, column=1, sticky="ew", pady=(0, 5) - ) - - # Scale info field - ttk.Label(main_frame, text="Scale Info:").grid( - row=2, column=0, sticky="w", pady=(0, 5) - ) - ttk.Entry(main_frame, textvariable=self.scale_info_var, width=40).grid( - row=2, column=1, sticky="ew", pady=(0, 5) - ) - ttk.Label(main_frame, text='(e.g., "0:good, 10:bad")').grid( - row=2, column=2, sticky="w", padx=(5, 0), pady=(0, 5) - ) - - # Scale range - scale_frame = ttk.Frame(main_frame) - scale_frame.grid(row=3, column=1, sticky="ew", pady=(0, 5)) - - ttk.Label(main_frame, text="Scale Range:").grid( - row=3, column=0, sticky="w", pady=(0, 5) - ) - ttk.Label(scale_frame, text="Min:").grid(row=0, column=0, sticky="w") - ttk.Entry(scale_frame, textvariable=self.scale_min_var, width=5).grid( - row=0, column=1, padx=(5, 10) - ) - ttk.Label(scale_frame, text="Max:").grid(row=0, column=2, sticky="w") - ttk.Entry(scale_frame, textvariable=self.scale_max_var, width=5).grid( - row=0, column=3, padx=5 - ) - - # Scale orientation - ttk.Label(main_frame, text="Scale Orientation:").grid( - row=4, column=0, sticky="w", pady=(0, 5) - ) - orientation_frame = ttk.Frame(main_frame) - orientation_frame.grid(row=4, column=1, sticky="ew", pady=(0, 5)) - - ttk.Radiobutton( - orientation_frame, - text="Normal (0=good)", - variable=self.orientation_var, - value="normal", - ).grid(row=0, column=0, sticky="w") - ttk.Radiobutton( - orientation_frame, - text="Inverted (0=bad)", - variable=self.orientation_var, - value="inverted", - ).grid(row=0, column=1, sticky="w", padx=(20, 0)) - - # Color field - ttk.Label(main_frame, text="Color:").grid( - row=5, column=0, sticky="w", pady=(0, 5) - ) - ttk.Entry(main_frame, textvariable=self.color_var, width=40).grid( - row=5, column=1, sticky="ew", pady=(0, 5) - ) - ttk.Label(main_frame, text="(hex format, e.g., #FF6B6B)").grid( - row=5, column=2, sticky="w", padx=(5, 0), pady=(0, 5) - ) - - # Default enabled checkbox - ttk.Checkbutton( - main_frame, text="Show in graph by default", variable=self.default_var - ).grid(row=6, column=1, sticky="w", pady=(10, 15)) - - # Buttons - button_frame = ttk.Frame(main_frame) - button_frame.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(10, 0)) - - ttk.Button(button_frame, text="Save", command=self._save_pathology).pack( - side="right", padx=(5, 0) - ) - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack( - side="right" - ) - - # Configure column weights - main_frame.grid_columnconfigure(1, weight=1) - - # Focus on first field - key_entry.focus() - - def _populate_fields(self): - """Populate fields if editing.""" - if self.pathology: - self.key_var.set(self.pathology.key) - self.name_var.set(self.pathology.display_name) - self.scale_info_var.set(self.pathology.scale_info) - self.color_var.set(self.pathology.color) - self.default_var.set(self.pathology.default_enabled) - self.scale_min_var.set(self.pathology.scale_min) - self.scale_max_var.set(self.pathology.scale_max) - self.orientation_var.set(self.pathology.scale_orientation) - - def _save_pathology(self): - """Save the pathology.""" - # Validate fields - key = self.key_var.get().strip() - name = self.name_var.get().strip() - scale_info = self.scale_info_var.get().strip() - color = self.color_var.get().strip() - scale_min = self.scale_min_var.get() - scale_max = self.scale_max_var.get() - - if not all([key, name, scale_info, color]): - messagebox.showerror("Error", "All fields are required.") - return - - # Validate key format (alphanumeric and underscores only) - if not key.replace("_", "").replace("-", "").isalnum(): - messagebox.showerror( - "Error", - "Key must contain only letters, numbers, underscores, and hyphens.", - ) - return - - # Validate scale range - if scale_min >= scale_max: - messagebox.showerror("Error", "Scale minimum must be less than maximum.") - return - - # Validate color format - if not color.startswith("#") or len(color) != 7: - messagebox.showerror( - "Error", "Color must be in hex format (e.g., #FF6B6B)." - ) - return - - try: - int(color[1:], 16) # Validate hex color - except ValueError: - messagebox.showerror("Error", "Invalid hex color format.") - return - - # Create pathology object - new_pathology = Pathology( - key=key, - display_name=name, - scale_info=scale_info, - color=color, - default_enabled=self.default_var.get(), - scale_min=scale_min, - scale_max=scale_max, - scale_orientation=self.orientation_var.get(), - ) - - # Save pathology - success = False - if self.is_edit: - success = self.pathology_manager.update_pathology( - self.pathology.key, new_pathology - ) - else: - success = self.pathology_manager.add_pathology(new_pathology) - - if success: - action = "updated" if self.is_edit else "added" - messagebox.showinfo("Success", f"Pathology {action} successfully!") - self.callback() - self.dialog.destroy() - else: - action = "update" if self.is_edit else "add" - messagebox.showerror("Error", f"Failed to {action} pathology.") +try: # noqa: SIM105 + from thechart.ui.pathology_management_window import * # type: ignore # noqa: F401,F403 +except ModuleNotFoundError: # pragma: no cover + # Fallback for dev environments not using package layout + from src.thechart.ui.pathology_management_window import * # type: ignore # noqa: F401,F403 diff --git a/src/pathology_manager.py b/src/pathology_manager.py index 3653e03..27b7991 100644 --- a/src/pathology_manager.py +++ b/src/pathology_manager.py @@ -1,199 +1,11 @@ -""" -Pathology configuration manager for the MedTracker application. -Handles dynamic loading and saving of pathology/symptom configurations. +"""Legacy shim: import canonical manager from thechart.managers. + +This module persists for backward compatibility with older imports +(`from pathology_manager import PathologyManager`). """ -import json -import logging -import os -from dataclasses import asdict, dataclass -from typing import Any +from __future__ import annotations +from thechart.managers import Pathology, PathologyManager # noqa: F401 -@dataclass -class Pathology: - """Data class representing a pathology/symptom.""" - - key: str # Internal key (e.g., "depression") - display_name: str # Display name (e.g., "Depression") - scale_info: str # Scale information (e.g., "0:good, 10:bad") - color: str # Color for graph display - default_enabled: bool = True # Whether to show in graph by default - scale_min: int = 0 # Minimum scale value - scale_max: int = 10 # Maximum scale value - scale_orientation: str = "normal" # "normal" (0=good) or "inverted" (0=bad) - - -class PathologyManager: - """Manages pathology configurations and provides access to pathology data.""" - - def __init__( - self, config_file: str = "pathologies.json", logger: logging.Logger = None - ): - self.config_file = config_file - self.logger = logger or logging.getLogger(__name__) - self.pathologies: dict[str, Pathology] = {} - self._load_pathologies() - - def _get_default_pathologies(self) -> list[Pathology]: - """Get the default pathology configuration.""" - return [ - Pathology( - key="depression", - display_name="Depression", - scale_info="0:good, 10:bad", - color="#FF6B6B", - default_enabled=True, - scale_orientation="normal", - ), - Pathology( - key="anxiety", - display_name="Anxiety", - scale_info="0:good, 10:bad", - color="#FFA726", - default_enabled=True, - scale_orientation="normal", - ), - Pathology( - key="sleep", - display_name="Sleep Quality", - scale_info="0:bad, 10:good", - color="#66BB6A", - default_enabled=True, - scale_orientation="inverted", - ), - Pathology( - key="appetite", - display_name="Appetite", - scale_info="0:bad, 10:good", - color="#42A5F5", - default_enabled=True, - scale_orientation="inverted", - ), - ] - - def _load_pathologies(self) -> None: - """Load pathologies from configuration file.""" - if os.path.exists(self.config_file): - try: - with open(self.config_file) as f: - data = json.load(f) - - self.pathologies = {} - for pathology_data in data.get("pathologies", []): - pathology = Pathology(**pathology_data) - self.pathologies[pathology.key] = pathology - - self.logger.info( - f"Loaded {len(self.pathologies)} pathologies from " - f"{self.config_file}" - ) - except Exception as e: - self.logger.error(f"Error loading pathologies config: {e}") - self._create_default_config() - else: - self._create_default_config() - - def _create_default_config(self) -> None: - """Create default pathology configuration.""" - default_pathologies = self._get_default_pathologies() - self.pathologies = {path.key: path for path in default_pathologies} - self.save_pathologies() - self.logger.info("Created default pathology configuration") - - def save_pathologies(self) -> bool: - """Save current pathologies to configuration file.""" - try: - data = { - "pathologies": [ - asdict(pathology) for pathology in self.pathologies.values() - ] - } - - with open(self.config_file, "w") as f: - json.dump(data, f, indent=2) - - self.logger.info( - f"Saved {len(self.pathologies)} pathologies to {self.config_file}" - ) - return True - except Exception as e: - self.logger.error(f"Error saving pathologies config: {e}") - return False - - def get_all_pathologies(self) -> dict[str, Pathology]: - """Get all pathologies.""" - return self.pathologies.copy() - - def get_pathology(self, key: str) -> Pathology | None: - """Get a specific pathology by key.""" - return self.pathologies.get(key) - - def add_pathology(self, pathology: Pathology) -> bool: - """Add a new pathology.""" - if pathology.key in self.pathologies: - self.logger.warning(f"Pathology with key '{pathology.key}' already exists") - return False - - self.pathologies[pathology.key] = pathology - return self.save_pathologies() - - def update_pathology(self, key: str, pathology: Pathology) -> bool: - """Update an existing pathology.""" - if key not in self.pathologies: - self.logger.warning(f"Pathology with key '{key}' does not exist") - return False - - # If key is changing, remove old entry - if key != pathology.key: - del self.pathologies[key] - - self.pathologies[pathology.key] = pathology - return self.save_pathologies() - - def remove_pathology(self, key: str) -> bool: - """Remove a pathology.""" - if key not in self.pathologies: - self.logger.warning(f"Pathology with key '{key}' does not exist") - return False - - del self.pathologies[key] - return self.save_pathologies() - - def get_pathology_keys(self) -> list[str]: - """Get list of all pathology keys.""" - return list(self.pathologies.keys()) - - def get_display_names(self) -> dict[str, str]: - """Get mapping of keys to display names.""" - return {key: path.display_name for key, path in self.pathologies.items()} - - def get_graph_colors(self) -> dict[str, str]: - """Get mapping of pathology keys to graph colors.""" - return {key: path.color for key, path in self.pathologies.items()} - - def get_default_enabled_pathologies(self) -> list[str]: - """Get list of pathologies that should be enabled by default in graphs.""" - return [key for key, path in self.pathologies.items() if path.default_enabled] - - def get_pathology_vars_dict(self) -> dict[str, tuple[Any, str]]: - """Get pathology variables dictionary for UI compatibility.""" - # This maintains compatibility with existing UI code - import tkinter as tk - - return { - key: (tk.IntVar(value=0), path.display_name) - for key, path in self.pathologies.items() - } - - def get_scale_info(self, key: str) -> tuple[int, int, str, str]: - """Get scale information for a pathology.""" - pathology = self.get_pathology(key) - if pathology: - return ( - pathology.scale_min, - pathology.scale_max, - pathology.scale_info, - pathology.scale_orientation, - ) - return (0, 10, "0-10", "normal") +__all__ = ["Pathology", "PathologyManager"] diff --git a/src/preferences.py b/src/preferences.py index d196c20..43dbadd 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -1,117 +1,24 @@ -"""Application preferences with simple JSON persistence. +"""Compatibility shim for preferences API. -API stays minimal: get_pref/set_pref for reads and writes, plus -load_preferences/save_preferences to manage disk state. +Canonical implementation lives in `thechart.core.preferences`. """ from __future__ import annotations -import json -import os -import sys -from typing import Any +from thechart.core.preferences import ( # noqa: F401 + get_config_dir, + get_pref, + load_preferences, + reset_preferences, + save_preferences, + set_pref, +) -_DEFAULTS: dict[str, Any] = { - # After a successful restore, offer to open the backups folder? - "prompt_open_folder_after_restore": False, - # Remember and restore window geometry between runs - "remember_window_geometry": True, - "last_window_geometry": "", - # Keep window always on top - "always_on_top": False, - # Search/filter UI state - "search_panel_visible": False, - "last_filter_state": None, - # Table column UX - "column_widths": {}, - "last_sort": {"column": None, "ascending": True}, - # Data: archiving/rotation - "archive_keep_years": 1, -} - -_PREFERENCES: dict[str, Any] = dict(_DEFAULTS) - - -def _config_dir() -> str: - """Return platform-appropriate config directory for TheChart.""" - try: - if sys.platform.startswith("win"): - base = os.environ.get("APPDATA", os.path.expanduser("~")) - return os.path.join(base, "TheChart") - if sys.platform == "darwin": - return os.path.join( - os.path.expanduser("~"), - "Library", - "Application Support", - "TheChart", - ) - # Linux and others: follow XDG - base = os.environ.get( - "XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config") - ) - return os.path.join(base, "thechart") - except Exception: - # Fallback to current directory if anything goes wrong - return os.getcwd() - - -def _config_path() -> str: - return os.path.join(_config_dir(), "preferences.json") - - -def get_config_dir() -> str: - """Public accessor for the application configuration directory.""" - return _config_dir() - - -def load_preferences() -> None: - """Load preferences from disk if present, fallback to defaults.""" - global _PREFERENCES - path = _config_path() - try: - if os.path.isfile(path): - with open(path, encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict): - merged = dict(_DEFAULTS) - merged.update(data) - _PREFERENCES = merged - except Exception: - # Ignore corrupt or unreadable files; continue with current prefs - pass - - -def save_preferences() -> None: - """Persist preferences to disk atomically.""" - path = _config_path() - directory = os.path.dirname(path) - try: - os.makedirs(directory, exist_ok=True) - tmp_path = path + ".tmp" - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(_PREFERENCES, f, indent=2, sort_keys=True) - os.replace(tmp_path, path) - except Exception: - # Best-effort persistence; ignore failures silently - pass - - -def reset_preferences() -> None: - """Reset preferences in memory to defaults and persist to disk.""" - global _PREFERENCES - _PREFERENCES = dict(_DEFAULTS) - save_preferences() - - -def get_pref(key: str, default: Any | None = None) -> Any: - """Get a preference value, or default if unset.""" - return _PREFERENCES.get(key, default) - - -def set_pref(key: str, value: Any) -> None: - """Set a preference value in memory (call save_preferences to persist).""" - _PREFERENCES[key] = value - - -# Attempt to load preferences on import for convenience -load_preferences() +__all__ = [ + "get_config_dir", + "load_preferences", + "save_preferences", + "reset_preferences", + "get_pref", + "set_pref", +] diff --git a/src/search_filter.py b/src/search_filter.py index 3f80c74..dda894e 100644 --- a/src/search_filter.py +++ b/src/search_filter.py @@ -1,421 +1,16 @@ -"""Search and filter functionality for TheChart application.""" +"""Legacy shim for search/filter logic. -import re -from typing import Any +The canonical implementation lives in ``thechart.search``. +This module re-exports those for backward compatibility with tests importing +``src.search_filter``. +""" -import pandas as pd +from __future__ import annotations +from thechart.search.search_filter import ( # noqa: F401 + DataFilter, + QuickFilters, + SearchHistory, +) -class DataFilter: - """Handles filtering and searching of medical data.""" - - def __init__(self, logger=None): - """ - Initialize data filter. - - Args: - logger: Logger instance for debugging - """ - self.logger = logger - self.active_filters = {} - self.search_term = "" - - def set_date_range_filter( - self, start_date: str | None = None, end_date: str | None = None - ) -> None: - """ - Set date range filter. - - Args: - start_date: Start date string (inclusive) - end_date: End date string (inclusive) - """ - if start_date or end_date: - self.active_filters["date_range"] = {"start": start_date, "end": end_date} - elif "date_range" in self.active_filters: - del self.active_filters["date_range"] - - def set_medicine_filter(self, medicine_key: str, taken: bool) -> None: - """ - Filter by medicine taken status. - - Args: - medicine_key: Medicine identifier - taken: Whether medicine was taken (True) or not taken (False) - """ - if "medicines" not in self.active_filters: - self.active_filters["medicines"] = {} - - self.active_filters["medicines"][medicine_key] = taken - - def set_pathology_range_filter( - self, - pathology_key: str, - min_score: int | None = None, - max_score: int | None = None, - ) -> None: - """ - Filter by pathology score range. - - Args: - pathology_key: Pathology identifier - min_score: Minimum score (inclusive) - max_score: Maximum score (inclusive) - """ - if min_score is not None or max_score is not None: - if "pathologies" not in self.active_filters: - self.active_filters["pathologies"] = {} - - self.active_filters["pathologies"][pathology_key] = { - "min": min_score, - "max": max_score, - } - - def set_search_term(self, search_term: str) -> None: - """ - Set text search term for notes and other text fields. - - Args: - search_term: Text to search for - """ - self.search_term = search_term.strip() - - def clear_all_filters(self) -> None: - """Clear all active filters and search terms.""" - self.active_filters.clear() - self.search_term = "" - - def clear_filter(self, filter_type: str, filter_key: str | None = None) -> None: - """ - Clear specific filter. - - Args: - filter_type: Type of filter ("date_range", "medicines", "pathologies") - filter_key: Specific key within filter type (optional) - """ - if filter_type in self.active_filters: - if filter_key and isinstance(self.active_filters[filter_type], dict): - if filter_key in self.active_filters[filter_type]: - del self.active_filters[filter_type][filter_key] - # Remove parent filter if empty - if not self.active_filters[filter_type]: - del self.active_filters[filter_type] - else: - del self.active_filters[filter_type] - - def apply_filters(self, df: pd.DataFrame) -> pd.DataFrame: - """ - Apply all active filters to the dataframe. - - Args: - df: Input dataframe - - Returns: - Filtered dataframe - """ - if df.empty: - return df - - filtered_df = df.copy() - - try: - # Apply date range filter - filtered_df = self._apply_date_filter(filtered_df) - - # Apply medicine filters - filtered_df = self._apply_medicine_filters(filtered_df) - - # Apply pathology filters - filtered_df = self._apply_pathology_filters(filtered_df) - - # Apply text search - filtered_df = self._apply_text_search(filtered_df) - - if self.logger: - original_count = len(df) - filtered_count = len(filtered_df) - self.logger.debug( - f"Applied filters: {original_count} -> {filtered_count} entries" - ) - - return filtered_df - - except Exception as e: - if self.logger: - self.logger.error(f"Error applying filters: {e}") - return df # Return original data if filtering fails - - def _apply_date_filter(self, df: pd.DataFrame) -> pd.DataFrame: - """Apply date range filter.""" - if "date_range" not in self.active_filters: - return df - - date_filter = self.active_filters["date_range"] - start_date = date_filter.get("start") - end_date = date_filter.get("end") - - if not start_date and not end_date: - return df - - # Support both legacy lowercase 'date' and capitalized 'Date' - date_col = ( - "date" if "date" in df.columns else "Date" if "Date" in df.columns else None - ) - if not date_col: - return df - - try: - # Convert date column to datetime – attempt multiple formats safely - df_dates = pd.to_datetime(df[date_col], errors="coerce") - - mask = pd.Series(True, index=df.index) - - if start_date: - mask &= df_dates >= pd.to_datetime(start_date, errors="coerce") - if end_date: - mask &= df_dates <= pd.to_datetime(end_date, errors="coerce") - - return df[mask] - except Exception as e: # pragma: no cover - defensive - if self.logger: - self.logger.warning(f"Date filter failed: {e}") - return df - - def _apply_medicine_filters(self, df: pd.DataFrame) -> pd.DataFrame: - """Apply medicine filters.""" - if "medicines" not in self.active_filters: - return df - - medicine_filters = self.active_filters["medicines"] - mask = pd.Series(True, index=df.index) - - for medicine_key, should_be_taken in medicine_filters.items(): - if medicine_key in df.columns: - col = df[medicine_key] - # Heuristic: - # - If object dtype and values look like time:dose strings, - # use string presence - # - Else if numeric (or numeric-like), use non-zero for taken, - # zero for not taken - # - Else fallback to string presence - if col.dtype == object: - s = col.astype(str) - looks_time_dose = s.str.contains( - r":|\|", regex=True, na=False - ).any() - if looks_time_dose: - if should_be_taken: - mask &= s.str.len() > 0 - else: - mask &= s.str.len() == 0 - continue - # Try numeric-like strings - numeric = pd.to_numeric(col, errors="coerce") - if numeric.notna().any(): - if should_be_taken: - mask &= numeric.fillna(0) != 0 - else: - mask &= numeric.fillna(0) == 0 - else: - if should_be_taken: - mask &= s.str.len() > 0 - else: - mask &= s.str.len() == 0 - else: - # Numeric dtype - if should_be_taken: - mask &= col.fillna(0) != 0 - else: - mask &= col.fillna(0) == 0 - - return df[mask] - - def _apply_pathology_filters(self, df: pd.DataFrame) -> pd.DataFrame: - """Apply pathology score range filters.""" - if "pathologies" not in self.active_filters: - return df - - pathology_filters = self.active_filters["pathologies"] - mask = pd.Series(True, index=df.index) - - for pathology_key, score_range in pathology_filters.items(): - if pathology_key in df.columns: - # Coerce to numeric; non-numeric -> NaN (excluded by comparisons) - col = pd.to_numeric(df[pathology_key], errors="coerce") - min_score = score_range.get("min") - max_score = score_range.get("max") - if min_score is not None: - mask &= col >= min_score - if max_score is not None: - mask &= col <= max_score - - return df[mask] - - def _apply_text_search(self, df: pd.DataFrame) -> pd.DataFrame: - """Apply text search to notes and other text fields.""" - if not self.search_term: - return df - - # Create regex pattern for case-insensitive search - try: - pattern = re.compile(re.escape(self.search_term), re.IGNORECASE) - except re.error: # pragma: no cover - defensive - pattern = self.search_term.lower() - - mask = pd.Series(False, index=df.index) - - # Support both Notes/note and Date/date columns - note_cols = [c for c in ("Notes", "Note", "note", "notes") if c in df.columns] - date_cols = [c for c in ("Date", "date") if c in df.columns] - - for col in note_cols + date_cols: - if isinstance(pattern, re.Pattern): - mask |= df[col].astype(str).str.contains(pattern, na=False) - else: - mask |= df[col].astype(str).str.lower().str.contains(pattern, na=False) - - return df[mask] - - def get_filter_summary(self) -> dict[str, Any]: - """ - Get summary of active filters. - - Returns: - Dictionary describing active filters - """ - summary = { - "has_filters": bool(self.active_filters or self.search_term), - "filter_count": len(self.active_filters), - "search_term": self.search_term, - "filters": {}, - } - - # Date range summary - if "date_range" in self.active_filters: - date_range = self.active_filters["date_range"] - summary["filters"]["date_range"] = { - "start": date_range.get("start", "Any"), - "end": date_range.get("end", "Any"), - } - - # Medicine filters summary - if "medicines" in self.active_filters: - medicine_filters = self.active_filters["medicines"] - summary["filters"]["medicines"] = { - "taken": [k for k, v in medicine_filters.items() if v], - "not_taken": [k for k, v in medicine_filters.items() if not v], - } - - # Pathology filters summary - if "pathologies" in self.active_filters: - pathology_filters = self.active_filters["pathologies"] - summary["filters"]["pathologies"] = {} - for key, range_filter in pathology_filters.items(): - min_val = range_filter.get("min", "Any") - max_val = range_filter.get("max", "Any") - summary["filters"]["pathologies"][key] = f"{min_val} - {max_val}" - - return summary - - -class QuickFilters: - """Predefined quick filters mirroring test expectations.""" - - @staticmethod - def last_week(data_filter: DataFilter) -> None: - from datetime import datetime, timedelta - - end_date = datetime.now().date() - start_date = end_date - timedelta(days=6) # inclusive 7 days - data_filter.set_date_range_filter(str(start_date), str(end_date)) - - @staticmethod - def last_month(data_filter: DataFilter) -> None: - from datetime import datetime, timedelta - - end_date = datetime.now().date() - start_date = end_date - timedelta(days=29) # inclusive 30 days - data_filter.set_date_range_filter(str(start_date), str(end_date)) - - @staticmethod - def this_month(data_filter: DataFilter) -> None: - from datetime import datetime - - now = datetime.now().date() - start_date = now.replace(day=1) - data_filter.set_date_range_filter(str(start_date), str(now)) - - @staticmethod - def high_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None: - for pathology_key in pathology_keys: - data_filter.set_pathology_range_filter(pathology_key, min_score=8) - - @staticmethod - def low_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None: - for pathology_key in pathology_keys: - data_filter.set_pathology_range_filter(pathology_key, max_score=3) - - @staticmethod - def no_medication(data_filter: DataFilter, medicine_keys: list[str]) -> None: - for medicine_key in medicine_keys: - data_filter.set_medicine_filter(medicine_key, taken=False) - - -class SearchHistory: - """Manages search history (tests assume <=15 retained).""" - - def __init__(self, max_history: int = 15): - self.max_history = max_history - self.history: list[str] = [] - - def add_search(self, search_term: str) -> None: - """ - Add a search term to history. - - Args: - search_term: Search term to add - """ - search_term = search_term.strip() - if not search_term: - return - - # Remove if already exists - if search_term in self.history: - self.history.remove(search_term) - - # Add to beginning - self.history.insert(0, search_term) - - # Trim to max size - if len(self.history) > self.max_history: - self.history = self.history[: self.max_history] - - def get_history(self) -> list[str]: - """Get search history.""" - return self.history.copy() - - def clear_history(self) -> None: - """Clear all search history.""" - self.history.clear() - - def get_suggestions(self, partial_term: str) -> list[str]: - """ - Get search suggestions based on partial input. - - Args: - partial_term: Partial search term - - Returns: - List of matching suggestions from history - """ - if not partial_term: - return self.history[:5] # Return recent searches - - partial_lower = partial_term.lower() - suggestions = [] - - for term in self.history: - if term.lower().startswith(partial_lower): - suggestions.append(term) - - return suggestions[:5] # Return top 5 matches +__all__ = ["DataFilter", "QuickFilters", "SearchHistory"] diff --git a/src/settings_window.py b/src/settings_window.py index 137ab80..e44506e 100644 --- a/src/settings_window.py +++ b/src/settings_window.py @@ -1,578 +1,12 @@ -"""Settings window for TheChart application.""" +"""Shim for backward compatibility. -import contextlib -import os -import sys -import tkinter as tk -from tkinter import messagebox, ttk +Re-exports canonical implementation from thechart.ui.settings_window. +""" -from constants import BACKUP_PATH -from preferences import ( - get_config_dir, - get_pref, - reset_preferences, - save_preferences, - set_pref, -) +from __future__ import annotations - -class SettingsWindow: - """Settings window for application preferences.""" - - def __init__(self, parent: tk.Tk, theme_manager, ui_manager) -> None: - self.parent = parent - self.theme_manager = theme_manager - self.ui_manager = ui_manager - - # Create window - self.window = tk.Toplevel(parent) - self.window.title("Settings - TheChart") - # Larger default size; allow user to resize - self.window.geometry("760x560") - self.window.minsize(640, 480) - self.window.resizable(True, True) - - # Make window modal - self.window.transient(parent) - self.window.grab_set() - - # Center the window - self._center_window() - - # Setup UI - self._setup_ui() - - # Set initial values - self._load_current_settings() - - def _center_window(self) -> None: - """Center the settings window on the parent.""" - self.window.update_idletasks() - - # Get window dimensions - window_width = self.window.winfo_reqwidth() - window_height = self.window.winfo_reqheight() - - # Get parent window position and size - parent_x = self.parent.winfo_x() - parent_y = self.parent.winfo_y() - parent_width = self.parent.winfo_width() - parent_height = self.parent.winfo_height() - - # Calculate centered position - x = parent_x + (parent_width // 2) - (window_width // 2) - y = parent_y + (parent_height // 2) - (window_height // 2) - - self.window.geometry(f"{window_width}x{window_height}+{x}+{y}") - - def _setup_ui(self) -> None: - """Setup the settings UI.""" - # Main container - main_frame = ttk.Frame(self.window, padding="20", style="Card.TFrame") - main_frame.pack(fill="both", expand=True) - - # Title - title_label = ttk.Label( - main_frame, - text="Application Settings", - font=("TkDefaultFont", 16, "bold"), - ) - title_label.pack(pady=(0, 20)) - - # Create notebook for different setting categories - notebook = ttk.Notebook(main_frame, style="Modern.TNotebook") - notebook.pack(fill="both", expand=True, pady=(0, 20)) - - # Theme settings tab - self._create_theme_tab(notebook) - - # UI settings tab - self._create_ui_tab(notebook) - - # About tab - self._create_about_tab(notebook) - - # Button frame - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill="x", pady=(10, 0)) - - # Buttons - ttk.Button( - button_frame, - text="Apply", - command=self._apply_settings, - style="Action.TButton", - ).pack(side="right", padx=(5, 0)) - - ttk.Button( - button_frame, - text="Cancel", - command=self._cancel, - style="Action.TButton", - ).pack(side="right") - - def _reset_all() -> None: - if messagebox.askyesno( - "Reset All Settings", - ( - "This will restore all settings to defaults and clear saved" - " window geometry. Continue?" - ), - parent=self.window, - ): - try: - reset_preferences() - # Reflect defaults in UI state - self.remember_size_var.set( - bool(get_pref("remember_window_geometry", True)) - ) - self.always_on_top_var.set(bool(get_pref("always_on_top", False))) - self.prompt_open_folder_after_restore_var.set( - bool(get_pref("prompt_open_folder_after_restore", False)) - ) - # Apply always-on-top immediately using default - with contextlib.suppress(Exception): - self.parent.wm_attributes( - "-topmost", bool(self.always_on_top_var.get()) - ) - if hasattr(self.ui_manager, "update_status"): - self.ui_manager.update_status( - "Settings reset to defaults", "info" - ) - except Exception: - messagebox.showerror( - "Error", - "Failed to reset settings.", - parent=self.window, - ) - - ttk.Button( - button_frame, - text="Reset All Settings…", - command=_reset_all, - style="Action.TButton", - ).pack(side="left") - - ttk.Button( - button_frame, - text="OK", - command=self._ok, - style="Action.TButton", - ).pack(side="right", padx=(0, 5)) - - def _create_theme_tab(self, notebook: ttk.Notebook) -> None: - """Create the theme settings tab.""" - theme_frame = ttk.Frame(notebook, style="Card.TFrame") - notebook.add(theme_frame, text="Theme") - - # Theme selection - theme_label_frame = ttk.LabelFrame( - theme_frame, text="Theme Selection", style="Card.TLabelframe" - ) - theme_label_frame.pack(fill="x", padx=10, pady=10) - - ttk.Label( - theme_label_frame, - text="Choose your preferred theme:", - font=("TkDefaultFont", 10), - ).pack(anchor="w", padx=10, pady=(10, 5)) - - # Theme radio buttons - self.theme_var = tk.StringVar() - themes = self.theme_manager.get_available_themes() - - theme_buttons_frame = ttk.Frame(theme_label_frame) - theme_buttons_frame.pack(fill="x", padx=10, pady=(0, 10)) - - # Create radio buttons in a grid - for i, theme in enumerate(themes): - row = i // 3 - col = i % 3 - - ttk.Radiobutton( - theme_buttons_frame, - text=theme.title(), - variable=self.theme_var, - value=theme, - style="Modern.TCheckbutton", - ).grid(row=row, column=col, sticky="w", padx=5, pady=2) - - # Theme preview info - preview_frame = ttk.LabelFrame( - theme_frame, text="Theme Preview", style="Card.TLabelframe" - ) - preview_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) - - preview_text = tk.Text( - preview_frame, - height=6, - wrap="word", - font=("TkDefaultFont", 9), - state="disabled", - ) - preview_text.pack(fill="both", expand=True, padx=10, pady=10) - - # Theme change callback - def on_theme_change(): - selected_theme = self.theme_var.get() - preview_text.config(state="normal") - preview_text.delete("1.0", "end") - preview_text.insert( - "1.0", - f"Selected theme: {selected_theme.title()}\\n\\n" - "Theme changes will be applied when you click 'Apply' or 'OK'. " - "The new theme will affect all windows and UI elements " - "in the application.", - ) - preview_text.config(state="disabled") - - self.theme_var.trace("w", lambda *args: on_theme_change()) - - def _create_ui_tab(self, notebook: ttk.Notebook) -> None: - """Create the UI settings tab.""" - ui_frame = ttk.Frame(notebook, style="Card.TFrame") - notebook.add(ui_frame, text="Interface") - - # Font settings - font_frame = ttk.LabelFrame( - ui_frame, text="Font Settings", style="Card.TLabelframe" - ) - font_frame.pack(fill="x", padx=10, pady=10) - - ttk.Label( - font_frame, - text="Font size adjustments (requires restart):", - font=("TkDefaultFont", 10), - ).pack(anchor="w", padx=10, pady=10) - - # Font size scale - self.font_scale_var = tk.DoubleVar(value=1.0) - font_scale = ttk.Scale( - font_frame, - from_=0.8, - to=1.5, - variable=self.font_scale_var, - orient="horizontal", - style="Modern.Horizontal.TScale", - ) - font_scale.pack(fill="x", padx=10, pady=(0, 10)) - - # Scale labels - scale_labels_frame = ttk.Frame(font_frame) - scale_labels_frame.pack(fill="x", padx=10, pady=(0, 10)) - - ttk.Label(scale_labels_frame, text="Small").pack(side="left") - ttk.Label(scale_labels_frame, text="Large").pack(side="right") - ttk.Label(scale_labels_frame, text="Normal").pack() - - # Window settings - window_frame = ttk.LabelFrame( - ui_frame, text="Window Settings", style="Card.TLabelframe" - ) - window_frame.pack(fill="x", padx=10, pady=(0, 10)) - - # Remember window size - from preferences import get_pref as _getp - - self.remember_size_var = tk.BooleanVar( - value=bool(_getp("remember_window_geometry", True)) - ) - ttk.Checkbutton( - window_frame, - text="Remember window size and position", - variable=self.remember_size_var, - style="Modern.TCheckbutton", - ).pack(anchor="w", padx=10, pady=10) - - # Always on top - self.always_on_top_var = tk.BooleanVar( - value=bool(_getp("always_on_top", False)) - ) - ttk.Checkbutton( - window_frame, - text="Keep window always on top", - variable=self.always_on_top_var, - style="Modern.TCheckbutton", - ).pack(anchor="w", padx=10, pady=(0, 10)) - - # Reset window position button - def _reset_window_position() -> None: - with contextlib.suppress(Exception): - # Clear saved geometry preference and persist - set_pref("last_window_geometry", "") - save_preferences() - - # Center the main window on the screen - try: - self.parent.update_idletasks() - width = self.parent.winfo_width() or self.parent.winfo_reqwidth() - height = self.parent.winfo_height() or self.parent.winfo_reqheight() - sw = self.parent.winfo_screenwidth() - sh = self.parent.winfo_screenheight() - x = (sw // 2) - (width // 2) - y = (sh // 2) - (height // 2) - self.parent.geometry(f"{width}x{height}+{x}+{y}") - if hasattr(self.ui_manager, "update_status"): - self.ui_manager.update_status("Window position reset", "info") - except Exception: - pass - - reset_btn = ttk.Button( - window_frame, - text="Reset Window Position", - command=_reset_window_position, - style="Action.TButton", - ) - reset_btn.pack(anchor="w", padx=10, pady=(0, 10)) - - # Tooltip for reset action - try: - if ( - hasattr(self.ui_manager, "tooltip_manager") - and self.ui_manager.tooltip_manager - ): - self.ui_manager.tooltip_manager.add_tooltip( - reset_btn, - "Clear saved window size/position and center the app", - delay=500, - ) - except Exception: - pass - - # Restore settings - restore_frame = ttk.LabelFrame( - ui_frame, text="Backup & Restore", style="Card.TLabelframe" - ) - restore_frame.pack(fill="x", padx=10, pady=(0, 10)) - - self.prompt_open_folder_after_restore_var = tk.BooleanVar( - value=bool(get_pref("prompt_open_folder_after_restore", False)) - ) - ttk.Checkbutton( - restore_frame, - text="Offer to open backups folder after successful restore", - variable=self.prompt_open_folder_after_restore_var, - style="Modern.TCheckbutton", - ).pack(anchor="w", padx=10, pady=10) - - # Backups folder path and open button - bkp_frame = ttk.Frame(restore_frame) - bkp_frame.pack(fill="x", padx=10, pady=(0, 10)) - - ttk.Label(bkp_frame, text="Backups folder:").pack(side="left", padx=(0, 8)) - # Resolve backup path from constants (env-aware) - self._bkp_path_var = tk.StringVar(value=BACKUP_PATH) - bkp_entry = ttk.Entry( - bkp_frame, - textvariable=self._bkp_path_var, - width=44, - state="readonly", - ) - bkp_entry.pack(side="left", fill="x", expand=True) - - def _open_bkp() -> None: - path = self._bkp_path_var.get() - with contextlib.suppress(Exception): - if not os.path.exists(path): - os.makedirs(path, exist_ok=True) - if sys.platform.startswith("darwin"): - os.system(f'open "{path}"') - elif os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] - else: - os.system(f'xdg-open "{path}" >/dev/null 2>&1 &') - - bkp_open_btn = ttk.Button( - bkp_frame, - text="Open", - command=_open_bkp, - style="Action.TButton", - width=8, - ) - bkp_open_btn.pack(side="left", padx=(8, 0)) - - # Brief description for backups folder - ttk.Label( - restore_frame, - text=( - "Automatic CSV backups are saved in this folder. " - "It will be created if it doesn't exist." - ), - justify="left", - wraplength=680, - ).pack(anchor="w", padx=10, pady=(2, 10)) - - # Tooltip for Open (backups) - try: - if ( - hasattr(self.ui_manager, "tooltip_manager") - and self.ui_manager.tooltip_manager - ): - self.ui_manager.tooltip_manager.add_tooltip( - bkp_open_btn, - "Open the backups folder in your file manager", - delay=500, - ) - except Exception: - pass - - # Config folder path and open button - cfg_frame = ttk.Frame(restore_frame) - cfg_frame.pack(fill="x", padx=10, pady=(0, 10)) - - ttk.Label(cfg_frame, text="Config folder:").pack(side="left", padx=(0, 8)) - self._cfg_path_var = tk.StringVar(value=get_config_dir()) - cfg_entry = ttk.Entry( - cfg_frame, - textvariable=self._cfg_path_var, - width=44, - state="readonly", - ) - cfg_entry.pack(side="left", fill="x", expand=True) - - def _open_cfg() -> None: - path = self._cfg_path_var.get() - with contextlib.suppress(Exception): - if not os.path.exists(path): - os.makedirs(path, exist_ok=True) - if sys.platform.startswith("darwin"): - os.system(f'open "{path}"') - elif os.name == "nt": - os.startfile(path) # type: ignore[attr-defined] - else: - os.system(f'xdg-open "{path}" >/dev/null 2>&1 &') - - cfg_open_btn = ttk.Button( - cfg_frame, - text="Open", - command=_open_cfg, - style="Action.TButton", - width=8, - ) - cfg_open_btn.pack(side="left", padx=(8, 0)) - - # Tooltip for Open (config) - try: - if ( - hasattr(self.ui_manager, "tooltip_manager") - and self.ui_manager.tooltip_manager - ): - self.ui_manager.tooltip_manager.add_tooltip( - cfg_open_btn, - "Open the configuration folder (preferences.json)", - delay=500, - ) - except Exception: - pass - - def _create_about_tab(self, notebook: ttk.Notebook) -> None: - """Create the about tab.""" - about_frame = ttk.Frame(notebook, style="Card.TFrame") - notebook.add(about_frame, text="About") - - # App info - info_frame = ttk.LabelFrame( - about_frame, text="Application Information", style="Card.TLabelframe" - ) - info_frame.pack(fill="both", expand=True, padx=10, pady=10) - - about_text = tk.Text( - info_frame, - wrap="word", - font=("TkDefaultFont", 10), - state="disabled", - bg=self.theme_manager.get_theme_colors()["bg"], - fg=self.theme_manager.get_theme_colors()["fg"], - ) - about_text.pack(fill="both", expand=True, padx=10, pady=10) - - about_content = """TheChart - Medication Tracker - -Version: 1.9.5 -Built with: Python, Tkinter, ttkthemes - -Features: -• Modern themed interface with multiple themes -• Medication and pathology tracking -• Visual graphs and charts -• Data export capabilities -• Keyboard shortcuts for efficiency -• Customizable UI settings - -This application helps you track your daily medications and health -conditions with an intuitive, modern interface. - -Enhanced with ttkthemes for better visual appeal and user experience.""" - - about_text.config(state="normal") - about_text.insert("1.0", about_content) - about_text.config(state="disabled") - - def _load_current_settings(self) -> None: - """Load current application settings.""" - # Set current theme - current_theme = self.theme_manager.get_current_theme() - self.theme_var.set(current_theme) - - # Trigger theme change to update preview - if hasattr(self, "theme_var"): - self.theme_var.set(current_theme) - # Ensure UI checkboxes reflect preferences - if hasattr(self, "prompt_open_folder_after_restore_var"): - self.prompt_open_folder_after_restore_var.set( - bool(get_pref("prompt_open_folder_after_restore", False)) - ) - - def _apply_settings(self) -> None: - """Apply the selected settings.""" - # Apply theme if changed - selected_theme = self.theme_var.get() - current_theme = self.theme_manager.get_current_theme() - - if selected_theme != current_theme: - if self.theme_manager.apply_theme(selected_theme): - self.ui_manager.update_status( - f"Theme changed to: {selected_theme.title()}", "info" - ) - else: - messagebox.showerror( - "Error", - f"Failed to apply theme: {selected_theme}", - parent=self.window, - ) - return - - # Apply other settings (font size, window settings, etc.) - # These would typically be saved to a config file - - # Save preferences - set_pref( - "prompt_open_folder_after_restore", - bool(self.prompt_open_folder_after_restore_var.get()), - ) - set_pref("remember_window_geometry", bool(self.remember_size_var.get())) - set_pref("always_on_top", bool(self.always_on_top_var.get())) - - # Apply always-on-top immediately - import contextlib as _ctx - - with _ctx.suppress(Exception): - self.parent.wm_attributes("-topmost", bool(self.always_on_top_var.get())) - - messagebox.showinfo( - "Settings Applied", - "Settings have been applied successfully!", - parent=self.window, - ) - # Persist settings at the end - with contextlib.suppress(Exception): - save_preferences() - - def _ok(self) -> None: - """Apply settings and close window.""" - self._apply_settings() - self.window.destroy() - - def _cancel(self) -> None: - """Close window without applying settings.""" - self.window.destroy() +try: # noqa: SIM105 + from thechart.ui.settings_window import * # type: ignore # noqa: F401,F403 +except ModuleNotFoundError: # pragma: no cover + # Fallback for dev environments not using package layout + from src.thechart.ui.settings_window import * # type: ignore # noqa: F401,F403 diff --git a/src/thechart/__init__.py b/src/thechart/__init__.py index 8cc1563..4b73e1e 100644 --- a/src/thechart/__init__.py +++ b/src/thechart/__init__.py @@ -4,8 +4,6 @@ This package provides the main application and components for the TheChart (medication tracker) desktop app. Notes ------ -- This package layer is introduced to follow Python packaging best practices while keeping backward compatibility with existing imports used in tests (e.g., ``src.*``). The original modules under ``src/`` remain available; this package enables ``python -m thechart`` @@ -21,6 +19,47 @@ try: # Prefer installed package version if available except Exception: # Fallback in editable/dev mode __version__ = "0.0.0.dev" +# Friendly, stable public API re-exports +from .core import ( # noqa: F401 + BACKUP_PATH, + LOG_CLEAR, + LOG_LEVEL, + LOG_PATH, + get_config_dir, + get_pref, + load_preferences, + reset_preferences, + save_preferences, + set_pref, +) +from .export import ExportManager # noqa: F401 +from .managers import ( # noqa: F401 + Medicine, + MedicineManager, + Pathology, + PathologyManager, +) +from .validation import InputValidator # noqa: F401 + __all__ = [ "__version__", + # validation + "InputValidator", + # core + "LOG_CLEAR", + "LOG_LEVEL", + "LOG_PATH", + "BACKUP_PATH", + "get_config_dir", + "load_preferences", + "save_preferences", + "reset_preferences", + "get_pref", + "set_pref", + # managers + "Medicine", + "MedicineManager", + "Pathology", + "PathologyManager", + "ExportManager", # Expose ExportManager for convenience ] diff --git a/src/thechart/analytics/__init__.py b/src/thechart/analytics/__init__.py new file mode 100644 index 0000000..439bf40 --- /dev/null +++ b/src/thechart/analytics/__init__.py @@ -0,0 +1,7 @@ +"""Analytics layer re-exports for TheChart.""" + +from __future__ import annotations + +from .graph_manager import GraphManager # noqa: F401 + +__all__ = ["GraphManager"] diff --git a/src/thechart/analytics/graph_manager.py b/src/thechart/analytics/graph_manager.py new file mode 100644 index 0000000..66544eb --- /dev/null +++ b/src/thechart/analytics/graph_manager.py @@ -0,0 +1,461 @@ +import sys +import tkinter as tk +from contextlib import suppress +from tkinter import ttk +from types import SimpleNamespace + +# Type-only imports to avoid hard runtime deps during package import +from typing import TYPE_CHECKING # noqa: F401 # retained for future type hints + +import matplotlib.pyplot as plt +import pandas as pd +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + +# Provide a module alias consistent with legacy module name in tests +sys.modules.setdefault("graph_manager", sys.modules[__name__]) + + +def _build_default_medicine_manager(): + """Create a lightweight default medicine manager used by legacy tests.""" + default_medicines = { + "bupropion": SimpleNamespace( + key="bupropion", + display_name="Bupropion", + color="#FF6B6B", + default_enabled=True, + ), + "hydroxyzine": SimpleNamespace( + key="hydroxyzine", + display_name="Hydroxyzine", + color="#4ECDC4", + default_enabled=False, + ), + "gabapentin": SimpleNamespace( + key="gabapentin", + display_name="Gabapentin", + color="#45B7D1", + default_enabled=False, + ), + "propranolol": SimpleNamespace( + key="propranolol", + display_name="Propranolol", + color="#96CEB4", + default_enabled=True, + ), + "quetiapine": SimpleNamespace( + key="quetiapine", + display_name="Quetiapine", + color="#FFEAA7", + default_enabled=False, + ), + } + + class _DefaultMedicineManager: + def get_medicine_keys(self): + return list(default_medicines.keys()) + + def get_medicine(self, key): + return default_medicines.get(key) + + def get_graph_colors(self): + return {k: v.color for k, v in default_medicines.items()} + + return _DefaultMedicineManager() + + +def _build_default_pathology_manager(): + """Create a lightweight default pathology manager for legacy tests.""" + default_pathologies = { + "depression": SimpleNamespace( + key="depression", + display_name="Depression", + scale_info="0-10", + scale_orientation="normal", + ), + "anxiety": SimpleNamespace( + key="anxiety", + display_name="Anxiety", + scale_info="0-10", + scale_orientation="normal", + ), + "sleep": SimpleNamespace( + key="sleep", + display_name="Sleep", + scale_info="0-10", + scale_orientation="normal", + ), + "appetite": SimpleNamespace( + key="appetite", + display_name="Appetite", + scale_info="0-10", + scale_orientation="normal", + ), + } + + class _DefaultPathologyManager: + def get_pathology_keys(self): + return list(default_pathologies.keys()) + + def get_pathology(self, key): + return default_pathologies.get(key) + + return _DefaultPathologyManager() + + +class GraphManager: + """Optimized version - Handle all graph-related operations for the app.""" + + def __init__( + self, + parent_frame: ttk.LabelFrame, + medicine_manager=None, + pathology_manager=None, + logger=None, + ) -> None: + """Create a GraphManager. + + Args: + parent_frame: Parent tkinter frame. + medicine_manager: Optional MedicineManager; if omitted a + lightweight default is created for test compatibility. + pathology_manager: Optional PathologyManager; if omitted a + lightweight default is created for test compatibility. + logger: Optional logger for debug messages. + """ + self.parent_frame: ttk.LabelFrame = parent_frame + self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame) + self.graph_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + self.medicine_manager = ( + medicine_manager + if medicine_manager is not None + else _build_default_medicine_manager() + ) + self.pathology_manager = ( + pathology_manager + if pathology_manager is not None + else _build_default_pathology_manager() + ) + self.logger = logger + + self.fig, self.ax = plt.subplots(figsize=(10, 6), dpi=80) + + self.current_data: pd.DataFrame = pd.DataFrame() + self._last_plot_hash: str = "" + + self.toggle_vars: dict[str, tk.BooleanVar] = {} + self._setup_ui() + self._initialize_toggle_vars() + self._create_chart_toggles() + + def _initialize_toggle_vars(self) -> None: + for pathology_key in self.pathology_manager.get_pathology_keys(): + self.toggle_vars[pathology_key] = tk.BooleanVar(value=True) + for medicine_key in self.medicine_manager.get_medicine_keys(): + med = self.medicine_manager.get_medicine(medicine_key) + default_enabled = getattr(med, "default_enabled", False) + self.toggle_vars[medicine_key] = tk.BooleanVar(value=bool(default_enabled)) + + def _setup_ui(self) -> None: + try: + self.canvas = FigureCanvasTkAgg(figure=self.fig, master=self.graph_frame) + self.canvas.draw_idle() + except (tk.TclError, RuntimeError): + + class _DummyCanvas: + def __init__(self, master: ttk.Frame) -> None: + self._widget = ttk.Frame(master) + + def draw(self) -> None: + pass + + def draw_idle(self) -> None: + pass + + def get_tk_widget(self): + return self._widget + + self.canvas = _DummyCanvas(self.graph_frame) + + canvas_widget = self.canvas.get_tk_widget() + canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + self.control_frame = ttk.Frame(self.parent_frame) + self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2) + + def _create_chart_toggles(self) -> None: + pathology_frame = ttk.LabelFrame( + self.control_frame, text="Pathologies", padding="5" + ) + pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2) + + row, col = 0, 0 + for pathology_key in self.pathology_manager.get_pathology_keys(): + pathology = self.pathology_manager.get_pathology(pathology_key) + if pathology: + display_name = pathology.display_name + text = ( + display_name[:10] + "..." + if len(display_name) > 10 + else display_name + ) + cb = ttk.Checkbutton( + pathology_frame, + text=text, + variable=self.toggle_vars[pathology_key], + command=self._handle_toggle_changed, + ) + cb.grid(row=row, column=col, sticky="w", padx=2) + col += 1 + if col > 1: + col = 0 + row += 1 + + medicine_frame = ttk.LabelFrame( + self.control_frame, text="Medicines", padding="5" + ) + medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2) + + row, col = 0, 0 + for medicine_key in self.medicine_manager.get_medicine_keys(): + medicine = self.medicine_manager.get_medicine(medicine_key) + if medicine: + med_name = medicine.display_name + text = med_name[:10] + "..." if len(med_name) > 10 else med_name + cb = ttk.Checkbutton( + medicine_frame, + text=text, + variable=self.toggle_vars[medicine_key], + command=self._handle_toggle_changed, + ) + cb.grid(row=row, column=col, sticky="w", padx=2) + col += 1 + if col > 2: + col = 0 + row += 1 + + def _handle_toggle_changed(self) -> None: + if not self.current_data.empty: + self._plot_graph_data(self.current_data) + + def update_graph(self, df: pd.DataFrame) -> None: + if getattr(df, "empty", True): + data_hash = "empty" + else: + try: + last_date = ( + df["date"].iloc[-1] + if hasattr(df, "columns") and "date" in df.columns and len(df) > 0 + else len(df) + ) + except Exception: + last_date = len(df) + try: + import zlib + + raw = ( + df.select_dtypes(exclude=["object"]).to_numpy(copy=False) + if hasattr(df, "select_dtypes") + else [] + ) + size = getattr(raw, "size", 0) + checksum = zlib.adler32(raw.tobytes()) if size else 0 + except Exception: + checksum = len(df) + data_hash = f"{len(df)}:{last_date}:{checksum}" + + if data_hash != self._last_plot_hash or getattr( + self.current_data, "empty", True + ): + self.current_data = ( + df.copy() if hasattr(df, "copy") and not df.empty else pd.DataFrame() + ) + self._last_plot_hash = data_hash + + try: + self._plot_graph_data(df) + except Exception: + if self.logger: + with suppress(Exception): + self.logger.exception("Error while plotting graph data") + + def _plot_graph_data(self, df: pd.DataFrame) -> None: + with plt.ioff(): + self.ax.clear() + if hasattr(df, "empty") and not df.empty: + df_processed = self._preprocess_data(df) + has_plotted_series = self._plot_pathology_data(df_processed) + medicine_data = self._plot_medicine_data(df_processed) + if has_plotted_series or medicine_data["has_plotted"]: + self._configure_graph_appearance(medicine_data) + try: + self.canvas.draw() + except Exception: + with plt.ioff(): + self.canvas.draw_idle() + + def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame: + if hasattr(df, "index") and isinstance(df.index, pd.DatetimeIndex): + return df + local = df.copy() if hasattr(df, "copy") else df + if hasattr(local, "columns") and "date" in local.columns: + local["date"] = pd.to_datetime(local["date"], errors="coerce") + local = local.dropna(subset=["date"]).sort_values("date") + local.set_index("date", inplace=True) + return local + + def _plot_pathology_data(self, df: pd.DataFrame) -> bool: + has_plotted_series = False + pathology_keys = self.pathology_manager.get_pathology_keys() + active_pathologies = [ + key + for key in pathology_keys + if ( + self.toggle_vars[key].get() + and hasattr(df, "columns") + and key in df.columns + ) + ] + for pathology_key in active_pathologies: + pathology = self.pathology_manager.get_pathology(pathology_key) + if pathology: + label = f"{pathology.display_name} ({pathology.scale_info})" + linestyle = ( + "dashed" if pathology.scale_orientation == "inverted" else "-" + ) + self._plot_series(df, pathology_key, label, "o", linestyle) + has_plotted_series = True + return has_plotted_series + + def _plot_medicine_data(self, df: pd.DataFrame) -> dict: + result = {"has_plotted": False, "with_data": [], "without_data": []} + medicine_colors = self.medicine_manager.get_graph_colors() + medicines = self.medicine_manager.get_medicine_keys() + medicine_doses: dict[str, list[float]] = {} + for medicine in medicines: + dose_column = f"{medicine}_doses" + if hasattr(df, "columns") and dose_column in df.columns: + daily_doses = [ + self._calculate_daily_dose(dose_str) for dose_str in df[dose_column] + ] + medicine_doses[medicine] = daily_doses + for medicine in medicines: + if self.toggle_vars[medicine].get() and medicine in medicine_doses: + daily_doses = medicine_doses[medicine] + if any(dose > 0 for dose in daily_doses): + result["with_data"].append(medicine) + scaled_doses = [dose / 10 for dose in daily_doses] + non_zero_doses = [d for d in daily_doses if d > 0] + if non_zero_doses: + avg_dose = sum(non_zero_doses) / len(non_zero_doses) + label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)" + self.ax.bar( + df.index, + scaled_doses, + alpha=0.6, + color=medicine_colors.get(medicine, "#DDA0DD"), + label=label, + width=0.6, + bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1, + ) + result["has_plotted"] = True + else: + if self.toggle_vars[medicine].get(): + result["without_data"].append(medicine) + return result + + def _configure_graph_appearance(self, medicine_data: dict) -> None: + _hl = self.ax.get_legend_handles_labels() + try: + handles, labels = _hl + except Exception: + handles, labels = [], [] + handles = list(handles) if handles else [] + labels = list(labels) if labels else [] + if medicine_data["without_data"]: + med_list = ", ".join(medicine_data["without_data"]) + info_text = f"Tracked (no doses): {med_list}" + from matplotlib.patches import Rectangle + + dummy_handle = Rectangle( + (0, 0), 0, 0, fc="none", fill=False, edgecolor="none", linewidth=0 + ) + handles.append(dummy_handle) + labels.append(info_text) + if handles and labels: + self.ax.legend( + handles, + labels, + loc="upper left", + bbox_to_anchor=(0, 1), + ncol=2, + fontsize="small", + frameon=True, + fancybox=True, + shadow=True, + framealpha=0.9, + ) + self.ax.set_title("Medication Effects Over Time") + self.ax.set_xlabel("Date") + self.ax.set_ylabel("Rating (0-10) / Dose (mg)") + try: + current_ylim = self.ax.get_ylim() + low = current_ylim[0] if hasattr(current_ylim, "__getitem__") else 0 + high = current_ylim[1] if hasattr(current_ylim, "__getitem__") else 10 + except Exception: + low, high = 0, 10 + with suppress(Exception): + self.ax.set_ylim(bottom=low, top=max(10, high)) + + def _plot_series( + self, + df: pd.DataFrame, + key: str, + label: str, + marker: str, + linestyle: str, + ) -> None: + import contextlib as _ctx + + with _ctx.suppress(Exception): + self.ax.plot( + df.index, + df[key], + marker=marker, + linestyle=linestyle, + label=label, + ) + + @staticmethod + def _calculate_daily_dose(dose_str: str | float | int) -> float: + # Numeric inputs + if isinstance(dose_str, (int, float)): # noqa: UP038 - runtime isinstance + return float(dose_str) + if not isinstance(dose_str, str) or not dose_str: + return 0.0 + parts = [p.strip() for p in str(dose_str).split(";") if p.strip()] + total = 0.0 + for p in parts: + try: + total += float(p.split()[0]) + except Exception: + continue + return total + + def close(self) -> None: + """Release plotting resources safely. + + Clears the axes and closes the matplotlib figure. Any errors during + cleanup are suppressed to avoid impacting the shutdown sequence. + """ + try: + with suppress(Exception): + self.ax.clear() + with suppress(Exception): + plt.close(self.fig) + except Exception: + # Final safety net; ignore cleanup errors + pass + + +__all__ = ["GraphManager"] diff --git a/src/thechart/core/__init__.py b/src/thechart/core/__init__.py new file mode 100644 index 0000000..df5d882 --- /dev/null +++ b/src/thechart/core/__init__.py @@ -0,0 +1,18 @@ +"""Core re-exports for TheChart. + +Canonical implementations live under this package. +""" + +from __future__ import annotations + +from .auto_save import AutoSaveManager, BackupManager # noqa: F401 +from .constants import * # noqa: F401,F403 +from .error_handler import ( # noqa: F401 + ErrorHandler, + OperationTimer, + UserFeedback, + handle_exceptions, +) +from .logger import init_logger # noqa: F401 +from .preferences import * # noqa: F401,F403 +from .undo_manager import UndoAction, UndoManager # noqa: F401 diff --git a/src/thechart/core/auto_save.py b/src/thechart/core/auto_save.py new file mode 100644 index 0000000..d6751e4 --- /dev/null +++ b/src/thechart/core/auto_save.py @@ -0,0 +1,363 @@ +"""Auto-save and backup utilities for TheChart (canonical module). + +This module provides both the new application API and the legacy test API +via a single implementation. Use `from thechart.core.auto_save import *` or +import specific classes. +""" + +from __future__ import annotations + +import contextlib +import glob +import os +import re +import shutil +import threading +from collections.abc import Callable +from datetime import datetime + +from .constants import BACKUP_PATH + + +class AutoSaveManager: + """Unified auto-save & backup manager supporting legacy and new APIs.""" + + # ------------------------------------------------------------------ + # Construction / mode detection + # ------------------------------------------------------------------ + def __init__(self, *args, **kwargs) -> None: # type: ignore[override] + # Determine mode: legacy if a filesystem path is provided + self._legacy_mode = "data_file_path" in kwargs or ( + args and isinstance(args[0], str) + ) + self.logger = kwargs.get("logger") + + if self._legacy_mode: + # Legacy parameters (tests expect these attributes) + self.data_file_path: str = kwargs.get( + "data_file_path", args[0] if args else "" + ) + self.backup_dir: str = kwargs.get("backup_dir", BACKUP_PATH) + self.status_callback: Callable[[str], None] | None = kwargs.get( + "status_callback" + ) + self.error_callback: Callable[[str], None] | None = kwargs.get( + "error_callback" + ) + self.interval_minutes: float = float(kwargs.get("interval_minutes", 5)) + self.max_backups: int = int(kwargs.get("max_backups", 10)) + self.interval_seconds: float = self.interval_minutes * 60 + self.save_callback: Callable[[], None] | None = None # Not used in tests + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + self.is_running: bool = False + self._last_save_time: datetime | None = None + self._data_modified = False # Unused in legacy tests but kept + self._ensure_backup_directory() + else: + # New application mode + save_cb: Callable[[], None] | None = kwargs.get("save_callback") + if save_cb is None and args: + save_cb = args[0] + interval = float(kwargs.get("interval_minutes", 5)) + self.save_callback = save_cb + self.interval_minutes = interval + self.interval_seconds = interval * 60 + self._auto_save_enabled = False + self._save_thread: threading.Thread | None = None + self._stop_event = threading.Event() + self._last_save_time: datetime | None = None + self._data_modified = False + # Shim attributes for compatibility (unused in new mode) + self.data_file_path = "" + self.backup_dir = BACKUP_PATH + self.status_callback = None + self.error_callback = None + self.max_backups = 10 + self.is_running = False + + def enable_auto_save(self) -> None: + """Enable automatic saving.""" + if self._legacy_mode: + # Map to legacy start() + self.start() + return + if getattr(self, "_auto_save_enabled", False): + return + self._auto_save_enabled = True + self._stop_event.clear() + self._save_thread = threading.Thread(target=self._auto_save_loop, daemon=True) + self._save_thread.start() + if self.logger: + self.logger.info( + f"Auto-save enabled with {self.interval_minutes:.1f} minute intervals" + ) + + def disable_auto_save(self) -> None: + """Disable automatic saving.""" + if self._legacy_mode: + self.stop() + return + if not getattr(self, "_auto_save_enabled", False): + return + self._auto_save_enabled = False + self._stop_event.set() + if self._save_thread and self._save_thread.is_alive(): + self._save_thread.join(timeout=2.0) + if self.logger: + self.logger.info("Auto-save disabled") + + def mark_data_modified(self) -> None: + """Mark that data has been modified and needs saving.""" + self._data_modified = True + + def force_save(self) -> None: + """Force an immediate save if data has been modified.""" + if self._data_modified and self.save_callback: + try: + self.save_callback() + self._last_save_time = datetime.now() + self._data_modified = False + if self.logger: + self.logger.debug("Force save completed successfully") + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.error(f"Force save failed: {e}") + + def get_last_save_time(self) -> datetime | None: + """Get the timestamp of the last successful save.""" + return self._last_save_time + + def is_enabled(self) -> bool: + """Check if auto-save is currently enabled.""" + return ( + self.is_running + if self._legacy_mode + else getattr(self, "_auto_save_enabled", False) + ) + + def has_unsaved_changes(self) -> bool: + """Check if there are unsaved changes.""" + return self._data_modified + + def _auto_save_loop(self) -> None: + """Main auto-save loop running in background thread.""" + while not self._stop_event.wait(self.interval_seconds): + if self._data_modified and self.save_callback: + try: + self.save_callback() + self._last_save_time = datetime.now() + self._data_modified = False + if self.logger: + self.logger.debug("Auto-save completed successfully") + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.error(f"Auto-save failed: {e}") + + def set_interval(self, minutes: int) -> None: + """ + Change the auto-save interval. + + Args: + minutes: New interval in minutes (minimum 1, maximum 60) + """ + if not 1 <= minutes <= 60: + raise ValueError("Auto-save interval must be between 1 and 60 minutes") + old = self.interval_minutes + self.interval_minutes = float(minutes) + self.interval_seconds = self.interval_minutes * 60 + if self.logger: + self.logger.info( + "Auto-save interval changed from %.1f to %.1f minutes", + old, + self.interval_minutes, + ) + if not self._legacy_mode and getattr(self, "_auto_save_enabled", False): + self.disable_auto_save() + self.enable_auto_save() + + def cleanup(self) -> None: + if self._legacy_mode: + self.stop() + else: + self.disable_auto_save() + if self._data_modified: + if self.logger: + self.logger.info("Performing final save on cleanup") + self.force_save() + + # ------------------------------------------------------------------ + # Legacy mode API (periodic file backups) + # ------------------------------------------------------------------ + def start(self) -> None: + if not self._legacy_mode or self.is_running: + return + self.is_running = True + self._stop_event.clear() + with contextlib.suppress(Exception): + self.create_backup("startup") + + def _loop() -> None: + while not self._stop_event.wait(self.interval_seconds): + with contextlib.suppress(Exception): + self.create_backup("auto") + + self._thread = threading.Thread(target=_loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + if not self._legacy_mode or not self.is_running: + return + self.is_running = False + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + + # --------------------- Backup helpers (legacy) --------------------- + def _ensure_backup_directory(self) -> None: + os.makedirs(self.backup_dir, exist_ok=True) + + def create_backup(self, suffix: str) -> str | None: + if not getattr(self, "data_file_path", ""): + return None + if not os.path.exists(self.data_file_path): + if self.error_callback: + self.error_callback("Source file does not exist") + return None + safe_suffix = re.sub(r"[^A-Za-z0-9_\-]+", "_", suffix.strip()) or "backup" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base = os.path.splitext(os.path.basename(self.data_file_path))[0] + filename = f"{base}_{safe_suffix}_{timestamp}.csv" + dest = os.path.join(self.backup_dir, filename) + try: + shutil.copy2(self.data_file_path, dest) + if self.status_callback: + self.status_callback(f"Backup created: {dest}") + self._cleanup_old_backups() + return dest + except Exception as e: # pragma: no cover - defensive + if self.error_callback: + self.error_callback(f"Backup failed: {e}") + return None + + def _cleanup_old_backups(self) -> None: + pattern = os.path.join(self.backup_dir, "*.csv") + files = glob.glob(pattern) + if len(files) <= self.max_backups: + return + files.sort(key=os.path.getmtime, reverse=True) + for f in files[self.max_backups :]: + with contextlib.suppress(Exception): + os.remove(f) + + def get_backup_files(self) -> list[str]: + pattern = os.path.join(self.backup_dir, "*.csv") + files = glob.glob(pattern) + files.sort(key=os.path.getmtime, reverse=True) + return files + + def restore_from_backup(self, backup_path: str) -> bool: + if not os.path.exists(backup_path): + if self.error_callback: + self.error_callback("Backup file does not exist") + return False + try: + shutil.copy2(backup_path, self.data_file_path) + if self.status_callback: + self.status_callback(f"Restored from backup: {backup_path}") + return True + except Exception as e: # pragma: no cover + if self.error_callback: + self.error_callback(f"Restore failed: {e}") + return False + + +class BackupManager: + """Standalone backup manager used by application code.""" + + def __init__( + self, + data_file_path: str, + backup_directory: str = BACKUP_PATH, + logger=None, + status_callback: Callable[[str], None] | None = None, + ) -> None: + self.data_file_path = data_file_path + self.backup_directory = backup_directory + self.logger = logger + self.status_callback = status_callback + self._ensure_backup_directory() + + def _ensure_backup_directory(self) -> None: + os.makedirs(self.backup_directory, exist_ok=True) + + def create_backup(self, backup_type: str = "manual") -> str | None: + if not os.path.exists(self.data_file_path): + if self.logger: + self.logger.warning("Cannot create backup: data file doesn't exist") + return None + try: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base_name = os.path.splitext(os.path.basename(self.data_file_path))[0] + backup_filename = f"{base_name}_backup_{backup_type}_{timestamp}.csv" + backup_path = os.path.join(self.backup_directory, backup_filename) + shutil.copy2(self.data_file_path, backup_path) + msg = f"Backup created: {backup_path}" + if self.logger: + self.logger.info(msg) + if self.status_callback: + self.status_callback(msg) + return backup_path + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.error(f"Backup creation failed: {e}") + return None + + def cleanup_old_backups(self, keep_count: int = 10) -> None: + try: + backup_pattern = os.path.join(self.backup_directory, "*_backup_*.csv") + backup_files = glob.glob(backup_pattern) + if len(backup_files) <= keep_count: + return + backup_files.sort(key=os.path.getmtime, reverse=True) + removed = 0 + for file_path in backup_files[keep_count:]: + with contextlib.suppress(Exception): + os.remove(file_path) + removed += 1 + msg = f"Cleaned up {removed} old backup files" + if self.logger: + self.logger.info(msg) + if self.status_callback and removed: + self.status_callback(msg) + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.error(f"Backup cleanup failed: {e}") + + def restore_from_backup(self, backup_path: str) -> bool: + if not os.path.exists(backup_path): + if self.logger: + self.logger.error(f"Backup file doesn't exist: {backup_path}") + return False + try: + # Create a backup of current data before restoring + current_backup = self.create_backup("pre_restore") + shutil.copy2(backup_path, self.data_file_path) + msg = f"Successfully restored from backup: {backup_path}" + if self.logger: + self.logger.info(msg) + if current_backup: + self.logger.info(f"Previous data backed up to: {current_backup}") + if self.status_callback: + self.status_callback(msg) + return True + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.error(f"Restore from backup failed: {e}") + return False + + +__all__ = [ + "AutoSaveManager", + "BackupManager", +] diff --git a/src/thechart/core/constants.py b/src/thechart/core/constants.py new file mode 100644 index 0000000..9622e13 --- /dev/null +++ b/src/thechart/core/constants.py @@ -0,0 +1,49 @@ +import os +import sys + +import dotenv as _dotenv + +# Determine external data directory (supports PyInstaller) +extDataDir = os.getcwd() +if getattr(sys, "frozen", False): # pragma: no cover - runtime packaging path + extDataDir = sys._MEIPASS # type: ignore[attr-defined] + +_already_initialized = globals().get("_already_initialized", False) + +# Snapshot environment before potential .env load so we can honor values +# that were present prior to loading .env and ignore values introduced by it. +_pre_env = dict(os.environ) + +# Preserve patched load_dotenv if present (tests patch this symbol) +if "load_dotenv" not in globals(): # first import or not patched yet + load_dotenv = _dotenv.load_dotenv # type: ignore[assignment] + +# Always call (tests expect call with override=True) +load_dotenv(override=True) +_already_initialized = True + + +def _pre_or_default(key: str, default: str) -> str: + """Return the value from the pre-dotenv environment or the default. + + Values that only exist due to .env load are ignored so tests (and env) + take precedence, while still allowing us to call load_dotenv(override=True). + """ + if key in _pre_env: + return _pre_env[key] + # Ignore values introduced only via .env + return default + + +# Environment driven constants (tests expect specific defaults / formats) +LOG_LEVEL = (_pre_or_default("LOG_LEVEL", "INFO") or "INFO").upper() +LOG_PATH = _pre_or_default("LOG_PATH", "/tmp/logs/thechart") +LOG_CLEAR = (_pre_or_default("LOG_CLEAR", "False") or "False").capitalize() +BACKUP_PATH = _pre_or_default("BACKUP_PATH", "/tmp/thechart/backups") + +__all__ = [ + "LOG_LEVEL", + "LOG_PATH", + "LOG_CLEAR", + "BACKUP_PATH", +] diff --git a/src/thechart/core/error_handler.py b/src/thechart/core/error_handler.py new file mode 100644 index 0000000..6cbcc7d --- /dev/null +++ b/src/thechart/core/error_handler.py @@ -0,0 +1,258 @@ +"""Enhanced error handling and feedback (canonical module).""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any + + +class ErrorHandler: + """Centralized error handling with user-friendly feedback.""" + + def __init__(self, logger: logging.Logger, ui_manager=None): + self.logger = logger + self.ui_manager = ui_manager + self.error_counts: dict[str, int] = {} + self.last_error_time: dict[str, datetime] = {} + + def handle_error( + self, + error: Exception, + context: str = "Unknown", + user_message: str | None = None, + show_dialog: bool = True, + log_level: int = logging.ERROR, + ) -> None: + error_key = f"{type(error).__name__}:{context}" + current_time = datetime.now() + self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 + self.last_error_time[error_key] = current_time + + error_msg = f"Error in {context}: {str(error)}" + if log_level >= logging.ERROR: + self.logger.error(error_msg, exc_info=True) + elif log_level >= logging.WARNING: + self.logger.warning(error_msg) + else: + self.logger.debug(error_msg) + + if user_message is None: + user_message = self._generate_user_message(error, context) + + if self.ui_manager: + self.ui_manager.update_status(f"Error: {user_message}", "error") + + if show_dialog and self.ui_manager: + show_fn = getattr(self.ui_manager, "show_error_dialog", None) + if callable(show_fn): + show_fn(user_message) + else: + self._show_error_dialog(user_message, error, context) + + def handle_validation_error( + self, field_name: str, error_message: str, suggested_fix: str = "" + ) -> None: + full_message = f"Validation error in {field_name}: {error_message}" + if suggested_fix: + full_message += f"\n\nSuggested fix: {suggested_fix}" + self.logger.warning(f"Validation error: {field_name} - {error_message}") + if self.ui_manager: + self.ui_manager.update_status( + f"Invalid {field_name}: {error_message}", "warning" + ) + + def handle_file_error( + self, + operation: str, + file_path: str, + error: Exception, + recovery_action: str = "", + ) -> None: + context = f"File {operation}: {file_path}" + user_message = f"Failed to {operation} file: {file_path}" + if recovery_action: + user_message += f"\n\nSuggested action: {recovery_action}" + self.handle_error(error, context, user_message) + + def handle_data_error( + self, + operation: str, + data_type: str, + error: Exception, + recovery_suggestions: list[str] | None = None, + ) -> None: + context = f"Data {operation}: {data_type}" + user_message = f"Data error during {operation} of {data_type}" + if recovery_suggestions: + user_message += "\n\nTry these solutions:\n" + user_message += "\n".join(f"• {s}" for s in recovery_suggestions) + self.handle_error(error, context, user_message) + + def log_performance_warning( + self, operation: str, duration_seconds: float, threshold_seconds: float = 1.0 + ) -> None: + if duration_seconds > threshold_seconds: + self.logger.warning( + f"Performance warning: {operation} took {duration_seconds:.2f}s " + f"(threshold: {threshold_seconds:.2f}s)" + ) + if self.ui_manager: + self.ui_manager.update_status( + f"Operation completed but was slow: {operation}", "warning" + ) + + def get_error_summary(self) -> dict[str, Any]: + return { + "total_errors": sum(self.error_counts.values()), + "unique_errors": len(self.error_counts), + "error_counts": self.error_counts.copy(), + "last_error_times": self.last_error_time.copy(), + } + + def _generate_user_message(self, error: Exception, context: str) -> str: + error_type = type(error).__name__ + user_messages = { + "FileNotFoundError": "The requested file could not be found.", + "PermissionError": "Permission denied. Check file permissions.", + "ValueError": "Invalid data format or value.", + "TypeError": "Incorrect data type provided.", + "KeyError": "Required data field is missing.", + "ConnectionError": "Network connection failed.", + "MemoryError": "Insufficient memory to complete operation.", + "OSError": "System operation failed.", + } + base_message = user_messages.get( + error_type, f"An unexpected error occurred: {str(error)}" + ) + return f"{base_message} (Context: {context})" + + def _show_error_dialog( + self, user_message: str, error: Exception, context: str + ) -> None: + from tkinter import messagebox + + title = f"Error in {context}" + messagebox.showerror(title, user_message) + + +class OperationTimer: + """Context manager for timing operations and detecting performance issues.""" + + def __init__( + self, + error_handler: ErrorHandler | None, + operation_name: str, + warning_threshold: float = 1.0, + ): + self.error_handler = error_handler + self.operation_name = operation_name + self.warning_threshold = warning_threshold + self.start_time: float | None = None + + def __enter__(self): + import time + + self.start_time = time.time() + return self + + def __exit__(self, _exc_type, _exc_val, _exc_tb): + import time + + if self.start_time is not None: + duration = time.time() - self.start_time + if duration > self.warning_threshold and self.error_handler: + self.error_handler.log_performance_warning( + self.operation_name, duration, self.warning_threshold + ) + return False + + +def handle_exceptions(error_handler: ErrorHandler, context: str = "Operation"): + def decorator(func): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + error_handler.handle_error(e, f"{context}:{func.__name__}") + if isinstance(e, MemoryError | KeyboardInterrupt | SystemExit): + raise + return None + + return wrapper + + return decorator + + +class UserFeedback: + """Enhanced user feedback system with progress tracking.""" + + def __init__(self, ui_manager=None, logger: logging.Logger | None = None): + self.ui_manager = ui_manager + self.logger = logger + self.current_operation: str | None = None + self.operation_start_time: float | None = None + + def start_operation( + self, operation_name: str, estimated_duration: float | None = None + ) -> None: + import time + + self.current_operation = operation_name + self.operation_start_time = time.time() + if self.ui_manager: + message = f"Starting: {operation_name}" + if estimated_duration: + message += f" (estimated: {estimated_duration:.1f}s)" + self.ui_manager.update_status(message, "info") + if self.logger: + self.logger.info(f"Started operation: {operation_name}") + + def update_progress( + self, progress_text: str, percentage: float | None = None + ) -> None: + if not self.current_operation: + return + if self.ui_manager: + message = f"{self.current_operation}: {progress_text}" + if percentage is not None: + message += f" ({percentage:.1f}%)" + self.ui_manager.update_status(message, "info") + + def complete_operation(self, success: bool = True, final_message: str = "") -> None: + if not self.current_operation: + return + import time + + duration = None + if self.operation_start_time: + duration = time.time() - self.operation_start_time + if self.ui_manager: + if final_message: + message = final_message + else: + status_word = "completed" if success else "failed" + message = f"{self.current_operation} {status_word}" + if duration: + message += f" ({duration:.1f}s)" + status_type = "success" if success else "error" + self.ui_manager.update_status(message, status_type) + if self.logger: + status_word = "completed" if success else "failed" + log_message = f"Operation {status_word}: {self.current_operation}" + if duration: + log_message += f" (duration: {duration:.1f}s)" + if success: + self.logger.info(log_message) + else: + self.logger.error(log_message) + self.current_operation = None + self.operation_start_time = None + + +__all__ = [ + "ErrorHandler", + "OperationTimer", + "handle_exceptions", + "UserFeedback", +] diff --git a/src/thechart/core/logger.py b/src/thechart/core/logger.py new file mode 100644 index 0000000..812aef8 --- /dev/null +++ b/src/thechart/core/logger.py @@ -0,0 +1,97 @@ +"""Application logging utilities (canonical). + +This module centralizes logger initialization and honors environment-driven +settings from `thechart.core.constants` (LOG_LEVEL, LOG_PATH, LOG_CLEAR). +""" + +from __future__ import annotations + +import contextlib +import logging + +try: # Optional dependency; fall back to plain logging if missing + import colorlog # type: ignore +except Exception: # pragma: no cover - defensive in case of runtime packaging + colorlog = None + +from .constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH + + +def _bool_from_str(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _level_from_str(level: str) -> int: + try: + return getattr(logging, level.upper()) + except AttributeError: + return logging.INFO + + +def init_logger(dunder_name: str, testing_mode: bool) -> logging.Logger: + """Initialize and return a configured logger. + + - Ensures the log directory exists (LOG_PATH) indirectly; failures are tolerated. + - Respects LOG_CLEAR: writes files in overwrite mode when true. + - Respects LOG_LEVEL for non-testing runs; testing forces DEBUG. + - Prevents duplicate handlers on repeated initialization. + """ + + log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s" + + logger = logging.getLogger(dunder_name) + logger.propagate = False + + # Clear existing handlers to avoid duplicates on re-init + if logger.handlers: + for h in list(logger.handlers): + logger.removeHandler(h) + with contextlib.suppress(Exception): + h.close() + + # Level selection + logger.setLevel(logging.DEBUG if testing_mode else _level_from_str(LOG_LEVEL)) + + # Console handler (colored if colorlog available) + if colorlog is not None: + bold_seq = "\033[1m" + colorlog_format = f"{bold_seq} %(log_color)s {log_format}" + sh = colorlog.StreamHandler() + sh.setLevel(logger.level) + sh.setFormatter(colorlog.ColoredFormatter(colorlog_format)) + else: + sh = logging.StreamHandler() + sh.setLevel(logger.level) + sh.setFormatter(logging.Formatter(log_format)) + logger.addHandler(sh) + + # File handlers (overwrite if LOG_CLEAR truthy) + write_mode = "w" if _bool_from_str(LOG_CLEAR) else "a" + formatter = logging.Formatter(log_format) + + try: + fh_all = logging.FileHandler( + f"{LOG_PATH}/thechart.log", mode=write_mode, encoding="utf-8" + ) + fh_all.setLevel(logging.DEBUG) + fh_all.setFormatter(formatter) + logger.addHandler(fh_all) + + fh_warn = logging.FileHandler( + f"{LOG_PATH}/thechart.warning.log", mode=write_mode, encoding="utf-8" + ) + fh_warn.setLevel(logging.WARNING) + fh_warn.setFormatter(formatter) + logger.addHandler(fh_warn) + + fh_err = logging.FileHandler( + f"{LOG_PATH}/thechart.error.log", mode=write_mode, encoding="utf-8" + ) + fh_err.setLevel(logging.ERROR) + fh_err.setFormatter(formatter) + logger.addHandler(fh_err) + except (PermissionError, FileNotFoundError): + # Fall back to console-only logging in restricted environments + pass + + return logger diff --git a/src/thechart/core/preferences.py b/src/thechart/core/preferences.py new file mode 100644 index 0000000..d196c20 --- /dev/null +++ b/src/thechart/core/preferences.py @@ -0,0 +1,117 @@ +"""Application preferences with simple JSON persistence. + +API stays minimal: get_pref/set_pref for reads and writes, plus +load_preferences/save_preferences to manage disk state. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +_DEFAULTS: dict[str, Any] = { + # After a successful restore, offer to open the backups folder? + "prompt_open_folder_after_restore": False, + # Remember and restore window geometry between runs + "remember_window_geometry": True, + "last_window_geometry": "", + # Keep window always on top + "always_on_top": False, + # Search/filter UI state + "search_panel_visible": False, + "last_filter_state": None, + # Table column UX + "column_widths": {}, + "last_sort": {"column": None, "ascending": True}, + # Data: archiving/rotation + "archive_keep_years": 1, +} + +_PREFERENCES: dict[str, Any] = dict(_DEFAULTS) + + +def _config_dir() -> str: + """Return platform-appropriate config directory for TheChart.""" + try: + if sys.platform.startswith("win"): + base = os.environ.get("APPDATA", os.path.expanduser("~")) + return os.path.join(base, "TheChart") + if sys.platform == "darwin": + return os.path.join( + os.path.expanduser("~"), + "Library", + "Application Support", + "TheChart", + ) + # Linux and others: follow XDG + base = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config") + ) + return os.path.join(base, "thechart") + except Exception: + # Fallback to current directory if anything goes wrong + return os.getcwd() + + +def _config_path() -> str: + return os.path.join(_config_dir(), "preferences.json") + + +def get_config_dir() -> str: + """Public accessor for the application configuration directory.""" + return _config_dir() + + +def load_preferences() -> None: + """Load preferences from disk if present, fallback to defaults.""" + global _PREFERENCES + path = _config_path() + try: + if os.path.isfile(path): + with open(path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + merged = dict(_DEFAULTS) + merged.update(data) + _PREFERENCES = merged + except Exception: + # Ignore corrupt or unreadable files; continue with current prefs + pass + + +def save_preferences() -> None: + """Persist preferences to disk atomically.""" + path = _config_path() + directory = os.path.dirname(path) + try: + os.makedirs(directory, exist_ok=True) + tmp_path = path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(_PREFERENCES, f, indent=2, sort_keys=True) + os.replace(tmp_path, path) + except Exception: + # Best-effort persistence; ignore failures silently + pass + + +def reset_preferences() -> None: + """Reset preferences in memory to defaults and persist to disk.""" + global _PREFERENCES + _PREFERENCES = dict(_DEFAULTS) + save_preferences() + + +def get_pref(key: str, default: Any | None = None) -> Any: + """Get a preference value, or default if unset.""" + return _PREFERENCES.get(key, default) + + +def set_pref(key: str, value: Any) -> None: + """Set a preference value in memory (call save_preferences to persist).""" + _PREFERENCES[key] = value + + +# Attempt to load preferences on import for convenience +load_preferences() diff --git a/src/thechart/core/undo_manager.py b/src/thechart/core/undo_manager.py new file mode 100644 index 0000000..3b60840 --- /dev/null +++ b/src/thechart/core/undo_manager.py @@ -0,0 +1,36 @@ +"""Undo stack for add/update/delete operations (canonical module).""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass +class UndoAction: + description: str + undo_callable: Callable[[], None] + + +class UndoManager: + def __init__(self, capacity: int = 20) -> None: + self.capacity = capacity + self._stack: list[UndoAction] = [] + + def push(self, action: UndoAction) -> None: + self._stack.append(action) + if len(self._stack) > self.capacity: + self._stack.pop(0) + + def undo(self) -> str | None: + if not self._stack: + return None + action = self._stack.pop() + action.undo_callable() + return action.description + + def has_actions(self) -> bool: + return bool(self._stack) + + +__all__ = ["UndoAction", "UndoManager"] diff --git a/src/thechart/data/__init__.py b/src/thechart/data/__init__.py new file mode 100644 index 0000000..1be8042 --- /dev/null +++ b/src/thechart/data/__init__.py @@ -0,0 +1,11 @@ +"""Data layer re-exports for TheChart. + +Canonical implementations live under ``thechart.data``. Legacy ``src`` modules +are thin shims importing from here to preserve backward compatibility. +""" + +from __future__ import annotations + +from .data_manager import DataManager # noqa: F401 + +__all__ = ["DataManager"] diff --git a/src/thechart/data/data_manager.py b/src/thechart/data/data_manager.py new file mode 100644 index 0000000..dd4372c --- /dev/null +++ b/src/thechart/data/data_manager.py @@ -0,0 +1,541 @@ +"""Canonical DataManager implementation. + +This file holds the authoritative implementation that used to live at +``src/data_manager.py``. The legacy module has been replaced by a shim +importing from here to preserve backward compatibility. +""" + +from __future__ import annotations +# ruff: noqa: I001 + +# isort: off # keep grouped imports stable during migration + +# Reuse the implementation from the legacy file by pasting its code here. +# Minimal adjustments: fix intra-project imports to go through package shims. + +# Standard library +import csv +from datetime import datetime +import logging +import os +import tempfile +from typing import Any + +# Third-party +import pandas as pd + +# Local imports +from thechart.managers import MedicineManager, PathologyManager + + +class DataManager: + """Handle all data operations for the application with performance optimizations.""" + + def __init__( + self, + filename: str, + logger: logging.Logger, + medicine_manager: MedicineManager, + pathology_manager: PathologyManager, + ) -> None: + self._init_internal( + filename, + logger, + medicine_manager, + pathology_manager, + ) + + def _init_internal( + self, + filename: str, + logger: logging.Logger, + medicine_manager: MedicineManager, + pathology_manager: PathologyManager, + ) -> None: + self.filename = filename + self.logger = logger + self.medicine_manager = medicine_manager + self.pathology_manager = pathology_manager + + self._data_cache = None + self._cache_timestamp = 0 + self._headers_cache = None + self._dtype_cache = None + self._graph_cache = None + self._config_version = 0 + self._initialize_csv_file() + + def _get_csv_headers(self) -> tuple[str, ...]: + """Get CSV headers based on current pathology and medicine configuration. + Cached to avoid repeated computation.""" + if self._headers_cache is not None: + return self._headers_cache + + # Start with date + headers = ["date"] + + # Add pathology headers + for pathology_key in self.pathology_manager.get_pathology_keys(): + headers.append(pathology_key) + + # Add medicine headers + for medicine_key in self.medicine_manager.get_medicine_keys(): + headers.extend([medicine_key, f"{medicine_key}_doses"]) + + result = tuple(headers + ["note"]) + self._headers_cache = result + return result + + def _initialize_csv_file(self) -> None: + """Create CSV file with headers if it doesn't exist or is empty.""" + try: + creating = not os.path.exists(self.filename) + if creating or os.path.getsize(self.filename) == 0: + with open(self.filename, mode="w", newline="") as file: + writer = csv.writer(file) + writer.writerow(self._get_csv_headers()) + if creating: + # Emit warning so tests detect creation of missing file + self.logger.warning( + "CSV file did not exist and was created with headers." + ) + except Exception as e: + self.logger.error(f"Failed to initialize CSV file: {e}") + + def _invalidate_cache(self) -> None: + """Invalidate the data cache when data changes.""" + self._data_cache = None + self._cache_timestamp = 0 + self._graph_cache = None + + def invalidate_structure(self) -> None: + """Invalidate caches due to structural changes (e.g., medicines/pathologies). + + Public method for other managers / UI to call instead of reaching into + private attributes. This bumps a config version ensuring future loads + rebuild dependent caches. + """ + self._headers_cache = None + self._dtype_cache = None + self._graph_cache = None + self._config_version += 1 + # Data remains valid but columns may differ; safest is full invalidation + self._invalidate_cache() + + def _should_reload_data(self) -> bool: + """Check if data should be reloaded based on file modification time.""" + if self._data_cache is None: + return True + + try: + file_mtime = os.path.getmtime(self.filename) + return file_mtime > self._cache_timestamp + except OSError: + return True + + def _get_dtype_dict(self) -> dict[str, type]: + """Get pandas dtype dictionary for efficient reading. + Cached to avoid recreation.""" + if self._dtype_cache is not None: + return self._dtype_cache + + dtype_dict = {"date": str, "note": str} + + # Add pathology types + for pathology_key in self.pathology_manager.get_pathology_keys(): + dtype_dict[pathology_key] = int + + # Add medicine types + for medicine_key in self.medicine_manager.get_medicine_keys(): + dtype_dict[medicine_key] = int + dtype_dict[f"{medicine_key}_doses"] = str + + self._dtype_cache = dtype_dict + return dtype_dict + + def load_data(self) -> pd.DataFrame: + """Load data from CSV file with caching for better performance.""" + if not os.path.exists(self.filename): + self.logger.warning("CSV file does not exist. No data to load.") + return pd.DataFrame() + if os.path.getsize(self.filename) == 0: + self.logger.warning("CSV file is empty. No data to load.") + return pd.DataFrame() + + # Use cached data if available and file hasn't changed + if not self._should_reload_data(): + return self._data_cache.copy() + + try: + # Use pre-built dtype dictionary for faster parsing + dtype_dict = self._get_dtype_dict() + + # Read with optimized settings + df: pd.DataFrame = pd.read_csv( + self.filename, + dtype=dtype_dict, + na_filter=False, # Don't convert to NaN, keep as empty strings + engine="c", # Use faster C engine + ) + + # If file has only headers (no rows), treat as empty with warning + if df.empty: + self.logger.warning("CSV file contains only headers. No data to load.") + return pd.DataFrame() + + # Sort only if needed (check if already sorted) + if len(df) > 1 and not df["date"].is_monotonic_increasing: + df = df.sort_values(by="date").reset_index(drop=True) + + # Cache the data and timestamp + self._data_cache = df.copy() + self._cache_timestamp = os.path.getmtime(self.filename) + # Invalidate graph cache because underlying data changed + self._graph_cache = None + + return df.copy() + + except pd.errors.EmptyDataError: + self.logger.warning("CSV file is empty. No data to load.") + return pd.DataFrame() + except Exception as e: + self.logger.error(f"Error loading data: {str(e)}") + return pd.DataFrame() + + def add_entry(self, entry_data: list[str | int]) -> bool: + """Add a new entry to the CSV file with optimized duplicate checking.""" + try: + # Quick duplicate check using cached data if available + date_to_add: str = str(entry_data[0]) + + if self._data_cache is not None: + # Use cached data for duplicate check + if date_to_add in self._data_cache["date"].values: + self.logger.warning( + f"Entry with date {date_to_add} already exists." + ) + return False + else: + # Fallback to loading data if no cache + df: pd.DataFrame = self.load_data() + if not df.empty and date_to_add in df["date"].values: + self.logger.warning( + f"Entry with date {date_to_add} already exists." + ) + return False + + # Write to file + with open(self.filename, mode="a", newline="") as file: + writer = csv.writer(file) + writer.writerow(entry_data) + + # Invalidate cache since data changed + self._invalidate_cache() + return True + + except Exception as e: + self.logger.error(f"Error adding entry: {str(e)}") + return False + + def update_entry(self, original_date: str, values: list[str | int]) -> bool: + """Update an existing entry identified by original_date + with optimized processing.""" + try: + df: pd.DataFrame = self.load_data() + new_date: str = str(values[0]) + + # Optimized duplicate check + if original_date != new_date: + date_exists = (df["date"] == new_date).any() + if date_exists: + self.logger.warning( + f"Cannot update: entry with date {new_date} already exists." + ) + return False + + # Get current CSV headers to match with values + headers = list(self._get_csv_headers()) + + # Ensure we have the right number of values with optimized padding + if len(values) < len(headers): + # Pad with defaults efficiently + padding_needed = len(headers) - len(values) + for i in range(padding_needed): + header_idx = len(values) + i + if header_idx < len(headers): + header = headers[header_idx] + if header == "note" or header.endswith("_doses"): + values.append("") + else: + values.append(0) + + # Use vectorized update for better performance + mask = df["date"] == original_date + if mask.any(): + df.loc[mask, headers] = values + # Atomic write back to CSV to avoid partial writes + self._atomic_write_csv(df) + self._invalidate_cache() + return True + else: + self.logger.warning( + f"Entry with date {original_date} not found for update." + ) + return False + + except Exception as e: + self.logger.error(f"Error updating entry: {str(e)}") + return False + + def delete_entry(self, date: str) -> bool: + """Delete an entry identified by date with optimized processing.""" + try: + df: pd.DataFrame = self.load_data() + original_len = len(df) + + # Use vectorized filtering for better performance + df = df[df["date"] != date] + + # Only write if something was actually deleted + if len(df) < original_len: + self._atomic_write_csv(df) + self._invalidate_cache() + + return True + except Exception as e: + self.logger.error(f"Error deleting entry: {str(e)}") + return False + + # ------------------------------------------------------------------ + # File write helpers + # ------------------------------------------------------------------ + def _atomic_write_csv(self, df: pd.DataFrame) -> None: + """Write a DataFrame to CSV atomically by writing to a temp file then replacing. + + This prevents corrupted files if the app crashes mid-write. + """ + directory = os.path.dirname(os.path.abspath(self.filename)) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + prefix="thechart_", suffix=".csv", dir=directory + ) + try: + with os.fdopen(fd, "w") as tmp_file: + df.to_csv(tmp_file, index=False) + os.replace(tmp_path, self.filename) + finally: + # If replace succeeded tmp_path no longer exists; suppress errors + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + + # ------------------------------------------------------------------ + # Archiving / Rotation + # ------------------------------------------------------------------ + def _get_archive_dir(self) -> str: + """Return path to the archives directory next to the main CSV.""" + base_dir = os.path.dirname(os.path.abspath(self.filename)) or "." + archive_dir = os.path.join(base_dir, "archives") + os.makedirs(archive_dir, exist_ok=True) + return archive_dir + + def _ensure_headers(self, df: pd.DataFrame) -> pd.DataFrame: + """Ensure dataframe has all expected headers in correct order. + + Missing numeric fields default to 0; dose/note string fields to ''. + Columns are ordered per _get_csv_headers(). + """ + headers = list(self._get_csv_headers()) + out = df.copy() + for col in headers: + if col not in out.columns: + if col == "note" or col.endswith("_doses"): + out[col] = "" + else: + out[col] = 0 + # Drop unknown columns to keep files tidy + out = out[headers] + return out + + def _write_archive_file(self, year: int, df: pd.DataFrame) -> str: + """Append archived rows to a per-year CSV with full headers. + + Returns the archive file path. + """ + archive_dir = self._get_archive_dir() + base = os.path.splitext(os.path.basename(self.filename))[0] + archive_path = os.path.join(archive_dir, f"{base}_{year}.csv") + df_to_write = self._ensure_headers(df) + # If file doesn't exist, write with header; else append without header + write_header = ( + not os.path.exists(archive_path) or os.path.getsize(archive_path) == 0 + ) + try: + df_to_write.to_csv(archive_path, mode="a", index=False, header=write_header) + except Exception as e: + self.logger.error(f"Failed to write archive file {archive_path}: {e}") + raise + return archive_path + + def archive_old_data(self, keep_years: int = 1) -> dict[str, Any]: + """Archive rows older than the most recent N years into per-year files. + + Args: + keep_years: Number of most recent full calendar years to keep in the + main CSV (minimum 1). Rows with a date older than the earliest + kept year are moved to archives/BASE_YYYY.csv. + + Returns: + Summary dict: { 'archived_rows': int, 'archive_files': set[str], + 'kept_rows': int } + """ + try: + keep_years = max(1, int(keep_years)) + except Exception: + keep_years = 1 + + df = self.load_data() + if df.empty or "date" not in df.columns: + return {"archived_rows": 0, "archive_files": set(), "kept_rows": 0} + + # Parse dates (stored as mm/dd/YYYY normally) + dates = pd.to_datetime(df["date"], format="%m/%d/%Y", errors="coerce") + df = df.copy() + df["__dt"] = dates + # If we couldn't parse dates, nothing to archive safely + if df["__dt"].isna().all(): + df.drop(columns=["__dt"], inplace=True) + return { + "archived_rows": 0, + "archive_files": set(), + "kept_rows": int(len(df)), + } + + current_year = datetime.now().year + earliest_kept_year = current_year - keep_years + 1 + + to_archive = df[df["__dt"].dt.year < earliest_kept_year] + to_keep = df[df["__dt"].dt.year >= earliest_kept_year] + + if to_archive.empty: + df.drop(columns=["__dt"], inplace=True) + return { + "archived_rows": 0, + "archive_files": set(), + "kept_rows": int(len(df)), + } + + archive_files: set[str] = set() + try: + # Group by year and append to each year's archive file + for year, group in to_archive.groupby(to_archive["__dt"].dt.year): + group = group.drop(columns=["__dt"]) # remove helper + path = self._write_archive_file(int(year), group) + archive_files.add(path) + + # Write the kept rows back to main CSV atomically + kept_df = to_keep.drop(columns=["__dt"]).copy() + # Ensure columns and order + kept_df = self._ensure_headers(kept_df) + self._atomic_write_csv(kept_df) + self._invalidate_cache() + except Exception as e: + # If archiving failed mid-way, log and propagate minimal info + self.logger.error(f"Archiving failed: {e}") + raise + + return { + "archived_rows": int(len(to_archive)), + "archive_files": archive_files, + "kept_rows": int(len(to_keep)), + } + + def get_today_medicine_doses( + self, date: str, medicine_name: str + ) -> list[tuple[str, str]]: + """Get list of (timestamp, dose) tuples for a medicine on a given date + with caching.""" + try: + df: pd.DataFrame = self.load_data() + if df.empty: + return [] + + # Use vectorized filtering for better performance + date_mask = df["date"] == date + if not date_mask.any(): + return [] + + dose_column = f"{medicine_name}_doses" + if dose_column not in df.columns: + return [] + + doses_str = df.loc[date_mask, dose_column].iloc[0] + + if not doses_str: + return [] + + # Optimized dose parsing + doses = [] + for dose_entry in doses_str.split("|"): + if ":" in dose_entry: + parts = dose_entry.split(":", 1) + if len(parts) == 2: + doses.append((parts[0], parts[1])) + + return doses + except Exception as e: + self.logger.error(f"Error getting medicine doses: {str(e)}") + return [] + + # ------------------------------------------------------------------ + # Retrieval helpers + # ------------------------------------------------------------------ + def get_row(self, date: str) -> list[str | int] | None: + """Return a row (as list aligned with current headers) for a date. + + Args: + date: Date string identifying the row + Returns: + List of values aligned with current CSV headers or None if not found. + """ + try: + df = self.load_data() + if df.empty or "date" not in df.columns: + return None + mask = df["date"] == date + if not mask.any(): + return None + headers = list(self._get_csv_headers()) + row_series = df.loc[mask, headers].iloc[0] + return [row_series[h] for h in headers] + except Exception: + return None + + # ------------------------------------------------------------------ + # Graph Data Handling + # ------------------------------------------------------------------ + def get_graph_ready_data(self) -> pd.DataFrame: + """Return a dataframe ready for graphing (datetime index cached). + + This avoids repeatedly parsing dates & re-sorting in the graph layer. + """ + base_df = self.load_data() + if base_df.empty: + return base_df + if self._graph_cache is not None: + return self._graph_cache.copy() + try: + graph_df = base_df.copy() + # Expect date stored in mm/dd/YYYY format + graph_df["date"] = pd.to_datetime( + graph_df["date"], format="%m/%d/%Y", errors="coerce" + ) + graph_df = graph_df.dropna(subset=["date"]).sort_values("date") + graph_df.set_index("date", inplace=True) + self._graph_cache = graph_df.copy() + return graph_df + except Exception: + # Fallback: return original (unindexed) data + return base_df diff --git a/src/thechart/export/__init__.py b/src/thechart/export/__init__.py new file mode 100644 index 0000000..72dd6b3 --- /dev/null +++ b/src/thechart/export/__init__.py @@ -0,0 +1,7 @@ +"""Export subsystem public API.""" + +from __future__ import annotations + +from .export_manager import ExportManager # noqa: F401 + +__all__ = ["ExportManager"] diff --git a/src/thechart/export/export_manager.py b/src/thechart/export/export_manager.py new file mode 100644 index 0000000..db50abd --- /dev/null +++ b/src/thechart/export/export_manager.py @@ -0,0 +1,388 @@ +""" +Export Manager for TheChart Application (canonical implementation). + +Handles exporting data and graphs to various formats: +- CSV data to JSON, XML +- Graphs to PDF (with data tables) +""" + +from __future__ import annotations + +# Standard library +import contextlib +import json +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Any +from xml.dom import minidom +from xml.etree.ElementTree import Element, SubElement, tostring + +# Third-party +import pandas as pd +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + Image, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + +# Local canonical imports +from thechart.analytics import GraphManager +from thechart.data import DataManager +from thechart.managers import MedicineManager, PathologyManager + + +class ExportManager: + """Handle data and graph export operations.""" + + def __init__( + self, + data_manager: DataManager, + graph_manager: GraphManager, + medicine_manager: MedicineManager, + pathology_manager: PathologyManager, + logger: logging.Logger, + ) -> None: + self.data_manager = data_manager + self.graph_manager = graph_manager + self.medicine_manager = medicine_manager + self.pathology_manager = pathology_manager + self.logger = logger + + def export_data_to_json( + self, export_path: str, df: pd.DataFrame | None = None + ) -> bool: + """Export CSV data to JSON format.""" + try: + df = df if df is not None else self.data_manager.load_data() + if df.empty: + self.logger.warning("No data to export") + return False + + # Convert DataFrame to dictionary with better structure + export_data = { + "metadata": { + "export_date": datetime.now().isoformat(), + "total_entries": len(df), + "date_range": { + "start": df["date"].min() if not df.empty else None, + "end": df["date"].max() if not df.empty else None, + }, + "pathologies": list(self.pathology_manager.get_pathology_keys()), + "medicines": list(self.medicine_manager.get_medicine_keys()), + }, + "entries": df.to_dict(orient="records"), + } + + with open(export_path, "w", encoding="utf-8") as f: + json.dump(export_data, f, indent=2, ensure_ascii=False) + + self.logger.info(f"Data exported to JSON: {export_path}") + return True + + except Exception as e: + self.logger.error(f"Error exporting to JSON: {str(e)}") + return False + + def export_data_to_xml( + self, export_path: str, df: pd.DataFrame | None = None + ) -> bool: + """Export CSV data to XML format.""" + try: + df = df if df is not None else self.data_manager.load_data() + if df.empty: + self.logger.warning("No data to export") + return False + + # Create root element + root = Element("thechart_data") + + # Add metadata + metadata = SubElement(root, "metadata") + SubElement(metadata, "export_date").text = datetime.now().isoformat() + SubElement(metadata, "total_entries").text = str(len(df)) + + # Date range + date_range = SubElement(metadata, "date_range") + SubElement(date_range, "start").text = ( + df["date"].min() if not df.empty else "" + ) + SubElement(date_range, "end").text = ( + df["date"].max() if not df.empty else "" + ) + + # Pathologies + pathologies = SubElement(metadata, "pathologies") + for pathology in self.pathology_manager.get_pathology_keys(): + SubElement(pathologies, "pathology").text = pathology + + # Medicines + medicines = SubElement(metadata, "medicines") + for medicine in self.medicine_manager.get_medicine_keys(): + SubElement(medicines, "medicine").text = medicine + + # Add entries + entries = SubElement(root, "entries") + for _, row in df.iterrows(): + entry = SubElement(entries, "entry") + for column, value in row.items(): + elem = SubElement(entry, column.replace(" ", "_")) + elem.text = str(value) if pd.notna(value) else "" + + # Pretty print XML + rough_string = tostring(root, "utf-8") + reparsed = minidom.parseString(rough_string) + pretty_xml = reparsed.toprettyxml(indent=" ") + + with open(export_path, "w", encoding="utf-8") as f: + f.write(pretty_xml) + + self.logger.info(f"Data exported to XML: {export_path}") + return True + + except Exception as e: + self.logger.error(f"Error exporting to XML: {str(e)}") + return False + + def _save_graph_as_image(self, temp_dir: Path) -> str | None: + """Save current graph as temporary image for PDF inclusion.""" + try: + # Check if graph manager exists + if self.graph_manager is None: + self.logger.warning("No graph manager available for export") + return None + + # Check if graph manager and figure exist + if not hasattr(self.graph_manager, "fig") or self.graph_manager.fig is None: + self.logger.warning("No graph figure available for export") + return None + + # Ensure graph is up to date with current data + df = self.data_manager.load_data() + if not df.empty: + self.graph_manager.update_graph(df) + else: + self.logger.warning("No data available to update graph for export") + return None + + # Ensure temp directory exists + temp_dir.mkdir(parents=True, exist_ok=True) + temp_image_path = temp_dir / "graph.png" + + # Save the current figure + self.graph_manager.fig.savefig( + str(temp_image_path), + dpi=150, + bbox_inches="tight", + facecolor="white", + edgecolor="none", + ) + + # Ensure the figure data is properly flushed to disk + import matplotlib.pyplot as plt + + plt.draw() + plt.pause(0.01) # Small pause to ensure file is written + + # Verify the file was actually created and has content + if not temp_image_path.exists(): + self.logger.error( + f"Graph image file was not created: {temp_image_path}" + ) + return None + + if temp_image_path.stat().st_size == 0: + self.logger.error(f"Graph image file is empty: {temp_image_path}") + return None + + self.logger.info(f"Graph image saved successfully: {temp_image_path}") + return str(temp_image_path) + + except Exception as e: + self.logger.error(f"Error saving graph image: {str(e)}") + return None + + def export_to_pdf( + self, + export_path: str, + include_graph: bool = True, + df: pd.DataFrame | None = None, + ) -> bool: + """Export data and optionally graph to PDF format.""" + try: + df = df if df is not None else self.data_manager.load_data() + + # Create PDF document in landscape format for better table/graph display + doc = SimpleDocTemplate( + export_path, + pagesize=landscape(A4), + rightMargin=72, + leftMargin=72, + topMargin=72, + bottomMargin=18, + ) + + # Get styles + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "CustomTitle", + parent=styles["Heading1"], + fontSize=18, + spaceAfter=30, + textColor=colors.darkblue, + ) + + story = [] + + # Title + story.append(Paragraph("TheChart - Medication Tracker Export", title_style)) + story.append(Spacer(1, 20)) + + # Export metadata + export_info = [ + f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f"Total Entries: {len(df) if not df.empty else 0}", + ] + + if not df.empty: + export_info.extend( + [ + f"Date Range: {df['date'].min()} to {df['date'].max()}", + ( + "Pathologies: " + + ", ".join(self.pathology_manager.get_pathology_keys()) + ), + ( + "Medicines: " + + ", ".join(self.medicine_manager.get_medicine_keys()) + ), + ] + ) + + for info in export_info: + story.append(Paragraph(info, styles["Normal"])) + + story.append(Spacer(1, 20)) + + # Include graph if requested and available + if include_graph: + temp_dir = Path(export_path).parent / "temp_export" + graph_path = None + + try: + graph_path = self._save_graph_as_image(temp_dir) + if graph_path and os.path.exists(graph_path): + # Add page break before graph for full page display + story.append(PageBreak()) + + story.append( + Paragraph("Data Visualization", styles["Heading2"]) + ) + story.append(Spacer(1, 20)) + + # Full page graph - maintain proportions while maximizing size + # Let ReportLab scale proportionally to fit landscape page + img = Image(graph_path, width=9 * inch, height=5.4 * inch) + story.append(img) + else: + # Graph not available, add a note instead + story.append(PageBreak()) + story.append( + Paragraph( + "Data Visualization (Graph not available)", + styles["Heading2"], + ) + ) + story.append(Spacer(1, 10)) + story.append( + Paragraph( + ( + "Graph image could not be generated. " + "Continuing with data export only." + ), + styles["Normal"], + ) + ) + finally: + # Clean up temporary image file + if graph_path and os.path.exists(graph_path): + with contextlib.suppress(Exception): + os.remove(graph_path) + with contextlib.suppress(Exception): + temp_dir.mkdir(parents=True, exist_ok=True) + # Remove directory if empty + if not any(temp_dir.iterdir()): + temp_dir.rmdir() + + # Add data table if there is data + if df.empty: + story.append( + Paragraph("No data available to export.", styles["Normal"]) + ) + else: + # Prepare table data + columns = list(df.columns) + data: list[list[Any]] = [columns] + + # Format rows + for _, row in df.iterrows(): + formatted_row = [] + for col in columns: + value = row[col] + if pd.isna(value): + formatted_row.append("") + elif isinstance(value, int | float): + formatted_row.append(f"{value}") + else: + formatted_row.append(str(value)) + data.append(formatted_row) + + # Create table with improved formatting for readability + table = Table(data, repeatRows=1) + + # Define table styles + style = TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.black), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 11), + ("BOTTOMPADDING", (0, 0), (-1, 0), 8), + ("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ] + ) + + # Add alternating row colors for better readability + for i in range(1, len(data)): + if i % 2 == 0: + style.add("BACKGROUND", (0, i), (-1, i), colors.beige) + + table.setStyle(style) + + story.append(Paragraph("Data Table", styles["Heading2"])) + story.append(Spacer(1, 10)) + story.append(table) + + # Build the PDF + doc.build(story) + + self.logger.info(f"Exported to PDF: {export_path}") + return True + + except Exception as e: + self.logger.error(f"Error exporting to PDF: {str(e)}") + return False + + +__all__ = ["ExportManager"] diff --git a/src/thechart/managers.py b/src/thechart/managers.py index 132a8d6..412d2a6 100644 --- a/src/thechart/managers.py +++ b/src/thechart/managers.py @@ -1,12 +1,18 @@ -"""Aggregate re-exports for thechart managers. +"""Aggregate re-exports for TheChart managers. -Keeps external imports simple (e.g., `from thechart.managers import DataManager`) while -preserving the current src layout. +External imports can use `from thechart.managers import ...`. +Gradually we migrate canonical implementations here, with legacy shims left in +`src/` for backward-compatibility. """ from __future__ import annotations +# ruff: noqa: I001 -from src.data_manager import DataManager # noqa: F401 -from src.medicine_manager import Medicine, MedicineManager # noqa: F401 -from src.pathology_manager import PathologyManager # noqa: F401 -from src.ui_manager import UIManager # noqa: F401 +# First-party re-exports +from thechart.data import DataManager # noqa: F401 +from .managers import ( # noqa: F401 + Medicine, + MedicineManager, + Pathology, + PathologyManager, +) diff --git a/src/thechart/managers/__init__.py b/src/thechart/managers/__init__.py new file mode 100644 index 0000000..0574af3 --- /dev/null +++ b/src/thechart/managers/__init__.py @@ -0,0 +1,18 @@ +"""Canonical manager implementations for TheChart. + +Exports: +- Medicine, MedicineManager +- Pathology, PathologyManager +""" + +from __future__ import annotations + +from .medicine_manager import Medicine, MedicineManager # noqa: F401 +from .pathology_manager import Pathology, PathologyManager # noqa: F401 + +__all__ = [ + "Medicine", + "MedicineManager", + "Pathology", + "PathologyManager", +] diff --git a/src/thechart/managers/medicine_manager.py b/src/thechart/managers/medicine_manager.py new file mode 100644 index 0000000..e3dbb33 --- /dev/null +++ b/src/thechart/managers/medicine_manager.py @@ -0,0 +1,195 @@ +""" +Medicine configuration manager for the MedTracker application. +Handles dynamic loading and saving of medicine configurations. +""" + +import json +import logging +import os +from dataclasses import asdict, dataclass +from typing import Any + + +@dataclass +class Medicine: + """Data class representing a medicine.""" + + key: str # Internal key (e.g., "bupropion") + display_name: str # Display name (e.g., "Bupropion") + dosage_info: str # Dosage information (e.g., "150/300 mg") + quick_doses: list[str] # Common dose amounts for quick selection + color: str # Color for graph display + default_enabled: bool = False # Whether to show in graph by default + + +class MedicineManager: + """Manages medicine configurations and provides access to medicine data.""" + + def __init__( + self, config_file: str = "medicines.json", logger: logging.Logger = None + ): + self.config_file = config_file + self.logger = logger or logging.getLogger(__name__) + self.medicines: dict[str, Medicine] = {} + self._load_medicines() + + def _get_default_medicines(self) -> list[Medicine]: + """Get the default medicine configuration.""" + return [ + Medicine( + key="bupropion", + display_name="Bupropion", + dosage_info="150/300 mg", + quick_doses=["150", "300"], + color="#FF6B6B", + default_enabled=True, + ), + Medicine( + key="hydroxyzine", + display_name="Hydroxyzine", + dosage_info="25 mg", + quick_doses=["25", "50"], + color="#4ECDC4", + default_enabled=False, + ), + Medicine( + key="gabapentin", + display_name="Gabapentin", + dosage_info="100 mg", + quick_doses=["100", "300", "600"], + color="#45B7D1", + default_enabled=False, + ), + Medicine( + key="propranolol", + display_name="Propranolol", + dosage_info="10 mg", + quick_doses=["10", "20", "40"], + color="#96CEB4", + default_enabled=True, + ), + Medicine( + key="quetiapine", + display_name="Quetiapine", + dosage_info="25 mg", + quick_doses=["25", "50", "100"], + color="#FFEAA7", + default_enabled=False, + ), + ] + + def _load_medicines(self) -> None: + """Load medicines from configuration file.""" + if os.path.exists(self.config_file): + try: + with open(self.config_file) as f: + data = json.load(f) + + self.medicines = {} + for medicine_data in data.get("medicines", []): + medicine = Medicine(**medicine_data) + self.medicines[medicine.key] = medicine + + self.logger.info( + f"Loaded {len(self.medicines)} medicines from {self.config_file}" + ) + except Exception as e: + self.logger.error(f"Error loading medicines config: {e}") + self._create_default_config() + else: + self._create_default_config() + + def _create_default_config(self) -> None: + """Create default medicine configuration.""" + default_medicines = self._get_default_medicines() + self.medicines = {med.key: med for med in default_medicines} + self.save_medicines() + self.logger.info("Created default medicine configuration") + + def save_medicines(self) -> bool: + """Save current medicines to configuration file.""" + try: + data = { + "medicines": [asdict(medicine) for medicine in self.medicines.values()] + } + + with open(self.config_file, "w") as f: + json.dump(data, f, indent=2) + + self.logger.info( + f"Saved {len(self.medicines)} medicines to {self.config_file}" + ) + return True + except Exception as e: + self.logger.error(f"Error saving medicines config: {e}") + return False + + def get_all_medicines(self) -> dict[str, Medicine]: + """Get all medicines.""" + return self.medicines.copy() + + def get_medicine(self, key: str) -> Medicine | None: + """Get a specific medicine by key.""" + return self.medicines.get(key) + + def add_medicine(self, medicine: Medicine) -> bool: + """Add a new medicine.""" + if medicine.key in self.medicines: + self.logger.warning(f"Medicine with key '{medicine.key}' already exists") + return False + + self.medicines[medicine.key] = medicine + return self.save_medicines() + + def update_medicine(self, key: str, medicine: Medicine) -> bool: + """Update an existing medicine.""" + if key not in self.medicines: + self.logger.warning(f"Medicine with key '{key}' does not exist") + return False + + # If key is changing, remove old entry + if key != medicine.key: + del self.medicines[key] + + self.medicines[medicine.key] = medicine + return self.save_medicines() + + def remove_medicine(self, key: str) -> bool: + """Remove a medicine.""" + if key not in self.medicines: + self.logger.warning(f"Medicine with key '{key}' does not exist") + return False + + del self.medicines[key] + return self.save_medicines() + + def get_medicine_keys(self) -> list[str]: + """Get list of all medicine keys.""" + return list(self.medicines.keys()) + + def get_display_names(self) -> dict[str, str]: + """Get mapping of keys to display names.""" + return {key: med.display_name for key, med in self.medicines.items()} + + def get_quick_doses(self, key: str) -> list[str]: + """Get quick dose options for a medicine.""" + medicine = self.medicines.get(key) + return medicine.quick_doses if medicine else ["25", "50"] + + def get_graph_colors(self) -> dict[str, str]: + """Get mapping of medicine keys to graph colors.""" + return {key: med.color for key, med in self.medicines.items()} + + def get_default_enabled_medicines(self) -> list[str]: + """Get list of medicines that should be enabled by default in graphs.""" + return [key for key, med in self.medicines.items() if med.default_enabled] + + def get_medicine_vars_dict(self) -> dict[str, tuple[Any, str]]: + """Get medicine variables dictionary for UI compatibility.""" + # This maintains compatibility with existing UI code + import tkinter as tk + + return { + key: (tk.IntVar(value=0), f"{med.display_name} {med.dosage_info}") + for key, med in self.medicines.items() + } diff --git a/src/thechart/managers/pathology_manager.py b/src/thechart/managers/pathology_manager.py new file mode 100644 index 0000000..3653e03 --- /dev/null +++ b/src/thechart/managers/pathology_manager.py @@ -0,0 +1,199 @@ +""" +Pathology configuration manager for the MedTracker application. +Handles dynamic loading and saving of pathology/symptom configurations. +""" + +import json +import logging +import os +from dataclasses import asdict, dataclass +from typing import Any + + +@dataclass +class Pathology: + """Data class representing a pathology/symptom.""" + + key: str # Internal key (e.g., "depression") + display_name: str # Display name (e.g., "Depression") + scale_info: str # Scale information (e.g., "0:good, 10:bad") + color: str # Color for graph display + default_enabled: bool = True # Whether to show in graph by default + scale_min: int = 0 # Minimum scale value + scale_max: int = 10 # Maximum scale value + scale_orientation: str = "normal" # "normal" (0=good) or "inverted" (0=bad) + + +class PathologyManager: + """Manages pathology configurations and provides access to pathology data.""" + + def __init__( + self, config_file: str = "pathologies.json", logger: logging.Logger = None + ): + self.config_file = config_file + self.logger = logger or logging.getLogger(__name__) + self.pathologies: dict[str, Pathology] = {} + self._load_pathologies() + + def _get_default_pathologies(self) -> list[Pathology]: + """Get the default pathology configuration.""" + return [ + Pathology( + key="depression", + display_name="Depression", + scale_info="0:good, 10:bad", + color="#FF6B6B", + default_enabled=True, + scale_orientation="normal", + ), + Pathology( + key="anxiety", + display_name="Anxiety", + scale_info="0:good, 10:bad", + color="#FFA726", + default_enabled=True, + scale_orientation="normal", + ), + Pathology( + key="sleep", + display_name="Sleep Quality", + scale_info="0:bad, 10:good", + color="#66BB6A", + default_enabled=True, + scale_orientation="inverted", + ), + Pathology( + key="appetite", + display_name="Appetite", + scale_info="0:bad, 10:good", + color="#42A5F5", + default_enabled=True, + scale_orientation="inverted", + ), + ] + + def _load_pathologies(self) -> None: + """Load pathologies from configuration file.""" + if os.path.exists(self.config_file): + try: + with open(self.config_file) as f: + data = json.load(f) + + self.pathologies = {} + for pathology_data in data.get("pathologies", []): + pathology = Pathology(**pathology_data) + self.pathologies[pathology.key] = pathology + + self.logger.info( + f"Loaded {len(self.pathologies)} pathologies from " + f"{self.config_file}" + ) + except Exception as e: + self.logger.error(f"Error loading pathologies config: {e}") + self._create_default_config() + else: + self._create_default_config() + + def _create_default_config(self) -> None: + """Create default pathology configuration.""" + default_pathologies = self._get_default_pathologies() + self.pathologies = {path.key: path for path in default_pathologies} + self.save_pathologies() + self.logger.info("Created default pathology configuration") + + def save_pathologies(self) -> bool: + """Save current pathologies to configuration file.""" + try: + data = { + "pathologies": [ + asdict(pathology) for pathology in self.pathologies.values() + ] + } + + with open(self.config_file, "w") as f: + json.dump(data, f, indent=2) + + self.logger.info( + f"Saved {len(self.pathologies)} pathologies to {self.config_file}" + ) + return True + except Exception as e: + self.logger.error(f"Error saving pathologies config: {e}") + return False + + def get_all_pathologies(self) -> dict[str, Pathology]: + """Get all pathologies.""" + return self.pathologies.copy() + + def get_pathology(self, key: str) -> Pathology | None: + """Get a specific pathology by key.""" + return self.pathologies.get(key) + + def add_pathology(self, pathology: Pathology) -> bool: + """Add a new pathology.""" + if pathology.key in self.pathologies: + self.logger.warning(f"Pathology with key '{pathology.key}' already exists") + return False + + self.pathologies[pathology.key] = pathology + return self.save_pathologies() + + def update_pathology(self, key: str, pathology: Pathology) -> bool: + """Update an existing pathology.""" + if key not in self.pathologies: + self.logger.warning(f"Pathology with key '{key}' does not exist") + return False + + # If key is changing, remove old entry + if key != pathology.key: + del self.pathologies[key] + + self.pathologies[pathology.key] = pathology + return self.save_pathologies() + + def remove_pathology(self, key: str) -> bool: + """Remove a pathology.""" + if key not in self.pathologies: + self.logger.warning(f"Pathology with key '{key}' does not exist") + return False + + del self.pathologies[key] + return self.save_pathologies() + + def get_pathology_keys(self) -> list[str]: + """Get list of all pathology keys.""" + return list(self.pathologies.keys()) + + def get_display_names(self) -> dict[str, str]: + """Get mapping of keys to display names.""" + return {key: path.display_name for key, path in self.pathologies.items()} + + def get_graph_colors(self) -> dict[str, str]: + """Get mapping of pathology keys to graph colors.""" + return {key: path.color for key, path in self.pathologies.items()} + + def get_default_enabled_pathologies(self) -> list[str]: + """Get list of pathologies that should be enabled by default in graphs.""" + return [key for key, path in self.pathologies.items() if path.default_enabled] + + def get_pathology_vars_dict(self) -> dict[str, tuple[Any, str]]: + """Get pathology variables dictionary for UI compatibility.""" + # This maintains compatibility with existing UI code + import tkinter as tk + + return { + key: (tk.IntVar(value=0), path.display_name) + for key, path in self.pathologies.items() + } + + def get_scale_info(self, key: str) -> tuple[int, int, str, str]: + """Get scale information for a pathology.""" + pathology = self.get_pathology(key) + if pathology: + return ( + pathology.scale_min, + pathology.scale_max, + pathology.scale_info, + pathology.scale_orientation, + ) + return (0, 10, "0-10", "normal") diff --git a/src/thechart/search/__init__.py b/src/thechart/search/__init__.py new file mode 100644 index 0000000..ff2623d --- /dev/null +++ b/src/thechart/search/__init__.py @@ -0,0 +1,13 @@ +"""Search and filtering utilities for TheChart. + +Public API: +- DataFilter: core filtering logic over DataFrames +- QuickFilters: convenience presets +- SearchHistory: recent search terms manager +""" + +from __future__ import annotations + +from .search_filter import DataFilter, QuickFilters, SearchHistory # noqa: F401 + +__all__ = ["DataFilter", "QuickFilters", "SearchHistory"] diff --git a/src/thechart/search/search_filter.py b/src/thechart/search/search_filter.py new file mode 100644 index 0000000..fc5a4cb --- /dev/null +++ b/src/thechart/search/search_filter.py @@ -0,0 +1,423 @@ +"""Search and filter functionality for TheChart application (canonical). + +This module implements the data filtering logic and related helpers. +""" + +from __future__ import annotations + +import re +from typing import Any + +import pandas as pd + + +class DataFilter: + """Handles filtering and searching of medical data.""" + + def __init__(self, logger=None): + """ + Initialize data filter. + + Args: + logger: Logger instance for debugging + """ + self.logger = logger + self.active_filters: dict[str, Any] = {} + self.search_term = "" + + def set_date_range_filter( + self, start_date: str | None = None, end_date: str | None = None + ) -> None: + """ + Set date range filter. + + Args: + start_date: Start date string (inclusive) + end_date: End date string (inclusive) + """ + if start_date or end_date: + self.active_filters["date_range"] = {"start": start_date, "end": end_date} + elif "date_range" in self.active_filters: + del self.active_filters["date_range"] + + def set_medicine_filter(self, medicine_key: str, taken: bool) -> None: + """ + Filter by medicine taken status. + + Args: + medicine_key: Medicine identifier + taken: Whether medicine was taken (True) or not taken (False) + """ + if "medicines" not in self.active_filters: + self.active_filters["medicines"] = {} + + self.active_filters["medicines"][medicine_key] = taken + + def set_pathology_range_filter( + self, + pathology_key: str, + min_score: int | None = None, + max_score: int | None = None, + ) -> None: + """ + Filter by pathology score range. + + Args: + pathology_key: Pathology identifier + min_score: Minimum score (inclusive) + max_score: Maximum score (inclusive) + """ + if min_score is not None or max_score is not None: + if "pathologies" not in self.active_filters: + self.active_filters["pathologies"] = {} + + self.active_filters["pathologies"][pathology_key] = { + "min": min_score, + "max": max_score, + } + + def set_search_term(self, search_term: str) -> None: + """ + Set text search term for notes and other text fields. + + Args: + search_term: Text to search for + """ + self.search_term = search_term.strip() + + def clear_all_filters(self) -> None: + """Clear all active filters and search terms.""" + self.active_filters.clear() + self.search_term = "" + + def clear_filter(self, filter_type: str, filter_key: str | None = None) -> None: + """ + Clear specific filter. + + Args: + filter_type: Type of filter ("date_range", "medicines", "pathologies") + filter_key: Specific key within filter type (optional) + """ + if filter_type in self.active_filters: + if filter_key and isinstance(self.active_filters[filter_type], dict): + if filter_key in self.active_filters[filter_type]: + del self.active_filters[filter_type][filter_key] + # Remove parent filter if empty + if not self.active_filters[filter_type]: + del self.active_filters[filter_type] + else: + del self.active_filters[filter_type] + + def apply_filters(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Apply all active filters to the dataframe. + + Args: + df: Input dataframe + + Returns: + Filtered dataframe + """ + if df.empty: + return df + + filtered_df = df.copy() + + try: + # Apply date range filter + filtered_df = self._apply_date_filter(filtered_df) + + # Apply medicine filters + filtered_df = self._apply_medicine_filters(filtered_df) + + # Apply pathology filters + filtered_df = self._apply_pathology_filters(filtered_df) + + # Apply text search + filtered_df = self._apply_text_search(filtered_df) + + if self.logger: + original_count = len(df) + filtered_count = len(filtered_df) + self.logger.debug( + f"Applied filters: {original_count} -> {filtered_count} entries" + ) + + return filtered_df + + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.error(f"Error applying filters: {e}") + return df # Return original data if filtering fails + + def _apply_date_filter(self, df: pd.DataFrame) -> pd.DataFrame: + """Apply date range filter.""" + if "date_range" not in self.active_filters: + return df + + date_filter = self.active_filters["date_range"] + start_date = date_filter.get("start") + end_date = date_filter.get("end") + + if not start_date and not end_date: + return df + + # Support both legacy lowercase 'date' and capitalized 'Date' + date_col = ( + "date" if "date" in df.columns else "Date" if "Date" in df.columns else None + ) + if not date_col: + return df + + try: + # Convert date column to datetime – attempt multiple formats safely + df_dates = pd.to_datetime(df[date_col], errors="coerce") + + mask = pd.Series(True, index=df.index) + + if start_date: + mask &= df_dates >= pd.to_datetime(start_date, errors="coerce") + if end_date: + mask &= df_dates <= pd.to_datetime(end_date, errors="coerce") + + return df[mask] + except Exception as e: # pragma: no cover - defensive + if self.logger: + self.logger.warning(f"Date filter failed: {e}") + return df + + def _apply_medicine_filters(self, df: pd.DataFrame) -> pd.DataFrame: + """Apply medicine filters.""" + if "medicines" not in self.active_filters: + return df + + medicine_filters = self.active_filters["medicines"] + mask = pd.Series(True, index=df.index) + + for medicine_key, should_be_taken in medicine_filters.items(): + if medicine_key in df.columns: + col = df[medicine_key] + # Heuristic: + # - If object dtype and values look like time:dose strings, + # use string presence + # - Else if numeric (or numeric-like), use non-zero for taken, + # zero for not taken + # - Else fallback to string presence + if col.dtype == object: + s = col.astype(str) + looks_time_dose = s.str.contains( + r":|\|", regex=True, na=False + ).any() + if looks_time_dose: + if should_be_taken: + mask &= s.str.len() > 0 + else: + mask &= s.str.len() == 0 + continue + # Try numeric-like strings + numeric = pd.to_numeric(col, errors="coerce") + if numeric.notna().any(): + if should_be_taken: + mask &= numeric.fillna(0) != 0 + else: + mask &= numeric.fillna(0) == 0 + else: + if should_be_taken: + mask &= s.str.len() > 0 + else: + mask &= s.str.len() == 0 + else: + # Numeric dtype + if should_be_taken: + mask &= col.fillna(0) != 0 + else: + mask &= col.fillna(0) == 0 + + return df[mask] + + def _apply_pathology_filters(self, df: pd.DataFrame) -> pd.DataFrame: + """Apply pathology score range filters.""" + if "pathologies" not in self.active_filters: + return df + + pathology_filters = self.active_filters["pathologies"] + mask = pd.Series(True, index=df.index) + + for pathology_key, score_range in pathology_filters.items(): + if pathology_key in df.columns: + # Coerce to numeric; non-numeric -> NaN (excluded by comparisons) + col = pd.to_numeric(df[pathology_key], errors="coerce") + min_score = score_range.get("min") + max_score = score_range.get("max") + if min_score is not None: + mask &= col >= min_score + if max_score is not None: + mask &= col <= max_score + + return df[mask] + + def _apply_text_search(self, df: pd.DataFrame) -> pd.DataFrame: + """Apply text search to notes and other text fields.""" + if not self.search_term: + return df + + # Create regex pattern for case-insensitive search + try: + pattern = re.compile(re.escape(self.search_term), re.IGNORECASE) + except re.error: # pragma: no cover - defensive + pattern = self.search_term.lower() + + mask = pd.Series(False, index=df.index) + + # Support both Notes/note and Date/date columns + note_cols = [c for c in ("Notes", "Note", "note", "notes") if c in df.columns] + date_cols = [c for c in ("Date", "date") if c in df.columns] + + for col in note_cols + date_cols: + if isinstance(pattern, re.Pattern): + mask |= df[col].astype(str).str.contains(pattern, na=False) + else: + mask |= df[col].astype(str).str.lower().str.contains(pattern, na=False) + + return df[mask] + + def get_filter_summary(self) -> dict[str, Any]: + """ + Get summary of active filters. + + Returns: + Dictionary describing active filters + """ + summary = { + "has_filters": bool(self.active_filters or self.search_term), + "filter_count": len(self.active_filters), + "search_term": self.search_term, + "filters": {}, + } + + # Date range summary + if "date_range" in self.active_filters: + date_range = self.active_filters["date_range"] + summary["filters"]["date_range"] = { + "start": date_range.get("start", "Any"), + "end": date_range.get("end", "Any"), + } + + # Medicine filters summary + if "medicines" in self.active_filters: + medicine_filters = self.active_filters["medicines"] + summary["filters"]["medicines"] = { + "taken": [k for k, v in medicine_filters.items() if v], + "not_taken": [k for k, v in medicine_filters.items() if not v], + } + + # Pathology filters summary + if "pathologies" in self.active_filters: + pathology_filters = self.active_filters["pathologies"] + summary["filters"]["pathologies"] = {} + for key, range_filter in pathology_filters.items(): + min_val = range_filter.get("min", "Any") + max_val = range_filter.get("max", "Any") + summary["filters"]["pathologies"][key] = f"{min_val} - {max_val}" + + return summary + + +class QuickFilters: + """Predefined quick filters mirroring test expectations.""" + + @staticmethod + def last_week(data_filter: DataFilter) -> None: + from datetime import datetime, timedelta + + end_date = datetime.now().date() + start_date = end_date - timedelta(days=6) # inclusive 7 days + data_filter.set_date_range_filter(str(start_date), str(end_date)) + + @staticmethod + def last_month(data_filter: DataFilter) -> None: + from datetime import datetime, timedelta + + end_date = datetime.now().date() + start_date = end_date - timedelta(days=29) # inclusive 30 days + data_filter.set_date_range_filter(str(start_date), str(end_date)) + + @staticmethod + def this_month(data_filter: DataFilter) -> None: + from datetime import datetime + + now = datetime.now().date() + start_date = now.replace(day=1) + data_filter.set_date_range_filter(str(start_date), str(now)) + + @staticmethod + def high_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None: + for pathology_key in pathology_keys: + data_filter.set_pathology_range_filter(pathology_key, min_score=8) + + @staticmethod + def low_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None: + for pathology_key in pathology_keys: + data_filter.set_pathology_range_filter(pathology_key, max_score=3) + + @staticmethod + def no_medication(data_filter: DataFilter, medicine_keys: list[str]) -> None: + for medicine_key in medicine_keys: + data_filter.set_medicine_filter(medicine_key, taken=False) + + +class SearchHistory: + """Manages search history (tests assume <=15 retained).""" + + def __init__(self, max_history: int = 15): + self.max_history = max_history + self.history: list[str] = [] + + def add_search(self, search_term: str) -> None: + """ + Add a search term to history. + + Args: + search_term: Search term to add + """ + search_term = search_term.strip() + if not search_term: + return + + # Remove if already exists + if search_term in self.history: + self.history.remove(search_term) + + # Add to beginning + self.history.insert(0, search_term) + + # Trim to max size + if len(self.history) > self.max_history: + self.history = self.history[: self.max_history] + + def get_history(self) -> list[str]: + """Get search history.""" + return self.history.copy() + + def clear_history(self) -> None: + """Clear all search history.""" + self.history.clear() + + # Small helper used by tests for UI suggestions + def get_suggestions(self, partial_term: str) -> list[str]: + """Return up to 5 recent searches starting with the given prefix. + + Case-insensitive prefix match against the stored history, preserving + recency order. + """ + if not partial_term: + return self.history[:5] + + pfx = partial_term.lower() + out: list[str] = [] + for term in self.history: + if term.lower().startswith(pfx): + out.append(term) + if len(out) >= 5: + break + return out diff --git a/src/thechart/ui/__init__.py b/src/thechart/ui/__init__.py new file mode 100644 index 0000000..6e46b79 --- /dev/null +++ b/src/thechart/ui/__init__.py @@ -0,0 +1,27 @@ +"""UI layer re-exports for TheChart. + +Canonical UI utilities live here. Windows are provided canonically as well. +""" + +from __future__ import annotations +# ruff: noqa: I001 + +from .search_filter_ui import SearchFilterWidget # noqa: F401 +from .theme_manager import ThemeManager # noqa: F401 +from .tooltip_system import ToolTip, TooltipManager # noqa: F401 +from .ui_manager import UIManager # noqa: F401 + +# Window proxies (import-all for backward compatibility with existing names) +from .export_window import * # noqa: F401,F403 +from .medicine_management_window import * # noqa: F401,F403 +from .pathology_management_window import * # noqa: F401,F403 +from .settings_window import * # noqa: F401,F403 + +__all__ = [ + "SearchFilterWidget", + # window proxies + "ThemeManager", + "UIManager", + "ToolTip", + "TooltipManager", +] diff --git a/src/thechart/ui/export_window.py b/src/thechart/ui/export_window.py new file mode 100644 index 0000000..2f2ffb9 --- /dev/null +++ b/src/thechart/ui/export_window.py @@ -0,0 +1,214 @@ +"""Export Window (canonical UI implementation).""" + +from __future__ import annotations + +import contextlib +import tkinter as tk +from collections.abc import Callable +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from thechart.export import ExportManager + + +class ExportWindow: + """Export window for data and graph export functionality.""" + + def __init__( + self, + parent: tk.Tk, + export_manager: ExportManager, + get_current_filtered_df: Callable[[], object] | None = None, + ) -> None: + self.parent = parent + self.export_manager = export_manager + self._get_current_filtered_df = get_current_filtered_df + + # Create the export window + self.window = tk.Toplevel(parent) + self.window.title("Export Data") + self.window.geometry("500x450") # Taller to ensure buttons visible + self.window.resizable(False, False) + + # Center the window + self._center_window() + + # Make window modal + self.window.transient(parent) + self.window.grab_set() + + # Setup the UI + self._setup_ui() + + def _center_window(self) -> None: + """Center the export window on the parent window.""" + self.window.update_idletasks() + width = self.window.winfo_width() + height = self.window.winfo_height() + parent_x = self.parent.winfo_rootx() + parent_y = self.parent.winfo_rooty() + parent_width = self.parent.winfo_width() + parent_height = self.parent.winfo_height() + x = parent_x + (parent_width // 2) - (width // 2) + y = parent_y + (parent_height // 2) - (height // 2) + self.window.geometry(f"{width}x{height}+{x}+{y}") + + def _setup_ui(self) -> None: + """Setup the export window UI.""" + main_frame = ttk.Frame(self.window, padding="15") + main_frame.pack(fill=tk.BOTH, expand=True) + title_label = ttk.Label( + main_frame, text="Export Data & Graphs", font=("Arial", 14, "bold") + ) + title_label.pack(pady=(0, 15)) + content_frame = ttk.Frame(main_frame) + content_frame.pack(fill=tk.BOTH, expand=True) + self._create_info_section(content_frame) + self._create_options_section(content_frame) + self._create_buttons_section(main_frame) + + def _create_info_section(self, parent: ttk.Frame) -> None: + info_frame = ttk.LabelFrame(parent, text="Data Summary", padding="10") + info_frame.pack(fill=tk.X, pady=(0, 20)) + export_info = self.export_manager.get_export_info() + if export_info["has_data"]: + info_text = ( + f"Total Entries: {export_info['total_entries']}\n" + f"Date Range: {export_info['date_range']['start']} to " + f"{export_info['date_range']['end']}\n" + f"Pathologies: {', '.join(export_info['pathologies'])}\n" + f"Medicines: {', '.join(export_info['medicines'])}" + ) + else: + info_text = "No data available for export." + info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT) + info_label.pack(anchor=tk.W) + + def _create_options_section(self, parent: ttk.Frame) -> None: + options_frame = ttk.LabelFrame(parent, text="Export Options", padding="10") + options_frame.pack(fill=tk.X, pady=(0, 20)) + self.include_graph_var = tk.BooleanVar(value=True) + graph_check = ttk.Checkbutton( + options_frame, + text="Include graph in PDF export", + variable=self.include_graph_var, + ) + graph_check.pack(anchor=tk.W, pady=(0, 10)) + self.scope_var = tk.StringVar(value="all") + scope_frame = ttk.Frame(options_frame) + scope_frame.pack(fill=tk.X, pady=(0, 10)) + ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT) + ttk.Radiobutton( + scope_frame, text="All data", variable=self.scope_var, value="all" + ).pack(side=tk.LEFT, padx=10) + ttk.Radiobutton( + scope_frame, + text="Current (filtered) view", + variable=self.scope_var, + value="filtered", + ).pack(side=tk.LEFT) + ttk.Label(options_frame, text="Export Format:").pack(anchor=tk.W) + self.format_var = tk.StringVar(value="JSON") + for fmt in ("JSON", "XML", "PDF"): + ttk.Radiobutton( + options_frame, text=fmt, variable=self.format_var, value=fmt + ).pack(anchor=tk.W, padx=(20, 0)) + + def _create_buttons_section(self, parent: ttk.Frame) -> None: + ttk.Separator(parent, orient="horizontal").pack(fill=tk.X, pady=(10, 10)) + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, pady=(0, 10)) + ttk.Button(button_frame, text="Export...", command=self._handle_export).pack( + side=tk.LEFT, padx=(10, 10), pady=5 + ) + ttk.Button(button_frame, text="Cancel", command=self.window.destroy).pack( + side=tk.RIGHT, padx=(10, 10), pady=5 + ) + + def _handle_export(self) -> None: + export_info = self.export_manager.get_export_info() + if not export_info["has_data"]: + messagebox.showwarning( + "No Data", "There is no data available to export.", parent=self.window + ) + return + selected_format = self.format_var.get() + file_types = { + "JSON": [("JSON files", "*.json"), ("All files", "*.*")], + "XML": [("XML files", "*.xml"), ("All files", "*.*")], + "PDF": [("PDF files", "*.pdf"), ("All files", "*.*")], + } + default_name = f"thechart_export.{selected_format.lower()}" + filename = filedialog.asksaveasfilename( + parent=self.window, + title=f"Export as {selected_format}", + defaultextension=f".{selected_format.lower()}", + filetypes=file_types[selected_format], + initialfile=default_name, + ) + if not filename: + return + scoped_df = None + if self.scope_var.get() == "filtered" and self._get_current_filtered_df: + with contextlib.suppress(Exception): + scoped_df = self._get_current_filtered_df() + success = False + try: + if selected_format == "JSON": + success = self.export_manager.export_data_to_json( + filename, df=scoped_df + ) + elif selected_format == "XML": + success = self.export_manager.export_data_to_xml(filename, df=scoped_df) + elif selected_format == "PDF": + include_graph = self.include_graph_var.get() + success = self.export_manager.export_to_pdf( + filename, include_graph=include_graph, df=scoped_df + ) + if success: + messagebox.showinfo( + "Export Successful", + f"Data exported successfully to:\n{filename}", + parent=self.window, + ) + if messagebox.askyesno( + "Open Location", + "Would you like to open the file location?", + parent=self.window, + ): + self._open_file_location(filename) + self.window.destroy() + else: + messagebox.showerror( + "Export Failed", + ( + f"Failed to export data as {selected_format}. " + "Please check the logs for more details." + ), + parent=self.window, + ) + except Exception as e: # pragma: no cover - defensive UX + messagebox.showerror( + "Export Error", + f"An error occurred during export:\n{str(e)}", + parent=self.window, + ) + + def _open_file_location(self, filepath: str) -> None: + try: + file_path = Path(filepath) + directory = file_path.parent + import subprocess + import sys + + if sys.platform == "win32": + subprocess.run(["explorer", str(directory)], check=False) + elif sys.platform == "darwin": + subprocess.run(["open", str(directory)], check=False) + else: + subprocess.run(["xdg-open", str(directory)], check=False) + except Exception: + pass + + +__all__ = ["ExportWindow"] diff --git a/src/thechart/ui/medicine_management_window.py b/src/thechart/ui/medicine_management_window.py new file mode 100644 index 0000000..25851d8 --- /dev/null +++ b/src/thechart/ui/medicine_management_window.py @@ -0,0 +1,397 @@ +"""Medicine management window (canonical).""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk + +from thechart.managers import Medicine, MedicineManager + + +class MedicineManagementWindow: + """Window for managing medicine configurations.""" + + def __init__( + self, parent: tk.Tk, medicine_manager: MedicineManager, refresh_callback + ): + self.parent = parent + self.medicine_manager = medicine_manager + self.refresh_callback = refresh_callback + + # Create the window + self.window = tk.Toplevel(parent) + self.window.title("Manage Medicines") + self.window.geometry("600x500") + self.window.resizable(True, True) + + # Make window modal + self.window.transient(parent) + self.window.grab_set() + + self._setup_ui() + self._populate_medicine_list() + + # Center window + self.window.update_idletasks() + x = (self.window.winfo_screenwidth() // 2) - (600 // 2) + y = (self.window.winfo_screenheight() // 2) - (500 // 2) + self.window.geometry(f"600x500+{x}+{y}") + + def _setup_ui(self): + """Set up the user interface.""" + main_frame = ttk.Frame(self.window, padding="10") + main_frame.grid(row=0, column=0, sticky="nsew") + + self.window.grid_rowconfigure(0, weight=1) + self.window.grid_columnconfigure(0, weight=1) + main_frame.grid_rowconfigure(1, weight=1) + main_frame.grid_columnconfigure(0, weight=1) + + # Title + title_label = ttk.Label( + main_frame, text="Medicine Management", font=("Arial", 14, "bold") + ) + title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10)) + + # Medicine list + list_frame = ttk.LabelFrame(main_frame, text="Current Medicines") + list_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(0, 10)) + list_frame.grid_rowconfigure(0, weight=1) + list_frame.grid_columnconfigure(0, weight=1) + + # Treeview for medicines + columns = ("key", "name", "dosage", "quick_doses", "color", "default") + self.tree = ttk.Treeview(list_frame, columns=columns, show="headings") + + # Column headings + self.tree.heading("key", text="Key") + self.tree.heading("name", text="Name") + self.tree.heading("dosage", text="Dosage Info") + self.tree.heading("quick_doses", text="Quick Doses") + self.tree.heading("color", text="Color") + self.tree.heading("default", text="Default Enabled") + + # Column widths + self.tree.column("key", width=80) + self.tree.column("name", width=100) + self.tree.column("dosage", width=100) + self.tree.column("quick_doses", width=120) + self.tree.column("color", width=70) + self.tree.column("default", width=100) + + self.tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # Scrollbar for treeview + scrollbar = ttk.Scrollbar( + list_frame, orient="vertical", command=self.tree.yview + ) + scrollbar.grid(row=0, column=1, sticky="ns") + self.tree.configure(yscrollcommand=scrollbar.set) + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0)) + + ttk.Button(button_frame, text="Add Medicine", command=self._add_medicine).grid( + row=0, column=0, padx=(0, 5) + ) + + ttk.Button( + button_frame, text="Edit Medicine", command=self._edit_medicine + ).grid(row=0, column=1, padx=5) + + ttk.Button( + button_frame, text="Remove Medicine", command=self._remove_medicine + ).grid(row=0, column=2, padx=5) + + ttk.Button(button_frame, text="Close", command=self._close_window).grid( + row=0, column=3, padx=(5, 0) + ) + + def _populate_medicine_list(self): + """Populate the medicine list.""" + for item in self.tree.get_children(): + self.tree.delete(item) + for medicine in self.medicine_manager.get_all_medicines().values(): + self.tree.insert( + "", + "end", + values=( + medicine.key, + medicine.display_name, + medicine.dosage_info, + ", ".join(medicine.quick_doses), + medicine.color, + "Yes" if medicine.default_enabled else "No", + ), + ) + + def _add_medicine(self): + """Add a new medicine.""" + MedicineEditDialog( + self.window, self.medicine_manager, None, self._on_medicine_changed + ) + + def _edit_medicine(self): + """Edit selected medicine.""" + selection = self.tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a medicine to edit.") + return + item = self.tree.item(selection[0]) + medicine_key = item["values"][0] + medicine = self.medicine_manager.get_medicine(medicine_key) + if medicine: + MedicineEditDialog( + self.window, self.medicine_manager, medicine, self._on_medicine_changed + ) + + def _remove_medicine(self): + """Remove selected medicine.""" + selection = self.tree.selection() + if not selection: + messagebox.showwarning( + "No Selection", "Please select a medicine to remove." + ) + return + item = self.tree.item(selection[0]) + medicine_key = item["values"][0] + medicine_name = item["values"][1] + if messagebox.askyesno( + "Confirm Removal", + ( + f"Are you sure you want to remove '{medicine_name}'?\n\n" + "This will also remove all associated data from your records!" + ), + ): + if self.medicine_manager.remove_medicine(medicine_key): + messagebox.showinfo( + "Success", f"'{medicine_name}' removed successfully!" + ) + self._populate_medicine_list() + self._refresh_main_app() + else: + messagebox.showerror("Error", f"Failed to remove '{medicine_name}'.") + + def _on_medicine_changed(self): + """Called when a medicine is added or edited.""" + self._populate_medicine_list() + self._refresh_main_app() + + def _refresh_main_app(self): + """Refresh the main application after medicine changes.""" + if self.refresh_callback: + self.refresh_callback() + + def _close_window(self): + """Close the window.""" + self.window.destroy() + + +class MedicineEditDialog: + """Dialog for adding/editing a medicine.""" + + def __init__( + self, + parent: tk.Toplevel, + medicine_manager: MedicineManager, + medicine: Medicine | None, + callback, + ): + self.parent = parent + self.medicine_manager = medicine_manager + self.medicine = medicine + self.callback = callback + self.is_edit = medicine is not None + + # Create dialog + self.dialog = tk.Toplevel(parent) + self.dialog.title("Edit Medicine" if self.is_edit else "Add Medicine") + self.dialog.geometry("400x350") + self.dialog.resizable(False, False) + + # Make modal + self.dialog.transient(parent) + self.dialog.grab_set() + + self._setup_dialog() + self._populate_fields() + + # Center dialog + self.dialog.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() // 2) - (400 // 2) + y = parent.winfo_y() + (parent.winfo_height() // 2) - (350 // 2) + self.dialog.geometry(f"400x350+{x}+{y}") + + def _setup_dialog(self): + """Set up the dialog UI.""" + main_frame = ttk.Frame(self.dialog, padding="15") + main_frame.grid(row=0, column=0, sticky="nsew") + + self.dialog.grid_rowconfigure(0, weight=1) + self.dialog.grid_columnconfigure(0, weight=1) + + # Fields + fields_frame = ttk.Frame(main_frame) + fields_frame.grid(row=0, column=0, sticky="ew", pady=(0, 15)) + fields_frame.grid_columnconfigure(1, weight=1) + + row = 0 + + # Key + ttk.Label(fields_frame, text="Key:").grid(row=row, column=0, sticky="w", pady=5) + self.key_var = tk.StringVar() + key_entry = ttk.Entry(fields_frame, textvariable=self.key_var) + key_entry.grid(row=row, column=1, sticky="ew", padx=(10, 0), pady=5) + if self.is_edit: + key_entry.configure(state="readonly") + row += 1 + + # Display Name + ttk.Label(fields_frame, text="Display Name:").grid( + row=row, column=0, sticky="w", pady=5 + ) + self.name_var = tk.StringVar() + ttk.Entry(fields_frame, textvariable=self.name_var).grid( + row=row, column=1, sticky="ew", padx=(10, 0), pady=5 + ) + row += 1 + + # Dosage Info + ttk.Label(fields_frame, text="Dosage Info:").grid( + row=row, column=0, sticky="w", pady=5 + ) + self.dosage_var = tk.StringVar() + ttk.Entry(fields_frame, textvariable=self.dosage_var).grid( + row=row, column=1, sticky="ew", padx=(10, 0), pady=5 + ) + row += 1 + + # Quick Doses + ttk.Label(fields_frame, text="Quick Doses:").grid( + row=row, column=0, sticky="w", pady=5 + ) + self.doses_var = tk.StringVar() + ttk.Entry(fields_frame, textvariable=self.doses_var).grid( + row=row, column=1, sticky="ew", padx=(10, 0), pady=5 + ) + ttk.Label( + fields_frame, text="(comma-separated, e.g. 25,50,100)", font=("Arial", 8) + ).grid(row=row + 1, column=1, sticky="w", padx=(10, 0)) + row += 2 + + # Color + ttk.Label(fields_frame, text="Graph Color:").grid( + row=row, column=0, sticky="w", pady=5 + ) + self.color_var = tk.StringVar() + ttk.Entry(fields_frame, textvariable=self.color_var).grid( + row=row, column=1, sticky="ew", padx=(10, 0), pady=5 + ) + ttk.Label( + fields_frame, text="(hex color, e.g. #FF6B6B)", font=("Arial", 8) + ).grid(row=row + 1, column=1, sticky="w", padx=(10, 0)) + row += 2 + + # Default Enabled + self.default_var = tk.BooleanVar() + ttk.Checkbutton( + fields_frame, + text="Show in graph by default", + variable=self.default_var, + ).grid(row=row, column=0, columnspan=2, sticky="w", pady=5) + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=1, column=0) + + ttk.Button(button_frame, text="Save", command=self._save_medicine).grid( + row=0, column=0, padx=(0, 10) + ) + + ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).grid( + row=0, column=1 + ) + + def _populate_fields(self): + """Populate fields if editing.""" + if self.medicine: + self.key_var.set(self.medicine.key) + self.name_var.set(self.medicine.display_name) + self.dosage_var.set(self.medicine.dosage_info) + self.doses_var.set(",".join(self.medicine.quick_doses)) + self.color_var.set(self.medicine.color) + self.default_var.set(self.medicine.default_enabled) + + def _save_medicine(self): + """Save the medicine.""" + key = self.key_var.get().strip() + name = self.name_var.get().strip() + dosage = self.dosage_var.get().strip() + doses_str = self.doses_var.get().strip() + color = self.color_var.get().strip() + + if not all([key, name, dosage, doses_str, color]): + messagebox.showerror("Error", "All fields are required.") + return + + # Validate key format (alphanumeric and underscores/hyphens only) + if not key.replace("_", "").replace("-", "").isalnum(): + messagebox.showerror( + "Error", + "Key must contain only letters, numbers, underscores, and hyphens.", + ) + return + + # Parse quick doses + try: + quick_doses = [dose.strip() for dose in doses_str.split(",")] + quick_doses = [dose for dose in quick_doses if dose] + if not quick_doses: + raise ValueError("At least one quick dose is required.") + except Exception: + messagebox.showerror("Error", "Quick doses must be comma-separated values.") + return + + # Validate color format + if not color.startswith("#") or len(color) != 7: + messagebox.showerror( + "Error", "Color must be in hex format (e.g., #FF6B6B)." + ) + return + try: + int(color[1:], 16) + except ValueError: + messagebox.showerror("Error", "Invalid hex color format.") + return + + # Create medicine object + new_medicine = Medicine( + key=key, + display_name=name, + dosage_info=dosage, + quick_doses=quick_doses, + color=color, + default_enabled=self.default_var.get(), + ) + + # Save medicine + success = False + if self.is_edit: + success = self.medicine_manager.update_medicine( + self.medicine.key, new_medicine + ) + else: + success = self.medicine_manager.add_medicine(new_medicine) + + if success: + action = "updated" if self.is_edit else "added" + messagebox.showinfo("Success", f"Medicine {action} successfully!") + self.callback() + self.dialog.destroy() + else: + action = "update" if self.is_edit else "add" + messagebox.showerror("Error", f"Failed to {action} medicine.") + + +__all__ = ["MedicineManagementWindow", "MedicineEditDialog"] diff --git a/src/thechart/ui/pathology_management_window.py b/src/thechart/ui/pathology_management_window.py new file mode 100644 index 0000000..c353c2d --- /dev/null +++ b/src/thechart/ui/pathology_management_window.py @@ -0,0 +1,428 @@ +"""Pathology management window (canonical).""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk + +from thechart.managers import Pathology, PathologyManager + + +class PathologyManagementWindow: + """Window for managing pathology configurations.""" + + def __init__( + self, parent: tk.Tk, pathology_manager: PathologyManager, refresh_callback + ): + self.parent = parent + self.pathology_manager = pathology_manager + self.refresh_callback = refresh_callback + + # Create the window + self.window = tk.Toplevel(parent) + self.window.title("Manage Pathologies") + self.window.geometry("800x500") + self.window.resizable(True, True) + + # Make window modal + self.window.transient(parent) + self.window.grab_set() + + self._setup_ui() + self._populate_pathology_list() + + # Center window + self.window.update_idletasks() + x = (self.window.winfo_screenwidth() // 2) - (800 // 2) + y = (self.window.winfo_screenheight() // 2) - (500 // 2) + self.window.geometry(f"800x500+{x}+{y}") + + def _setup_ui(self): + """Set up the UI components.""" + # Main frame + main_frame = ttk.Frame(self.window, padding="10") + main_frame.grid(row=0, column=0, sticky="nsew") + self.window.grid_rowconfigure(0, weight=1) + self.window.grid_columnconfigure(0, weight=1) + + # Pathology list + list_frame = ttk.LabelFrame(main_frame, text="Pathologies", padding="5") + list_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) + main_frame.grid_rowconfigure(0, weight=1) + main_frame.grid_columnconfigure(0, weight=1) + + # Treeview for pathology list + columns = ( + "Key", + "Display Name", + "Scale Info", + "Color", + "Default Enabled", + "Scale Range", + ) + self.tree = ttk.Treeview(list_frame, columns=columns, show="headings") + + # Configure columns + self.tree.heading("Key", text="Key") + self.tree.heading("Display Name", text="Display Name") + self.tree.heading("Scale Info", text="Scale Info") + self.tree.heading("Color", text="Color") + self.tree.heading("Default Enabled", text="Default Enabled") + self.tree.heading("Scale Range", text="Scale Range") + + self.tree.column("Key", width=120) + self.tree.column("Display Name", width=150) + self.tree.column("Scale Info", width=150) + self.tree.column("Color", width=80) + self.tree.column("Default Enabled", width=100) + self.tree.column("Scale Range", width=100) + + # Scrollbar for treeview + scrollbar = ttk.Scrollbar( + list_frame, orient="vertical", command=self.tree.yview + ) + self.tree.configure(yscrollcommand=scrollbar.set) + + self.tree.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + + list_frame.grid_rowconfigure(0, weight=1) + list_frame.grid_columnconfigure(0, weight=1) + + # Buttons frame + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=1, column=0, sticky="ew") + + ttk.Button( + button_frame, text="Add Pathology", command=self._add_pathology + ).pack(side="left", padx=(0, 5)) + ttk.Button( + button_frame, text="Edit Pathology", command=self._edit_pathology + ).pack(side="left", padx=(0, 5)) + ttk.Button( + button_frame, text="Remove Pathology", command=self._remove_pathology + ).pack(side="left", padx=(0, 5)) + ttk.Button(button_frame, text="Close", command=self.window.destroy).pack( + side="right" + ) + + def _populate_pathology_list(self): + """Populate the pathology list.""" + # Clear existing items + for item in self.tree.get_children(): + self.tree.delete(item) + + # Add pathologies + for pathology in self.pathology_manager.get_all_pathologies().values(): + scale_range = f"{pathology.scale_min}-{pathology.scale_max}" + self.tree.insert( + "", + "end", + values=( + pathology.key, + pathology.display_name, + pathology.scale_info, + pathology.color, + "Yes" if pathology.default_enabled else "No", + scale_range, + ), + ) + + def _add_pathology(self): + """Add a new pathology.""" + PathologyEditDialog( + self.window, self.pathology_manager, None, self._on_pathology_changed + ) + + def _edit_pathology(self): + """Edit selected pathology.""" + selection = self.tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a pathology to edit.") + return + + item = self.tree.item(selection[0]) + pathology_key = item["values"][0] + pathology = self.pathology_manager.get_pathology(pathology_key) + + if pathology: + PathologyEditDialog( + self.window, + self.pathology_manager, + pathology, + self._on_pathology_changed, + ) + + def _remove_pathology(self): + """Remove selected pathology.""" + selection = self.tree.selection() + if not selection: + messagebox.showwarning( + "No Selection", "Please select a pathology to remove." + ) + return + + item = self.tree.item(selection[0]) + pathology_key = item["values"][0] + pathology_name = item["values"][1] + + if messagebox.askyesno( + "Confirm Removal", + f"Are you sure you want to remove '{pathology_name}'?\n\n" + "This will also remove all associated data from your records!", + ): + if self.pathology_manager.remove_pathology(pathology_key): + messagebox.showinfo( + "Success", f"'{pathology_name}' removed successfully!" + ) + self._populate_pathology_list() + self._refresh_main_app() + else: + messagebox.showerror("Error", f"Failed to remove '{pathology_name}'.") + + def _on_pathology_changed(self): + """Handle pathology changes.""" + self._populate_pathology_list() + self._refresh_main_app() + + def _refresh_main_app(self): + """Refresh the main application.""" + if self.refresh_callback: + self.refresh_callback() + + +class PathologyEditDialog: + """Dialog for adding/editing a pathology.""" + + def __init__( + self, + parent: tk.Toplevel, + pathology_manager: PathologyManager, + pathology: Pathology | None, + callback, + ): + self.parent = parent + self.pathology_manager = pathology_manager + self.pathology = pathology + self.callback = callback + self.is_edit = pathology is not None + + # Create dialog + self.dialog = tk.Toplevel(parent) + self.dialog.title("Edit Pathology" if self.is_edit else "Add Pathology") + self.dialog.geometry("450x400") + self.dialog.resizable(False, False) + + # Make modal + self.dialog.transient(parent) + self.dialog.grab_set() + + self._setup_dialog() + self._populate_fields() + + # Center dialog + self.dialog.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() // 2) - (450 // 2) + y = parent.winfo_y() + (parent.winfo_height() // 2) - (400 // 2) + self.dialog.geometry(f"450x400+{x}+{y}") + + def _setup_dialog(self): + """Set up the dialog UI.""" + # Main frame + main_frame = ttk.Frame(self.dialog, padding="15") + main_frame.grid(row=0, column=0, sticky="nsew") + self.dialog.grid_rowconfigure(0, weight=1) + self.dialog.grid_columnconfigure(0, weight=1) + + # Form fields + self.key_var = tk.StringVar() + self.name_var = tk.StringVar() + self.scale_info_var = tk.StringVar() + self.color_var = tk.StringVar() + self.default_var = tk.BooleanVar() + self.scale_min_var = tk.IntVar(value=0) + self.scale_max_var = tk.IntVar(value=10) + self.orientation_var = tk.StringVar(value="normal") + + # Key field + ttk.Label(main_frame, text="Key:").grid( + row=0, column=0, sticky="w", pady=(0, 5) + ) + key_entry = ttk.Entry(main_frame, textvariable=self.key_var, width=40) + key_entry.grid(row=0, column=1, sticky="ew", pady=(0, 5)) + ttk.Label(main_frame, text="(alphanumeric, underscores, hyphens only)").grid( + row=0, column=2, sticky="w", padx=(5, 0), pady=(0, 5) + ) + + # Display name field + ttk.Label(main_frame, text="Display Name:").grid( + row=1, column=0, sticky="w", pady=(0, 5) + ) + ttk.Entry(main_frame, textvariable=self.name_var, width=40).grid( + row=1, column=1, sticky="ew", pady=(0, 5) + ) + + # Scale info field + ttk.Label(main_frame, text="Scale Info:").grid( + row=2, column=0, sticky="w", pady=(0, 5) + ) + ttk.Entry(main_frame, textvariable=self.scale_info_var, width=40).grid( + row=2, column=1, sticky="ew", pady=(0, 5) + ) + ttk.Label(main_frame, text='(e.g., "0:good, 10:bad")').grid( + row=2, column=2, sticky="w", padx=(5, 0), pady=(0, 5) + ) + + # Scale range + scale_frame = ttk.Frame(main_frame) + scale_frame.grid(row=3, column=1, sticky="ew", pady=(0, 5)) + + ttk.Label(main_frame, text="Scale Range:").grid( + row=3, column=0, sticky="w", pady=(0, 5) + ) + ttk.Label(scale_frame, text="Min:").grid(row=0, column=0, sticky="w") + ttk.Entry(scale_frame, textvariable=self.scale_min_var, width=5).grid( + row=0, column=1, padx=(5, 10) + ) + ttk.Label(scale_frame, text="Max:").grid(row=0, column=2, sticky="w") + ttk.Entry(scale_frame, textvariable=self.scale_max_var, width=5).grid( + row=0, column=3, padx=5 + ) + + # Scale orientation + ttk.Label(main_frame, text="Scale Orientation:").grid( + row=4, column=0, sticky="w", pady=(0, 5) + ) + orientation_frame = ttk.Frame(main_frame) + orientation_frame.grid(row=4, column=1, sticky="ew", pady=(0, 5)) + + ttk.Radiobutton( + orientation_frame, + text="Normal (0=good)", + variable=self.orientation_var, + value="normal", + ).grid(row=0, column=0, sticky="w") + ttk.Radiobutton( + orientation_frame, + text="Inverted (0=bad)", + variable=self.orientation_var, + value="inverted", + ).grid(row=0, column=1, sticky="w", padx=(20, 0)) + + # Color field + ttk.Label(main_frame, text="Color:").grid( + row=5, column=0, sticky="w", pady=(0, 5) + ) + ttk.Entry(main_frame, textvariable=self.color_var, width=40).grid( + row=5, column=1, sticky="ew", pady=(0, 5) + ) + ttk.Label(main_frame, text="(hex format, e.g., #FF6B6B)").grid( + row=5, column=2, sticky="w", padx=(5, 0), pady=(0, 5) + ) + + # Default enabled checkbox + ttk.Checkbutton( + main_frame, text="Show in graph by default", variable=self.default_var + ).grid(row=6, column=1, sticky="w", pady=(10, 15)) + + # Buttons + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(10, 0)) + + ttk.Button(button_frame, text="Save", command=self._save_pathology).pack( + side="right", padx=(5, 0) + ) + ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack( + side="right" + ) + + # Configure column weights + main_frame.grid_columnconfigure(1, weight=1) + + # Focus on first field + key_entry.focus() + + def _populate_fields(self): + """Populate fields if editing.""" + if self.pathology: + self.key_var.set(self.pathology.key) + self.name_var.set(self.pathology.display_name) + self.scale_info_var.set(self.pathology.scale_info) + self.color_var.set(self.pathology.color) + self.default_var.set(self.pathology.default_enabled) + self.scale_min_var.set(self.pathology.scale_min) + self.scale_max_var.set(self.pathology.scale_max) + self.orientation_var.set(self.pathology.scale_orientation) + + def _save_pathology(self): + """Save the pathology.""" + # Validate fields + key = self.key_var.get().strip() + name = self.name_var.get().strip() + scale_info = self.scale_info_var.get().strip() + color = self.color_var.get().strip() + scale_min = self.scale_min_var.get() + scale_max = self.scale_max_var.get() + + if not all([key, name, scale_info, color]): + messagebox.showerror("Error", "All fields are required.") + return + + # Validate key format (alphanumeric and underscores only) + if not key.replace("_", "").replace("-", "").isalnum(): + messagebox.showerror( + "Error", + "Key must contain only letters, numbers, underscores, and hyphens.", + ) + return + + # Validate scale range + if scale_min >= scale_max: + messagebox.showerror("Error", "Scale minimum must be less than maximum.") + return + + # Validate color format + if not color.startswith("#") or len(color) != 7: + messagebox.showerror( + "Error", "Color must be in hex format (e.g., #FF6B6B)." + ) + return + + try: + int(color[1:], 16) # Validate hex color + except ValueError: + messagebox.showerror("Error", "Invalid hex color format.") + return + + # Create pathology object + new_pathology = Pathology( + key=key, + display_name=name, + scale_info=scale_info, + color=color, + default_enabled=self.default_var.get(), + scale_min=scale_min, + scale_max=scale_max, + scale_orientation=self.orientation_var.get(), + ) + + # Save pathology + success = False + if self.is_edit: + success = self.pathology_manager.update_pathology( + self.pathology.key, new_pathology + ) + else: + success = self.pathology_manager.add_pathology(new_pathology) + + if success: + action = "updated" if self.is_edit else "added" + messagebox.showinfo("Success", f"Pathology {action} successfully!") + self.callback() + self.dialog.destroy() + else: + action = "update" if self.is_edit else "add" + messagebox.showerror("Error", f"Failed to {action} pathology.") + + +__all__ = ["PathologyManagementWindow", "PathologyEditDialog"] diff --git a/src/thechart/ui/search_filter_ui.py b/src/thechart/ui/search_filter_ui.py new file mode 100644 index 0000000..3899357 --- /dev/null +++ b/src/thechart/ui/search_filter_ui.py @@ -0,0 +1,345 @@ +"""Search and filter UI components for TheChart (canonical). + +This mirrors the existing src/search_filter_ui.SearchFilterWidget implementation, +kept here to enable `from thechart.ui import SearchFilterWidget`. +""" + +from __future__ import annotations + +import contextlib +import tkinter as tk +from collections.abc import Callable +from tkinter import ttk + +from ..search import DataFilter, QuickFilters, SearchHistory + + +class SearchFilterWidget: + """Widget providing search and filter UI controls.""" + + def __init__( + self, + parent: tk.Widget, + data_filter: DataFilter, + update_callback: Callable, + medicine_manager, + pathology_manager, + logger=None, + ): + """Initialize search and filter widget.""" + self.parent = parent + self.data_filter = data_filter + self.update_callback = update_callback + self.medicine_manager = medicine_manager + self.pathology_manager = pathology_manager + self.logger = logger + + # Visibility and UI init state + self.is_visible = False + self._ui_initialized = False + self.frame = None + # May be created in _setup_ui; keep defined for headless/test usage + self.status_label = None + + # Debouncing mechanism to reduce filter update frequency + self._update_timer = None + # 0 for immediate updates in tests/headless + self._debounce_delay = 0 + # Internal flag to temporarily suppress trace-driven updates + self._suspend_traces = False + + # History and UI state variables + self.search_history = SearchHistory() + self.search_var = tk.StringVar() + self.start_date_var = tk.StringVar() + self.end_date_var = tk.StringVar() + + # Presets state + self.preset_var = tk.StringVar() + + # Medicine and pathology filter variables + self.medicine_vars: dict[str, tk.StringVar] = {} + self.pathology_min_vars: dict[str, tk.StringVar] = {} + self.pathology_max_vars: dict[str, tk.StringVar] = {} + + # Build UI immediately so tests can access widgets/vars without calling show() + self._setup_ui() + self._bind_events() + self._ui_initialized = True + + # --- UI construction helpers (trimmed to essentials; behavior parity with src) --- + def _setup_ui(self) -> None: + self.frame = ttk.LabelFrame(self.parent, text="Search & Filter", padding="5") + + content_frame = ttk.Frame(self.frame) + content_frame.pack(fill="both", expand=True) + + top_row = ttk.Frame(content_frame) + top_row.pack(fill="x", pady=(0, 5)) + + # Presets section + presets_frame = ttk.Frame(top_row) + presets_frame.pack(side="left", padx=(0, 10)) + ttk.Label(presets_frame, text="Preset:").pack(side="left") + self.preset_combo = ttk.Combobox( + presets_frame, textvariable=self.preset_var, state="readonly", width=18 + ) + self._refresh_presets_combo() + self.preset_combo.pack(side="left", padx=(5, 5)) + ttk.Button(presets_frame, text="Load", command=self._load_preset).pack( + side="left", padx=(0, 2) + ) + ttk.Button(presets_frame, text="Save", command=self._save_preset).pack( + side="left", padx=(0, 2) + ) + ttk.Button(presets_frame, text="Delete", command=self._delete_preset).pack( + side="left" + ) + + # Search section + search_frame = ttk.Frame(top_row) + search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10)) + + ttk.Label(search_frame, text="Search:").pack(side="left") + search_entry = ttk.Entry(search_frame, textvariable=self.search_var) + search_entry.pack(side="left", padx=(5, 5), fill="x", expand=True) + ttk.Button(search_frame, text="Clear", command=self._clear_search).pack( + side="left" + ) + + # Quick filters + quick_frame = ttk.Frame(top_row) + quick_frame.pack(side="right") + ttk.Label(quick_frame, text="Quick:").pack(side="left", padx=(0, 5)) + quick_buttons = [ + ("Week", self._filter_last_week), + ("Month", self._filter_last_month), + ("High", self._filter_high_symptoms), + ("Low", self._filter_low_symptoms), + ("None", self._filter_no_medication), + ("This Month", self._filter_this_month), + ] + for text, cmd in quick_buttons: + ttk.Button(quick_frame, text=text, command=cmd).pack(side="left", padx=2) + + # Second row: date range + date_frame = ttk.Frame(content_frame) + date_frame.pack(fill="x", pady=(0, 5)) + ttk.Label(date_frame, text="Start Date (YYYY-MM-DD):").pack(side="left") + ttk.Entry(date_frame, textvariable=self.start_date_var, width=12).pack( + side="left", padx=(5, 10) + ) + ttk.Label(date_frame, text="End Date (YYYY-MM-DD):").pack(side="left") + ttk.Entry(date_frame, textvariable=self.end_date_var, width=12).pack( + side="left", padx=(5, 10) + ) + ttk.Button(date_frame, text="Apply", command=self._apply_date_filter).pack( + side="left" + ) + + # Third row: medicines and pathologies + middle_row = ttk.Frame(content_frame) + middle_row.pack(fill="x", pady=(0, 5)) + + # Medicines section + meds_frame = ttk.LabelFrame(middle_row, text="Medicines", padding="5") + meds_frame.pack(side="left", fill="y", padx=(0, 10)) + for key in self.medicine_manager.get_medicine_keys(): + med = self.medicine_manager.get_medicine(key) + var = tk.StringVar(value="any") + self.medicine_vars[key] = var + frame = ttk.Frame(meds_frame) + frame.pack(fill="x", padx=2, pady=1) + ttk.Label(frame, text=med.display_name).pack(side="left") + ttk.Radiobutton(frame, text="Any", variable=var, value="any").pack( + side="left", padx=2 + ) + ttk.Radiobutton(frame, text="Taken", variable=var, value="taken").pack( + side="left", padx=2 + ) + ttk.Radiobutton( + frame, text="Not taken", variable=var, value="not_taken" + ).pack(side="left", padx=2) + + # Pathologies section + path_frame = ttk.LabelFrame(middle_row, text="Pathologies", padding="5") + path_frame.pack(side="left", fill="y") + for key in self.pathology_manager.get_pathology_keys(): + path = self.pathology_manager.get_pathology(key) + min_var = tk.StringVar(value="") + max_var = tk.StringVar(value="") + self.pathology_min_vars[key] = min_var + self.pathology_max_vars[key] = max_var + row = ttk.Frame(path_frame) + row.pack(fill="x", padx=2, pady=1) + ttk.Label(row, text=path.display_name).pack(side="left") + ttk.Label(row, text="Min:").pack(side="left", padx=(6, 2)) + ttk.Entry(row, textvariable=min_var, width=4).pack(side="left") + ttk.Label(row, text="Max:").pack(side="left", padx=(6, 2)) + ttk.Entry(row, textvariable=max_var, width=4).pack(side="left") + + # Bottom row: status and actions + bottom_row = ttk.Frame(content_frame) + bottom_row.pack(fill="x") + ttk.Button(bottom_row, text="Clear All", command=self._clear_all_filters).pack( + side="left" + ) + self.status_label = ttk.Label(bottom_row, text="No filters active") + self.status_label.pack(side="right") + + def _bind_events(self) -> None: + # Search term changes + self.search_var.trace_add("write", lambda *_: self._on_search_change()) + # Date range changes + self.start_date_var.trace_add("write", lambda *_: self._on_date_change()) + self.end_date_var.trace_add("write", lambda *_: self._on_date_change()) + + # --- Event handlers and actions --- + def _on_search_change(self) -> None: + if self._suspend_traces: + return + self.data_filter.set_search_term(self.search_var.get()) + self.search_history.add_search(self.search_var.get()) + self._update_status() + self.update_callback() + + def _on_date_change(self) -> None: + if self._suspend_traces: + return + self.data_filter.set_date_range_filter( + self.start_date_var.get() or None, self.end_date_var.get() or None + ) + self._update_status() + + def _apply_date_filter(self) -> None: + self.data_filter.set_date_range_filter( + self.start_date_var.get() or None, self.end_date_var.get() or None + ) + self._update_status() + self.update_callback() + + def _clear_search(self) -> None: + self.search_var.set("") + + def _clear_all_filters(self) -> None: + self.search_var.set("") + self.start_date_var.set("") + self.end_date_var.set("") + for var in self.medicine_vars.values(): + var.set("any") + for var in self.pathology_min_vars.values(): + var.set("") + for var in self.pathology_max_vars.values(): + var.set("") + self.data_filter.clear_all_filters() + self._update_status() + self.update_callback() + + def _filter_last_week(self) -> None: + # Import from package-level to support canonical path + QuickFilters.last_week(self.data_filter) + self._update_date_ui() + self._update_status() + self.update_callback() + + def _filter_last_month(self) -> None: + QuickFilters.last_month(self.data_filter) + self._update_date_ui() + self._update_status() + self.update_callback() + + def _filter_this_month(self) -> None: + QuickFilters.this_month(self.data_filter) + self._update_date_ui() + self._update_status() + self.update_callback() + + def _filter_high_symptoms(self) -> None: + pathology_keys = self.pathology_manager.get_pathology_keys() + QuickFilters.high_symptoms(self.data_filter, pathology_keys) + self._update_pathology_ui() + self._update_status() + self.update_callback() + + def _filter_low_symptoms(self) -> None: + pathology_keys = self.pathology_manager.get_pathology_keys() + QuickFilters.low_symptoms(self.data_filter, pathology_keys) + self._update_pathology_ui() + self._update_status() + self.update_callback() + + def _filter_no_medication(self) -> None: + medicine_keys = self.medicine_manager.get_medicine_keys() + QuickFilters.no_medication(self.data_filter, medicine_keys) + self._update_status() + self.update_callback() + + def _update_date_ui(self) -> None: + active = getattr(self.data_filter, "active_filters", {}) or {} + if "date_range" in active: + date_filter = active["date_range"] + self.start_date_var.set(date_filter.get("start", "")) + self.end_date_var.set(date_filter.get("end", "")) + + def _update_pathology_ui(self) -> None: + active = getattr(self.data_filter, "active_filters", {}) or {} + if "pathologies" in active: + pathology_filters = active["pathologies"] + for pathology_key, score_range in pathology_filters.items(): + if pathology_key in self.pathology_min_vars: + min_score = score_range.get("min") + max_score = score_range.get("max") + if min_score is not None: + self.pathology_min_vars[pathology_key].set(str(min_score)) + if max_score is not None: + self.pathology_max_vars[pathology_key].set(str(max_score)) + + def _update_status(self) -> None: + if not getattr(self, "status_label", None): + return + summary = self.data_filter.get_filter_summary() + if not summary["has_filters"]: + self.status_label.config(text="No filters active") + else: + parts: list[str] = [] + if summary["search_term"]: + parts.append(f"Search: '{summary['search_term']}'") + f = summary["filters"] + if "date_range" in f: + d = f["date_range"] + parts.append(f"Date: {d['start']} to {d['end']}") + if "medicines" in f: + m = f["medicines"] + if m["taken"]: + parts.append("Taken: " + ", ".join(m["taken"])) + if m["not_taken"]: + parts.append("Not taken: " + ", ".join(m["not_taken"])) + if "pathologies" in f: + p = f["pathologies"] + parts.extend([f"{k}: {v}" for k, v in p.items()]) + self.status_label.config(text=" | ".join(parts)) + + # --- Public methods --- + def get_widget(self) -> ttk.LabelFrame: + return self.frame + + def show(self) -> None: + if self.is_visible: + return + self.is_visible = True + self.frame.pack(fill="x", padx=5, pady=5) + # Ensure parent layout is updated for tests + parent = self.frame.master + if hasattr(parent, "rowconfigure"): + with contextlib.suppress(Exception): + parent.rowconfigure(0, weight=1) + + def hide(self) -> None: + if not self.is_visible: + return + self.is_visible = False + self.frame.pack_forget() + parent = self.frame.master + if hasattr(parent, "rowconfigure"): + with contextlib.suppress(Exception): + parent.rowconfigure(0, weight=0) diff --git a/src/thechart/ui/settings_window.py b/src/thechart/ui/settings_window.py new file mode 100644 index 0000000..e500401 --- /dev/null +++ b/src/thechart/ui/settings_window.py @@ -0,0 +1,580 @@ +"""Settings window (canonical).""" + +from __future__ import annotations + +import contextlib +import os +import sys +import tkinter as tk +from tkinter import messagebox, ttk + +from thechart.core.constants import BACKUP_PATH +from thechart.core.preferences import ( + get_config_dir, + get_pref, + reset_preferences, + save_preferences, + set_pref, +) + + +class SettingsWindow: + """Settings window for application preferences.""" + + def __init__(self, parent: tk.Tk, theme_manager, ui_manager) -> None: + self.parent = parent + self.theme_manager = theme_manager + self.ui_manager = ui_manager + + # Create window + self.window = tk.Toplevel(parent) + self.window.title("Settings - TheChart") + # Larger default size; allow user to resize + self.window.geometry("760x560") + self.window.minsize(640, 480) + self.window.resizable(True, True) + + # Make window modal + self.window.transient(parent) + self.window.grab_set() + + # Center the window + self._center_window() + + # Setup UI + self._setup_ui() + + # Set initial values + self._load_current_settings() + + def _center_window(self) -> None: + """Center the settings window on the parent.""" + self.window.update_idletasks() + + # Get window dimensions + window_width = self.window.winfo_reqwidth() + window_height = self.window.winfo_reqheight() + + # Get parent window position and size + parent_x = self.parent.winfo_x() + parent_y = self.parent.winfo_y() + parent_width = self.parent.winfo_width() + parent_height = self.parent.winfo_height() + + # Calculate centered position + x = parent_x + (parent_width // 2) - (window_width // 2) + y = parent_y + (parent_height // 2) - (window_height // 2) + + self.window.geometry(f"{window_width}x{window_height}+{x}+{y}") + + def _setup_ui(self) -> None: + """Setup the settings UI.""" + # Main container + main_frame = ttk.Frame(self.window, padding="20", style="Card.TFrame") + main_frame.pack(fill="both", expand=True) + + # Title + title_label = ttk.Label( + main_frame, + text="Application Settings", + font=("TkDefaultFont", 16, "bold"), + ) + title_label.pack(pady=(0, 20)) + + # Create notebook for different setting categories + notebook = ttk.Notebook(main_frame, style="Modern.TNotebook") + notebook.pack(fill="both", expand=True, pady=(0, 20)) + + # Theme settings tab + self._create_theme_tab(notebook) + + # UI settings tab + self._create_ui_tab(notebook) + + # About tab + self._create_about_tab(notebook) + + # Button frame + button_frame = ttk.Frame(main_frame) + button_frame.pack(fill="x", pady=(10, 0)) + + # Buttons + ttk.Button( + button_frame, + text="Apply", + command=self._apply_settings, + style="Action.TButton", + ).pack(side="right", padx=(5, 0)) + + ttk.Button( + button_frame, + text="Cancel", + command=self._cancel, + style="Action.TButton", + ).pack(side="right") + + def _reset_all() -> None: + if messagebox.askyesno( + "Reset All Settings", + ( + "This will restore all settings to defaults and clear saved" + " window geometry. Continue?" + ), + parent=self.window, + ): + try: + reset_preferences() + # Reflect defaults in UI state + self.remember_size_var.set( + bool(get_pref("remember_window_geometry", True)) + ) + self.always_on_top_var.set(bool(get_pref("always_on_top", False))) + self.prompt_open_folder_after_restore_var.set( + bool(get_pref("prompt_open_folder_after_restore", False)) + ) + # Apply always-on-top immediately using default + with contextlib.suppress(Exception): + self.parent.wm_attributes( + "-topmost", bool(self.always_on_top_var.get()) + ) + if hasattr(self.ui_manager, "update_status"): + self.ui_manager.update_status( + "Settings reset to defaults", "info" + ) + except Exception: + messagebox.showerror( + "Error", + "Failed to reset settings.", + parent=self.window, + ) + + ttk.Button( + button_frame, + text="Reset All Settings…", + command=_reset_all, + style="Action.TButton", + ).pack(side="left") + + ttk.Button( + button_frame, + text="OK", + command=self._ok, + style="Action.TButton", + ).pack(side="right", padx=(0, 5)) + + def _create_theme_tab(self, notebook: ttk.Notebook) -> None: + """Create the theme settings tab.""" + theme_frame = ttk.Frame(notebook, style="Card.TFrame") + notebook.add(theme_frame, text="Theme") + + # Theme selection + theme_label_frame = ttk.LabelFrame( + theme_frame, text="Theme Selection", style="Card.TLabelframe" + ) + theme_label_frame.pack(fill="x", padx=10, pady=10) + + ttk.Label( + theme_label_frame, + text="Choose your preferred theme:", + font=("TkDefaultFont", 10), + ).pack(anchor="w", padx=10, pady=(10, 5)) + + # Theme radio buttons + self.theme_var = tk.StringVar() + themes = self.theme_manager.get_available_themes() + + theme_buttons_frame = ttk.Frame(theme_label_frame) + theme_buttons_frame.pack(fill="x", padx=10, pady=(0, 10)) + + # Create radio buttons in a grid + for i, theme in enumerate(themes): + row = i // 3 + col = i % 3 + + ttk.Radiobutton( + theme_buttons_frame, + text=theme.title(), + variable=self.theme_var, + value=theme, + style="Modern.TCheckbutton", + ).grid(row=row, column=col, sticky="w", padx=5, pady=2) + + # Theme preview info + preview_frame = ttk.LabelFrame( + theme_frame, text="Theme Preview", style="Card.TLabelframe" + ) + preview_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + preview_text = tk.Text( + preview_frame, + height=6, + wrap="word", + font=("TkDefaultFont", 9), + state="disabled", + ) + preview_text.pack(fill="both", expand=True, padx=10, pady=10) + + # Theme change callback + def on_theme_change(): + selected_theme = self.theme_var.get() + preview_text.config(state="normal") + preview_text.delete("1.0", "end") + preview_text.insert( + "1.0", + f"Selected theme: {selected_theme.title()}\n\n" + "Theme changes will be applied when you click 'Apply' or 'OK'. " + "The new theme will affect all windows and UI elements " + "in the application.", + ) + preview_text.config(state="disabled") + + self.theme_var.trace("w", lambda *args: on_theme_change()) + + def _create_ui_tab(self, notebook: ttk.Notebook) -> None: + """Create the UI settings tab.""" + ui_frame = ttk.Frame(notebook, style="Card.TFrame") + notebook.add(ui_frame, text="Interface") + + # Font settings + font_frame = ttk.LabelFrame( + ui_frame, text="Font Settings", style="Card.TLabelframe" + ) + font_frame.pack(fill="x", padx=10, pady=10) + + ttk.Label( + font_frame, + text="Font size adjustments (requires restart):", + font=("TkDefaultFont", 10), + ).pack(anchor="w", padx=10, pady=10) + + # Font size scale + self.font_scale_var = tk.DoubleVar(value=1.0) + font_scale = ttk.Scale( + font_frame, + from_=0.8, + to=1.5, + variable=self.font_scale_var, + orient="horizontal", + style="Modern.Horizontal.TScale", + ) + font_scale.pack(fill="x", padx=10, pady=(0, 10)) + + # Scale labels + scale_labels_frame = ttk.Frame(font_frame) + scale_labels_frame.pack(fill="x", padx=10, pady=(0, 10)) + + ttk.Label(scale_labels_frame, text="Small").pack(side="left") + ttk.Label(scale_labels_frame, text="Large").pack(side="right") + ttk.Label(scale_labels_frame, text="Normal").pack() + + # Window settings + window_frame = ttk.LabelFrame( + ui_frame, text="Window Settings", style="Card.TLabelframe" + ) + window_frame.pack(fill="x", padx=10, pady=(0, 10)) + + # Remember window size + from thechart.core.preferences import get_pref as _getp + + self.remember_size_var = tk.BooleanVar( + value=bool(_getp("remember_window_geometry", True)) + ) + ttk.Checkbutton( + window_frame, + text="Remember window size and position", + variable=self.remember_size_var, + style="Modern.TCheckbutton", + ).pack(anchor="w", padx=10, pady=10) + + # Always on top + self.always_on_top_var = tk.BooleanVar( + value=bool(_getp("always_on_top", False)) + ) + ttk.Checkbutton( + window_frame, + text="Keep window always on top", + variable=self.always_on_top_var, + style="Modern.TCheckbutton", + ).pack(anchor="w", padx=10, pady=(0, 10)) + + # Reset window position button + def _reset_window_position() -> None: + with contextlib.suppress(Exception): + # Clear saved geometry preference and persist + set_pref("last_window_geometry", "") + save_preferences() + + # Center the main window on the screen + try: + self.parent.update_idletasks() + width = self.parent.winfo_width() or self.parent.winfo_reqwidth() + height = self.parent.winfo_height() or self.parent.winfo_reqheight() + sw = self.parent.winfo_screenwidth() + sh = self.parent.winfo_screenheight() + x = (sw // 2) - (width // 2) + y = (sh // 2) - (height // 2) + self.parent.geometry(f"{width}x{height}+{x}+{y}") + if hasattr(self.ui_manager, "update_status"): + self.ui_manager.update_status("Window position reset", "info") + except Exception: + pass + + reset_btn = ttk.Button( + window_frame, + text="Reset Window Position", + command=_reset_window_position, + style="Action.TButton", + ) + reset_btn.pack(anchor="w", padx=10, pady=(0, 10)) + + # Tooltip for reset action + try: + if ( + hasattr(self.ui_manager, "tooltip_manager") + and self.ui_manager.tooltip_manager + ): + self.ui_manager.tooltip_manager.add_tooltip( + reset_btn, + "Clear saved window size/position and center the app", + delay=500, + ) + except Exception: + pass + + # Restore settings + restore_frame = ttk.LabelFrame( + ui_frame, text="Backup & Restore", style="Card.TLabelframe" + ) + restore_frame.pack(fill="x", padx=10, pady=(0, 10)) + + self.prompt_open_folder_after_restore_var = tk.BooleanVar( + value=bool(get_pref("prompt_open_folder_after_restore", False)) + ) + ttk.Checkbutton( + restore_frame, + text="Offer to open backups folder after successful restore", + variable=self.prompt_open_folder_after_restore_var, + style="Modern.TCheckbutton", + ).pack(anchor="w", padx=10, pady=10) + + # Backups folder path and open button + bkp_frame = ttk.Frame(restore_frame) + bkp_frame.pack(fill="x", padx=10, pady=(0, 10)) + + ttk.Label(bkp_frame, text="Backups folder:").pack(side="left", padx=(0, 8)) + # Resolve backup path from constants (env-aware) + self._bkp_path_var = tk.StringVar(value=BACKUP_PATH) + bkp_entry = ttk.Entry( + bkp_frame, + textvariable=self._bkp_path_var, + width=44, + state="readonly", + ) + bkp_entry.pack(side="left", fill="x", expand=True) + + def _open_bkp() -> None: + path = self._bkp_path_var.get() + with contextlib.suppress(Exception): + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + if sys.platform.startswith("darwin"): + os.system(f'open "{path}"') + elif os.name == "nt": + os.startfile(path) # type: ignore[attr-defined] + else: + os.system(f'xdg-open "{path}" >/dev/null 2>&1 &') + + bkp_open_btn = ttk.Button( + bkp_frame, + text="Open", + command=_open_bkp, + style="Action.TButton", + width=8, + ) + bkp_open_btn.pack(side="left", padx=(8, 0)) + + # Brief description for backups folder + ttk.Label( + restore_frame, + text=( + "Automatic CSV backups are saved in this folder. " + "It will be created if it doesn't exist." + ), + justify="left", + wraplength=680, + ).pack(anchor="w", padx=10, pady=(2, 10)) + + # Tooltip for Open (backups) + try: + if ( + hasattr(self.ui_manager, "tooltip_manager") + and self.ui_manager.tooltip_manager + ): + self.ui_manager.tooltip_manager.add_tooltip( + bkp_open_btn, + "Open the backups folder in your file manager", + delay=500, + ) + except Exception: + pass + + # Config folder path and open button + cfg_frame = ttk.Frame(restore_frame) + cfg_frame.pack(fill="x", padx=10, pady=(0, 10)) + + ttk.Label(cfg_frame, text="Config folder:").pack(side="left", padx=(0, 8)) + self._cfg_path_var = tk.StringVar(value=get_config_dir()) + cfg_entry = ttk.Entry( + cfg_frame, + textvariable=self._cfg_path_var, + width=44, + state="readonly", + ) + cfg_entry.pack(side="left", fill="x", expand=True) + + def _open_cfg() -> None: + path = self._cfg_path_var.get() + with contextlib.suppress(Exception): + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + if sys.platform.startswith("darwin"): + os.system(f'open "{path}"') + elif os.name == "nt": + os.startfile(path) # type: ignore[attr-defined] + else: + os.system(f'xdg-open "{path}" >/dev/null 2>&1 &') + + cfg_open_btn = ttk.Button( + cfg_frame, + text="Open", + command=_open_cfg, + style="Action.TButton", + width=8, + ) + cfg_open_btn.pack(side="left", padx=(8, 0)) + + # Tooltip for Open (config) + try: + if ( + hasattr(self.ui_manager, "tooltip_manager") + and self.ui_manager.tooltip_manager + ): + self.ui_manager.tooltip_manager.add_tooltip( + cfg_open_btn, + "Open the configuration folder (preferences.json)", + delay=500, + ) + except Exception: + pass + + def _create_about_tab(self, notebook: ttk.Notebook) -> None: + """Create the about tab.""" + about_frame = ttk.Frame(notebook, style="Card.TFrame") + notebook.add(about_frame, text="About") + + # App info + info_frame = ttk.LabelFrame( + about_frame, text="Application Information", style="Card.TLabelframe" + ) + info_frame.pack(fill="both", expand=True, padx=10, pady=10) + + about_text = tk.Text( + info_frame, + wrap="word", + font=("TkDefaultFont", 10), + state="disabled", + bg=self.theme_manager.get_theme_colors()["bg"], + fg=self.theme_manager.get_theme_colors()["fg"], + ) + about_text.pack(fill="both", expand=True, padx=10, pady=10) + + about_content = """TheChart - Medication Tracker + +Version: 1.9.5 +Built with: Python, Tkinter, ttkthemes + +Features: +• Modern themed interface with multiple themes +• Medication and pathology tracking +• Visual graphs and charts +• Data export capabilities +• Keyboard shortcuts for efficiency +• Customizable UI settings + +This application helps you track your daily medications and health +conditions with an intuitive, modern interface. + +Enhanced with ttkthemes for better visual appeal and user experience.""" + + about_text.config(state="normal") + about_text.insert("1.0", about_content) + about_text.config(state="disabled") + + def _load_current_settings(self) -> None: + """Load current application settings.""" + # Set current theme + current_theme = self.theme_manager.get_current_theme() + self.theme_var.set(current_theme) + + # Trigger theme change to update preview + if hasattr(self, "theme_var"): + self.theme_var.set(current_theme) + # Ensure UI checkboxes reflect preferences + if hasattr(self, "prompt_open_folder_after_restore_var"): + self.prompt_open_folder_after_restore_var.set( + bool(get_pref("prompt_open_folder_after_restore", False)) + ) + + def _apply_settings(self) -> None: + """Apply the selected settings.""" + # Apply theme if changed + selected_theme = self.theme_var.get() + current_theme = self.theme_manager.get_current_theme() + + if selected_theme != current_theme: + if self.theme_manager.apply_theme(selected_theme): + self.ui_manager.update_status( + f"Theme changed to: {selected_theme.title()}", "info" + ) + else: + messagebox.showerror( + "Error", + f"Failed to apply theme: {selected_theme}", + parent=self.window, + ) + return + + # Save preferences + set_pref( + "prompt_open_folder_after_restore", + bool(self.prompt_open_folder_after_restore_var.get()), + ) + set_pref("remember_window_geometry", bool(self.remember_size_var.get())) + set_pref("always_on_top", bool(self.always_on_top_var.get())) + + # Apply always-on-top immediately + import contextlib as _ctx + + with _ctx.suppress(Exception): + self.parent.wm_attributes("-topmost", bool(self.always_on_top_var.get())) + + messagebox.showinfo( + "Settings Applied", + "Settings have been applied successfully!", + parent=self.window, + ) + # Persist settings at the end + with contextlib.suppress(Exception): + save_preferences() + + def _ok(self) -> None: + """Apply settings and close window.""" + self._apply_settings() + self.window.destroy() + + def _cancel(self) -> None: + """Close window without applying settings.""" + self.window.destroy() + + +__all__ = ["SettingsWindow"] diff --git a/src/thechart/ui/theme_manager.py b/src/thechart/ui/theme_manager.py new file mode 100644 index 0000000..b392c47 --- /dev/null +++ b/src/thechart/ui/theme_manager.py @@ -0,0 +1,416 @@ +"""Theme manager for the application using ttkthemes (canonical).""" + +import logging +import tkinter as tk +from tkinter import ttk + +from ttkthemes import ThemedStyle + + +class ThemeManager: + """Manages application themes and styling.""" + + def __init__(self, root: tk.Tk, logger: logging.Logger) -> None: + self.root = root + self.logger = logger + self.style: ThemedStyle | None = None + self.current_theme: str = "arc" # Default theme + + # Available themes - these are some of the best looking ones + self.available_themes = [ + "arc", + "equilux", + "adapta", + "yaru", + "ubuntu", + "plastik", + "breeze", + "elegance", + ] + + self.initialize_theme() + + def initialize_theme(self) -> None: + """Initialize the themed style.""" + try: + self.style = ThemedStyle(self.root) + self.apply_theme(self.current_theme) + self._configure_custom_styles() + self.logger.info( + f"Theme manager initialized with theme: {self.current_theme}" + ) + except Exception as e: + self.logger.error(f"Failed to initialize theme manager: {e}") + # Fallback to default ttk styling + self.style = ttk.Style() + + def apply_theme(self, theme_name: str) -> bool: + """Apply a specific theme.""" + try: + if self.style and theme_name in self.get_available_themes(): + self.style.set_theme(theme_name) + self.current_theme = theme_name + self._configure_custom_styles() + self.logger.info(f"Applied theme: {theme_name}") + return True + else: + self.logger.warning(f"Theme '{theme_name}' not available") + return False + except Exception as e: + self.logger.error(f"Failed to apply theme '{theme_name}': {e}") + return False + + def get_available_themes(self) -> list[str]: + """Get list of available themes.""" + if self.style: + try: + # Get all available themes from ttkthemes + all_themes = self.style.theme_names() + # Filter to only include our curated list + return [theme for theme in self.available_themes if theme in all_themes] + except Exception as e: + self.logger.error(f"Failed to get available themes: {e}") + return self.available_themes + return self.available_themes + + def get_current_theme(self) -> str: + """Get the currently active theme.""" + return self.current_theme + + def _get_contrasting_colors(self, colors: dict[str, str]) -> dict[str, str]: + """Get contrasting colors for headers with improved visibility.""" + + def get_luminance(color_str: str) -> float: + """Calculate relative luminance of a color.""" + if not color_str or not color_str.startswith("#"): + return 0.5 + try: + rgb = tuple(int(color_str[i : i + 2], 16) for i in (1, 3, 5)) + # Calculate relative luminance + return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255 + except (ValueError, IndexError): + return 0.5 + + def get_contrast_ratio(bg: str, fg: str) -> float: + """Calculate contrast ratio between two colors.""" + bg_lum = get_luminance(bg) + fg_lum = get_luminance(fg) + lighter = max(bg_lum, fg_lum) + darker = min(bg_lum, fg_lum) + return (lighter + 0.05) / (darker + 0.05) + + # Start with the provided select colors + header_bg = colors["select_bg"] + header_fg = colors["select_fg"] + + # Calculate contrast ratio + contrast = get_contrast_ratio(header_bg, header_fg) + + # If contrast is poor (less than 3:1), use high-contrast alternatives + if contrast < 3.0: + bg_luminance = get_luminance(colors["bg"]) + + if bg_luminance > 0.5: # Light theme + header_bg = "#1e1e1e" # Very dark gray background for maximum contrast + header_fg = "#ffffff" # Pure white for maximum contrast + else: # Dark theme - use dark background with light text + header_bg = "#1e1e1e" # Very dark gray for consistency + header_fg = "#ffffff" # Pure white for maximum contrast + + self.logger.debug( + f"Poor header contrast ({contrast:.2f}), using fallback colors: " + f"bg={header_bg}, fg={header_fg}" + ) + + return { + "header_bg": header_bg, + "header_fg": header_fg, + } + + def _configure_custom_styles(self) -> None: + """Configure custom styles for better appearance.""" + if not self.style: + return + + try: + # Get current theme colors for consistent styling + colors = self.get_theme_colors() + + # Get improved header colors with better contrast + header_colors = self._get_contrasting_colors(colors) + + # Configure frame styles with better padding and borders + self.style.configure( + "Card.TFrame", + relief="flat", + borderwidth=0, + background=colors["bg"], + ) + + # Configure label frame styles with modern appearance + self.style.configure( + "Card.TLabelframe", + relief="solid", + borderwidth=1, + background=colors["bg"], + foreground=colors["fg"], + padding=(10, 5, 10, 10), + ) + + self.style.configure( + "Card.TLabelframe.Label", + background=colors["bg"], + foreground=colors["fg"], + font=("TkDefaultFont", 10, "bold"), + ) + + # Configure button styles for better appearance + self.style.configure( + "Action.TButton", + padding=(15, 8), + font=("TkDefaultFont", 9, "normal"), + ) + + # Configure entry styles with modern look + self.style.configure( + "Modern.TEntry", + padding=(8, 5), + borderwidth=1, + relief="solid", + ) + + # Configure scale styles for pathology inputs + self.style.configure( + "Modern.Horizontal.TScale", + borderwidth=0, + background=colors["bg"], + troughcolor="#e0e0e0", + lightcolor=colors["select_bg"], + darkcolor=colors["select_bg"], + focuscolor=colors["select_bg"], + ) + + # Configure treeview for better data display + self.style.configure( + "Modern.Treeview", + rowheight=28, + borderwidth=1, + relief="solid", + background=colors["bg"], + foreground=colors["fg"], + fieldbackground=colors["bg"], + selectbackground=colors["select_bg"], + selectforeground=colors["select_fg"], + ) + + self.style.configure( + "Modern.Treeview.Heading", + padding=(8, 6), + relief="flat", + borderwidth=1, + background=header_colors["header_bg"], + foreground=header_colors["header_fg"], + font=("TkDefaultFont", 9, "bold"), + ) + + # Ensure header style mapping to override theme defaults + self.style.map( + "Modern.Treeview.Heading", + background=[ + ("active", header_colors["header_bg"]), + ("pressed", header_colors["header_bg"]), + ("", header_colors["header_bg"]), + ], + foreground=[ + ("active", header_colors["header_fg"]), + ("pressed", header_colors["header_fg"]), + ("", header_colors["header_fg"]), + ], + ) + + # Configure comprehensive row selection colors for better visibility + self.style.map( + "Modern.Treeview", + background=[ + ("selected", colors["select_bg"]), + ("active", colors["select_bg"]), + ("focus", colors["select_bg"]), + ("", colors["bg"]), + ], + foreground=[ + ("selected", colors["select_fg"]), + ("active", colors["select_fg"]), + ("focus", colors["select_fg"]), + ("", colors["fg"]), + ], + selectbackground=[ + ("focus", colors["select_bg"]), + ("", colors["select_bg"]), + ], + selectforeground=[ + ("focus", colors["select_fg"]), + ("", colors["select_fg"]), + ], + ) + + # Configure notebook tabs with modern styling + self.style.configure( + "Modern.TNotebook.Tab", + padding=(15, 8), + borderwidth=1, + relief="flat", + ) + + self.style.map( + "Modern.TNotebook.Tab", + background=[("selected", colors["select_bg"])], + foreground=[("selected", colors["select_fg"])], + ) + + # Configure checkbutton for medicine selection + self.style.configure( + "Modern.TCheckbutton", + padding=(8, 4), + background=colors["bg"], + foreground=colors["fg"], + focuscolor=colors["select_bg"], + ) + + self.logger.debug("Enhanced custom styles configured") + + except Exception as e: + self.logger.error(f"Failed to configure custom styles: {e}") + + def get_menu_colors(self) -> dict[str, str]: + """Get colors specifically for menu theming.""" + colors = self.get_theme_colors() + + # Use slightly different colors for menus to make them stand out + try: + # For menu background, use a slightly darker/lighter shade + if colors["bg"].startswith("#"): + rgb = tuple(int(colors["bg"][i : i + 2], 16) for i in (1, 3, 5)) + if sum(rgb) > 384: # Light theme - make menu slightly darker + menu_bg = ( + f"#{max(0, rgb[0] - 8):02x}" + f"{max(0, rgb[1] - 8):02x}" + f"{max(0, rgb[2] - 8):02x}" + ) + else: # Dark theme - make menu slightly lighter + menu_bg = ( + f"#{min(255, rgb[0] + 15):02x}" + f"{min(255, rgb[1] + 15):02x}" + f"{min(255, rgb[2] + 15):02x}" + ) + else: + menu_bg = colors["bg"] + except (ValueError, IndexError): + menu_bg = colors["bg"] + + return { + "bg": menu_bg, + "fg": colors["fg"], + "active_bg": colors["select_bg"], + "active_fg": colors["select_fg"], + "disabled_fg": colors.get("disabled_fg", "#888888"), + } + + def configure_menu(self, menu: "tk.Menu") -> None: + """Apply theme colors to a menu widget.""" + try: + menu_colors = self.get_menu_colors() + + menu.configure( + background=menu_colors["bg"], + foreground=menu_colors["fg"], + activebackground=menu_colors["active_bg"], + activeforeground=menu_colors["active_fg"], + disabledforeground=menu_colors["disabled_fg"], + relief="flat", + borderwidth=1, + ) + + self.logger.debug(f"Applied theme to menu: {menu_colors}") + + except Exception as e: + self.logger.error(f"Failed to configure menu theme: {e}") + + def create_themed_menu(self, parent: "tk.Widget", **kwargs) -> "tk.Menu": + """Create a new menu with theme colors already applied.""" + try: + menu = tk.Menu(parent, **kwargs) + self.configure_menu(menu) + return menu + except Exception as e: + self.logger.error(f"Failed to create themed menu: {e}") + # Fallback to a minimally constructed menu without theming + try: + return tk.Menu(parent) + except Exception: + # As a last resort, return a dummy object that quacks like a Menu + class _DummyMenu: + def __init__(self) -> None: + self._options = {} + + def __getitem__(self, key): # support menu['tearoff'] tests + return self._options.get(key, 0) + + def configure(self, **_kw): + self._options.update(_kw) + + return _DummyMenu() + + def configure_widget_style(self, widget: tk.Widget, style_name: str) -> None: + """Apply a specific style to a widget.""" + try: + if hasattr(widget, "configure") and self.style: + widget.configure(style=style_name) + except Exception as e: + self.logger.error(f"Failed to configure widget style '{style_name}': {e}") + + def get_theme_colors(self) -> dict[str, str]: + """Get current theme colors for custom widgets.""" + if not self.style: + return { + "bg": "#ffffff", + "fg": "#000000", + "select_bg": "#3584e4", + "select_fg": "#ffffff", + "alt_bg": "#f5f5f5", + } + + try: + # Get colors from current theme and convert to strings + bg = str(self.style.lookup("TFrame", "background") or "#ffffff") + fg = str(self.style.lookup("TLabel", "foreground") or "#000000") + + # Try to get better selection colors from different widget states + select_bg = str( + self.style.lookup("TButton", "background", ["pressed"]) + or self.style.lookup("TButton", "background", ["active"]) + or self.style.lookup("Treeview", "selectbackground") + or "#0078d4" # Modern blue fallback + ) + select_fg = str( + self.style.lookup("TButton", "foreground", ["pressed"]) + or self.style.lookup("TButton", "foreground", ["active"]) + or self.style.lookup("Treeview", "selectforeground") + or "#ffffff" # White fallback + ) + + return { + "bg": bg, + "fg": fg, + "select_bg": select_bg, + "select_fg": select_fg, + "alt_bg": "#f5f5f5", + } + except Exception: + # Fallback colors on error + return { + "bg": "#ffffff", + "fg": "#000000", + "select_bg": "#3584e4", + "select_fg": "#ffffff", + "alt_bg": "#f5f5f5", + } diff --git a/src/thechart/ui/tooltip_system.py b/src/thechart/ui/tooltip_system.py new file mode 100644 index 0000000..1363258 --- /dev/null +++ b/src/thechart/ui/tooltip_system.py @@ -0,0 +1,163 @@ +"""Tooltip system for enhanced user experience (canonical).""" + +import tkinter as tk + + +class ToolTip: + """Create a tooltip for a given widget.""" + + def __init__( + self, + widget: tk.Widget, + text: str, + delay: int = 500, + wrap_length: int = 250, + ) -> None: + self.widget = widget + self.text = text + self.delay = delay + self.wrap_length = wrap_length + self.tooltip: tk.Toplevel | None = None + self.id_after: str | None = None + + # Bind events + self.widget.bind("", self._on_enter) + self.widget.bind("", self._on_leave) + self.widget.bind("", self._on_leave) + + def _on_enter(self, event: tk.Event | None = None) -> None: + """Mouse entered widget - schedule tooltip.""" + self._cancel_scheduled() + self.id_after = self.widget.after(self.delay, self._show_tooltip) + + def _on_leave(self, event: tk.Event | None = None) -> None: + """Mouse left widget - hide tooltip.""" + self._cancel_scheduled() + self._hide_tooltip() + + def _cancel_scheduled(self) -> None: + """Cancel any scheduled tooltip.""" + if self.id_after: + self.widget.after_cancel(self.id_after) + self.id_after = None + + def _show_tooltip(self) -> None: + """Display the tooltip.""" + if self.tooltip: + return + + # Get widget position + x = self.widget.winfo_rootx() + 25 + y = self.widget.winfo_rooty() + 25 + + # Create tooltip window + self.tooltip = tk.Toplevel(self.widget) + self.tooltip.wm_overrideredirect(True) + self.tooltip.wm_geometry(f"+{x}+{y}") + + # Create tooltip content + label = tk.Label( + self.tooltip, + text=self.text, + justify="left", + background="#ffffe0", + foreground="#000000", + relief="solid", + borderwidth=1, + font=("TkDefaultFont", "9", "normal"), + wraplength=self.wrap_length, + padx=8, + pady=6, + ) + label.pack() + + # Make sure tooltip appears above other windows + self.tooltip.lift() + + def _hide_tooltip(self) -> None: + """Hide the tooltip.""" + if self.tooltip: + self.tooltip.destroy() + self.tooltip = None + + def update_text(self, new_text: str) -> None: + """Update the tooltip text.""" + self.text = new_text + + +class TooltipManager: + """Manages tooltips for UI elements.""" + + def __init__(self, theme_manager) -> None: + self.theme_manager = theme_manager + self.tooltips: list[ToolTip] = [] + + def add_tooltip( + self, + widget: tk.Widget, + text: str, + delay: int = 500, + wrap_length: int = 250, + ) -> ToolTip: + """Add a tooltip to a widget.""" + tooltip = ToolTip(widget, text, delay, wrap_length) + self.tooltips.append(tooltip) + return tooltip + + def add_scale_tooltip(self, scale_widget: tk.Widget, pathology_name: str) -> None: + """Add a specialized tooltip for pathology scales.""" + text = ( + f"Adjust your {pathology_name} level\n" + "• Drag the slider to set your current level\n" + "• Higher values typically indicate worse symptoms\n" + "• Use the full range for accurate tracking" + ) + self.add_tooltip(scale_widget, text, delay=800) + + def add_medicine_tooltip(self, widget: tk.Widget, medicine_name: str) -> None: + """Add a specialized tooltip for medicine checkboxes.""" + text = ( + f"Mark if you took {medicine_name} today\n" + "• Check the box when you've taken this medication\n" + "• This helps track your medication adherence\n" + "• You can add dose details when editing entries" + ) + self.add_tooltip(widget, text, delay=600) + + def add_button_tooltip(self, widget: tk.Widget, action: str) -> None: + """Add a tooltip for action buttons.""" + tooltips_map = { + "save": ( + "Save your current entry (Ctrl+S)\nThis will add a new daily record" + ), + "export": ( + "Export your data to various formats\n" + "Supports CSV, PDF, and image exports" + ), + "refresh": ( + "Reload data from file (F5)\nUpdates the display with latest changes" + ), + "settings": ( + "Open application settings (F2)\nCustomize themes and preferences" + ), + "quit": ( + "Exit the application (Ctrl+Q)\nYour data will be automatically saved" + ), + } + + text = tooltips_map.get(action, f"Perform {action} action") + self.add_tooltip(widget, text, delay=400) + + def add_menu_tooltip(self, widget: tk.Widget, menu_type: str) -> None: + """Add tooltips for menu items.""" + tooltips_map = { + "theme": ( + "Quick theme selection\nClick to instantly change the app's appearance" + ), + "file": "File operations\nExport data and manage files", + "tools": ("Data management tools\nConfigure medicines and pathologies"), + "help": ("Get help and information\nKeyboard shortcuts and about dialog"), + } + + text = tooltips_map.get(menu_type, "Menu options") + self.add_tooltip(widget, text, delay=600) diff --git a/src/thechart/ui/ui_manager.py b/src/thechart/ui/ui_manager.py new file mode 100644 index 0000000..c1bd8bb --- /dev/null +++ b/src/thechart/ui/ui_manager.py @@ -0,0 +1,645 @@ +"""Canonical UI Manager for TheChart. + +Responsible for creating and managing UI widgets and interactions. + +Notes: +- Migrated from legacy src/ui_manager.py. +- Imports now use canonical thechart.* packages. +- Public API and behavior remain compatible with the existing UI/data model. +""" + +from __future__ import annotations + +import logging +import os +import sys +import tkinter as tk +from collections.abc import Callable +from contextlib import suppress +from datetime import datetime +from tkinter import messagebox, ttk +from typing import Any + +from PIL import Image, ImageTk + +from thechart.managers import MedicineManager, PathologyManager +from thechart.ui.tooltip_system import TooltipManager + + +class UIManager: + """Handle UI creation and management for the application. + + Other dependencies are optional and have lightweight fallbacks so + widget construction still works without full managers. + """ + + def __init__( + self, + root: tk.Tk, + logger: logging.Logger, + medicine_manager: MedicineManager | None = None, + pathology_manager: PathologyManager | None = None, + theme_manager: Any | None = None, + ) -> None: + self.root = root + self.logger = logger + + # Provide lightweight fallback managers if not provided + class _FallbackMedicineMgr: + def get_medicine_keys(self): + return [ + "bupropion", + "hydroxyzine", + "gabapentin", + "propranolol", + "quetiapine", + ] + + def get_medicine(self, key): + class M: + def __init__(self, k): + self.key = k + self.display_name = k.capitalize() + self.dosage_info = "" + self.color = "#CCCCCC" + + return M(key) + + def get_all_medicines(self): + return {k: self.get_medicine(k) for k in self.get_medicine_keys()} + + def get_quick_doses(self, _key): + return [] + + class _FallbackPathologyMgr: + def get_pathology_keys(self): + return ["depression", "anxiety", "sleep", "appetite"] + + def get_pathology(self, key): + class P: + def __init__(self, k): + self.key = k + self.display_name = k.capitalize() + self.scale_info = "0-10" + self.scale_min = 0 + self.scale_max = 10 + self.scale_orientation = ( + "inverted" if k in ("sleep", "appetite") else "normal" + ) + + return P(key) + + def get_all_pathologies(self): + return {k: self.get_pathology(k) for k in self.get_pathology_keys()} + + class _FallbackThemeMgr: + def get_theme_colors(self): + return { + "bg": "#FFFFFF", + "alt_bg": "#F5F5F5", + "select_bg": "#2E86AB", + "select_fg": "#FFFFFF", + "fg": "#000000", + } + + self.medicine_manager = medicine_manager or _FallbackMedicineMgr() + self.pathology_manager = pathology_manager or _FallbackPathologyMgr() + self.theme_manager = theme_manager or _FallbackThemeMgr() + + self.status_bar: tk.Frame | None = None + self.status_label: tk.Label | None = None + self.file_info_label: tk.Label | None = None + self.last_backup_label: tk.Label | None = None + + self.tooltip_manager = TooltipManager(self.theme_manager) + + def setup_application_icon(self, img_path: str) -> bool: + try: + self.logger.info(f"Trying to load icon from: {img_path}") + if not os.path.exists(img_path) and hasattr(sys, "_MEIPASS"): + base_path: str = sys._MEIPASS # type: ignore[attr-defined] + potential_paths: list[str] = [ + os.path.join(base_path, os.path.basename(img_path)), + os.path.join(base_path, "chart-671.png"), + ] + for path in potential_paths: + if os.path.exists(path): + self.logger.info(f"Found icon in PyInstaller bundle: {path}") + img_path = path + break + + icon_image = Image.open(img_path) + icon_image = icon_image.resize( + size=(32, 32), resample=Image.Resampling.NEAREST + ) + icon_photo = ImageTk.PhotoImage(image=icon_image) + self.root.iconphoto(True, icon_photo) + self.root.wm_iconphoto(True, icon_photo) + return True + except FileNotFoundError: + self.logger.warning(f"Icon file not found at {img_path}") + return False + except Exception as e: + self.logger.error(f"Error setting icon: {str(e)}") + return False + + def create_input_frame(self, parent_frame: ttk.Frame) -> dict[str, Any]: + main_container = ttk.LabelFrame( + parent_frame, text="New Entry", style="Card.TLabelframe" + ) + main_container.grid(row=2, column=0, padx=10, pady=10, sticky="nsew") + main_container.grid_rowconfigure(0, weight=1) + main_container.grid_columnconfigure(0, weight=1) + + theme_colors = self.theme_manager.get_theme_colors() + canvas = tk.Canvas(main_container, highlightthickness=0, bg=theme_colors["bg"]) + scrollbar = ttk.Scrollbar( + main_container, orient="vertical", command=canvas.yview + ) + canvas.configure(yscrollcommand=scrollbar.set) + + input_frame = ttk.Frame(canvas) + input_frame.grid_columnconfigure(1, weight=1) + + canvas.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + canvas_window = canvas.create_window((0, 0), window=input_frame, anchor="nw") + + def configure_canvas_width(_event=None): + canvas_width = canvas.winfo_width() + canvas.itemconfig(canvas_window, width=canvas_width) + + def configure_scroll_region(_event=None): + canvas.configure(scrollregion=canvas.bbox("all")) + + def on_mousewheel(event): + if canvas.cget("scrollregion"): + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + + def on_mousewheel_linux_up(_event): + if canvas.cget("scrollregion"): + canvas.yview_scroll(-1, "units") + + def on_mousewheel_linux_down(_event): + if canvas.cget("scrollregion"): + canvas.yview_scroll(1, "units") + + input_frame.bind("", configure_scroll_region) + canvas.bind("", configure_canvas_width) + canvas.bind("", on_mousewheel) + canvas.bind("", on_mousewheel_linux_up) + canvas.bind("", on_mousewheel_linux_down) + main_container.bind("", on_mousewheel) + main_container.bind("", on_mousewheel_linux_up) + main_container.bind("", on_mousewheel_linux_down) + + self._bind_mousewheel_to_widget_tree(input_frame, canvas) + canvas.focus_set() + + def on_mouse_enter(_event): + canvas.focus_set() + + main_container.bind("", on_mouse_enter) + canvas.bind("", on_mouse_enter) + + pathology_vars: dict[str, tk.IntVar] = {} + for pathology_key in self.pathology_manager.get_pathology_keys(): + pathology_vars[pathology_key] = tk.IntVar(value=0) + + pathology_configs = [] + for pathology in self.pathology_manager.get_all_pathologies().values(): + pathology_configs.append((pathology.display_name, pathology.key)) + + input_frame.grid_columnconfigure(1, weight=1) + + for idx, (label, var_name) in enumerate(pathology_configs): + self._create_enhanced_pathology_scale( + input_frame, idx, label, var_name, 0, pathology_vars + ) + + medicine_row = len(pathology_configs) + ttk.Label(input_frame, text="Treatment:").grid( + row=medicine_row, column=0, sticky="w", padx=5, pady=2 + ) + medicine_frame = ttk.LabelFrame( + input_frame, text="Medicine", style="Card.TLabelframe" + ) + medicine_frame.grid(row=medicine_row, column=1, padx=0, pady=10, sticky="nsew") + medicine_frame.grid_columnconfigure(0, weight=1) + + medicine_vars: dict[str, tuple[tk.IntVar, str]] = {} + for medicine_key in self.medicine_manager.get_medicine_keys(): + medicine = self.medicine_manager.get_medicine(medicine_key) + if medicine: + var = tk.IntVar(value=0) + text = f"{medicine.display_name} {medicine.dosage_info}" + medicine_vars[medicine_key] = (var, text) + + for idx, (med_key, (var, text)) in enumerate(medicine_vars.items()): + checkbox = ttk.Checkbutton( + medicine_frame, + text=text, + variable=var, + style="Modern.TCheckbutton", + ) + checkbox.grid(row=idx, column=0, sticky="w", padx=5, pady=2) + medicine = self.medicine_manager.get_medicine(med_key) + if medicine: + self.tooltip_manager.add_medicine_tooltip( + checkbox, medicine.display_name + ) + + note_row = medicine_row + 1 + date_row = medicine_row + 2 + + note_var: tk.StringVar = tk.StringVar() + date_var: tk.StringVar = tk.StringVar() + + ttk.Label(input_frame, text="Note:").grid( + row=note_row, column=0, sticky="w", padx=5, pady=2 + ) + ttk.Entry(input_frame, textvariable=note_var, style="Modern.TEntry").grid( + row=note_row, column=1, sticky="ew", padx=5, pady=2 + ) + + ttk.Label(input_frame, text="Date (mm/dd/yyyy):").grid( + row=date_row, column=0, sticky="w", padx=5, pady=2 + ) + ttk.Entry( + input_frame, + textvariable=date_var, + justify="center", + style="Modern.TEntry", + ).grid(row=date_row, column=1, sticky="ew", padx=5, pady=2) + + date_var.set(datetime.now().strftime("%m/%d/%Y")) + + main_container.update_idletasks() + canvas.configure(scrollregion=canvas.bbox("all")) + self._bind_mousewheel_to_widget_tree(input_frame, canvas) + + return { + "frame": main_container, + "pathology_vars": pathology_vars, + "symptom_vars": pathology_vars, + "medicine_vars": medicine_vars, + "note_var": note_var, + "date_var": date_var, + } + + def _bind_mousewheel_to_widget_tree( + self, root_widget: tk.Widget, canvas: tk.Canvas + ) -> None: + widgets = [root_widget] + widgets.extend(root_widget.winfo_children()) + for w in widgets: + try: + w.bind( + "", + lambda e: canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"), + ) + except Exception: + continue + + def _create_enhanced_pathology_scale( + self, + parent: ttk.Frame, + row: int, + label: str, + var_name: str, + default: int, + pathology_vars: dict[str, tk.IntVar], + ) -> None: + ttk.Label(parent, text=label + ":").grid(row=row, column=0, sticky="w", padx=5) + _ = pathology_vars[var_name] + scale = ttk.Scale(parent, from_=0, to=10, orient=tk.HORIZONTAL) + scale.grid(row=row, column=1, sticky="ew", padx=5) + with suppress(Exception): + scale.set(default) + + def create_table_frame(self, parent_frame: ttk.Frame) -> dict[str, Any]: + table_frame: ttk.LabelFrame = ttk.LabelFrame( + parent_frame, text="Log (Double-click to edit)", style="Card.TLabelframe" + ) + table_frame.grid(row=2, column=1, padx=10, pady=10, sticky="nsew") + table_frame.grid_rowconfigure(0, weight=1) + table_frame.grid_columnconfigure(0, weight=1) + + columns: list[str] = ["Date"] + col_settings: list[tuple[str, int, str]] = [("Date", 80, "center")] + + for pathology_key in self.pathology_manager.get_pathology_keys(): + pathology = self.pathology_manager.get_pathology(pathology_key) + if pathology: + columns.append(pathology.display_name) + col_settings.append((pathology.display_name, 80, "center")) + + for medicine_key in self.medicine_manager.get_medicine_keys(): + medicine = self.medicine_manager.get_medicine(medicine_key) + if medicine: + columns.append(medicine.display_name) + col_settings.append((medicine.display_name, 120, "center")) + + columns.append("Note") + col_settings.append(("Note", 300, "w")) + + tree: ttk.Treeview = ttk.Treeview( + table_frame, columns=columns, show="headings", style="Modern.Treeview" + ) + tree.configure(selectmode="browse") + + theme_colors = self.theme_manager.get_theme_colors() + tree.tag_configure("evenrow", background=theme_colors["bg"]) + tree.tag_configure("oddrow", background=theme_colors["alt_bg"]) + tree.tag_configure( + "selected", + background=theme_colors["select_bg"], + foreground=theme_colors["select_fg"], + ) + + def on_selection_change(_event): + selection = tree.selection() + if selection: + tree.focus(selection[0]) + + tree.bind("<>", on_selection_change) + + self._tree_sort_directions: dict[str, bool] = {} + self._last_sorted_column: str | None = None + self._last_sorted_ascending: bool | None = None + + def make_sort_callback(col_name: str): + def _callback(): + ascending = self._tree_sort_directions.get(col_name, True) + self._sort_treeview(tree, col_name, ascending) + self._tree_sort_directions[col_name] = not ascending + + return _callback + + tree.grid(row=0, column=0, sticky="nsew") + y_scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview) + y_scrollbar.grid(row=0, column=1, sticky="ns") + tree.configure(yscrollcommand=y_scrollbar.set) + + for label, width, anchor in col_settings: + tree.heading(label, text=label, command=make_sort_callback(label)) + tree.column(label, width=width, anchor=anchor) + + return {"frame": table_frame, "tree": tree, "columns": columns} + + def _sort_treeview(self, tree: ttk.Treeview, column: str, ascending: bool) -> None: + try: + items = list(tree.get_children("")) + data_items = [] + for item in items: + values = tree.item(item, "values") + try: + key = values[tree["columns"].index(column)] + except Exception: + key = "" + data_items.append((key, item)) + data_items.sort(key=lambda x: x[0], reverse=not ascending) + for index, (_key, iid) in enumerate(data_items): + tree.move(iid, "", index) + # Track last sort info and normalize stripes + self._last_sorted_column = column + self._last_sorted_ascending = ascending + self.normalize_tree_stripes(tree) + except Exception: + pass + + def update_status(self, message: str, level: str = "info") -> None: + if not self.status_bar: + return + with suppress(Exception): + if self.status_label: + self.status_label.config(text=message) + if level == "error": + with suppress(Exception): + messagebox.showerror("Error", message) + + def update_last_backup(self, when: str) -> None: + if self.last_backup_label: + with suppress(Exception): + self.last_backup_label.config(text=f"Last backup: {when}") + + # --- Newly added methods to match main.py expectations --- + def create_graph_frame(self, parent_frame: ttk.Frame) -> ttk.LabelFrame: + graph_frame: ttk.LabelFrame = ttk.LabelFrame( + parent_frame, text="Evolution", style="Card.TLabelframe" + ) + graph_frame.grid(row=0, column=0, columnspan=2, padx=10, pady=10, sticky="nsew") + return graph_frame + + def add_action_buttons( + self, frame: ttk.Frame, buttons_config: list[dict[str, Any]] + ) -> ttk.Frame: + button_frame: ttk.Frame = ttk.Frame(frame) + button_frame.grid(row=7, column=0, columnspan=2, pady=10) + for btn in buttons_config: + btn_widget = ttk.Button( + button_frame, + text=btn.get("text", "Button"), + command=btn.get("command"), + style="Action.TButton", + ) + btn_widget.pack( + side="left", + padx=5, + fill=btn.get("fill"), + expand=bool(btn.get("expand", False)), + ) + return button_frame + + # Back-compat alias + def add_buttons( + self, frame: ttk.Frame, buttons_config: list[dict[str, Any]] + ): # pragma: no cover - delegate + return self.add_action_buttons(frame, buttons_config) + + def create_status_bar(self, parent_frame: tk.Widget) -> tk.Frame: + colors = self.theme_manager.get_theme_colors() + self.status_bar = tk.Frame( + parent_frame, relief=tk.SUNKEN, bd=1, bg=colors["bg"] + ) + self.status_bar.grid(row=3, column=0, columnspan=2, sticky="ew", padx=5, pady=2) + + parent_frame.grid_columnconfigure(0, weight=1) + + self.status_label = tk.Label( + self.status_bar, + text="Ready", + anchor=tk.W, + font=("TkDefaultFont", 9), + padx=10, + pady=2, + bg=colors["bg"], + fg=colors["fg"], + ) + self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + + self.file_info_label = tk.Label( + self.status_bar, + text="", + anchor=tk.E, + font=("TkDefaultFont", 9), + padx=10, + pady=2, + bg=colors["bg"], + fg=colors["fg"], + ) + self.file_info_label.pack(side=tk.RIGHT) + + self.last_backup_label = tk.Label( + self.status_bar, + text="Last backup: —", + anchor=tk.E, + font=("TkDefaultFont", 9), + padx=10, + pady=2, + bg=colors["bg"], + fg=colors["fg"], + ) + self.last_backup_label.pack(side=tk.RIGHT) + + self.filter_hint_label = tk.Label( + self.status_bar, + text="", + anchor=tk.E, + font=("TkDefaultFont", 9), + padx=8, + pady=2, + bg=colors["bg"], + fg="#6c757d", + ) + self.filter_hint_label.pack(side=tk.RIGHT) + + return self.status_bar + + def update_file_info( + self, filename: str, entry_count: int = 0, filter_status: str | None = None + ) -> None: + if not self.file_info_label: + return + file_display = os.path.basename(filename) if filename else "No file" + info = f"{file_display}" + if entry_count: + info += f" ({entry_count} entries" + if filter_status: + info += f", {filter_status}" + info += ")" + self.file_info_label.config(text=info) + + def show_toast(self, message: str, duration_ms: int = 3000) -> None: + try: + toast = tk.Toplevel(self.root) + toast.overrideredirect(True) + toast.attributes("-topmost", True) + + colors = self.theme_manager.get_theme_colors() + bg = colors.get("alt_bg", "#333333") + fg = colors.get("fg", "#000000") + + frame = tk.Frame(toast, bg=bg, bd=1, relief=tk.SOLID) + frame.pack(fill=tk.BOTH, expand=True) + label = tk.Label( + frame, + text=message, + bg=bg, + fg=fg, + padx=12, + pady=8, + font=("TkDefaultFont", 9), + ) + label.pack() + + self.root.update_idletasks() + rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty() + rw, rh = self.root.winfo_width(), self.root.winfo_height() + toast.update_idletasks() + tw = toast.winfo_width() or 240 + th = toast.winfo_height() or 48 + x = rx + rw - tw - 20 + y = ry + rh - th - 20 + toast.geometry(f"{tw}x{th}+{max(0, x)}+{max(0, y)}") + toast.after(duration_ms, toast.destroy) + except Exception: + pass + + def set_filter_hint(self, active: bool, text: str | None = None) -> None: + if not getattr(self, "filter_hint_label", None): + return + self.filter_hint_label.config(text=(text or "Filters active") if active else "") + + def normalize_tree_stripes(self, tree: ttk.Treeview) -> None: + try: + for idx, item in enumerate(tree.get_children("")): + tag = "evenrow" if idx % 2 == 0 else "oddrow" + tree.item(item, tags=(tag,)) + except Exception: + pass + + def reapply_last_sort(self, tree: ttk.Treeview) -> None: + try: + if ( + getattr(self, "_last_sorted_column", None) is None + or getattr(self, "_last_sorted_ascending", None) is None + ): + return + self._sort_treeview( + tree, self._last_sorted_column, bool(self._last_sorted_ascending) + ) + except Exception: + pass + + def create_edit_window( + self, values: tuple[str, ...], callbacks: dict[str, Callable] + ) -> tk.Toplevel: + """Minimal edit window allowing date and note changes. + + This simplified version passes missing pathology/medicine values as zeros + and an empty dose mapping to the caller's save callback for compatibility. + """ + win = tk.Toplevel(master=self.root) + win.title("Edit Entry") + win.transient(self.root) + win.minsize(400, 240) + + container = ttk.Frame(win, padding=12) + container.pack(fill=tk.BOTH, expand=True) + + ttk.Label(container, text="Date (mm/dd/yyyy):").grid( + row=0, column=0, sticky="w" + ) + date_var = tk.StringVar(value=values[0] if values else "") + ttk.Entry( + container, textvariable=date_var, justify="center", style="Modern.TEntry" + ).grid(row=0, column=1, sticky="ew", padx=8, pady=4) + + ttk.Label(container, text="Note:").grid(row=1, column=0, sticky="w") + note_val = values[-1] if values else "" + note_var = tk.StringVar(value=str(note_val)) + ttk.Entry(container, textvariable=note_var, style="Modern.TEntry").grid( + row=1, column=1, sticky="ew", padx=8, pady=4 + ) + + container.grid_columnconfigure(1, weight=1) + + buttons = ttk.Frame(container) + buttons.grid(row=2, column=0, columnspan=2, pady=10) + + def _on_save(): + # Only provide date and note; caller will default others. + with suppress(Exception): + callbacks.get("save")(win, date_var.get(), note_var.get(), {}) + + def _on_delete(): + with suppress(Exception): + callbacks.get("delete")(win) + + ttk.Button(buttons, text="Save", command=_on_save, style="Action.TButton").pack( + side="left", padx=5 + ) + ttk.Button(buttons, text="Delete", command=_on_delete).pack(side="left", padx=5) + + return win diff --git a/src/thechart/validation/__init__.py b/src/thechart/validation/__init__.py new file mode 100644 index 0000000..c468047 --- /dev/null +++ b/src/thechart/validation/__init__.py @@ -0,0 +1,7 @@ +"""Validation utilities public API for the thechart package.""" + +from __future__ import annotations + +from .input_validator import InputValidator # re-export + +__all__ = ["InputValidator"] diff --git a/src/thechart/validation/input_validator.py b/src/thechart/validation/input_validator.py new file mode 100644 index 0000000..ac46887 --- /dev/null +++ b/src/thechart/validation/input_validator.py @@ -0,0 +1,296 @@ +"""Input validation utilities for TheChart application. + +This is the canonical implementation, migrated under the thechart package. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any + + +class InputValidator: + """Handles input validation for various data types in the application.""" + + @staticmethod + def validate_date(date_str: str) -> tuple[bool, str, datetime | None]: + """ + Validate date string and return parsed datetime if valid. + + Args: + date_str: Date string to validate + + Returns: + Tuple of (is_valid, error_message, parsed_date) + """ + if not date_str or not date_str.strip(): + return False, "Date cannot be empty", None + + date_str = date_str.strip() + + # Common date formats to try + date_formats = [ + "%m/%d/%Y", # 01/15/2025 + "%m-%d-%Y", # 01-15-2025 + "%Y-%m-%d", # 2025-01-15 + "%m/%d/%y", # 01/15/25 + "%m-%d-%y", # 01-15-25 + ] + + for date_format in date_formats: + try: + parsed_date = datetime.strptime(date_str, date_format) + # Check for reasonable date range (not too far in past/future) + current_year = datetime.now().year + if not (1900 <= parsed_date.year <= current_year + 10): + continue + return True, "", parsed_date + except ValueError: + continue + + return False, "Invalid date format. Use MM/DD/YYYY format.", None + + @staticmethod + def validate_pathology_score(score: Any) -> tuple[bool, str, int]: + """ + Validate pathology score (0-10 scale). + + Args: + score: Score value to validate + + Returns: + Tuple of (is_valid, error_message, validated_score) + """ + try: + score_int = int(score) + if 0 <= score_int <= 10: + return True, "", score_int + else: + return False, "Pathology score must be between 0 and 10", 0 + except (ValueError, TypeError): + return False, "Pathology score must be a valid number", 0 + + @staticmethod + def validate_medicine_taken(taken: Any) -> tuple[bool, str, int]: + """ + Validate medicine taken boolean (0 or 1). + + Args: + taken: Boolean-like value to validate + + Returns: + Tuple of (is_valid, error_message, validated_value) + """ + try: + taken_int = int(taken) + if taken_int in (0, 1): + return True, "", taken_int + else: + return False, "Medicine taken must be 0 (not taken) or 1 (taken)", 0 + except (ValueError, TypeError): + return False, "Medicine taken must be a valid boolean value", 0 + + @staticmethod + def validate_dose_amount(dose_str: str) -> tuple[bool, str, str]: + """ + Validate dose amount string. + + Args: + dose_str: Dose string to validate + + Returns: + Tuple of (is_valid, error_message, cleaned_dose) + """ + if not dose_str: + return True, "", "" # Empty dose is valid + + dose_str = dose_str.strip() + + # Allow alphanumeric characters, spaces, periods, and common dose units + if re.match(r"^[\w\s\./\-\+]+$", dose_str): + # Limit length to prevent extremely long entries + if len(dose_str) <= 50: + return True, "", dose_str + else: + return ( + False, + "Dose description too long (max 50 characters)", + dose_str[:50], + ) + else: + return False, "Dose contains invalid characters", "" + + @staticmethod + def validate_note(note_str: str) -> tuple[bool, str, str]: + """ + Validate and sanitize note text. + + Args: + note_str: Note string to validate + + Returns: + Tuple of (is_valid, error_message, cleaned_note) + """ + if not note_str: + return True, "", "" # Empty note is valid + + note_str = note_str.strip() + + # Remove any potential harmful characters while preserving readability + cleaned_note = re.sub(r"[^\w\s\.,\!\?\:\;\-\(\)\[\]\'\"]+", "", note_str) + + # Limit length + if len(cleaned_note) <= 500: + return True, "", cleaned_note + else: + return False, "Note too long (max 500 characters)", cleaned_note[:500] + + @staticmethod + def validate_filename(filename: str) -> tuple[bool, str, str]: + """ + Validate filename for export operations. + + Args: + filename: Filename to validate + + Returns: + Tuple of (is_valid, error_message, cleaned_filename) + """ + if not filename or not filename.strip(): + return False, "Filename cannot be empty", "" + + filename = filename.strip() + + # Remove/replace invalid filename characters + invalid_chars = r'[<>:"/\\|?*]' + cleaned_filename = re.sub(invalid_chars, "_", filename) + + # Ensure reasonable length + if len(cleaned_filename) <= 100: + return True, "", cleaned_filename + else: + return ( + False, + "Filename too long (max 100 characters)", + cleaned_filename[:100], + ) + + @staticmethod + def validate_time_format(time_str: str) -> tuple[bool, str, datetime | None]: + """ + Validate time string for dose tracking. + + Args: + time_str: Time string to validate + + Returns: + Tuple of (is_valid, error_message, parsed_time) + """ + if not time_str or not time_str.strip(): + return False, "Time cannot be empty", None + + time_str = time_str.strip() + + # Common time formats + time_formats = [ + "%I:%M %p", # 02:30 PM + "%H:%M", # 14:30 + "%I:%M%p", # 2:30PM (no space) + "%I%p", # 2PM + ] + + for time_format in time_formats: + try: + parsed_time = datetime.strptime(time_str, time_format) + return True, "", parsed_time + except ValueError: + continue + + return False, "Invalid time format. Use HH:MM AM/PM or HH:MM (24-hour)", None + + @staticmethod + def sanitize_csv_field(field_str: str) -> str: + """ + Sanitize field for CSV output to prevent injection attacks. + + Args: + field_str: Field string to sanitize + + Returns: + Sanitized string safe for CSV + """ + if not isinstance(field_str, str): + field_str = str(field_str) + + # Remove potential CSV injection characters + dangerous_prefixes = ["=", "+", "-", "@"] + cleaned = field_str.strip() + + # If field starts with dangerous character, prepend space + if cleaned and cleaned[0] in dangerous_prefixes: + cleaned = " " + cleaned + + return cleaned + + @staticmethod + def validate_entry_completeness( + entry_data: dict[str, Any], + ) -> tuple[bool, list[str]]: + """ + Backward-compat entry completeness check. + + Delegates to validate_entry_completeness_with_keys when possible. + """ + # Heuristic split: treat keys ending with _doses and note/date as + # non-core and assume the rest are a mix of pathologies and medicines; + # callers should prefer the explicit API below. + keys = [ + k + for k in entry_data + if k not in {"date", "note"} and not str(k).endswith("_doses") + ] + # Even split guess is unreliable; use value patterns instead: + path_keys = [k for k in keys if isinstance(entry_data.get(k), int | float)] + med_keys = [k for k in keys if k not in path_keys] + return InputValidator.validate_entry_completeness_with_keys( + entry_data, path_keys, med_keys + ) + + @staticmethod + def validate_entry_completeness_with_keys( + entry_data: dict[str, Any], + pathology_keys: list[str], + medicine_keys: list[str], + ) -> tuple[bool, list[str]]: + """ + Validate that an entry has the minimum required data using explicit keys. + + Args: + entry_data: Dictionary containing entry data + pathology_keys: Keys representing pathology scores (numeric, >0 meaningful) + medicine_keys: Keys representing medicine taken flags (0/1 boolean) + + Returns: + Tuple of (is_complete, list_of_missing_fields) + """ + missing_fields: list[str] = [] + if not entry_data.get("date"): + missing_fields.append("Date") + + def _as_int(v: Any) -> int: + try: + return int(v) + except Exception: + try: + return int(float(v)) + except Exception: + return 0 + + has_pathology = any(_as_int(entry_data.get(k, 0)) > 0 for k in pathology_keys) + has_medicine = any(_as_int(entry_data.get(k, 0)) == 1 for k in medicine_keys) + + if not (has_pathology or has_medicine): + missing_fields.append("At least one pathology score or medicine entry") + + return len(missing_fields) == 0, missing_fields diff --git a/src/theme_manager.py b/src/theme_manager.py index 699d6e1..79e7f99 100644 --- a/src/theme_manager.py +++ b/src/theme_manager.py @@ -1,445 +1,11 @@ -"""Theme manager for the application using ttkthemes.""" +"""Legacy shim for ThemeManager. -import logging -import tkinter as tk -from tkinter import ttk +This preserves backward compatibility for imports like: + from theme_manager import ThemeManager -from ttkthemes import ThemedStyle +Canonical implementation lives in: thechart.ui.theme_manager +""" +from thechart.ui.theme_manager import ThemeManager # noqa: F401 -class ThemeManager: - """Manages application themes and styling.""" - - def __init__(self, root: tk.Tk, logger: logging.Logger) -> None: - self.root = root - self.logger = logger - self.style: ThemedStyle | None = None - self.current_theme: str = "arc" # Default theme - - # Available themes - these are some of the best looking ones - self.available_themes = [ - "arc", - "equilux", - "adapta", - "yaru", - "ubuntu", - "plastik", - "breeze", - "elegance", - ] - - self.initialize_theme() - - def initialize_theme(self) -> None: - """Initialize the themed style.""" - try: - self.style = ThemedStyle(self.root) - self.apply_theme(self.current_theme) - self._configure_custom_styles() - self.logger.info( - f"Theme manager initialized with theme: {self.current_theme}" - ) - except Exception as e: - self.logger.error(f"Failed to initialize theme manager: {e}") - # Fallback to default ttk styling - self.style = ttk.Style() - - def apply_theme(self, theme_name: str) -> bool: - """Apply a specific theme.""" - try: - if self.style and theme_name in self.get_available_themes(): - self.style.set_theme(theme_name) - self.current_theme = theme_name - self._configure_custom_styles() - self.logger.info(f"Applied theme: {theme_name}") - return True - else: - self.logger.warning(f"Theme '{theme_name}' not available") - return False - except Exception as e: - self.logger.error(f"Failed to apply theme '{theme_name}': {e}") - return False - - def get_available_themes(self) -> list[str]: - """Get list of available themes.""" - if self.style: - try: - # Get all available themes from ttkthemes - all_themes = self.style.theme_names() - # Filter to only include our curated list - return [theme for theme in self.available_themes if theme in all_themes] - except Exception as e: - self.logger.error(f"Failed to get available themes: {e}") - return self.available_themes - return self.available_themes - - def get_current_theme(self) -> str: - """Get the currently active theme.""" - return self.current_theme - - def _get_contrasting_colors(self, colors: dict[str, str]) -> dict[str, str]: - """Get contrasting colors for headers with improved visibility.""" - - def get_luminance(color_str: str) -> float: - """Calculate relative luminance of a color.""" - if not color_str or not color_str.startswith("#"): - return 0.5 - try: - rgb = tuple(int(color_str[i : i + 2], 16) for i in (1, 3, 5)) - # Calculate relative luminance - return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255 - except (ValueError, IndexError): - return 0.5 - - def get_contrast_ratio(bg: str, fg: str) -> float: - """Calculate contrast ratio between two colors.""" - bg_lum = get_luminance(bg) - fg_lum = get_luminance(fg) - lighter = max(bg_lum, fg_lum) - darker = min(bg_lum, fg_lum) - return (lighter + 0.05) / (darker + 0.05) - - # Start with the provided select colors - header_bg = colors["select_bg"] - header_fg = colors["select_fg"] - - # Calculate contrast ratio - contrast = get_contrast_ratio(header_bg, header_fg) - - # If contrast is poor (less than 3:1), use high-contrast alternatives - if contrast < 3.0: - bg_luminance = get_luminance(colors["bg"]) - - if bg_luminance > 0.5: # Light theme - header_bg = "#1e1e1e" # Very dark gray background for maximum contrast - header_fg = "#ffffff" # Pure white for maximum contrast - else: # Dark theme - use dark background with light text - header_bg = "#1e1e1e" # Very dark gray for consistency - header_fg = "#ffffff" # Pure white for maximum contrast - - self.logger.debug( - f"Poor header contrast ({contrast:.2f}), using fallback colors: " - f"bg={header_bg}, fg={header_fg}" - ) - - return { - "header_bg": header_bg, - "header_fg": header_fg, - } - - def _configure_custom_styles(self) -> None: - """Configure custom styles for better appearance.""" - if not self.style: - return - - try: - # Get current theme colors for consistent styling - colors = self.get_theme_colors() - - # Get improved header colors with better contrast - header_colors = self._get_contrasting_colors(colors) - - # Configure frame styles with better padding and borders - self.style.configure( - "Card.TFrame", - relief="flat", - borderwidth=0, - background=colors["bg"], - ) - - # Configure label frame styles with modern appearance - self.style.configure( - "Card.TLabelframe", - relief="solid", - borderwidth=1, - background=colors["bg"], - foreground=colors["fg"], - padding=(10, 5, 10, 10), - ) - - self.style.configure( - "Card.TLabelframe.Label", - background=colors["bg"], - foreground=colors["fg"], - font=("TkDefaultFont", 10, "bold"), - ) - - # Configure button styles for better appearance - self.style.configure( - "Action.TButton", - padding=(15, 8), - font=("TkDefaultFont", 9, "normal"), - ) - - # Configure entry styles with modern look - self.style.configure( - "Modern.TEntry", - padding=(8, 5), - borderwidth=1, - relief="solid", - ) - - # Configure scale styles for pathology inputs - self.style.configure( - "Modern.Horizontal.TScale", - borderwidth=0, - background=colors["bg"], - troughcolor="#e0e0e0", - lightcolor=colors["select_bg"], - darkcolor=colors["select_bg"], - focuscolor=colors["select_bg"], - ) - - # Configure treeview for better data display - self.style.configure( - "Modern.Treeview", - rowheight=28, - borderwidth=1, - relief="solid", - background=colors["bg"], - foreground=colors["fg"], - fieldbackground=colors["bg"], - selectbackground=colors["select_bg"], - selectforeground=colors["select_fg"], - ) - - self.style.configure( - "Modern.Treeview.Heading", - padding=(8, 6), - relief="flat", - borderwidth=1, - background=header_colors["header_bg"], - foreground=header_colors["header_fg"], - font=("TkDefaultFont", 9, "bold"), - ) - - # Ensure header style mapping to override theme defaults - self.style.map( - "Modern.Treeview.Heading", - background=[ - ("active", header_colors["header_bg"]), - ("pressed", header_colors["header_bg"]), - ("", header_colors["header_bg"]), - ], - foreground=[ - ("active", header_colors["header_fg"]), - ("pressed", header_colors["header_fg"]), - ("", header_colors["header_fg"]), - ], - ) - - # Configure comprehensive row selection colors for better visibility - self.style.map( - "Modern.Treeview", - background=[ - ("selected", colors["select_bg"]), - ("active", colors["select_bg"]), - ("focus", colors["select_bg"]), - ("", colors["bg"]), - ], - foreground=[ - ("selected", colors["select_fg"]), - ("active", colors["select_fg"]), - ("focus", colors["select_fg"]), - ("", colors["fg"]), - ], - selectbackground=[ - ("focus", colors["select_bg"]), - ("", colors["select_bg"]), - ], - selectforeground=[ - ("focus", colors["select_fg"]), - ("", colors["select_fg"]), - ], - ) - - # Configure notebook tabs with modern styling - self.style.configure( - "Modern.TNotebook.Tab", - padding=(15, 8), - borderwidth=1, - relief="flat", - ) - - self.style.map( - "Modern.TNotebook.Tab", - background=[("selected", colors["select_bg"])], - foreground=[("selected", colors["select_fg"])], - ) - - # Configure checkbutton for medicine selection - self.style.configure( - "Modern.TCheckbutton", - padding=(8, 4), - background=colors["bg"], - foreground=colors["fg"], - focuscolor=colors["select_bg"], - ) - - self.logger.debug("Enhanced custom styles configured") - - except Exception as e: - self.logger.error(f"Failed to configure custom styles: {e}") - - def get_menu_colors(self) -> dict[str, str]: - """Get colors specifically for menu theming.""" - colors = self.get_theme_colors() - - # Use slightly different colors for menus to make them stand out - try: - # For menu background, use a slightly darker/lighter shade - if colors["bg"].startswith("#"): - rgb = tuple(int(colors["bg"][i : i + 2], 16) for i in (1, 3, 5)) - if sum(rgb) > 384: # Light theme - make menu slightly darker - menu_bg = ( - f"#{max(0, rgb[0] - 8):02x}" - f"{max(0, rgb[1] - 8):02x}" - f"{max(0, rgb[2] - 8):02x}" - ) - else: # Dark theme - make menu slightly lighter - menu_bg = ( - f"#{min(255, rgb[0] + 15):02x}" - f"{min(255, rgb[1] + 15):02x}" - f"{min(255, rgb[2] + 15):02x}" - ) - else: - menu_bg = colors["bg"] - except (ValueError, IndexError): - menu_bg = colors["bg"] - - return { - "bg": menu_bg, - "fg": colors["fg"], - "active_bg": colors["select_bg"], - "active_fg": colors["select_fg"], - "disabled_fg": colors.get("disabled_fg", "#888888"), - } - - def configure_menu(self, menu: "tk.Menu") -> None: - """Apply theme colors to a menu widget.""" - try: - menu_colors = self.get_menu_colors() - - menu.configure( - background=menu_colors["bg"], - foreground=menu_colors["fg"], - activebackground=menu_colors["active_bg"], - activeforeground=menu_colors["active_fg"], - disabledforeground=menu_colors["disabled_fg"], - relief="flat", - borderwidth=1, - ) - - self.logger.debug(f"Applied theme to menu: {menu_colors}") - - except Exception as e: - self.logger.error(f"Failed to configure menu theme: {e}") - - def create_themed_menu(self, parent: "tk.Widget", **kwargs) -> "tk.Menu": - """Create a new menu with theme colors already applied.""" - try: - menu = tk.Menu(parent, **kwargs) - self.configure_menu(menu) - return menu - except Exception as e: - self.logger.error(f"Failed to create themed menu: {e}") - # Fallback to a minimally constructed menu without theming - try: - return tk.Menu(parent) - except Exception: - # As a last resort, return a dummy object that quacks like a Menu - class _DummyMenu: - def __init__(self) -> None: - self._options = {} - - def __getitem__(self, key): # support menu['tearoff'] tests - return self._options.get(key, 0) - - def configure(self, **_kw): - self._options.update(_kw) - - return _DummyMenu() - - def configure_widget_style(self, widget: tk.Widget, style_name: str) -> None: - """Apply a specific style to a widget.""" - try: - if hasattr(widget, "configure") and self.style: - widget.configure(style=style_name) - except Exception as e: - self.logger.error(f"Failed to configure widget style '{style_name}': {e}") - - def get_theme_colors(self) -> dict[str, str]: - """Get current theme colors for custom widgets.""" - if not self.style: - return { - "bg": "#ffffff", - "fg": "#000000", - "select_bg": "#3584e4", - "select_fg": "#ffffff", - "alt_bg": "#f5f5f5", - } - - try: - # Get colors from current theme and convert to strings - bg = str(self.style.lookup("TFrame", "background") or "#ffffff") - fg = str(self.style.lookup("TLabel", "foreground") or "#000000") - - # Try to get better selection colors from different widget states - select_bg = str( - self.style.lookup("TButton", "background", ["pressed"]) - or self.style.lookup("TButton", "background", ["active"]) - or self.style.lookup("Treeview", "selectbackground") - or "#0078d4" # Modern blue fallback - ) - select_fg = str( - self.style.lookup("TButton", "foreground", ["pressed"]) - or self.style.lookup("TButton", "foreground", ["active"]) - or self.style.lookup("Treeview", "selectforeground") - or "#ffffff" # White fallback - ) - - # Ensure contrast - if selection colors are too similar to background, - # use fallbacks - if select_bg == bg or select_bg.lower() == bg.lower(): - select_bg = "#0078d4" if bg != "#0078d4" else "#0066cc" - - if select_fg == fg or select_fg.lower() == fg.lower(): - select_fg = "#ffffff" if fg != "#ffffff" else "#000000" - - # Calculate alternating row color - if bg.startswith("#"): - try: - rgb = tuple(int(bg[i : i + 2], 16) for i in (1, 3, 5)) - if sum(rgb) > 384: # Light theme - alt_bg = ( - f"#{max(0, rgb[0] - 10):02x}" - f"{max(0, rgb[1] - 10):02x}" - f"{max(0, rgb[2] - 10):02x}" - ) - else: # Dark theme - alt_bg = ( - f"#{min(255, rgb[0] + 10):02x}" - f"{min(255, rgb[1] + 10):02x}" - f"{min(255, rgb[2] + 10):02x}" - ) - except ValueError: - alt_bg = "#f5f5f5" - else: - alt_bg = "#f5f5f5" - - return { - "bg": bg, - "fg": fg, - "select_bg": select_bg, - "select_fg": select_fg, - "alt_bg": alt_bg, # Add alternating background color - } - except Exception as e: - self.logger.error(f"Failed to get theme colors: {e}") - return { - "bg": "#ffffff", - "fg": "#000000", - "select_bg": "#3584e4", - "select_fg": "#ffffff", - "alt_bg": "#f5f5f5", - } +__all__ = ["ThemeManager"] diff --git a/src/tooltip_system.py b/src/tooltip_system.py index 4dec2a4..4250da6 100644 --- a/src/tooltip_system.py +++ b/src/tooltip_system.py @@ -1,163 +1,11 @@ -"""Tooltip system for enhanced user experience.""" +"""Legacy shim for tooltip system. -import tkinter as tk +This preserves backward compatibility for imports like: + from tooltip_system import TooltipManager, ToolTip +Canonical implementation lives in: thechart.ui.tooltip_system +""" -class ToolTip: - """Create a tooltip for a given widget.""" +from thechart.ui.tooltip_system import ToolTip, TooltipManager # noqa: F401 - def __init__( - self, - widget: tk.Widget, - text: str, - delay: int = 500, - wrap_length: int = 250, - ) -> None: - self.widget = widget - self.text = text - self.delay = delay - self.wrap_length = wrap_length - self.tooltip: tk.Toplevel | None = None - self.id_after: str | None = None - - # Bind events - self.widget.bind("", self._on_enter) - self.widget.bind("", self._on_leave) - self.widget.bind("", self._on_leave) - - def _on_enter(self, event: tk.Event | None = None) -> None: - """Mouse entered widget - schedule tooltip.""" - self._cancel_scheduled() - self.id_after = self.widget.after(self.delay, self._show_tooltip) - - def _on_leave(self, event: tk.Event | None = None) -> None: - """Mouse left widget - hide tooltip.""" - self._cancel_scheduled() - self._hide_tooltip() - - def _cancel_scheduled(self) -> None: - """Cancel any scheduled tooltip.""" - if self.id_after: - self.widget.after_cancel(self.id_after) - self.id_after = None - - def _show_tooltip(self) -> None: - """Display the tooltip.""" - if self.tooltip: - return - - # Get widget position - x = self.widget.winfo_rootx() + 25 - y = self.widget.winfo_rooty() + 25 - - # Create tooltip window - self.tooltip = tk.Toplevel(self.widget) - self.tooltip.wm_overrideredirect(True) - self.tooltip.wm_geometry(f"+{x}+{y}") - - # Create tooltip content - label = tk.Label( - self.tooltip, - text=self.text, - justify="left", - background="#ffffe0", - foreground="#000000", - relief="solid", - borderwidth=1, - font=("TkDefaultFont", "9", "normal"), - wraplength=self.wrap_length, - padx=8, - pady=6, - ) - label.pack() - - # Make sure tooltip appears above other windows - self.tooltip.lift() - - def _hide_tooltip(self) -> None: - """Hide the tooltip.""" - if self.tooltip: - self.tooltip.destroy() - self.tooltip = None - - def update_text(self, new_text: str) -> None: - """Update the tooltip text.""" - self.text = new_text - - -class TooltipManager: - """Manages tooltips for UI elements.""" - - def __init__(self, theme_manager) -> None: - self.theme_manager = theme_manager - self.tooltips: list[ToolTip] = [] - - def add_tooltip( - self, - widget: tk.Widget, - text: str, - delay: int = 500, - wrap_length: int = 250, - ) -> ToolTip: - """Add a tooltip to a widget.""" - tooltip = ToolTip(widget, text, delay, wrap_length) - self.tooltips.append(tooltip) - return tooltip - - def add_scale_tooltip(self, scale_widget: tk.Widget, pathology_name: str) -> None: - """Add a specialized tooltip for pathology scales.""" - text = ( - f"Adjust your {pathology_name} level\\n" - "• Drag the slider to set your current level\\n" - "• Higher values typically indicate worse symptoms\\n" - "• Use the full range for accurate tracking" - ) - self.add_tooltip(scale_widget, text, delay=800) - - def add_medicine_tooltip(self, widget: tk.Widget, medicine_name: str) -> None: - """Add a specialized tooltip for medicine checkboxes.""" - text = ( - f"Mark if you took {medicine_name} today\\n" - "• Check the box when you've taken this medication\\n" - "• This helps track your medication adherence\\n" - "• You can add dose details when editing entries" - ) - self.add_tooltip(widget, text, delay=600) - - def add_button_tooltip(self, widget: tk.Widget, action: str) -> None: - """Add a tooltip for action buttons.""" - tooltips_map = { - "save": ( - "Save your current entry (Ctrl+S)\\nThis will add a new daily record" - ), - "export": ( - "Export your data to various formats\\n" - "Supports CSV, PDF, and image exports" - ), - "refresh": ( - "Reload data from file (F5)\\nUpdates the display with latest changes" - ), - "settings": ( - "Open application settings (F2)\\nCustomize themes and preferences" - ), - "quit": ( - "Exit the application (Ctrl+Q)\\nYour data will be automatically saved" - ), - } - - text = tooltips_map.get(action, f"Perform {action} action") - self.add_tooltip(widget, text, delay=400) - - def add_menu_tooltip(self, widget: tk.Widget, menu_type: str) -> None: - """Add tooltips for menu items.""" - tooltips_map = { - "theme": ( - "Quick theme selection\\nClick to instantly change the app's appearance" - ), - "file": "File operations\\nExport data and manage files", - "tools": ("Data management tools\\nConfigure medicines and pathologies"), - "help": ("Get help and information\\nKeyboard shortcuts and about dialog"), - } - - text = tooltips_map.get(menu_type, "Menu options") - self.add_tooltip(widget, text, delay=600) +__all__ = ["ToolTip", "TooltipManager"] diff --git a/src/undo_manager.py b/src/undo_manager.py index 45d4a8c..896a991 100644 --- a/src/undo_manager.py +++ b/src/undo_manager.py @@ -1,33 +1,7 @@ -"""Undo stack for add/update/delete operations.""" +"""Compatibility shim for undo utilities.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass +from thechart.core.undo_manager import UndoAction, UndoManager # noqa: F401 - -@dataclass -class UndoAction: - description: str - undo_callable: Callable[[], None] - - -class UndoManager: - def __init__(self, capacity: int = 20) -> None: - self.capacity = capacity - self._stack: list[UndoAction] = [] - - def push(self, action: UndoAction) -> None: - self._stack.append(action) - if len(self._stack) > self.capacity: - self._stack.pop(0) - - def undo(self) -> str | None: - if not self._stack: - return None - action = self._stack.pop() - action.undo_callable() - return action.description - - def has_actions(self) -> bool: - return bool(self._stack) +__all__ = ["UndoAction", "UndoManager"]