From 61c8c72cf77283457e10e789232b49b3eadc883d Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 8 Aug 2025 11:32:43 -0700 Subject: [PATCH] feat: Enhance UI feedback and improve data filtering logic --- src/main.py | 45 +++++++++++++++++-------- src/search_filter.py | 18 +++++++--- src/search_filter_ui.py | 41 +++++++++++----------- src/ui_manager.py | 75 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 137 insertions(+), 42 deletions(-) diff --git a/src/main.py b/src/main.py index 1f0c700..b95f3e2 100644 --- a/src/main.py +++ b/src/main.py @@ -1017,9 +1017,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" self._mark_data_modified() # Mark for auto-save edit_win.destroy() self.ui_manager.update_status("Entry updated successfully!", "success") - messagebox.showinfo( - "Success", "Entry updated successfully!", parent=self.root - ) + 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] @@ -1170,11 +1169,23 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" return entry_data["note"] = validated_note - # Check entry completeness - is_complete, missing_fields = InputValidator.validate_entry_completeness( - entry_data + # 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() ) - if not is_complete: + 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 ) @@ -1224,9 +1235,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" if self.data_manager.add_entry(entry): self._mark_data_modified() # Mark for auto-save self.ui_manager.update_status("Entry added successfully!", "success") - messagebox.showinfo( - "Success", "Entry added successfully!", parent=self.root - ) + 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] @@ -1278,9 +1288,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" self._mark_data_modified() # Mark for auto-save edit_win.destroy() self.ui_manager.update_status("Entry deleted successfully!", "success") - messagebox.showinfo( - "Success", "Entry deleted successfully!", parent=self.root - ) + if hasattr(self.ui_manager, "show_toast"): + self.ui_manager.show_toast("Entry deleted", 1500) self.refresh_data_display() if deleted_row: @@ -1308,7 +1317,11 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" def _clear_entries(self) -> None: """Clear all input fields.""" logger.debug("Clearing input fields.") - self.date_var.set("") + # 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: @@ -1336,6 +1349,10 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" # 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"): diff --git a/src/search_filter.py b/src/search_filter.py index 03dde0b..764046f 100644 --- a/src/search_filter.py +++ b/src/search_filter.py @@ -192,11 +192,19 @@ class DataFilter: for medicine_key, should_be_taken in medicine_filters.items(): if medicine_key in df.columns: col = df[medicine_key] - # Medicine columns in tests contain empty string when not taken - if should_be_taken: - mask &= col.astype(str).str.len() > 0 - else: - mask &= col.astype(str).str.len() == 0 + # Prefer numeric/boolean interpretation when possible (0/1) + try: + numeric = pd.to_numeric(col, errors="coerce") + if should_be_taken: + mask &= numeric.fillna(0) == 1 + else: + mask &= numeric.fillna(0) == 0 + except Exception: + # Fallback to truthy string length semantics + if should_be_taken: + mask &= col.astype(str).str.len() > 0 + else: + mask &= col.astype(str).str.len() == 0 return df[mask] diff --git a/src/search_filter_ui.py b/src/search_filter_ui.py index 7db339c..0333f37 100644 --- a/src/search_filter_ui.py +++ b/src/search_filter_ui.py @@ -38,33 +38,29 @@ class SearchFilterWidget: self.pathology_manager = pathology_manager self.logger = logger - # Initialize visibility state + # Visibility and UI init state self.is_visible = False - - self.search_history = SearchHistory() + self._ui_initialized = False + self.frame: ttk.LabelFrame | None = None # Debouncing mechanism to reduce filter update frequency - self._update_timer = None - self._debounce_delay = 300 # milliseconds + self._update_timer: str | None = None + self._debounce_delay = 450 # milliseconds - # UI state variables + # History and UI state variables + self.search_history = SearchHistory() self.search_var = tk.StringVar() self.start_date_var = tk.StringVar() self.end_date_var = tk.StringVar() - # Medicine filter variables - self.medicine_vars = {} - - # Pathology filter variables - self.pathology_min_vars = {} - self.pathology_max_vars = {} - - self._setup_ui() - self._bind_events() + # Medicine and pathology filter variables + self.medicine_vars: dict[str, tk.StringVar] = {} + self.pathology_min_vars: dict[str, tk.StringVar] = {} + self.pathology_max_vars: dict[str, tk.StringVar] = {} def _setup_ui(self) -> None: """Set up the search and filter UI.""" - # Main container - remove height limit to allow full horizontal stretch + # Main container self.frame = ttk.LabelFrame(self.parent, text="Search & Filter", padding="5") # Create main content frame without scrolling - use horizontal layout @@ -442,12 +438,17 @@ class SearchFilterWidget: self.status_label.config(text=status_text) - def get_widget(self) -> ttk.LabelFrame: - """Get the main widget for embedding in UI.""" + def get_widget(self) -> ttk.LabelFrame | None: + """Get the main widget for embedding in UI (may be None until shown).""" return self.frame def show(self) -> None: """Show the search filter widget and configure the parent row.""" + if not self._ui_initialized: + self._setup_ui() + self._bind_events() + self._ui_initialized = True + assert self.frame is not None self.frame.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=5, pady=2) # Configure the parent grid row for horizontal layout (smaller minsize) if hasattr(self.parent, "grid_rowconfigure"): @@ -457,6 +458,8 @@ class SearchFilterWidget: def hide(self) -> None: """Hide the search filter widget and reset the parent row.""" + if not self.frame: + return self.frame.grid_remove() # Reset the parent grid row to not allocate space when hidden if hasattr(self.parent, "grid_rowconfigure"): @@ -466,7 +469,7 @@ class SearchFilterWidget: def toggle(self) -> None: """Toggle visibility of the search and filter widget.""" - if self.frame.winfo_viewable(): + if self.frame and self.frame.winfo_viewable(): self.hide() else: self.show() diff --git a/src/ui_manager.py b/src/ui_manager.py index be22da4..5e39904 100644 --- a/src/ui_manager.py +++ b/src/ui_manager.py @@ -392,10 +392,15 @@ class UIManager: # Column sort state tracking self._tree_sort_directions: dict[str, bool] = {} + self._last_sorted_column: str | None = None + self._last_sorted_ascending: bool | None = None def make_sort_callback(col_name: str): def _callback(): self.sort_tree_column(tree, col_name) + # Remember last sort state + self._last_sorted_column = col_name + self._last_sorted_ascending = self._tree_sort_directions.get(col_name) return _callback @@ -407,10 +412,12 @@ class UIManager: tree.pack(side="left", fill="both", expand=True) - # Add scrollbar with optimized scroll handling - scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview) - tree.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side="right", fill="y") + # Add scrollbars with optimized scroll handling + vscroll = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview) + hscroll = ttk.Scrollbar(table_frame, orient="horizontal", command=tree.xview) + tree.configure(yscrollcommand=vscroll.set, xscrollcommand=hscroll.set) + vscroll.pack(side="right", fill="y") + hscroll.pack(side="bottom", fill="x") # Optimize tree scrolling performance self._optimize_tree_scrolling(tree) @@ -452,6 +459,50 @@ class UIManager: # Update heading arrow (basic glyph) direction_glyph = "▲" if ascending else "▼" tree.heading(column, text=f"{column} {direction_glyph}") + # Re-apply alternating row tags after sort + self.normalize_tree_stripes(tree) + + def _sort_tree_column_direction( + self, tree: ttk.Treeview, column: str, ascending: bool + ) -> None: + """Sort a treeview column in a specific direction without toggling state.""" + data = [] + for item in tree.get_children(""): + values = tree.item(item, "values") + try: + col_index = tree["columns"].index(column) + except ValueError: + continue + data.append((values[col_index], item, values)) + + def try_cast(v: Any): + for caster in (int, float): + try: + return caster(v) + except Exception: + continue + return str(v) + + data.sort(key=lambda tup: try_cast(tup[0]), reverse=not ascending) + + for index, (_value, item, _vals) in enumerate(data): + tree.move(item, "", index) + + direction_glyph = "▲" if ascending else "▼" + tree.heading(column, text=f"{column} {direction_glyph}") + # Re-apply alternating row tags after sort + self.normalize_tree_stripes(tree) + + def reapply_last_sort(self, tree: ttk.Treeview) -> None: + """Reapply the last known sort to the tree after data refresh.""" + if not self._last_sorted_column or self._last_sorted_ascending is None: + return + import contextlib + + with contextlib.suppress(Exception): + self._sort_tree_column_direction( + tree, self._last_sorted_column, bool(self._last_sorted_ascending) + ) def diff_update_tree(self, tree: ttk.Treeview, df: pd.DataFrame) -> None: """Apply minimal changes to treeview vs full rebuild. @@ -518,6 +569,22 @@ class UIManager: tag = "evenrow" if idx % 2 == 0 else "oddrow" tree.insert("", "end", values=list(row), tags=(tag,)) + # Ensure alternating stripes are normalized after updates + self.normalize_tree_stripes(tree) + + def normalize_tree_stripes(self, tree: ttk.Treeview) -> None: + """Normalize alternating row tags based on current visual order. + + Keeps even/odd striping consistent after inserts, deletes, and sorts. + """ + try: + for idx, item in enumerate(tree.get_children("")): + tag = "evenrow" if idx % 2 == 0 else "oddrow" + tree.item(item, tags=(tag,)) + except Exception: + # Best-effort visual enhancement; ignore errors + pass + def create_graph_frame(self, parent_frame: ttk.Frame) -> ttk.LabelFrame: """Create and configure the graph frame.""" graph_frame: ttk.LabelFrame = ttk.LabelFrame(