feat: Trends landing page with directorate overview chart (Task E.3)
This commit is contained in:
+10
-10
@@ -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
|
||||
|
||||
---
|
||||
|
||||
+3
-1
@@ -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(),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"},
|
||||
),
|
||||
]),
|
||||
],
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user