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.
This commit is contained in:
Andrew Charlwood
2026-02-06 21:45:13 +00:00
parent 6c9f68d880
commit 11b5cc5b81
6 changed files with 131 additions and 49 deletions
+7 -7
View File
@@ -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 ✓ - **Checkpoint**: Sidebar switches between two views, active state highlights correctly, app starts without errors ✓
### 10.3 Header redesign — remove KPI row, add fraction KPIs ### 10.3 Header redesign — remove KPI row, add fraction KPIs
- [ ] Remove `dash_app/components/kpi_row.py` (or gut it) - [x] Remove `dash_app/components/kpi_row.py` (or gut it)
- [ ] Remove KPI row from `app.py` layout - [x] Remove KPI row from `app.py` layout
- [ ] Update `dash_app/components/header.py`: - [x] Update `dash_app/components/header.py`:
- Add fraction KPI display: "X / X patients", "X / X drugs", "£X / £X cost" - 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) - Numerator = filtered values (from chart-data store), denominator = global totals (from reference-data store)
- Position: right side of header, alongside existing data freshness indicator - Position: right side of header, alongside existing data freshness indicator
- Remove indication match rate KPI entirely - Remove indication match rate KPI entirely
- [ ] Update header callbacks to receive both filtered and total values - [x] Update header callbacks to receive both filtered and total values
- [ ] Update CSS in `dash_app/assets/nhs.css` for new header layout - [x] Update CSS in `dash_app/assets/nhs.css` for new header layout
- [ ] Apply design from 10.1 - [x] Apply design from 10.1
- **Checkpoint**: Header shows fraction KPIs, KPI row is gone, header looks clean with 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 ### 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) - [ ] Extract date filter dropdowns + chart type toggle from `filter_bar.py` into a new sub-header component (or restyle existing filter_bar)
-2
View File
@@ -4,7 +4,6 @@ import dash_mantine_components as dmc
from dash_app.components.header import make_header from dash_app.components.header import make_header
from dash_app.components.sidebar import make_sidebar 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.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
@@ -49,7 +48,6 @@ app.layout = dmc.MantineProvider(
html.Div( html.Div(
id="patient-pathways-view", id="patient-pathways-view",
children=[ children=[
make_kpi_row(),
make_filter_bar(), make_filter_bar(),
make_chart_card(), make_chart_card(),
], ],
+42
View File
@@ -66,6 +66,48 @@ body {
display: inline-block; margin-right: 4px; 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 ── */
.sidebar { .sidebar {
position: fixed; top: 56px; left: 0; bottom: 0; position: fixed; top: 56px; left: 0; bottom: 0;
+38 -19
View File
@@ -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 from dash import Input, Output, no_update
@@ -12,30 +12,49 @@ def _format_cost(cost):
def register_kpi_callbacks(app): def register_kpi_callbacks(app):
"""Register KPI update callback.""" """Register header KPI update callbacks."""
@app.callback( @app.callback(
Output("kpi-patients", "children"), # Filtered values (numerators)
Output("kpi-drugs", "children"), Output("kpi-filtered-patients", "children"),
Output("kpi-cost", "children"), Output("kpi-filtered-drugs", "children"),
Output("kpi-match", "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("chart-data", "data"),
Input("app-state", "data"), Input("reference-data", "data"),
) )
def update_kpis(chart_data, app_state): def update_header_kpis(chart_data, ref_data):
"""Update KPI card values when chart-data changes.""" """Update header fraction KPIs from chart-data (filtered) and reference-data (totals)."""
if not chart_data: # Filtered values from chart-data
return no_update, no_update, no_update, no_update if chart_data:
patients = chart_data.get("unique_patients", 0) patients = chart_data.get("unique_patients", 0)
drugs = chart_data.get("total_drugs", 0) drugs = chart_data.get("total_drugs", 0)
cost = chart_data.get("total_cost", 0.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_str = f"{patients:,}" if patients else "\u2014" # Total values from reference-data
drugs_str = str(drugs) if drugs else "\u2014" if ref_data:
cost_str = _format_cost(cost) if cost else "\u2014" 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"
chart_type = (app_state or {}).get("chart_type", "directory") return (
match_str = "~93%" if chart_type == "indication" else "\u2014" filtered_patients, filtered_drugs, filtered_cost,
total_patients, total_drugs, total_cost,
return patients_str, drugs_str, cost_str, match_str )
+27 -9
View File
@@ -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 from dash import html
def make_header(): 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( return html.Header(
className="top-header", className="top-header",
children=[ children=[
# Brand area (NHS logo + title) with angled clip-path # Left: brand (NHS logo + title)
html.Div( html.Div(
className="top-header__brand", className="top-header__brand",
children=[ children=[
@@ -17,15 +17,33 @@ def make_header():
), ),
], ],
), ),
# Breadcrumb
# Center: 3 fraction KPIs (filtered / total)
html.Div( html.Div(
className="top-header__breadcrumb", className="top-header__kpis",
children=[ children=[
"Dashboard \u203A ", html.Div(className="header-kpi", children=[
html.Strong("Pathway Analysis"), 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( html.Div(
className="top-header__right", className="top-header__right",
children=[ children=[
@@ -37,7 +55,7 @@ def make_header():
), ),
html.Span( html.Span(
children=[ children=[
"Last updated: ", "Updated: ",
html.Span("...", id="header-last-updated"), html.Span("...", id="header-last-updated"),
], ],
), ),
+9 -4
View File
@@ -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_directorates: sorted list of unique directorate labels (level 2, directory charts)
available_indications: sorted list of unique indications from ref_drug_indication_clusters available_indications: sorted list of unique indications from ref_drug_indication_clusters
total_records: source row count from latest completed refresh 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 last_updated: ISO timestamp of latest completed refresh
""" """
if not db_path.exists(): 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()] 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 total_patients = 0
if not total_records: total_cost = 0.0
cursor.execute(""" cursor.execute("""
SELECT value FROM pathway_nodes SELECT value, cost FROM pathway_nodes
WHERE level = 0 AND date_filter_id = 'all_6mo' AND chart_type = 'directory' WHERE level = 0 AND date_filter_id = 'all_6mo' AND chart_type = 'directory'
LIMIT 1 LIMIT 1
""") """)
root_row = cursor.fetchone() root_row = cursor.fetchone()
total_patients = (root_row[0] or 0) if root_row else 0 if root_row:
total_patients = root_row[0] or 0
total_cost = root_row[1] or 0.0
return { return {
"available_drugs": available_drugs, "available_drugs": available_drugs,
@@ -110,6 +114,7 @@ def load_initial_data(db_path: Path) -> dict:
"available_trusts": available_trusts, "available_trusts": available_trusts,
"total_records": total_records, "total_records": total_records,
"total_patients": total_patients, "total_patients": total_patients,
"total_cost": total_cost,
"last_updated": last_updated, "last_updated": last_updated,
} }
except sqlite3.Error as e: except sqlite3.Error as e: