From b039447a1fd6aa7f8d68712123202cc2752ca9e2 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 8 Aug 2025 11:54:43 -0700 Subject: [PATCH] feat: Implement search filter persistence and UI synchronization --- src/main.py | 70 ++++++++++++++++++++++++++++++++++++++--- src/search_filter_ui.py | 42 +++++++++++++++++++++++++ src/ui_manager.py | 25 +++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index b95f3e2..6a6cb23 100644 --- a/src/main.py +++ b/src/main.py @@ -374,14 +374,31 @@ class MedTrackerApp: self.pathology_manager, logger, ) - # Initially hidden - can be toggled with Ctrl+F - self.search_filter_visible = False + # Restore prior visibility state from preferences + self.search_filter_visible = bool(get_pref("search_panel_visible", False)) + if self.search_filter_visible: + self.search_filter_widget.show() # --- Create Status Bar --- self.status_bar = self.ui_manager.create_status_bar(main_frame) - # Load data - self.refresh_data_display() + # Load data, optionally restoring saved filters and syncing the UI + 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 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_visible = False self.ui_manager.update_status("Search panel hidden", "info") + set_pref("search_panel_visible", False) + save_preferences() else: self.search_filter_widget.show() self.search_filter_visible = True self.ui_manager.update_status("Search panel shown", "info") + set_pref("search_panel_visible", True) + save_preferences() def _on_filter_update(self) -> None: """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): 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 self._filter_debounce_id = self.root.after( # type: ignore[attr-defined] 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.") 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 # Use cached graph-ready data for plotting & base data for table 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 # 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) self.current_filtered_data = df else: @@ -1376,6 +1430,12 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" else: 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: status_msg = ( "No data matches filters" if apply_filters else "No data to display" diff --git a/src/search_filter_ui.py b/src/search_filter_ui.py index 0333f37..83cf6be 100644 --- a/src/search_filter_ui.py +++ b/src/search_filter_ui.py @@ -442,6 +442,48 @@ class SearchFilterWidget: """Get the main widget for embedding in UI (may be None until shown).""" 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: """Show the search filter widget and configure the parent row.""" if not self._ui_initialized: diff --git a/src/ui_manager.py b/src/ui_manager.py index 5e39904..f574b73 100644 --- a/src/ui_manager.py +++ b/src/ui_manager.py @@ -686,6 +686,19 @@ class UIManager: # Pack after file_info so it appears to the left of it 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 def update_last_backup(self, when_text: str) -> None: @@ -815,6 +828,18 @@ class UIManager: # Non-fatal UI convenience; ignore errors 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( self, values: tuple[str, ...], callbacks: dict[str, Callable] ) -> tk.Toplevel: