Files
thechart/src/main.py
T
2025-08-08 11:32:43 -07:00

1457 lines
59 KiB
Python

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("<Configure>", self._on_configure, add="+")
except Exception:
# Older Tk variants may not support add; fall back
self.root.bind("<Configure>", 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("<Double-1>", 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 = [
("<Control-s>", self.add_new_entry),
("<Control-S>", self.add_new_entry),
("<Control-q>", self.handle_window_closing),
("<Control-Q>", self.handle_window_closing),
("<Control-e>", self._open_export_window),
("<Control-E>", self._open_export_window),
("<Control-n>", self._clear_entries),
("<Control-N>", self._clear_entries),
("<Control-r>", self.refresh_data_display),
("<Control-R>", self.refresh_data_display),
("<F5>", self.refresh_data_display),
("<Control-m>", self._open_medicine_manager),
("<Control-M>", self._open_medicine_manager),
("<Control-p>", self._open_pathology_manager),
("<Control-P>", self._open_pathology_manager),
("<Control-f>", self._toggle_search_filter),
("<Control-F>", self._toggle_search_filter),
("<Delete>", self._delete_selected_entry),
("<Escape>", self._clear_selection),
("<F1>", self._show_keyboard_shortcuts),
("<F2>", self._open_settings_window),
("<Control-z>", self._undo_last),
("<Control-Z>", self._undo_last),
("<Control-l>", self._open_logs_folder),
("<Control-L>", self._open_logs_folder),
("<Control-d>", self._open_data_folder),
("<Control-D>", self._open_data_folder),
("<Control-b>", self._open_backups_folder),
("<Control-B>", self._open_backups_folder),
("<Control-h>", self._open_documentation),
("<Control-H>", self._open_documentation),
("<Control-Shift-B>", self._create_manual_backup),
("<Control-Shift-R>", self._restore_from_backup),
("<Control-Shift-C>", 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("<Double-1>", 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()