# Code Patterns

## 1. Page Wrapper Pattern
Thin Streamlit page files delegate to core modules. Pages contain only route logic, not business logic.

**Example** (pages/1_🗳️_Stemwijzer.py):
```python
import streamlit as st
from quiz_module import render_quiz_page

st.set_page_config(...)
render_quiz_page()
```

**Example** (pages/2_🔍_Explorer.py):
```python
import streamlit as st
from explorer import render_explorer

st.set_page_config(...)
render_explorer()
```

**Rule**: Pages should have <20 lines of logic. All complexity lives in modules.

---

## 2. Pipeline Pattern
Data flows: fetch → transform → store

**Location**: `pipeline/` directory

**Pattern**:
```python
def run_pipeline():
    raw_data = fetch_from_source()
    transformed = transform(raw_data)
    store(transformed)

def fetch_from_source():
    # API call or DB query
    ...

def transform(raw):
    # Clean, normalize, compute derived fields
    ...
```

**Usage**: SVD computation pipeline, data ingestion, motion processing

---

## 3. API Client Pattern
HTTP client with retry/backoff for external data sources.

**Pattern**:
```python
import time
import requests

def fetch_with_retry(url, max_retries=3):
    for attempt in range(max_retries):
        try:
            response = requests.get(url)
            response.raise_for_status()
            return response.json()
        except requests.RequestException:
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)  # exponential backoff
            else:
                raise
```

---

## 4. Pure Helper Functions
Functions in `explorer_helpers.py` have no side effects, no IO.

**Pattern**:
```python
def compute_party_coords(svd_df, party_map, window):
    """Pure function: same inputs → same outputs, no side effects."""
    # Filter, compute, return
    return result_df

def build_scatter_trace(df, color_col, marker_size=8):
    """Pure: returns Plotly trace dict, no rendering."""
    trace = go.Scatter(x=df.x, y=df.y, mode='markers', ...)
    return trace
```

**Rule**: No `import streamlit` in helper modules. No file I/O. No global state.

---

## 5. Dummy Fallbacks for Optional Dependencies
Gracefully degrade when optional packages are unavailable.

**Pattern**:
```python
try:
    import umap
    HAS_UMAP = True
except ImportError:
    HAS_UMAP = False
    # or provide dummy stub

def project_to_2d(vectors):
    if HAS_UMAP:
        return umap.UMAP().fit_transform(vectors)
    else:
        return vectors[:, :2]  # fallback: just take first 2 dims
```

**Used for**: UMAP, Plotly (with fallback to altair or text-only)

---

## 6. Cached Data Loaders
Expensive DB queries wrapped with `@st.cache_data`.

**Pattern**:
```python
@st.cache_data
def load_svd_vectors(window: str) -> pd.DataFrame:
    return db.query("SELECT * FROM svd_vectors WHERE window = ?", window)

@st.cache_data
def load_party_centroids(window: str) -> pd.DataFrame:
    return db.query("SELECT * FROM party_centroids WHERE window = ?", window)

# Clear cache when data updates
@st.cache_data
def load_motions(category: str | None = None) -> pd.DataFrame:
    ...
```

**Rule**: Use `ttl=3600` for large datasets. Use `show_spinner=False` where appropriate.

---

## 7. Plotly Dual-Layer Charts
Charts built with two traces: scatter points + text annotations.

**Pattern**:
```python
def build_dual_layer_chart(df, x_col, y_col, label_col):
    # Layer 1: markers
    scatter = go.Scatter(
        x=df[x_col], y=df[y_col],
        mode='markers',
        marker=dict(size=10, color=df['color']),
        name='Parties'
    )
    # Layer 2: labels (smaller, non-hoverable)
    labels = go.Scatter(
        x=df[x_col], y=df[y_col],
        mode='text',
        text=df[label_col],
        textposition='top center',
        showlegend=False
    )
    return [scatter, labels]
```

**Used in**: Explorer tab charts, party position plots

---

## 8. Singleton Module Instances
One shared instance per module, created at import time.

**Pattern**:
```python
# database.py
class MotionDatabase:
    def __init__(self, db_path=None):
        self.conn = ibis.duckdb.connect(db_path)
        self._load_schema()

_db = None
def get_db():
    global _db
    if _db is None:
        _db = MotionDatabase()
    return _db

# At module bottom:
db = MotionDatabase()  # singleton instance
```

**Also used in**: `config.py` exports `config` and `PARTY_COLOURS`

---

## 9. Dataclass Config Pattern
Configuration centralized in a `@dataclass`.

**Pattern**:
```python
from dataclasses import dataclass, field

@dataclass
class Config:
    db_path: str = "data/stemwijzer.duckdb"
    default_window: str = "2023"
    cache_ttl: int = 3600
    party_colours: dict = field(default_factory=lambda: PARTY_COLOURS)

    def __post_init__(self):
        if not Path(self.db_path).exists():
            raise FileNotFoundError(f"Database not found: {self.db_path}")
```

---

## 10. Graceful Degradation with try/except
Core pattern throughout: attempt operation, fall back gracefully.

**Pattern**:
```python
def get_political_position(mp_name, window):
    try:
        vectors = load_svd_vectors(window)
        return vectors[vectors['mp_name'] == mp_name]['vector_2d'].iloc[0]
    except (KeyError, IndexError):
        return [0.0, 0.0]  # neutral fallback
```
