""" HCD Analysis v2 - Redesigned Reflex Application. Single-page dashboard with reactive filtering and real-time chart updates. Design reference: DESIGN_SYSTEM.md """ from datetime import datetime, timedelta from pathlib import Path from typing import Any import reflex as rx from pathways_app.styles import ( Colors, Typography, Spacing, Radii, Shadows, Transitions, TOP_BAR_HEIGHT, PAGE_MAX_WIDTH, PAGE_PADDING, card_style, input_style, text_h1, text_h3, text_caption, button_ghost_style, kpi_card_style, kpi_value_style, kpi_label_style, ) # ============================================================================= # State # ============================================================================= class AppState(rx.State): """ Application state for HCD Analysis v2. This is a minimal placeholder state for the app skeleton. Will be expanded in Phase 3 with full filter state and data management. """ # ========================================================================= # Data State Variables # ========================================================================= # Data loading status data_loaded: bool = False total_records: int = 0 chart_loading: bool = False error_message: str = "" # Data freshness tracking last_updated: str = "" # ISO format timestamp of last data load # Raw data storage - list of dicts (Reflex-friendly) # Each dict represents a patient record with keys like: # UPID, Drug Name, Intervention Date, Price Actual, Directory, etc. raw_data: list[dict[str, Any]] = [] # Latest date in dataset (detected on load, used for "to" date defaults) latest_date_in_data: str = "" # ========================================================================= # UI State Variables # ========================================================================= # Placeholder for current chart type (for top bar tabs) current_chart: str = "icicle" # ========================================================================= # Filter State Variables # ========================================================================= # Filter toggle state initiated_filter_enabled: bool = False last_seen_filter_enabled: bool = True # Date filter values (ISO format strings YYYY-MM-DD) # Initiated filter: Defaults empty (filter is OFF by default) initiated_from_date: str = "" initiated_to_date: str = "" # Last Seen filter: Defaults to last 6 months (filter is ON by default) # These will be updated on data load to use actual latest date last_seen_from_date: str = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d") last_seen_to_date: str = datetime.now().strftime("%Y-%m-%d") # Available options for dropdowns (populated from data in Phase 3) available_drugs: list[str] = ["Drug A", "Drug B", "Drug C", "Drug D", "Drug E"] available_indications: list[str] = ["Indication 1", "Indication 2", "Indication 3"] available_directorates: list[str] = ["Medical", "Surgical", "Oncology", "Rheumatology"] # Selected items (empty = all) selected_drugs: list[str] = [] selected_indications: list[str] = [] selected_directorates: list[str] = [] # Search text for dropdowns drug_search: str = "" indication_search: str = "" directorate_search: str = "" # Dropdown visibility state drug_dropdown_open: bool = False indication_dropdown_open: bool = False directorate_dropdown_open: bool = False # Event handlers for filter toggles def toggle_initiated_filter(self): """Toggle initiated date filter on/off.""" self.initiated_filter_enabled = not self.initiated_filter_enabled def toggle_last_seen_filter(self): """Toggle last seen date filter on/off.""" self.last_seen_filter_enabled = not self.last_seen_filter_enabled # Event handlers for date changes def set_initiated_from(self, value: str): """Set initiated from date.""" self.initiated_from_date = value def set_initiated_to(self, value: str): """Set initiated to date.""" self.initiated_to_date = value def set_last_seen_from(self, value: str): """Set last seen from date.""" self.last_seen_from_date = value def set_last_seen_to(self, value: str): """Set last seen to date.""" self.last_seen_to_date = value # Event handlers for search def set_drug_search(self, value: str): """Update drug search text.""" self.drug_search = value def set_indication_search(self, value: str): """Update indication search text.""" self.indication_search = value def set_directorate_search(self, value: str): """Update directorate search text.""" self.directorate_search = value # Event handlers for dropdown visibility def toggle_drug_dropdown(self): """Toggle drug dropdown visibility.""" self.drug_dropdown_open = not self.drug_dropdown_open # Close other dropdowns self.indication_dropdown_open = False self.directorate_dropdown_open = False def toggle_indication_dropdown(self): """Toggle indication dropdown visibility.""" self.indication_dropdown_open = not self.indication_dropdown_open # Close other dropdowns self.drug_dropdown_open = False self.directorate_dropdown_open = False def toggle_directorate_dropdown(self): """Toggle directorate dropdown visibility.""" self.directorate_dropdown_open = not self.directorate_dropdown_open # Close other dropdowns self.drug_dropdown_open = False self.indication_dropdown_open = False def close_all_dropdowns(self): """Close all dropdowns.""" self.drug_dropdown_open = False self.indication_dropdown_open = False self.directorate_dropdown_open = False # Event handlers for item selection def toggle_drug(self, drug: str): """Toggle a drug selection.""" if drug in self.selected_drugs: self.selected_drugs = [d for d in self.selected_drugs if d != drug] else: self.selected_drugs = self.selected_drugs + [drug] def toggle_indication(self, indication: str): """Toggle an indication selection.""" if indication in self.selected_indications: self.selected_indications = [i for i in self.selected_indications if i != indication] else: self.selected_indications = self.selected_indications + [indication] def toggle_directorate(self, directorate: str): """Toggle a directorate selection.""" if directorate in self.selected_directorates: self.selected_directorates = [d for d in self.selected_directorates if d != directorate] else: self.selected_directorates = self.selected_directorates + [directorate] # Select/clear all handlers def select_all_drugs(self): """Select all available drugs.""" self.selected_drugs = self.available_drugs.copy() def clear_all_drugs(self): """Clear all drug selections.""" self.selected_drugs = [] def select_all_indications(self): """Select all available indications.""" self.selected_indications = self.available_indications.copy() def clear_all_indications(self): """Clear all indication selections.""" self.selected_indications = [] def select_all_directorates(self): """Select all available directorates.""" self.selected_directorates = self.available_directorates.copy() def clear_all_directorates(self): """Clear all directorate selections.""" self.selected_directorates = [] # Computed vars for filtered options based on search @rx.var def filtered_drugs(self) -> list[str]: """Return drugs filtered by search text.""" if not self.drug_search: return self.available_drugs search_lower = self.drug_search.lower() return [d for d in self.available_drugs if search_lower in d.lower()] @rx.var def filtered_indications(self) -> list[str]: """Return indications filtered by search text.""" if not self.indication_search: return self.available_indications search_lower = self.indication_search.lower() return [i for i in self.available_indications if search_lower in i.lower()] @rx.var def filtered_directorates(self) -> list[str]: """Return directorates filtered by search text.""" if not self.directorate_search: return self.available_directorates search_lower = self.directorate_search.lower() return [d for d in self.available_directorates if search_lower in d.lower()] # Computed vars for selection counts @rx.var def drug_selection_text(self) -> str: """Display text for drug selection count.""" count = len(self.selected_drugs) total = len(self.available_drugs) if count == 0: return f"All {total} drugs" return f"{count} of {total} selected" @rx.var def indication_selection_text(self) -> str: """Display text for indication selection count.""" count = len(self.selected_indications) total = len(self.available_indications) if count == 0: return f"All {total} indications" return f"{count} of {total} selected" @rx.var def directorate_selection_text(self) -> str: """Display text for directorate selection count.""" count = len(self.selected_directorates) total = len(self.available_directorates) if count == 0: 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}%" @rx.var def last_updated_display(self) -> str: """Format last updated timestamp for display in top bar.""" if not self.last_updated: return "Never" try: # Parse ISO format timestamp dt = datetime.fromisoformat(self.last_updated) now = datetime.now() diff = now - dt if diff.days == 0: if diff.seconds < 60: return "Just now" if diff.seconds < 3600: mins = diff.seconds // 60 return f"{mins}m ago" hours = diff.seconds // 3600 return f"{hours}h ago" if diff.days == 1: return "Yesterday" if diff.days < 7: return f"{diff.days}d ago" return dt.strftime("%d %b %Y") except (ValueError, TypeError): return "Unknown" # ========================================================================= # Data Loading Methods # ========================================================================= def load_data(self): """ Load data from SQLite database on app initialization. This method: 1. Connects to the SQLite database (data/pathways.db) 2. Loads available drugs, indications, directorates from actual data 3. Detects the latest date in the dataset for "to" date defaults 4. Updates total_records, last_updated, and data_loaded state """ import sqlite3 db_path = Path("data/pathways.db") if not db_path.exists(): self.error_message = "Database not found. Please run data migration first." return try: conn = sqlite3.connect(str(db_path)) cursor = conn.cursor() # Get total records cursor.execute("SELECT COUNT(*) FROM fact_interventions") self.total_records = cursor.fetchone()[0] if self.total_records == 0: self.error_message = "No data in database. Please run data migration." conn.close() return # Get available drugs (distinct, sorted) cursor.execute(""" SELECT DISTINCT drug_name_std FROM fact_interventions WHERE drug_name_std IS NOT NULL AND drug_name_std != '' ORDER BY drug_name_std """) self.available_drugs = [row[0] for row in cursor.fetchall()] # Get available directories (distinct, sorted) cursor.execute(""" SELECT DISTINCT directory FROM fact_interventions WHERE directory IS NOT NULL AND directory != '' ORDER BY directory """) self.available_directorates = [row[0] for row in cursor.fetchall()] # Get available indications from ref_drug_indication_clusters cursor.execute(""" SELECT DISTINCT indication FROM ref_drug_indication_clusters WHERE indication IS NOT NULL AND indication != '' ORDER BY indication """) self.available_indications = [row[0] for row in cursor.fetchall()] # If no indications in reference table, use placeholder if not self.available_indications: self.available_indications = ["(No indications available)"] # Get date range from data cursor.execute(""" SELECT MIN(intervention_date), MAX(intervention_date) FROM fact_interventions """) date_range = cursor.fetchone() min_date, max_date = date_range # Update latest_date_in_data and set "to" date defaults if max_date: self.latest_date_in_data = max_date self.last_seen_to_date = max_date self.initiated_to_date = max_date # Set "from" date for last_seen filter (6 months before max_date) max_dt = datetime.strptime(max_date, "%Y-%m-%d") six_months_ago = max_dt - timedelta(days=180) self.last_seen_from_date = six_months_ago.strftime("%Y-%m-%d") # Get unique patient count for KPIs cursor.execute("SELECT COUNT(DISTINCT upid) FROM fact_interventions") self.unique_patients = cursor.fetchone()[0] # Get unique drug count self.total_drugs = len(self.available_drugs) # Get total cost cursor.execute("SELECT SUM(price_actual) FROM fact_interventions") total_cost_result = cursor.fetchone()[0] self.total_cost = float(total_cost_result) if total_cost_result else 0.0 conn.close() # Set data_loaded and last_updated self.data_loaded = True self.last_updated = datetime.now().isoformat() self.error_message = "" except sqlite3.Error as e: self.error_message = f"Database error: {str(e)}" self.data_loaded = False except Exception as e: self.error_message = f"Failed to load data: {str(e)}" self.data_loaded = False # ============================================================================= # Layout Components # ============================================================================= def date_range_picker( label: str, enabled: rx.Var[bool], toggle_handler, from_value: rx.Var[str], to_value: rx.Var[str], on_from_change, on_to_change, ) -> rx.Component: """ Date range picker with enable/disable checkbox. Args: label: Label for the date range (e.g., "Initiated", "Last Seen") enabled: Whether the filter is active toggle_handler: Event handler to toggle enabled state from_value: Current "from" date value to_value: Current "to" date value on_from_change: Handler for from date change on_to_change: Handler for to date change """ return rx.vstack( # Header with checkbox rx.hstack( rx.checkbox( checked=enabled, on_change=toggle_handler, size="2", ), rx.text( label, font_size=Typography.H3_SIZE, font_weight=Typography.H3_WEIGHT, color=Colors.SLATE_900, font_family=Typography.FONT_FAMILY, ), align="center", spacing="2", ), # Date inputs rx.hstack( rx.vstack( rx.text( "From", **text_caption(), ), rx.input( type="date", value=from_value, on_change=on_from_change, disabled=~enabled, **input_style(), width="140px", opacity=rx.cond(enabled, "1", "0.5"), ), spacing="1", align="start", ), rx.vstack( rx.text( "To", **text_caption(), ), rx.input( type="date", value=to_value, on_change=on_to_change, disabled=~enabled, **input_style(), width="140px", opacity=rx.cond(enabled, "1", "0.5"), ), spacing="1", align="start", ), spacing="3", align="end", ), spacing="2", align="start", ) def searchable_dropdown( label: str, selection_text: rx.Var[str], is_open: rx.Var[bool], toggle_handler, search_value: rx.Var[str], on_search_change, filtered_items: rx.Var[list[str]], selected_items: rx.Var[list[str]], toggle_item_handler, select_all_handler, clear_all_handler, ) -> rx.Component: """ Searchable multi-select dropdown component. Args: label: Label for the dropdown selection_text: Text showing selection count is_open: Whether dropdown is expanded toggle_handler: Handler to toggle dropdown open/close search_value: Current search text on_search_change: Handler for search input change filtered_items: Items filtered by search selected_items: Currently selected items toggle_item_handler: Handler to toggle item selection select_all_handler: Handler to select all clear_all_handler: Handler to clear selection """ return rx.box( rx.vstack( # Label rx.text( label, font_size=Typography.CAPTION_SIZE, font_weight=Typography.CAPTION_WEIGHT, color=Colors.SLATE_700, font_family=Typography.FONT_FAMILY, ), # Dropdown trigger button rx.box( rx.hstack( rx.text( selection_text, font_size=Typography.BODY_SIZE, color=Colors.SLATE_900, font_family=Typography.FONT_FAMILY, flex="1", ), rx.icon( rx.cond(is_open, "chevron-up", "chevron-down"), size=16, color=Colors.SLATE_500, ), justify="between", align="center", width="100%", ), **input_style(), display="flex", align_items="center", cursor="pointer", on_click=toggle_handler, width="100%", ), # Dropdown panel rx.cond( is_open, rx.box( rx.vstack( # Search input rx.hstack( rx.icon("search", size=14, color=Colors.SLATE_500), rx.input( placeholder="Search...", value=search_value, on_change=on_search_change, variant="soft", size="2", width="100%", ), spacing="2", align="center", width="100%", padding=Spacing.SM, background_color=Colors.SLATE_100, border_radius=Radii.SM, ), # Action buttons rx.hstack( rx.button( "Select All", on_click=select_all_handler, variant="ghost", size="1", color_scheme="blue", ), rx.button( "Clear", on_click=clear_all_handler, variant="ghost", size="1", color_scheme="gray", ), spacing="2", ), # Items list rx.box( rx.foreach( filtered_items, lambda item: rx.box( rx.checkbox( item, checked=selected_items.contains(item), on_change=lambda: toggle_item_handler(item), size="2", ), padding_y=Spacing.XS, padding_x=Spacing.SM, border_radius=Radii.SM, background_color=rx.cond( selected_items.contains(item), Colors.PALE, "transparent", ), _hover={ "background_color": Colors.SLATE_100, }, width="100%", ), ), max_height="200px", overflow_y="auto", width="100%", ), spacing="2", align="start", width="100%", padding=Spacing.SM, ), position="absolute", top="100%", left="0", right="0", background_color=Colors.WHITE, border=f"1px solid {Colors.SLATE_300}", border_radius=Radii.MD, box_shadow=Shadows.LG, z_index="50", margin_top=Spacing.XS, ), ), spacing="1", align="start", width="100%", ), position="relative", width="100%", ) def chart_tab(label: str, chart_type: str, is_active: bool = False) -> rx.Component: """ Individual chart type tab/pill for top bar navigation. Active state: White background with Heritage Blue text Inactive state: Transparent with white text, hover shows Vibrant Blue background """ return rx.box( rx.text( label, font_size=Typography.BODY_SMALL_SIZE, font_weight="500", color=Colors.HERITAGE_BLUE if is_active else Colors.WHITE, font_family=Typography.FONT_FAMILY, ), background_color=Colors.WHITE if is_active else "transparent", padding_x=Spacing.LG, padding_y=Spacing.SM, border_radius=Radii.FULL, cursor="pointer", transition=f"background-color {Transitions.COLOR}", _hover={ "background_color": Colors.WHITE if is_active else "rgba(255,255,255,0.15)", }, # Future: on_click handler to switch chart type ) def top_bar() -> rx.Component: """ Top navigation bar component. Contains: Logo + App Name | Chart Type Tabs | Data Freshness Indicator Fixed height: 64px (from design system) Heritage Blue background with white text. """ return rx.box( rx.hstack( # Left: Logo and App Title rx.hstack( rx.image( src="/logo.png", height="36px", alt="NHS Logo", ), rx.text( "HCD Analysis", font_size=Typography.H2_SIZE, font_weight=Typography.H2_WEIGHT, color=Colors.WHITE, font_family=Typography.FONT_FAMILY, letter_spacing="-0.01em", ), align="center", spacing="3", ), # Center: Chart Type Tabs rx.hstack( chart_tab("Icicle", "icicle", is_active=True), chart_tab("Sankey", "sankey", is_active=False), chart_tab("Timeline", "timeline", is_active=False), spacing="2", align="center", background_color="rgba(255,255,255,0.1)", padding=Spacing.XS, border_radius=Radii.FULL, ), # Right: Data Freshness Indicator rx.hstack( rx.icon( "database", size=16, color=Colors.SKY, ), rx.vstack( rx.text( rx.cond( AppState.data_loaded, AppState.total_records.to_string() + " records", "Loading data...", ), font_size=Typography.CAPTION_SIZE, font_weight="500", color=Colors.WHITE, font_family=Typography.FONT_FAMILY, ), rx.text( rx.cond( AppState.data_loaded, "Refreshed: " + AppState.last_updated_display, "Connecting...", ), font_size="11px", color=Colors.WHITE, opacity="0.7", font_family=Typography.FONT_FAMILY, ), spacing="0", align="end", ), spacing="2", align="center", ), justify="between", align="center", width="100%", max_width=PAGE_MAX_WIDTH, margin_x="auto", padding_x=Spacing.XL, ), background_color=Colors.HERITAGE_BLUE, height=TOP_BAR_HEIGHT, width="100%", display="flex", align_items="center", position="sticky", top="0", z_index="100", box_shadow=Shadows.MD, ) def filter_section() -> rx.Component: """ Filter section component. Contains: - Two date range pickers: Initiated (default OFF), Last Seen (default ON) - Three searchable multi-select dropdowns: Drugs, Indications, Directorates Layout: Two rows - Row 1: Date pickers side by side - Row 2: Three dropdowns in a grid """ return rx.box( rx.vstack( # Header rx.text( "Filters", **text_h1(), ), # Row 1: Date range pickers rx.hstack( date_range_picker( label="Initiated", enabled=AppState.initiated_filter_enabled, toggle_handler=AppState.toggle_initiated_filter, from_value=AppState.initiated_from_date, to_value=AppState.initiated_to_date, on_from_change=AppState.set_initiated_from, on_to_change=AppState.set_initiated_to, ), rx.divider(orientation="vertical", size="3"), date_range_picker( label="Last Seen", enabled=AppState.last_seen_filter_enabled, toggle_handler=AppState.toggle_last_seen_filter, from_value=AppState.last_seen_from_date, to_value=AppState.last_seen_to_date, on_from_change=AppState.set_last_seen_from, on_to_change=AppState.set_last_seen_to, ), spacing="5", align="start", flex_wrap="wrap", ), # Divider rx.divider(size="4"), # Row 2: Searchable dropdowns rx.hstack( rx.box( searchable_dropdown( label="Drugs", selection_text=AppState.drug_selection_text, is_open=AppState.drug_dropdown_open, toggle_handler=AppState.toggle_drug_dropdown, search_value=AppState.drug_search, on_search_change=AppState.set_drug_search, filtered_items=AppState.filtered_drugs, selected_items=AppState.selected_drugs, toggle_item_handler=AppState.toggle_drug, select_all_handler=AppState.select_all_drugs, clear_all_handler=AppState.clear_all_drugs, ), flex="1", min_width="200px", ), rx.box( searchable_dropdown( label="Indications", selection_text=AppState.indication_selection_text, is_open=AppState.indication_dropdown_open, toggle_handler=AppState.toggle_indication_dropdown, search_value=AppState.indication_search, on_search_change=AppState.set_indication_search, filtered_items=AppState.filtered_indications, selected_items=AppState.selected_indications, toggle_item_handler=AppState.toggle_indication, select_all_handler=AppState.select_all_indications, clear_all_handler=AppState.clear_all_indications, ), flex="1", min_width="200px", ), rx.box( searchable_dropdown( label="Directorates", selection_text=AppState.directorate_selection_text, is_open=AppState.directorate_dropdown_open, toggle_handler=AppState.toggle_directorate_dropdown, search_value=AppState.directorate_search, on_search_change=AppState.set_directorate_search, filtered_items=AppState.filtered_directorates, selected_items=AppState.selected_directorates, toggle_item_handler=AppState.toggle_directorate, select_all_handler=AppState.select_all_directorates, clear_all_handler=AppState.clear_all_directorates, ), flex="1", min_width="200px", ), spacing="4", width="100%", flex_wrap="wrap", ), spacing="4", width="100%", align="start", ), **card_style(), width="100%", ) def kpi_card( value: rx.Var[str], label: str, icon_name: str = None, highlight: bool = False, ) -> rx.Component: """ KPI card component displaying a metric value with label. 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 Design specs from DESIGN_SYSTEM.md: - Large mono number: 32-48px, Slate 900 - Label: Caption size, Slate 500 - Background: White or Pale Blue tint """ # 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( value, **kpi_value_style(), ), # Label rx.text( label, **kpi_label_style(), ), spacing="1", align="center", ), # 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, ), spacing="4", width="100%", flex_wrap="wrap", align="stretch", ) def chart_loading_skeleton() -> rx.Component: """ Loading skeleton for the chart area. Displays animated pulsing bars to indicate loading state. Uses design system colors and spacing. """ return rx.box( rx.vstack( # Simulated chart bars at different heights rx.hstack( rx.box( background_color=Colors.SLATE_300, width="12%", height="60%", border_radius=Radii.SM, animation="pulse 1.5s ease-in-out infinite", ), rx.box( background_color=Colors.SLATE_300, width="12%", height="80%", border_radius=Radii.SM, animation="pulse 1.5s ease-in-out infinite", animation_delay="0.1s", ), rx.box( background_color=Colors.SLATE_300, width="12%", height="45%", border_radius=Radii.SM, animation="pulse 1.5s ease-in-out infinite", animation_delay="0.2s", ), rx.box( background_color=Colors.SLATE_300, width="12%", height="70%", border_radius=Radii.SM, animation="pulse 1.5s ease-in-out infinite", animation_delay="0.3s", ), rx.box( background_color=Colors.SLATE_300, width="12%", height="55%", border_radius=Radii.SM, animation="pulse 1.5s ease-in-out infinite", animation_delay="0.4s", ), rx.box( background_color=Colors.SLATE_300, width="12%", height="90%", border_radius=Radii.SM, animation="pulse 1.5s ease-in-out infinite", animation_delay="0.5s", ), spacing="3", align="end", justify="center", height="100%", width="100%", ), # Loading text rx.hstack( rx.spinner(size="2"), rx.text( "Generating chart...", font_size=Typography.BODY_SIZE, font_weight=Typography.BODY_WEIGHT, color=Colors.SLATE_500, font_family=Typography.FONT_FAMILY, ), spacing="2", align="center", ), spacing="4", align="center", justify="center", height="100%", width="100%", ), background_color=Colors.SLATE_100, border_radius=Radii.MD, width="100%", height="500px", padding=Spacing.XL, ) def chart_error_state(error_message: rx.Var[str]) -> rx.Component: """ Error state for the chart area. Displays a friendly error message with an icon and the error details. Provides guidance on how to resolve the issue. """ return rx.box( rx.center( rx.vstack( rx.icon( "triangle-alert", size=48, color=Colors.WARNING, ), rx.text( "Unable to Generate Chart", font_size=Typography.H2_SIZE, font_weight=Typography.H2_WEIGHT, color=Colors.SLATE_900, font_family=Typography.FONT_FAMILY, ), rx.text( error_message, font_size=Typography.BODY_SIZE, font_weight=Typography.BODY_WEIGHT, color=Colors.SLATE_700, font_family=Typography.FONT_FAMILY, text_align="center", max_width="400px", ), rx.text( "Try adjusting the filters or check the data source.", font_size=Typography.CAPTION_SIZE, font_weight=Typography.CAPTION_WEIGHT, color=Colors.SLATE_500, font_family=Typography.FONT_FAMILY, ), spacing="3", align="center", ), width="100%", height="100%", ), background_color=Colors.SLATE_100, border_radius=Radii.MD, width="100%", height="500px", padding=Spacing.XL, ) def chart_empty_state() -> rx.Component: """ Empty state for when no data matches the filters. Displays a friendly message encouraging filter adjustment. """ return rx.box( rx.center( rx.vstack( rx.icon( "search-x", size=48, color=Colors.SLATE_500, ), rx.text( "No Data to Display", font_size=Typography.H2_SIZE, font_weight=Typography.H2_WEIGHT, color=Colors.SLATE_900, font_family=Typography.FONT_FAMILY, ), rx.text( "No patient records match your current filter criteria.", font_size=Typography.BODY_SIZE, font_weight=Typography.BODY_WEIGHT, color=Colors.SLATE_700, font_family=Typography.FONT_FAMILY, text_align="center", ), rx.text( "Try widening your date range or selecting more drugs/indications.", font_size=Typography.CAPTION_SIZE, font_weight=Typography.CAPTION_WEIGHT, color=Colors.SLATE_500, font_family=Typography.FONT_FAMILY, ), spacing="3", align="center", ), width="100%", height="100%", ), background_color=Colors.SLATE_100, border_radius=Radii.MD, width="100%", height="500px", padding=Spacing.XL, ) def chart_ready_placeholder() -> rx.Component: """ Ready state placeholder for the chart area. This will be replaced with actual rx.plotly() in Phase 4. For now, shows a placeholder indicating the chart location. """ return rx.box( rx.center( rx.vstack( rx.icon( "chart-bar-stacked", size=48, color=Colors.PRIMARY, ), rx.text( "Chart Ready", font_size=Typography.H2_SIZE, font_weight=Typography.H2_WEIGHT, color=Colors.SLATE_900, font_family=Typography.FONT_FAMILY, ), rx.text( "Plotly icicle chart will render here in Phase 4.", font_size=Typography.BODY_SIZE, font_weight=Typography.BODY_WEIGHT, color=Colors.SLATE_500, font_family=Typography.FONT_FAMILY, ), spacing="3", align="center", ), width="100%", height="100%", ), background_color=Colors.PALE, border=f"2px dashed {Colors.PRIMARY}", border_radius=Radii.MD, width="100%", height="500px", padding=Spacing.XL, ) def chart_section() -> rx.Component: """ Main chart section component. Contains: Plotly icicle chart with loading, error, empty, and ready states. State handling: - Loading: Shows skeleton animation when chart_loading is True - Error: Shows error message when error_message is not empty - Empty: Shows empty state when data_loaded but unique_patients is 0 - Ready: Shows chart placeholder (will be replaced with actual chart in Phase 4) Full implementation with Plotly integration in Phase 4. """ return rx.box( rx.vstack( # Header row with title and chart type info rx.hstack( rx.text( "Patient Pathway Visualization", **text_h1(), ), rx.hstack( rx.icon( "info", size=14, color=Colors.SLATE_500, ), rx.text( "Hierarchical view: Trust → Directorate → Drug → Patient Pathway", font_size=Typography.CAPTION_SIZE, font_weight=Typography.CAPTION_WEIGHT, color=Colors.SLATE_500, font_family=Typography.FONT_FAMILY, ), spacing="1", align="center", ), justify="between", align="center", width="100%", flex_wrap="wrap", ), # Chart container with state-based rendering rx.cond( # Priority 1: Loading state AppState.chart_loading, chart_loading_skeleton(), # Not loading - check for error rx.cond( # Priority 2: Error state AppState.error_message != "", chart_error_state(AppState.error_message), # No error - check if data loaded rx.cond( # Priority 3: Data loaded but empty AppState.data_loaded & (AppState.unique_patients == 0), chart_empty_state(), # Priority 4: Ready state (data loaded and has records) # or initial state (data not loaded yet) chart_ready_placeholder(), ), ), ), spacing="4", width="100%", align="start", ), **card_style(), width="100%", ) def main_content() -> rx.Component: """ Main content area below the top bar. Layout: Filter Section → KPI Row → Chart Section Max width constrained to PAGE_MAX_WIDTH, centered. """ return rx.box( rx.vstack( filter_section(), kpi_row(), chart_section(), spacing="5", width="100%", align="stretch", ), width="100%", max_width=PAGE_MAX_WIDTH, margin_x="auto", padding=PAGE_PADDING, padding_top=Spacing.XL, ) def page_layout() -> rx.Component: """ Full page layout combining top bar and main content. Structure: - Sticky top bar (64px) - Scrollable main content area - White background """ return rx.box( rx.vstack( top_bar(), main_content(), spacing="0", width="100%", min_height="100vh", ), background_color=Colors.WHITE, font_family=Typography.FONT_FAMILY, width="100%", ) # ============================================================================= # Page Definition # ============================================================================= def index() -> rx.Component: """Main page for HCD Analysis v2.""" return page_layout() # ============================================================================= # App Configuration # ============================================================================= app = rx.App( theme=rx.theme( accent_color="blue", gray_color="slate", radius="medium", ), stylesheets=[ # Google Fonts - Inter (primary) and JetBrains Mono (monospace) "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap", ], ) # Register page with on_load handler to load data on app initialization app.add_page(index, route="/", title="HCD Analysis | Patient Pathways", on_load=AppState.load_data)