feat: Implement application preferences with JSON persistence
Build and Push Docker Image / build-and-push (push) Has been cancelled

- Added preferences management in `preferences.py` with functions to load, save, get, set, and reset preferences.
- Introduced a configuration directory structure based on the operating system.
- Integrated preferences into the settings window, allowing users to reset settings and manage window geometry.
- Enhanced `search_filter.py` to support flexible date column names and improved filtering logic.
- Updated `settings_window.py` to include options for managing backup and configuration folder paths.
- Introduced an `UndoManager` class to handle undo actions for add/update/delete operations.
- Improved UIManager to support sorting in tree views and added a toast notification feature.
This commit is contained in:
William Valentin
2025-08-07 16:26:17 -07:00
parent 73498af138
commit 9372d6ef29
15 changed files with 1997 additions and 468 deletions
+550 -95
View File
@@ -1,8 +1,10 @@
import contextlib
import os
import sys
import tkinter as tk
from collections.abc import Callable
from tkinter import messagebox, ttk
from datetime import datetime
from tkinter import filedialog, messagebox, ttk
from typing import Any
import pandas as pd
@@ -20,11 +22,13 @@ 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:
@@ -34,19 +38,23 @@ class MedTrackerApp:
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: str = 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(
f"Data file {first_argument} doesn't exist. \
Using default file: {self.filename}"
"Data file %s doesn't exist. Using default file: %s",
first_argument,
self.filename,
)
logger.info(f"Log level: {LOG_LEVEL}")
@@ -73,6 +81,8 @@ class MedTrackerApp:
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
@@ -90,7 +100,11 @@ class MedTrackerApp:
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)
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()
@@ -106,8 +120,26 @@ class MedTrackerApp:
# Setup keyboard shortcuts
self._setup_keyboard_shortcuts()
# Center the window on screen
self._center_window()
# 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()
@@ -115,6 +147,143 @@ class MedTrackerApp:
# 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
@@ -226,41 +395,79 @@ class MedTrackerApp:
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...",
label="Export Data... (Ctrl+E)",
command=self._open_export_window,
accelerator="Ctrl+E",
)
file_menu.add_separator()
file_menu.add_command(
label="Exit", command=self.handle_window_closing, accelerator="Ctrl+Q"
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...",
label="Manage Pathologies... (Ctrl+P)",
command=self._open_pathology_manager,
accelerator="Ctrl+P",
)
tools_menu.add_command(
label="Manage Medicines...",
label="Manage Medicines... (Ctrl+M)",
command=self._open_medicine_manager,
accelerator="Ctrl+M",
)
tools_menu.add_separator()
tools_menu.add_command(
label="Clear Entries", command=self._clear_entries, accelerator="Ctrl+N"
label="Clear Entries (Ctrl+N)",
command=self._clear_entries,
accelerator="Ctrl+N",
)
tools_menu.add_command(
label="Refresh Data", command=self.refresh_data_display, accelerator="F5"
label="Refresh Data (F5)",
command=self.refresh_data_display,
accelerator="F5",
)
tools_menu.add_separator()
tools_menu.add_command(
label="Search & Filter",
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)
@@ -279,66 +486,92 @@ class MedTrackerApp:
theme_menu.add_separator()
theme_menu.add_command(
label="More Settings...",
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="Settings...",
command=self._open_settings_window,
accelerator="F2",
)
help_menu.add_separator()
help_menu.add_command(
label="Keyboard Shortcuts",
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."""
# Bind keyboard shortcuts to the main window
self.root.bind("<Control-s>", lambda e: self.add_new_entry())
self.root.bind("<Control-S>", lambda e: self.add_new_entry())
self.root.bind("<Control-q>", lambda e: self.handle_window_closing())
self.root.bind("<Control-Q>", lambda e: self.handle_window_closing())
self.root.bind("<Control-e>", lambda e: self._open_export_window())
self.root.bind("<Control-E>", lambda e: self._open_export_window())
self.root.bind("<Control-n>", lambda e: self._clear_entries())
self.root.bind("<Control-N>", lambda e: self._clear_entries())
self.root.bind("<Control-r>", lambda e: self.refresh_data_display())
self.root.bind("<Control-R>", lambda e: self.refresh_data_display())
self.root.bind("<F5>", lambda e: self.refresh_data_display())
self.root.bind("<Control-m>", lambda e: self._open_medicine_manager())
self.root.bind("<Control-M>", lambda e: self._open_medicine_manager())
self.root.bind("<Control-p>", lambda e: self._open_pathology_manager())
self.root.bind("<Control-P>", lambda e: self._open_pathology_manager())
self.root.bind("<Control-f>", lambda e: self._toggle_search_filter())
self.root.bind("<Control-F>", lambda e: self._toggle_search_filter())
self.root.bind("<Delete>", lambda e: self._delete_selected_entry())
self.root.bind("<Escape>", lambda e: self._clear_selection())
self.root.bind("<F1>", lambda e: self._show_keyboard_shortcuts())
self.root.bind("<F2>", lambda e: self._open_settings_window())
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())
# Make the window focusable so it can receive key events
self.root.focus_set()
logger.info("Keyboard shortcuts configured:")
logger.info(" Ctrl+S: Save/Add new entry")
logger.info(" Ctrl+Q: Quit application")
logger.info(" Ctrl+E: Export data")
logger.info(" Ctrl+N: Clear entries")
logger.info(" Ctrl+R/F5: Refresh data")
logger.info(" Ctrl+M: Manage medicines")
logger.info(" Ctrl+P: Manage pathologies")
logger.info(" Ctrl+F: Toggle search/filter")
logger.info(" Delete: Delete selected entry")
logger.info(" Escape: Clear selection")
logger.info(" F1: Show keyboard shortcuts help")
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."""
@@ -353,6 +586,8 @@ 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
@@ -362,21 +597,49 @@ 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"""
• 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"
)
# Refresh the menu to update radio button selection
self._setup_menu()
self._setup_menu() # Refresh menu radio selection
else:
self.ui_manager.update_status(
f"Failed to apply theme: {theme_name}", "error"
@@ -402,6 +665,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
"""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."""
@@ -417,10 +682,106 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
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."""
@@ -430,9 +791,15 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
# Clear caches in optimized data manager
if hasattr(self.data_manager, "_invalidate_cache"):
self.data_manager._invalidate_cache()
self.data_manager._headers_cache = None
self.data_manager._dtype_cache = None
# 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()
@@ -488,7 +855,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
):
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
@@ -497,6 +865,25 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
"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(
@@ -624,6 +1011,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
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()
@@ -633,6 +1022,22 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
)
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()
@@ -653,6 +1058,11 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
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()
@@ -686,7 +1096,19 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
def _on_filter_update(self) -> None:
"""Handle filter updates from the search widget."""
self.refresh_data_display(apply_filters=True)
# 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."""
@@ -807,6 +1229,13 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
)
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()
@@ -822,6 +1251,16 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
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}")
@@ -833,7 +1272,7 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
# 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
@@ -843,6 +1282,25 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
"Success", "Entry deleted successfully!", parent=self.root
)
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)
@@ -863,7 +1321,9 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
try:
# Load data from the CSV file once
df: pd.DataFrame = self.data_manager.load_data()
# 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
@@ -877,7 +1337,14 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
self._update_tree_efficiently(df)
# Update the graph (always use unfiltered data for complete picture)
self.graph_manager.update_graph(original_df)
# 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)
@@ -929,42 +1396,30 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
# Use update_idletasks to batch operations and reduce flickering
try:
# Clear existing data efficiently
children = self.tree.get_children()
if children:
self.tree.delete(*children)
# Update the treeview with the data
# Build display dataframe (strip dose columns) once
if not df.empty:
# Build display columns dynamically
# (exclude dose columns for table view)
display_columns = ["date"]
# Add pathology columns
for pathology_key in self.pathology_manager.get_pathology_keys():
display_columns.append(pathology_key)
# Add medicine columns (without dose columns)
for medicine_key in self.medicine_manager.get_medicine_keys():
display_columns.append(medicine_key)
display_columns.extend(self.pathology_manager.get_pathology_keys())
display_columns.extend(self.medicine_manager.get_medicine_keys())
display_columns.append("note")
# Filter to only the columns we want to display
if all(col in df.columns for col in display_columns):
display_df = df[display_columns]
else:
# Fallback - just use all columns
display_df = df
else:
display_df = df
# Batch insert for better performance with alternating row colors
# 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():
# Add alternating row tags for better visibility
tag = "evenrow" if index % 2 == 0 else "oddrow"
self.tree.insert(
parent="", index="end", values=list(row), tags=(tag,)
)
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
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()