From c253e0504619fd34656404dd36cc03d6777499ce Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Sat, 7 Feb 2026 22:19:52 +0000 Subject: [PATCH] feat: Trends landing page with directorate overview chart (Task E.3) --- IMPLEMENTATION_PLAN.md | 20 ++--- dash_app/app.py | 4 +- dash_app/callbacks/__init__.py | 2 + dash_app/callbacks/trends.py | 94 +++++++++++++++++++++ dash_app/components/trends.py | 112 +++++++++++++++++++++++++ dash_app/data/queries.py | 12 +++ src/data_processing/pathway_queries.py | 15 +--- 7 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 dash_app/callbacks/trends.py create mode 100644 dash_app/components/trends.py diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3a89469..ea44aca 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -277,21 +277,21 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard. - **Checkpoint**: 3 sidebar items visible. Clicking "Trends" switches to empty trends view. `python run_dash.py` starts cleanly. PASSED. ### E.3 Create Trends landing page — directorate-level trends -- [ ] Create `dash_app/components/trends.py`: +- [x] Create `dash_app/components/trends.py`: - `make_trends_landing()` — container with title, description, metric toggle (`dmc.SegmentedControl` id: `trends-view-metric-toggle`, options: Patients / Cost per Patient / Cost per Patient p.a.), and `dcc.Graph(id="trends-overview-chart")` wrapped in `dcc.Loading` - `make_trends_detail()` — hidden container with back button (id: `trends-back-btn`), title (id: `trends-detail-title`), same metric toggle, and `dcc.Graph(id="trends-detail-chart")` wrapped in `dcc.Loading` -- [ ] Update `get_trend_data()` in `pathway_queries.py` to support `group_by` parameter: +- [x] Update `get_trend_data()` in `pathway_queries.py` to support `group_by` parameter: - `group_by="drug"` (default, existing behavior): one line per drug - `group_by="directory"`: one line per directory (aggregate drugs within each directory) - When `group_by="directory"`: `SELECT period_end, directory AS name, SUM(...) ... GROUP BY period_end, directory` -- [ ] Update thin wrapper in `dash_app/data/queries.py` to pass `group_by` param -- [ ] Create `dash_app/callbacks/trends.py` with `register_trends_callbacks(app)`: +- [x] Update thin wrapper in `dash_app/data/queries.py` to pass `group_by` param +- [x] Create `dash_app/callbacks/trends.py` with `register_trends_callbacks(app)`: - Callback to render directorate-level chart: Input `app-state` + `trends-view-metric-toggle` → Output `trends-overview-chart` figure. Calls `get_trend_data(group_by="directory", metric=...)` → `create_trend_figure(data, title, metric)`. - Only fires when `active_view == "trends"` and `selected_trends_directorate` is None. -- [ ] Register in `dash_app/callbacks/__init__.py` -- [ ] Rename "Cost" label to "Cost per Patient" in the metric toggle options (value stays `total_cost`) -- [ ] Wire `trends-view` div in `app.py` to contain `make_trends_landing()` + `make_trends_detail()` -- **Checkpoint**: Trends view shows directorate-level line chart. Metric toggle switches y-axis. Lines show one per directorate. +- [x] Register in `dash_app/callbacks/__init__.py` +- [x] Rename "Cost" label to "Cost per Patient" in the metric toggle options (value stays `total_cost`) +- [x] Wire `trends-view` div in `app.py` to contain `make_trends_landing()` + `make_trends_detail()` +- **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` @@ -356,11 +356,11 @@ Comprehensive review and improvement of all Plotly charts in the Dash dashboard. ### Phase E - [x] Trends tab removed from Patient Pathways (9 tabs remain) - [x] 3rd sidebar item "Trends" visible and functional -- [ ] Trends landing page shows directorate-level line chart with metric toggle +- [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 - [ ] Charts fill available viewport height (no fixed 500px cutoff) -- [ ] "Cost" renamed to "Cost per Patient" in metric toggles +- [x] "Cost" renamed to "Cost per Patient" in metric toggles - [ ] `python run_dash.py` starts cleanly --- diff --git a/dash_app/app.py b/dash_app/app.py index 549c53f..6d0129d 100644 --- a/dash_app/app.py +++ b/dash_app/app.py @@ -10,6 +10,7 @@ from dash_app.components.chart_card import make_chart_card from dash_app.components.footer import make_footer from dash_app.components.modals import make_modals from dash_app.components.trust_comparison import make_tc_landing, make_tc_dashboard +from dash_app.components.trends import make_trends_landing, make_trends_detail app = Dash( __name__, @@ -70,7 +71,8 @@ app.layout = dmc.MantineProvider( id="trends-view", style={"display": "none"}, children=[ - html.H3("Trends", style={"padding": "24px"}), + make_trends_landing(), + make_trends_detail(), ], ), ], diff --git a/dash_app/callbacks/__init__.py b/dash_app/callbacks/__init__.py index 58ec72d..7f207ad 100644 --- a/dash_app/callbacks/__init__.py +++ b/dash_app/callbacks/__init__.py @@ -9,6 +9,7 @@ def register_callbacks(app): from dash_app.callbacks.modals import register_modal_callbacks from dash_app.callbacks.navigation import register_navigation_callbacks from dash_app.callbacks.trust_comparison import register_trust_comparison_callbacks + from dash_app.callbacks.trends import register_trends_callbacks register_filter_callbacks(app) register_chart_callbacks(app) @@ -16,3 +17,4 @@ def register_callbacks(app): register_modal_callbacks(app) register_navigation_callbacks(app) register_trust_comparison_callbacks(app) + register_trends_callbacks(app) diff --git a/dash_app/callbacks/trends.py b/dash_app/callbacks/trends.py new file mode 100644 index 0000000..7578285 --- /dev/null +++ b/dash_app/callbacks/trends.py @@ -0,0 +1,94 @@ +"""Callbacks for Trends view — directorate overview + drug drill-down.""" +from dash import Input, Output, State, no_update +import plotly.graph_objects as go + + +def register_trends_callbacks(app): + """Register Trends view callbacks.""" + + def _trends_empty(message): + """Return a blank figure with a centered message.""" + fig = go.Figure() + fig.update_layout( + xaxis={"visible": False}, yaxis={"visible": False}, + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + margin={"t": 0, "l": 0, "r": 0, "b": 0}, height=400, + annotations=[{ + "text": message, "xref": "paper", "yref": "paper", + "x": 0.5, "y": 0.5, "showarrow": False, + "font": {"size": 14, "color": "#768692", + "family": "Source Sans 3, system-ui, sans-serif"}, + "xanchor": "center", "yanchor": "middle", + }], + ) + return fig + + # --- Landing / Detail toggle --- + @app.callback( + Output("trends-landing", "style"), + Output("trends-detail", "style"), + Output("trends-detail-title", "children"), + Input("app-state", "data"), + ) + def toggle_trends_subviews(app_state): + """Toggle between landing page and drug detail view.""" + if not app_state: + return {}, {"display": "none"}, "" + + selected = app_state.get("selected_trends_directorate") + show = {} + hide = {"display": "none"} + + if selected: + title = f"{selected} \u2014 Drug Trends" + return hide, show, title + else: + return show, hide, "" + + # --- Directorate overview chart (landing page) --- + @app.callback( + Output("trends-overview-chart", "figure"), + Input("app-state", "data"), + Input("trends-view-metric-toggle", "value"), + prevent_initial_call=True, + ) + def render_trends_overview(app_state, metric): + """Render directorate-level trends line chart on the landing page.""" + 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 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, + group_by="directory", + ) + except Exception: + return _trends_empty("Failed to load trend data.") + + if not data: + return _trends_empty( + "No trend data available.
" + "Run python -m cli.compute_trends to generate." + ) + + metric_labels = { + "patients": "Patients", + "total_cost": "Cost per Patient", + "cost_pp_pa": "Cost per Patient p.a.", + } + title = f"Directorate Trends \u2014 {metric_labels.get(metric, 'Patients')}" + + return create_trend_figure(data, title, metric) diff --git a/dash_app/components/trends.py b/dash_app/components/trends.py new file mode 100644 index 0000000..3fe5992 --- /dev/null +++ b/dash_app/components/trends.py @@ -0,0 +1,112 @@ +"""Trends view — directorate-level overview + drug-level drill-down.""" +from dash import html, dcc +import dash_mantine_components as dmc + + +def make_trends_landing(): + """Trends landing page — directorate-level overview chart with metric toggle.""" + return html.Div( + id="trends-landing", + children=[ + html.Div( + className="trends-landing__header", + children=[ + html.Div( + style={"display": "flex", "alignItems": "center", + "justifyContent": "space-between", "gap": "12px", + "flexWrap": "wrap"}, + children=[ + html.H2("Trends — Directorate Overview", + className="trends-landing__title", + style={"margin": "0", "color": "#1E293B", + "fontSize": "18px", + "fontFamily": "Source Sans 3, system-ui, sans-serif"}), + dmc.SegmentedControl( + id="trends-view-metric-toggle", + data=[ + {"value": "patients", "label": "Patients"}, + {"value": "total_cost", "label": "Cost per Patient"}, + {"value": "cost_pp_pa", "label": "Cost per Patient p.a."}, + ], + value="patients", + size="xs", + ), + ], + ), + html.P( + "Click a directorate line to drill down into drug-level trends.", + className="trends-landing__desc", + style={"margin": "4px 0 0", "color": "#768692", + "fontSize": "14px"}, + ), + ], + style={"padding": "20px 24px 8px"}, + ), + dcc.Loading(type="circle", color="#005EB8", children=[ + dcc.Graph( + id="trends-overview-chart", + config={"displayModeBar": False, "displaylogo": False}, + style={"height": "500px"}, + ), + ]), + ], + ) + + +def make_trends_detail(): + """Trends detail page — drug-level trends within a selected directorate.""" + return html.Div( + id="trends-detail", + style={"display": "none"}, + children=[ + html.Div( + className="trends-detail__header", + children=[ + html.Div( + style={"display": "flex", "alignItems": "center", + "justifyContent": "space-between", "gap": "12px", + "flexWrap": "wrap"}, + children=[ + html.Div( + style={"display": "flex", "alignItems": "center", + "gap": "12px"}, + children=[ + html.Button( + "\u2190 Back", + id="trends-back-btn", + className="tc-dashboard__back", + n_clicks=0, + ), + html.H2( + id="trends-detail-title", + children="", + style={"margin": "0", "color": "#1E293B", + "fontSize": "18px", + "fontFamily": "Source Sans 3, system-ui, sans-serif"}, + ), + ], + ), + dmc.SegmentedControl( + id="trends-detail-metric-toggle", + data=[ + {"value": "patients", "label": "Patients"}, + {"value": "total_cost", "label": "Cost per Patient"}, + {"value": "cost_pp_pa", "label": "Cost per Patient p.a."}, + ], + value="patients", + size="xs", + ), + ], + ), + ], + style={"padding": "20px 24px 8px"}, + ), + dcc.Loading(type="circle", color="#005EB8", children=[ + dcc.Graph( + id="trends-detail-chart", + config={"displayModeBar": False, "displaylogo": False}, + style={"height": "500px"}, + ), + ]), + ], + ) diff --git a/dash_app/data/queries.py b/dash_app/data/queries.py index 7cfc192..a088966 100644 --- a/dash_app/data/queries.py +++ b/dash_app/data/queries.py @@ -30,6 +30,7 @@ from data_processing.pathway_queries import ( get_drug_network as _get_drug_network, get_drug_timeline as _get_drug_timeline, get_dosing_distribution as _get_dosing_distribution, + get_trend_data as _get_trend_data, ) DB_PATH = Path(__file__).resolve().parents[2] / "data" / "pathways.db" @@ -251,3 +252,14 @@ def get_dosing_distribution( return _get_dosing_distribution(DB_PATH, date_filter_id, chart_type, directory, trust) +# --- Trends query wrappers (Phase E) --- + + +def get_trend_data( + metric: str = "patients", + directory: Optional[str] = None, + drug: Optional[str] = None, + group_by: str = "drug", +) -> list[dict]: + """Time-series trend data from pathway_trends table.""" + return _get_trend_data(DB_PATH, metric, directory, drug, group_by) diff --git a/src/data_processing/pathway_queries.py b/src/data_processing/pathway_queries.py index baa01ba..699ee1a 100644 --- a/src/data_processing/pathway_queries.py +++ b/src/data_processing/pathway_queries.py @@ -1591,18 +1591,19 @@ def get_trend_data( metric: str = "patients", directory: Optional[str] = None, drug: Optional[str] = None, + group_by: str = "drug", ) -> list[dict]: """ Query pathway_trends table for time-series data. Returns list of dicts with: period_end, name (drug or directory), value. - Groups by drug (one line per drug) unless aggregating by directory. Args: db_path: Path to pathways.db metric: "patients", "total_cost", or "cost_pp_pa" directory: Optional directory filter drug: Optional drug filter + group_by: "drug" (one line per drug) or "directory" (one line per directory) Returns: List of dicts: [{period_end, name, value}, ...] @@ -1612,7 +1613,6 @@ def get_trend_data( conn.row_factory = sqlite3.Row try: - # Check if the table exists cursor = conn.cursor() cursor.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='pathway_trends'" @@ -1624,7 +1624,6 @@ def get_trend_data( if metric not in valid_metrics: metric = "patients" - # Build query — group by drug (one line per drug over time) where_clauses = [] params = [] @@ -1637,16 +1636,10 @@ def get_trend_data( where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" - # Aggregate across directories per drug per period (or per directory if filtering by drug) - if drug: - # One line per directory for a specific drug - group_col = "directory" - else: - # One line per drug (aggregate across directories) - group_col = "drug" + # Determine grouping column + group_col = "directory" if group_by == "directory" else "drug" if metric == "cost_pp_pa": - # Weighted average for cost_pp_pa agg = "SUM(cost_pp_pa * patients) / NULLIF(SUM(patients), 0)" elif metric == "total_cost": agg = "SUM(total_cost)"