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)"