import contextlib import os import sys import tkinter as tk from collections.abc import Callable from datetime import datetime from tkinter import filedialog, messagebox, ttk 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 search_filter_ui import SearchFilterWidget from settings_window import SettingsWindow from theme_manager import ThemeManager from ui_manager import UIManager from undo_manager import UndoAction, UndoManager class MedTrackerApp: def __init__(self, root: tk.Tk) -> None: self.root: tk.Tk = root self.root.resizable(True, True) self.root.title("Thechart - medication tracker") self.root.protocol("WM_DELETE_WINDOW", self.handle_window_closing) # Live geometry persistence state self._geom_save_job: str | None = None self._last_saved_geometry: str = "" # Set up data file self.filename: str = "thechart_data.csv" first_argument: str = "" if len(sys.argv) > 1: first_argument = sys.argv[1] if os.path.exists(first_argument): self.filename = first_argument logger.info(f"Using data file: {first_argument}") else: logger.warning( "Data file %s doesn't exist. Using default file: %s", first_argument, self.filename, ) logger.info(f"Log level: {LOG_LEVEL}") # Initialize theme manager first self.theme_manager: ThemeManager = ThemeManager(self.root, logger) # Initialize error handler self.error_handler = ErrorHandler(logger) if LOG_LEVEL == "DEBUG": logger.debug(f"Script name: {sys.argv[0]}") logger.debug(f"Logs path: {LOG_PATH}") logger.debug(f"Log clear: {LOG_CLEAR}") logger.debug(f"First argument: {first_argument}") # Initialize managers self.medicine_manager: MedicineManager = MedicineManager(logger=logger) self.pathology_manager: PathologyManager = PathologyManager(logger=logger) self.ui_manager: UIManager = UIManager( root, logger, self.medicine_manager, self.pathology_manager, self.theme_manager, ) # Undo manager (history of data mutations) self.undo_manager: UndoManager = UndoManager() # Update error handler with UI manager for user feedback self.error_handler.ui_manager = self.ui_manager self.data_manager: DataManager = DataManager( self.filename, logger, self.medicine_manager, self.pathology_manager ) # Set up application icon icon_path: str = "chart-671.png" if not os.path.exists(icon_path) and os.path.exists("./chart-671.png"): icon_path = "./chart-671.png" self.ui_manager.setup_application_icon(img_path=icon_path) # Initialize auto-save and backup managers self.auto_save_manager = AutoSaveManager( save_callback=self._auto_save_callback, interval_minutes=5, logger=logger ) self.backup_manager = BackupManager( data_file_path=self.filename, logger=logger, status_callback=self._on_backup_status, ) # Initialize search/filter system self.data_filter = DataFilter() self.current_filtered_data = None self.current_filtered_data: pd.DataFrame | None = None # Set up the main application UI self._setup_main_ui() # Add menu bar self._setup_menu() # Setup keyboard shortcuts self._setup_keyboard_shortcuts() # Apply window preferences (geometry, always-on-top) then center if needed with contextlib.suppress(Exception): self.root.wm_attributes("-topmost", bool(get_pref("always_on_top", False))) geom = str(get_pref("last_window_geometry", "")) if get_pref("remember_window_geometry", True) and geom: try: self.root.geometry(geom) except Exception: self._center_window() else: # Center the window on screen self._center_window() # Bind configure to persist geometry live (debounced) try: self.root.bind("", self._on_configure, add="+") except Exception: # Older Tk variants may not support add; fall back self.root.bind("", self._on_configure) # Enable auto-save by default self.auto_save_manager.enable_auto_save() # Create initial backup self.backup_manager.create_backup("startup") def _on_configure(self, _event: object | None = None) -> None: """Debounce window configure events to persist geometry live.""" # Skip when user disabled remembering geometry with contextlib.suppress(Exception): if not get_pref("remember_window_geometry", True): return # Avoid saving while minimized with contextlib.suppress(Exception): if getattr(self.root, "state", lambda: "normal")() == "iconic": return # Debounce saves to limit disk writes if self._geom_save_job is not None: with contextlib.suppress(Exception): self.root.after_cancel(self._geom_save_job) self._geom_save_job = None with contextlib.suppress(Exception): self._geom_save_job = self.root.after(600, self._save_geometry_now) def _save_geometry_now(self) -> None: """Capture current geometry and persist to preferences if changed.""" try: geom = self.root.geometry() if geom and geom != self._last_saved_geometry: set_pref("last_window_geometry", geom) save_preferences() self._last_saved_geometry = geom except Exception: pass def _on_backup_status(self, msg: str) -> None: """Handle backup-related status updates with status bar and toast.""" try: self.ui_manager.update_status(msg, "success") # Show a brief toast for backup events if available if hasattr(self.ui_manager, "show_toast"): # Keep toast short to avoid annoyance during startup/shutdown self.ui_manager.show_toast(msg, 1500) # Update 'Last backup' indicator on backup creation if "Backup created:" in msg and hasattr( self.ui_manager, "update_last_backup" ): when = datetime.now().strftime("%Y-%m-%d %H:%M") self.ui_manager.update_last_backup(when) except Exception as exc: logger.error(f"Failed to show backup status: {exc}") def _restore_from_backup(self) -> None: """Prompt user to select a backup CSV and restore it.""" initial_dir = getattr(self.backup_manager, "backup_directory", os.getcwd()) file_path = filedialog.askopenfilename( parent=self.root, title="Restore from Backup", initialdir=initial_dir, filetypes=[("CSV Files", "*.csv"), ("All Files", "*.*")], ) if not file_path: return # Build a detailed confirmation with file info try: size_b = os.path.getsize(file_path) def _fmt_size(n: int) -> str: for unit in ["B", "KB", "MB", "GB"]: if n < 1024: return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" n /= 1024 return f"{n:.1f} TB" mtime = datetime.fromtimestamp(os.path.getmtime(file_path)) mtime_str = mtime.strftime("%Y-%m-%d %H:%M") confirm_msg = ( "You're about to restore data from this backup file:\n\n" f"• File: {os.path.basename(file_path)}\n" f"• Size: {_fmt_size(size_b)}\n" f"• Modified: {mtime_str}\n\n" f"This will replace: {os.path.abspath(self.filename)}\n\n" "A pre-restore backup of the current data will be created.\n\n" "Proceed with restore?" ) except Exception: confirm_msg = "Restore selected backup? Current data will be saved first." if not messagebox.askyesno("Confirm Restore", confirm_msg, parent=self.root): return try: # Create a safety backup of the current data before restoring try: self.backup_manager.create_backup("pre_restore") except Exception as _exc: logger.warning(f"Pre-restore backup failed: {_exc}") ok = self.backup_manager.restore_from_backup(file_path) if ok: if hasattr(self.data_manager, "_invalidate_cache"): self.data_manager._invalidate_cache() self.refresh_data_display() base = os.path.basename(file_path) self.ui_manager.update_status(f"Restored from: {base}", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast(f"Restored: {base}", 1800) # Offer to open the folder containing the restored file (if opted-in) try: if get_pref( "prompt_open_folder_after_restore", False ) and messagebox.askyesno( "Restore Complete", ( f"Restored from '{base}'.\n\n" "Open the containing backups folder now?" ), parent=self.root, ): path = os.path.dirname(file_path) 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 &') except Exception as _e: logger.warning(f"Failed to open restored folder: {_e}") else: self.ui_manager.update_status("Restore failed", "error") messagebox.showerror( "Restore Failed", "Could not restore backup.", parent=self.root, ) except Exception as e: logger.error(f"Restore from backup failed: {e}") self.ui_manager.update_status("Restore failed", "error") messagebox.showerror("Restore Failed", str(e), parent=self.root) def _center_window(self) -> None: """Center the main window on the screen.""" # Update the window to get accurate dimensions self.root.update_idletasks() # Get window dimensions window_width = self.root.winfo_reqwidth() window_height = self.root.winfo_reqheight() # Get screen dimensions screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() # Calculate position to center the window x = (screen_width // 2) - (window_width // 2) y = (screen_height // 2) - (window_height // 2) # Set the window geometry self.root.geometry(f"{window_width}x{window_height}+{x}+{y}") def _setup_main_ui(self) -> None: """Set up the main UI components.""" import tkinter.ttk as ttk # --- Main Frame --- main_frame: ttk.Frame = ttk.Frame(self.root, padding="10", style="Card.TFrame") main_frame.grid(row=0, column=0, sticky="nsew") # Configure root window grid self.root.grid_rowconfigure(0, weight=1) self.root.grid_columnconfigure(0, weight=1) # Configure main frame grid for scaling for i in range(4): # Changed from 3 to 4 to accommodate search filter # Row 2 (table) gets main weight, other rows have no weight initially main_frame.grid_rowconfigure(i, weight=1 if i == 2 else 0) main_frame.grid_columnconfigure(i, weight=3 if i == 1 else 1) logger.debug("Main frame and root grid configured for scaling.") # --- Create Graph Frame --- graph_frame: ttk.Frame = self.ui_manager.create_graph_frame(main_frame) self.graph_manager: GraphManager = GraphManager( graph_frame, self.medicine_manager, self.pathology_manager ) # Initialize export manager self.export_manager: ExportManager = ExportManager( self.data_manager, self.graph_manager, self.medicine_manager, self.pathology_manager, logger, ) # --- Create Input Frame --- input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame) self.input_frame: ttk.Frame = input_ui["frame"] self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"] self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"] self.note_var: tk.StringVar = input_ui["note_var"] self.date_var: tk.StringVar = input_ui["date_var"] # Add buttons to input frame self.ui_manager.add_action_buttons( self.input_frame, [ { "text": "Add Entry (Ctrl+S)", "command": self.add_new_entry, "fill": "both", "expand": True, }, {"text": "Quit (Ctrl+Q)", "command": self.handle_window_closing}, ], ) # --- Create Table Frame --- table_ui: dict[str, Any] = self.ui_manager.create_table_frame(main_frame) self.tree: ttk.Treeview = table_ui["tree"] self.tree.bind("", self.handle_double_click) # --- Create Search/Filter Widget --- self.search_filter_widget = SearchFilterWidget( main_frame, self.data_filter, self._on_filter_update, self.medicine_manager, self.pathology_manager, logger, ) # Initially hidden - can be toggled with Ctrl+F self.search_filter_visible = False # --- Create Status Bar --- self.status_bar = self.ui_manager.create_status_bar(main_frame) # Load data self.refresh_data_display() # Initialize status bar with ready message self.ui_manager.update_status("Application ready", "info") def _setup_menu(self) -> None: """Set up the menu bar.""" menubar = self.theme_manager.create_themed_menu(self.root) self.root.config(menu=menubar) # File menu file_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0) menubar.add_cascade(label="File", menu=file_menu) file_menu.add_command( label="Export Data... (Ctrl+E)", command=self._open_export_window, accelerator="Ctrl+E", ) file_menu.add_separator() file_menu.add_command( label="Exit (Ctrl+Q)", command=self.handle_window_closing, accelerator="Ctrl+Q", ) # Tools menu tools_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0) menubar.add_cascade(label="Tools", menu=tools_menu) tools_menu.add_command( label="Manage Pathologies... (Ctrl+P)", command=self._open_pathology_manager, accelerator="Ctrl+P", ) tools_menu.add_command( label="Manage Medicines... (Ctrl+M)", command=self._open_medicine_manager, accelerator="Ctrl+M", ) tools_menu.add_separator() tools_menu.add_command( label="Clear Entries (Ctrl+N)", command=self._clear_entries, accelerator="Ctrl+N", ) tools_menu.add_command( label="Refresh Data (F5)", command=self.refresh_data_display, accelerator="F5", ) tools_menu.add_separator() tools_menu.add_command( label="Search & Filter (Ctrl+F)", command=self._toggle_search_filter, accelerator="Ctrl+F", ) tools_menu.add_separator() tools_menu.add_command( label="Open Logs Folder (Ctrl+L)", command=self._open_logs_folder, accelerator="Ctrl+L", ) tools_menu.add_command( label="Open Data Folder (Ctrl+D)", command=self._open_data_folder, accelerator="Ctrl+D", ) tools_menu.add_command( label="Open Backups Folder (Ctrl+B)", command=self._open_backups_folder, accelerator="Ctrl+B", ) tools_menu.add_command( label="Create Backup Now (Ctrl+Shift+B)", command=self._create_manual_backup, accelerator="Ctrl+Shift+B", ) tools_menu.add_command( label="Restore from Backup... (Ctrl+Shift+R)", command=self._restore_from_backup, accelerator="Ctrl+Shift+R", ) tools_menu.add_separator() tools_menu.add_command( label="Open Config Folder (Ctrl+Shift+C)", command=self._open_config_folder, accelerator="Ctrl+Shift+C", ) # Theme menu theme_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0) menubar.add_cascade(label="Theme", menu=theme_menu) # Add quick theme options available_themes = self.theme_manager.get_available_themes() current_theme = self.theme_manager.get_current_theme() for theme in available_themes: theme_menu.add_radiobutton( label=theme.title(), command=lambda t=theme: self._change_theme(t), value=theme == current_theme, ) theme_menu.add_separator() theme_menu.add_command( label="More Settings... (F2)", command=self._open_settings_window, accelerator="F2", ) # Help menu help_menu = self.theme_manager.create_themed_menu(menubar, tearoff=0) menubar.add_cascade(label="Help", menu=help_menu) help_menu.add_command( label="Keyboard Shortcuts (F1)", command=self._show_keyboard_shortcuts, accelerator="F1", ) help_menu.add_command(label="About", command=self._show_about_dialog) help_menu.add_separator() help_menu.add_command( label="Open Documentation (Ctrl+H)", command=self._open_documentation, accelerator="Ctrl+H", ) def _setup_keyboard_shortcuts(self) -> None: """Set up keyboard shortcuts for common actions.""" bindings = [ ("", self.add_new_entry), ("", self.add_new_entry), ("", self.handle_window_closing), ("", self.handle_window_closing), ("", self._open_export_window), ("", self._open_export_window), ("", self._clear_entries), ("", self._clear_entries), ("", self.refresh_data_display), ("", self.refresh_data_display), ("", self.refresh_data_display), ("", self._open_medicine_manager), ("", self._open_medicine_manager), ("", self._open_pathology_manager), ("", self._open_pathology_manager), ("", self._toggle_search_filter), ("", self._toggle_search_filter), ("", self._delete_selected_entry), ("", self._clear_selection), ("", self._show_keyboard_shortcuts), ("", self._open_settings_window), ("", self._undo_last), ("", self._undo_last), ("", self._open_logs_folder), ("", self._open_logs_folder), ("", self._open_data_folder), ("", self._open_data_folder), ("", self._open_backups_folder), ("", self._open_backups_folder), ("", self._open_documentation), ("", self._open_documentation), ("", self._create_manual_backup), ("", self._restore_from_backup), ("", self._open_config_folder), ] for seq, func in bindings: self.root.bind(seq, lambda e, f=func: f()) self.root.focus_set() logger.info("Keyboard shortcuts configured:") for desc in [ "Ctrl+S: Save/Add new entry", "Ctrl+Q: Quit application", "Ctrl+E: Export data", "Ctrl+N: Clear entries", "Ctrl+R/F5: Refresh data", "Ctrl+M: Manage medicines", "Ctrl+P: Manage pathologies", "Ctrl+F: Toggle search/filter", "Ctrl+L: Open logs folder", "Ctrl+D: Open data folder", "Ctrl+B: Open backups folder", "Ctrl+Shift+B: Create backup now", "Ctrl+Shift+R: Restore from backup...", "Ctrl+Shift+C: Open config folder", "Ctrl+H: Open documentation", "Delete: Delete selected entry", "Escape: Clear selection", "F1: Show keyboard shortcuts help", "Ctrl+Z: Undo last change", ]: logger.info(" " + desc) def _show_keyboard_shortcuts(self) -> None: """Show a dialog with keyboard shortcuts information.""" shortcuts_text = """Keyboard Shortcuts: File Operations: • Ctrl+S: Save/Add new entry • Ctrl+Q: Quit application • Ctrl+E: Export data Data Management: • Ctrl+N: Clear entries • Ctrl+R / F5: Refresh data • Ctrl+F: Toggle search/filter • Ctrl+L: Open logs folder • Ctrl+D: Open data folder Window Management: • Ctrl+M: Manage medicines • Ctrl+P: Manage pathologies Table Operations: • Delete: Delete selected entry • Escape: Clear selection • Double-click: Edit entry • Ctrl+Z: Undo last change Help: • F1: Show this help dialog • F2: Open settings window • Ctrl+H: Open documentation • Ctrl+Shift+B: Create backup now • Ctrl+Shift+R: Restore from backup... • Ctrl+Shift+C: Open config folder """ messagebox.showinfo("Keyboard Shortcuts", shortcuts_text, parent=self.root) def _open_documentation(self) -> None: """Open the docs directory in your default file viewer or README in browser.""" # Prefer docs/ directory; else open README.md docs_dir = os.path.join(os.getcwd(), "docs") target = ( docs_dir if os.path.isdir(docs_dir) else os.path.join(os.getcwd(), "README.md") ) try: if sys.platform.startswith("darwin"): os.system(f'open "{target}"') elif os.name == "nt": os.startfile(target) # type: ignore[attr-defined] else: os.system(f'xdg-open "{target}" >/dev/null 2>&1 &') self.ui_manager.update_status("Opened documentation", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Documentation opened", 1500) except Exception as e: logger.error(f"Failed to open documentation: {e}") self.ui_manager.update_status("Failed to open documentation", "error") def _change_theme(self, theme_name: str) -> None: """Change the application theme.""" if self.theme_manager.apply_theme(theme_name): self.ui_manager.update_status( f"Theme changed to: {theme_name.title()}", "info" ) self._setup_menu() # Refresh menu radio selection else: self.ui_manager.update_status( f"Failed to apply theme: {theme_name}", "error" ) def _show_about_dialog(self) -> None: """Show about dialog.""" about_text = """TheChart - Medication Tracker A simple application for tracking medications and pathologies. Features: • Add daily medication and pathology entries • Visual graphs and charts • Data export capabilities • Keyboard shortcuts for efficiency Use Ctrl+S to save entries and Ctrl+Q to quit.""" messagebox.showinfo("About TheChart", about_text, parent=self.root) def _open_export_window(self) -> None: """Open the export window.""" self.ui_manager.update_status("Opening export window", "info") ExportWindow(self.root, self.export_manager) if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Export window opened", 1200) def _open_pathology_manager(self) -> None: """Open the pathology management window.""" self.ui_manager.update_status("Opening pathology manager", "info") PathologyManagementWindow( self.root, self.pathology_manager, self._refresh_ui_after_config_change ) def _open_medicine_manager(self) -> None: """Open the medicine management window.""" self.ui_manager.update_status("Opening medicine manager", "info") MedicineManagementWindow( self.root, self.medicine_manager, self._refresh_ui_after_config_change ) def _open_logs_folder(self) -> None: """Open the application logs directory in the system file explorer.""" path = LOG_PATH try: if not os.path.exists(path): os.makedirs(path, exist_ok=True) # Cross-platform opener if sys.platform.startswith("darwin"): os.system(f'open "{path}"') elif os.name == "nt": os.startfile(path) # type: ignore[attr-defined] else: # Linux os.system(f'xdg-open "{path}" >/dev/null 2>&1 &') self.ui_manager.update_status("Opened logs folder", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Logs folder opened", 1500) except Exception as e: logger.error(f"Failed to open logs folder: {e}") self.ui_manager.update_status("Failed to open logs folder", "error") def _open_data_folder(self) -> None: """Open the data file's directory in the system file explorer.""" try: folder = os.path.dirname(os.path.abspath(self.filename)) or "." if not os.path.exists(folder): os.makedirs(folder, exist_ok=True) if sys.platform.startswith("darwin"): os.system(f'open "{folder}"') elif os.name == "nt": os.startfile(folder) # type: ignore[attr-defined] else: os.system(f'xdg-open "{folder}" >/dev/null 2>&1 &') self.ui_manager.update_status("Opened data folder", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Data folder opened", 1500) except Exception as e: logger.error(f"Failed to open data folder: {e}") self.ui_manager.update_status("Failed to open data folder", "error") def _open_settings_window(self) -> None: """Open the settings window.""" self.ui_manager.update_status("Opening settings window", "info") SettingsWindow(self.root, self.theme_manager, self.ui_manager) if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Settings opened", 1200) def _open_config_folder(self) -> None: """Open the application configuration folder in the file explorer.""" try: path = get_config_dir() 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 &') self.ui_manager.update_status("Opened config folder", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Config folder opened", 1500) except Exception as e: logger.error(f"Failed to open config folder: {e}") self.ui_manager.update_status("Failed to open config folder", "error") def _create_manual_backup(self) -> None: """Create a manual backup immediately.""" try: self.ui_manager.update_status("Creating backup...", "info") self.backup_manager.create_backup("manual") # Optional cleanup to enforce retention policy if hasattr(self.backup_manager, "cleanup_old_backups"): self.backup_manager.cleanup_old_backups(keep_count=5) except Exception as e: logger.error(f"Manual backup failed: {e}") self.ui_manager.update_status("Manual backup failed", "error") def _open_backups_folder(self) -> None: """Open the backups directory in the system file explorer.""" # Prefer the manager's directory if available path = getattr(self.backup_manager, "backup_directory", None) if not path: # Fallback to data file's directory path = os.path.dirname(os.path.abspath(self.filename)) or "." try: 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 &') self.ui_manager.update_status("Opened backups folder", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Backups folder opened", 1500) except Exception as e: logger.error(f"Failed to open backups folder: {e}") self.ui_manager.update_status("Failed to open backups folder", "error") def _refresh_ui_after_config_change(self) -> None: """Refresh UI components after pathology or medicine configuration changes.""" self.ui_manager.update_status( "Refreshing UI after configuration change", "info" ) # Clear caches in optimized data manager if hasattr(self.data_manager, "_invalidate_cache"): # Use public structural invalidation method if available if hasattr(self.data_manager, "invalidate_structure"): self.data_manager.invalidate_structure() else: self.data_manager._invalidate_cache() if hasattr(self.data_manager, "_headers_cache"): self.data_manager._headers_cache = None if hasattr(self.data_manager, "_dtype_cache"): self.data_manager._dtype_cache = None # Recreate the input frame with new pathologies and medicines self.input_frame.destroy() input_ui: dict[str, Any] = self.ui_manager.create_input_frame( self.input_frame.master ) self.input_frame: ttk.Frame = input_ui["frame"] self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"] self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"] # Add buttons to input frame self.ui_manager.add_action_buttons( self.input_frame, [ { "text": "Add Entry (Ctrl+S)", "command": self.add_new_entry, "fill": "both", "expand": True, }, {"text": "Quit (Ctrl+Q)", "command": self.handle_window_closing}, ], ) # Recreate the table with new columns self.tree.destroy() table_ui: dict[str, Any] = self.ui_manager.create_table_frame( self.tree.master.master ) self.tree: ttk.Treeview = table_ui["tree"] self.tree.bind("", self.handle_double_click) # Refresh data display self.refresh_data_display() # Update status to show completion self.ui_manager.update_status("UI refreshed successfully", "success") def _delete_selected_entry(self) -> None: """Delete the currently selected entry in the table.""" selection = self.tree.selection() if not selection: self.ui_manager.update_status("No entry selected for deletion", "warning") return item_id = selection[0] item_values = self.tree.item(item_id, "values") if messagebox.askyesno( "Delete Entry", f"Are you sure you want to delete the entry for {item_values[0]}?", parent=self.root, ): date: str = item_values[0] logger.debug(f"Deleting entry with date={date}") # Capture row BEFORE deletion for undo deleted_row = self.data_manager.get_row(date) self.ui_manager.update_status("Deleting entry...", "info") if self.data_manager.delete_entry(date): self._mark_data_modified() # Mark for auto-save self.ui_manager.update_status("Entry deleted successfully!", "success") messagebox.showinfo( "Success", "Entry deleted successfully!", parent=self.root ) self.refresh_data_display() if deleted_row: def _undo_del() -> None: import csv as _csv existing = self.data_manager.load_data() if ( not existing.empty and "date" in existing.columns and date in existing["date"].values ): return # Already restored with open(self.filename, "a", newline="") as _f: _csv.writer(_f).writerow(deleted_row) if hasattr(self.data_manager, "_invalidate_cache"): self.data_manager._invalidate_cache() self.refresh_data_display() self.undo_manager.push(UndoAction(f"Delete {date}", _undo_del)) else: self.ui_manager.update_status("Failed to delete entry", "error") messagebox.showerror( "Error", "Failed to delete entry", parent=self.root ) def _clear_selection(self) -> None: """Clear the current selection in the table.""" if self.tree.selection(): self.tree.selection_remove(self.tree.selection()) self.ui_manager.update_status("Selection cleared", "info") def handle_double_click(self, event: tk.Event) -> None: """Handle double-click event to edit an entry.""" logger.debug("Double-click event triggered on treeview.") if len(self.tree.get_children()) > 0: item_id = self.tree.selection()[0] item_values = self.tree.item(item_id, "values") self.ui_manager.update_status( f"Opening entry for {item_values[0]} for editing", "info" ) logger.debug(f"Editing item_id={item_id}, values={item_values}") self._create_edit_window(item_id, item_values) else: self.ui_manager.update_status("No entries to edit", "warning") def _create_edit_window(self, item_id: str, values: tuple[str, ...]) -> None: """Create a new Toplevel window for editing an entry.""" original_date = values[0] # Store the original date # Get the full row data from the CSV (including dose columns) df = self.data_manager.load_data() if not df.empty and original_date in df["date"].values: full_row = df[df["date"] == original_date].iloc[0] # Convert to tuple in the expected order for the edit window full_values = [full_row["date"]] # Add pathology data dynamically for pathology_key in self.pathology_manager.get_pathology_keys(): if pathology_key in full_row: full_values.append(full_row[pathology_key]) else: full_values.append(0) # Add medicine data dynamically for medicine_key in self.medicine_manager.get_medicine_keys(): if medicine_key in full_row: full_values.append(full_row[medicine_key]) full_values.append(full_row.get(f"{medicine_key}_doses", "")) else: full_values.extend([0, ""]) full_values.append(full_row["note"]) full_values = tuple(full_values) else: # Fallback to the table values if full data not found full_values = values # Define callbacks for edit window buttons callbacks: dict[str, Callable] = { "save": lambda win, *args: self._save_edit(win, original_date, *args), "delete": lambda win: self._delete_entry(win, item_id), } # Create edit window using UI manager with full data _: tk.Toplevel = self.ui_manager.create_edit_window(full_values, callbacks) def _save_edit( self, edit_win: tk.Toplevel, original_date: str, *args, ) -> None: """Save edited data to CSV file with dynamic pathology/medicine support.""" # Parse dynamic arguments # Format: date, pathology1, pathology2, ..., medicine1, medicine2, # ..., note, dose_data if len(args) < 2: # At minimum need date and note messagebox.showerror("Error", "Invalid save data format", parent=edit_win) return # Extract arguments date = args[0] # Get pathology count to extract values pathology_keys = self.pathology_manager.get_pathology_keys() medicine_keys = self.medicine_manager.get_medicine_keys() # Expected format: date, pathology_values..., medicine_values..., # note, dose_data expected_pathology_count = len(pathology_keys) expected_medicine_count = len(medicine_keys) # Extract pathology values pathology_values = [] for i in range(expected_pathology_count): if i + 1 < len(args): pathology_values.append(args[i + 1]) else: pathology_values.append(0) # Extract medicine values medicine_values = [] medicine_start_idx = 1 + expected_pathology_count for i in range(expected_medicine_count): if medicine_start_idx + i < len(args): medicine_values.append(args[medicine_start_idx + i]) else: medicine_values.append(0) # Extract note and dose data (last two arguments) note = args[-2] if len(args) >= 2 else "" dose_data = args[-1] if len(args) >= 1 else {} # Build the values list for data manager values = [date] values.extend(pathology_values) # Add medicine data dynamically for i, medicine_key in enumerate(medicine_keys): values.append(medicine_values[i] if i < len(medicine_values) else 0) values.append(dose_data.get(medicine_key, "")) values.append(note) self.ui_manager.update_status("Saving changes...", "info") # Capture previous row BEFORE updating prev_row = self.data_manager.get_row(original_date) if self.data_manager.update_entry(original_date, values): self._mark_data_modified() # Mark for auto-save edit_win.destroy() self.ui_manager.update_status("Entry updated successfully!", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Entry updated", 1500) self._clear_entries() self.refresh_data_display() new_date = values[0] def _undo_update() -> None: import csv as _csv # Remove the updated (new) row self.data_manager.delete_entry(str(new_date)) # Restore previous row if prev_row: with open(self.filename, "a", newline="") as _f: _csv.writer(_f).writerow(prev_row) if hasattr(self.data_manager, "_invalidate_cache"): self.data_manager._invalidate_cache() self.refresh_data_display() self.undo_manager.push(UndoAction(f"Update {original_date}", _undo_update)) else: # Check if it's a duplicate date issue df = self.data_manager.load_data() if original_date != date and not df.empty and date in df["date"].values: self.ui_manager.update_status("Duplicate date found", "error") messagebox.showerror( "Error", f"An entry for date '{date}' already exists. " "Please use a different date.", parent=edit_win, ) else: self.ui_manager.update_status("Failed to save changes", "error") messagebox.showerror("Error", "Failed to save changes", parent=edit_win) def handle_window_closing(self) -> None: """Handle application closing with cleanup.""" if messagebox.askokcancel( "Quit", "Do you want to quit the application?", parent=self.root ): # Save window geometry if preference is enabled with contextlib.suppress(Exception): if get_pref("remember_window_geometry", True): set_pref("last_window_geometry", self.root.geometry()) save_preferences() # Clean up auto-save and create final backup if hasattr(self, "auto_save_manager"): self.auto_save_manager.cleanup() if hasattr(self, "backup_manager"): self.backup_manager.create_backup("shutdown") self.backup_manager.cleanup_old_backups(keep_count=5) self.graph_manager.close() self.root.destroy() def _auto_save_callback(self) -> None: """Callback function for auto-save operations.""" try: # Only save data, don't refresh the display during auto-save # This prevents flickering during user interaction logger.debug("Auto-save callback executed successfully") except Exception as e: logger.error(f"Auto-save callback failed: {e}") def _toggle_search_filter(self) -> None: """Toggle the search and filter panel.""" if self.search_filter_visible: self.search_filter_widget.hide() self.search_filter_visible = False self.ui_manager.update_status("Search panel hidden", "info") else: self.search_filter_widget.show() self.search_filter_visible = True self.ui_manager.update_status("Search panel shown", "info") def _on_filter_update(self) -> None: """Handle filter updates from the search widget.""" # Debounce rapid filter changes to avoid repeated heavy refresh. if not hasattr(self, "_filter_debounce_id"): self._filter_debounce_id = None # type: ignore[attr-defined] if self._filter_debounce_id is not None: # type: ignore[attr-defined] import contextlib with contextlib.suppress(Exception): self.root.after_cancel(self._filter_debounce_id) # type: ignore[attr-defined] # Schedule refresh after short delay self._filter_debounce_id = self.root.after( # type: ignore[attr-defined] 250, lambda: self.refresh_data_display(apply_filters=True) ) def _mark_data_modified(self) -> None: """Mark that data has been modified for auto-save.""" if hasattr(self, "auto_save_manager"): self.auto_save_manager.mark_data_modified() def add_new_entry(self) -> None: """Add a new entry to the CSV file with validation.""" # Validate date first date_str = self.date_var.get() is_valid_date, date_error, _ = InputValidator.validate_date(date_str) if not is_valid_date: self.ui_manager.update_status(f"Invalid date: {date_error}", "error") messagebox.showerror("Invalid Date", date_error, parent=self.root) return # Validate pathology scores entry_data = {"date": date_str} for pathology_key in self.pathology_manager.get_pathology_keys(): score = self.pathology_vars[pathology_key].get() is_valid_score, score_error, validated_score = ( InputValidator.validate_pathology_score(score) ) if not is_valid_score: self.ui_manager.update_status( f"Invalid pathology score: {score_error}", "error" ) messagebox.showerror( "Invalid Pathology Score", score_error, parent=self.root ) return entry_data[pathology_key] = validated_score # Validate medicine data for medicine_key in self.medicine_manager.get_medicine_keys(): taken = self.medicine_vars[medicine_key][0].get() is_valid_taken, taken_error, validated_taken = ( InputValidator.validate_medicine_taken(taken) ) if not is_valid_taken: self.ui_manager.update_status( f"Invalid medicine data: {taken_error}", "error" ) messagebox.showerror( "Invalid Medicine Data", taken_error, parent=self.root ) return entry_data[medicine_key] = validated_taken # Validate note note_str = self.note_var.get() is_valid_note, note_error, validated_note = InputValidator.validate_note( note_str ) if not is_valid_note: self.ui_manager.update_status(f"Invalid note: {note_error}", "error") messagebox.showerror("Invalid Note", note_error, parent=self.root) return entry_data["note"] = validated_note # Check entry completeness: require date and at least one of # (any pathology score > 0) or (any medicine taken == 1) missing_fields: list[str] = [] if not entry_data.get("date"): missing_fields.append("Date") has_pathology = any( entry_data.get(k, 0) > 0 for k in self.pathology_manager.get_pathology_keys() ) has_medicine = any( entry_data.get(k, 0) == 1 for k in self.medicine_manager.get_medicine_keys() ) if not (has_pathology or has_medicine): missing_fields.append("At least one pathology score or medicine entry") if missing_fields: missing_msg = "Missing required data:\n" + "\n".join( f"• {field}" for field in missing_fields ) self.ui_manager.update_status( "Entry incomplete: missing required data", "warning" ) result = messagebox.askyesno( "Incomplete Entry", f"{missing_msg}\n\nSave entry anyway?", parent=self.root, ) if not result: return # Get current doses for today today = self.date_var.get() dose_values = {} if today: # Get doses for all medicines dynamically for medicine_key in self.medicine_manager.get_medicine_keys(): doses = self.data_manager.get_today_medicine_doses(today, medicine_key) dose_values[f"{medicine_key}_doses"] = "|".join( [f"{ts}:{dose}" for ts, dose in doses] ) else: # Set empty doses for all medicines for medicine_key in self.medicine_manager.get_medicine_keys(): dose_values[f"{medicine_key}_doses"] = "" # Build entry dynamically entry: list[str | int] = [self.date_var.get()] # Add pathology data dynamically for pathology_key in self.pathology_manager.get_pathology_keys(): entry.append(self.pathology_vars[pathology_key].get()) # Add medicine data for medicine_key in self.medicine_manager.get_medicine_keys(): entry.append(self.medicine_vars[medicine_key][0].get()) entry.append(dose_values[f"{medicine_key}_doses"]) entry.append(validated_note) # Use validated note logger.debug(f"Adding entry: {entry}") self.ui_manager.update_status("Adding new entry...", "info") if self.data_manager.add_entry(entry): self._mark_data_modified() # Mark for auto-save self.ui_manager.update_status("Entry added successfully!", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Entry added", 1500) self._clear_entries() self.refresh_data_display() added_date = entry[0] def _undo_add() -> None: self.data_manager.delete_entry(str(added_date)) self.refresh_data_display() self.undo_manager.push(UndoAction(f"Add {added_date}", _undo_add)) else: # Check if it's a duplicate date by trying to load existing data df = self.data_manager.load_data() if not df.empty and self.date_var.get() in df["date"].values: self.ui_manager.update_status("Duplicate entry found", "error") messagebox.showerror( "Error", f"An entry for date '{self.date_var.get()}' already exists. " "Please use a different date or edit the existing entry.", parent=self.root, ) else: self.ui_manager.update_status("Failed to add entry", "error") messagebox.showerror("Error", "Failed to add entry", parent=self.root) def _undo_last(self) -> None: """Undo the last data modifying action.""" result = self.undo_manager.undo() if result: self._mark_data_modified() self.refresh_data_display() self.ui_manager.update_status(f"Undid: {result}", "info") else: self.ui_manager.update_status("Nothing to undo", "warning") def _delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None: """Delete the selected entry from the CSV file.""" logger.debug(f"Delete requested for item_id={item_id}") if messagebox.askyesno( "Delete Entry", "Are you sure you want to delete this entry?", parent=edit_win, ): # Get the date of the entry to delete date: str = self.tree.item(item_id, "values")[0] logger.debug(f"Deleting entry with date={date}") deleted_row = self.data_manager.get_row(date) self.ui_manager.update_status("Deleting entry...", "info") if self.data_manager.delete_entry(date): self._mark_data_modified() # Mark for auto-save edit_win.destroy() self.ui_manager.update_status("Entry deleted successfully!", "success") if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Entry deleted", 1500) self.refresh_data_display() if deleted_row: def _undo_del2() -> None: import csv as _csv existing = self.data_manager.load_data() if ( not existing.empty and "date" in existing.columns and date in existing["date"].values ): return with open(self.filename, "a", newline="") as _f: _csv.writer(_f).writerow(deleted_row) if hasattr(self.data_manager, "_invalidate_cache"): self.data_manager._invalidate_cache() self.refresh_data_display() self.undo_manager.push(UndoAction(f"Delete {date}", _undo_del2)) else: self.ui_manager.update_status("Failed to delete entry", "error") messagebox.showerror("Error", "Failed to delete entry", parent=edit_win) def _clear_entries(self) -> None: """Clear all input fields.""" logger.debug("Clearing input fields.") # Keep date practical: default to today's date after clear try: self.date_var.set(datetime.now().strftime("%m/%d/%Y")) except Exception: self.date_var.set("") for key in self.pathology_vars: self.pathology_vars[key].set(0) for key in self.medicine_vars: self.medicine_vars[key][0].set(0) self.note_var.set("") def refresh_data_display(self, apply_filters: bool = False) -> None: """Load data from the CSV file into the table and graph.""" logger.debug("Loading data from CSV.") try: # Load data from the CSV file once # Use cached graph-ready data for plotting & base data for table df_full: pd.DataFrame = self.data_manager.load_data() df: pd.DataFrame = df_full original_df = df.copy() # Keep a copy for graph updates # Apply filters if requested and filters are active if apply_filters and self.data_filter.get_filter_summary()["has_filters"]: df = self.data_filter.apply_filters(df) self.current_filtered_data = df else: self.current_filtered_data = None # Use efficient tree update to reduce flickering self._update_tree_efficiently(df) # Reapply last sort state if any if hasattr(self.ui_manager, "reapply_last_sort"): self.ui_manager.reapply_last_sort(self.tree) # Update the graph (always use unfiltered data for complete picture) # Graph gets preprocessed, use dedicated cached transformation if hasattr(self.data_manager, "get_graph_ready_data"): graph_df = self.data_manager.get_graph_ready_data() self.graph_manager.update_graph( graph_df.reset_index().rename(columns={"date": "date"}) ) else: self.graph_manager.update_graph(original_df) # Update status bar with file info total_entries = len(original_df) if apply_filters else len(df) displayed_entries = len(df) if apply_filters and self.current_filtered_data is not None: self.ui_manager.update_file_info( self.filename, displayed_entries, f"filtered ({displayed_entries}/{total_entries})", ) else: self.ui_manager.update_file_info(self.filename, displayed_entries) if displayed_entries == 0: status_msg = ( "No data matches filters" if apply_filters else "No data to display" ) self.ui_manager.update_status(status_msg, "warning") else: status_msg = ( "Filtered data loaded" if apply_filters else "Data loaded successfully" ) self.ui_manager.update_status(status_msg, "success") except Exception as e: self.error_handler.handle_data_error( operation="loading", data_type="CSV data", error=e, recovery_suggestions=[ "Check if the data file exists and is not corrupted", "Verify file permissions", "Try restarting the application", "Check available disk space", ], ) def _update_tree_efficiently(self, df: pd.DataFrame) -> None: """Update tree view efficiently to reduce flickering.""" # Store current scroll position import contextlib current_scroll_top = 0 with contextlib.suppress(tk.TclError, IndexError): current_scroll_top = self.tree.yview()[0] # Use update_idletasks to batch operations and reduce flickering try: # Build display dataframe (strip dose columns) once if not df.empty: display_columns = ["date"] display_columns.extend(self.pathology_manager.get_pathology_keys()) display_columns.extend(self.medicine_manager.get_medicine_keys()) display_columns.append("note") if all(col in df.columns for col in display_columns): display_df = df[display_columns] else: display_df = df else: display_df = df # Use diff-based update if available if hasattr(self.ui_manager, "diff_update_tree"): self.ui_manager.diff_update_tree(self.tree, display_df) else: children = self.tree.get_children() if children: self.tree.delete(*children) for index, row in display_df.iterrows(): tag = "evenrow" if index % 2 == 0 else "oddrow" self.tree.insert("", "end", values=list(row), tags=(tag,)) logger.debug(f"Loaded {len(display_df)} entries into treeview.") # Process pending events to update display self.root.update_idletasks() # Restore scroll position with contextlib.suppress(tk.TclError, IndexError): if current_scroll_top > 0: self.tree.yview_moveto(current_scroll_top) except Exception as e: logger.error(f"Error updating tree efficiently: {e}") if __name__ == "__main__": root: tk.Tk = tk.Tk() app: MedTrackerApp = MedTrackerApp(root) root.mainloop()