diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9a3b1d6..207c964 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -228,10 +228,10 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_ ## Phase 5: Polish & Cleanup ### 5.1 Trust selection -- [ ] Add trust selection either: +- [x] Add trust selection either: - In the dmc.Drawer as a "Trusts" section (preferred — keeps all filters in one place), OR - As sidebar checkboxes -- [ ] Wire trust selection to `selected_trusts` in `app-state` → pathway data reload +- [x] Wire trust selection to `selected_trusts` in `app-state` → pathway data reload - **Checkpoint**: Selecting trusts filters the chart correctly ### 5.2 Loading/error/empty states + dynamic hierarchy label diff --git a/dash_app/app.py b/dash_app/app.py index cb35ab6..107a24e 100644 --- a/dash_app/app.py +++ b/dash_app/app.py @@ -25,6 +25,7 @@ app.layout = dmc.MantineProvider( "date_filter_id": "all_6mo", "selected_drugs": [], "selected_directorates": [], + "selected_trusts": [], }), dcc.Store(id="chart-data", storage_type="memory"), dcc.Store(id="reference-data", storage_type="session"), diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py index 6cc9dc7..9b013f4 100644 --- a/dash_app/callbacks/chart.py +++ b/dash_app/callbacks/chart.py @@ -32,6 +32,13 @@ def _generate_chart_title(app_state): else: parts.append(f"{len(selected_directorates)} directorates") + selected_trusts = app_state.get("selected_trusts") or [] + if selected_trusts: + if len(selected_trusts) <= 2: + parts.append(", ".join(selected_trusts)) + else: + parts.append(f"{len(selected_trusts)} trusts") + return " | ".join(parts) if parts else "All Patients" @@ -53,12 +60,14 @@ def register_chart_callbacks(app): chart_type = app_state.get("chart_type", "directory") selected_drugs = app_state.get("selected_drugs") or None selected_directorates = app_state.get("selected_directorates") or None + selected_trusts = app_state.get("selected_trusts") or None return query_pathway_data( filter_id=filter_id, chart_type=chart_type, selected_drugs=selected_drugs, selected_directorates=selected_directorates, + selected_trusts=selected_trusts, ) @app.callback( diff --git a/dash_app/callbacks/drawer.py b/dash_app/callbacks/drawer.py index 39eb959..3ff53fa 100644 --- a/dash_app/callbacks/drawer.py +++ b/dash_app/callbacks/drawer.py @@ -9,14 +9,16 @@ def register_drawer_callbacks(app): Output("drug-drawer", "opened"), Input("sidebar-drug-selection", "n_clicks"), Input("sidebar-indications", "n_clicks"), + Input("sidebar-trust-selection", "n_clicks"), prevent_initial_call=True, ) - def open_drawer(_drug_clicks, _indication_clicks): - """Open the drawer when sidebar Drug Selection or Indications is clicked.""" + def open_drawer(_drug_clicks, _indication_clicks, _trust_clicks): + """Open the drawer when sidebar Drug Selection, Indications, or Trust Selection is clicked.""" return True @app.callback( Output("all-drugs-chips", "value"), + Output("trust-chips", "value"), Input({"type": "drug-fragment", "index": ALL}, "n_clicks"), Input("clear-drug-filters", "n_clicks"), State("all-drugs-chips", "value"), @@ -27,20 +29,20 @@ def register_drawer_callbacks(app): """Handle drug fragment badge click (substring match) or clear all filters. Fragment click: find all full drug names containing the fragment substring, - toggle them in the chip selection. - Clear click: reset chip selection to empty. + toggle them in the chip selection. Trust chips unchanged. + Clear click: reset both drug and trust chip selections. """ triggered = ctx.triggered_id - # Clear button + # Clear button — reset both drug and trust chips if triggered == "clear-drug-filters": - return [] + return [], [] # Fragment badge click — triggered_id is a dict like {"type": "drug-fragment", "index": "DIR|FRAG"} if isinstance(triggered, dict) and triggered.get("type") == "drug-fragment": # Check if any fragment was actually clicked (not just initial render) if not any(n for n in (fragment_clicks or []) if n): - return no_update + return no_update, no_update fragment_key = triggered["index"] # e.g. "CARDIOLOGY|ABCIXIMAB" fragment = fragment_key.split("|", 1)[-1] if "|" in fragment_key else fragment_key @@ -55,7 +57,7 @@ def register_drawer_callbacks(app): ] if not matching_drugs: - return no_update + return no_update, no_update # Toggle: if all matching drugs are already selected, deselect them; # otherwise, add them to selection @@ -67,6 +69,6 @@ def register_drawer_callbacks(app): else: updated = current | set(matching_drugs) - return sorted(updated) + return sorted(updated), no_update - return no_update + return no_update, no_update diff --git a/dash_app/callbacks/filters.py b/dash_app/callbacks/filters.py index ff523dc..b99096a 100644 --- a/dash_app/callbacks/filters.py +++ b/dash_app/callbacks/filters.py @@ -32,12 +32,14 @@ def register_filter_callbacks(app): Input("filter-initiated", "value"), Input("filter-last-seen", "value"), Input("all-drugs-chips", "value"), + Input("trust-chips", "value"), State("app-state", "data"), ) def update_app_state( - _dir_clicks, _ind_clicks, initiated, last_seen, selected_drugs, current_state + _dir_clicks, _ind_clicks, initiated, last_seen, selected_drugs, + selected_trusts, current_state ): - """Update app-state when chart type toggle, date filters, or drug chips change.""" + """Update app-state when chart type toggle, date filters, drug chips, or trust chips change.""" if not current_state: current_state = { "chart_type": "directory", @@ -46,6 +48,7 @@ def register_filter_callbacks(app): "date_filter_id": "all_6mo", "selected_drugs": [], "selected_directorates": [], + "selected_trusts": [], } triggered_id = ctx.triggered_id @@ -68,6 +71,7 @@ def register_filter_callbacks(app): "last_seen": last_seen, "date_filter_id": date_filter_id, "selected_drugs": selected_drugs or [], + "selected_trusts": selected_trusts or [], } # Toggle pill CSS classes diff --git a/dash_app/components/drawer.py b/dash_app/components/drawer.py index 8d9dffc..da5242a 100644 --- a/dash_app/components/drawer.py +++ b/dash_app/components/drawer.py @@ -1,8 +1,9 @@ """ -Drug browser drawer component using Dash Mantine Components. +Filter drawer component using Dash Mantine Components. Provides a right-side drawer with: - "All Drugs" section: flat alphabetical list of all drugs from pathway_nodes +- "Trusts" section: all NHS trusts from pathway_nodes for trust filtering - Directorate cards: grouped by PrimaryDirectorate from DimSearchTerm.csv, with Accordion items per Search_Term containing drug fragment chips - "Clear Filters" button at the bottom @@ -11,7 +12,7 @@ Provides a right-side drawer with: from dash import html import dash_mantine_components as dmc -from dash_app.data.card_browser import build_directorate_tree, get_all_drugs +from dash_app.data.card_browser import build_directorate_tree, get_all_drugs, get_all_trusts def _make_drug_chips(drugs: list[str]) -> dmc.ChipGroup: @@ -27,6 +28,19 @@ def _make_drug_chips(drugs: list[str]) -> dmc.ChipGroup: ) +def _make_trust_chips(trusts: list[str]) -> dmc.ChipGroup: + """Create a ChipGroup with multiple selection for the 'Trusts' section.""" + return dmc.ChipGroup( + id="trust-chips", + multiple=True, + value=[], + children=[ + dmc.Chip(trust, value=trust, size="xs") + for trust in trusts + ], + ) + + def _make_directorate_card(directorate: str, indications: dict[str, list[str]]) -> dmc.AccordionItem: """ Create an AccordionItem for a single directorate. @@ -97,6 +111,7 @@ def make_drawer(): Returns a dmc.Drawer that will be opened/closed via callbacks in Phase 4.2. """ drugs = get_all_drugs() + trusts = get_all_trusts() directorate_tree = build_directorate_tree() # All Drugs section @@ -116,6 +131,23 @@ def make_drawer(): ], ) + # Trusts section + trusts_section = html.Div( + className="drawer-section", + children=[ + dmc.Text("Trusts", fw=700, size="sm", className="drawer-section-title"), + dmc.Text( + f"{len(trusts)} NHS trusts", + size="xs", + c="dimmed", + ), + html.Div( + className="drawer-chips-wrap", + children=_make_trust_chips(trusts), + ), + ], + ) + # Directorate cards section directorate_items = [ _make_directorate_card(directorate, indications) @@ -163,6 +195,8 @@ def make_drawer(): children=[ all_drugs_section, dmc.Divider(), + trusts_section, + dmc.Divider(), directorate_section, clear_button, ], diff --git a/dash_app/components/sidebar.py b/dash_app/components/sidebar.py index 1d4eed3..42326ca 100644 --- a/dash_app/components/sidebar.py +++ b/dash_app/components/sidebar.py @@ -47,7 +47,9 @@ def make_sidebar(): _sidebar_item( "Drug Selection", "drug", item_id="sidebar-drug-selection" ), - _sidebar_item("Trust Selection", "trust"), + _sidebar_item( + "Trust Selection", "trust", item_id="sidebar-trust-selection" + ), _sidebar_item("Directory Selection", "directory"), _sidebar_item( "Indications", "indication", item_id="sidebar-indications" diff --git a/dash_app/data/card_browser.py b/dash_app/data/card_browser.py index 4f23800..685a37c 100644 --- a/dash_app/data/card_browser.py +++ b/dash_app/data/card_browser.py @@ -81,3 +81,15 @@ def get_all_drugs() -> list[str]: data = load_initial_data() return data.get("available_drugs", []) + + +def get_all_trusts() -> list[str]: + """ + Return a sorted flat list of all unique trust names from pathway_nodes level 1. + + Delegates to load_initial_data() which already queries the database. + """ + from dash_app.data.queries import load_initial_data + + data = load_initial_data() + return data.get("available_trusts", []) diff --git a/dash_app/data/queries.py b/dash_app/data/queries.py index 358f884..47e4e42 100644 --- a/dash_app/data/queries.py +++ b/dash_app/data/queries.py @@ -26,6 +26,7 @@ def load_pathway_data( chart_type: str = "directory", selected_drugs: Optional[list[str]] = None, selected_directorates: Optional[list[str]] = None, + selected_trusts: Optional[list[str]] = None, ) -> dict: """Load pre-computed pathway nodes with optional filters.""" return _load_pathway_nodes( @@ -34,4 +35,5 @@ def load_pathway_data( chart_type=chart_type, selected_drugs=selected_drugs, selected_directorates=selected_directorates, + selected_trusts=selected_trusts, ) diff --git a/src/data_processing/pathway_queries.py b/src/data_processing/pathway_queries.py index bf1941d..f8eb9ee 100644 --- a/src/data_processing/pathway_queries.py +++ b/src/data_processing/pathway_queries.py @@ -31,6 +31,7 @@ def load_initial_data(db_path: Path) -> dict: "available_drugs": [], "available_directorates": [], "available_indications": [], + "available_trusts": [], "total_records": 0, "last_updated": "", "error": "Database not found", @@ -82,10 +83,20 @@ def load_initial_data(db_path: Path) -> dict: if not available_indications: available_indications = ["(No indications available)"] + # Unique trusts from pathway_nodes level 1 + cursor.execute(""" + SELECT DISTINCT trust_name + FROM pathway_nodes + WHERE level = 1 AND trust_name IS NOT NULL AND trust_name != '' + ORDER BY trust_name + """) + available_trusts = [row[0] for row in cursor.fetchall()] + return { "available_drugs": available_drugs, "available_directorates": available_directorates, "available_indications": available_indications, + "available_trusts": available_trusts, "total_records": total_records, "last_updated": last_updated, } @@ -94,6 +105,7 @@ def load_initial_data(db_path: Path) -> dict: "available_drugs": [], "available_directorates": [], "available_indications": [], + "available_trusts": [], "total_records": 0, "last_updated": "", "error": f"Database error: {e}", @@ -108,6 +120,7 @@ def load_pathway_nodes( chart_type: str, selected_drugs: Optional[list[str]] = None, selected_directorates: Optional[list[str]] = None, + selected_trusts: Optional[list[str]] = None, ) -> dict: """ Load pre-computed pathway nodes from SQLite. @@ -120,6 +133,7 @@ def load_pathway_nodes( chart_type: "directory" or "indication" selected_drugs: optional list of drug names to filter by selected_directorates: optional list of directorate names to filter by + selected_trusts: optional list of trust names to filter by Returns dict with keys: nodes: list of dicts (JSON-serializable) with chart node data @@ -157,6 +171,13 @@ def load_pathway_nodes( f"({' OR '.join(drug_conditions)} OR drug_sequence IS NULL)" ) + if selected_trusts: + placeholders = ",".join("?" * len(selected_trusts)) + where_clauses.append( + f"(trust_name IN ({placeholders}) OR trust_name IS NULL OR trust_name = '')" + ) + params.extend(selected_trusts) + where_clause = " AND ".join(where_clauses) query = f"""