feat: Implement search filter persistence and UI synchronization

This commit is contained in:
William Valentin
2025-08-08 11:54:43 -07:00
parent 61c8c72cf7
commit b039447a1f
3 changed files with 132 additions and 5 deletions
+65 -5
View File
@@ -374,14 +374,31 @@ class MedTrackerApp:
self.pathology_manager, self.pathology_manager,
logger, logger,
) )
# Initially hidden - can be toggled with Ctrl+F # Restore prior visibility state from preferences
self.search_filter_visible = False self.search_filter_visible = bool(get_pref("search_panel_visible", False))
if self.search_filter_visible:
self.search_filter_widget.show()
# --- Create Status Bar --- # --- Create Status Bar ---
self.status_bar = self.ui_manager.create_status_bar(main_frame) self.status_bar = self.ui_manager.create_status_bar(main_frame)
# Load data # Load data, optionally restoring saved filters and syncing the UI
self.refresh_data_display() saved_summary = get_pref("last_filter_state", None)
has_saved_filters = bool(
isinstance(saved_summary, dict) and saved_summary.get("has_filters")
)
if has_saved_filters:
# Force one-time restoration in refresh and reflect in the UI if visible
try:
self.refresh_data_display(apply_filters=True)
if self.search_filter_visible and hasattr(
self.search_filter_widget, "sync_ui_from_filter"
):
self.search_filter_widget.sync_ui_from_filter()
except Exception:
self.refresh_data_display()
else:
self.refresh_data_display()
# Initialize status bar with ready message # Initialize status bar with ready message
self.ui_manager.update_status("Application ready", "info") self.ui_manager.update_status("Application ready", "info")
@@ -1088,10 +1105,14 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
self.search_filter_widget.hide() self.search_filter_widget.hide()
self.search_filter_visible = False self.search_filter_visible = False
self.ui_manager.update_status("Search panel hidden", "info") self.ui_manager.update_status("Search panel hidden", "info")
set_pref("search_panel_visible", False)
save_preferences()
else: else:
self.search_filter_widget.show() self.search_filter_widget.show()
self.search_filter_visible = True self.search_filter_visible = True
self.ui_manager.update_status("Search panel shown", "info") self.ui_manager.update_status("Search panel shown", "info")
set_pref("search_panel_visible", True)
save_preferences()
def _on_filter_update(self) -> None: def _on_filter_update(self) -> None:
"""Handle filter updates from the search widget.""" """Handle filter updates from the search widget."""
@@ -1104,6 +1125,12 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.root.after_cancel(self._filter_debounce_id) # type: ignore[attr-defined] self.root.after_cancel(self._filter_debounce_id) # type: ignore[attr-defined]
# Persist filters to preferences for next run
try:
set_pref("last_filter_state", self.data_filter.get_filter_summary())
save_preferences()
except Exception:
pass
# Schedule refresh after short delay # Schedule refresh after short delay
self._filter_debounce_id = self.root.after( # type: ignore[attr-defined] self._filter_debounce_id = self.root.after( # type: ignore[attr-defined]
250, lambda: self.refresh_data_display(apply_filters=True) 250, lambda: self.refresh_data_display(apply_filters=True)
@@ -1333,6 +1360,32 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
logger.debug("Loading data from CSV.") logger.debug("Loading data from CSV.")
try: try:
# One-time restoration of last filter state (best-effort)
if apply_filters and not hasattr(self, "_restored_filters_once"):
import contextlib
self._restored_filters_once = True # type: ignore[attr-defined]
summary = get_pref("last_filter_state", None)
if isinstance(summary, dict) and summary.get("has_filters"):
self.data_filter.set_search_term(summary.get("search_term", ""))
date_rng = summary.get("filters", {}).get("date_range") or {}
self.data_filter.set_date_range_filter(
date_rng.get("start") or None, date_rng.get("end") or None
)
meds = summary.get("filters", {}).get("medicines") or {}
for key in meds.get("taken", []) or []:
self.data_filter.set_medicine_filter(key, True)
for key in meds.get("not_taken", []) or []:
self.data_filter.set_medicine_filter(key, False)
paths = summary.get("filters", {}).get("pathologies") or {}
for key, _range_text in paths.items():
with contextlib.suppress(Exception):
parts = str(_range_text).split("-")
mn = parts[0].strip()
mx = parts[1].strip() if len(parts) > 1 else ""
mn_i = int(mn) if mn and mn.lower() != "any" else None
mx_i = int(mx) if mx and mx.lower() != "any" else None
self.data_filter.set_pathology_range_filter(key, mn_i, mx_i)
# Load data from the CSV file once # Load data from the CSV file once
# Use cached graph-ready data for plotting & base data for table # Use cached graph-ready data for plotting & base data for table
df_full: pd.DataFrame = self.data_manager.load_data() df_full: pd.DataFrame = self.data_manager.load_data()
@@ -1340,7 +1393,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
original_df = df.copy() # Keep a copy for graph updates original_df = df.copy() # Keep a copy for graph updates
# Apply filters if requested and filters are active # Apply filters if requested and filters are active
if apply_filters and self.data_filter.get_filter_summary()["has_filters"]: filter_summary = self.data_filter.get_filter_summary()
if apply_filters and filter_summary["has_filters"]:
df = self.data_filter.apply_filters(df) df = self.data_filter.apply_filters(df)
self.current_filtered_data = df self.current_filtered_data = df
else: else:
@@ -1376,6 +1430,12 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
else: else:
self.ui_manager.update_file_info(self.filename, displayed_entries) self.ui_manager.update_file_info(self.filename, displayed_entries)
# Update tiny filter activity hint
import contextlib
with contextlib.suppress(Exception):
self.ui_manager.set_filter_hint(bool(filter_summary["has_filters"]))
if displayed_entries == 0: if displayed_entries == 0:
status_msg = ( status_msg = (
"No data matches filters" if apply_filters else "No data to display" "No data matches filters" if apply_filters else "No data to display"
+42
View File
@@ -442,6 +442,48 @@ class SearchFilterWidget:
"""Get the main widget for embedding in UI (may be None until shown).""" """Get the main widget for embedding in UI (may be None until shown)."""
return self.frame return self.frame
def sync_ui_from_filter(self) -> None:
"""Synchronize the UI controls with the current DataFilter state.
Best-effort: silently ignores keys not present in the UI (e.g., when
managers have changed). Does not trigger an immediate callback; traces
may schedule a debounced update which is acceptable.
"""
# Search term
import contextlib
with contextlib.suppress(Exception):
self.search_var.set(self.data_filter.search_term or "")
# Date range
with contextlib.suppress(Exception):
date_filter = self.data_filter.active_filters.get("date_range", {})
self.start_date_var.set(date_filter.get("start", "") or "")
self.end_date_var.set(date_filter.get("end", "") or "")
# Medicine filters
with contextlib.suppress(Exception):
meds = self.data_filter.active_filters.get("medicines", {})
for key, var in self.medicine_vars.items():
if key in meds:
var.set("taken" if meds[key] else "not taken")
else:
var.set("any")
# Pathology ranges
with contextlib.suppress(Exception):
paths = self.data_filter.active_filters.get("pathologies", {})
for key, rng in paths.items():
if key in self.pathology_min_vars:
mn = rng.get("min")
self.pathology_min_vars[key].set("" if mn is None else str(mn))
if key in self.pathology_max_vars:
mx = rng.get("max")
self.pathology_max_vars[key].set("" if mx is None else str(mx))
# Update status text
self._update_status()
def show(self) -> None: def show(self) -> None:
"""Show the search filter widget and configure the parent row.""" """Show the search filter widget and configure the parent row."""
if not self._ui_initialized: if not self._ui_initialized:
+25
View File
@@ -686,6 +686,19 @@ class UIManager:
# Pack after file_info so it appears to the left of it # Pack after file_info so it appears to the left of it
self.last_backup_label.pack(side=tk.RIGHT) self.last_backup_label.pack(side=tk.RIGHT)
# Tiny filter activity hint (right side, left of backup info)
self.filter_hint_label = tk.Label(
self.status_bar,
text="",
anchor=tk.E,
font=("TkDefaultFont", 9),
padx=8,
pady=2,
bg=theme_colors["bg"],
fg="#6c757d",
)
self.filter_hint_label.pack(side=tk.RIGHT)
return self.status_bar return self.status_bar
def update_last_backup(self, when_text: str) -> None: def update_last_backup(self, when_text: str) -> None:
@@ -815,6 +828,18 @@ class UIManager:
# Non-fatal UI convenience; ignore errors # Non-fatal UI convenience; ignore errors
pass pass
def set_filter_hint(self, active: bool, text: str | None = None) -> None:
"""Show or hide a small status hint when filters are active.
Args:
active: Whether filters are currently active
text: Optional custom hint text (defaults to 'Filters active')
"""
if not self.filter_hint_label:
return
hint_text = (text or "Filters active") if active else ""
self.filter_hint_label.config(text=hint_text)
def create_edit_window( def create_edit_window(
self, values: tuple[str, ...], callbacks: dict[str, Callable] self, values: tuple[str, ...], callbacks: dict[str, Callable]
) -> tk.Toplevel: ) -> tk.Toplevel: