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:
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user