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.
|
- **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
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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_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)
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user