Files
HighCostDrugsDemo/docs/PHASE10_DESIGN.md
T

741 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 10 Design Specification
## Aesthetic Direction
**Utilitarian clinical** — authoritative, data-dense, no decoration. Every element earns its screen real estate. The NHS brand palette is law. The hierarchy is:
1. Header (identity + live metrics)
2. Sub-header (global controls — always visible, always the same)
3. Sidebar (view switching)
4. Content (view-specific)
Vertical rhythm: header 56px → sub-header 44px → content starts at 100px from top.
---
## 1. Header Redesign
### Layout
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [NHS] HCD Analysis │ 3,847 / 11,118 39 / 42 £48.2M / £130.6M │ ● 11,118 patients Updated 2h ago │
│ BRAND │ patients drugs cost │ FRESHNESS │
└─────────────────────────────────────────────────────────────────────────┘
```
The header stays 56px tall. The breadcrumb is REMOVED (it was redundant — the sidebar shows where you are). The middle section becomes **3 inline fraction KPIs**. The right section stays as data freshness.
### HTML Structure (Dash)
```python
html.Header(className="top-header", children=[
# Left: brand (unchanged)
html.Div(className="top-header__brand", children=[
html.Div("NHS", className="top-header__logo"),
html.Div(html.Div("HCD Analysis", className="top-header__title")),
]),
# Center: fraction KPIs
html.Div(className="top-header__kpis", children=[
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: data freshness (unchanged structure, same IDs)
html.Div(className="top-header__right", children=[
html.Span(children=[
html.Span(className="status-dot"),
html.Span("...", id="header-record-count"),
]),
html.Span(children=[
"Updated: ",
html.Span("...", id="header-last-updated"),
]),
]),
])
```
### CSS — New Classes
```css
/* ── 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;
}
```
### CSS — Modified Classes
Remove `.top-header__breadcrumb` usage (delete from header.py, CSS can stay for backward compat or be removed).
### Callback IDs
- **Outputs (filtered values from chart-data)**: `kpi-filtered-patients`, `kpi-filtered-drugs`, `kpi-filtered-cost`
- **Outputs (total values from reference-data)**: `kpi-total-patients`, `kpi-total-drugs`, `kpi-total-cost`
- **Existing (unchanged)**: `header-record-count`, `header-last-updated`
---
## 2. Global Filter Sub-Header
### Layout
```
┌─────────────────────────────────────────────────────────────────────────┐
│ VIEW [By Directory] [By Indication] │ INITIATED [All years ▾] LAST SEEN [Last 6 months ▾] │
└─────────────────────────────────────────────────────────────────────────┘
```
Sits directly below the header. Fixed position. Full width minus sidebar. Light blue-grey background (`#E8F0FE` — the same tint used for active sidebar items) with a subtle bottom border. Contains ONLY the chart type toggle and date filters — no drug/trust/directorate buttons.
### HTML Structure (Dash)
```python
html.Div(className="sub-header", children=[
# Chart type toggle
html.Div(className="sub-header__group", children=[
html.Span("View", className="sub-header__label"),
html.Div(className="toggle-pills", role="radiogroup",
**{"aria-label": "Chart view type"}, children=[
html.Button("By Directory", id="chart-type-directory",
className="toggle-pill toggle-pill--active",
role="radio", n_clicks=0, **{"aria-checked": "true"}),
html.Button("By Indication", id="chart-type-indication",
className="toggle-pill", role="radio",
n_clicks=0, **{"aria-checked": "false"}),
]),
]),
# Divider
html.Div(className="sub-header__divider"),
# Date filters
html.Div(className="sub-header__group", children=[
html.Span("Initiated", className="sub-header__label"),
dcc.Dropdown(id="filter-initiated", ...same options...,
className="filter-dropdown"),
]),
html.Div(className="sub-header__group", children=[
html.Span("Last seen", className="sub-header__label"),
dcc.Dropdown(id="filter-last-seen", ...same options...,
className="filter-dropdown"),
]),
])
```
### CSS — New Classes
```css
/* ── Global Filter Sub-Header ── */
.sub-header {
position: fixed;
top: 56px; /* below main header */
left: var(--sidebar-w); /* right of sidebar */
right: 0;
z-index: 150;
height: 44px;
background: #E8F0FE;
border-bottom: 1px solid #C5D4E8;
display: flex;
align-items: center;
padding: 0 24px;
gap: 16px;
}
.sub-header__group {
display: flex;
align-items: center;
gap: 8px;
}
.sub-header__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--nhs-dark-blue);
white-space: nowrap;
opacity: 0.6;
}
.sub-header__divider {
width: 1px;
height: 24px;
background: rgba(0, 48, 135, 0.15);
}
```
### CSS — Modified Classes
`.main` top margin increases from 56px to 100px (56px header + 44px sub-header):
```css
.main {
margin-left: var(--sidebar-w);
margin-top: 100px; /* was 56px */
padding: 24px;
min-height: calc(100vh - 100px); /* was 56px */
display: flex; flex-direction: column; gap: 20px;
}
```
`.sidebar` top position increases to 56px (stays below main header, sub-header floats over content area):
Actually, the sidebar should start below the header (56px), and the sub-header should start at the right of the sidebar. The sidebar extends from 56px to bottom. The sub-header is only in the content area.
```
┌──────────────────────────────────────────────────┐
│ HEADER (56px) │
├────────┬─────────────────────────────────────────┤
│ │ SUB-HEADER (44px) │
│ SIDE ├─────────────────────────────────────────┤
│ BAR │ │
│ (240) │ CONTENT AREA │
│ │ │
└────────┴─────────────────────────────────────────┘
```
---
## 3. Trust Comparison Landing Page
### Layout
A clean selector grid. Each button is a card-like element showing the directorate/indication name. Arranged in a responsive grid — 3 columns for ~14 directorates, 4 columns for ~32 indications.
```
┌─────────────────────────────────────────────────────┐
│ Trust Comparison │
│ Select a directorate to compare drug usage across │
│ trusts. │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │CARDIOLOGY│ │DERMATOL- │ │DIABETIC │ │
│ │ │ │OGY │ │MEDICINE │ │
│ │ 847 pts │ │ 423 pts │ │ 312 pts │ │
│ │ 12 drugs│ │ 8 drugs│ │ 6 drugs│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │GASTRO- │ │CLINICAL │ │MEDICAL │ │
│ │ENTEROLOGY│ │HAEMATOL..│ │ONCOLOGY │ │
│ │ 298 pts │ │ 567 pts │ │ 234 pts │ │
│ │ 11 drugs│ │ 15 drugs│ │ 9 drugs│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ... │
└─────────────────────────────────────────────────────┘
```
Each card shows: directorate name (bold), patient count, drug count. Sorted by patient count descending. The blue left border on hover provides the NHS accent.
### HTML Structure (Dash)
```python
html.Div(className="tc-landing", id="trust-comparison-landing", children=[
# Header
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",
),
]),
# Grid of directorate cards
html.Div(className="tc-landing__grid", id="tc-landing-grid", children=[
# Populated by callback — one per directorate/indication
# Each card:
html.Button(
className="tc-card",
id={"type": "tc-selector", "index": "CARDIOLOGY"},
n_clicks=0,
children=[
html.Div("CARDIOLOGY", className="tc-card__name"),
html.Div(className="tc-card__stats", children=[
html.Span("847 patients", className="tc-card__stat"),
html.Span("·", className="tc-card__dot"),
html.Span("12 drugs", className="tc-card__stat"),
]),
],
),
# ... more cards
]),
])
```
### CSS — New Classes
```css
/* ── 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-bottom: 4px;
}
.tc-landing__desc {
font-size: 14px;
color: var(--nhs-mid-grey);
font-weight: 400;
}
.tc-landing__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
/* 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);
}
```
For indication mode (~32 buttons), switch to 4 columns:
```css
/* Use this class when chart_type == "indication" */
.tc-landing__grid--wide {
grid-template-columns: repeat(4, 1fr);
}
```
---
## 4. Trust Comparison 6-Chart Dashboard
### Layout
2-column × 3-row grid of chart cards. Each card has a small title and a `dcc.Graph`. A sticky top bar shows the selected directorate name + back button.
```
┌─────────────────────────────────────────────────────┐
│ ← Back RHEUMATOLOGY — Trust Comparison │
├────────────────────────┬────────────────────────────┤
│ Market Share │ Cost Waterfall │
│ ┌──────────────────┐ │ ┌──────────────────────┐ │
│ │ dcc.Graph │ │ │ dcc.Graph │ │
│ └──────────────────┘ │ └──────────────────────┘ │
├────────────────────────┼────────────────────────────┤
│ Dosing Intervals │ Drug × Trust Heatmap │
│ ┌──────────────────┐ │ ┌──────────────────────┐ │
│ │ dcc.Graph │ │ │ dcc.Graph │ │
│ └──────────────────┘ │ └──────────────────────┘ │
├────────────────────────┼────────────────────────────┤
│ Treatment Duration │ Cost Effectiveness │
│ ┌──────────────────┐ │ ┌──────────────────────┐ │
│ │ dcc.Graph │ │ │ dcc.Graph │ │
│ └──────────────────┘ │ └──────────────────────┘ │
└────────────────────────┴────────────────────────────┘
```
### HTML Structure (Dash)
```python
html.Div(className="tc-dashboard", id="trust-comparison-dashboard", children=[
# Dashboard header with back button
html.Div(className="tc-dashboard__header", children=[
html.Button("← Back", id="tc-back-btn", className="tc-dashboard__back",
n_clicks=0),
html.H2(id="tc-dashboard-title", className="tc-dashboard__title",
children="RHEUMATOLOGY — Trust Comparison"),
]),
# 6-chart grid
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 × Trust Heatmap", "tc-chart-heatmap"),
_tc_chart_cell("Treatment Duration", "tc-chart-duration"),
_tc_chart_cell("Cost Effectiveness", "tc-chart-cost-effectiveness"),
]),
])
```
Helper for each chart cell:
```python
def _tc_chart_cell(title, graph_id):
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"},
),
]),
])
```
### CSS — New Classes
```css
/* ── 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);
}
/* 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);
}
```
---
## 5. Patient Pathways Filter Placement
### Approach
The drug/trust/directorate filter buttons sit in a **secondary filter strip** directly below the global sub-header. This strip is ONLY rendered when `active_view == "patient-pathways"`. It's a slimmer, lighter bar that reads as "view-specific controls" vs the sub-header's "global controls."
```
┌──────────────────────────────────────────────────────┐ ← HEADER (always)
├────────────────────────────────────────────────────── │ ← SUB-HEADER (always)
├──────────────────────────────────────────────────────┤
│ Drugs (3) Trusts (2) Directorates │ Clear All │ ← PATHWAY FILTERS (Patient Pathways only)
├──────────────────────────────────────────────────────┤
│ │
│ [chart card with tabs + graph] │
│ │
└──────────────────────────────────────────────────────┘
```
This strip uses the existing `.filter-btn` classes. It's rendered as part of the Patient Pathways view content (not fixed position) — it scrolls with the content.
### HTML Structure (Dash)
```python
# This goes inside the Patient Pathways view, at the top of its content area
html.Div(className="pathway-filters", id="pathway-filters", children=[
html.Div(className="pathway-filters__buttons", children=[
html.Button(children=[
"Drugs",
html.Span(id="drug-count-badge",
className="filter-btn__badge filter-btn__badge--hidden"),
], id="open-drug-modal", className="filter-btn", n_clicks=0),
html.Button(children=[
"Trusts",
html.Span(id="trust-count-badge",
className="filter-btn__badge filter-btn__badge--hidden"),
], id="open-trust-modal", className="filter-btn", n_clicks=0),
html.Button(children=[
"Directorates",
html.Span(id="directorate-count-badge",
className="filter-btn__badge filter-btn__badge--hidden"),
], id="open-directorate-modal", className="filter-btn", n_clicks=0),
]),
html.Button("Clear All", id="clear-all-filters",
className="filter-btn filter-btn--clear", n_clicks=0),
])
```
### CSS — New Classes
```css
/* ── Patient Pathways Filter Strip ── */
.pathway-filters {
background: var(--nhs-white);
border: 1px solid var(--nhs-pale-grey);
border-bottom: 2px solid var(--nhs-blue);
padding: 8px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.pathway-filters__buttons {
display: flex;
align-items: center;
gap: 8px;
}
```
The bottom border `2px solid nhs-blue` gives it a subtle "active" feel that connects it visually to the chart content below.
---
## Page Structure Summary
### app.py Layout Assembly (Phase 10)
```python
app.layout = dmc.MantineProvider(children=[
# State stores
dcc.Store(id="app-state", storage_type="session", data={
"chart_type": "directory",
"initiated": "all",
"last_seen": "6mo",
"date_filter_id": "all_6mo",
"selected_drugs": [],
"selected_directorates": [],
"selected_trusts": [],
"active_view": "patient-pathways",
"selected_comparison_directorate": None,
}),
dcc.Store(id="chart-data", storage_type="memory"),
dcc.Store(id="reference-data", storage_type="session"),
dcc.Store(id="active-tab", storage_type="memory", data="icicle"),
dcc.Location(id="url", refresh=False),
# Page structure
make_header(), # Fixed, 56px, dark blue
make_sidebar(), # Fixed, 240px left, below header
make_sub_header(), # Fixed, 44px, light blue, right of sidebar
make_modals(), # Filter modals (drug, trust, directorate)
html.Main(className="main", children=[
# Content switched by active_view
html.Div(id="view-container", children=[
# Patient Pathways view
html.Div(id="patient-pathways-view", children=[
make_pathway_filters(), # Drug/trust/directorate buttons
make_chart_card(), # Tab bar + chart (Icicle + Sankey only)
]),
# Trust Comparison view
html.Div(id="trust-comparison-view", style={"display": "none"}, children=[
make_tc_landing(), # Directorate selector grid
make_tc_dashboard(), # 6-chart dashboard (hidden initially)
]),
]),
make_footer(),
]),
])
```
### Sidebar Changes
```python
def make_sidebar():
return html.Nav(className="sidebar", **{"aria-label": "Main navigation"}, children=[
html.Div(className="sidebar__section", children=[
html.Div("Analysis", className="sidebar__label"),
_sidebar_item("Patient Pathways", "pathway",
active=True, item_id="nav-patient-pathways"),
_sidebar_item("Trust Comparison", "compare",
active=False, item_id="nav-trust-comparison"),
]),
html.Div(className="sidebar__footer", children=[
"NHS Norfolk & Waveney ICB",
html.Br(),
"High Cost Drugs Programme",
]),
])
```
New icon needed for "compare":
```python
_ICONS = {
"pathway": '<rect x="3" y="3" width="7" height="7"/>...', # existing
"compare": '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>', # bar chart icon
}
```
### View Switching Callback
```python
@app.callback(
Output("patient-pathways-view", "style"),
Output("trust-comparison-view", "style"),
Output("nav-patient-pathways", "className"),
Output("nav-trust-comparison", "className"),
Input("app-state", "data"),
)
def switch_view(app_state):
view = app_state.get("active_view", "patient-pathways")
show = {}
hide = {"display": "none"}
active_cls = "sidebar__item sidebar__item--active"
inactive_cls = "sidebar__item"
if view == "patient-pathways":
return show, hide, active_cls, inactive_cls
else:
return hide, show, inactive_cls, active_cls
```
---
## CSS Variable Additions
```css
:root {
/* ... existing ... */
--sub-header-h: 44px;
--header-total-h: 100px; /* 56px header + 44px sub-header */
}
```
Update `.main`:
```css
.main {
margin-left: var(--sidebar-w);
margin-top: var(--header-total-h);
padding: 24px;
min-height: calc(100vh - var(--header-total-h));
display: flex; flex-direction: column; gap: 20px;
}
```
---
## Responsive Adjustments
```css
@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: 768px) {
.sidebar { display: none; }
.main { margin-left: 0; }
.sub-header { left: 0; }
.tc-landing__grid { grid-template-columns: 1fr; }
.tc-dashboard__grid { grid-template-columns: 1fr; }
}
```