feat: implement KPI row with reactive metrics (Task 2.3)
- Create kpi_card() component with icon, value, label, and highlight option - Create kpi_row() with 4 KPI cards: Unique Patients, Drug Types, Total Cost, Indication Match - Add computed vars for formatted KPI display values - Add placeholder KPI state variables (unique_patients, total_drugs, total_cost, indication_match_rate) - Use design system tokens for styling with hover effects - Responsive flex-wrap layout for smaller screens
This commit is contained in:
@@ -74,14 +74,14 @@ cd pathways_app && timeout 60 python -m reflex run 2>&1 | head -30
|
||||
- [x] Style according to design system
|
||||
|
||||
### 2.3 KPI Row
|
||||
- [ ] Create `kpi_card()` component:
|
||||
- [x] Create `kpi_card()` component:
|
||||
- Large mono number (32-48px)
|
||||
- Label below (caption style)
|
||||
- Subtle background tint
|
||||
- [ ] Create `kpi_row()` component with responsive grid
|
||||
- [ ] Initially show: Unique Patients count
|
||||
- [ ] Leave space for future metrics (Drugs count, Total cost, Match rate)
|
||||
- [ ] KPIs should be reactive to filter state
|
||||
- [x] Create `kpi_row()` component with responsive grid
|
||||
- [x] Initially show: Unique Patients count
|
||||
- [x] Leave space for future metrics (Drugs count, Total cost, Match rate)
|
||||
- [x] KPIs should be reactive to filter state
|
||||
|
||||
### 2.4 Chart Container
|
||||
- [ ] Create `chart_section()` component
|
||||
|
||||
+147
-18
@@ -23,6 +23,9 @@ from pathways_app.styles import (
|
||||
text_h3,
|
||||
text_caption,
|
||||
button_ghost_style,
|
||||
kpi_card_style,
|
||||
kpi_value_style,
|
||||
kpi_label_style,
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +247,51 @@ class AppState(rx.State):
|
||||
return f"All {total} directorates"
|
||||
return f"{count} of {total} selected"
|
||||
|
||||
# =========================================================================
|
||||
# KPI State Variables
|
||||
# =========================================================================
|
||||
|
||||
# Placeholder KPI values (will be computed from filtered data in Phase 3)
|
||||
# For now, these are static placeholders that demonstrate reactivity
|
||||
unique_patients: int = 0
|
||||
total_drugs: int = 0
|
||||
total_cost: float = 0.0
|
||||
indication_match_rate: float = 0.0
|
||||
|
||||
# Computed KPI display values
|
||||
@rx.var
|
||||
def unique_patients_display(self) -> str:
|
||||
"""Format unique patients count for display."""
|
||||
if self.unique_patients == 0:
|
||||
return "—"
|
||||
return f"{self.unique_patients:,}"
|
||||
|
||||
@rx.var
|
||||
def total_drugs_display(self) -> str:
|
||||
"""Format total drugs count for display."""
|
||||
if self.total_drugs == 0:
|
||||
return "—"
|
||||
return f"{self.total_drugs:,}"
|
||||
|
||||
@rx.var
|
||||
def total_cost_display(self) -> str:
|
||||
"""Format total cost for display."""
|
||||
if self.total_cost == 0.0:
|
||||
return "—"
|
||||
# Format as £X.XM or £X.XK depending on magnitude
|
||||
if self.total_cost >= 1_000_000:
|
||||
return f"£{self.total_cost / 1_000_000:.1f}M"
|
||||
if self.total_cost >= 1_000:
|
||||
return f"£{self.total_cost / 1_000:.1f}K"
|
||||
return f"£{self.total_cost:,.0f}"
|
||||
|
||||
@rx.var
|
||||
def match_rate_display(self) -> str:
|
||||
"""Format indication match rate for display."""
|
||||
if self.indication_match_rate == 0.0:
|
||||
return "—"
|
||||
return f"{self.indication_match_rate:.0f}%"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Layout Components
|
||||
@@ -727,40 +775,121 @@ def filter_section() -> rx.Component:
|
||||
)
|
||||
|
||||
|
||||
def kpi_row() -> rx.Component:
|
||||
def kpi_card(
|
||||
value: rx.Var[str],
|
||||
label: str,
|
||||
icon_name: str = None,
|
||||
highlight: bool = False,
|
||||
) -> rx.Component:
|
||||
"""
|
||||
KPI metrics row component.
|
||||
KPI card component displaying a metric value with label.
|
||||
|
||||
Contains: Unique patients count, and space for additional metrics.
|
||||
Args:
|
||||
value: The display value (should be a formatted string from computed var)
|
||||
label: Label describing the metric
|
||||
icon_name: Optional Lucide icon name to display
|
||||
highlight: If True, uses Pale Blue background tint
|
||||
|
||||
Will be fully implemented in Task 2.3.
|
||||
Design specs from DESIGN_SYSTEM.md:
|
||||
- Large mono number: 32-48px, Slate 900
|
||||
- Label: Caption size, Slate 500
|
||||
- Background: White or Pale Blue tint
|
||||
"""
|
||||
return rx.hstack(
|
||||
rx.box(
|
||||
# Build content - icon only if provided
|
||||
content_items = []
|
||||
if icon_name:
|
||||
content_items.append(
|
||||
rx.icon(
|
||||
icon_name,
|
||||
size=20,
|
||||
color=Colors.PRIMARY,
|
||||
)
|
||||
)
|
||||
|
||||
return rx.box(
|
||||
rx.vstack(
|
||||
# Optional icon
|
||||
rx.icon(
|
||||
icon_name if icon_name else "activity",
|
||||
size=20,
|
||||
color=Colors.PRIMARY,
|
||||
) if icon_name else rx.fragment(),
|
||||
# Value
|
||||
rx.text(
|
||||
"—",
|
||||
font_family=Typography.FONT_MONO,
|
||||
font_size="32px",
|
||||
font_weight="600",
|
||||
color=Colors.SLATE_900,
|
||||
value,
|
||||
**kpi_value_style(),
|
||||
),
|
||||
# Label
|
||||
rx.text(
|
||||
"Unique Patients",
|
||||
font_size=Typography.CAPTION_SIZE,
|
||||
font_weight=Typography.CAPTION_WEIGHT,
|
||||
color=Colors.SLATE_500,
|
||||
label,
|
||||
**kpi_label_style(),
|
||||
),
|
||||
spacing="1",
|
||||
align="center",
|
||||
),
|
||||
**card_style(),
|
||||
min_width="200px",
|
||||
# Apply card styling manually to allow background_color override
|
||||
background_color=Colors.PALE if highlight else Colors.WHITE,
|
||||
border=f"1px solid {Colors.SLATE_300}",
|
||||
border_radius=Radii.LG,
|
||||
padding=Spacing.XL,
|
||||
box_shadow=Shadows.SM,
|
||||
text_align="center",
|
||||
min_width="180px",
|
||||
flex="1",
|
||||
transition=f"box-shadow {Transitions.SHADOW}, transform {Transitions.TRANSFORM}",
|
||||
_hover={
|
||||
"box_shadow": Shadows.MD,
|
||||
"transform": "translateY(-2px)",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def kpi_row() -> rx.Component:
|
||||
"""
|
||||
KPI metrics row component with responsive grid layout.
|
||||
|
||||
Contains:
|
||||
- Unique Patients: COUNT(DISTINCT patient_id)
|
||||
- Total Drugs: Count of selected/filtered drugs
|
||||
- Total Cost: Sum of costs in filtered data
|
||||
- Match Rate: Indication match percentage
|
||||
|
||||
Layout: Responsive flex row that wraps on smaller screens.
|
||||
KPIs update reactively when filters change (Phase 3).
|
||||
"""
|
||||
return rx.hstack(
|
||||
# Unique Patients KPI - highlighted as primary metric
|
||||
kpi_card(
|
||||
value=AppState.unique_patients_display,
|
||||
label="Unique Patients",
|
||||
icon_name="users",
|
||||
highlight=True,
|
||||
),
|
||||
# Total Drugs KPI
|
||||
kpi_card(
|
||||
value=AppState.total_drugs_display,
|
||||
label="Drug Types",
|
||||
icon_name="pill",
|
||||
highlight=False,
|
||||
),
|
||||
# Total Cost KPI
|
||||
kpi_card(
|
||||
value=AppState.total_cost_display,
|
||||
label="Total Cost",
|
||||
icon_name="pound-sterling",
|
||||
highlight=False,
|
||||
),
|
||||
# Indication Match Rate KPI
|
||||
kpi_card(
|
||||
value=AppState.match_rate_display,
|
||||
label="Indication Match",
|
||||
icon_name="circle-check",
|
||||
highlight=False,
|
||||
),
|
||||
# Space for additional KPI cards
|
||||
spacing="4",
|
||||
width="100%",
|
||||
flex_wrap="wrap",
|
||||
align="stretch",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user