<!-- skill: Plotly Dashboards | version: 1.0.0 | description: Build Plotly Express charts and Dash apps with callbacks and gunicorn deploy | category: Python & Data -->
## Instructions
- Start with Plotly Express (`px.bar/line/scatter`): one DataFrame, `x/y/color/facet_col`, `template='plotly_white'`.
- Keep figures JSON-serializable: no lambdas in `fig` props; build data in pandas, style in `fig.update_layout`.
- Structure Dash apps as `app.py` + `data.py` + `figures.py`: layout reads from figure functions, callbacks stay thin.
- Use Dash callbacks with explicit `Input/Output/State`; one callback per concern; guard with `PreventUpdate` on empty input.
- Cache expensive queries: `flask_caching` memoize or precompute Parquet; never query a DB inside a figure builder per render.
- Design layout mobile-first: `dbc.Container/Row/Col` (dash-bootstrap), one KPI row, one filter row, charts below.
- Format numbers for humans: `tickprefix='$'`, `',.0f'`, `%{y:.1%}`; label axes with units; title states the takeaway.
- Handle empty states: if filter yields zero rows, return a placeholder figure with an annotation, not a traceback.
- Deploy with gunicorn: expose `server = app.server`, `gunicorn app:server -w 4`, set `HOST/PORT`, disable debug in prod.
- Log figure build time (`time.perf_counter`) and row counts; alert when source Parquet is older than 24h.
- Complement data-viz skill: use matplotlib there for static reports, Plotly/Dash here for interactive web dashboards.
- Never ship secrets in code: read DB URLs/tokens from env vars; `.env` locally, platform secrets in production.

## Examples
- Sales dashboard → KPI cards + date-range filter + `px.line` monthly revenue + `px.bar` top products callback.
- Ops monitor → auto-refresh `dcc.Interval` polling a Parquet file, `px.scatter` latency vs hour with color=region.
- Executive report → multi-page Dash (`pages/`) with shared filters via `dcc.Store` and URL routing.

## Workflows
1. Prototype figure in a notebook with Express; lock the chart type and columns.
2. Extract figure builders to `figures.py(df, filters) -> go.Figure` pure functions.
3. Wire layout + callbacks in `app.py`; test every filter combo including empty result.
4. Harden: caching, number formatting, loading spinners (`dcc.Loading`), error placeholder figures.
5. Deploy: `requirements.txt`, `gunicorn app:server`, env config, smoke-test `/` and one callback POST.

## Templates
```python
# figures.py — pure figure builders (testable without Dash)
import plotly.express as px
def monthly_revenue(df, region="ALL"):
    d = df if region == "ALL" else df[df["region"] == region]
    if d.empty:
        fig = px.bar(title="No data for this filter")
        fig.add_annotation(text="Try widening the date range", showarrow=False)
        return fig
    g = d.groupby("month", as_index=False)["revenue"].sum()
    fig = px.line(g, x="month", y="revenue", markers=True, template="plotly_white",
                  title="Monthly revenue")
    fig.update_layout(yaxis_tickprefix="$", yaxis_tickformat=",.0f", hovermode="x unified")
    return fig
```
```python
# app.py — Dash layout + callback + gunicorn entrypoint
from dash import Dash, dcc, html, Input, Output, no_update
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
import pandas as pd
from figures import monthly_revenue
df = pd.read_parquet("monthly.parquet")
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
server = app.server  # gunicorn app:server
app.layout = dbc.Container([
    html.H3("Revenue dashboard"),
    dcc.Dropdown(id="region", options=["ALL"] + sorted(df["region"].unique()),
                 value="ALL", clearable=False),
    dcc.Loading(dcc.Graph(id="chart")),
])
@app.callback(Output("chart", "figure"), Input("region", "value"))
def _update(region):
    if not region:
        raise PreventUpdate
    return monthly_revenue(df, region)
if __name__ == "__main__":
    app.run(debug=True, port=8050)
```

## Tools
- Core: `pip install dash dash-bootstrap-components plotly pandas gunicorn flask-caching`.
- Run dev: `python app.py`; prod: `gunicorn app:server -w 4 -b 0.0.0.0:$PORT`.
- SARAH Run launches dev server; open the printed `http://127.0.0.1:8050` link.

## Advanced
- Shared state: `dcc.Store(data=...)` for filtered JSON (keep <1 MB); compute heavy frames server-side, ship aggregates.
- Multi-page: `use_pages=True`, one file per page in `pages/`; shared navbar via `dash.page_container`.
- Caching: `@cache.memoize(timeout=300)` on figure builders keyed by filter tuple; invalidate on file mtime change.
- Performance: `px` with `render_mode='webgl'` for >10k points; downsample server-side; avoid `dcc.Graph(config)` bloat.
- Auth basics: `dash-auth` or reverse-proxy basic auth; never expose unprotected internal dashboards publicly.
- Troubleshooting: blank graph → check callback returns `Figure` not `None`; double-fire → `prevent_initial_call=True`.

## Documentation
- Plotly Express: https://plotly.com/python/plotly-express/
- Dash docs: https://dash.plotly.com/
- Deployment: https://dash.plotly.com/deployment
