From 10739ca84d7d9ccf3457e6add23abdbe74cd9c3c Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Fri, 6 Feb 2026 22:15:10 +0000 Subject: [PATCH] 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 --- IMPLEMENTATION_PLAN.md | 12 +- dash_app/app.py | 30 +---- dash_app/assets/nhs.css | 141 ++++++++++++++++++++++++ dash_app/callbacks/__init__.py | 2 + dash_app/callbacks/filters.py | 26 ++++- dash_app/callbacks/trust_comparison.py | 131 ++++++++++++++++++++++ dash_app/components/trust_comparison.py | 80 ++++++++++++++ dash_app/data/queries.py | 12 ++ src/data_processing/pathway_queries.py | 59 ++++++++++ 9 files changed, 457 insertions(+), 36 deletions(-) create mode 100644 dash_app/callbacks/trust_comparison.py create mode 100644 dash_app/components/trust_comparison.py diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 77ccdb1..6ba1864 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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 diff --git a/dash_app/app.py b/dash_app/app.py index c37e8e5..bd014a2 100644 --- a/dash_app/app.py +++ b/dash_app/app.py @@ -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(), ], ), ], diff --git a/dash_app/assets/nhs.css b/dash_app/assets/nhs.css index f3de3be..8650a06 100644 --- a/dash_app/assets/nhs.css +++ b/dash_app/assets/nhs.css @@ -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; } } diff --git a/dash_app/callbacks/__init__.py b/dash_app/callbacks/__init__.py index f53c441..58ec72d 100644 --- a/dash_app/callbacks/__init__.py +++ b/dash_app/callbacks/__init__.py @@ -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) diff --git a/dash_app/callbacks/filters.py b/dash_app/callbacks/filters.py index bb26649..824dd09 100644 --- a/dash_app/callbacks/filters.py +++ b/dash_app/callbacks/filters.py @@ -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 diff --git a/dash_app/callbacks/trust_comparison.py b/dash_app/callbacks/trust_comparison.py new file mode 100644 index 0000000..cc4bafb --- /dev/null +++ b/dash_app/callbacks/trust_comparison.py @@ -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 diff --git a/dash_app/components/trust_comparison.py b/dash_app/components/trust_comparison.py new file mode 100644 index 0000000..da27f96 --- /dev/null +++ b/dash_app/components/trust_comparison.py @@ -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"), + ], + ), + ], + ) diff --git a/dash_app/data/queries.py b/dash_app/data/queries.py index 63a7566..ec91508 100644 --- a/dash_app/data/queries.py +++ b/dash_app/data/queries.py @@ -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) diff --git a/src/data_processing/pathway_queries.py b/src/data_processing/pathway_queries.py index de36662..8de4541 100644 --- a/src/data_processing/pathway_queries.py +++ b/src/data_processing/pathway_queries.py @@ -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()