From 11b5cc5b815f75a279cf198433d891e2b82175b3 Mon Sep 17 00:00:00 2001 From: Andrew Charlwood Date: Fri, 6 Feb 2026 21:45:13 +0000 Subject: [PATCH] feat: header fraction KPIs replacing KPI row (Task 10.3) Remove KPI card row, add 3 inline fraction KPIs to header bar: filtered/total patients, drugs, cost. Breadcrumb removed. KPI callback refactored for 6 output IDs (3 filtered + 3 total). total_cost added to load_initial_data() reference data. --- IMPLEMENTATION_PLAN.md | 14 +++--- dash_app/app.py | 2 - dash_app/assets/nhs.css | 42 +++++++++++++++++ dash_app/callbacks/kpi.py | 63 +++++++++++++++++--------- dash_app/components/header.py | 36 +++++++++++---- src/data_processing/pathway_queries.py | 23 ++++++---- 6 files changed, 131 insertions(+), 49 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index b0d1684..9bb6789 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -481,17 +481,17 @@ Additionally: KPI row removed, fraction KPIs moved to header, global filter sub- - **Checkpoint**: Sidebar switches between two views, active state highlights correctly, app starts without errors ✓ ### 10.3 Header redesign — remove KPI row, add fraction KPIs -- [ ] Remove `dash_app/components/kpi_row.py` (or gut it) -- [ ] Remove KPI row from `app.py` layout -- [ ] Update `dash_app/components/header.py`: +- [x] Remove `dash_app/components/kpi_row.py` (or gut it) +- [x] Remove KPI row from `app.py` layout +- [x] Update `dash_app/components/header.py`: - Add fraction KPI display: "X / X patients", "X / X drugs", "£X / £X cost" - Numerator = filtered values (from chart-data store), denominator = global totals (from reference-data store) - Position: right side of header, alongside existing data freshness indicator - Remove indication match rate KPI entirely -- [ ] Update header callbacks to receive both filtered and total values -- [ ] Update CSS in `dash_app/assets/nhs.css` for new header layout -- [ ] Apply design from 10.1 -- **Checkpoint**: Header shows fraction KPIs, KPI row is gone, header looks clean with design from 10.1 +- [x] Update header callbacks to receive both filtered and total values +- [x] Update CSS in `dash_app/assets/nhs.css` for new header layout +- [x] Apply design from 10.1 +- **Checkpoint**: Header shows fraction KPIs, KPI row is gone, header looks clean with design from 10.1 ✓ ### 10.4 Global filter sub-header bar - [ ] Extract date filter dropdowns + chart type toggle from `filter_bar.py` into a new sub-header component (or restyle existing filter_bar) diff --git a/dash_app/app.py b/dash_app/app.py index 6864824..0ab7b2a 100644 --- a/dash_app/app.py +++ b/dash_app/app.py @@ -4,7 +4,6 @@ import dash_mantine_components as dmc from dash_app.components.header import make_header from dash_app.components.sidebar import make_sidebar -from dash_app.components.kpi_row import make_kpi_row 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 @@ -49,7 +48,6 @@ app.layout = dmc.MantineProvider( html.Div( id="patient-pathways-view", children=[ - make_kpi_row(), make_filter_bar(), make_chart_card(), ], diff --git a/dash_app/assets/nhs.css b/dash_app/assets/nhs.css index 8f13b22..fd16936 100644 --- a/dash_app/assets/nhs.css +++ b/dash_app/assets/nhs.css @@ -66,6 +66,48 @@ body { display: inline-block; margin-right: 4px; } +/* ── Header KPIs ── */ +.top-header__kpis { + display: flex; + align-items: center; + gap: 24px; +} +.header-kpi { + display: flex; + align-items: baseline; + gap: 3px; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 400; + white-space: nowrap; +} +.header-kpi__num { + color: var(--nhs-white); + font-size: 16px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.header-kpi__sep { + color: rgba(255, 255, 255, 0.35); + font-weight: 300; + font-size: 14px; + margin: 0 1px; +} +.header-kpi__den { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + font-weight: 400; + font-variant-numeric: tabular-nums; +} +.header-kpi__label { + color: rgba(255, 255, 255, 0.4); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-left: 4px; +} + /* ── Sidebar ── */ .sidebar { position: fixed; top: 56px; left: 0; bottom: 0; diff --git a/dash_app/callbacks/kpi.py b/dash_app/callbacks/kpi.py index 5fb83bf..b61a175 100644 --- a/dash_app/callbacks/kpi.py +++ b/dash_app/callbacks/kpi.py @@ -1,4 +1,4 @@ -"""Callback for updating KPI card values from chart-data store.""" +"""Callback for updating fraction KPI values in the header bar.""" from dash import Input, Output, no_update @@ -12,30 +12,49 @@ def _format_cost(cost): def register_kpi_callbacks(app): - """Register KPI update callback.""" + """Register header KPI update callbacks.""" @app.callback( - Output("kpi-patients", "children"), - Output("kpi-drugs", "children"), - Output("kpi-cost", "children"), - Output("kpi-match", "children"), + # Filtered values (numerators) + Output("kpi-filtered-patients", "children"), + Output("kpi-filtered-drugs", "children"), + Output("kpi-filtered-cost", "children"), + # Total values (denominators) + Output("kpi-total-patients", "children"), + Output("kpi-total-drugs", "children"), + Output("kpi-total-cost", "children"), Input("chart-data", "data"), - Input("app-state", "data"), + Input("reference-data", "data"), ) - def update_kpis(chart_data, app_state): - """Update KPI card values when chart-data changes.""" - if not chart_data: - return no_update, no_update, no_update, no_update + def update_header_kpis(chart_data, ref_data): + """Update header fraction KPIs from chart-data (filtered) and reference-data (totals).""" + # Filtered values from chart-data + if chart_data: + patients = chart_data.get("unique_patients", 0) + drugs = chart_data.get("total_drugs", 0) + cost = chart_data.get("total_cost", 0.0) + filtered_patients = f"{patients:,}" if patients else "\u2014" + filtered_drugs = str(drugs) if drugs else "\u2014" + filtered_cost = _format_cost(cost) if cost else "\u2014" + else: + filtered_patients = "\u2014" + filtered_drugs = "\u2014" + filtered_cost = "\u2014" - patients = chart_data.get("unique_patients", 0) - drugs = chart_data.get("total_drugs", 0) - cost = chart_data.get("total_cost", 0.0) + # Total values from reference-data + if ref_data: + total_patients_val = ref_data.get("total_patients", 0) + total_drugs_val = len(ref_data.get("available_drugs", [])) + total_cost_val = ref_data.get("total_cost", 0.0) + total_patients = f"{total_patients_val:,}" if total_patients_val else "\u2014" + total_drugs = str(total_drugs_val) if total_drugs_val else "\u2014" + total_cost = _format_cost(total_cost_val) if total_cost_val else "\u2014" + else: + total_patients = "\u2014" + total_drugs = "\u2014" + total_cost = "\u2014" - patients_str = f"{patients:,}" if patients else "\u2014" - drugs_str = str(drugs) if drugs else "\u2014" - cost_str = _format_cost(cost) if cost else "\u2014" - - chart_type = (app_state or {}).get("chart_type", "directory") - match_str = "~93%" if chart_type == "indication" else "\u2014" - - return patients_str, drugs_str, cost_str, match_str + return ( + filtered_patients, filtered_drugs, filtered_cost, + total_patients, total_drugs, total_cost, + ) diff --git a/dash_app/components/header.py b/dash_app/components/header.py index 494faa6..7290964 100644 --- a/dash_app/components/header.py +++ b/dash_app/components/header.py @@ -1,13 +1,13 @@ -"""Top header bar component matching 01_nhs_classic.html design.""" +"""Top header bar component with NHS branding, fraction KPIs, and data freshness.""" from dash import html def make_header(): - """Return the fixed top header with NHS branding and data freshness indicators.""" + """Return the fixed top header with NHS branding, fraction KPIs, and freshness indicators.""" return html.Header( className="top-header", children=[ - # Brand area (NHS logo + title) with angled clip-path + # Left: brand (NHS logo + title) html.Div( className="top-header__brand", children=[ @@ -17,15 +17,33 @@ def make_header(): ), ], ), - # Breadcrumb + + # Center: 3 fraction KPIs (filtered / total) html.Div( - className="top-header__breadcrumb", + className="top-header__kpis", children=[ - "Dashboard \u203A ", - html.Strong("Pathway Analysis"), + html.Div(className="header-kpi", children=[ + html.Span("—", id="kpi-filtered-patients", className="header-kpi__num"), + html.Span(" / ", className="header-kpi__sep"), + html.Span("—", id="kpi-total-patients", className="header-kpi__den"), + html.Span("patients", className="header-kpi__label"), + ]), + html.Div(className="header-kpi", children=[ + html.Span("—", id="kpi-filtered-drugs", className="header-kpi__num"), + html.Span(" / ", className="header-kpi__sep"), + html.Span("—", id="kpi-total-drugs", className="header-kpi__den"), + html.Span("drugs", className="header-kpi__label"), + ]), + html.Div(className="header-kpi", children=[ + html.Span("—", id="kpi-filtered-cost", className="header-kpi__num"), + html.Span(" / ", className="header-kpi__sep"), + html.Span("—", id="kpi-total-cost", className="header-kpi__den"), + html.Span("cost", className="header-kpi__label"), + ]), ], ), - # Right side: status dot + record count + last updated + + # Right: data freshness (status dot + record count + last updated) html.Div( className="top-header__right", children=[ @@ -37,7 +55,7 @@ def make_header(): ), html.Span( children=[ - "Last updated: ", + "Updated: ", html.Span("...", id="header-last-updated"), ], ), diff --git a/src/data_processing/pathway_queries.py b/src/data_processing/pathway_queries.py index 3da7fe7..424ac16 100644 --- a/src/data_processing/pathway_queries.py +++ b/src/data_processing/pathway_queries.py @@ -24,6 +24,8 @@ def load_initial_data(db_path: Path) -> dict: available_directorates: sorted list of unique directorate labels (level 2, directory charts) available_indications: sorted list of unique indications from ref_drug_indication_clusters total_records: source row count from latest completed refresh + total_patients: patient count from default root node + total_cost: total cost from default root node last_updated: ISO timestamp of latest completed refresh """ if not db_path.exists(): @@ -92,16 +94,18 @@ def load_initial_data(db_path: Path) -> dict: """) available_trusts = [row[0] for row in cursor.fetchall()] - # Total patients from default root node (fallback when source_row_count is empty) + # Total patients and cost from default root node total_patients = 0 - if not total_records: - cursor.execute(""" - SELECT value FROM pathway_nodes - WHERE level = 0 AND date_filter_id = 'all_6mo' AND chart_type = 'directory' - LIMIT 1 - """) - root_row = cursor.fetchone() - total_patients = (root_row[0] or 0) if root_row else 0 + total_cost = 0.0 + cursor.execute(""" + SELECT value, cost FROM pathway_nodes + WHERE level = 0 AND date_filter_id = 'all_6mo' AND chart_type = 'directory' + LIMIT 1 + """) + root_row = cursor.fetchone() + if root_row: + total_patients = root_row[0] or 0 + total_cost = root_row[1] or 0.0 return { "available_drugs": available_drugs, @@ -110,6 +114,7 @@ def load_initial_data(db_path: Path) -> dict: "available_trusts": available_trusts, "total_records": total_records, "total_patients": total_patients, + "total_cost": total_cost, "last_updated": last_updated, } except sqlite3.Error as e: