feat: Trust Comparison landing page + directorate selector (Task 10.7)

- Add get_directorate_summary() query for per-directorate patient/drug counts
- Create trust_comparison.py with landing grid and 6-chart dashboard layout
- Wire directorate card clicks and back button through app-state callbacks
- Add TC landing and dashboard CSS per Phase 10 design spec
- Placeholder charts for 6 dashboard graphs (filled in Task 10.8)
- Chart type toggle clears selected directorate when switching modes
This commit is contained in:
Andrew Charlwood
2026-02-06 22:15:10 +00:00
parent 04edf2cea5
commit 10739ca84d
9 changed files with 457 additions and 36 deletions
+6 -6
View File
@@ -522,14 +522,14 @@ Additionally: KPI row removed, fraction KPIs moved to header, global filter sub-
- **Checkpoint**: All 6 query functions return correct per-trust data for sample directorates
### 10.7 Trust Comparison landing page + directorate selector
- [ ] Create Trust Comparison view component with two states:
- [x] Create Trust Comparison view component with two states:
- **Landing**: Grid of directorate/indication buttons (source: reference-data store)
- **Dashboard**: 6-chart layout for selected directorate (see 10.8)
- [ ] Directorate buttons: ~14 for "By Directory" mode, ~32 for "By Indication" mode (from chart type toggle)
- [ ] Clicking a button sets `selected_comparison_directorate` in app-state, switching to dashboard view
- [ ] Back button to return to landing page (clears `selected_comparison_directorate`)
- [ ] Apply layout design from 10.1
- [ ] This view is shown when `active_view == "trust-comparison"`
- [x] Directorate buttons: ~14 for "By Directory" mode, ~32 for "By Indication" mode (from chart type toggle)
- [x] Clicking a button sets `selected_comparison_directorate` in app-state, switching to dashboard view
- [x] Back button to return to landing page (clears `selected_comparison_directorate`)
- [x] Apply layout design from 10.1
- [x] This view is shown when `active_view == "trust-comparison"`
- **Checkpoint**: Landing page shows directorate buttons, clicking one transitions to dashboard state, back button works
### 10.8 Trust Comparison 6-chart dashboard
+3 -27
View File
@@ -9,6 +9,7 @@ from dash_app.components.filter_bar import make_filter_bar
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
app = Dash(
__name__,
@@ -59,33 +60,8 @@ app.layout = dmc.MantineProvider(
id="trust-comparison-view",
style={"display": "none"},
children=[
html.Div(
className="tc-landing",
id="trust-comparison-landing",
children=[
html.Div(
className="tc-landing__header",
children=[
html.H2(
"Trust Comparison",
className="tc-landing__title",
),
html.P(
"Select a directorate to compare drug usage across trusts.",
className="tc-landing__desc",
id="tc-landing-desc",
),
],
),
html.Div(
className="tc-landing__grid",
id="tc-landing-grid",
children=[
# Populated by callback in later task
],
),
],
),
make_tc_landing(),
make_tc_dashboard(),
],
),
],
+141
View File
@@ -397,12 +397,153 @@ body {
text-align: center;
}
/* ── Trust Comparison Landing ── */
.tc-landing {
display: flex;
flex-direction: column;
gap: 24px;
}
.tc-landing__header {
padding: 0 0 8px;
}
.tc-landing__title {
font-size: 22px;
font-weight: 700;
color: var(--nhs-dark-blue);
margin: 0 0 4px;
}
.tc-landing__desc {
font-size: 14px;
color: var(--nhs-mid-grey);
font-weight: 400;
margin: 0;
}
.tc-landing__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.tc-landing__grid--wide {
grid-template-columns: repeat(4, 1fr);
}
/* Directorate selector cards */
.tc-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 20px;
background: var(--nhs-white);
border: 1px solid var(--nhs-pale-grey);
border-left: 4px solid transparent;
cursor: pointer;
text-align: left;
font-family: inherit;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.tc-card:hover {
border-left-color: var(--nhs-blue);
background: #FAFCFF;
box-shadow: 0 1px 4px rgba(0, 48, 135, 0.08);
}
.tc-card:focus-visible {
box-shadow: 0 0 0 3px var(--nhs-yellow);
z-index: 1;
}
.tc-card__name {
font-size: 14px;
font-weight: 700;
color: var(--nhs-dark-blue);
line-height: 1.3;
}
.tc-card__stats {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--nhs-mid-grey);
}
.tc-card__stat {
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.tc-card__dot {
color: var(--nhs-pale-grey);
}
/* ── Trust Comparison Dashboard ── */
.tc-dashboard {
display: flex;
flex-direction: column;
gap: 16px;
}
.tc-dashboard__header {
display: flex;
align-items: center;
gap: 16px;
}
.tc-dashboard__back {
padding: 6px 12px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
color: var(--nhs-blue);
background: var(--nhs-white);
border: 1px solid var(--nhs-pale-grey);
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.tc-dashboard__back:hover {
background: #E8F0FE;
}
.tc-dashboard__back:focus-visible {
box-shadow: 0 0 0 3px var(--nhs-yellow);
}
.tc-dashboard__title {
font-size: 20px;
font-weight: 700;
color: var(--nhs-dark-blue);
margin: 0;
}
/* 2×3 chart grid */
.tc-dashboard__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Individual chart cell */
.tc-chart-cell {
background: var(--nhs-white);
border: 1px solid var(--nhs-pale-grey);
display: flex;
flex-direction: column;
}
.tc-chart-cell__title {
padding: 10px 16px;
font-size: 13px;
font-weight: 700;
color: var(--nhs-dark-blue);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--nhs-pale-grey);
}
/* ── Responsive ── */
@media (max-width: 1200px) {
.tc-landing__grid { grid-template-columns: repeat(2, 1fr); }
.tc-landing__grid--wide { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 1024px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.sidebar { display: none; }
.main { margin-left: 0; }
.sub-header { left: 0; }
.kpi-row { grid-template-columns: 1fr; }
.tc-landing__grid { grid-template-columns: 1fr; }
.tc-dashboard__grid { grid-template-columns: 1fr; }
}
+2
View File
@@ -8,9 +8,11 @@ def register_callbacks(app):
from dash_app.callbacks.kpi import register_kpi_callbacks
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
register_filter_callbacks(app)
register_chart_callbacks(app)
register_kpi_callbacks(app)
register_modal_callbacks(app)
register_navigation_callbacks(app)
register_trust_comparison_callbacks(app)
+23 -3
View File
@@ -1,6 +1,6 @@
"""Callbacks for reference data loading and filter state management."""
from datetime import datetime
from dash import Input, Output, State, callback, ctx, no_update
from dash import Input, Output, State, callback, ctx, no_update, ALL
def _format_relative_time(iso_timestamp: str) -> str:
@@ -75,13 +75,16 @@ def register_filter_callbacks(app):
Input("trust-chips", "value"),
Input("nav-patient-pathways", "n_clicks"),
Input("nav-trust-comparison", "n_clicks"),
Input({"type": "tc-selector", "index": ALL}, "n_clicks"),
Input("tc-back-btn", "n_clicks"),
State("app-state", "data"),
)
def update_app_state(
_dir_clicks, _ind_clicks, initiated, last_seen, selected_drugs,
selected_trusts, _nav_pp_clicks, _nav_tc_clicks, current_state
selected_trusts, _nav_pp_clicks, _nav_tc_clicks,
_tc_selector_clicks, _tc_back_clicks, current_state
):
"""Update app-state when chart type toggle, date filters, drug/trust chips, or sidebar nav change."""
"""Update app-state when any filter, nav, or TC selector changes."""
if not current_state:
current_state = {
"chart_type": "directory",
@@ -99,6 +102,7 @@ def register_filter_callbacks(app):
# Determine chart type from toggle pills
chart_type = current_state.get("chart_type", "directory")
prev_chart_type = chart_type
if triggered_id == "chart-type-directory":
chart_type = "directory"
elif triggered_id == "chart-type-indication":
@@ -111,6 +115,21 @@ def register_filter_callbacks(app):
elif triggered_id == "nav-trust-comparison":
active_view = "trust-comparison"
# Trust Comparison directorate selection
selected_comparison_directorate = current_state.get("selected_comparison_directorate")
# Handle TC card click (pattern-matching ID)
if isinstance(triggered_id, dict) and triggered_id.get("type") == "tc-selector":
selected_comparison_directorate = triggered_id["index"]
# Handle TC back button
if triggered_id == "tc-back-btn":
selected_comparison_directorate = None
# If chart type changed while a directorate is selected, return to landing
if chart_type != prev_chart_type and selected_comparison_directorate is not None:
selected_comparison_directorate = None
# Compute date_filter_id from dropdown values
date_filter_id = f"{initiated}_{last_seen}"
@@ -124,6 +143,7 @@ def register_filter_callbacks(app):
"selected_drugs": selected_drugs or [],
"selected_trusts": selected_trusts or [],
"active_view": active_view,
"selected_comparison_directorate": selected_comparison_directorate,
}
# Toggle pill CSS classes
+131
View File
@@ -0,0 +1,131 @@
"""Callbacks for Trust Comparison landing page and dashboard navigation."""
from dash import html, Input, Output, State, ctx, no_update
import plotly.graph_objects as go
def register_trust_comparison_callbacks(app):
"""Register Trust Comparison view callbacks."""
@app.callback(
Output("tc-landing-grid", "children"),
Output("tc-landing-grid", "className"),
Output("tc-landing-desc", "children"),
Input("app-state", "data"),
)
def populate_landing_grid(app_state):
"""Populate the landing page grid with directorate/indication cards."""
if not app_state:
return [], "tc-landing__grid", "Select a directorate to compare drug usage across trusts."
from dash_app.data.queries import get_directorate_summary
chart_type = app_state.get("chart_type", "directory")
date_filter_id = app_state.get("date_filter_id", "all_6mo")
summaries = get_directorate_summary(date_filter_id, chart_type)
# Build card buttons
cards = []
for item in summaries:
name = item["name"]
patients = item["patients"]
drugs = item["drugs"]
cards.append(
html.Button(
className="tc-card",
id={"type": "tc-selector", "index": name},
n_clicks=0,
children=[
html.Div(name, className="tc-card__name"),
html.Div(
className="tc-card__stats",
children=[
html.Span(
f"{patients:,} patients",
className="tc-card__stat",
),
html.Span("\u00b7", className="tc-card__dot"),
html.Span(
f"{drugs} drugs",
className="tc-card__stat",
),
],
),
],
)
)
# Grid class: wider for indication mode (more items)
grid_cls = "tc-landing__grid"
if chart_type == "indication":
grid_cls += " tc-landing__grid--wide"
# Description text adapts to chart type
if chart_type == "indication":
desc = "Select an indication to compare drug usage across trusts."
else:
desc = "Select a directorate to compare drug usage across trusts."
return cards, grid_cls, desc
@app.callback(
Output("trust-comparison-landing", "style"),
Output("trust-comparison-dashboard", "style"),
Output("tc-dashboard-title", "children"),
Input("app-state", "data"),
)
def toggle_tc_subviews(app_state):
"""Toggle between landing page and 6-chart dashboard."""
if not app_state:
return {}, {"display": "none"}, ""
selected = app_state.get("selected_comparison_directorate")
show = {}
hide = {"display": "none"}
if selected:
chart_type = app_state.get("chart_type", "directory")
label = "Indication" if chart_type == "indication" else "Directorate"
title = f"{selected} \u2014 Trust Comparison"
return hide, show, title
else:
return show, hide, ""
# Dashboard chart rendering will be added in Task 10.8.
# For now, register empty figure placeholders for the 6 chart IDs
# so the dcc.Graph components don't error on load.
_tc_chart_ids = [
"tc-chart-market-share",
"tc-chart-cost-waterfall",
"tc-chart-dosing",
"tc-chart-heatmap",
"tc-chart-duration",
"tc-chart-cost-effectiveness",
]
for chart_id in _tc_chart_ids:
@app.callback(
Output(chart_id, "figure"),
Input("app-state", "data"),
prevent_initial_call=True,
)
def _placeholder_chart(app_state, _cid=chart_id):
"""Placeholder — returns empty figure until Task 10.8 implements real charts."""
selected = (app_state or {}).get("selected_comparison_directorate")
if not selected:
return no_update
fig = go.Figure()
fig.update_layout(
template="plotly_white",
margin=dict(l=20, r=20, t=30, b=20),
height=300,
annotations=[
dict(
text="Chart will be implemented in Task 10.8",
xref="paper", yref="paper",
x=0.5, y=0.5, showarrow=False,
font=dict(size=14, color="#999"),
)
],
)
return fig
+80
View File
@@ -0,0 +1,80 @@
"""Trust Comparison view — landing page (directorate selector) + 6-chart dashboard."""
from dash import html, dcc
def _tc_chart_cell(title, graph_id):
"""Helper to create a single chart cell in the 6-chart dashboard grid."""
return html.Div(className="tc-chart-cell", children=[
html.Div(title, className="tc-chart-cell__title"),
dcc.Loading(type="circle", color="#005EB8", children=[
dcc.Graph(
id=graph_id,
config={"displayModeBar": False, "displaylogo": False},
style={"height": "320px"},
),
]),
])
def make_tc_landing():
"""Trust Comparison landing page — directorate/indication selector grid."""
return html.Div(
className="tc-landing",
id="trust-comparison-landing",
children=[
html.Div(
className="tc-landing__header",
children=[
html.H2("Trust Comparison", className="tc-landing__title"),
html.P(
"Select a directorate to compare drug usage across trusts.",
className="tc-landing__desc",
id="tc-landing-desc",
),
],
),
html.Div(
className="tc-landing__grid",
id="tc-landing-grid",
children=[],
),
],
)
def make_tc_dashboard():
"""Trust Comparison 6-chart dashboard for a selected directorate."""
return html.Div(
className="tc-dashboard",
id="trust-comparison-dashboard",
style={"display": "none"},
children=[
html.Div(
className="tc-dashboard__header",
children=[
html.Button(
"\u2190 Back",
id="tc-back-btn",
className="tc-dashboard__back",
n_clicks=0,
),
html.H2(
id="tc-dashboard-title",
className="tc-dashboard__title",
children="",
),
],
),
html.Div(
className="tc-dashboard__grid",
children=[
_tc_chart_cell("Market Share", "tc-chart-market-share"),
_tc_chart_cell("Cost Waterfall", "tc-chart-cost-waterfall"),
_tc_chart_cell("Dosing Intervals", "tc-chart-dosing"),
_tc_chart_cell("Drug \u00d7 Trust Heatmap", "tc-chart-heatmap"),
_tc_chart_cell("Treatment Duration", "tc-chart-duration"),
_tc_chart_cell("Cost Effectiveness", "tc-chart-cost-effectiveness"),
],
),
],
)
+12
View File
@@ -23,6 +23,7 @@ from data_processing.pathway_queries import (
get_trust_dosing as _get_trust_dosing,
get_trust_heatmap as _get_trust_heatmap,
get_trust_durations as _get_trust_durations,
get_directorate_summary as _get_directorate_summary,
)
DB_PATH = Path(__file__).resolve().parents[2] / "data" / "pathways.db"
@@ -168,3 +169,14 @@ def get_trust_durations(
) -> list[dict]:
"""Drug durations by trust within a single directorate."""
return _get_trust_durations(DB_PATH, date_filter_id, chart_type, directory)
# --- Directorate summary for Trust Comparison landing page ---
def get_directorate_summary(
date_filter_id: str = "all_6mo",
chart_type: str = "directory",
) -> list[dict]:
"""Per-directorate summary (name, patient count, drug count) for landing cards."""
return _get_directorate_summary(DB_PATH, date_filter_id, chart_type)
+59
View File
@@ -1064,3 +1064,62 @@ def get_trust_durations(
return []
finally:
conn.close()
# --- Directorate/indication summary for Trust Comparison landing page ---
def get_directorate_summary(
db_path: Path,
date_filter_id: str,
chart_type: str,
) -> list[dict]:
"""Get per-directorate (or per-indication) summary stats for landing page cards.
Returns a list of dicts sorted by patient count descending:
[{"name": "RHEUMATOLOGY", "patients": 847, "drugs": 12}, ...]
Level 2 nodes provide patient counts; level 3 node count gives drug count.
"""
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
# Get patient counts from level 2 nodes
level2_query = """
SELECT labels AS name, SUM(value) AS patients
FROM pathway_nodes
WHERE date_filter_id = ? AND chart_type = ? AND level = 2
AND labels IS NOT NULL AND labels != ''
GROUP BY labels
"""
level2_rows = conn.execute(level2_query, (date_filter_id, chart_type)).fetchall()
# Get drug counts from level 3 nodes grouped by directory
level3_query = """
SELECT directory, COUNT(DISTINCT labels) AS drug_count
FROM pathway_nodes
WHERE date_filter_id = ? AND chart_type = ? AND level = 3
AND directory IS NOT NULL AND directory != ''
GROUP BY directory
"""
level3_rows = conn.execute(level3_query, (date_filter_id, chart_type)).fetchall()
drug_counts = {r["directory"]: r["drug_count"] for r in level3_rows}
result = []
for r in level2_rows:
name = r["name"]
patients = r["patients"] or 0
if patients == 0:
continue
result.append({
"name": name,
"patients": patients,
"drugs": drug_counts.get(name, 0),
})
result.sort(key=lambda x: x["patients"], reverse=True)
return result
except sqlite3.Error:
return []
finally:
conn.close()