feat: Trends landing page with directorate overview chart (Task E.3)

This commit is contained in:
Andrew Charlwood
2026-02-07 22:19:52 +00:00
parent 6c5f9be776
commit c253e05046
7 changed files with 237 additions and 22 deletions
+10 -10
View File
@@ -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. - **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 ### 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_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` - `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="drug"` (default, existing behavior): one line per drug
- `group_by="directory"`: one line per directory (aggregate drugs within each directory) - `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` - 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 - [x] 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] 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)`. - 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. - Only fires when `active_view == "trends"` and `selected_trends_directorate` is None.
- [ ] Register in `dash_app/callbacks/__init__.py` - [x] Register in `dash_app/callbacks/__init__.py`
- [ ] Rename "Cost" label to "Cost per Patient" in the metric toggle options (value stays `total_cost`) - [x] 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()` - [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. - **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 ### 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 `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 ### Phase E
- [x] Trends tab removed from Patient Pathways (9 tabs remain) - [x] Trends tab removed from Patient Pathways (9 tabs remain)
- [x] 3rd sidebar item "Trends" visible and functional - [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 - [ ] Clicking a directorate drills into drug-level trends
- [ ] Back button returns to directorate overview - [ ] Back button returns to directorate overview
- [ ] Charts fill available viewport height (no fixed 500px cutoff) - [ ] 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 - [ ] `python run_dash.py` starts cleanly
--- ---
+3 -1
View File
@@ -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.footer import make_footer
from dash_app.components.modals import make_modals 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.trust_comparison import make_tc_landing, make_tc_dashboard
from dash_app.components.trends import make_trends_landing, make_trends_detail
app = Dash( app = Dash(
__name__, __name__,
@@ -70,7 +71,8 @@ app.layout = dmc.MantineProvider(
id="trends-view", id="trends-view",
style={"display": "none"}, style={"display": "none"},
children=[ children=[
html.H3("Trends", style={"padding": "24px"}), make_trends_landing(),
make_trends_detail(),
], ],
), ),
], ],
+2
View File
@@ -9,6 +9,7 @@ def register_callbacks(app):
from dash_app.callbacks.modals import register_modal_callbacks from dash_app.callbacks.modals import register_modal_callbacks
from dash_app.callbacks.navigation import register_navigation_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.trust_comparison import register_trust_comparison_callbacks
from dash_app.callbacks.trends import register_trends_callbacks
register_filter_callbacks(app) register_filter_callbacks(app)
register_chart_callbacks(app) register_chart_callbacks(app)
@@ -16,3 +17,4 @@ def register_callbacks(app):
register_modal_callbacks(app) register_modal_callbacks(app)
register_navigation_callbacks(app) register_navigation_callbacks(app)
register_trust_comparison_callbacks(app) register_trust_comparison_callbacks(app)
register_trends_callbacks(app)
+94
View File
@@ -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.<br>"
"Run <b>python -m cli.compute_trends</b> 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)
+112
View File
@@ -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"},
),
]),
],
)
+12
View File
@@ -30,6 +30,7 @@ from data_processing.pathway_queries import (
get_drug_network as _get_drug_network, get_drug_network as _get_drug_network,
get_drug_timeline as _get_drug_timeline, get_drug_timeline as _get_drug_timeline,
get_dosing_distribution as _get_dosing_distribution, get_dosing_distribution as _get_dosing_distribution,
get_trend_data as _get_trend_data,
) )
DB_PATH = Path(__file__).resolve().parents[2] / "data" / "pathways.db" 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) 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)
+4 -11
View File
@@ -1591,18 +1591,19 @@ def get_trend_data(
metric: str = "patients", metric: str = "patients",
directory: Optional[str] = None, directory: Optional[str] = None,
drug: Optional[str] = None, drug: Optional[str] = None,
group_by: str = "drug",
) -> list[dict]: ) -> list[dict]:
""" """
Query pathway_trends table for time-series data. Query pathway_trends table for time-series data.
Returns list of dicts with: period_end, name (drug or directory), value. Returns list of dicts with: period_end, name (drug or directory), value.
Groups by drug (one line per drug) unless aggregating by directory.
Args: Args:
db_path: Path to pathways.db db_path: Path to pathways.db
metric: "patients", "total_cost", or "cost_pp_pa" metric: "patients", "total_cost", or "cost_pp_pa"
directory: Optional directory filter directory: Optional directory filter
drug: Optional drug filter drug: Optional drug filter
group_by: "drug" (one line per drug) or "directory" (one line per directory)
Returns: Returns:
List of dicts: [{period_end, name, value}, ...] List of dicts: [{period_end, name, value}, ...]
@@ -1612,7 +1613,6 @@ def get_trend_data(
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try: try:
# Check if the table exists
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='pathway_trends'" "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: if metric not in valid_metrics:
metric = "patients" metric = "patients"
# Build query — group by drug (one line per drug over time)
where_clauses = [] where_clauses = []
params = [] params = []
@@ -1637,16 +1636,10 @@ def get_trend_data(
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" 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) # Determine grouping column
if drug: group_col = "directory" if group_by == "directory" else "drug"
# One line per directory for a specific drug
group_col = "directory"
else:
# One line per drug (aggregate across directories)
group_col = "drug"
if metric == "cost_pp_pa": if metric == "cost_pp_pa":
# Weighted average for cost_pp_pa
agg = "SUM(cost_pp_pa * patients) / NULLIF(SUM(patients), 0)" agg = "SUM(cost_pp_pa * patients) / NULLIF(SUM(patients), 0)"
elif metric == "total_cost": elif metric == "total_cost":
agg = "SUM(total_cost)" agg = "SUM(total_cost)"