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 - **Checkpoint**: All 6 query functions return correct per-trust data for sample directorates
### 10.7 Trust Comparison landing page + directorate selector ### 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) - **Landing**: Grid of directorate/indication buttons (source: reference-data store)
- **Dashboard**: 6-chart layout for selected directorate (see 10.8) - **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) - [x] 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 - [x] 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`) - [x] Back button to return to landing page (clears `selected_comparison_directorate`)
- [ ] Apply layout design from 10.1 - [x] Apply layout design from 10.1
- [ ] This view is shown when `active_view == "trust-comparison"` - [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 - **Checkpoint**: Landing page shows directorate buttons, clicking one transitions to dashboard state, back button works
### 10.8 Trust Comparison 6-chart dashboard ### 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.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
app = Dash( app = Dash(
__name__, __name__,
@@ -59,33 +60,8 @@ app.layout = dmc.MantineProvider(
id="trust-comparison-view", id="trust-comparison-view",
style={"display": "none"}, style={"display": "none"},
children=[ children=[
html.Div( make_tc_landing(),
className="tc-landing", make_tc_dashboard(),
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
],
),
],
),
], ],
), ),
], ],
+141
View File
@@ -397,12 +397,153 @@ body {
text-align: center; 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 ── */ /* ── 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) { @media (max-width: 1024px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); } .kpi-row { grid-template-columns: repeat(2, 1fr); }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { display: none; } .sidebar { display: none; }
.main { margin-left: 0; } .main { margin-left: 0; }
.sub-header { left: 0; }
.kpi-row { grid-template-columns: 1fr; } .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.kpi import register_kpi_callbacks
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
register_filter_callbacks(app) register_filter_callbacks(app)
register_chart_callbacks(app) register_chart_callbacks(app)
register_kpi_callbacks(app) register_kpi_callbacks(app)
register_modal_callbacks(app) register_modal_callbacks(app)
register_navigation_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.""" """Callbacks for reference data loading and filter state management."""
from datetime import datetime 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: def _format_relative_time(iso_timestamp: str) -> str:
@@ -75,13 +75,16 @@ def register_filter_callbacks(app):
Input("trust-chips", "value"), Input("trust-chips", "value"),
Input("nav-patient-pathways", "n_clicks"), Input("nav-patient-pathways", "n_clicks"),
Input("nav-trust-comparison", "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"), State("app-state", "data"),
) )
def update_app_state( def update_app_state(
_dir_clicks, _ind_clicks, initiated, last_seen, selected_drugs, _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: if not current_state:
current_state = { current_state = {
"chart_type": "directory", "chart_type": "directory",
@@ -99,6 +102,7 @@ def register_filter_callbacks(app):
# Determine chart type from toggle pills # Determine chart type from toggle pills
chart_type = current_state.get("chart_type", "directory") chart_type = current_state.get("chart_type", "directory")
prev_chart_type = chart_type
if triggered_id == "chart-type-directory": if triggered_id == "chart-type-directory":
chart_type = "directory" chart_type = "directory"
elif triggered_id == "chart-type-indication": elif triggered_id == "chart-type-indication":
@@ -111,6 +115,21 @@ def register_filter_callbacks(app):
elif triggered_id == "nav-trust-comparison": elif triggered_id == "nav-trust-comparison":
active_view = "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 # Compute date_filter_id from dropdown values
date_filter_id = f"{initiated}_{last_seen}" date_filter_id = f"{initiated}_{last_seen}"
@@ -124,6 +143,7 @@ def register_filter_callbacks(app):
"selected_drugs": selected_drugs or [], "selected_drugs": selected_drugs or [],
"selected_trusts": selected_trusts or [], "selected_trusts": selected_trusts or [],
"active_view": active_view, "active_view": active_view,
"selected_comparison_directorate": selected_comparison_directorate,
} }
# Toggle pill CSS classes # 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_dosing as _get_trust_dosing,
get_trust_heatmap as _get_trust_heatmap, get_trust_heatmap as _get_trust_heatmap,
get_trust_durations as _get_trust_durations, get_trust_durations as _get_trust_durations,
get_directorate_summary as _get_directorate_summary,
) )
DB_PATH = Path(__file__).resolve().parents[2] / "data" / "pathways.db" DB_PATH = Path(__file__).resolve().parents[2] / "data" / "pathways.db"
@@ -168,3 +169,14 @@ def get_trust_durations(
) -> list[dict]: ) -> list[dict]:
"""Drug durations by trust within a single directorate.""" """Drug durations by trust within a single directorate."""
return _get_trust_durations(DB_PATH, date_filter_id, chart_type, directory) 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 [] return []
finally: finally:
conn.close() 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()