From 28f858ec0f2dc595f5900998e0bcc736d6fba04a Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Sat, 7 Feb 2026 22:26:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Trends=20drill-down=20=E2=80=94=20click?= =?UTF-8?q?=20directorate=20to=20see=20drug-level=20trends=20(Task=20E.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IMPLEMENTATION_PLAN.md | 30 ++++++++--------- dash_app/callbacks/filters.py | 21 +++++++++++- dash_app/callbacks/trends.py | 46 +++++++++++++++++++++++++++ src/visualization/plotly_generator.py | 1 + 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ea44aca..be277eb 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -294,22 +294,18 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard. - **Checkpoint**: Trends view shows directorate-level line chart. Metric toggle switches y-axis. Lines show one per directorate. PASSED. ### E.4 Add drug drill-down within Trends view -- [ ] Add `selected_trends_directorate` key (default `None`) to `app-state` initial data in `app.py` -- [ ] Add directorate selection callback in `dash_app/callbacks/trends.py`: - - Clicking a line/trace on the overview chart sets `selected_trends_directorate` in app-state - - Use `clickData` from `trends-overview-chart` as Input - - Extract directorate name from the clicked trace's `name` attribute - - Update `app-state` with `selected_trends_directorate` -- [ ] Add landing/detail toggle callback: - - Input: `app-state` → show/hide `trends-landing` vs `trends-detail` - - When `selected_trends_directorate` is set: hide landing, show detail with title "[Directorate] — Drug Trends" -- [ ] Add detail chart callback: - - Input: `app-state` + `trends-view-metric-toggle` → Output `trends-detail-chart` +- [x] Add `selected_trends_directorate` key (default `None`) to `app-state` initial data in `app.py` (already done in E.2) +- [x] Add `customdata=[name]*len(periods)` to each trace in `create_trend_figure()` so directorate name is accessible from clickData +- [x] Add `Input("trends-overview-chart", "clickData")` and `Input("trends-back-btn", "n_clicks")` to `update_app_state()` in `filters.py`: + - Clicking a trace point extracts directorate name from `clickData["points"][0]["customdata"]` + - Back button clears `selected_trends_directorate` to None + - Chart type change also clears `selected_trends_directorate` +- [x] Landing/detail toggle callback already exists in `trends.py` (`toggle_trends_subviews`) — handles show/hide based on `selected_trends_directorate` +- [x] Add `render_trends_detail()` callback in `trends.py`: + - Input: `app-state` + `trends-detail-metric-toggle` → Output `trends-detail-chart` - Calls `get_trend_data(directory=selected, metric=..., group_by="drug")` → `create_trend_figure()` - - Only fires when `selected_trends_directorate` is not None -- [ ] Add back button callback: - - Clicking `trends-back-btn` clears `selected_trends_directorate` in app-state → returns to landing -- **Checkpoint**: Click a directorate line → drill into drug-level trends. Back button returns to overview. `python run_dash.py` starts cleanly. + - Guards: only fires when `active_view == "trends"` and `selected_trends_directorate` is not None +- **Checkpoint**: Click a directorate line → drill into drug-level trends. Back button returns to overview. `python run_dash.py` starts cleanly. PASSED. ### E.5 Fix chart height to fill viewport - [ ] In `create_trend_figure()` in `plotly_generator.py`: remove explicit `height=500`, let `autosize=True` (from `_base_layout()`) handle it @@ -357,8 +353,8 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard. - [x] Trends tab removed from Patient Pathways (9 tabs remain) - [x] 3rd sidebar item "Trends" visible and functional - [x] Trends landing page shows directorate-level line chart with metric toggle -- [ ] Clicking a directorate drills into drug-level trends -- [ ] Back button returns to directorate overview +- [x] Clicking a directorate drills into drug-level trends +- [x] Back button returns to directorate overview - [ ] Charts fill available viewport height (no fixed 500px cutoff) - [x] "Cost" renamed to "Cost per Patient" in metric toggles - [ ] `python run_dash.py` starts cleanly diff --git a/dash_app/callbacks/filters.py b/dash_app/callbacks/filters.py index 5f83d41..cc2d905 100644 --- a/dash_app/callbacks/filters.py +++ b/dash_app/callbacks/filters.py @@ -78,12 +78,15 @@ def register_filter_callbacks(app): Input("nav-trends", "n_clicks"), Input({"type": "tc-selector", "index": ALL}, "n_clicks"), Input("tc-back-btn", "n_clicks"), + Input("trends-overview-chart", "clickData"), + Input("trends-back-btn", "n_clicks"), State("app-state", "data"), ) def update_app_state( _dir_clicks, _ind_clicks, initiated, last_seen, selected_drugs, selected_trusts, _nav_pp_clicks, _nav_tc_clicks, _nav_trends_clicks, - _tc_selector_clicks, _tc_back_clicks, current_state + _tc_selector_clicks, _tc_back_clicks, trends_click_data, + _trends_back_clicks, current_state ): """Update app-state when any filter, nav, or TC selector changes.""" if not current_state: @@ -134,6 +137,21 @@ def register_filter_callbacks(app): if chart_type != prev_chart_type and selected_comparison_directorate is not None: selected_comparison_directorate = None + # Trends directorate drill-down + selected_trends_directorate = current_state.get("selected_trends_directorate") + + if triggered_id == "trends-overview-chart" and trends_click_data: + points = trends_click_data.get("points", []) + if points: + selected_trends_directorate = points[0].get("customdata") + + if triggered_id == "trends-back-btn": + selected_trends_directorate = None + + # If chart type changed while trends directorate is selected, return to landing + if chart_type != prev_chart_type and selected_trends_directorate is not None: + selected_trends_directorate = None + # Compute date_filter_id from dropdown values date_filter_id = f"{initiated}_{last_seen}" @@ -148,6 +166,7 @@ def register_filter_callbacks(app): "selected_trusts": selected_trusts or [], "active_view": active_view, "selected_comparison_directorate": selected_comparison_directorate, + "selected_trends_directorate": selected_trends_directorate, } # Toggle pill CSS classes diff --git a/dash_app/callbacks/trends.py b/dash_app/callbacks/trends.py index 7578285..7fb67c7 100644 --- a/dash_app/callbacks/trends.py +++ b/dash_app/callbacks/trends.py @@ -92,3 +92,49 @@ def register_trends_callbacks(app): title = f"Directorate Trends \u2014 {metric_labels.get(metric, 'Patients')}" return create_trend_figure(data, title, metric) + + # --- Drug detail chart (drill-down) --- + @app.callback( + Output("trends-detail-chart", "figure"), + Input("app-state", "data"), + Input("trends-detail-metric-toggle", "value"), + prevent_initial_call=True, + ) + def render_trends_detail(app_state, metric): + """Render drug-level trends for the selected directorate.""" + if not app_state: + return no_update + + active_view = app_state.get("active_view", "") + if active_view != "trends": + return no_update + + selected = app_state.get("selected_trends_directorate") + if not selected: + return no_update + + metric = metric or "patients" + + from dash_app.data.queries import get_trend_data + from visualization.plotly_generator import create_trend_figure + + try: + data = get_trend_data( + metric=metric, + directory=selected, + group_by="drug", + ) + except Exception: + return _trends_empty("Failed to load trend data.") + + if not data: + return _trends_empty("No trend data for this directorate.") + + metric_labels = { + "patients": "Patients", + "total_cost": "Cost per Patient", + "cost_pp_pa": "Cost per Patient p.a.", + } + title = f"{selected} \u2014 {metric_labels.get(metric, 'Patients')}" + + return create_trend_figure(data, title, metric) diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py index 6ad0bed..5106892 100644 --- a/src/visualization/plotly_generator.py +++ b/src/visualization/plotly_generator.py @@ -2343,6 +2343,7 @@ def create_trend_figure( y=s["values"], mode="lines+markers", name=name, + customdata=[name] * len(s["periods"]), line=dict(color=colour, width=2), marker=dict(color=colour, size=6), hovertemplate=(